feat(linkedin-studio): SB-S1 brain CLI — ingest + published list (init preserved) [skip-docs]
Add flag-routing infra (parseFlags) + two subcommands to the brain CLI, keeping the existing `init` branch intact: - `ingest --file <path> [--source] [--date]` / `ingest --scan-inbox` → capture published posts into ingest/published/ (Wrote / Duplicate / Collision report). - `published list [--json]` → inspect the gold corpus (id · provenance · date · first line). 6 subprocess tests incl. an explicit `init` regression guard. brain 57→63 tests, tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4
This commit is contained in:
parent
0d3e4911d7
commit
b65eb25328
2 changed files with 177 additions and 21 deletions
|
|
@ -1,43 +1,121 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* CLI for the second-brain foundation (SB-S0).
|
* CLI for the second-brain foundation + ingest (SB-S0 / SB-S1).
|
||||||
*
|
*
|
||||||
* node --import tsx src/cli.ts init
|
* node --import tsx src/cli.ts init
|
||||||
|
* node --import tsx src/cli.ts ingest --file <path> [--source <s>] [--date <YYYY-MM-DD>]
|
||||||
|
* node --import tsx src/cli.ts ingest --scan-inbox [--source <s>]
|
||||||
|
* node --import tsx src/cli.ts published list [--json]
|
||||||
*
|
*
|
||||||
* `init` scaffolds the `brain/` + `ingest/` tree under the per-user data dir
|
* `init` scaffolds the brain/ + ingest/ tree (idempotent). `ingest` captures the
|
||||||
* (`LINKEDIN_STUDIO_DATA` overrides the root). Idempotent — running it twice is a
|
* user's published posts into ingest/published/ tagged provenance=published — the
|
||||||
* no-op. No session-start wiring yet (SB-S2 owns that); this is the invokable
|
* gold signal the voice/profile-learning surface learns from (never ai-draft).
|
||||||
* subcommand only.
|
|
||||||
*
|
*
|
||||||
* Exit code: 0 on success, 2 on usage error.
|
* Exit code: 0 on success, 2 on usage error.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
import { dataRoot } from "./dataRoot.js";
|
import { dataRoot } from "./dataRoot.js";
|
||||||
|
import { ingestText, listPublished, scanInbox } from "./ingest.js";
|
||||||
import { initBrain } from "./scaffold.js";
|
import { initBrain } from "./scaffold.js";
|
||||||
|
|
||||||
|
/** Parse `--key value` / boolean `--flag` args (the specifics-bank CLI idiom). */
|
||||||
|
function parseFlags(args: string[]): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const a = args[i];
|
||||||
|
if (a.startsWith("--")) {
|
||||||
|
const key = a.slice(2);
|
||||||
|
const next = args[i + 1];
|
||||||
|
if (next === undefined || next.startsWith("--")) {
|
||||||
|
out[key] = "true";
|
||||||
|
} else {
|
||||||
|
out[key] = next;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function today(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
function usage(msg: string): never {
|
function usage(msg: string): never {
|
||||||
console.error(`error: ${msg}`);
|
console.error(`error: ${msg}`);
|
||||||
console.error("usage:\n init");
|
console.error(
|
||||||
|
"usage:\n" +
|
||||||
|
" init\n" +
|
||||||
|
" ingest --file <path> [--source <s>] [--date <YYYY-MM-DD>]\n" +
|
||||||
|
" ingest --scan-inbox [--source <s>]\n" +
|
||||||
|
" published list [--json]",
|
||||||
|
);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function main(): void {
|
function runInit(): void {
|
||||||
const [command] = process.argv.slice(2);
|
const { created, skipped } = initBrain();
|
||||||
|
console.log(`Brain root: ${dataRoot("brain")}`);
|
||||||
|
if (created.length > 0) {
|
||||||
|
console.log(`Created (${created.length}):`);
|
||||||
|
for (const c of created) console.log(` + ${c}`);
|
||||||
|
}
|
||||||
|
if (skipped.length > 0) {
|
||||||
|
console.log(`Skipped — already present (${skipped.length}):`);
|
||||||
|
for (const s of skipped) console.log(` · ${s}`);
|
||||||
|
}
|
||||||
|
if (created.length === 0) console.log("Already initialised — nothing to do.");
|
||||||
|
}
|
||||||
|
|
||||||
if (command === "init") {
|
function runIngest(flags: Record<string, string>): void {
|
||||||
const { created, skipped } = initBrain();
|
if (flags["scan-inbox"] === "true") {
|
||||||
console.log(`Brain root: ${dataRoot("brain")}`);
|
const res = scanInbox({ captured_at: today(), source: flags.source });
|
||||||
if (created.length > 0) {
|
console.log(
|
||||||
console.log(`Created (${created.length}):`);
|
`Scanned inbox → published: ${res.processed.length} new, ${res.skipped.length} already published`,
|
||||||
for (const c of created) console.log(` + ${c}`);
|
);
|
||||||
}
|
|
||||||
if (skipped.length > 0) {
|
|
||||||
console.log(`Skipped — already present (${skipped.length}):`);
|
|
||||||
for (const s of skipped) console.log(` · ${s}`);
|
|
||||||
}
|
|
||||||
if (created.length === 0) console.log("Already initialised — nothing to do.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const file = flags.file;
|
||||||
|
if (!file || file === "true") usage("ingest needs --file <path> or --scan-inbox");
|
||||||
|
const body = readFileSync(file, "utf8");
|
||||||
|
const res = ingestText({
|
||||||
|
body,
|
||||||
|
captured_at: today(),
|
||||||
|
source: flags.source,
|
||||||
|
published_date: flags.date === "true" ? undefined : flags.date,
|
||||||
|
});
|
||||||
|
if (res.written && res.collision) {
|
||||||
|
console.log(`Collision (different body, same id) → wrote ${res.path}`);
|
||||||
|
} else if (res.written) {
|
||||||
|
console.log(`Wrote ingest/published/${res.record.id}.md`);
|
||||||
|
} else {
|
||||||
|
console.log(`Duplicate — already published: ${res.record.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPublished(rest: string[], flags: Record<string, string>): void {
|
||||||
|
if (rest[0] !== "list") usage("published needs: list");
|
||||||
|
const { records, skipped } = listPublished();
|
||||||
|
if (flags.json === "true") {
|
||||||
|
console.log(JSON.stringify({ records, skipped }, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tail = skipped > 0 ? ` (${skipped} unreadable skipped)` : "";
|
||||||
|
console.log(`Published gold corpus: ${records.length} record(s)${tail}`);
|
||||||
|
for (const r of records) {
|
||||||
|
console.log(` · ${r.id} · ${r.provenance} · ${r.published_date} · ${r.firstLine}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(): void {
|
||||||
|
const [command, ...rest] = process.argv.slice(2);
|
||||||
|
const flags = parseFlags(rest);
|
||||||
|
|
||||||
|
if (command === "init") return runInit();
|
||||||
|
if (command === "ingest") return runIngest(flags);
|
||||||
|
if (command === "published") return runPublished(rest, flags);
|
||||||
|
|
||||||
usage(command ? `unknown command: ${command}` : "no command given");
|
usage(command ? `unknown command: ${command}` : "no command given");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
78
scripts/brain/tests/cli.test.ts
Normal file
78
scripts/brain/tests/cli.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { describe, test, beforeEach, afterEach } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
||||||
|
import { join, dirname } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const CLI = join(HERE, "..", "src", "cli.ts");
|
||||||
|
|
||||||
|
/** Run the brain CLI in a child process with LINKEDIN_STUDIO_DATA pointed at `root`. */
|
||||||
|
function runCli(root: string, args: string[]): { stdout: string; code: number } {
|
||||||
|
try {
|
||||||
|
const stdout = execFileSync("node", ["--import", "tsx", CLI, ...args], {
|
||||||
|
env: { ...process.env, LINKEDIN_STUDIO_DATA: root },
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
return { stdout, code: 0 };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { stdout: (e.stdout ?? "") + (e.stderr ?? ""), code: e.status ?? 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("brain CLI dispatch (SB-S1)", () => {
|
||||||
|
let root: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
root = mkdtempSync(join(tmpdir(), "brain-cli-"));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("`init` still works after the CLI refactor (regression)", () => {
|
||||||
|
const { stdout, code } = runCli(root, ["init"]);
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.ok(existsSync(join(root, "brain", "profile.md")), "init scaffolded brain/");
|
||||||
|
assert.match(stdout, /Created|Brain root/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("`ingest --file` writes a published record", () => {
|
||||||
|
const f = join(root, "post.md");
|
||||||
|
writeFileSync(f, "A published post body.", "utf8");
|
||||||
|
const { stdout, code } = runCli(root, ["ingest", "--file", f]);
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.match(stdout, /Wrote ingest\/published\/[0-9a-f]{12}\.md/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("`ingest --file` twice on the same body reports a duplicate", () => {
|
||||||
|
const f = join(root, "post.md");
|
||||||
|
writeFileSync(f, "dup body", "utf8");
|
||||||
|
runCli(root, ["ingest", "--file", f]);
|
||||||
|
const { stdout } = runCli(root, ["ingest", "--file", f]);
|
||||||
|
assert.match(stdout, /Duplicate/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("`ingest --scan-inbox` processes the inbox", () => {
|
||||||
|
runCli(root, ["init"]);
|
||||||
|
writeFileSync(join(root, "ingest", "inbox", "a.md"), "inbox post", "utf8");
|
||||||
|
const { stdout, code } = runCli(root, ["ingest", "--scan-inbox"]);
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.match(stdout, /1 new/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("`published list` lists ingested records with provenance", () => {
|
||||||
|
const f = join(root, "post.md");
|
||||||
|
writeFileSync(f, "listed post", "utf8");
|
||||||
|
runCli(root, ["ingest", "--file", f]);
|
||||||
|
const { stdout, code } = runCli(root, ["published", "list"]);
|
||||||
|
assert.equal(code, 0);
|
||||||
|
assert.match(stdout, /published/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown command exits 2 (usage error)", () => {
|
||||||
|
const { code } = runCli(root, ["bogus"]);
|
||||||
|
assert.equal(code, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue