@vecteur/cli 0.3.1 → 0.4.0

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.
@@ -5,21 +5,20 @@ import { dirname, join } from "node:path";
5
5
  import { CliError } from "./errors.js";
6
6
 
7
7
  /**
8
- * ONE PERSISTENT LOGIN SRD 06's release bar for this door, beside "the same product from any
9
- * working directory".
8
+ * PERSISTENT LOGINS, ONE FILE, PER ORIGIN.
10
9
  *
11
- * Before this, `configuration()` read `VECTEUR_TOKEN` from the environment and nothing else, so
12
- * "logged in" lasted exactly as long as one shell. Every new terminal, every cron line and every
13
- * `cd` into a project began by pasting a credential again — which is not a login, and it is the
14
- * habit that puts secrets in shell history.
10
+ * SRD 06's release bar for this door is "one persistent login, the same product from any
11
+ * working directory". Before persistence, `configuration()` read `VECTEUR_TOKEN` from the
12
+ * environment and nothing else, so "logged in" lasted one shell.
15
13
  *
16
- * THE TOKEN IS NEVER AN ARGUMENT. `vecteur login` reads it from stdin. A credential passed as
17
- * `--token vct_…` is visible in `ps`, in `/proc/<pid>/cmdline` to every local user, and in the
18
- * shell history file afterwards the exact exposure `SHIP-007` exists to rotate away in this
19
- * same product. A door that ships a convenient way to make that mistake has shipped the mistake.
14
+ * THE TOKEN IS NEVER AN ARGUMENT and is never printed, not even a prefix. `vecteur login`
15
+ * acquires it through the browser device grant. A credential passed as `--token vct_…` is
16
+ * visible in `ps`, in `/proc/<pid>/cmdline` to every local user, and in the shell history
17
+ * file afterwards the exact exposure `SHIP-007` exists to rotate away in this same product.
20
18
  *
21
19
  * The store lives OUTSIDE every git tree, one file, 0600 — the same shape `scripts/with-secrets.py`
22
- * holds the fleet's own store in, for the same reason.
20
+ * holds the fleet's own store in, for the same reason. One file is the only store; there is no
21
+ * second reader.
23
22
  */
24
23
  const FILE_MODE = 0o600;
25
24
  const DIRECTORY_MODE = 0o700;
@@ -32,13 +31,65 @@ export function credentialsPath(env = process.env) {
32
31
  return join(base, "vecteur", "credentials.json");
33
32
  }
34
33
 
35
- /** `{ origin, token }` as stored, or `null` when nobody has logged in on this machine. */
34
+ function persistStore(store, env) {
35
+ const path = credentialsPath(env);
36
+ mkdirSync(dirname(path), { recursive: true, mode: DIRECTORY_MODE });
37
+ // Written 0600 AT CREATION, not chmod'ed after: between an 0644 create and a chmod there is a
38
+ // window in which any local user can read the token, and on a shared machine that window is
39
+ // the whole exposure.
40
+ writeFileSync(path, `${JSON.stringify({ current: store.current, logins: store.logins }, null, 2)}\n`, {
41
+ mode: FILE_MODE,
42
+ });
43
+ chmodSync(path, FILE_MODE); // an existing file keeps its old mode through writeFileSync
44
+ return path;
45
+ }
46
+
47
+ function unreadable(env) {
48
+ return new CliError("credentials_unreadable",
49
+ `${credentialsPath(env)} is not readable JSON — run \`vecteur logout --local\` and log in again`);
50
+ }
51
+
52
+ function isLogin(value) {
53
+ return value && typeof value === "object" && !Array.isArray(value)
54
+ && typeof value.token === "string"
55
+ && Object.keys(value).every(key => ["token", "project_id"].includes(key))
56
+ && (value.project_id === undefined || (typeof value.project_id === "string" && value.project_id.length > 0));
57
+ }
58
+
59
+ function fromLegacy(parsed) {
60
+ // Migrated in place: the 0.4.0 file is exactly `{origin, token}` strings, which is already
61
+ // one valid login, so rewriting it as `{current, logins}` keeps the credential and one store.
62
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
63
+ && Object.keys(parsed).sort().join("\n") === "origin\ntoken"
64
+ && typeof parsed.origin === "string" && typeof parsed.token === "string"
65
+ ? { current: parsed.origin, logins: { [parsed.origin]: { token: parsed.token } } }
66
+ : null;
67
+ }
68
+
69
+ function fromStore(parsed) {
70
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
71
+ && Object.keys(parsed).sort().join("\n") === "current\nlogins"
72
+ && typeof parsed.current === "string" && parsed.current.length > 0
73
+ && parsed.logins && typeof parsed.logins === "object" && !Array.isArray(parsed.logins)
74
+ && Object.entries(parsed.logins).every(([origin, login]) => origin.length > 0
75
+ && (typeof login === "string" || isLogin(login)))
76
+ && Object.hasOwn(parsed.logins, parsed.current)
77
+ ? { current: parsed.current, logins: Object.fromEntries(Object.entries(parsed.logins).map(([origin, login]) =>
78
+ [origin, typeof login === "string" ? { token: login } : login])) }
79
+ : null;
80
+ }
81
+
82
+ /**
83
+ * Per-origin `{token, project_id?}` logins, or `null` when nobody has logged in.
84
+ * Both legacy single-origin and string-map stores migrate in place on first read.
85
+ */
36
86
  export function readCredentials(env = process.env) {
37
87
  let raw;
38
88
  try {
39
89
  raw = readFileSync(credentialsPath(env), "utf8");
40
- } catch {
41
- return null;
90
+ } catch (error) {
91
+ if (error.code === "ENOENT") return null;
92
+ throw unreadable(env);
42
93
  }
43
94
  let parsed;
44
95
  try {
@@ -46,30 +97,66 @@ export function readCredentials(env = process.env) {
46
97
  } catch {
47
98
  // A corrupt store is a typed refusal, not a silent fall back to "not logged in": the second
48
99
  // reads as "run login again" and loses the fact that a credential IS there and unreadable.
49
- throw new CliError("credentials_unreadable",
50
- `${credentialsPath(env)} is not readable JSON — run \`vecteur logout\` and log in again`);
100
+ throw unreadable(env);
51
101
  }
52
- if (!parsed || typeof parsed !== "object" ||
53
- typeof parsed.origin !== "string" || typeof parsed.token !== "string") {
54
- throw new CliError("credentials_unreadable",
55
- `${credentialsPath(env)} does not hold an origin and a token`);
102
+ const store = fromStore(parsed);
103
+ if (store) {
104
+ if (Object.values(parsed.logins).some(login => typeof login === "string")) persistStore(store, env);
105
+ return store;
56
106
  }
57
- return { origin: parsed.origin, token: parsed.token };
107
+ const migrated = fromLegacy(parsed);
108
+ if (!migrated) throw unreadable(env);
109
+ persistStore(migrated, env);
110
+ return migrated;
58
111
  }
59
112
 
60
113
  export function writeCredentials({ origin, token }, env = process.env) {
61
- const path = credentialsPath(env);
62
- mkdirSync(dirname(path), { recursive: true, mode: DIRECTORY_MODE });
63
- // Written 0600 AT CREATION, not chmod'ed after: between an 0644 create and a chmod there is a
64
- // window in which any local user can read the token, and on a shared machine that window is
65
- // the whole exposure.
66
- writeFileSync(path, `${JSON.stringify({ origin, token }, null, 2)}\n`, { mode: FILE_MODE });
67
- chmodSync(path, FILE_MODE); // an existing file keeps its old mode through writeFileSync
68
- return path;
114
+ const saved = readCredentials(env) ?? { current: origin, logins: {} };
115
+ saved.logins[origin] = { token };
116
+ saved.current = origin;
117
+ return persistStore(saved, env);
118
+ }
119
+
120
+ export function setCurrentOrigin(origin, env = process.env) {
121
+ const saved = readCredentials(env);
122
+ if (!saved || !Object.hasOwn(saved.logins, origin) || saved.current === origin) return;
123
+ saved.current = origin;
124
+ persistStore(saved, env);
69
125
  }
70
126
 
71
- export function clearCredentials(env = process.env) {
127
+ export function clearCredentials(env = process.env, origin = undefined) {
72
128
  const path = credentialsPath(env);
73
- rmSync(path, { force: true });
129
+ if (origin === undefined) {
130
+ rmSync(path, { force: true });
131
+ return path;
132
+ }
133
+ let saved;
134
+ try {
135
+ saved = readCredentials(env);
136
+ } catch {
137
+ rmSync(path, { force: true });
138
+ return path;
139
+ }
140
+ if (!saved) {
141
+ rmSync(path, { force: true });
142
+ return path;
143
+ }
144
+ delete saved.logins[origin];
145
+ if (!Object.keys(saved.logins).length) {
146
+ rmSync(path, { force: true });
147
+ return path;
148
+ }
149
+ if (saved.current === origin) saved.current = Object.keys(saved.logins).sort()[0];
150
+ persistStore(saved, env);
74
151
  return path;
75
152
  }
153
+
154
+ /** Selection is an id only; every use resolves it against the live authorized list. */
155
+ export function selectProject(origin, projectId, env = process.env) {
156
+ if (env.VECTEUR_TOKEN) throw new CliError("persistent_login_required",
157
+ "project use requires a persistent login; unset VECTEUR_TOKEN and run `vecteur login`");
158
+ const saved = readCredentials(env);
159
+ if (!saved?.logins[origin]) throw new CliError("persistent_login_required", "run `vecteur login` first");
160
+ saved.logins[origin].project_id = projectId;
161
+ persistStore(saved, env);
162
+ }
@@ -0,0 +1,42 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { link, open, unlink } from "node:fs/promises";
3
+ import { basename, dirname, resolve } from "node:path";
4
+
5
+ import { CliError } from "./errors.js";
6
+
7
+ function defaultName(casRef) {
8
+ return `artifact-${casRef.slice("sha256:".length, "sha256:".length + 12)}`;
9
+ }
10
+
11
+ /**
12
+ * Stream admitted bytes to a private sibling, then publish with an atomic no-overwrite link.
13
+ * A remote filename never reaches this path: the caller either supplies --output or receives
14
+ * a digest-derived basename, so traversal and shell metacharacters cannot arrive from RunWire.
15
+ */
16
+ export async function downloadArtifact(client, casRef, projectId, output) {
17
+ const target = resolve(output ?? defaultName(casRef));
18
+ const temporary = resolve(dirname(target), `.vecteur-${basename(target)}-${randomUUID()}.part`);
19
+ let handle;
20
+ try {
21
+ handle = await open(temporary, "wx", 0o600);
22
+ const result = await client.artifactGet(casRef, projectId, chunk => handle.writeFile(chunk));
23
+ await handle.sync();
24
+ await handle.close();
25
+ handle = undefined;
26
+ try {
27
+ await link(temporary, target);
28
+ } catch (error) {
29
+ if (error?.code === "EEXIST") {
30
+ throw new CliError("output_exists", `refusing to overwrite ${target}`);
31
+ }
32
+ throw error;
33
+ }
34
+ return { ...result, path: target };
35
+ } catch (error) {
36
+ if (error instanceof CliError) throw error;
37
+ throw new CliError("download_failed", `could not safely write ${target}`);
38
+ } finally {
39
+ if (handle) await handle.close().catch(() => {});
40
+ await unlink(temporary).catch(() => {});
41
+ }
42
+ }
package/src/format.js CHANGED
@@ -1,4 +1,8 @@
1
- import { ORIGIN_KINDS, ORIGIN_WORD } from "./contract.js";
1
+ import {
2
+ ORIGIN_KINDS, ORIGIN_WORD, TERMINAL_FIELDS, TERMINAL_RECOVERY_DECLARATIONS,
3
+ validateArtifact, validateSynthesis,
4
+ } from "./contract.js";
5
+ import { CliError } from "./errors.js";
2
6
 
3
7
  export function canonicalJson(value) {
4
8
  validateNumbers(value);
@@ -67,13 +71,134 @@ export function inputLines(inputs) {
67
71
  // (orbit.csv)" is an answer. It is omitted only when the origin does not carry one.
68
72
  const ref = typeof row?.origin?.ref === "string" && row.origin.ref
69
73
  ? ` ${row.origin.ref}` : "";
70
- return ` ${row?.name ?? "?"} = ${JSON.stringify(row?.value)}${unit} (${word}${ref})`;
74
+ const rendered = `${row?.name ?? "?"} = ${JSON.stringify(row?.value)}${unit} (${word}${ref})`;
75
+ return ` ${plainLine(rendered)}`;
71
76
  });
72
77
  }
73
78
 
74
- export function human(value) {
79
+ function shellQuote(value) {
80
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
81
+ }
82
+
83
+ function safeArtifactName(artifact) {
84
+ const leaf = artifact.filename.replaceAll("\\", "/").split("/").at(-1);
85
+ if (!leaf || leaf === "." || leaf === ".." || leaf.startsWith("-") || leaf.startsWith(".")) {
86
+ return `artifact-${artifact.cas_ref.slice("sha256:".length, "sha256:".length + 12)}`;
87
+ }
88
+ return leaf;
89
+ }
90
+
91
+ function terminalPresentation(value) {
92
+ const terminalEvent = value.events.at(-1);
93
+ if (terminalEvent?.kind !== "terminal") {
94
+ throw new CliError("terminal_invalid", "Run record has no final terminal event");
95
+ }
96
+ const payload = terminalEvent.payload;
97
+ const allowed = new Set([...TERMINAL_FIELDS, "terminal"]);
98
+ if (Object.keys(payload).some(key => !allowed.has(key))) {
99
+ throw new CliError("terminal_invalid", "Run terminal carries an undeclared field");
100
+ }
101
+ if (payload.state !== undefined && payload.terminal !== undefined
102
+ && payload.state !== payload.terminal) {
103
+ throw new CliError("terminal_mismatch", "Run terminal carries conflicting states");
104
+ }
105
+ const state = payload.state ?? payload.terminal;
106
+ if (state !== value.terminal) {
107
+ throw new CliError("terminal_mismatch", "Run terminal event disagrees with its record");
108
+ }
109
+ const syntheses = value.events.filter(event => event.kind === "synthesis").map(event => event.payload);
110
+ const synthesis = syntheses.at(-1) ?? null;
111
+ if (synthesis !== null && !validateSynthesis(synthesis)) {
112
+ throw new CliError("synthesis_invalid", "Run carries malformed synthesis");
113
+ }
114
+ if (["ok", "partial"].includes(state)
115
+ && (!synthesis || synthesis.terminal !== state)) {
116
+ throw new CliError("synthesis_invalid", `Run terminal ${state} disagrees with its synthesis`);
117
+ }
118
+ if (payload.answer !== undefined
119
+ && (!synthesis || payload.answer !== synthesis.answer)) {
120
+ throw new CliError("synthesis_invalid", "Run terminal answer differs from its synthesis");
121
+ }
122
+
123
+ const defect = payload.defect;
124
+ const recovery = payload.recovery;
125
+ if (!["ok", "partial"].includes(state) && defect === undefined) {
126
+ throw new CliError("recovery_invalid", `Run terminal ${state} carries no typed cause/recovery`);
127
+ }
128
+ if ((defect === undefined) !== (recovery === undefined)) {
129
+ throw new CliError("recovery_invalid", "Run terminal cause and recovery must travel together");
130
+ }
131
+ if (defect !== undefined) {
132
+ const declarations = TERMINAL_RECOVERY_DECLARATIONS[defect];
133
+ if (!declarations || !Array.isArray(recovery) || recovery.length === 0) {
134
+ throw new CliError("recovery_invalid", "Run terminal recovery is not owner-declared");
135
+ }
136
+ for (const action of recovery) {
137
+ const fields = action && typeof action === "object" && !Array.isArray(action)
138
+ ? Object.keys(action).sort().join(",") : "";
139
+ const declaration = declarations.find(([capability]) => capability === action?.capability);
140
+ const argumentsValue = action?.arguments;
141
+ if (fields !== "arguments,capability" || !declaration || !argumentsValue
142
+ || typeof argumentsValue !== "object" || Array.isArray(argumentsValue)
143
+ || Object.keys(argumentsValue).sort().join(",") !== [...declaration[1]].sort().join(",")
144
+ || Object.values(argumentsValue).some(item => !(
145
+ (typeof item === "string" && item.length > 0)
146
+ || (Number.isSafeInteger(item) && item > 0)
147
+ ))) {
148
+ throw new CliError("recovery_invalid", "Run terminal recovery action is malformed");
149
+ }
150
+ }
151
+ }
152
+ return { state, answer: payload.answer, defect, recovery: recovery ?? [] };
153
+ }
154
+
155
+ function recoveryLine(action) {
156
+ const args = Object.entries(action.arguments)
157
+ .map(([name, value]) => `${name}=${JSON.stringify(value)}`).join(" ");
158
+ return `Recovery: ${action.capability}${args ? ` ${args}` : ""}`;
159
+ }
160
+
161
+ export function humanRun(value, { projectId, cost } = {}) {
162
+ const terminal = terminalPresentation(value);
163
+ const origins = value.events.flatMap(event => event.kind === "step_finished"
164
+ ? inputLines(event.payload?.step?.inputs ?? event.payload?.inputs) : []);
165
+ const artifacts = value.events.filter(event => event.kind === "artifact").map(event => event.payload);
166
+ if (!artifacts.every(validateArtifact)) {
167
+ throw new CliError("artifact_invalid", "Run carries malformed artifact metadata");
168
+ }
169
+ const lines = [];
170
+ if (terminal.answer !== undefined) lines.push(terminal.answer, "");
171
+ if (origins.length) lines.push("Inputs and origins:", ...origins, "");
172
+ if (artifacts.length) {
173
+ lines.push("Artifacts:");
174
+ for (const artifact of artifacts) {
175
+ lines.push(` ${artifact.filename} (${artifact.kind}, ${artifact.id})`);
176
+ const project = projectId ? ` --project ${shellQuote(projectId)}` : "";
177
+ lines.push(` vecteur artifact get ${shellQuote(artifact.cas_ref)} --output ${shellQuote(safeArtifactName(artifact))}${project}`);
178
+ }
179
+ lines.push("");
180
+ }
181
+ lines.push(`Run: ${plainLine(value.run_id)}`, `Terminal: ${terminal.state}`);
182
+ if (terminal.defect !== undefined) lines.push(`Cause: ${terminal.defect}`);
183
+ lines.push(...terminal.recovery.map(recoveryLine));
184
+ if (cost) lines.push("", humanCost(cost).trimEnd());
185
+ return `${lines.join("\n")}\n`;
186
+ }
187
+
188
+ export function humanCost(value) {
189
+ const lines = [`Project cost (UTC month, ${value.coverage} coverage):`];
190
+ for (const [label, amount] of [
191
+ ["Used", value.display.used], ["Limit", value.display.limit], ["Remaining", value.display.remaining],
192
+ ]) {
193
+ if (amount !== null) lines.push(` ${label}: ${amount}`);
194
+ }
195
+ if (value.display.used === null) lines.push(" No exact amount is available.");
196
+ return `${lines.join("\n")}\n`;
197
+ }
198
+
199
+ export function human(value, options = {}) {
75
200
  if (value && typeof value === "object" && typeof value.run_id === "string") {
76
- return `Run ${value.run_id}: ${value.terminal ?? value.state_hash ?? "accepted"}\n`;
201
+ return humanRun(value, options);
77
202
  }
78
203
  if (value && typeof value === "object" && typeof value.name === "string") {
79
204
  return `${value.name} (${value.id})\n`;
@@ -82,10 +207,24 @@ export function human(value) {
82
207
  }
83
208
 
84
209
  export function progress(event) {
85
- const head = `[${event.seq}] ${event.kind}\n`;
86
- // A finished step is where a run's evidence becomes visible: its inputs each carry an
87
- // origin. Every other kind keeps the one-line shape.
88
- if (event?.kind !== "step_finished") return head;
210
+ if (event?.kind === "step_started") {
211
+ const title = plainLine(event.payload?.title ?? "");
212
+ return title.trim() ? `Starting: ${title}\n` : "Starting\n";
213
+ }
214
+ if (event?.kind === "step_label") {
215
+ const label = plainLine(event.payload?.label ?? "");
216
+ return label.trim() ? `Working: ${label}\n` : "";
217
+ }
218
+ if (event?.kind === "artifact") {
219
+ return `File ready: ${plainLine(event.payload.filename)}\n`;
220
+ }
221
+ if (event?.kind !== "step_finished") return "";
89
222
  const lines = inputLines(event?.payload?.step?.inputs ?? event?.payload?.inputs);
223
+ const title = plainLine(event.payload?.step?.title ?? event.payload?.title ?? "");
224
+ const head = title.trim() ? `Finished: ${title}\n` : "Finished\n";
90
225
  return lines.length ? `${head}${lines.join("\n")}\n` : head;
91
226
  }
227
+
228
+ function plainLine(value) {
229
+ return String(value).replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ");
230
+ }
package/src/mcp.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
2
3
  import * as z from "zod/v4";
3
4
 
4
- import { CliError, VecteurClient } from "./client.js";
5
+ import { CliError, VecteurClient, redact } from "./client.js";
5
6
  import { EVENT_KINDS } from "./contract.js";
6
7
  import { canonicalJson } from "./format.js";
7
8
 
@@ -19,23 +20,33 @@ const eventOutput = z.object({
19
20
  }).strict();
20
21
  const memberOutput = z.object({
21
22
  user_id: z.string().min(1), email: z.string().min(1), display_name: z.string().min(1),
22
- role: z.enum(["OWNER", "ADMIN", "EDITOR", "VIEWER"]),
23
+ role: z.enum(["OWNER", "EDITOR", "VIEWER"]),
23
24
  }).strict();
24
25
  const projectOutput = z.object({
25
26
  id: z.string().min(1), name: z.string().min(1), org_id: z.string().min(1),
26
- workspace_id: z.string().min(1), role: z.enum(["OWNER", "ADMIN", "EDITOR", "VIEWER"]),
27
- role_source: z.enum(["workspace", "project"]), members: z.array(memberOutput),
27
+ workspace_id: z.string().min(1), role: z.enum(["OWNER", "EDITOR", "VIEWER"]),
28
+ members: z.array(memberOutput),
28
29
  created_at: z.number().int().safe(), updated_at: z.number().int().safe(),
29
30
  }).strict();
31
+ const billableDimensionsOutput = z.object({
32
+ uncached_input: z.number().int().safe().nonnegative(),
33
+ cache_read: z.number().int().safe().nonnegative(),
34
+ cache_write: z.number().int().safe().nonnegative(),
35
+ output: z.number().int().safe().nonnegative(),
36
+ cache_write_class: z.string().min(1).nullable().optional(),
37
+ cache_write_duration_s: z.number().int().safe().nonnegative().nullable().optional(),
38
+ provider_tier: z.string().min(1).nullable().optional(),
39
+ long_context_band: z.string().min(1).nullable().optional(),
40
+ }).strict();
30
41
  const admittedOutput = z.object({
31
42
  run_id: z.string().min(1), state_hash: z.string().min(1), result_hash: z.string().min(1),
32
- profile: z.string().min(1),
43
+ requested_route_id: z.string().min(1), profile: z.string().min(1),
33
44
  terminal: z.enum(["ok", "partial", "blocked", "refused", "infeasible_physics", "capability_gap"]),
45
+ duration_seconds: z.number().int().safe().nonnegative().nullable().optional(),
34
46
  usage: z.object({
35
- state: z.enum(["recorded", "usage_unavailable"]), provider: z.string().min(1),
36
- model_id: z.string().min(1), input_tokens: z.number().int().safe().nonnegative().nullable(),
37
- output_tokens: z.number().int().safe().nonnegative().nullable(),
38
- cost_usd: z.number().finite().nonnegative().nullable(),
47
+ coverage: z.enum(["complete", "incomplete"]), provider_id: z.string().min(1),
48
+ model_id: z.string().min(1), observed_at: z.number().int().safe().nonnegative(),
49
+ billable_dimensions: z.array(billableDimensionsOutput).min(1),
39
50
  }).strict(),
40
51
  events: z.array(eventOutput),
41
52
  }).strict();
@@ -96,3 +107,29 @@ export function createVecteurMcpServer(client = new VecteurClient(), shutdownSig
96
107
 
97
108
  return server;
98
109
  }
110
+
111
+ export function serveVecteurMcp() {
112
+ const shutdown = new AbortController();
113
+ const handle = serveStdio(() => createVecteurMcpServer(undefined, shutdown.signal), {
114
+ onerror(error) {
115
+ process.stderr.write(`mcp_transport_error: ${redact(error?.message ?? "MCP transport failed")}\n`);
116
+ },
117
+ });
118
+
119
+ let closing;
120
+ function close() {
121
+ if (!closing) {
122
+ shutdown.abort();
123
+ closing = handle.close().catch((error) => {
124
+ process.stderr.write(`mcp_close_error: ${redact(error?.message ?? "MCP close failed")}\n`);
125
+ process.exitCode = 1;
126
+ });
127
+ }
128
+ return closing;
129
+ }
130
+
131
+ process.stdin.once("end", () => { void close(); });
132
+ for (const signal of ["SIGINT", "SIGTERM"]) {
133
+ process.once(signal, () => { void close(); });
134
+ }
135
+ }
@@ -0,0 +1,42 @@
1
+ import { CliError } from "./errors.js";
2
+
3
+ /** Resolve only server-authorized objects. Ids take precedence over names. */
4
+ export function resolveNamed(rows, value, kind) {
5
+ const byId = rows.find(row => row.id === value);
6
+ if (byId) return byId;
7
+ const matches = rows.filter(row => row.name === value);
8
+ if (matches.length === 1) return matches[0];
9
+ if (matches.length > 1) throw new CliError(`${kind}_ambiguous`,
10
+ `${value} matches multiple ${kind}s: ${matches.map(row => row.id).join(", ")}; use an id`);
11
+ throw new CliError(`${kind}_not_found`,
12
+ `${value} is not an authorized ${kind}; available: ${describe(rows)}`);
13
+ }
14
+
15
+ const describe = rows => rows.map(row => `${row.name} (${row.id})`).join(", ") || "none";
16
+
17
+ export function resolveWorkspace(account, value) {
18
+ const rows = account.workspaces;
19
+ if (value !== undefined) return resolveNamed(rows, value, "workspace").id;
20
+ if (rows.length === 1) return rows[0].id;
21
+ throw new CliError("workspace_required", `Choose --workspace <name-or-id>: ${describe(rows)}`);
22
+ }
23
+
24
+ export async function resolveRunProject(client, explicitId) {
25
+ if (explicitId !== undefined) return explicitId;
26
+ const selected = client.config.projectId;
27
+ if (!selected) throw new CliError("project_selection_required",
28
+ "Select a Project with `vecteur project use <name-or-id>` after persistent login, or pass --project <id>");
29
+ const projects = await client.projectList();
30
+ if (!projects.some(project => project.id === selected)) {
31
+ throw new CliError("project_not_found",
32
+ `Selected Project ${selected} is no longer authorized; run \`vecteur project use <name-or-id>\``);
33
+ }
34
+ return selected;
35
+ }
36
+
37
+ export function humanProjects(value) {
38
+ const rows = value.projects.map(project =>
39
+ `${project.id === value.selected_project_id ? "*" : " "} ${project.name} (${project.id}) ${project.role}`);
40
+ const allowance = value.active_projects;
41
+ return [...rows, `Active projects: ${allowance.current} / ${allowance.maximum ?? "Contract limit"}`].join("\n") + "\n";
42
+ }
@@ -1,29 +0,0 @@
1
- #!/usr/bin/env node
2
- import { serveStdio } from "@modelcontextprotocol/server/stdio";
3
-
4
- import { redact } from "../src/client.js";
5
- import { createVecteurMcpServer } from "../src/mcp.js";
6
-
7
- const shutdown = new AbortController();
8
- const handle = serveStdio(() => createVecteurMcpServer(undefined, shutdown.signal), {
9
- onerror(error) {
10
- process.stderr.write(`mcp_transport_error: ${redact(error?.message ?? "MCP transport failed")}\n`);
11
- },
12
- });
13
-
14
- let closing;
15
- function close() {
16
- if (!closing) {
17
- shutdown.abort();
18
- closing = handle.close().catch((error) => {
19
- process.stderr.write(`mcp_close_error: ${redact(error?.message ?? "MCP close failed")}\n`);
20
- process.exitCode = 1;
21
- });
22
- }
23
- return closing;
24
- }
25
-
26
- process.stdin.once("end", () => { void close(); });
27
- for (const signal of ["SIGINT", "SIGTERM"]) {
28
- process.once(signal, () => { void close(); });
29
- }