@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.
- package/LICENSE +8 -0
- package/README.md +103 -0
- package/dist/auth/client.js +531 -0
- package/dist/auth/credentials.js +293 -0
- package/dist/auth/hosts.js +85 -0
- package/dist/auth/loopback.js +108 -0
- package/dist/auth/pkce.js +33 -0
- package/dist/auth/wire.js +40 -0
- package/dist/commands/arm.js +154 -0
- package/dist/commands/curl.js +101 -0
- package/dist/commands/doctor.js +217 -0
- package/dist/commands/login.js +113 -0
- package/dist/commands/logout.js +78 -0
- package/dist/commands/mcp.js +33 -0
- package/dist/commands/project.js +145 -0
- package/dist/commands/status.js +78 -0
- package/dist/commands/sync.js +94 -0
- package/dist/commands/uninstall.js +149 -0
- package/dist/commands/up.js +176 -0
- package/dist/commands/update.js +120 -0
- package/dist/commands/watch.js +155 -0
- package/dist/commands/whoami.js +103 -0
- package/dist/index.js +147 -0
- package/dist/mcp/scopes.js +215 -0
- package/dist/mcp/server.js +366 -0
- package/dist/mcp/tools.js +646 -0
- package/dist/skills/bundle.js +441 -0
- package/dist/skills/claude-md.js +135 -0
- package/dist/skills/install.js +188 -0
- package/dist/skills/settings-merge.js +107 -0
- package/dist/sync/api.js +380 -0
- package/dist/sync/diff.js +172 -0
- package/dist/sync/ledger.js +319 -0
- package/dist/sync/paths.js +447 -0
- package/dist/sync/protect.js +108 -0
- package/dist/sync/reconcile.js +870 -0
- package/dist/sync/watcher.js +206 -0
- package/dist/util/log.js +58 -0
- package/dist/util/platform.js +79 -0
- package/dist/util/version.js +24 -0
- 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
|
+
});
|