@nurix/nustack 0.23.9 → 0.23.10

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.
@@ -0,0 +1,137 @@
1
+ export const ROUTING_LATCH_LIMIT = 2;
2
+ export const ROUTING_JOB_KINDS = ["read.bulk", "lane.subagent"];
3
+ export const LABEL_ROW_HANDLERS = [
4
+ "outline",
5
+ "bypass",
6
+ "digest",
7
+ "full_read",
8
+ "local_model",
9
+ "redirect",
10
+ "pack_hit",
11
+ "pack_miss",
12
+ ];
13
+ export const LABEL_ROW_CHECK_RESULTS = ["pass", "fail", "budget", "unavailable", "latched"];
14
+ export const LABEL_ROW_ID_MAX = 128;
15
+ export const LABEL_ROW_PATH_MAX = 512;
16
+ export const LABEL_ROW_SYNC_STATES = ["pending", "synced", "refused", "local"];
17
+ export const GATED_PATH_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
18
+ export class LabelRowRefusedError extends Error {
19
+ code = "LABEL_ROW_REFUSED";
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "LabelRowRefusedError";
23
+ }
24
+ }
25
+ function isOneOf(values, value) {
26
+ return typeof value === "string" && values.includes(value);
27
+ }
28
+ function requireIdentifier(field, value) {
29
+ if (typeof value !== "string" || value.length < 1 || value.length > LABEL_ROW_ID_MAX) {
30
+ throw new LabelRowRefusedError(`${field} must be 1-${LABEL_ROW_ID_MAX} characters`);
31
+ }
32
+ }
33
+ function requireInteger(field, value, isSigned) {
34
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || (!isSigned && value < 0)) {
35
+ throw new LabelRowRefusedError(`${field} must be a${isSigned ? "n" : " non-negative"} integer`);
36
+ }
37
+ }
38
+ export function clipLabelRowPath(path) {
39
+ return path.length <= LABEL_ROW_PATH_MAX ? path : `…${path.slice(path.length - (LABEL_ROW_PATH_MAX - 1))}`;
40
+ }
41
+ export function insertLabelRow(store, row, syncState = "pending") {
42
+ requireIdentifier("id", row.id);
43
+ requireIdentifier("session_id", row.session_id);
44
+ requireIdentifier("agent_type", row.agent_type);
45
+ if (!isOneOf(LABEL_ROW_HANDLERS, row.handler))
46
+ throw new LabelRowRefusedError(`unknown handler "${String(row.handler)}"`);
47
+ if (row.check_result !== null && !isOneOf(LABEL_ROW_CHECK_RESULTS, row.check_result)) {
48
+ throw new LabelRowRefusedError(`unknown check result "${String(row.check_result)}"`);
49
+ }
50
+ if (!isOneOf(LABEL_ROW_SYNC_STATES, syncState))
51
+ throw new LabelRowRefusedError(`unknown sync state "${String(syncState)}"`);
52
+ if (row.bypass_used !== 0 && row.bypass_used !== 1)
53
+ throw new LabelRowRefusedError("bypass_used must be 0 or 1");
54
+ if (row.path !== null && typeof row.path !== "string")
55
+ throw new LabelRowRefusedError("path must be a string or null");
56
+ requireInteger("estimated_tokens", row.estimated_tokens, false);
57
+ requireInteger("returned_tokens", row.returned_tokens, false);
58
+ requireInteger("latency_ms", row.latency_ms, false);
59
+ requireInteger("tokens_kept_out", row.tokens_kept_out, true);
60
+ requireInteger("created_at", row.created_at, false);
61
+ store.db
62
+ .prepare(`INSERT INTO label_rows (id, session_id, agent_type, path, estimated_tokens, returned_tokens, handler,
63
+ check_result, bypass_used, latency_ms, tokens_kept_out, created_at, sync_state)
64
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
65
+ .run(row.id, row.session_id, row.agent_type, row.path === null ? null : clipLabelRowPath(row.path), row.estimated_tokens, row.returned_tokens, row.handler, row.check_result, row.bypass_used, row.latency_ms, row.tokens_kept_out, row.created_at, syncState);
66
+ }
67
+ export function labelRowWire(record) {
68
+ return {
69
+ id: record.id,
70
+ sessionId: record.session_id,
71
+ agentType: record.agent_type,
72
+ path: record.path,
73
+ estimatedTokens: record.estimated_tokens,
74
+ returnedTokens: record.returned_tokens,
75
+ handler: record.handler,
76
+ checkResult: record.check_result,
77
+ isBypassUsed: record.bypass_used === 1,
78
+ latencyMs: record.latency_ms,
79
+ tokensKeptOut: record.tokens_kept_out,
80
+ createdAt: record.created_at,
81
+ };
82
+ }
83
+ export function listPendingLabelRows(store, limit) {
84
+ return store.db
85
+ .prepare("SELECT * FROM label_rows WHERE sync_state = 'pending' ORDER BY created_at, id LIMIT ?")
86
+ .all(limit);
87
+ }
88
+ function markPending(store, ids, state) {
89
+ if (ids.length === 0)
90
+ return 0;
91
+ const placeholders = ids.map(() => "?").join(", ");
92
+ const result = store.db
93
+ .prepare(`UPDATE label_rows SET sync_state = ? WHERE sync_state = 'pending' AND id IN (${placeholders})`)
94
+ .run(state, ...ids);
95
+ return Number(result.changes);
96
+ }
97
+ export function markLabelRowsSynced(store, ids) {
98
+ return markPending(store, ids, "synced");
99
+ }
100
+ export function markLabelRowsRefused(store, ids) {
101
+ return markPending(store, ids, "refused");
102
+ }
103
+ export function isLatched(store, sessionId, jobKind) {
104
+ const row = store.db
105
+ .prepare("SELECT latched_at FROM routing_latch WHERE session_id = ? AND job_kind = ?")
106
+ .get(sessionId, jobKind);
107
+ return row?.latched_at != null;
108
+ }
109
+ export function recordRoutingMiss(store, sessionId, jobKind, miss, now = Date.now()) {
110
+ if (!isOneOf(ROUTING_JOB_KINDS, jobKind))
111
+ throw new Error(`unknown routing job kind "${String(jobKind)}"`);
112
+ const failures = miss === "failure" ? 1 : 0;
113
+ const budgetMisses = miss === "budget" ? 1 : 0;
114
+ store.db
115
+ .prepare(`INSERT INTO routing_latch (session_id, job_kind, failures, budget_misses, latched_at)
116
+ VALUES (?, ?, ?, ?, NULL)
117
+ ON CONFLICT (session_id, job_kind) DO UPDATE SET
118
+ failures = failures + excluded.failures,
119
+ budget_misses = budget_misses + excluded.budget_misses`)
120
+ .run(sessionId, jobKind, failures, budgetMisses);
121
+ store.db
122
+ .prepare(`UPDATE routing_latch SET latched_at = ?
123
+ WHERE session_id = ? AND job_kind = ? AND latched_at IS NULL AND failures + budget_misses >= ?`)
124
+ .run(now, sessionId, jobKind, ROUTING_LATCH_LIMIT);
125
+ }
126
+ export function isPathGated(store, sessionId, path) {
127
+ return store.db.prepare("SELECT 1 FROM gated_paths WHERE session_id = ? AND path = ?").get(sessionId, path) !== undefined;
128
+ }
129
+ export function gatePathOnce(store, sessionId, path, now = Date.now()) {
130
+ const result = store.db
131
+ .prepare("INSERT INTO gated_paths (session_id, path, gated_at) VALUES (?, ?, ?) ON CONFLICT (session_id, path) DO NOTHING")
132
+ .run(sessionId, path, now);
133
+ const isInserted = Number(result.changes) === 1;
134
+ if (isInserted)
135
+ store.db.prepare("DELETE FROM gated_paths WHERE gated_at < ?").run(now - GATED_PATH_RETENTION_MS);
136
+ return isInserted;
137
+ }
@@ -0,0 +1,491 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { Command } from "commander";
3
+ import { runLogin } from "./commands/login.js";
4
+ import { runInit } from "./commands/init.js";
5
+ import { runAdd } from "./commands/add.js";
6
+ import { runList } from "./commands/list.js";
7
+ import { runSync } from "./commands/sync.js";
8
+ import { runDescribe } from "./commands/describe.js";
9
+ import { runValidate } from "./commands/validate.js";
10
+ import { runGraph } from "./commands/graph.js";
11
+ import { runLogscope } from "./commands/logscope.js";
12
+ import { runDoctor } from "./commands/doctor.js";
13
+ import { runTokenCreate, runTokenList, runTokenRevoke } from "./commands/token.js";
14
+ import { runOpen } from "./commands/open.js";
15
+ import { runBootstrap } from "./commands/bootstrap.js";
16
+ import { runLogout } from "./commands/logout.js";
17
+ import { runTelemetry } from "./commands/telemetry.js";
18
+ import { runSearch, runSearchServices } from "./commands/search.js";
19
+ import { runDeviceList, runDeviceRevoke, runDeviceStatus } from "./commands/device.js";
20
+ import { registerProjectSyncCommands } from "./commands/project-sync.js";
21
+ import { registerProjectCommands } from "./commands/project.js";
22
+ import { registerDeployCommands, registerLogsCommand, registerProjectSetCommand } from "./commands/deploy.js";
23
+ import { registerActivityCommand } from "./commands/activity.js";
24
+ import { registerWhoamiCommand } from "./commands/whoami.js";
25
+ import { registerApiCommand } from "./commands/api.js";
26
+ import { registerMcpCommands } from "./commands/mcp.js";
27
+ import { NON_INTERACTIVE_VARIABLE } from "./lib/interactive.js";
28
+ import { runOrganizationList, runOrganizationCreate, runOrganizationUse } from "./commands/organization.js";
29
+ import { runDocsList, runDocsShow, runDocsStatus } from "./commands/docs.js";
30
+ import { runHandoffList, runHandoffShow } from "./commands/handoff.js";
31
+ import { runToolkitStatus, runToolkitReconcile } from "./commands/toolkit.js";
32
+ import { runHarnessMount, runHarnessStatus } from "./commands/harness.js";
33
+ import { runSessionsList, runSessionsPrune, runSessionsRecord, runSessionsServe, } from "./commands/sessions.js";
34
+ import { registerReviewCommands } from "./commands/review.js";
35
+ import { registerRecordsCommands, startDrainIfDue } from "./commands/records.js";
36
+ import { isPartCheckStale, startTelemetryReconcile } from "./lib/telemetry/part_state.js";
37
+ import { RECORD_ALIAS_GROUPS, registerRecordAliases } from "./commands/recordAliases.js";
38
+ import { runAgentContext, runAgentHookInstall, runAgentHookRemove } from "./commands/agent.js";
39
+ import { runCensus } from "./commands/census.js";
40
+ import { runGateInstall, runGateRemove, runGateStatus } from "./commands/gate.js";
41
+ import { HOOK_SUBCOMMANDS } from "./lib/gate/subcommands.js";
42
+ import { printJsonError } from "./lib/json_output.js";
43
+ import { setCliVersion } from "./lib/auth_grant.js";
44
+ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
45
+ setCliVersion(version);
46
+ const program = new Command();
47
+ program
48
+ .name("nustack")
49
+ .description("Bootstrap NuStack services into your repo via the NuStack discovery plane.")
50
+ .version(version)
51
+ .option("--nustack-url <url>", "NuStack base URL (defaults to $NUSTACK_URL, then the hosted instance)")
52
+ .option("--non-interactive", `never prompt — take the default or refuse with a coded error (also $${NON_INTERACTIVE_VARIABLE}=1)`)
53
+ .action(async () => runBootstrap(program.opts()));
54
+ program.addHelpText("after", "\nRun `nustack` with no command to bootstrap: it checks your sign-in, runs login if needed, then init.");
55
+ program
56
+ .command("login")
57
+ .description("Sign in with your browser; this machine's grant is saved to your OS keychain")
58
+ .option("--no-open", "don't auto-open the browser; just print the sign-in URL")
59
+ .option("--manual", "sign in anywhere and paste the code here instead of using a browser on this machine")
60
+ .option("--force", "sign in again even if this machine is already signed in")
61
+ .action(async (options) => runLogin(program.opts(), options));
62
+ program
63
+ .command("logout")
64
+ .description("Sign out: end this machine's grant and clear the shared keychain credential (both CLI and desktop)")
65
+ .option("--forget-device", "also clear the shared ~/.nustack/device_id machine identity")
66
+ .action(async (options) => runLogout(program.opts(), { forgetDevice: options.forgetDevice }));
67
+ program
68
+ .command("init")
69
+ .description("Onboard this folder: sign in, then bind it to a Project (the onboarding router)")
70
+ .option("--organization <id>", "the Organization to create or select the Project in")
71
+ .option("--yes", "pre-confirm the populated-folder onboarding guard")
72
+ .action(async (options) => runInit({ ...program.opts(), ...options }));
73
+ program
74
+ .command("add [service]")
75
+ .description("Record a stack service in this repo: install its package, add the consumes edge")
76
+ .option("--json", "emit the versioned machine envelope instead of interactive output")
77
+ .option("--yes", "assume yes for the package-install and manifest-scaffold prompts")
78
+ .action(async (service, options) => runAdd(service, { ...program.opts(), ...options }));
79
+ program
80
+ .command("list")
81
+ .description("List the services in the NuStack catalogue")
82
+ .option("--json", "emit the versioned machine envelope wrapping the catalogue entries")
83
+ .action(async (options) => runList(options, program.opts()));
84
+ program
85
+ .command("sync")
86
+ .description("Project this Project's Service credentials into the managed .env block (not Documents or Git)")
87
+ .option("--project <id>", "the Project to act as (defaults to nustack.service.json / NUSTACK_WORKSPACE_ID)")
88
+ .option("--json", "emit the versioned machine envelope instead of interactive output")
89
+ .action(async (options) => runSync({ ...program.opts(), ...options }));
90
+ program
91
+ .command("describe <service>")
92
+ .description("Show the full catalogue entry for a service")
93
+ .option("--json", "emit the raw catalogue entry as JSON")
94
+ .action(async (service, options) => runDescribe(service, options, program.opts()));
95
+ program
96
+ .command("validate [manifest]")
97
+ .description("Validate an nustack.service.json (path or URL) against the nustack.dev/v1 schema")
98
+ .option("--schema <pathOrUrl>", "override the bundled packages/contracts/spec/schema.json")
99
+ .option("--json", "emit the versioned machine envelope with { target, isValid, errors }")
100
+ .action(async (manifest, options) => runValidate(manifest, options));
101
+ program
102
+ .command("graph [fn] [args...]")
103
+ .description("The code graph: ask a question of this repository's index, or `index` / `status` / `functions`")
104
+ .option("--repo <pathOrId>", "the repository to ask about (default: the working tree's root)")
105
+ .option("--no-refresh", "answer from the index as it stands, however old")
106
+ .option("--budget <n>", "token ceiling for the answer; 0 means unbounded")
107
+ .option("--commits <n>", "history depth for `graph index` (default 5000)")
108
+ .option("--json", "emit the versioned machine envelope with the answer as data")
109
+ .allowUnknownOption()
110
+ .allowExcessArguments()
111
+ .action(async (_fn, _args, options) => runGraph(process.argv, options));
112
+ program
113
+ .command("logscope [fn] [args...]")
114
+ .description("Log tracking: ask this machine's log corpus a question, or `index` / `corpora` / `allow` / `prune`")
115
+ .option("--root <dir>", "the project this command is about (default: the working tree's root)")
116
+ .option("--corpus <ref>", "a corpus id, or a path belonging to one")
117
+ .option("--budget <n>", "token ceiling for the answer; 0 means unbounded")
118
+ .option("--limit <n>", "rows requested — beats a default budget, never an explicit one")
119
+ .option("--retain <days>", "retention for `index`; 0 keeps the corpus until it is forgotten")
120
+ .option("--examples", "keep up to three exemplar log lines per template (off by default)")
121
+ .option("--no-redact", "store exemplars unredacted (loud, recorded on the corpus)")
122
+ .option("--since <ts>", "window start for container sources")
123
+ .option("--until <ts>", "window end for container sources")
124
+ .option("--revision <sha>", "the source revision these logs were written by")
125
+ .option("--note <text>", "a note recorded with an `allow` grant")
126
+ .option("--dry-run", "for `prune`: report what would be dropped and delete nothing")
127
+ .option("--json", "emit the versioned machine envelope with the answer as data")
128
+ .allowUnknownOption()
129
+ .allowExcessArguments()
130
+ .action(async (_fn, _args, options) => runLogscope(process.argv, options));
131
+ program
132
+ .command("doctor")
133
+ .description("Run the full local diagnostics matrix (identity, repository, manifests, toolkit, telemetry, credentials)")
134
+ .option("--project <id>", "the Project to diagnose (defaults to the bound repository)")
135
+ .option("--json", "emit the versioned machine envelope (secrets and absolute paths redacted)")
136
+ .option("--local-verbose", "include Device-local absolute paths in the output")
137
+ .action(async (options) => runDoctor({ ...program.opts(), ...options }));
138
+ registerDeployCommands(program, () => program.opts());
139
+ registerLogsCommand(program, () => program.opts());
140
+ registerWhoamiCommand(program, () => program.opts());
141
+ registerApiCommand(program, () => program.opts());
142
+ registerMcpCommands(program, () => program.opts());
143
+ program
144
+ .command("open <service>")
145
+ .description("Open the service's dev endpoint (or repository) in the browser")
146
+ .option("--env <name>", "pick a specific endpoint environment (default: dev)")
147
+ .option("--json", "print the resolved URL as the machine envelope instead of launching")
148
+ .action(async (service, options) => runOpen(service, options, program.opts()));
149
+ const token = program.command("token").description("Mint, list and revoke your personal access tokens (the NUSTACK_TOKEN a pipeline or agent uses)");
150
+ token
151
+ .command("create")
152
+ .description("Mint a personal access token; the secret is printed once, on stdout alone")
153
+ .requiredOption("--label <label>", "a name for the token, unique among your live ones")
154
+ .option("--expires <days>", "days until the token expires (1–365); no expiry when omitted")
155
+ .option("--json", "emit the versioned machine envelope")
156
+ .action(async (options) => runTokenCreate(options, program.opts()));
157
+ token
158
+ .command("list")
159
+ .description("List your personal access tokens (never a secret)")
160
+ .option("--json", "emit the versioned machine envelope")
161
+ .action(async (options) => runTokenList(options, program.opts()));
162
+ token
163
+ .command("revoke <token-id>")
164
+ .description("Revoke a personal access token; it stops working on the next request")
165
+ .option("--json", "emit the versioned machine envelope")
166
+ .action(async (tokenId, options) => runTokenRevoke(tokenId, options, program.opts()));
167
+ const device = program.command("device").description("Inspect your signed-in devices and end this machine's access");
168
+ device
169
+ .command("list")
170
+ .description("List your signed-in devices (never a token)")
171
+ .option("--json", "emit the versioned machine envelope")
172
+ .action(async (options) => runDeviceList(options, program.opts()));
173
+ device
174
+ .command("status")
175
+ .description("Show this device's local state merged with its server record")
176
+ .option("--json", "emit the versioned machine envelope")
177
+ .action(async (options) => runDeviceStatus(options, program.opts()));
178
+ device
179
+ .command("revoke [device-id]")
180
+ .description("End this machine's access (other machines are revoked from Profile › Devices)")
181
+ .option("--json", "emit the versioned machine envelope")
182
+ .option("--yes", "skip the confirmation prompt")
183
+ .action(async (deviceId, options) => runDeviceRevoke(deviceId, options, program.opts()));
184
+ program
185
+ .command("telemetry [action]")
186
+ .description("Inspect or control telemetry (status | enable | disable | reinstall | output | reconcile). Inside a bound folder, enable/disable move that folder alone; output points the shipper at your own Elastic, its key read from NUSTACK_ELASTIC_API_KEY; reconcile asks the server for each project's telemetry part")
187
+ .option("--json", "emit the versioned machine envelope")
188
+ .option("--device", "move this machine's switch instead of the current folder's")
189
+ .option("--local-verbose", "include each folder's absolute path (local consumers only — never pipe into a log)")
190
+ .option("--elastic-url <url>", "output: the Elastic endpoint (default NUSTACK_ELASTIC_URL)")
191
+ .option("--index <name>", "output: the index events are written to (default claude-sessions)")
192
+ .option("--quiet", "reconcile: print nothing")
193
+ .action(async (action, options) => {
194
+ const allowed = ["status", "enable", "disable", "reinstall", "output", "reconcile"];
195
+ const chosen = (allowed.includes(action ?? "") ? action : "status");
196
+ return runTelemetry(chosen, {
197
+ ...program.opts(),
198
+ json: options.json,
199
+ device: options.device,
200
+ localVerbose: options.localVerbose,
201
+ elasticUrl: options.elasticUrl,
202
+ index: options.index,
203
+ quiet: options.quiet,
204
+ });
205
+ });
206
+ const toLimit = (value) => Number.parseInt(value, 10);
207
+ const search = program
208
+ .command("search [keywords...]")
209
+ .description("Search the NuStack Directory for projects by keyword")
210
+ .option("--json", "emit the raw results array as JSON")
211
+ .option("--category <cat>", "filter projects by category")
212
+ .option("--limit <n>", "max results (default 20)", toLimit)
213
+ .action(async (keywords, options) => runSearch(keywords, options, program.opts()));
214
+ search
215
+ .command("services [keywords...]")
216
+ .description("Search the services registry by keyword (then `nustack add <name>` to integrate)")
217
+ .option("--json", "emit the raw results array as JSON")
218
+ .option("--type <type>", "filter services by type")
219
+ .option("--limit <n>", "max results (default 20)", toLimit)
220
+ .action(async (keywords, options) => runSearchServices(keywords, { ...search.opts(), ...options }, program.opts()));
221
+ search.addHelpText("after", '\n`services` is a reserved subcommand. To search projects for the literal word "services", use the\n' +
222
+ "explicit separator: `nustack search -- services` (everything after `--` is an projects query).");
223
+ const organization = program.command("organization").description("Work with your NuStack Organizations");
224
+ organization
225
+ .command("list")
226
+ .description("List your Organization memberships (name, slug, role, active marker)")
227
+ .option("--json", "emit the versioned machine envelope")
228
+ .action(async (options) => runOrganizationList(options, program.opts()));
229
+ organization
230
+ .command("create <name>")
231
+ .description("Create an Organization and print it")
232
+ .option("--json", "emit the versioned machine envelope")
233
+ .action(async (name, options) => runOrganizationCreate(name, options, program.opts()));
234
+ organization
235
+ .command("use <id-or-slug>")
236
+ .description("Set the active Organization context for this device")
237
+ .option("--json", "emit the versioned machine envelope")
238
+ .action(async (target, options) => runOrganizationUse(target, options, program.opts()));
239
+ const project = program.command("project").description("Work with NuStack Projects");
240
+ registerProjectCommands(project, () => program.opts());
241
+ registerProjectSetCommand(project, () => program.opts());
242
+ registerActivityCommand(project, () => program.opts());
243
+ registerProjectSyncCommands(project, () => program.opts());
244
+ registerReviewCommands(program.command("review").description("Read and mirror NuStack Review items"), () => program.opts());
245
+ registerRecordsCommands(program.command("records").description("Emit, send and list harness records"), () => program.opts());
246
+ registerRecordAliases(program, () => program.opts());
247
+ const LOCAL_ONLY_COMMANDS = ["census"];
248
+ const UNDRAINED_GROUPS = ["records", "gate", ...RECORD_ALIAS_GROUPS];
249
+ program.hook("preAction", async (_program, action) => {
250
+ if (program.opts().nonInteractive)
251
+ process.env[NON_INTERACTIVE_VARIABLE] = "1";
252
+ if (action.parent === program && LOCAL_ONLY_COMMANDS.includes(action.name()))
253
+ return;
254
+ const group = action.parent?.name() ?? "";
255
+ if (!UNDRAINED_GROUPS.includes(group))
256
+ await startDrainIfDue(program.opts());
257
+ if (action.name() !== "telemetry" && isPartCheckStale())
258
+ startTelemetryReconcile({ nustackUrl: program.opts().nustackUrl });
259
+ });
260
+ const docs = program.command("docs").description("Work with a Project's Documents");
261
+ docs
262
+ .command("list")
263
+ .description("List the Project's Documents")
264
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
265
+ .option("--json", "emit the versioned machine envelope")
266
+ .action(async (options) => runDocsList(options, program.opts()));
267
+ docs
268
+ .command("show <slug-or-id>")
269
+ .description("Show a Document: a bounded inline body or its content URL")
270
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
271
+ .option("--json", "emit the versioned machine envelope")
272
+ .action(async (target, options) => runDocsShow(target, options, program.opts()));
273
+ docs
274
+ .command("status")
275
+ .description("Report per-path local/cloud Document convergence for the bound repository")
276
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
277
+ .option("--json", "emit the versioned machine envelope")
278
+ .action(async (options) => runDocsStatus(options, program.opts()));
279
+ const handoff = program.command("handoff").description("Work with a Project's Handoffs");
280
+ handoff
281
+ .command("list")
282
+ .description("List the Project's Handoffs")
283
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
284
+ .option("--json", "emit the versioned machine envelope")
285
+ .action(async (options) => runHandoffList(options, program.opts()));
286
+ handoff
287
+ .command("show <id>")
288
+ .description("Show a Handoff (add --targets for the binding-scoped per-target manifest)")
289
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
290
+ .option("--targets", "list each target's path, operation, and base semantics")
291
+ .option("--json", "emit the versioned machine envelope")
292
+ .action(async (id, options) => runHandoffShow(id, options, program.opts()));
293
+ const sessions = program
294
+ .command("sessions")
295
+ .description("Sessions on this machine — the engine the desktop drives, and the record of what ran");
296
+ sessions
297
+ .command("serve")
298
+ .description("Run the long-lived session supervisor: JSONL frames on stdin/stdout (used by the NuStack desktop)")
299
+ .action(async () => runSessionsServe(version));
300
+ sessions
301
+ .command("record")
302
+ .description("Record the sessions one desktop scan found in a bound folder (used by the NuStack desktop)")
303
+ .option("--scan <json>", "the scan payload as JSON, or @<path> to read it from a file")
304
+ .option("--json", "emit the versioned machine envelope")
305
+ .action(async (options) => runSessionsRecord(options, program.opts()));
306
+ sessions
307
+ .command("list")
308
+ .description("List the sessions this machine's engine opened, and what each one's worktree and branch still hold")
309
+ .option("--json", "emit the versioned machine envelope")
310
+ .action(async (options) => runSessionsList(options));
311
+ sessions
312
+ .command("prune")
313
+ .description("Reclaim the worktree and branch of a session that has ended (preview unless --yes)")
314
+ .option("--session <id>", "reclaim one session instead of every reclaimable one")
315
+ .option("--yes", "apply the preview without prompting")
316
+ .option("--force", "also reclaim an unmerged or dirty session — never one whose engine is running")
317
+ .option("--json", "emit the versioned machine envelope")
318
+ .action(async (options) => runSessionsPrune(options));
319
+ const harness = program.command("harness").description("Mount the Project's harness and compare it with the cloud pin");
320
+ harness
321
+ .command("mount [path]")
322
+ .description("Mount the resolved harness version into a checkout (default: the current repository)")
323
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
324
+ .option("--manifest <url>", "mount this manifest instead of the one the Project resolves")
325
+ .option("--json", "emit the versioned machine envelope")
326
+ .action(async (target, options) => runHarnessMount(target, options, program.opts()));
327
+ harness
328
+ .command("status")
329
+ .description("Print the mounted harness against the cloud pin; exits 1 when they differ")
330
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
331
+ .option("--json", "emit the versioned machine envelope")
332
+ .action(async (options) => runHarnessStatus(options, program.opts()));
333
+ const toolkit = program.command("toolkit").description("Inspect and reconcile the Project's toolkit pin");
334
+ toolkit
335
+ .command("status")
336
+ .description("Compare the cloud pin against .nustack/workspace.json and nustack.service.json (F-026)")
337
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
338
+ .option("--json", "emit the versioned machine envelope")
339
+ .action(async (options) => runToolkitStatus(options, program.opts()));
340
+ toolkit
341
+ .command("reconcile")
342
+ .description("Preview and apply the deterministic toolkit repair (no User content is touched)")
343
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
344
+ .option("--yes", "apply the deterministic preview without prompting")
345
+ .option("--json", "emit the versioned machine envelope")
346
+ .action(async (options) => runToolkitReconcile(options, program.opts()));
347
+ const agent = program.command("agent").description("The discovery hook an agent uses to find the running NuStack desktop");
348
+ agent
349
+ .command("context")
350
+ .description("Print the SessionStart hook payload when the NuStack desktop is reachable (silent otherwise)")
351
+ .action(async () => runAgentContext());
352
+ const agentHook = agent.command("hook").description("Manage the `nustack agent context` SessionStart hook in ~/.claude/settings.json");
353
+ agentHook
354
+ .command("install")
355
+ .description("Register the SessionStart hook (idempotent — a second install is a no-op)")
356
+ .action(async () => runAgentHookInstall());
357
+ agentHook
358
+ .command("remove")
359
+ .description("Remove exactly the managed SessionStart hook entry")
360
+ .action(async () => runAgentHookRemove());
361
+ program
362
+ .command("census")
363
+ .description("Measure where your Claude Code tokens go, from your own transcripts, and recommend the large-read gate's threshold")
364
+ .option("--root <dir>", "the transcript root (default ~/.claude/projects)")
365
+ .option("--since <date>", "only sessions changed on or after this date (YYYY-MM-DD)")
366
+ .option("--max-sessions <n>", "only the most recently changed n sessions")
367
+ .option("--json", "emit the versioned machine envelope")
368
+ .action(async (options) => runCensus(options));
369
+ const gate = program.command("gate").description("The large-read gate: answer a large full read with an outline, once per session");
370
+ for (const subcommand of HOOK_SUBCOMMANDS) {
371
+ gate.command(subcommand)
372
+ .description(`Claude Code PreToolUse hook entry point (${subcommand}); reads the hook JSON on stdin`)
373
+ .action(async () => {
374
+ const { runGateHook } = await import("./lib/gate/hook.js");
375
+ await runGateHook(subcommand);
376
+ });
377
+ }
378
+ gate.command("install")
379
+ .description("Register the gate's Read and Bash hooks in ~/.claude/settings.json (idempotent)")
380
+ .action(async () => runGateInstall());
381
+ gate.command("remove")
382
+ .description("Remove exactly the gate's Read and Bash hook entries")
383
+ .action(async () => runGateRemove());
384
+ gate.command("status")
385
+ .description("Report the gate's hooks, the Local Reader switch, the threshold, the outline and the store version")
386
+ .option("--json", "emit the versioned machine envelope")
387
+ .action(async (options) => runGateStatus(options));
388
+ function describeError(error) {
389
+ const directCode = error?.code;
390
+ if (directCode === "PROJECT_CONTEXT_MISSING") {
391
+ return "no Project in context — run inside a bound repository (`.nustack/workspace.json`) or pass `--project <id>`.";
392
+ }
393
+ if (directCode === "ORGANIZATION_CONTEXT_MISSING") {
394
+ return "no Organization in context — run `nustack organization use <id>` or pass `--organization <id>`.";
395
+ }
396
+ if (directCode === "TOOLKIT_INCOMPATIBLE") {
397
+ return "the installed toolkit is incompatible with the Project's cloud pin — run `nustack toolkit reconcile`.";
398
+ }
399
+ const cause = error?.cause;
400
+ const code = cause?.code;
401
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
402
+ return "couldn't resolve the NuStack host — check your connection or pass --nustack-url.";
403
+ }
404
+ if (code === "ECONNREFUSED") {
405
+ return "the NuStack host refused the connection — is it up? Override with --nustack-url.";
406
+ }
407
+ if (error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError")) {
408
+ return "the request to NuStack timed out — check your connection or try again.";
409
+ }
410
+ if (error instanceof Error && error.message === "fetch failed") {
411
+ return "couldn't reach NuStack — check your connection or pass --nustack-url.";
412
+ }
413
+ return error instanceof Error ? error.message : String(error);
414
+ }
415
+ function searchSeparatorInvocation(rawArgv) {
416
+ const args = rawArgv.slice(2);
417
+ const searchIdx = args.indexOf("search");
418
+ if (searchIdx === -1)
419
+ return null;
420
+ if (searchIdx > 0 && args[searchIdx - 1] === "--nustack-url")
421
+ return null;
422
+ let cut = searchIdx + 1;
423
+ while (cut < args.length) {
424
+ const token = args[cut] ?? "";
425
+ if (token === "--category" || token === "--limit" || token === "--nustack-url") {
426
+ cut += 2;
427
+ }
428
+ else if (token.startsWith("--") && token !== "--") {
429
+ cut += 1;
430
+ }
431
+ else {
432
+ break;
433
+ }
434
+ }
435
+ if (args[cut] !== "--")
436
+ return null;
437
+ const beforeSeparator = args.slice(0, cut);
438
+ const literalKeywords = args.slice(cut + 1);
439
+ return {
440
+ run: async () => {
441
+ const scoped = new Command();
442
+ scoped.exitOverride();
443
+ scoped.name("nustack").option("--nustack-url <url>", "");
444
+ scoped
445
+ .command("search [keywords...]")
446
+ .option("--json", "")
447
+ .option("--category <cat>", "")
448
+ .option("--limit <n>", "", toLimit)
449
+ .action(async (keywords, options) => runSearch(keywords, options, scoped.opts()));
450
+ await scoped.parseAsync(["node", "nustack", ...beforeSeparator, ...literalKeywords]);
451
+ },
452
+ };
453
+ }
454
+ function isJsonInvocation(argv) {
455
+ return argv.slice(2).includes("--json");
456
+ }
457
+ function commandLabelFromArgv(argv) {
458
+ const args = argv.slice(2);
459
+ const parts = [];
460
+ for (let i = 0; i < args.length; i++) {
461
+ const token = args[i] ?? "";
462
+ if (token === "--nustack-url") {
463
+ i++;
464
+ continue;
465
+ }
466
+ if (token.startsWith("-"))
467
+ break;
468
+ parts.push(token);
469
+ }
470
+ return parts.length ? parts.join(" ") : "nustack";
471
+ }
472
+ function errorCodeOf(error) {
473
+ const direct = error?.code ?? error?.cause?.code;
474
+ return typeof direct === "string" ? direct : "UNEXPECTED";
475
+ }
476
+ try {
477
+ const separated = searchSeparatorInvocation(process.argv);
478
+ if (separated)
479
+ await separated.run();
480
+ else
481
+ await program.parseAsync(process.argv);
482
+ }
483
+ catch (error) {
484
+ if (isJsonInvocation(process.argv)) {
485
+ printJsonError(commandLabelFromArgv(process.argv), { code: errorCodeOf(error), message: describeError(error) });
486
+ }
487
+ else {
488
+ console.error(`nustack: ${describeError(error)}`);
489
+ process.exitCode = 1;
490
+ }
491
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/nustack",
3
- "version": "0.23.9",
3
+ "version": "0.23.10",
4
4
  "description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -35,8 +35,8 @@
35
35
  "@types/node": "^22.19.20",
36
36
  "typescript": "^5.7.2",
37
37
  "vitest": "^4.1.11",
38
- "@nurix/nustack.studio": "0.0.0",
39
38
  "@nurix/contracts": "2.0.1",
39
+ "@nurix/nustack.studio": "0.0.0",
40
40
  "@nurix/nustack.ui": "0.0.0"
41
41
  },
42
42
  "publishConfig": {