@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,206 @@
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
+ // The filesystem watcher.
6
+ //
7
+ // ## It decides NOTHING. That is the whole design.
8
+ //
9
+ // `reconcile` is the guarantee; events are the optimisation
10
+ // (`odin_notebook_writeback.rs` header, and the plan's port list). This file
11
+ // therefore emits ONE kind of fact — *"this path may have changed"* — and never
12
+ // an interpretation of it. There is no "delete" event here, no "rename" event,
13
+ // no content, no hash. The sweep re-STATS every dirty path and runs the same
14
+ // `decide()` table a full sweep runs.
15
+ //
16
+ // ⚠ THAT IS WHAT MAKES THE VIM DANCE PASS STRUCTURALLY RATHER THAN BY LUCK.
17
+ //
18
+ // A vim `:w` on `strategy.py` produces, in order:
19
+ //
20
+ // create 4913 · delete 4913 (the writability probe)
21
+ // create strategy.py~ (the backup)
22
+ // delete strategy.py (the rename away)
23
+ // create strategy.py (the rename in)
24
+ //
25
+ // A watcher that maps events to operations sends DELETE then POST — two writes,
26
+ // a window where the file does not exist server-side, and a new row id that
27
+ // invalidates every ledger `file_id`. If the DELETE lands and the POST does not,
28
+ // the file is gone.
29
+ //
30
+ // Here: `4913` and `strategy.py~` are dropped by `isEditorTemp`; the remaining
31
+ // two events collapse into one dirty entry for `strategy.py`; the debounce
32
+ // expires; the sweep stats the path, finds it present with new content, and
33
+ // issues exactly **one PATCH and zero DELETE**. No special case, no rename
34
+ // pairing heuristic, no cookie matching. The path that produced a delete
35
+ // server-side is the one where the sweep gets an affirmative ENOENT — which is
36
+ // the ported rule (`settle_vanish`, :513-519) stated as an architecture instead
37
+ // of a check.
38
+ //
39
+ // ## Started BEFORE the initial materialize
40
+ //
41
+ // Ported from `start()` (:1746-1780): the watcher is spawned first and the
42
+ // materialize runs after. On the server that ordering closed a ~90s window in
43
+ // which terminal writes produced no event and nothing replayed the gap. Locally
44
+ // the pull is faster but the window is the same shape, and the loop-breaker
45
+ // (`S === L` -> `none`) is what makes it safe: the sweep's own writes come back
46
+ // as dirty paths, hash equal, and stop there.
47
+ import { watch } from "chokidar";
48
+ import { relative, sep } from "node:path";
49
+ import { shouldIgnore, isCliManaged } from "./paths.js";
50
+ import { eprint } from "../util/log.js";
51
+ export class Watcher {
52
+ opts;
53
+ fsw = null;
54
+ dirty = new Set();
55
+ timer = null;
56
+ firstDirtyAt = 0;
57
+ debounceMs;
58
+ maxWaitMs;
59
+ constructor(opts) {
60
+ this.opts = opts;
61
+ this.debounceMs = opts.debounceMs ?? 300;
62
+ this.maxWaitMs = opts.maxWaitMs ?? 3_000;
63
+ }
64
+ /** Resolves once the initial scan has completed and events are flowing. */
65
+ async start() {
66
+ const w = watch(this.opts.root, {
67
+ // The sweep enumerates the tree itself; replaying it as events would
68
+ // duplicate the whole project into the dirty set on every start.
69
+ ignoreInitial: true,
70
+ persistent: true,
71
+ followSymlinks: false,
72
+ // `add` for a large file can fire while it is still being written. This
73
+ // only delays the EVENT — a partially-observed file is still re-hashed by
74
+ // the sweep, so the worst case is a redundant pass, never a truncated push.
75
+ awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 },
76
+ ignored: (p) => {
77
+ const rel = this.rel(p);
78
+ // The root itself, and anything above it, are not ignorable.
79
+ if (rel === "" || rel === null)
80
+ return false;
81
+ return this.skip(rel);
82
+ },
83
+ });
84
+ // Every event maps to the SAME fact. Listing them separately is deliberate:
85
+ // it documents that we know which events exist and chose to erase the
86
+ // distinction, rather than having missed one.
87
+ for (const ev of ["add", "change", "unlink"]) {
88
+ w.on(ev, (p) => this.mark(p));
89
+ }
90
+ // Directory events matter only because a directory delete removes files
91
+ // whose own `unlink` events chokidar may not emit. Marking the directory
92
+ // path lets the sweep notice; the sweep enumerates, so it finds the children.
93
+ for (const ev of ["addDir", "unlinkDir"]) {
94
+ w.on(ev, (p) => this.mark(p, true));
95
+ }
96
+ // An error must never be silent, and must never be read as "nothing
97
+ // changed". The engine keeps its 60s sweep either way, which is the reason
98
+ // this can be a log rather than a fatal.
99
+ w.on("error", (err) => {
100
+ eprint(`symbols watch: filesystem watcher error (the 60s sweep still covers you): ${err instanceof Error ? err.message : String(err)}\n`);
101
+ });
102
+ this.fsw = w;
103
+ await new Promise((resolve) => w.on("ready", () => resolve()));
104
+ }
105
+ async stop() {
106
+ if (this.timer)
107
+ clearTimeout(this.timer);
108
+ this.timer = null;
109
+ // Hand over whatever is buffered — dropping it would mean a `watch` that is
110
+ // stopped mid-batch silently loses the edits it had already seen.
111
+ this.flush();
112
+ await this.fsw?.close();
113
+ this.fsw = null;
114
+ }
115
+ /** Project-relative POSIX path, or null if the path is outside the root. */
116
+ rel(abs) {
117
+ const r = relative(this.opts.root, abs);
118
+ if (r === "")
119
+ return "";
120
+ if (r.startsWith("..") || r.startsWith(sep + ".."))
121
+ return null;
122
+ return r.split(sep).join("/");
123
+ }
124
+ /**
125
+ * Paths the watcher must not raise events for.
126
+ *
127
+ * ⚠ `isCliManaged` sits beside `shouldIgnore` here and it is load-bearing.
128
+ * `ensureProjects` writes `<root>/.symbols/project.json` on every run, INSIDE
129
+ * the watched tree — so without this the CLI's own bookkeeping wakes a sweep
130
+ * every time it runs, and the only thing stopping the loop is that the sweep
131
+ * then skips the path. Caught by the end-to-end vim-dance test, which saw
132
+ * `.symbols/project.json` in a batch that should have held exactly one entry.
133
+ *
134
+ * Both predicates come from `paths.ts`. Nothing is re-derived here.
135
+ */
136
+ skip(rel) {
137
+ return shouldIgnore(rel) || isCliManaged(rel);
138
+ }
139
+ mark(abs, isDir = false) {
140
+ const rel = this.rel(abs);
141
+ if (rel === null || rel === "")
142
+ return;
143
+ // ⚠ THE IGNORE FILTER RUNS HERE TOO, not only in chokidar's `ignored`.
144
+ //
145
+ // `ignored` prunes what is WATCHED; it does not guarantee no event for a
146
+ // path created after the watch was established on its parent. `isEditorTemp`
147
+ // is the load-bearing half (`4913`, `~`, `.swp`) and it must be applied to
148
+ // every event, or the vim dance leaks two junk paths into the sweep — which
149
+ // would create and delete a `4913` row server-side on every single save.
150
+ if (this.skip(rel))
151
+ return;
152
+ if (isDir) {
153
+ // A directory is not a file row. Mark its subtree by marking the prefix;
154
+ // the sweep expands it. Recorded with a trailing marker so a consumer can
155
+ // tell "this exact path" from "everything under here".
156
+ this.dirty.add(`${rel}/`);
157
+ }
158
+ else {
159
+ this.dirty.add(rel);
160
+ }
161
+ if (this.firstDirtyAt === 0)
162
+ this.firstDirtyAt = Date.now();
163
+ if (this.timer)
164
+ clearTimeout(this.timer);
165
+ // The starvation ceiling. If the batch has been open longer than maxWait,
166
+ // flush now rather than extending it again.
167
+ if (Date.now() - this.firstDirtyAt >= this.maxWaitMs) {
168
+ this.flush();
169
+ return;
170
+ }
171
+ this.timer = setTimeout(() => this.flush(), this.debounceMs);
172
+ }
173
+ flush() {
174
+ if (this.timer)
175
+ clearTimeout(this.timer);
176
+ this.timer = null;
177
+ this.firstDirtyAt = 0;
178
+ if (this.dirty.size === 0)
179
+ return;
180
+ const batch = [...this.dirty];
181
+ this.dirty = new Set();
182
+ this.opts.onDirty(batch);
183
+ }
184
+ }
185
+ /**
186
+ * Expand a dirty batch into concrete paths to examine.
187
+ *
188
+ * Directory marks (`"src/"`) become "every tracked path under `src/`" — the
189
+ * caller supplies the tracked set, because the watcher deliberately holds no
190
+ * state about what exists.
191
+ */
192
+ export function expandDirty(batch, tracked) {
193
+ const out = new Set();
194
+ for (const entry of batch) {
195
+ if (entry.endsWith("/")) {
196
+ for (const t of tracked) {
197
+ if (t.startsWith(entry))
198
+ out.add(t);
199
+ }
200
+ }
201
+ else {
202
+ out.add(entry);
203
+ }
204
+ }
205
+ return [...out];
206
+ }
@@ -0,0 +1,58 @@
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
+ // The CLI's output chokepoint.
6
+ //
7
+ // # Why this is a module and not 89 calls to `process.stderr.write`
8
+ //
9
+ // `symbols mcp` speaks JSON-RPC over **stdout**. One stray byte on that stream
10
+ // corrupts the protocol and surfaces to the user as a client bug, miles from
11
+ // whatever printed it. `mcp/server.ts` is careful about this today, but the
12
+ // care lives in a comment — and a comment does not survive someone adding a
13
+ // `console.log` to a helper three imports down.
14
+ //
15
+ // So stdout is claimed here. `enterProtocolMode()` latches, and after it every
16
+ // `out()` is redirected to stderr rather than corrupting the stream. The
17
+ // failure mode becomes a misplaced diagnostic instead of a dead session.
18
+ //
19
+ // ⚠ This is a chokepoint, not a logging framework. No levels, no timestamps, no
20
+ // JSON. The CLI's output IS its user interface — `symbols status` prints a
21
+ // report a human reads — so formatting stays at the call site where the author
22
+ // can see it.
23
+ /** Set once `stdout` belongs to a wire protocol. Never unset. */
24
+ let protocolMode = false;
25
+ /**
26
+ * Claim stdout for a machine-readable protocol (today: MCP's JSON-RPC).
27
+ *
28
+ * Call this BEFORE any transport is constructed. It is one-way on purpose:
29
+ * a mode that can be turned off is a mode that gets turned off in a `finally`.
30
+ */
31
+ export function enterProtocolMode() {
32
+ protocolMode = true;
33
+ }
34
+ export function inProtocolMode() {
35
+ return protocolMode;
36
+ }
37
+ /**
38
+ * Human-facing output — the thing the user asked to see.
39
+ *
40
+ * Goes to stdout so it can be piped (`symbols project ls | grep foo`), EXCEPT
41
+ * in protocol mode, where stdout is not ours to write to.
42
+ */
43
+ export function print(text) {
44
+ if (protocolMode) {
45
+ process.stderr.write(text);
46
+ return;
47
+ }
48
+ process.stdout.write(text);
49
+ }
50
+ /**
51
+ * Diagnostics, prompts, progress and errors.
52
+ *
53
+ * Always stderr: it must not pollute a pipe, and it must remain visible when
54
+ * the user is redirecting stdout to a file.
55
+ */
56
+ export function eprint(text) {
57
+ process.stderr.write(text);
58
+ }
@@ -0,0 +1,79 @@
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
+ // Where `symbols` is allowed to write, and nothing else.
6
+ //
7
+ // ⚠ THE OWNERSHIP RULE — the one line every path in this CLI answers to:
8
+ //
9
+ // `symbols` owns `~/Symbols/**` and its own `~/.symbols/`. IT OWNS NOTHING
10
+ // ELSE. It never writes `~/.claude/CLAUDE.md`, `~/.claude/settings.json`,
11
+ // `~/.claude.json`, or any shell rc.
12
+ //
13
+ // The container earned that right by owning the filesystem; the CLI has not. A
14
+ // user's `~/.claude/` is THEIR configuration, shared with every other project
15
+ // they work on, and a tool that edits it to make its own skills load has decided
16
+ // on their behalf that its needs outrank a stranger's repo.
17
+ //
18
+ // The mechanism that makes this affordable is `claude plugin install --scope
19
+ // project` plus a plugin-shipped `.mcp.json` — both verified real in P0's spike,
20
+ // which is why "never write ~/.claude.json" is a rule and not an aspiration.
21
+ import { spawn } from "node:child_process";
22
+ import { platform } from "node:os";
23
+ import { homedir } from "node:os";
24
+ import { join } from "node:path";
25
+ /**
26
+ * `~/.symbols` — CLI-owned state: the credential, the sync ledger, the skills
27
+ * bundle, the anti-rollback counter.
28
+ *
29
+ * `SYMBOLS_HOME` redirects it, which the tests and the conformance harness rely
30
+ * on. It is deliberately NOT a way to point the CLI at someone else's config:
31
+ * everything under it is written by this CLI and read by nothing else.
32
+ */
33
+ export function symbolsHome() {
34
+ return process.env["SYMBOLS_HOME"] ?? join(homedir(), ".symbols");
35
+ }
36
+ /** `~/Symbols` — where projects materialise as real directories. */
37
+ export function workspaceRoot() {
38
+ return process.env["SYMBOLS_WORKSPACE"] ?? join(homedir(), "Symbols");
39
+ }
40
+ /**
41
+ * The unpacked skills bundle, and the marketplace root `claude plugin
42
+ * marketplace add` is pointed at.
43
+ *
44
+ * Under `~/.symbols/`, never under `~/.claude/`: the plugin CACHE is Claude
45
+ * Code's to manage, and writing into it directly is how you get a cache whose
46
+ * contents disagree with `installed_plugins.json` — which is exactly the failure
47
+ * `symbols doctor` exists to catch.
48
+ */
49
+ export function bundleRoot() {
50
+ return join(symbolsHome(), "plugins");
51
+ }
52
+ /** The manifest of the bundle currently unpacked at `bundleRoot()`. */
53
+ export function installedManifestPath() {
54
+ return join(symbolsHome(), "manifest.json");
55
+ }
56
+ /**
57
+ * Open a URL in the user's default browser.
58
+ *
59
+ * ⚠ `spawn` with an ARGV ARRAY, never a shell string, so a URL can never become
60
+ * a command. Callers must pass a URL built from an allowlisted origin — this
61
+ * function does not validate, because the only safe place to decide that is
62
+ * where the origin is resolved (`auth/hosts.ts`).
63
+ *
64
+ * Failure is deliberately not fatal: a headless box, a missing `xdg-open`, or a
65
+ * locked-down desktop should print the URL rather than abort a sign-in. Every
66
+ * caller also prints it.
67
+ */
68
+ export function openBrowser(url) {
69
+ const [cmd, args] = platform() === "darwin"
70
+ ? ["open", [url]]
71
+ : platform() === "win32"
72
+ ? ["cmd", ["/c", "start", "", url]]
73
+ : ["xdg-open", [url]];
74
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
75
+ child.on("error", () => {
76
+ /* printed by the caller; see above */
77
+ });
78
+ child.unref();
79
+ }
@@ -0,0 +1,24 @@
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
+ // The single source of the CLI's version.
6
+ //
7
+ // Read from the shipped package.json rather than duplicated as a literal — a
8
+ // version constant that drifts from the manifest is exactly the class of bug
9
+ // this project opened with (repo 0.7.7, installed 0.7.4). One value, one place.
10
+ import { readFileSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ import { dirname, join } from "node:path";
13
+ function read() {
14
+ try {
15
+ // dist/util/version.js → dist/util → dist → package root
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const pkg = JSON.parse(readFileSync(join(here, "..", "..", "package.json"), "utf8"));
18
+ return pkg.version ?? "0.0.0";
19
+ }
20
+ catch {
21
+ return "0.0.0";
22
+ }
23
+ }
24
+ export const CLI_VERSION = read();
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@symbols-cli/cli",
3
+ "version": "0.0.1",
4
+ "description": "Symbols CLI \u2014 run the Symbols agent on your own machine",
5
+ "type": "module",
6
+ "bin": {
7
+ "symbols": "dist/index.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=20.18.1"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json",
22
+ "dev": "tsc -p tsconfig.json --watch",
23
+ "typecheck": "tsc -p tsconfig.json --noEmit",
24
+ "test": "node --test \"test/*.test.mjs\"",
25
+ "prepack": "rm -rf dist && tsc -p tsconfig.json --sourceMap false"
26
+ },
27
+ "dependencies": {
28
+ "@modelcontextprotocol/sdk": "~1.30.0",
29
+ "better-sqlite3": "^13.0.0",
30
+ "chokidar": "^4.0.0",
31
+ "undici": "^7.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/better-sqlite3": "^7.6.13",
35
+ "@types/node": "^22.0.0",
36
+ "typescript": "^5.6.0"
37
+ },
38
+ "license": "UNLICENSED",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/mohammedraqeebb/symbols-terminal.git",
42
+ "directory": "apps/cli"
43
+ }
44
+ }