@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,33 @@
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 mcp` — run the Symbols MCP server on stdio.
6
+ //
7
+ // Not invoked by hand. Claude Code launches it from a plugin-shipped `.mcp.json`:
8
+ //
9
+ // { "mcpServers": { "symbols": { "command": "symbols", "args": ["mcp"] } } }
10
+ //
11
+ // The P0 spike confirmed a plugin may ship its own `.mcp.json` (verified live —
12
+ // the vercel and supabase plugins do), so nothing here needs to write
13
+ // `~/.claude.json`. Tools then appear to the agent as
14
+ // `mcp__plugin_<plugin>_<serverKey>__<tool>` — e.g. `mcp__plugin_odin_symbols__get_quote`.
15
+ //
16
+ // ⚠ This process owns stdout for JSON-RPC. Anything printed there breaks the
17
+ // protocol, so every message this command emits goes to stderr.
18
+ import { serve } from "../mcp/server.js";
19
+ import { eprint } from "../util/log.js";
20
+ export async function run(argv) {
21
+ if (argv.includes("-h") || argv.includes("--help")) {
22
+ eprint("usage: symbols mcp\n\n" +
23
+ "Runs the Symbols MCP server on stdio. Launched by Claude Code via the\n" +
24
+ "plugin's .mcp.json — you do not normally run this yourself.\n");
25
+ return 2;
26
+ }
27
+ if (argv.length > 0) {
28
+ eprint(`symbols mcp: unexpected argument '${argv[0]}'\n`);
29
+ return 2;
30
+ }
31
+ await serve();
32
+ return 0;
33
+ }
@@ -0,0 +1,145 @@
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 project` — new · ls · rm · detach.
6
+ //
7
+ // The replacement for the container's `nb` helper, and the semantics DIFFER in a
8
+ // way that matters: `notebook-workspace/SKILL.md` currently teaches that the
9
+ // project folders are SYMLINKS and that `rm -rf` is silently undone by the next
10
+ // materialize. On a laptop both are false. Projects are real directories and
11
+ // `rm -rf` is genuinely destructive, so the confirm-before-delete rule gets
12
+ // STRONGER here, not weaker.
13
+ //
14
+ // ## `rm` and `detach` are different operations and must never be confused
15
+ //
16
+ // `rm` deletes the NOTEBOOK, server-side, for every device. Irreversible.
17
+ // `detach` stops syncing this directory. The notebook and the files both
18
+ // survive; only this machine forgets the binding.
19
+ //
20
+ // A user who means the second and gets the first has lost work in a way no
21
+ // amount of local backup helps. So `rm` requires the project's name typed back,
22
+ // and `detach` never touches the server.
23
+ import { promises as fs } from "node:fs";
24
+ import { createInterface } from "node:readline/promises";
25
+ import { Ledger } from "../sync/ledger.js";
26
+ import { ensureProjects, projectJsonPath, rootGate } from "../sync/reconcile.js";
27
+ import { listProjects, createNotebook, deleteNotebook } from "../sync/api.js";
28
+ import { workspaceRoot } from "../util/platform.js";
29
+ import { eprint, print } from "../util/log.js";
30
+ async function confirm(question, expect) {
31
+ if (!process.stdin.isTTY) {
32
+ eprint(`refusing without a terminal to confirm on. Re-run interactively, or pass --yes if you are certain.\n`);
33
+ return false;
34
+ }
35
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
36
+ try {
37
+ const answer = await rl.question(question);
38
+ return answer.trim() === expect;
39
+ }
40
+ finally {
41
+ rl.close();
42
+ }
43
+ }
44
+ async function cmdLs(ledger) {
45
+ const server = await listProjects();
46
+ const bound = new Map(ledger.projects().map((p) => [p.notebookId, p]));
47
+ if (server.length === 0) {
48
+ print("no projects on the server\n");
49
+ return 0;
50
+ }
51
+ for (const p of server) {
52
+ const local = bound.get(p.id);
53
+ const where = local ? local.root : `(not synced — run \`symbols up\`)`;
54
+ const problem = local ? await rootGate(local) : null;
55
+ const flag = local?.frozenReason ? "FROZEN " : problem ? "OFFLINE" : local ? "ok " : " ";
56
+ print(`${flag} ${p.name}\n ${where}\n`);
57
+ }
58
+ return 0;
59
+ }
60
+ async function cmdNew(ledger, name) {
61
+ if (!name) {
62
+ eprint("usage: symbols project new <name>\n");
63
+ return 2;
64
+ }
65
+ const created = await createNotebook(name);
66
+ print(`created notebook ${created.id}\n`);
67
+ // Re-materialize so the directory, `project.json` and the ledger binding all
68
+ // exist before the user's next command — the server computes the dirname, and
69
+ // the dedupe is positional, so the only correct answer comes from a fresh list.
70
+ const ensured = await ensureProjects(ledger, await listProjects(), workspaceRoot());
71
+ const mine = ensured.find((e) => e.notebookId === created.id);
72
+ if (mine?.project)
73
+ print(`${mine.project.root}\n`);
74
+ return 0;
75
+ }
76
+ async function cmdDetach(ledger, name) {
77
+ const target = ledger
78
+ .projects()
79
+ .find((p) => p.name === name || p.dirname === name || p.notebookId === name);
80
+ if (!target) {
81
+ eprint(`no synced project named '${name}'\n`);
82
+ return 1;
83
+ }
84
+ // The ledger has no "forget" verb by design — dropping rows is how a base is
85
+ // lost, and a lost base re-conflicts every path. Freezing achieves the goal
86
+ // (nothing is synced) without discarding what we knew.
87
+ ledger.freezeProject(target.id, `detached by \`symbols project detach\` — no file is synced until you run \`symbols up\` here again`);
88
+ // The identity anchor goes, so a later `up` in this directory does not silently
89
+ // re-adopt it. The files stay: detach is not a delete.
90
+ await fs.rm(projectJsonPath(target.root), { force: true });
91
+ print(`detached ${target.name} — ${target.root} is no longer synced\n`);
92
+ print(`your files were NOT touched.\n`);
93
+ return 0;
94
+ }
95
+ async function cmdRm(ledger, name, yes) {
96
+ const server = await listProjects();
97
+ const target = server.find((p) => p.name === name || p.dirname === name || p.id === name);
98
+ if (!target) {
99
+ eprint(`no project named '${name}'\n`);
100
+ return 1;
101
+ }
102
+ eprint(`\n⚠ This DELETES the notebook '${target.name}' and every file in it, for every device.\n` +
103
+ ` It is not undoable. To stop syncing this machine only, use \`symbols project detach\`.\n\n`);
104
+ if (!yes && !(await confirm(`Type the project name to confirm: `, target.name))) {
105
+ eprint("aborted — nothing was deleted\n");
106
+ return 1;
107
+ }
108
+ await deleteNotebook(target.id);
109
+ const local = ledger.project(target.id);
110
+ if (local) {
111
+ ledger.freezeProject(local.id, `the notebook was deleted with \`symbols project rm\``);
112
+ // The local directory is NOT removed. The server row is gone and the files
113
+ // here are now the only copy — deleting them on the strength of a successful
114
+ // API call would turn one confirmed action into two, and the second one was
115
+ // never confirmed.
116
+ print(`the local directory ${local.root} was kept — delete it yourself if you meant to.\n`);
117
+ }
118
+ print(`deleted ${target.name}\n`);
119
+ return 0;
120
+ }
121
+ export async function run(argv) {
122
+ const [sub, ...rest] = argv;
123
+ const yes = rest.includes("--yes");
124
+ const name = rest.filter((a) => !a.startsWith("--")).join(" ");
125
+ const ledger = await Ledger.open();
126
+ try {
127
+ switch (sub) {
128
+ case "ls":
129
+ case undefined:
130
+ return await cmdLs(ledger);
131
+ case "new":
132
+ return await cmdNew(ledger, name);
133
+ case "detach":
134
+ return await cmdDetach(ledger, name);
135
+ case "rm":
136
+ return await cmdRm(ledger, name, yes);
137
+ default:
138
+ eprint(`usage: symbols project [ls|new <name>|rm <name>|detach <name>]\n`);
139
+ return 2;
140
+ }
141
+ }
142
+ finally {
143
+ ledger.close();
144
+ }
145
+ }
@@ -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 status` — what sync is holding, and why.
6
+ //
7
+ // ## Frozen is a FIRST-CLASS state, not an error to be swept up
8
+ //
9
+ // The three-way table's answer to every ambiguous case is "freeze and report",
10
+ // which is only a safe design if the report is somewhere the user actually
11
+ // looks. A conflict that keeps the local file and writes a sidecar has lost
12
+ // nothing — but if nobody ever learns the path stopped syncing, the server copy
13
+ // diverges silently for weeks and the eventual merge is a research project.
14
+ //
15
+ // So this command reads the LEDGER, not the network: it works offline, it is
16
+ // instant, and it never itself changes anything. `--json` exists because
17
+ // `symbols doctor` and the conformance harness parse it, and a human-formatted
18
+ // table parsed with a regex is the substring-grep failure in a different hat.
19
+ import { Ledger } from "../sync/ledger.js";
20
+ import { rootGate } from "../sync/reconcile.js";
21
+ import { print } from "../util/log.js";
22
+ export async function run(argv) {
23
+ const json = argv.includes("--json");
24
+ const ledger = await Ledger.open();
25
+ try {
26
+ const out = [];
27
+ for (const p of ledger.projects()) {
28
+ const problem = await rootGate(p);
29
+ const frozenFiles = ledger.frozen(p.id);
30
+ out.push({
31
+ name: p.name,
32
+ notebook_id: p.notebookId,
33
+ root: p.root,
34
+ present: problem === null,
35
+ // The project-level freeze and the "is it there right now" check are
36
+ // separate facts: a project can be frozen for an ambiguity that has
37
+ // nothing to do with the directory being absent.
38
+ frozen_reason: p.frozenReason ?? problem,
39
+ tracked: ledger.files(p.id).length,
40
+ frozen_paths: frozenFiles.map((f) => ({
41
+ path: f.path,
42
+ reason: f.frozenReason ?? "frozen",
43
+ })),
44
+ queued: ledger.pending(p.id).length,
45
+ });
46
+ }
47
+ if (json) {
48
+ print(`${JSON.stringify({ projects: out }, null, 2)}\n`);
49
+ }
50
+ else if (out.length === 0) {
51
+ print("no projects are being synced — run `symbols up`\n");
52
+ }
53
+ else {
54
+ for (const p of out) {
55
+ const flag = p.frozen_reason ? "FROZEN" : p.present ? "ok" : "OFFLINE";
56
+ print(`${flag} ${p.name}\n`);
57
+ print(` ${p.root}\n`);
58
+ print(` ${p.tracked} tracked file(s)`);
59
+ if (p.queued > 0)
60
+ print(`, ${p.queued} queued`);
61
+ print("\n");
62
+ if (p.frozen_reason)
63
+ print(` ⚠ ${p.frozen_reason}\n`);
64
+ for (const f of p.frozen_paths) {
65
+ print(` ! ${f.path}\n ${f.reason}\n`);
66
+ }
67
+ print("\n");
68
+ }
69
+ }
70
+ // Non-zero when anything is frozen. `status` is the surface a script polls,
71
+ // and "everything is fine" must be a code, not a paragraph.
72
+ const bad = out.filter((p) => p.frozen_reason || p.frozen_paths.length > 0).length;
73
+ return bad > 0 ? 1 : 0;
74
+ }
75
+ finally {
76
+ ledger.close();
77
+ }
78
+ }
@@ -0,0 +1,94 @@
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 sync` — one full sweep, then exit.
6
+ //
7
+ // The same engine `watch` runs on a timer; there is deliberately no second code
8
+ // path. A "quick sync" that skipped a guard would be the drift the whole design
9
+ // is built to prevent.
10
+ import { Ledger } from "../sync/ledger.js";
11
+ import { ensureProjects, syncProject } from "../sync/reconcile.js";
12
+ import { listProjects } from "../sync/api.js";
13
+ import { workspaceRoot } from "../util/platform.js";
14
+ import { eprint, print } from "../util/log.js";
15
+ export function renderResult(r) {
16
+ const lines = [];
17
+ if (r.offline) {
18
+ lines.push(` ${r.project}: OFFLINE — ${r.offlineReason}`);
19
+ return lines.join("\n");
20
+ }
21
+ const moved = r.pulled + r.pushed + r.localDeletes + r.serverDeletes + r.conflicts + r.frozen.length;
22
+ const parts = [];
23
+ if (r.pulled)
24
+ parts.push(`${r.pulled} pulled`);
25
+ if (r.pushed)
26
+ parts.push(`${r.pushed} pushed`);
27
+ if (r.localDeletes)
28
+ parts.push(`${r.localDeletes} deleted locally`);
29
+ if (r.serverDeletes)
30
+ parts.push(`${r.serverDeletes} deleted on the server`);
31
+ if (r.conflicts)
32
+ parts.push(`${r.conflicts} CONFLICT`);
33
+ if (r.frozen.length)
34
+ parts.push(`${r.frozen.length} frozen`);
35
+ lines.push(` ${r.project}: ${moved === 0 ? "up to date" : parts.join(", ")}`);
36
+ if (r.deletesBlocked) {
37
+ lines.push(` ⚠ ${r.deletesBlocked}`);
38
+ }
39
+ for (const f of r.frozen) {
40
+ lines.push(` ! ${f.path} — ${f.reason}`);
41
+ }
42
+ return lines.join("\n");
43
+ }
44
+ export async function run(argv) {
45
+ const confirmDeletes = argv.includes("--confirm-deletes");
46
+ const dryRun = argv.includes("--dry-run") || argv.includes("-n");
47
+ const only = argv.find((a) => a.startsWith("--project="))?.slice("--project=".length);
48
+ const ledger = await Ledger.open();
49
+ try {
50
+ // The server list is the authority for which notebooks exist and what each
51
+ // one's directory is called. A failure here is NOT a reason to sweep the
52
+ // ledger's cached projects anyway: if we cannot list, we cannot tell a
53
+ // deleted notebook from an unreachable server, and the second one must never
54
+ // produce deletes.
55
+ const projects = await listProjects();
56
+ const ensured = await ensureProjects(ledger, projects, workspaceRoot());
57
+ const results = [];
58
+ let problems = 0;
59
+ for (const e of ensured) {
60
+ if (e.dirnameDrift) {
61
+ eprint(`symbols: ${e.name}: ${e.dirnameDrift}\n`);
62
+ }
63
+ if (e.problem || !e.project) {
64
+ eprint(`symbols: ${e.name}: ${e.problem ?? "could not be bound"}\n`);
65
+ problems += 1;
66
+ continue;
67
+ }
68
+ if (only && e.project.name !== only && e.project.dirname !== only)
69
+ continue;
70
+ results.push(await syncProject({
71
+ ledger,
72
+ project: e.project,
73
+ confirmDeletes,
74
+ ...(dryRun ? { dryRun: true } : {}),
75
+ log: (l) => print(`${l}\n`),
76
+ }));
77
+ }
78
+ print(`${dryRun ? "would sync" : "synced"} ${results.length} project(s)\n`);
79
+ for (const r of results)
80
+ print(`${renderResult(r)}\n`);
81
+ // ⚠ The exit code is a contract, and a frozen path is NOT success. A sync
82
+ // that silently returns 0 while three files are frozen trains the user (and
83
+ // any script) to stop reading the output, which is how the one conflict that
84
+ // mattered goes unnoticed.
85
+ const frozen = results.reduce((n, r) => n + r.frozen.length, 0);
86
+ const offline = results.filter((r) => r.offline).length;
87
+ if (problems > 0 || frozen > 0 || offline > 0)
88
+ return 1;
89
+ return 0;
90
+ }
91
+ finally {
92
+ ledger.close();
93
+ }
94
+ }
@@ -0,0 +1,149 @@
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 uninstall` — remove what this CLI installed, and NOTHING ELSE.
6
+ //
7
+ // # ⚠ THE ONE RULE: THIS COMMAND NEVER DELETES A PROJECT
8
+ //
9
+ // `~/Symbols/<Project>/` holds the user's work. Some of it is synced and some
10
+ // of it — anything over 1 MB, anything non-UTF-8, every plot and CSV and
11
+ // parquet the agent produced — EXISTS NOWHERE ELSE. The server-side notebook is
12
+ // not a backup of it.
13
+ //
14
+ // So an uninstall that "tidies up the workspace" is unrecoverable data loss
15
+ // dressed as housekeeping, and no confirmation prompt makes that acceptable:
16
+ // the person typing `uninstall` is thinking about the tool, not about the four
17
+ // months of notebooks under it. This command therefore has no flag that deletes
18
+ // projects. Not `--all`, not `--purge`. If the user wants the directory gone
19
+ // they can delete it themselves, having seen it.
20
+ //
21
+ // What it DOES remove, all of it re-creatable:
22
+ // ~/.symbols/plugins/ the skills bundle (re-fetch: symbols update)
23
+ // ~/.symbols/manifest.json (same)
24
+ // ~/.symbols/state.db the sync ledger (rebuilt on next sync)
25
+ // the credential keychain or 0600 file (re-created: symbols login)
26
+ //
27
+ // The ledger is worth a note: dropping it is safe precisely BECAUSE the sync is
28
+ // three-way. With no recorded base every path re-diffs as "no common ancestor",
29
+ // which the table resolves to CONFLICT — keep local, write the server copy
30
+ // beside it, freeze. Noisy, never destructive. A last-writer-wins design could
31
+ // not offer this command at all.
32
+ import { promises as fs } from "node:fs";
33
+ import { join } from "node:path";
34
+ import { load, clear, credentialsPath, storeKind } from "../auth/credentials.js";
35
+ import { requestAnonymous, forgetAccessToken } from "../auth/client.js";
36
+ import { symbolsHome, workspaceRoot, bundleRoot, installedManifestPath } from "../util/platform.js";
37
+ import { print, eprint } from "../util/log.js";
38
+ function usage() {
39
+ eprint(`usage: symbols uninstall [--keep-credential] [--yes]\n\n` +
40
+ `Removes the skills bundle, the sync ledger and this device's credential.\n\n` +
41
+ ` --keep-credential leave the login in place (skills + ledger only)\n` +
42
+ ` --yes do not prompt\n\n` +
43
+ `⚠ Your projects under ~/Symbols are NEVER touched. Delete them yourself if\n` +
44
+ ` you want them gone — some files there exist nowhere else.\n\n` +
45
+ `This does not remove the npm package: npm rm -g @symbols-cli/cli\n`);
46
+ return 2;
47
+ }
48
+ async function rm(path) {
49
+ try {
50
+ await fs.rm(path, { recursive: true, force: true });
51
+ return true;
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ }
57
+ /** Prompt on a TTY. Anywhere else, refuse rather than assume consent. */
58
+ async function confirm(question) {
59
+ if (!process.stdin.isTTY)
60
+ return false;
61
+ process.stderr.write(question);
62
+ const { createInterface } = await import("node:readline/promises");
63
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
64
+ try {
65
+ const answer = (await rl.question("")).trim().toLowerCase();
66
+ return answer === "y" || answer === "yes";
67
+ }
68
+ finally {
69
+ rl.close();
70
+ }
71
+ }
72
+ export async function run(argv) {
73
+ if (argv.includes("-h") || argv.includes("--help"))
74
+ return usage();
75
+ const keepCredential = argv.includes("--keep-credential");
76
+ const assumeYes = argv.includes("--yes");
77
+ const home = symbolsHome();
78
+ const ledger = join(home, "state.db");
79
+ // Say exactly what will go, and exactly what will not, BEFORE asking.
80
+ print(`This will remove:\n` +
81
+ ` ${bundleRoot()} (skills bundle — re-fetch with \`symbols update\`)\n` +
82
+ ` ${installedManifestPath()}\n` +
83
+ ` ${ledger} (sync ledger — rebuilt on the next sync)\n` +
84
+ (keepCredential
85
+ ? ``
86
+ : ` this device's credential (${storeKind() === "keychain" ? "keychain" : credentialsPath()})\n`) +
87
+ `\nIt will NOT touch your projects in ${workspaceRoot()}.\n` +
88
+ `Some files there — anything over 1 MB, anything not UTF-8 — exist nowhere else.\n\n`);
89
+ if (!assumeYes && !(await confirm("Continue? [y/N] "))) {
90
+ // A non-TTY lands here too: a piped or CI invocation must pass --yes
91
+ // explicitly rather than have consent inferred from an absent terminal.
92
+ eprint("\nNothing was removed. Pass --yes to skip this prompt.\n");
93
+ return 1;
94
+ }
95
+ // ── the credential goes FIRST, and server-side before local ────────────────
96
+ //
97
+ // Same ordering as `logout`, for the same reason: clearing locally first and
98
+ // then failing to reach the server leaves a live credential on the account
99
+ // with nothing on this machine able to revoke it. The user believes the
100
+ // laptop is disconnected; it is not.
101
+ if (!keepCredential) {
102
+ const cred = await load();
103
+ if (cred) {
104
+ try {
105
+ await requestAnonymous("/api/auth/cli/logout", {
106
+ method: "POST",
107
+ body: JSON.stringify({ refresh_token: cred.refreshToken }),
108
+ });
109
+ print("Device revoked server-side.\n");
110
+ }
111
+ catch (err) {
112
+ eprint(`\n⚠ Could not revoke this device server-side: ${err.message}\n` +
113
+ `The local credential has NOT been removed, because deleting it now would\n` +
114
+ `leave a working credential on your account that this machine can no longer\n` +
115
+ `revoke. Re-run when you are online, or revoke the device in the app.\n`);
116
+ return 1;
117
+ }
118
+ await clear();
119
+ forgetAccessToken();
120
+ print("Local credential removed.\n");
121
+ }
122
+ }
123
+ let removed = 0;
124
+ for (const path of [bundleRoot(), installedManifestPath(), ledger]) {
125
+ if (await rm(path))
126
+ removed += 1;
127
+ }
128
+ print(`Removed ${removed} item(s).\n`);
129
+ // ⚠ `~/.symbols` itself is removed ONLY if it is now empty. It is the
130
+ // documented home for CLI state, and a future version may keep something
131
+ // there this build does not know about — `rm -rf` on a directory whose full
132
+ // contents you cannot enumerate is how the notebook-delete incident happened.
133
+ try {
134
+ const left = await fs.readdir(home);
135
+ if (left.length === 0) {
136
+ await fs.rmdir(home);
137
+ print(`Removed ${home}.\n`);
138
+ }
139
+ else {
140
+ print(`Left ${home} in place — it still holds: ${left.join(", ")}\n`);
141
+ }
142
+ }
143
+ catch {
144
+ // Already gone, or never existed.
145
+ }
146
+ print(`\nYour projects are untouched in ${workspaceRoot()}.\n` +
147
+ `To remove the command itself: npm rm -g @symbols-cli/cli\n`);
148
+ return 0;
149
+ }
@@ -0,0 +1,176 @@
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 up` — turn an account into working directories.
6
+ //
7
+ // One command between `symbols login` and `claude`. It materialises every
8
+ // notebook as a real directory under `~/Symbols/`, wires the project-scope
9
+ // plugin install, merges (never overwrites) the project settings and `CLAUDE.md`,
10
+ // and runs the first sync.
11
+ //
12
+ // ## The ownership rule, restated because this is the command that could break it
13
+ //
14
+ // > `symbols` owns `~/Symbols/**` and nothing else.
15
+ //
16
+ // Nothing here writes `~/.claude/CLAUDE.md`, `~/.claude/settings.json`,
17
+ // `~/.claude.json`, or a shell rc. The container earned the right to own a
18
+ // filesystem by BEING the filesystem; the CLI is a guest in someone's home
19
+ // directory. Every file this command writes is inside a project root, and every
20
+ // one of them is merged rather than replaced.
21
+ //
22
+ // ## ⚠ The `.mcp.json` question, answered by NOT writing one
23
+ //
24
+ // The plan's layout line for this file reads "CLAUDE.md, .mcp.json, plugins,
25
+ // .gitignore", and the `.mcp.json` half is deliberately **not** implemented here.
26
+ // The plan's own skills section settles it: *"a plugin can ship its own
27
+ // `.mcp.json`, so we never write `~/.claude.json`"* — the MCP registration rides
28
+ // in the bundle, which is signed. A `.mcp.json` written by this command would be
29
+ // an UNSIGNED file that tells Claude Code what process to launch, sitting in a
30
+ // directory the sync engine also writes to. That is the remote-code-execution
31
+ // channel the plan calls the thing it got most wrong, re-opened by a convenience.
32
+ // If the bundle ever stops shipping one, this is where it goes back — signed.
33
+ import { promises as fs } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { listProjects } from "../sync/api.js";
36
+ import { Ledger } from "../sync/ledger.js";
37
+ import { ensureProjects, syncProject } from "../sync/reconcile.js";
38
+ import { fetchProtected, protectedPaths, applyReadOnly } from "../sync/protect.js";
39
+ import { readInstalledManifest } from "../skills/bundle.js";
40
+ import { installPlugins, ClaudeCliMissingError } from "../skills/install.js";
41
+ import { mergeProjectSettings } from "../skills/settings-merge.js";
42
+ import { writeManagedBlock, renderManagedBody, MarkerError } from "../skills/claude-md.js";
43
+ import { workspaceRoot } from "../util/platform.js";
44
+ import { renderResult } from "./sync.js";
45
+ import { eprint, print } from "../util/log.js";
46
+ /**
47
+ * What we add to a project's `.gitignore`, and nothing else.
48
+ *
49
+ * Appended between no markers because a `.gitignore` has no comment structure to
50
+ * hang them on — so it is APPEND-ONLY and idempotent by line membership. A user
51
+ * who deletes our line keeps it deleted only until the next `up`, which is the
52
+ * one behaviour here that is worse than the settings merge; it is accepted
53
+ * because a synced `.symbols/state.db` in someone's git history is worse.
54
+ */
55
+ const GITIGNORE_LINES = [".symbols/", "*.server.*"];
56
+ async function ensureGitignore(root) {
57
+ const path = join(root, ".gitignore");
58
+ let existing = "";
59
+ try {
60
+ existing = await fs.readFile(path, "utf8");
61
+ }
62
+ catch (err) {
63
+ if (err.code !== "ENOENT")
64
+ throw err;
65
+ }
66
+ const have = new Set(existing.split("\n").map((l) => l.trim()));
67
+ const missing = GITIGNORE_LINES.filter((l) => !have.has(l));
68
+ if (missing.length === 0)
69
+ return false;
70
+ const prefix = existing === "" ? "" : existing.endsWith("\n") ? "" : "\n";
71
+ await fs.writeFile(path, `${existing}${prefix}\n# Symbols CLI\n${missing.join("\n")}\n`, "utf8");
72
+ return true;
73
+ }
74
+ export async function run(argv) {
75
+ const skipSkills = argv.includes("--no-skills");
76
+ const skipSync = argv.includes("--no-sync");
77
+ const ledger = await Ledger.open();
78
+ try {
79
+ const projects = await listProjects();
80
+ if (projects.length === 0) {
81
+ print("no notebooks on this account yet — create one with `symbols project new <name>`\n");
82
+ return 0;
83
+ }
84
+ const ensured = await ensureProjects(ledger, projects, workspaceRoot());
85
+ const manifest = skipSkills ? null : await readInstalledManifest();
86
+ if (!skipSkills && manifest === null) {
87
+ // Not fatal: the directories and the sync are useful without the skills,
88
+ // and failing the whole bootstrap on a missing bundle would make a
89
+ // network blip look like a broken install.
90
+ eprint("symbols: no skills bundle is installed yet — run `symbols update` to fetch it\n");
91
+ }
92
+ let failures = 0;
93
+ for (const e of ensured) {
94
+ if (e.dirnameDrift)
95
+ eprint(`symbols: ${e.name}: ${e.dirnameDrift}\n`);
96
+ if (e.problem || !e.project) {
97
+ eprint(`symbols: ${e.name}: ${e.problem ?? "could not be bound"}\n`);
98
+ failures += 1;
99
+ continue;
100
+ }
101
+ const root = e.project.root;
102
+ print(`${e.name}\n ${root}\n`);
103
+ await ensureGitignore(root);
104
+ // Which files a live regime or widget is reading. Fetched BEFORE the sync
105
+ // so the agent is told before it can form the intent to delete one — and
106
+ // so the read-only marks are already on when the first pull lands.
107
+ let protectedEntries = [];
108
+ try {
109
+ protectedEntries = await fetchProtected(e.notebookId);
110
+ }
111
+ catch (err) {
112
+ // ⚠ NOT an empty set. A failed read is not an affirmative "nothing is
113
+ // protected", and treating it as one would silently unmark every
114
+ // regime-backed file on a blip. The server's 409 still refuses.
115
+ eprint(`symbols: could not read protected files for ${e.name} (${err.message}) — ` +
116
+ `existing read-only marks are left as they are\n`);
117
+ }
118
+ if (manifest) {
119
+ try {
120
+ await mergeProjectSettings(root, manifest);
121
+ await installPlugins(root, manifest);
122
+ }
123
+ catch (err) {
124
+ if (err instanceof ClaudeCliMissingError) {
125
+ eprint(`symbols: ${err.message}\n`);
126
+ }
127
+ else {
128
+ eprint(`symbols: ${e.name}: ${err.message}\n`);
129
+ failures += 1;
130
+ }
131
+ }
132
+ try {
133
+ await writeManagedBlock(root, renderManagedBody({
134
+ projectName: e.name,
135
+ notebookId: e.notebookId,
136
+ plugins: manifest.plugins.map((p) => ({ name: p.name, version: p.version })),
137
+ protectedPaths: protectedPaths(protectedEntries),
138
+ }));
139
+ }
140
+ catch (err) {
141
+ if (err instanceof MarkerError) {
142
+ // Duplicate or unmatched markers mean we cannot tell which region is
143
+ // ours. Freezing is right: rewriting the file would eat whatever the
144
+ // user put between the stray markers.
145
+ eprint(`symbols: ${e.name}: ${err.message}\n`);
146
+ failures += 1;
147
+ }
148
+ else {
149
+ throw err;
150
+ }
151
+ }
152
+ }
153
+ if (!skipSync) {
154
+ const res = await syncProject({
155
+ ledger,
156
+ project: e.project,
157
+ log: (l) => print(`${l}\n`),
158
+ });
159
+ print(`${renderResult(res)}\n`);
160
+ if (res.offline || res.frozen.length > 0)
161
+ failures += 1;
162
+ }
163
+ // Marks go on AFTER the pull, so a file that did not exist locally a
164
+ // moment ago is marked too.
165
+ for (const p of protectedPaths(protectedEntries)) {
166
+ await applyReadOnly(join(root, p), true);
167
+ }
168
+ }
169
+ print(`\nready. \`cd ${workspaceRoot()}/<project>\` and run \`claude\`.\n` +
170
+ `Keep \`symbols watch\` running in another terminal to stay in sync.\n`);
171
+ return failures > 0 ? 1 : 0;
172
+ }
173
+ finally {
174
+ ledger.close();
175
+ }
176
+ }