From b65eb25328fb1ebea99aa979d30eafe1c00e6e08 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Tue, 23 Jun 2026 14:56:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(linkedin-studio):=20SB-S1=20brain=20CLI=20?= =?UTF-8?q?=E2=80=94=20ingest=20+=20published=20list=20(init=20preserved)?= =?UTF-8?q?=20[skip-docs]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add flag-routing infra (parseFlags) + two subcommands to the brain CLI, keeping the existing `init` branch intact: - `ingest --file [--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) Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4 --- scripts/brain/src/cli.ts | 118 ++++++++++++++++++++++++++------ scripts/brain/tests/cli.test.ts | 78 +++++++++++++++++++++ 2 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 scripts/brain/tests/cli.test.ts diff --git a/scripts/brain/src/cli.ts b/scripts/brain/src/cli.ts index ae2442a..54d0ee5 100644 --- a/scripts/brain/src/cli.ts +++ b/scripts/brain/src/cli.ts @@ -1,43 +1,121 @@ #!/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 ingest --file [--source ] [--date ] + * node --import tsx src/cli.ts ingest --scan-inbox [--source ] + * node --import tsx src/cli.ts published list [--json] * - * `init` scaffolds the `brain/` + `ingest/` tree under the per-user data dir - * (`LINKEDIN_STUDIO_DATA` overrides the root). Idempotent — running it twice is a - * no-op. No session-start wiring yet (SB-S2 owns that); this is the invokable - * subcommand only. + * `init` scaffolds the brain/ + ingest/ tree (idempotent). `ingest` captures the + * user's published posts into ingest/published/ tagged provenance=published — the + * gold signal the voice/profile-learning surface learns from (never ai-draft). * * Exit code: 0 on success, 2 on usage error. */ +import { readFileSync } from "node:fs"; + import { dataRoot } from "./dataRoot.js"; +import { ingestText, listPublished, scanInbox } from "./ingest.js"; import { initBrain } from "./scaffold.js"; +/** Parse `--key value` / boolean `--flag` args (the specifics-bank CLI idiom). */ +function parseFlags(args: string[]): Record { + const out: Record = {}; + 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 { console.error(`error: ${msg}`); - console.error("usage:\n init"); + console.error( + "usage:\n" + + " init\n" + + " ingest --file [--source ] [--date ]\n" + + " ingest --scan-inbox [--source ]\n" + + " published list [--json]", + ); process.exit(2); } -function main(): void { - const [command] = process.argv.slice(2); +function runInit(): void { + 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") { - 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."); +function runIngest(flags: Record): void { + if (flags["scan-inbox"] === "true") { + const res = scanInbox({ captured_at: today(), source: flags.source }); + console.log( + `Scanned inbox → published: ${res.processed.length} new, ${res.skipped.length} already published`, + ); return; } + const file = flags.file; + if (!file || file === "true") usage("ingest needs --file 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): 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"); } diff --git a/scripts/brain/tests/cli.test.ts b/scripts/brain/tests/cli.test.ts new file mode 100644 index 0000000..c66d9ba --- /dev/null +++ b/scripts/brain/tests/cli.test.ts @@ -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); + }); +});