@symbols-cli/cli 0.0.1

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.
Files changed (41) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +103 -0
  3. package/dist/auth/client.js +531 -0
  4. package/dist/auth/credentials.js +293 -0
  5. package/dist/auth/hosts.js +85 -0
  6. package/dist/auth/loopback.js +108 -0
  7. package/dist/auth/pkce.js +33 -0
  8. package/dist/auth/wire.js +40 -0
  9. package/dist/commands/arm.js +154 -0
  10. package/dist/commands/curl.js +101 -0
  11. package/dist/commands/doctor.js +217 -0
  12. package/dist/commands/login.js +113 -0
  13. package/dist/commands/logout.js +78 -0
  14. package/dist/commands/mcp.js +33 -0
  15. package/dist/commands/project.js +145 -0
  16. package/dist/commands/status.js +78 -0
  17. package/dist/commands/sync.js +94 -0
  18. package/dist/commands/uninstall.js +149 -0
  19. package/dist/commands/up.js +176 -0
  20. package/dist/commands/update.js +120 -0
  21. package/dist/commands/watch.js +155 -0
  22. package/dist/commands/whoami.js +103 -0
  23. package/dist/index.js +147 -0
  24. package/dist/mcp/scopes.js +215 -0
  25. package/dist/mcp/server.js +366 -0
  26. package/dist/mcp/tools.js +646 -0
  27. package/dist/skills/bundle.js +441 -0
  28. package/dist/skills/claude-md.js +135 -0
  29. package/dist/skills/install.js +188 -0
  30. package/dist/skills/settings-merge.js +107 -0
  31. package/dist/sync/api.js +380 -0
  32. package/dist/sync/diff.js +172 -0
  33. package/dist/sync/ledger.js +319 -0
  34. package/dist/sync/paths.js +447 -0
  35. package/dist/sync/protect.js +108 -0
  36. package/dist/sync/reconcile.js +870 -0
  37. package/dist/sync/watcher.js +206 -0
  38. package/dist/util/log.js +58 -0
  39. package/dist/util/platform.js +79 -0
  40. package/dist/util/version.js +24 -0
  41. package/package.json +44 -0
@@ -0,0 +1,120 @@
1
+ // Copyright (c) 2025 Symbols LLC. All rights reserved.
2
+ //
3
+ // This source code is proprietary and confidential. Unauthorized copying,
4
+ // distribution, modification, or use of this file, via any medium, is strictly prohibited.
5
+ // `symbols update` — refresh the skills bundle, and say whether the CLI itself
6
+ // is behind.
7
+ //
8
+ // # This command does NOT update the CLI binary
9
+ //
10
+ // It would be one `npm i -g` away, and that is exactly why it is not here. The
11
+ // agent runs as the same uid as this process: a self-update path is a
12
+ // `spawn(npm, ["i", "-g", …])` that an injected instruction can aim at any
13
+ // package name it likes, and the CLI is the thing holding the device
14
+ // credential. So the version is REPORTED and the user runs one line.
15
+ //
16
+ // The skills bundle is different, and the difference is the signature. Bundle
17
+ // content is verified against `PINNED_KEYS` compiled into this binary, with a
18
+ // monotonic sequence floor stored outside the agent's reach — so a compromised
19
+ // server cannot push instructions to it, and cannot roll it back to a version
20
+ // whose defects were fixed. That is what makes automatic refresh acceptable
21
+ // here and not for the binary.
22
+ //
23
+ // ⚠ THE FAILURE THIS EXISTS FOR: the repo shipped odin 0.7.7 while laptops ran
24
+ // 0.7.4, because `plugin install` on an already-installed plugin exits 0
25
+ // WITHOUT upgrading. A stale skill is not a cosmetic problem — it teaches the
26
+ // agent a workflow that no longer exists.
27
+ import { installBundle, readInstalledManifest, BundleVerificationError } from "../skills/bundle.js";
28
+ import { installPlugins, ClaudeCliMissingError } from "../skills/install.js";
29
+ import { workspaceRoot } from "../util/platform.js";
30
+ import { load } from "../auth/credentials.js";
31
+ import { print, eprint } from "../util/log.js";
32
+ function usage() {
33
+ eprint(`usage: symbols update [--check]\n\n` +
34
+ `Refreshes the Symbols skills bundle and reports whether the CLI is behind.\n\n` +
35
+ ` --check report only; write nothing\n\n` +
36
+ `The CLI binary is NOT self-updating: run \`npm i -g @symbols-cli/cli\`.\n`);
37
+ return 2;
38
+ }
39
+ /** `1.2.10` > `1.2.9`. Compares numerically, segment by segment. */
40
+ export function isOlder(a, b) {
41
+ const pa = a.split(".").map((n) => Number.parseInt(n, 10) || 0);
42
+ const pb = b.split(".").map((n) => Number.parseInt(n, 10) || 0);
43
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
44
+ const x = pa[i] ?? 0;
45
+ const y = pb[i] ?? 0;
46
+ if (x !== y)
47
+ return x < y;
48
+ }
49
+ return false;
50
+ }
51
+ export async function run(argv) {
52
+ if (argv.includes("-h") || argv.includes("--help"))
53
+ return usage();
54
+ const checkOnly = argv.includes("--check");
55
+ const cred = await load();
56
+ if (!cred) {
57
+ eprint("Not signed in. Run `symbols login` first.\n");
58
+ return 1;
59
+ }
60
+ // ⚠ THE VERSION CHECK IS GONE, NOT DISABLED. It called `/api/cli/version`,
61
+ // which is **not registered on the server** — grep `apps/server/routes` for
62
+ // `cli/version` and you get nothing. So it 404'd on every run, was swallowed
63
+ // by a bare `catch`, and reported nothing while looking like a feature.
64
+ //
65
+ // A check that cannot succeed is worse than no check: it makes the next
66
+ // reader believe staleness is being detected. `isOlder` is kept and tested —
67
+ // it is correct, and it is what this needs the day the route exists.
68
+ // ── the skills bundle ──────────────────────────────────────────────────────
69
+ const before = await readInstalledManifest();
70
+ if (checkOnly) {
71
+ print(before
72
+ ? `Skills bundle: sequence ${before.sequence}, stamp ${before.stamp.slice(0, 12)}\n` +
73
+ `Run \`symbols update\` to fetch the current one.\n`
74
+ : "No skills bundle is installed yet. Run `symbols update`.\n");
75
+ return 0;
76
+ }
77
+ let installed;
78
+ try {
79
+ installed = await installBundle();
80
+ }
81
+ catch (err) {
82
+ if (err instanceof BundleVerificationError) {
83
+ // ⚠ A verification failure is NOT a transient error and must never be
84
+ // retried into submission. Refusing leaves the previous, verified bundle
85
+ // in place, which is the safe state.
86
+ eprint(`Refused the skills bundle: ${err.message}\n\n` +
87
+ `The bundle already on this machine is unchanged and still in use.\n`);
88
+ return 1;
89
+ }
90
+ eprint(`Could not update the skills bundle: ${err.message}\n`);
91
+ return 1;
92
+ }
93
+ if (!installed.changed && before) {
94
+ print(`Skills already current (sequence ${installed.manifest.sequence}).\n`);
95
+ return 0;
96
+ }
97
+ print(`Skills updated to sequence ${installed.manifest.sequence} ` +
98
+ `(${installed.manifest.plugins.length} plugin(s)).\n`);
99
+ // Re-point Claude Code at the refreshed cache. `plugin install` alone is the
100
+ // no-op that caused the 0.7.4/0.7.7 split; `installPlugins` runs the update
101
+ // path, which is why this is not simply "unpack and stop".
102
+ try {
103
+ const results = await installPlugins(workspaceRoot(), installed.manifest);
104
+ for (const r of results) {
105
+ const what = r.updated ? "updated" : r.installed ? "installed" : "unchanged";
106
+ print(` ${r.plugin} ${what} — ${r.detail}\n`);
107
+ }
108
+ }
109
+ catch (err) {
110
+ if (err instanceof ClaudeCliMissingError) {
111
+ eprint(`\nThe bundle is installed, but the \`claude\` CLI was not found, so the\n` +
112
+ `plugins were not registered. Install Claude Code, then run\n` +
113
+ `\`symbols update\` again.\n`);
114
+ return 1;
115
+ }
116
+ throw err;
117
+ }
118
+ print(`\nRun \`symbols doctor\` to confirm.\n`);
119
+ return 0;
120
+ }
@@ -0,0 +1,155 @@
1
+ // Copyright (c) 2025 Symbols LLC. All rights reserved.
2
+ //
3
+ // This source code is proprietary and confidential. Unauthorized copying,
4
+ // distribution, modification, or use of this file, via any medium, is strictly prohibited.
5
+ // `symbols watch` — the long-running sync.
6
+ //
7
+ // ## The ordering this file exists to get right
8
+ //
9
+ // 1. bind the projects (directories, `project.json`, the inode binding)
10
+ // 2. **start the watchers**
11
+ // 3. run the first full sweep
12
+ // 4. sweep again every 60s, and on every watcher batch
13
+ //
14
+ // Steps 2 and 3 are in that order, ported from `start()`
15
+ // (`odin_notebook_writeback.rs:1746-1780`). On the server, spawning the watcher
16
+ // after the materialize left a ~90s window in which a write produced no event
17
+ // and nothing replayed the gap — there is no startup dirwalk to recover it.
18
+ // Locally the first sweep is faster but the window is the same shape, and a file
19
+ // the user saves during it would wait a full 60s for the timer.
20
+ //
21
+ // It is SAFE in that order for exactly the reason the Rust comment gives: our own
22
+ // writes come back as events, hash-equal, and stop at the loop breaker. The cost
23
+ // is that each pulled file pays one extra stat before short-circuiting.
24
+ //
25
+ // ## Why the 60s sweep is not optional
26
+ //
27
+ // Reconcile is the guarantee. The watcher misses things by construction — the
28
+ // server side has no event stream to us at all (REST `create/update/delete_file`
29
+ // emit no `fs_event`; the plan's "known gaps" table says so), so **a web tile
30
+ // edit is invisible until the next sweep**. Anything the CLI learns about the
31
+ // server, it learns by asking.
32
+ import { Ledger } from "../sync/ledger.js";
33
+ import { ensureProjects, syncProject } from "../sync/reconcile.js";
34
+ import { listProjects, RateLimitedError } from "../sync/api.js";
35
+ import { Watcher, expandDirty } from "../sync/watcher.js";
36
+ import { workspaceRoot } from "../util/platform.js";
37
+ import { renderResult } from "./sync.js";
38
+ import { eprint, print } from "../util/log.js";
39
+ const SWEEP_MS = 60_000;
40
+ /** After a 429, wait this long before the next sweep. */
41
+ const BACKOFF_MS = 60_000;
42
+ export async function run(argv) {
43
+ const confirmDeletes = argv.includes("--confirm-deletes");
44
+ const once = argv.includes("--once");
45
+ const intervalArg = argv.find((a) => a.startsWith("--interval="));
46
+ const sweepMs = intervalArg ? Number(intervalArg.slice("--interval=".length)) * 1000 : SWEEP_MS;
47
+ const ledger = await Ledger.open();
48
+ const watchers = [];
49
+ let stopping = false;
50
+ const projects = await listProjects();
51
+ const ensured = await ensureProjects(ledger, projects, workspaceRoot());
52
+ const live = [];
53
+ for (const e of ensured) {
54
+ if (e.dirnameDrift)
55
+ eprint(`symbols: ${e.name}: ${e.dirnameDrift}\n`);
56
+ if (e.problem || !e.project) {
57
+ eprint(`symbols: ${e.name}: ${e.problem ?? "could not be bound"}\n`);
58
+ continue;
59
+ }
60
+ live.push(e.project);
61
+ }
62
+ // One in-flight sweep at a time, per project. Two concurrent sweeps of one
63
+ // project would each read a snapshot, then each act on it — the second acting
64
+ // on state the first has already changed. Serialising is not a performance
65
+ // choice; overlapping sweeps re-introduce exactly the mid-flight class
66
+ // `baseAfter` exists to catch, one level up.
67
+ const busy = new Set();
68
+ const pendingDirty = new Map();
69
+ const sweep = async (project, only) => {
70
+ if (stopping)
71
+ return;
72
+ if (busy.has(project.id)) {
73
+ // Remember the hint rather than dropping it — the running sweep may have
74
+ // already read the file it names.
75
+ const set = pendingDirty.get(project.id) ?? new Set();
76
+ for (const p of only ?? [])
77
+ set.add(p);
78
+ pendingDirty.set(project.id, set);
79
+ return;
80
+ }
81
+ busy.add(project.id);
82
+ try {
83
+ const fresh = ledger.project(project.notebookId) ?? project;
84
+ const res = await syncProject({
85
+ ledger,
86
+ project: fresh,
87
+ confirmDeletes,
88
+ ...(only ? { only } : {}),
89
+ log: (l) => print(`${l}\n`),
90
+ });
91
+ const moved = res.pulled + res.pushed + res.localDeletes + res.serverDeletes + res.conflicts;
92
+ if (moved > 0 || res.frozen.length > 0 || res.offline) {
93
+ print(`${renderResult(res)}\n`);
94
+ }
95
+ }
96
+ catch (err) {
97
+ if (err instanceof RateLimitedError) {
98
+ eprint(`symbols: ${err.message}; pausing ${BACKOFF_MS / 1000}s\n`);
99
+ await new Promise((r) => setTimeout(r, BACKOFF_MS));
100
+ }
101
+ else {
102
+ eprint(`symbols: sweep failed for ${project.name}: ${err.message}\n`);
103
+ }
104
+ }
105
+ finally {
106
+ busy.delete(project.id);
107
+ const queued = pendingDirty.get(project.id);
108
+ if (queued && queued.size > 0 && !stopping) {
109
+ pendingDirty.delete(project.id);
110
+ void sweep(project, [...queued]);
111
+ }
112
+ }
113
+ };
114
+ // ── 2. WATCHERS FIRST ──────────────────────────────────────────────────────
115
+ for (const project of live) {
116
+ const w = new Watcher({
117
+ root: project.root,
118
+ onDirty: (batch) => {
119
+ const tracked = ledger.files(project.id).map((f) => f.path);
120
+ void sweep(project, expandDirty(batch, tracked));
121
+ },
122
+ });
123
+ await w.start();
124
+ watchers.push(w);
125
+ }
126
+ print(`watching ${live.length} project(s) under ${workspaceRoot()}\n`);
127
+ // ── 3. …then the first full sweep ──────────────────────────────────────────
128
+ for (const project of live)
129
+ await sweep(project);
130
+ if (once) {
131
+ for (const w of watchers)
132
+ await w.stop();
133
+ ledger.close();
134
+ return 0;
135
+ }
136
+ // ── 4. the guarantee ───────────────────────────────────────────────────────
137
+ const timer = setInterval(() => {
138
+ for (const project of live)
139
+ void sweep(project);
140
+ }, sweepMs);
141
+ await new Promise((resolve) => {
142
+ const shutdown = () => {
143
+ stopping = true;
144
+ clearInterval(timer);
145
+ resolve();
146
+ };
147
+ process.on("SIGINT", shutdown);
148
+ process.on("SIGTERM", shutdown);
149
+ });
150
+ print("\nstopping…\n");
151
+ for (const w of watchers)
152
+ await w.stop();
153
+ ledger.close();
154
+ return 0;
155
+ }
@@ -0,0 +1,103 @@
1
+ // Copyright (c) 2025 Symbols LLC. All rights reserved.
2
+ //
3
+ // This source code is proprietary and confidential. Unauthorized copying,
4
+ // distribution, modification, or use of this file, via any medium, is strictly prohibited.
5
+ // `symbols whoami` — who this device is signed in as, and what it may do.
6
+ //
7
+ // Two properties this command must have:
8
+ //
9
+ // * It REPORTS THE STORE, never assumes it. `credentials.ts` falls back from
10
+ // keychain to file when the keychain is locked or absent, and a user told
11
+ // "your token is in the keychain" when it is on disk has been misinformed
12
+ // about their own exposure.
13
+ // * It PRINTS SCOPES. The whole safety argument for putting a credential on a
14
+ // laptop is that it carries no money scope; that claim should be checkable by
15
+ // the person holding it, not just by a test in the server repo.
16
+ //
17
+ // It never prints the token, in any form — not truncated, not hashed. A
18
+ // "harmless" prefix is exactly what ends up pasted into a bug report.
19
+ import { load, storeKind, credentialsPath } from "../auth/credentials.js";
20
+ import { request, NotLoggedInError } from "../auth/client.js";
21
+ import { apiOrigin } from "../auth/hosts.js";
22
+ import { CLI_VERSION } from "../util/version.js";
23
+ import { eprint, print } from "../util/log.js";
24
+ export async function run(argv) {
25
+ const asJson = argv.includes("--json");
26
+ const cred = await load();
27
+ if (!cred) {
28
+ if (asJson) {
29
+ print(`${JSON.stringify({ signed_in: false })}\n`);
30
+ }
31
+ else {
32
+ eprint("Not signed in. Run `symbols login`.\n");
33
+ }
34
+ return 1;
35
+ }
36
+ // Ask the server rather than trusting the local file: the device may have been
37
+ // revoked from another machine, and the local copy would not know.
38
+ let session = null;
39
+ let error = null;
40
+ try {
41
+ ({ body: session } = await request("/api/auth/cli/session"));
42
+ }
43
+ catch (err) {
44
+ if (err instanceof NotLoggedInError)
45
+ throw err;
46
+ error = err instanceof Error ? err.message : String(err);
47
+ }
48
+ const store = storeKind();
49
+ const storeDesc = store === "keychain" ? "macOS keychain" : `${credentialsPath()} (0600)`;
50
+ if (asJson) {
51
+ print(`${JSON.stringify({
52
+ signed_in: true,
53
+ user_id: session?.user_id ?? cred.userId,
54
+ email: session?.email ?? cred.email ?? null,
55
+ device_id: cred.deviceId,
56
+ origin: cred.origin,
57
+ credential_store: store,
58
+ cli_version: CLI_VERSION,
59
+ scopes: session?.scopes ?? null,
60
+ server_reachable: session !== null,
61
+ error,
62
+ }, null, 2)}\n`);
63
+ return session ? 0 : 1;
64
+ }
65
+ const lines = [
66
+ `Signed in as ${session?.email ?? cred.email ?? cred.userId}`,
67
+ `User ${session?.user_id ?? cred.userId}`,
68
+ `Device ${session?.device_label ?? cred.deviceId}`,
69
+ `API ${apiOrigin()}`,
70
+ `Credential ${storeDesc}`,
71
+ `CLI ${CLI_VERSION}`,
72
+ ];
73
+ if (session) {
74
+ lines.push(`Access expires in ${Math.round(session.expires_in / 60)} min`);
75
+ lines.push("");
76
+ // ⚠ "CANNOT place orders" is true and INCOMPLETE, which is the dangerous
77
+ // shape for a safety claim. This credential CAN permanently delete a
78
+ // notebook — `DELETE /api/notebooks/{id}` is in its scope list on purpose,
79
+ // because `symbols project rm` needs it. A P6 agent wrote "the device
80
+ // credential is read-only, so a DELETE is refused" into a skill, verified
81
+ // it, and found it false. Printing the destructive scopes separately means
82
+ // nobody has to take anyone's word for it.
83
+ const destructive = session.scopes.filter((s) => s.startsWith("DELETE "));
84
+ const rest = session.scopes.filter((s) => !s.startsWith("DELETE "));
85
+ lines.push(`Scopes (${session.scopes.length}) — this credential CANNOT place orders.`);
86
+ if (destructive.length > 0) {
87
+ lines.push("");
88
+ lines.push(` ⚠ It CAN permanently delete (${destructive.length}):`);
89
+ for (const s of destructive)
90
+ lines.push(` ${s}`);
91
+ }
92
+ lines.push("");
93
+ for (const s of rest)
94
+ lines.push(` ${s}`);
95
+ }
96
+ else {
97
+ lines.push("");
98
+ lines.push(`⚠ Could not reach the server: ${error}`);
99
+ lines.push(" The details above are from the local credential and may be stale.");
100
+ }
101
+ print(`${lines.join("\n")}\n`);
102
+ return session ? 0 : 1;
103
+ }
package/dist/index.js ADDED
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2025 Symbols LLC. All rights reserved.
3
+ //
4
+ // This source code is proprietary and confidential. Unauthorized copying,
5
+ // distribution, modification, or use of this file, via any medium, is strictly prohibited.
6
+ // Symbols CLI — entry point and the ONLY place commands are registered.
7
+ //
8
+ // ⚠ APPEND-ONLY REGISTRY. Four phases (P0/P2/P3/P4) add commands to this file in
9
+ // parallel worktrees. Each command lives in its own `commands/<name>.ts`; this
10
+ // file only maps a name to a lazy import. Keeping the bodies out means a merge
11
+ // here is a one-line addition, not a conflict — which is what makes the parallel
12
+ // phases actually parallel rather than nominally so.
13
+ //
14
+ // Add a row. Do not restructure.
15
+ import { eprint, print } from "./util/log.js";
16
+ import { CLI_VERSION } from "./util/version.js";
17
+ const COMMANDS = {
18
+ // ── P0 ─────────────────────────────────────────────────────────────────────
19
+ login: {
20
+ summary: "Sign in via your browser and store a device credential",
21
+ phase: "P0",
22
+ load: () => import("./commands/login.js"),
23
+ },
24
+ logout: {
25
+ summary: "Revoke this device's credential",
26
+ phase: "P0",
27
+ load: () => import("./commands/logout.js"),
28
+ },
29
+ whoami: {
30
+ summary: "Show who this device is signed in as",
31
+ phase: "P0",
32
+ load: () => import("./commands/whoami.js"),
33
+ },
34
+ curl: {
35
+ summary: "Call the Symbols API by path (never a full URL)",
36
+ phase: "P0",
37
+ load: () => import("./commands/curl.js"),
38
+ },
39
+ // ── P2 ─────────────────────────────────────────────────────────────────────
40
+ doctor: {
41
+ summary: "Verify the Symbols skills install (machine-readable; non-zero on any problem)",
42
+ phase: "P2",
43
+ load: () => import("./commands/doctor.js"),
44
+ },
45
+ // ── P3 ─────────────────────────────────────────────────────────────────────
46
+ mcp: {
47
+ summary: "Run the Symbols MCP server on stdio (launched by Claude Code)",
48
+ phase: "P3",
49
+ load: () => import("./commands/mcp.js"),
50
+ },
51
+ // ── P5 ─────────────────────────────────────────────────────────────────────
52
+ arm: {
53
+ summary: "Open a bounded window in which the agent may spend money (browser consent)",
54
+ phase: "P5",
55
+ load: () => import("./commands/arm.js"),
56
+ },
57
+ // ── P4 appends below. One row each; bodies in commands/. ───────────────────
58
+ up: {
59
+ summary: "Materialise your notebooks as directories under ~/Symbols and sync them",
60
+ phase: "P4b",
61
+ load: () => import("./commands/up.js"),
62
+ },
63
+ sync: {
64
+ summary: "Reconcile every project once (--confirm-deletes, --dry-run, --project=NAME)",
65
+ phase: "P4b",
66
+ load: () => import("./commands/sync.js"),
67
+ },
68
+ watch: {
69
+ summary: "Keep projects in sync: filesystem events plus a 60s reconcile sweep",
70
+ phase: "P4b",
71
+ load: () => import("./commands/watch.js"),
72
+ },
73
+ status: {
74
+ summary: "Show what sync is holding and why (--json)",
75
+ phase: "P4b",
76
+ load: () => import("./commands/status.js"),
77
+ },
78
+ project: {
79
+ summary: "Manage projects: ls | new <name> | rm <name> | detach <name>",
80
+ phase: "P4b",
81
+ load: () => import("./commands/project.js"),
82
+ },
83
+ update: {
84
+ summary: "Refresh the skills bundle; report if the CLI is behind",
85
+ phase: "P2",
86
+ load: () => import("./commands/update.js"),
87
+ },
88
+ uninstall: {
89
+ summary: "Remove skills, ledger and credential (never your projects)",
90
+ phase: "P2",
91
+ load: () => import("./commands/uninstall.js"),
92
+ },
93
+ };
94
+ /**
95
+ * @param asked true when the user typed `--help`; false when we are correcting
96
+ * a mistake (no command, unknown command).
97
+ *
98
+ * ⚠ THE EXIT CODE IS PART OF THE INTERFACE. Both cases used to return 2, so
99
+ * `symbols --help` — a request that SUCCEEDED — reported failure, which breaks
100
+ * `symbols --help && echo ok` and any CI step that checks the binary runs.
101
+ * Asked-for help goes to stdout and exits 0; a usage error stays on stderr at 2.
102
+ */
103
+ function usage(asked = false) {
104
+ const width = Math.max(...Object.keys(COMMANDS).map((k) => k.length));
105
+ const lines = Object.entries(COMMANDS)
106
+ .map(([name, c]) => ` ${name.padEnd(width)} ${c.summary}`)
107
+ .join("\n");
108
+ const text = `symbols — run the Symbols agent on your own machine\n\nusage: symbols <command> [args]\n\n${lines}\n`;
109
+ if (asked) {
110
+ print(text);
111
+ return 0;
112
+ }
113
+ eprint(text);
114
+ return 2;
115
+ }
116
+ async function main() {
117
+ const [name, ...rest] = process.argv.slice(2);
118
+ if (name === "-h" || name === "--help")
119
+ return usage(true);
120
+ if (name === "-v" || name === "--version") {
121
+ print(`${CLI_VERSION}\n`);
122
+ return 0;
123
+ }
124
+ if (!name)
125
+ return usage();
126
+ const command = COMMANDS[name];
127
+ if (!command) {
128
+ eprint(`symbols: unknown command '${name}'\n`);
129
+ return usage();
130
+ }
131
+ const mod = await command.load();
132
+ const code = await mod.run(rest);
133
+ return typeof code === "number" ? code : 0;
134
+ }
135
+ main()
136
+ .then((code) => process.exit(code))
137
+ .catch((err) => {
138
+ // One place that decides how a failure looks. Never print a stack to a user
139
+ // unless they asked — and never print a credential, which is why this
140
+ // formats the message rather than dumping the error object.
141
+ const msg = err instanceof Error ? err.message : String(err);
142
+ eprint(`symbols: ${msg}\n`);
143
+ if (process.env["SYMBOLS_DEBUG"] === "1" && err instanceof Error && err.stack) {
144
+ eprint(`${err.stack}\n`);
145
+ }
146
+ process.exit(1);
147
+ });