@sporhq/spor 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,6 @@
2
2
  "name": "spor",
3
3
  "displayName": "Spor Context Compiler",
4
4
  "description": "Maintains a typed, versioned knowledge graph and compiles compact briefings from it: session-start injection, per-prompt relevance digests, capture at discovery, end-of-session distillation, decision queue.",
5
- "version": "0.2.9",
5
+ "version": "0.3.0",
6
6
  "author": { "name": "losthammer" }
7
7
  }
package/bin/spor.js CHANGED
@@ -22,65 +22,23 @@ const os = require("os");
22
22
  const path = require("path");
23
23
  const https = require("https");
24
24
  const { spawnSync } = require("child_process");
25
+ const { parseArgs } = require("util");
25
26
 
26
27
  const ROOT = path.resolve(__dirname, "..");
27
28
  const { loadConfig } = require(path.join(ROOT, "lib", "config.js"));
28
29
  const remote = require(path.join(ROOT, "lib", "remote.js"));
29
30
  const u = require(path.join(ROOT, "scripts", "engines", "util.js"));
30
31
 
31
- const HELP = `spor Spor client CLI
32
-
33
- Usage: spor <verb> [args]
34
-
35
- Getting started
36
- init create the local graph home (nodes/, git, .gitignore)
37
- install [host...] wire spor into an agent: claude codex gemini opencode
38
- copilot cursor (no host => list detected). --scope
39
- user|repo, --all, --print, --server/--token to configure
40
- upgrade [host...] refresh wired spor to the installed package version after
41
- an npm bump (claude: marketplace+plugin update). No host
42
- => every detected wired host. Also flags a newer release
43
- published to npm. --scope user|repo, --print, --no-net
44
- status resolved mode, graph, project, identity, health
45
- join <url> <token> point the client at a graph (writes user config)
46
- migrate <url> push the local graph to a remote you own (solo-remote)
47
- whoami who the team graph thinks you are (remote)
48
-
49
- Team admin (remote, admin token)
50
- invite --person <id> | --name <n> --email <e> mint a teammate token
51
- token list | token revoke <prefix> manage tokens
52
-
53
- Graph
54
- add "<text>" capture a node (local: typed file; remote: /v1/capture)
55
- next [--project S] the decision queue (local: lib/queue; remote: /v1/queue)
56
- get <id> a node by id (local: file; remote: /v1/nodes/<id>)
57
- lens [<id>] render a saved view (remote). No id => list the lens
58
- catalog; <id> renders it. Flags: --format text|json
59
- (default text), --PARAM VALUE to pass lens params,
60
- --json (machine output of the catalog/JSON tree)
61
-
62
- Repo scoping
63
- disable | enable turn Spor off/on for this repo (.spor.json)
64
- link <slug> set this repo's canonical project slug (.spor marker)
65
- compile <args> full neighborhood / digest (local; byte-identical)
66
- brief <id> compile a briefing for a node (alias: compile --root <id>)
67
- validate lint the local graph (byte-identical)
68
-
69
- Dispatch (Claude Code background agents)
70
- dispatch "<task>" compile a briefing + launch 'claude --bg' in the repo.
71
- Also: dispatch <node-id> | --node <id> | --from-queue |
72
- --backfill. Flags: --dir P, --full, --no-brief, --model M,
73
- --permission-mode P, --agent A, --name N, --print,
74
- --template F (own prompt; {{brief}}/{{task}}/{{node}}/
75
- {{title}}/{{slug}}/{{dir}}/{{default}} placeholders)
76
- repos show the local slug->repo-dir map used to pick the
77
- directory (repos add <slug> <path> | repos rm <slug>)
78
-
79
- Other
80
- cost [--since D] LLM spend summary from journal/llm-calls (local)
81
- version print version
82
- help this message
32
+ // The CLI surface is a single declarative table (COMMANDS, defined below): it is
33
+ // the one source of truth for dispatch, flag parsing (Node's built-in
34
+ // util.parseArgs), and help — top-level AND per-command (`spor <verb> --help`).
35
+ // Adding a verb or a flag means editing one table entry; the help can't drift
36
+ // from the parser because both read the same spec. The header/footer frame the
37
+ // generated top-level listing.
38
+ const HELP_HEADER = `spor Spor client CLI
83
39
 
40
+ Usage: spor <command> [args]`;
41
+ const HELP_FOOTER = `Run 'spor <command> --help' for a command's flags and detail.
84
42
  Mode is set by config/env (SPOR_SERVER ⇒ remote). See 'spor status'.`;
85
43
 
86
44
  // A consumer that closes the pipe early (`spor next | head`) makes stdout emit
@@ -268,8 +226,8 @@ function renderQueue(q) {
268
226
  }
269
227
  }
270
228
 
271
- async function cmdGet(cfg, args) {
272
- const id = args[0];
229
+ async function cmdGet(cfg, { positionals }) {
230
+ const id = positionals[0];
273
231
  if (!id) {
274
232
  err("usage: spor get <id>");
275
233
  return 1;
@@ -429,13 +387,13 @@ function today() {
429
387
  return new Date().toISOString().slice(0, 10);
430
388
  }
431
389
 
432
- async function cmdAdd(cfg, args) {
433
- const prose = args.find((a) => !a.startsWith("--"));
390
+ async function cmdAdd(cfg, { values, positionals }) {
391
+ const prose = positionals[0];
434
392
  if (!prose) {
435
393
  err('usage: spor add "<text>" [--type T] [--title ...] [--project S]');
436
394
  return 1;
437
395
  }
438
- const project = optVal(args, "project") || safeSlug();
396
+ const project = values.project || safeSlug();
439
397
 
440
398
  if (cfg.mode() === "remote") {
441
399
  const r = await remote.post(cfg, "/v1/capture", { text: prose, context: { project } });
@@ -459,8 +417,8 @@ async function cmdAdd(cfg, args) {
459
417
  err(`no graph at ${nodesDir} — run 'spor init' first`);
460
418
  return 1;
461
419
  }
462
- const type = optVal(args, "type") || "task";
463
- const title = optVal(args, "title") || prose.split(/\s+/).slice(0, 10).join(" ");
420
+ const type = values.type || "task";
421
+ const title = values.title || prose.split(/\s+/).slice(0, 10).join(" ");
464
422
  const summary = prose.length > 500 ? prose.slice(0, 497) + "..." : prose;
465
423
 
466
424
  let g;
@@ -472,7 +430,7 @@ async function cmdAdd(cfg, args) {
472
430
  }
473
431
  const prefixes = (g.registry && g.registry.prefixesFor(type)) || null;
474
432
  const prefix = prefixes && prefixes[0] ? prefixes[0] : `${type}-`;
475
- let id = optVal(args, "id") || `${prefix}${kebab(title) || today()}`;
433
+ let id = values.id || `${prefix}${kebab(title) || today()}`;
476
434
  // uniquify against existing files
477
435
  let n = 1;
478
436
  let base = id;
@@ -520,10 +478,9 @@ function writeServerToken(home, server, token) {
520
478
  // Write server+token to USER config (never a committable repo config), then
521
479
  // confirm immediately — the upgrade research found no one-step way to point a
522
480
  // client at a graph and know it took.
523
- async function cmdJoin(cfg, args) {
524
- const positional = args.filter((a) => !a.startsWith("--"));
525
- const server = optVal(args, "server") || positional[0];
526
- const token = optVal(args, "token") || positional[1];
481
+ async function cmdJoin(cfg, { values, positionals }) {
482
+ const server = values.server || positionals[0];
483
+ const token = values.token || positionals[1];
527
484
  if (!server) {
528
485
  err("usage: spor join <server-url> <token>");
529
486
  return 1;
@@ -556,15 +513,15 @@ function notAdminHint(r) {
556
513
  return false;
557
514
  }
558
515
 
559
- async function cmdInvite(cfg, args) {
516
+ async function cmdInvite(cfg, { values }) {
560
517
  if (cfg.mode() !== "remote") {
561
518
  err("invite needs a team graph — set SPOR_SERVER/SPOR_TOKEN (see 'spor join').");
562
519
  return 1;
563
520
  }
564
- let person = optVal(args, "person");
565
- const name = optVal(args, "name");
566
- const email = optVal(args, "email");
567
- const expires = optVal(args, "expires");
521
+ let person = values.person;
522
+ const name = values.name;
523
+ const email = values.email;
524
+ const expires = values.expires;
568
525
 
569
526
  // create the person node first when only name/email is given (the mint
570
527
  // endpoint binds to an EXISTING node, it cannot conjure a subject).
@@ -574,7 +531,7 @@ async function cmdInvite(cfg, args) {
574
531
  err(" or: spor invite --name <name> --email <email> [--id person-x] [--expires <Nd>]");
575
532
  return 1;
576
533
  }
577
- person = optVal(args, "id") || `person-${kebab(name)}`;
534
+ person = values.id || `person-${kebab(name)}`;
578
535
  const md = `---\nid: ${person}\ntype: person\ntitle: ${name.replace(/\n/g, " ")}\nsummary: Team member ${name}.\nemail: ${email}\ndate: ${today()}\n---\n\nTeam member ${name} <${email}>.\n`;
579
536
  const pr = await remote.post(cfg, "/v1/nodes", { nodes: [{ node: md, if_exists: "skip" }] });
580
537
  if (pr.transport) {
@@ -694,7 +651,7 @@ function hasGit() {
694
651
  // route for BYO-repo registration, and the GitHub-App write grant of
695
652
  // dec-spor-solo-remote-write-credential-custody is unbuilt server-side; both
696
653
  // are tracked separately, so this verb stops at "your graph is on your remote".
697
- function cmdMigrate(cfg, args) {
654
+ function cmdMigrate(cfg, { positionals }) {
698
655
  const home = cfg.graphHome();
699
656
  const nodesDir = cfg.nodesDir();
700
657
  if (!fs.existsSync(nodesDir)) {
@@ -732,7 +689,7 @@ function cmdMigrate(cfg, args) {
732
689
  }
733
690
  // 3. wire the remote. An explicit URL sets/updates origin; otherwise reuse an
734
691
  // existing origin, or explain that one is required.
735
- const url = args.find((a) => !a.startsWith("--"));
692
+ const url = positionals[0];
736
693
  const haveOrigin = git(home, ["remote", "get-url", "origin"]).status === 0;
737
694
  if (url) {
738
695
  const r = haveOrigin ? git(home, ["remote", "set-url", "origin", url]) : git(home, ["remote", "add", "origin", url]);
@@ -780,8 +737,8 @@ function cmdScope(enabled) {
780
737
  // --- spor link <slug>: write the .spor identity marker --------------------
781
738
  // Fixes a wrong inferred slug (basename != canonical) deterministically,
782
739
  // instead of waiting for the server's fingerprint-alias proposal to be approved.
783
- function cmdLink(args) {
784
- const slug = args.find((a) => !a.startsWith("--")) || safeSlug();
740
+ function cmdLink(cfg, { positionals }) {
741
+ const slug = positionals[0] || safeSlug();
785
742
  if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) {
786
743
  err(`invalid slug '${slug}' — must match ^[a-z0-9][a-z0-9-]*$`);
787
744
  return 1;
@@ -982,20 +939,6 @@ async function npmLatest(timeoutMs = 4000) {
982
939
  });
983
940
  }
984
941
 
985
- // Options that consume the following token, so positional parsing can skip it.
986
- const INSTALL_VALUE_OPTS = new Set(["scope", "server", "token"]);
987
- function positionals(args) {
988
- const pos = [];
989
- for (let i = 0; i < args.length; i++) {
990
- const a = args[i];
991
- if (a.startsWith("--")) {
992
- if (INSTALL_VALUE_OPTS.has(a.slice(2))) i++; // skip its value
993
- continue;
994
- }
995
- pos.push(a);
996
- }
997
- return pos;
998
- }
999
942
 
1000
943
  function deepReplace(v, from, to) {
1001
944
  if (typeof v === "string") return v.split(from).join(to);
@@ -1171,8 +1114,8 @@ function installPluginHost(spec, scope, dryRun) {
1171
1114
  return 0;
1172
1115
  }
1173
1116
 
1174
- async function cmdInstall(cfg, args) {
1175
- const dryRun = args.includes("--print") || args.includes("--dry-run");
1117
+ async function cmdInstall(cfg, { values, positionals: pos }) {
1118
+ const dryRun = !!(values.print || values["dry-run"]);
1176
1119
  // Node prerequisite (issue-spor-onboarding-no-node-silent-fail-open). The
1177
1120
  // hooks fail open on a box with no/old Node (every one silently no-ops), so
1178
1121
  // make the requirement loud HERE, at wire-up time. A too-old interpreter is a
@@ -1183,25 +1126,24 @@ async function cmdInstall(cfg, args) {
1183
1126
  err(` Spor's hooks fail open, so on this Node they install but every hook silently no-ops — upgrade Node first.`);
1184
1127
  return 1;
1185
1128
  }
1186
- let scope = optVal(args, "scope") || "user";
1129
+ let scope = values.scope || "user";
1187
1130
  if (scope === "project") scope = "repo";
1188
1131
  if (scope !== "user" && scope !== "repo") {
1189
1132
  err(`invalid --scope '${scope}' — use 'user' or 'repo'`);
1190
1133
  return 1;
1191
1134
  }
1192
1135
 
1193
- const pos = positionals(args);
1194
1136
  const bad = pos.find((a) => !HOSTS[a]);
1195
1137
  if (bad) {
1196
1138
  err(`unknown host '${bad}' — known: ${Object.keys(HOSTS).join(", ")}`);
1197
1139
  return 1;
1198
1140
  }
1199
1141
  let hosts = pos.slice();
1200
- if (args.includes("--all")) hosts = detectHosts();
1142
+ if (values.all) hosts = detectHosts();
1201
1143
 
1202
1144
  // The "configure" half: persist server/token to user config when given.
1203
- const server = optVal(args, "server");
1204
- const token = optVal(args, "token");
1145
+ const server = values.server;
1146
+ const token = values.token;
1205
1147
  if ((server || token) && !dryRun) {
1206
1148
  try {
1207
1149
  const f = writeServerToken(cfg.userConfigHome(), server, token);
@@ -1301,15 +1243,14 @@ function hostHasSpor(host, scope) {
1301
1243
  }
1302
1244
  }
1303
1245
 
1304
- async function cmdUpgrade(cfg, args) {
1305
- const dryRun = args.includes("--print") || args.includes("--dry-run");
1306
- let scope = optVal(args, "scope") || "user";
1246
+ async function cmdUpgrade(cfg, { values, positionals: pos }) {
1247
+ const dryRun = !!(values.print || values["dry-run"]);
1248
+ let scope = values.scope || "user";
1307
1249
  if (scope === "project") scope = "repo";
1308
1250
  if (scope !== "user" && scope !== "repo") {
1309
1251
  err(`invalid --scope '${scope}' — use 'user' or 'repo'`);
1310
1252
  return 1;
1311
1253
  }
1312
- const pos = positionals(args);
1313
1254
  const bad = pos.find((a) => !HOSTS[a]);
1314
1255
  if (bad) {
1315
1256
  err(`unknown host '${bad}' — known: ${Object.keys(HOSTS).join(", ")}`);
@@ -1341,7 +1282,7 @@ async function cmdUpgrade(cfg, args) {
1341
1282
  // The refresh above closes the loaded-vs-installed gap; this closes the
1342
1283
  // installed-vs-published one — if npm has a newer release, the package on
1343
1284
  // disk itself is behind, so point the user at the npm bump (then re-upgrade).
1344
- if (!args.includes("--no-net")) {
1285
+ if (!values["no-net"]) {
1345
1286
  const latest = await npmLatest();
1346
1287
  const installed = version();
1347
1288
  if (latest && verCmp(installed, latest) < 0) {
@@ -1503,36 +1444,26 @@ function onboardRepo(cfg, dir) {
1503
1444
  }
1504
1445
  }
1505
1446
 
1506
- async function cmdDispatch(cfg, args) {
1507
- const dryRun = args.includes("--print") || args.includes("--dry-run");
1508
- const full = args.includes("--full");
1509
- const noBrief = args.includes("--no-brief");
1510
- const backfill = args.includes("--backfill");
1511
- const fromQueue = args.includes("--from-queue");
1512
- const dirOpt = optVal(args, "dir");
1513
- const model = optVal(args, "model");
1514
- const permMode = optVal(args, "permission-mode");
1515
- const agent = optVal(args, "agent");
1447
+ async function cmdDispatch(cfg, { values, positionals: pos }) {
1448
+ const dryRun = !!(values.print || values["dry-run"]);
1449
+ const full = !!values.full;
1450
+ const noBrief = !!values["no-brief"];
1451
+ const backfill = !!values.backfill;
1452
+ const fromQueue = !!values["from-queue"];
1453
+ const dirOpt = values.dir || null;
1454
+ const model = values.model || null;
1455
+ const permMode = values["permission-mode"] || null;
1456
+ const agent = values.agent || null;
1516
1457
  // A user-supplied prompt template (task-spor-dispatch-user-prompt-templates):
1517
1458
  // --template wins, else a personal default in the config cascade
1518
1459
  // (dispatch.template — an absolute path, like dispatch.repos). Empty until we
1519
1460
  // resolve the file below, so an absent option leaves the prompt byte-identical.
1520
- const templateOpt = optVal(args, "template") || cfg.get("dispatch.template", null);
1521
- let nodeId = optVal(args, "node");
1522
- let targetSlug = optVal(args, "slug");
1523
- let name = optVal(args, "name");
1524
-
1525
- // Positional task text: everything that isn't a flag or a flag's value.
1526
- const VALUE_OPTS = new Set(["dir", "node", "slug", "model", "permission-mode", "agent", "name", "template"]);
1527
- const pos = [];
1528
- for (let i = 0; i < args.length; i++) {
1529
- const a = args[i];
1530
- if (a.startsWith("--")) {
1531
- if (VALUE_OPTS.has(a.slice(2))) i++;
1532
- continue;
1533
- }
1534
- pos.push(a);
1535
- }
1461
+ const templateOpt = values.template || cfg.get("dispatch.template", null);
1462
+ let nodeId = values.node || null;
1463
+ let targetSlug = values.slug || null;
1464
+ let name = values.name || null;
1465
+
1466
+ // Positional task text: parseArgs already split flags from positionals.
1536
1467
  let taskText = pos.join(" ").trim();
1537
1468
 
1538
1469
  // Load the template now (before any briefing compile) so a bad path fails fast.
@@ -1736,79 +1667,441 @@ function version() {
1736
1667
  }
1737
1668
  }
1738
1669
 
1670
+ // ---------------------------------------------------------------------------
1671
+ // The command table — the single source of truth for dispatch, parsing, and help
1672
+ // ---------------------------------------------------------------------------
1673
+ // Each entry declares its group (for the top-level listing), a positional usage
1674
+ // hint (`args`), a one-line `summary`, a longer `help` body, an `options` map in
1675
+ // util.parseArgs shape (each carrying a help-only `desc`/`value`), optional
1676
+ // `examples`, and `aliases`. `parse` picks the front-door behavior:
1677
+ // "strict" — central util.parseArgs validates flags (unknown-flag errors with
1678
+ // a suggestion) and the run() gets { values, positionals }.
1679
+ // "raw" — run() gets the raw argv array; for commands that own their parsing
1680
+ // (subcommands like token/repos), forward open --PARAM sets (lens),
1681
+ // or must stay byte-identical passthrough to lib/*.js (compile,
1682
+ // validate, next-local, cost). norm-cc-byte-identical-refactor.
1683
+ // "meta" — listing-only (help/version are intercepted in main before dispatch).
1684
+ const SCOPE_OPT = { type: "string", value: "user|repo", desc: "where to write — 'user' (default) or 'repo' (this checkout)" };
1685
+ const PRINT_OPT = { type: "boolean", desc: "dry run — show what would change, write nothing" };
1686
+ const DRYRUN_OPT = { type: "boolean", desc: "alias for --print" };
1687
+
1688
+ const COMMANDS = {
1689
+ // --- Getting started ---
1690
+ init: {
1691
+ group: "Getting started", parse: "strict", args: "", options: {},
1692
+ summary: "create the local graph home (nodes/, git, .gitignore)",
1693
+ help:
1694
+ "Idempotently create the local graph home: a nodes/ directory, a git repo\n" +
1695
+ "to version it, and a .gitignore for machine-local state. Safe to re-run —\n" +
1696
+ "an existing graph is reported, never clobbered.",
1697
+ run: (cfg) => cmdInit(cfg),
1698
+ },
1699
+ install: {
1700
+ group: "Getting started", parse: "strict", args: "[host...]", aliases: ["setup"],
1701
+ summary: "wire spor into an agent (claude codex gemini opencode copilot cursor)",
1702
+ help:
1703
+ "Wire the spor hooks/plugin into one or more host agents. With no host, lists\n" +
1704
+ "the hosts detected on this machine and touches nothing. Claude Code is wired\n" +
1705
+ "via its plugin CLI; the others receive a merged hooks manifest.\n\n" +
1706
+ "--server/--token also persist remote-graph credentials to your user config.",
1707
+ options: {
1708
+ scope: SCOPE_OPT,
1709
+ all: { type: "boolean", desc: "install into every detected host" },
1710
+ print: PRINT_OPT,
1711
+ "dry-run": DRYRUN_OPT,
1712
+ server: { type: "string", value: "url", desc: "persist a team-graph server URL to user config" },
1713
+ token: { type: "string", value: "tok", desc: "persist an auth token to user config" },
1714
+ },
1715
+ examples: ["spor install claude", "spor install codex gemini --scope repo", "spor install --all --print"],
1716
+ run: (cfg, p) => cmdInstall(cfg, p),
1717
+ },
1718
+ upgrade: {
1719
+ group: "Getting started", parse: "strict", args: "[host...]", aliases: ["update"],
1720
+ summary: "refresh wired spor to the installed package version (after an npm bump)",
1721
+ help:
1722
+ "Refresh wired hosts to the package version on disk. A bumped npm package does\n" +
1723
+ "not change what an agent already loaded — Claude Code runs its own cached copy\n" +
1724
+ "until 'plugin update' swaps it. With no host, refreshes every detected host\n" +
1725
+ "that has spor wired. Also flags a newer release published to npm.",
1726
+ options: { scope: SCOPE_OPT, print: PRINT_OPT, "dry-run": DRYRUN_OPT, "no-net": { type: "boolean", desc: "skip the npm 'newer version published' check" } },
1727
+ examples: ["spor upgrade", "spor upgrade claude --print"],
1728
+ run: (cfg, p) => cmdUpgrade(cfg, p),
1729
+ },
1730
+ status: {
1731
+ group: "Getting started", parse: "strict", args: "", options: {},
1732
+ summary: "resolved mode, graph, project, identity, health",
1733
+ help: "Print the resolved mode (local/remote), graph home, project slug, identity,\nand a health probe. In local mode it also warns of a split-brain claude.ai\nSpor MCP connector; it always surfaces the Node prerequisite line.",
1734
+ run: (cfg) => cmdStatus(cfg),
1735
+ },
1736
+ join: {
1737
+ group: "Getting started", parse: "strict", args: "<url> <token>", aliases: ["login"],
1738
+ summary: "point the client at a graph (writes user config)",
1739
+ help: "Write a team-graph server URL and token to your USER config (never a\ncommittable repo config), then confirm the connection immediately. The URL and\ntoken may be given positionally or as --server/--token.",
1740
+ options: {
1741
+ server: { type: "string", value: "url", desc: "server URL (else the first positional)" },
1742
+ token: { type: "string", value: "tok", desc: "auth token (else the second positional)" },
1743
+ },
1744
+ examples: ["spor join https://graph.example.com tok_abc123"],
1745
+ run: (cfg, p) => cmdJoin(cfg, p),
1746
+ },
1747
+ migrate: {
1748
+ group: "Getting started", parse: "strict", args: "<url>", aliases: ["push"],
1749
+ summary: "push the local graph to a remote you own (solo-remote)",
1750
+ help: "Commit the local graph home and push it to a git remote you own (e.g. a\nprivate GitHub repo). The URL is remembered as 'origin', so later pushes need\nno argument. Pure git plumbing — no server route is involved.",
1751
+ options: {},
1752
+ examples: ["spor migrate git@github.com:you/my-graph.git", "spor push"],
1753
+ run: (cfg, p) => cmdMigrate(cfg, p),
1754
+ },
1755
+ whoami: {
1756
+ group: "Getting started", parse: "strict", args: "", options: {},
1757
+ summary: "who the team graph thinks you are (remote)",
1758
+ help: "Echo the identity the server binds to your token (remote mode). In local\nmode it explains there is no server identity.",
1759
+ run: (cfg) => cmdWhoami(cfg),
1760
+ },
1761
+
1762
+ // --- Team admin ---
1763
+ invite: {
1764
+ group: "Team admin (remote, admin token)", parse: "strict",
1765
+ args: "--person <id> | --name <n> --email <e>",
1766
+ summary: "mint a teammate token (creates the person node if needed)",
1767
+ help:
1768
+ "Mint a person-bound token and print a paste-ready 'spor join' line. Remote +\n" +
1769
+ "admin only. Pass --person to bind an existing person node, or --name/--email\n" +
1770
+ "to create the node first.",
1771
+ options: {
1772
+ person: { type: "string", value: "id", desc: "bind to an existing person node" },
1773
+ name: { type: "string", value: "name", desc: "create a person node with this name" },
1774
+ email: { type: "string", value: "email", desc: "the new person's email" },
1775
+ id: { type: "string", value: "id", desc: "explicit id for the created person node" },
1776
+ expires: { type: "string", value: "Nd", desc: "token lifetime, e.g. 30d" },
1777
+ },
1778
+ examples: ["spor invite --person person-jo", "spor invite --name 'Jo Diaz' --email jo@x.io --expires 30d"],
1779
+ run: (cfg, p) => cmdInvite(cfg, p),
1780
+ },
1781
+ token: {
1782
+ group: "Team admin (remote, admin token)", parse: "raw", args: "list | revoke <prefix>",
1783
+ summary: "manage tokens (list, revoke)",
1784
+ help: "List or revoke team tokens. Remote + admin only.\n\n spor token list show all tokens (hash prefix, person, expiry)\n spor token revoke <prefix> revoke the token with that hash prefix",
1785
+ examples: ["spor token list", "spor token revoke a1b2c3"],
1786
+ run: (cfg, args) => cmdToken(cfg, args),
1787
+ },
1788
+
1789
+ // --- Graph ---
1790
+ add: {
1791
+ group: "Graph", parse: "strict", args: '"<text>"', aliases: ["capture"],
1792
+ summary: "capture a node (local: typed file; remote: /v1/capture)",
1793
+ help:
1794
+ "Capture a node from prose. In remote mode the server's ingestion model types\n" +
1795
+ "and links it; in local mode a well-formed, validated node file is written so\n" +
1796
+ "you never hand-author frontmatter. --type/--title/--id apply to local mode.",
1797
+ options: {
1798
+ type: { type: "string", value: "T", desc: "node type (local only; default: task)" },
1799
+ title: { type: "string", value: "...", desc: "title (default: first 10 words)" },
1800
+ project: { type: "string", value: "S", desc: "project slug (default: inferred from cwd)" },
1801
+ id: { type: "string", value: "id", desc: "explicit node id (local only)" },
1802
+ },
1803
+ examples: ['spor add "Cache tf-idf norms across compiles for speed" --type task'],
1804
+ run: (cfg, p) => cmdAdd(cfg, p),
1805
+ },
1806
+ next: {
1807
+ group: "Graph", parse: "raw", args: "[--project S]", aliases: ["queue"],
1808
+ summary: "the decision queue (local: lib/queue; remote: /v1/queue)",
1809
+ help: "Show the ranked decision queue. Remote mode reads /v1/queue; local mode is a\nbyte-identical passthrough to lib/queue.js, so it also accepts that script's\nflags (--days, --no-front, --limit, --name-only, --nodes).",
1810
+ options: {
1811
+ project: { type: "string", value: "S", desc: "scope to a project slug (default: inferred)" },
1812
+ json: { type: "boolean", desc: "machine-readable JSON output" },
1813
+ },
1814
+ examples: ["spor next", "spor next --project spor --json"],
1815
+ run: (cfg, args) => cmdNext(cfg, args),
1816
+ },
1817
+ get: {
1818
+ group: "Graph", parse: "strict", args: "<id>", options: {},
1819
+ summary: "a node by id (local: file; remote: /v1/nodes/<id>)",
1820
+ help: "Print one node's raw markdown by id. Remote mode reads /v1/nodes/<id>; local\nmode reads the node file. A missing node exits 1.",
1821
+ examples: ["spor get dec-cc-zero-dep-client"],
1822
+ run: (cfg, p) => cmdGet(cfg, p),
1823
+ },
1824
+ lens: {
1825
+ group: "Graph", parse: "raw", args: "[<id>]", aliases: ["render-lens"],
1826
+ summary: "render a saved view (remote)",
1827
+ help:
1828
+ "Render a saved lens (remote only — lenses render server-side). With no id,\n" +
1829
+ "lists the lens catalog; with an id, renders it. Any extra --PARAM VALUE flags\n" +
1830
+ "beyond --format/--json are forwarded to the lens as render parameters.",
1831
+ options: {
1832
+ format: { type: "string", value: "text|json", desc: "server rendering format (default: text)" },
1833
+ json: { type: "boolean", desc: "force JSON: the raw catalog / view tree" },
1834
+ },
1835
+ examples: ["spor lens", "spor lens lens-roadmap", "spor lens lens-roadmap --project spor"],
1836
+ run: (cfg, args) => cmdLens(cfg, args),
1837
+ },
1838
+
1839
+ // --- Repo scoping ---
1840
+ disable: {
1841
+ group: "Repo scoping", parse: "strict", args: "", options: {},
1842
+ summary: "turn Spor off for this repo (.spor.json)",
1843
+ help: "Set { enabled: false } in this repo's committable .spor.json. The hooks then\nno-op here until re-enabled. Commit the file to share the setting.",
1844
+ run: (cfg) => cmdScope(false),
1845
+ },
1846
+ enable: {
1847
+ group: "Repo scoping", parse: "strict", args: "", options: {},
1848
+ summary: "turn Spor back on for this repo (.spor.json)",
1849
+ help: "Set { enabled: true } in this repo's committable .spor.json, undoing a prior\n'spor disable'.",
1850
+ run: (cfg) => cmdScope(true),
1851
+ },
1852
+ link: {
1853
+ group: "Repo scoping", parse: "strict", args: "<slug>", options: {},
1854
+ summary: "set this repo's canonical project slug (.spor marker)",
1855
+ help: "Write a .spor identity marker (repo: <slug>) at the repo root, fixing a wrong\ninferred slug deterministically. The slug must be canonical (^[a-z0-9][a-z0-9-]*$).\nWith no slug it uses the inferred one. Commit the marker to share the identity.",
1856
+ examples: ["spor link my-repo"],
1857
+ run: (cfg, p) => cmdLink(cfg, p),
1858
+ },
1859
+ compile: {
1860
+ group: "Repo scoping", parse: "raw", args: "<args>",
1861
+ summary: "full neighborhood / digest (local; byte-identical)",
1862
+ help: "Compile a node neighborhood or a prompt-time digest from the local graph.\nByte-identical passthrough to lib/compile.js (norm-cc-byte-identical-refactor).",
1863
+ options: {
1864
+ root: { type: "string", value: "id", desc: "compile a node's neighborhood" },
1865
+ query: { type: "string", value: "text", desc: "compile from free-text (query mode)" },
1866
+ project: { type: "string", value: "slug", desc: "session slug (scopes project: corrections)" },
1867
+ nodes: { type: "string", value: "dir", desc: "graph nodes dir (default: $SPOR_HOME/nodes)" },
1868
+ digest: { type: "boolean", desc: "emit a compact prompt-time digest" },
1869
+ skeleton: { type: "boolean", desc: "write a versioned briefing-node skeleton (root mode)" },
1870
+ "min-sim": { type: "string", value: "n", desc: "query-mode relevance gate (default: 0.08)" },
1871
+ out: { type: "string", value: "file", desc: "write to a file instead of stdout" },
1872
+ quiet: { type: "boolean", desc: "suppress the stderr stats / no-graph lines" },
1873
+ },
1874
+ examples: ['spor compile --root dec-x', 'spor compile --query "auth token rotation" --digest'],
1875
+ run: (cfg, args) => cmdCompile(cfg, "compile", args),
1876
+ },
1877
+ brief: {
1878
+ group: "Repo scoping", parse: "raw", args: "<id>",
1879
+ summary: "compile a briefing for a node (sugar for compile --root <id>)",
1880
+ help: "Compile a briefing for one node — sugar for 'compile --root <id>'. Byte-\nidentical passthrough to lib/compile.js.",
1881
+ examples: ["spor brief dec-cc-zero-dep-client"],
1882
+ run: (cfg, args) => cmdCompile(cfg, "brief", args),
1883
+ },
1884
+ validate: {
1885
+ group: "Repo scoping", parse: "raw", args: "",
1886
+ summary: "lint the local graph (byte-identical)",
1887
+ help: "Lint the local graph and exit 1 on errors. Byte-identical passthrough to\nlib/validate.js.",
1888
+ options: { nodes: { type: "string", value: "dir", desc: "graph nodes dir to lint" } },
1889
+ run: (cfg, args) => passthrough("validate.js", args),
1890
+ },
1891
+
1892
+ // --- Dispatch ---
1893
+ dispatch: {
1894
+ group: "Dispatch (Claude Code background agents)", parse: "strict", args: '"<task>" | <node-id>', aliases: ["bg"],
1895
+ summary: "compile a briefing + launch 'claude --bg' in the repo",
1896
+ help:
1897
+ "Compile a briefing for a task and launch a Claude Code background agent in the\n" +
1898
+ "right repo. Give free-text, a <node-id>, --node <id>, --from-queue (the top\n" +
1899
+ "ranked item), or --backfill (onboard/repair a repo). The target dir is the\n" +
1900
+ "slug->path map ('spor repos'), overridable with --dir.\n\n" +
1901
+ "--template supplies your own prompt with {{brief}}/{{task}}/{{node}}/{{title}}/\n" +
1902
+ "{{slug}}/{{dir}}/{{default}} placeholders.",
1903
+ options: {
1904
+ dir: { type: "string", value: "path", desc: "launch directory (overrides the slug map)" },
1905
+ node: { type: "string", value: "id", desc: "dispatch a specific node id" },
1906
+ slug: { type: "string", value: "slug", desc: "target project slug (cross-repo resolution)" },
1907
+ model: { type: "string", value: "M", desc: "claude --model" },
1908
+ "permission-mode": { type: "string", value: "P", desc: "claude --permission-mode" },
1909
+ agent: { type: "string", value: "A", desc: "claude --agent" },
1910
+ name: { type: "string", value: "N", desc: "claude --name (session name)" },
1911
+ template: { type: "string", value: "F", desc: "prompt template file (placeholders above)" },
1912
+ full: { type: "boolean", desc: "full briefing instead of the digest" },
1913
+ "no-brief": { type: "boolean", desc: "raw task prompt, no briefing block" },
1914
+ "from-queue": { type: "boolean", desc: "dispatch the top-ranked queue item" },
1915
+ backfill: { type: "boolean", desc: "onboard/repair this repo (runs /spor:backfill)" },
1916
+ print: { type: "boolean", desc: "dry run — print the prompt, launch nothing" },
1917
+ "dry-run": DRYRUN_OPT,
1918
+ },
1919
+ examples: ['spor dispatch "rotate the pipeline auth tokens" --dir ../api', "spor dispatch dec-x --model haiku", "spor dispatch --from-queue --print"],
1920
+ run: (cfg, p) => cmdDispatch(cfg, p),
1921
+ },
1922
+ repos: {
1923
+ group: "Dispatch (Claude Code background agents)", parse: "raw", args: "[list | add <slug> <path> | rm <slug>]",
1924
+ summary: "the local slug->repo-dir map used to pick the dispatch directory",
1925
+ help: "Show or edit the per-machine slug->repo-dir map dispatch uses to find a repo.\nThe map self-registers as you open sessions.\n\n spor repos list the map\n spor repos add <slug> <p> map a slug to a path\n spor repos rm <slug> forget a mapping",
1926
+ examples: ["spor repos", "spor repos add api ~/code/api"],
1927
+ run: (cfg, args) => cmdRepos(cfg, args),
1928
+ },
1929
+
1930
+ // --- Other ---
1931
+ cost: {
1932
+ group: "Other", parse: "raw", args: "[--since D]",
1933
+ summary: "LLM spend summary from journal/llm-calls (local)",
1934
+ help: "Summarize recorded LLM spend from journal/llm-calls. Byte-identical\npassthrough to lib/cost.js.",
1935
+ options: {
1936
+ since: { type: "string", value: "YYYY-MM-DD", desc: "include calls on/after this date" },
1937
+ until: { type: "string", value: "YYYY-MM-DD", desc: "include calls on/before this date" },
1938
+ project: { type: "string", value: "slug", desc: "scope to a project" },
1939
+ json: { type: "boolean", desc: "machine-readable JSON output" },
1940
+ },
1941
+ examples: ["spor cost", "spor cost --since 2026-06-01"],
1942
+ run: (cfg, args) => passthrough("cost.js", args),
1943
+ },
1944
+ version: {
1945
+ group: "Other", parse: "meta", args: "", summary: "print version",
1946
+ run: () => 0,
1947
+ },
1948
+ help: {
1949
+ group: "Other", parse: "meta", args: "[<command>]", summary: "this message, or a command's detailed help",
1950
+ run: () => 0,
1951
+ },
1952
+ };
1953
+
1954
+ const GROUP_ORDER = [
1955
+ "Getting started",
1956
+ "Team admin (remote, admin token)",
1957
+ "Graph",
1958
+ "Repo scoping",
1959
+ "Dispatch (Claude Code background agents)",
1960
+ "Other",
1961
+ ];
1962
+
1963
+ // alias -> canonical verb (every canonical maps to itself).
1964
+ const ALIAS_TO_CANON = (() => {
1965
+ const m = {};
1966
+ for (const [name, e] of Object.entries(COMMANDS)) {
1967
+ m[name] = name;
1968
+ for (const a of e.aliases || []) m[a] = name;
1969
+ }
1970
+ return m;
1971
+ })();
1972
+ function resolveVerb(v) {
1973
+ return Object.prototype.hasOwnProperty.call(ALIAS_TO_CANON, v) ? ALIAS_TO_CANON[v] : null;
1974
+ }
1975
+
1976
+ // Build the parseArgs options descriptor from a table entry's options, dropping
1977
+ // the help-only keys (desc/value) so only {type, short, multiple} reach parseArgs.
1978
+ function paOptions(options) {
1979
+ const o = {};
1980
+ for (const [name, spec] of Object.entries(options || {})) {
1981
+ o[name] = { type: spec.type };
1982
+ if (spec.short) o[name].short = spec.short;
1983
+ if (spec.multiple) o[name].multiple = true;
1984
+ }
1985
+ return o;
1986
+ }
1987
+
1988
+ // Closest candidate by edit distance, for "did you mean --foo?" hints.
1989
+ function editDistance(a, b) {
1990
+ const dp = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
1991
+ for (let i = 0; i <= a.length; i++) dp[i][0] = i;
1992
+ for (let j = 0; j <= b.length; j++) dp[0][j] = j;
1993
+ for (let i = 1; i <= a.length; i++)
1994
+ for (let j = 1; j <= b.length; j++)
1995
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
1996
+ return dp[a.length][b.length];
1997
+ }
1998
+ function suggest(word, candidates) {
1999
+ let best = null;
2000
+ let bestD = Infinity;
2001
+ for (const c of candidates) {
2002
+ const d = editDistance(word, c);
2003
+ if (d < bestD) {
2004
+ bestD = d;
2005
+ best = c;
2006
+ }
2007
+ }
2008
+ return bestD <= Math.max(2, Math.ceil(word.length / 3)) ? best : null;
2009
+ }
2010
+
2011
+ // Turn a parseArgs throw into a friendly, no-stack-trace error + a flag hint.
2012
+ function parseError(e, entry, verb) {
2013
+ const m = /'(-{1,2}[^']+)'/.exec(e.message || "");
2014
+ if (e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" && m) {
2015
+ err(`spor ${verb}: unknown flag '${m[1]}'`);
2016
+ const s = suggest(m[1].replace(/^-+/, ""), Object.keys(entry.options || {}));
2017
+ if (s) err(` did you mean --${s}?`);
2018
+ err(` run 'spor ${verb} --help' for the flag list.`);
2019
+ return 1;
2020
+ }
2021
+ err(`spor ${verb}: ${e.message}`);
2022
+ err(` run 'spor ${verb} --help' for usage.`);
2023
+ return 1;
2024
+ }
2025
+
2026
+ // The top-level listing, generated from the table so it can't drift.
2027
+ function renderTopHelp() {
2028
+ const verbs = Object.keys(COMMANDS);
2029
+ const sigOf = (v) => `${v}${COMMANDS[v].args ? " " + COMMANDS[v].args : ""}`;
2030
+ const width = Math.min(22, Math.max(...verbs.map((v) => sigOf(v).length)));
2031
+ const lines = [HELP_HEADER, ""];
2032
+ for (const group of GROUP_ORDER) {
2033
+ const inGroup = verbs.filter((v) => COMMANDS[v].group === group);
2034
+ if (!inGroup.length) continue;
2035
+ lines.push(group);
2036
+ for (const v of inGroup) lines.push(` ${sigOf(v).padEnd(width)} ${COMMANDS[v].summary}`);
2037
+ lines.push("");
2038
+ }
2039
+ lines.push(HELP_FOOTER);
2040
+ return lines.join("\n");
2041
+ }
2042
+
2043
+ // One command's detailed page (usage, aliases, description, flags, examples).
2044
+ function renderCmdHelp(verb) {
2045
+ const e = COMMANDS[verb];
2046
+ const opts = Object.entries(e.options || {});
2047
+ const sig = `spor ${verb}${e.args ? " " + e.args : ""}${opts.length ? " [options]" : ""}`;
2048
+ const lines = [sig, "", e.summary];
2049
+ if (e.aliases && e.aliases.length) lines.push(`Aliases: ${e.aliases.join(", ")}`);
2050
+ if (e.help) lines.push("", e.help);
2051
+ if (opts.length) {
2052
+ const rendered = opts.map(([name, o]) => [`--${name}${o.type === "string" ? ` <${o.value || "value"}>` : ""}`, o.desc || ""]);
2053
+ const w = Math.min(26, Math.max(...rendered.map((r) => r[0].length)));
2054
+ lines.push("", "Options:");
2055
+ for (const [flag, desc] of rendered) lines.push(` ${flag.padEnd(w)} ${desc}`);
2056
+ }
2057
+ if (e.examples && e.examples.length) {
2058
+ lines.push("", "Examples:");
2059
+ for (const ex of e.examples) lines.push(` ${ex}`);
2060
+ }
2061
+ return lines.join("\n");
2062
+ }
2063
+
1739
2064
  async function main() {
1740
2065
  const argv = process.argv.slice(2);
1741
2066
  const verb = argv.shift();
1742
2067
  const args = argv;
1743
2068
  const cfg = loadConfig({ cwd: process.cwd() });
1744
2069
 
1745
- switch (verb) {
1746
- case undefined:
1747
- case "help":
1748
- case "-h":
1749
- case "--help":
1750
- out(HELP);
1751
- return 0;
1752
- case "version":
1753
- case "--version":
1754
- case "-v":
1755
- out(version());
1756
- return 0;
1757
- case "init":
1758
- return cmdInit(cfg);
1759
- case "install":
1760
- case "setup":
1761
- return await cmdInstall(cfg, args);
1762
- case "upgrade":
1763
- case "update":
1764
- return await cmdUpgrade(cfg, args);
1765
- case "status":
1766
- return await cmdStatus(cfg);
1767
- case "whoami":
1768
- return await cmdWhoami(cfg);
1769
- case "join":
1770
- case "login":
1771
- return await cmdJoin(cfg, args);
1772
- case "migrate":
1773
- case "push":
1774
- return cmdMigrate(cfg, args);
1775
- case "add":
1776
- case "capture":
1777
- return await cmdAdd(cfg, args);
1778
- case "enable":
1779
- return cmdScope(true);
1780
- case "disable":
1781
- return cmdScope(false);
1782
- case "link":
1783
- return cmdLink(args);
1784
- case "invite":
1785
- return await cmdInvite(cfg, args);
1786
- case "token":
1787
- return await cmdToken(cfg, args);
1788
- case "next":
1789
- case "queue":
1790
- return await cmdNext(cfg, args);
1791
- case "get":
1792
- return await cmdGet(cfg, args);
1793
- case "lens":
1794
- case "render-lens":
1795
- return await cmdLens(cfg, args);
1796
- case "compile":
1797
- case "brief":
1798
- return cmdCompile(cfg, verb, args);
1799
- case "dispatch":
1800
- case "bg":
1801
- return await cmdDispatch(cfg, args);
1802
- case "repos":
1803
- return cmdRepos(cfg, args);
1804
- case "validate":
1805
- return passthrough("validate.js", args);
1806
- case "cost":
1807
- return passthrough("cost.js", args); // local: LLM spend summary
1808
- default:
1809
- err(`spor: unknown verb '${verb}'. Try 'spor help'.`);
1810
- return 1;
2070
+ // Top-level help / version are intercepted before table dispatch. `spor help
2071
+ // <command>` prints that command's detailed page.
2072
+ if (verb === undefined || verb === "help" || verb === "-h" || verb === "--help") {
2073
+ const topic = verb === "help" && args[0] ? resolveVerb(args[0]) : null;
2074
+ out(topic && COMMANDS[topic].parse !== "meta" ? renderCmdHelp(topic) : renderTopHelp());
2075
+ return 0;
2076
+ }
2077
+ if (verb === "version" || verb === "--version" || verb === "-v") {
2078
+ out(version());
2079
+ return 0;
2080
+ }
2081
+
2082
+ const canon = resolveVerb(verb);
2083
+ if (!canon || COMMANDS[canon].parse === "meta") {
2084
+ err(`spor: unknown verb '${verb}'. Try 'spor help'.`);
2085
+ return 1;
2086
+ }
2087
+ const entry = COMMANDS[canon];
2088
+
2089
+ // `spor <command> --help|-h` => the command's own page.
2090
+ if (args.includes("--help") || args.includes("-h")) {
2091
+ out(renderCmdHelp(canon));
2092
+ return 0;
2093
+ }
2094
+
2095
+ if (entry.parse === "raw") return await entry.run(cfg, args, verb);
2096
+
2097
+ // strict: util.parseArgs is the parser; a parse failure is a friendly error.
2098
+ let parsed;
2099
+ try {
2100
+ parsed = parseArgs({ args, options: paOptions(entry.options), allowPositionals: true, strict: true });
2101
+ } catch (e) {
2102
+ return parseError(e, entry, canon);
1811
2103
  }
2104
+ return await entry.run(cfg, parsed, verb);
1812
2105
  }
1813
2106
 
1814
2107
  // Expose the pure helpers for unit tests (the version-check logic has no I/O),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporhq/spor",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
4
4
  "description": "Spor — a shared memory substrate for teams and agents. Decisions, their reasons, and the traces they leave. Knowledge-graph context compiler: session-start briefings, per-prompt digests, capture at discovery, end-of-session distillation, decision queue.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Anthony Allen",