@yagni-app/code 1.0.9 → 1.1.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.
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { Box, Container, HStack, Key, SelectList, Text, matchesKey, } from "@earendil-works/pi-tui";
22
22
  import { mergeRulesIntoSandbox } from "./config.js";
23
+ import { resolveWorktreeGitAccess } from "./worktreeGit.js";
23
24
  // ---------------------------------------------------------------------------
24
25
  // Pure derivation helpers (unit-tested; no TUI dependency)
25
26
  // ---------------------------------------------------------------------------
@@ -335,7 +336,7 @@ export function buildPanelState(settings, sessionToggledOff, rules, paths, sessi
335
336
  sessionToggledOff,
336
337
  dependencyErrors: dependencyStatus.errors,
337
338
  dependencyWarnings: dependencyStatus.warnings,
338
- merge: mergeRulesIntoSandbox(settings, rules, paths),
339
+ merge: mergeRulesIntoSandbox(settings, rules, paths, resolveWorktreeGitAccess(paths.cwd)),
339
340
  sessionGrants: [...sessionGrants],
340
341
  };
341
342
  }
@@ -22,6 +22,7 @@ import { codeStateHome } from "../stateHome.js";
22
22
  import { isDebug } from "../diagnostics.js";
23
23
  import { mutateConfigJson, mutateLocalConfig } from "../settingsFiles.js";
24
24
  import { loadSandboxSettings } from "./config.js";
25
+ import { resolveWorktreeGitAccess } from "./worktreeGit.js";
25
26
  import { annotateCommandOutput, makeSandboxSpawnHook, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
26
27
  import { YagniSandboxManager } from "./manager.js";
27
28
  import { SandboxPanel, buildPanelState } from "./panel.js";
@@ -68,6 +69,12 @@ export function makeBashComposition(manager, settings, cwd) {
68
69
  ? `Network: only these domains (wildcards ok): ${allowed.join(", ")}`
69
70
  : "Network: no domains pre-allowed — the first contact to each host prompts the user");
70
71
  restrictions.push(`Filesystem writes: working directory, /tmp, and paths granted by Edit(...) allow rules`);
72
+ // A linked-worktree session additionally allows the shared common git
73
+ // dir (git operations work sandboxed there); the description must say
74
+ // so or the model under-reports what it can do.
75
+ if (resolveWorktreeGitAccess(cwd)) {
76
+ restrictions.push("Filesystem writes (git): the linked worktree's shared .git directory — git operations (add/commit/branch/checkout) are allowed and should run sandboxed; its hooks/ and config remain read-only");
77
+ }
71
78
  if (s.filesystem?.denyWrite?.length)
72
79
  restrictions.push(`Denied writes: ${s.filesystem.denyWrite.join(", ")}`);
73
80
  const strictNote = s.allowUnsandboxedCommands === false
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Linked-worktree git resolution for the sandbox. When the session cwd is
3
+ * the root of a linked worktree, routine git writes (status/add/commit/
4
+ * branch/checkout) land in the MAIN repo's shared `.git` directory —
5
+ * outside the sandbox's default write allowlist ({cwd, tmpdir()}), so every
6
+ * one of them fails with EPERM and forces the dangerouslyDisableSandbox
7
+ * retry path. Resolving the shared common git dir here lets the sandbox
8
+ * allow exactly that directory (never the main checkout's working tree).
9
+ *
10
+ * Resolution chain (Claude Code's resolveCanonicalRoot, src/utils/git.ts,
11
+ * applied to the sandbox problem — Claude's own sandbox side only does a
12
+ * shape check on the gitdir path, which would trust an attacker-crafted
13
+ * `.git` file; the full chain here is deliberately stricter):
14
+ * cwd/.git file → `gitdir:` → resolve against cwd → <gitdir>/commondir →
15
+ * resolve against gitdir → common git dir. Both validation checks from
16
+ * the reference are enforced before anything is returned:
17
+ * 1. structural — the worktree gitdir is a direct child of
18
+ * <commonDir>/worktrees/ (the commondir we read lives where git put it,
19
+ * not wherever an attacker's `.git` file pointed);
20
+ * 2. back-link — <gitdir>/gitdir points back at THIS cwd's .git (an
21
+ * attacker cannot borrow an existing worktree entry of another repo).
22
+ *
23
+ * Fail-through cases (all return null, no behavior change vs the
24
+ * pre-worktree-allow sandbox):
25
+ * plain main checkout (.git is a directory → EISDIR), no repo at all,
26
+ * submodule (.git file but no commondir), failed structural/back-link
27
+ * validation, any read/parse error.
28
+ *
29
+ * Bare-repo worktrees are supported: there the common dir is the bare repo
30
+ * itself (`<bare>/worktrees/<name>/commondir` → `<bare>`), so the allowed
31
+ * directory IS the common dir (Claude's `/.git/worktrees/` shape marker
32
+ * silently no-ops there; ours resolves it).
33
+ *
34
+ * Root-only by design (Claude sandbox-side parity): a session started in a
35
+ * worktree SUBDIRECTORY keeps the pre-existing behavior — the `.git` file
36
+ * sits only at the worktree root.
37
+ */
38
+ /** Resolved write-target facts for a linked-worktree session. */
39
+ export interface WorktreeGitAccess {
40
+ /** The shared common git dir git writes actually land in. */
41
+ commonGitDir: string;
42
+ /** Realpath'd worktree root (safe.directory needs the on-disk spelling). */
43
+ worktreeRoot: string;
44
+ }
45
+ /**
46
+ * Resolve the shared common git dir when cwd is a linked worktree root.
47
+ * Returns null for every non-worktree / untrusted shape — callers treat
48
+ * null as "no extra entries" and never widen the sandbox.
49
+ *
50
+ * Null REASONS are surfaced (debug level, paths only) whenever a `gitdir:`
51
+ * .git file existed but validation refused: a legitimate worktree that
52
+ * silently misses the allow is indistinguishable from "not a worktree"
53
+ * without them. The plain-repo / no-repo fall-throughs stay silent — they
54
+ * are the common case and carry no signal.
55
+ */
56
+ export declare function resolveWorktreeGitAccess(cwd: string): WorktreeGitAccess | null;
57
+ //# sourceMappingURL=worktreeGit.d.ts.map
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Linked-worktree git resolution for the sandbox. When the session cwd is
3
+ * the root of a linked worktree, routine git writes (status/add/commit/
4
+ * branch/checkout) land in the MAIN repo's shared `.git` directory —
5
+ * outside the sandbox's default write allowlist ({cwd, tmpdir()}), so every
6
+ * one of them fails with EPERM and forces the dangerouslyDisableSandbox
7
+ * retry path. Resolving the shared common git dir here lets the sandbox
8
+ * allow exactly that directory (never the main checkout's working tree).
9
+ *
10
+ * Resolution chain (Claude Code's resolveCanonicalRoot, src/utils/git.ts,
11
+ * applied to the sandbox problem — Claude's own sandbox side only does a
12
+ * shape check on the gitdir path, which would trust an attacker-crafted
13
+ * `.git` file; the full chain here is deliberately stricter):
14
+ * cwd/.git file → `gitdir:` → resolve against cwd → <gitdir>/commondir →
15
+ * resolve against gitdir → common git dir. Both validation checks from
16
+ * the reference are enforced before anything is returned:
17
+ * 1. structural — the worktree gitdir is a direct child of
18
+ * <commonDir>/worktrees/ (the commondir we read lives where git put it,
19
+ * not wherever an attacker's `.git` file pointed);
20
+ * 2. back-link — <gitdir>/gitdir points back at THIS cwd's .git (an
21
+ * attacker cannot borrow an existing worktree entry of another repo).
22
+ *
23
+ * Fail-through cases (all return null, no behavior change vs the
24
+ * pre-worktree-allow sandbox):
25
+ * plain main checkout (.git is a directory → EISDIR), no repo at all,
26
+ * submodule (.git file but no commondir), failed structural/back-link
27
+ * validation, any read/parse error.
28
+ *
29
+ * Bare-repo worktrees are supported: there the common dir is the bare repo
30
+ * itself (`<bare>/worktrees/<name>/commondir` → `<bare>`), so the allowed
31
+ * directory IS the common dir (Claude's `/.git/worktrees/` shape marker
32
+ * silently no-ops there; ours resolves it).
33
+ *
34
+ * Root-only by design (Claude sandbox-side parity): a session started in a
35
+ * worktree SUBDIRECTORY keeps the pre-existing behavior — the `.git` file
36
+ * sits only at the worktree root.
37
+ */
38
+ import { readFileSync, realpathSync } from "node:fs";
39
+ import { dirname, join, resolve } from "node:path";
40
+ import { logEvent } from "../errorSink.js";
41
+ import { isDebug } from "../diagnostics.js";
42
+ /**
43
+ * Resolve the shared common git dir when cwd is a linked worktree root.
44
+ * Returns null for every non-worktree / untrusted shape — callers treat
45
+ * null as "no extra entries" and never widen the sandbox.
46
+ *
47
+ * Null REASONS are surfaced (debug level, paths only) whenever a `gitdir:`
48
+ * .git file existed but validation refused: a legitimate worktree that
49
+ * silently misses the allow is indistinguishable from "not a worktree"
50
+ * without them. The plain-repo / no-repo fall-throughs stay silent — they
51
+ * are the common case and carry no signal.
52
+ */
53
+ export function resolveWorktreeGitAccess(cwd) {
54
+ let gitContent;
55
+ try {
56
+ gitContent = readFileSync(join(cwd, ".git"), "utf-8").trim();
57
+ }
58
+ catch {
59
+ // No .git, or .git is a directory (plain main checkout → EISDIR).
60
+ return null;
61
+ }
62
+ if (!gitContent.startsWith("gitdir:"))
63
+ return null;
64
+ // gitdir may be relative (rare, but git accepts it) — resolve against cwd.
65
+ const worktreeGitDir = resolve(cwd, gitContent.slice("gitdir:".length).trim());
66
+ const debugRefused = (reason) => {
67
+ if (!isDebug())
68
+ return;
69
+ logEvent({
70
+ source: "sandbox",
71
+ level: "debug",
72
+ event: "sandbox_worktree_git_refused",
73
+ fields: { reason, worktreeGitDir },
74
+ });
75
+ };
76
+ let commonContent;
77
+ try {
78
+ // Submodules have a .git file but no commondir (ENOENT → fall through).
79
+ commonContent = readFileSync(join(worktreeGitDir, "commondir"), "utf-8").trim();
80
+ }
81
+ catch {
82
+ debugRefused("no-commondir");
83
+ return null;
84
+ }
85
+ const commonDir = resolve(worktreeGitDir, commonContent);
86
+ // Structural check: the worktree gitdir must be a direct child of
87
+ // <commonDir>/worktrees — the commondir we just read must live inside the
88
+ // common dir it named, not at an arbitrary attacker-chosen path. win32
89
+ // compares case-insensitively (git preserves the casing used at worktree
90
+ // creation, so genuine worktrees can carry casing drift between the
91
+ // gitdir spelling and the commondir resolution); POSIX compares exactly.
92
+ const structuralMatches = process.platform === "win32"
93
+ ? dirname(worktreeGitDir).toLowerCase() === join(commonDir, "worktrees").toLowerCase()
94
+ : dirname(worktreeGitDir) === join(commonDir, "worktrees");
95
+ if (!structuralMatches) {
96
+ debugRefused("structural-mismatch");
97
+ return null;
98
+ }
99
+ // Back-link check: git writes <worktreeGitDir>/gitdir pointing back at
100
+ // this worktree's .git. Realpath the worktree root (not the .git entry —
101
+ // a symlinked .git must not be followed) so legitimate worktrees reached
102
+ // through a symlinked path (macOS /tmp → /private/tmp) still validate.
103
+ // win32: git's strbuf_realpath expands 8.3 short names (C:\\RUNNER~1 →
104
+ // runneradmin) when it writes the back-link, while Node's JS-side
105
+ // realpath does not — both sides go through realpathSync.native (which
106
+ // expands them) and compare case-insensitively, or every genuine
107
+ // worktree under a short-named TEMP would fail closed on spelling.
108
+ // POSIX compares exactly (symlinks already resolved by realpath).
109
+ let rawBacklink;
110
+ try {
111
+ rawBacklink = readFileSync(join(worktreeGitDir, "gitdir"), "utf-8").trim();
112
+ }
113
+ catch {
114
+ debugRefused("no-backlink");
115
+ return null;
116
+ }
117
+ let backlink;
118
+ try {
119
+ backlink = process.platform === "win32" ? realpathSync.native(rawBacklink) : realpathSync(rawBacklink);
120
+ }
121
+ catch {
122
+ // Exists but cannot be canonicalized: dangling symlink, permissions —
123
+ // a different failure than an absent back-link, and worth its own code.
124
+ debugRefused("backlink-unreadable");
125
+ return null;
126
+ }
127
+ let realCwd;
128
+ try {
129
+ realCwd = process.platform === "win32" ? realpathSync.native(cwd) : realpathSync(cwd);
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ const backlinkMatches = process.platform === "win32"
135
+ ? backlink.toLowerCase() === join(realCwd, ".git").toLowerCase()
136
+ : backlink === join(realCwd, ".git");
137
+ if (!backlinkMatches) {
138
+ debugRefused("backlink-mismatch");
139
+ return null;
140
+ }
141
+ return {
142
+ commonGitDir: safeRealpath(commonDir),
143
+ worktreeRoot: realCwd,
144
+ };
145
+ }
146
+ /** realpath that tolerates a vanished path (returns the input spelling) —
147
+ * post-validation only, so a stale spelling can never widen anything: the
148
+ * validation above already proved the entry structure exists. */
149
+ function safeRealpath(p) {
150
+ try {
151
+ return realpathSync(p);
152
+ }
153
+ catch {
154
+ return p;
155
+ }
156
+ }
157
+ //# sourceMappingURL=worktreeGit.js.map
@@ -38,6 +38,13 @@ export interface McpDeps {
38
38
  }
39
39
  /** The structural slice of the extension's mcp config surface this file needs. */
40
40
  export interface McpCliModule {
41
+ authenticate?(serverName: string, config: {
42
+ type: "http";
43
+ url: string;
44
+ tools: string[];
45
+ }): Promise<{
46
+ result: "AUTHORIZED";
47
+ }>;
41
48
  loadMcpServers(cwd: string, env: NodeJS.ProcessEnv): McpLoadResult;
42
49
  mcpConfigPath(): string;
43
50
  PROJECT_CONFIG_FILENAME: string;
@@ -21,7 +21,7 @@ import { fileURLToPath } from "node:url";
21
21
  import { CLAUDE_PLUGIN_MCP_ENV, claudeCompatArgs } from "./claudeCompat.js";
22
22
  import { agentDir } from "./credentials.js";
23
23
  import { DISTRIBUTION } from "./distribution.js";
24
- import { getActiveProfileName } from "./profiles.js";
24
+ import { getActiveProfileName, readActiveProfile } from "./profiles.js";
25
25
  export function resolveMcpConfigPath() {
26
26
  const bundled = fileURLToPath(new URL("./extension/mcp/cliConfig.js", import.meta.url));
27
27
  if (existsSync(bundled))
@@ -55,6 +55,9 @@ Scopes:
55
55
  user available in all your projects (~/.yagni-code/mcp.json)
56
56
  project shared via .mcp.json at the repo root (approval-gated)
57
57
 
58
+ Connect YAGNI Workers:
59
+ ${DISTRIBUTION.commandName} mcp connect-workers Browser consent for the active environment
60
+
58
61
  Examples:
59
62
  ${DISTRIBUTION.commandName} mcp add --transport http sentry https://mcp.sentry.dev/mcp
60
63
  ${DISTRIBUTION.commandName} mcp add -e API_KEY=xxx my-server -- npx my-mcp-server
@@ -90,7 +93,10 @@ export function parseMcpArgs(argv) {
90
93
  rest.push(...after.slice(i + 1));
91
94
  break;
92
95
  }
93
- if (flag === "s" || flag === "t" || flag === "client-id" || flag === "callback-port") {
96
+ if (flag === "s" ||
97
+ flag === "t" ||
98
+ flag === "client-id" ||
99
+ flag === "callback-port") {
94
100
  if (flag === "s") {
95
101
  scope = scopeFrom(arg);
96
102
  scopeExplicit = true;
@@ -204,11 +210,28 @@ export function parseMcpArgs(argv) {
204
210
  }
205
211
  rest.push(arg);
206
212
  }
207
- if (flag === "s" || flag === "t" || flag === "client-id" || flag === "callback-port") {
213
+ if (flag === "s" ||
214
+ flag === "t" ||
215
+ flag === "client-id" ||
216
+ flag === "callback-port") {
208
217
  throw new Error("Missing flag value.");
209
218
  }
210
219
  const [name, ...commandArgs] = rest;
211
- return { subcommand, name, rest, scope, scopeExplicit, transport, transportExplicit, env, headers, commandArgs, clientId, clientSecret, callbackPort };
220
+ return {
221
+ subcommand,
222
+ name,
223
+ rest,
224
+ scope,
225
+ scopeExplicit,
226
+ transport,
227
+ transportExplicit,
228
+ env,
229
+ headers,
230
+ commandArgs,
231
+ clientId,
232
+ clientSecret,
233
+ callbackPort,
234
+ };
212
235
  }
213
236
  function scopeFrom(value) {
214
237
  if (value === "local" || value === "user" || value === "project")
@@ -236,7 +259,11 @@ function describeScopePath(scope, cwd) {
236
259
  async function defaultPluginMcpEnv(cwd) {
237
260
  try {
238
261
  const profile = await getActiveProfileName();
239
- const compat = await claudeCompatArgs({ cwd, agentDir: agentDir(profile), interactive: false });
262
+ const compat = await claudeCompatArgs({
263
+ cwd,
264
+ agentDir: agentDir(profile),
265
+ interactive: false,
266
+ });
240
267
  return compat.env[CLAUDE_PLUGIN_MCP_ENV];
241
268
  }
242
269
  catch {
@@ -272,6 +299,8 @@ export async function mcpCommand(args, deps = {}) {
272
299
  return 1;
273
300
  }
274
301
  switch (parsed.subcommand) {
302
+ case "connect-workers":
303
+ return connectWorkers(mod, parsed, { cwd, stdout, stderr }, deps.env ?? process.env);
275
304
  case undefined:
276
305
  case "help":
277
306
  stdout(USAGE);
@@ -283,18 +312,105 @@ export async function mcpCommand(args, deps = {}) {
283
312
  case "remove":
284
313
  return mcpRemove(mod, parsed, { cwd, stdout, stderr });
285
314
  case "list":
286
- return mcpList(mod, { cwd, stdout, stderr, env: await envWithPluginMcp(deps, cwd), probeServer: deps.probeServer });
315
+ return mcpList(mod, {
316
+ cwd,
317
+ stdout,
318
+ stderr,
319
+ env: await envWithPluginMcp(deps, cwd),
320
+ probeServer: deps.probeServer,
321
+ });
287
322
  case "get":
288
- return mcpGet(mod, parsed, { cwd, stdout, stderr, env: await envWithPluginMcp(deps, cwd), probeServer: deps.probeServer });
323
+ return mcpGet(mod, parsed, {
324
+ cwd,
325
+ stdout,
326
+ stderr,
327
+ env: await envWithPluginMcp(deps, cwd),
328
+ probeServer: deps.probeServer,
329
+ });
289
330
  case "reset-project-choices":
290
331
  return mcpResetChoices(mod, { cwd, stdout, stderr });
291
332
  case "add-from-claude":
292
- return mcpAddFromClaude(mod, parsed, { cwd, stdout, stderr, home: deps.home ?? homedir() });
333
+ return mcpAddFromClaude(mod, parsed, {
334
+ cwd,
335
+ stdout,
336
+ stderr,
337
+ home: deps.home ?? homedir(),
338
+ });
293
339
  default:
294
340
  stderr(`Unknown mcp subcommand "${parsed.subcommand}".\n${USAGE}`);
295
341
  return 1;
296
342
  }
297
343
  }
344
+ const WORKER_TOOL_NAMES = [
345
+ "get_context",
346
+ "create_team",
347
+ "engage_worker",
348
+ "critique_plan",
349
+ "review_pr",
350
+ "test_pr",
351
+ "get_qa_evidence",
352
+ "get_qa_replay",
353
+ "validate_qa_replay",
354
+ "accept_qa_replay",
355
+ "authorize_qa_fork",
356
+ "list_work",
357
+ "get_work",
358
+ "add_feedback",
359
+ "propose_instruction_change",
360
+ "prepare_review_publication",
361
+ "resolve_decision",
362
+ ];
363
+ async function connectWorkers(mod, parsed, io, env) {
364
+ if (parsed.rest.length ||
365
+ parsed.scopeExplicit ||
366
+ parsed.transportExplicit ||
367
+ Object.keys(parsed.headers).length ||
368
+ Object.keys(parsed.env).length ||
369
+ parsed.clientId ||
370
+ parsed.clientSecret ||
371
+ parsed.callbackPort) {
372
+ io.stderr("Usage: yagni mcp connect-workers (uses your active environment and private user configuration)\n");
373
+ return 1;
374
+ }
375
+ try {
376
+ if (!mod.authenticate)
377
+ throw new Error("Update YAGNI Code to connect Workers");
378
+ const profile = await readActiveProfile(env);
379
+ const url = new URL("/mcp", profile.baseUrl);
380
+ if (url.username ||
381
+ url.password ||
382
+ (url.protocol !== "https:" &&
383
+ !(url.protocol === "http:" &&
384
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))))
385
+ throw new Error("Worker connections require HTTPS or a local development server");
386
+ const config = {
387
+ type: "http",
388
+ url: url.href,
389
+ tools: WORKER_TOOL_NAMES,
390
+ };
391
+ const { file, errors } = mod.readUserMcpConfig();
392
+ if (errors.length)
393
+ throw new Error("Fix the existing MCP configuration before connecting Workers");
394
+ const previous = file
395
+ .mcpServers?.["yagni-workers"];
396
+ if (previous && JSON.stringify(previous) !== JSON.stringify(config))
397
+ throw new Error("An existing yagni-workers connection uses different settings. Remove it with yagni mcp remove yagni-workers before reconnecting");
398
+ const shadow = mod
399
+ .loadMcpServers(io.cwd, env)
400
+ .servers.find((server) => server.name === "yagni-workers" && server.scope !== "user");
401
+ if (shadow)
402
+ throw new Error("A project or local yagni-workers entry would override this connection. Remove that entry before connecting");
403
+ io.stdout("Opening YAGNI in your browser. Choose your workspace and permissions; paid Worker requests use that workspace's balance.\n");
404
+ await mod.authenticate("yagni-workers", config);
405
+ writeServerToScope(mod, "yagni-workers", config, "user", io.cwd);
406
+ io.stdout("Workers connected. Start a new YAGNI Code session to use them; /mcp shows connection status.\n");
407
+ return 0;
408
+ }
409
+ catch {
410
+ io.stderr("Worker connection did not complete. Check your environment and existing yagni-workers configuration, then retry browser consent.\n");
411
+ return 1;
412
+ }
413
+ }
298
414
  function mcpAdd(mod, parsed, io) {
299
415
  const { name, rest } = parsed;
300
416
  const commandOrUrl = rest[1];
@@ -308,7 +424,9 @@ function mcpAdd(mod, parsed, io) {
308
424
  serverConfig = {
309
425
  type: parsed.transport,
310
426
  url: commandOrUrl,
311
- ...(Object.keys(parsed.headers).length > 0 ? { headers: parsed.headers } : {}),
427
+ ...(Object.keys(parsed.headers).length > 0
428
+ ? { headers: parsed.headers }
429
+ : {}),
312
430
  ...(oauthBlock(parsed) ? { oauth: oauthBlock(parsed) } : {}),
313
431
  };
314
432
  }
@@ -398,18 +516,29 @@ function readClientSecret(io) {
398
516
  function validateConfigShape(config) {
399
517
  const type = config["type"];
400
518
  if (type === undefined || type === "stdio") {
401
- if (typeof config["command"] !== "string" || config["command"].length === 0) {
402
- return { ok: false, message: 'stdio server requires a non-empty "command"' };
519
+ if (typeof config["command"] !== "string" ||
520
+ config["command"].length === 0) {
521
+ return {
522
+ ok: false,
523
+ message: 'stdio server requires a non-empty "command"',
524
+ };
403
525
  }
404
526
  return { ok: true };
405
527
  }
406
528
  if (type === "http" || type === "sse") {
407
- if (typeof config["url"] !== "string" || config["url"].length === 0) {
408
- return { ok: false, message: `${type} server requires a non-empty "url"` };
529
+ if (typeof config["url"] !== "string" ||
530
+ config["url"].length === 0) {
531
+ return {
532
+ ok: false,
533
+ message: `${type} server requires a non-empty "url"`,
534
+ };
409
535
  }
410
536
  return { ok: true };
411
537
  }
412
- return { ok: false, message: 'unknown "type" — expected stdio, http, or sse' };
538
+ return {
539
+ ok: false,
540
+ message: 'unknown "type" — expected stdio, http, or sse',
541
+ };
413
542
  }
414
543
  function writeServerToScope(mod, name, config, scope, cwd) {
415
544
  if (scope === "project") {
@@ -584,7 +713,9 @@ async function mcpGet(mod, parsed, io) {
584
713
  else {
585
714
  io.stdout(` Type: stdio\n`);
586
715
  io.stdout(` Command: ${config["command"]}\n`);
587
- const args = Array.isArray(config["args"]) ? config["args"] : [];
716
+ const args = Array.isArray(config["args"])
717
+ ? config["args"]
718
+ : [];
588
719
  if (args.length > 0)
589
720
  io.stdout(` Args: ${args.join(" ")}\n`);
590
721
  for (const [key, value] of Object.entries(config["env"] ?? {})) {
@@ -610,7 +741,9 @@ function printOAuthDetail(mod, name, config, stdout) {
610
741
  const cfg = config;
611
742
  const oauth = cfg["oauth"] ?? {};
612
743
  const clientId = typeof oauth["clientId"] === "string" ? oauth["clientId"] : undefined;
613
- const callbackPort = typeof oauth["callbackPort"] === "number" ? oauth["callbackPort"] : undefined;
744
+ const callbackPort = typeof oauth["callbackPort"] === "number"
745
+ ? oauth["callbackPort"]
746
+ : undefined;
614
747
  const stored = mod.getStoredOAuthEntry(name, config);
615
748
  if (clientId || callbackPort || stored?.clientSecret) {
616
749
  stdout(` OAuth: client_id ${clientId ? "configured" : "(DCR)"}, client_secret ${stored?.clientSecret ? "configured" : "not set"}${callbackPort ? `, callback_port ${callbackPort}` : ""}\n`);
@@ -642,10 +775,20 @@ async function mcpList(mod, io) {
642
775
  return 0;
643
776
  }
644
777
  const lines = [];
645
- const byScope = { user: [], project: [], local: [], plugin: [] };
778
+ const byScope = {
779
+ user: [],
780
+ project: [],
781
+ local: [],
782
+ plugin: [],
783
+ };
646
784
  for (const s of servers)
647
785
  (byScope[s.scope] ??= []).push(s);
648
- const labels = { local: "Local", project: "Project", user: "User", plugin: "Plugin" };
786
+ const labels = {
787
+ local: "Local",
788
+ project: "Project",
789
+ user: "User",
790
+ plugin: "Plugin",
791
+ };
649
792
  for (const scope of ["local", "project", "user", "plugin"]) {
650
793
  const group = byScope[scope];
651
794
  if (!group?.length)
package/dist/refresh.js CHANGED
@@ -108,8 +108,11 @@ export async function maybeRefreshAtLaunch(creds, deps = {}) {
108
108
  await deps.persist(next);
109
109
  }
110
110
  catch {
111
- // A failed write shouldn't block the spawn; the rotated token is still
112
- // used for this session, and a later launch re-attempts persistence.
111
+ return {
112
+ creds: next,
113
+ refreshed: true,
114
+ warnings: ["Your refreshed YAGNI Code session could not be saved. Check disk space and profile permissions, then run yagni login before the next session."],
115
+ };
113
116
  }
114
117
  }
115
118
  return { creds: next, refreshed: true, warnings: [] };
package/dist/token.d.ts CHANGED
@@ -15,11 +15,12 @@ import type { Credentials } from "./credentials.js";
15
15
  import { type Profile } from "./profiles.js";
16
16
  export interface TokenCommandDeps {
17
17
  readProfile?: () => Promise<Profile>;
18
+ readNamedProfile?: (name: string) => Promise<Profile | null>;
18
19
  refresh?: typeof maybeRefreshAtLaunch;
19
20
  persist?: (name: string, creds: Credentials) => Promise<void>;
20
21
  now?: () => number;
21
22
  stdout?: (text: string) => void;
22
23
  stderr?: (text: string) => void;
23
24
  }
24
- export declare function tokenCommand(deps?: TokenCommandDeps): Promise<number>;
25
+ export declare function tokenCommand(deps?: TokenCommandDeps, args?: string[]): Promise<number>;
25
26
  //# sourceMappingURL=token.d.ts.map
package/dist/token.js CHANGED
@@ -12,14 +12,40 @@
12
12
  */
13
13
  import { classifyTokenExpiry } from "./launch.js";
14
14
  import { maybeRefreshAtLaunch } from "./refresh.js";
15
- import { credentialsFromProfile, persistProfileTokenRotation, readActiveProfile, } from "./profiles.js";
16
- export async function tokenCommand(deps = {}) {
15
+ import { credentialsFromProfile, persistProfileTokenRotation, readActiveProfile, readProfile as readNamedProfile, isValidProfileName, } from "./profiles.js";
16
+ export async function tokenCommand(deps = {}, args = []) {
17
17
  const readProfile = deps.readProfile ?? readActiveProfile;
18
18
  const refresh = deps.refresh ?? maybeRefreshAtLaunch;
19
19
  const persist = deps.persist ?? persistProfileTokenRotation;
20
20
  const stdout = deps.stdout ?? ((t) => process.stdout.write(t));
21
21
  const stderr = deps.stderr ?? ((t) => process.stderr.write(t));
22
- const profile = await readProfile();
22
+ let profile;
23
+ if (args.length > 0) {
24
+ const options = new Map();
25
+ for (let i = 0; i < args.length; i += 2) {
26
+ const key = args[i];
27
+ const value = args[i + 1];
28
+ if (!["--profile", "--base-url"].includes(key) || !value || options.has(key)) {
29
+ stderr("Usage: yagni token [--profile <name> --base-url <url>]\n");
30
+ return 1;
31
+ }
32
+ options.set(key, value);
33
+ }
34
+ const name = options.get("--profile");
35
+ const baseUrl = options.get("--base-url");
36
+ if (!name || !isValidProfileName(name) || !baseUrl) {
37
+ stderr("A valid --profile and --base-url are both required.\n");
38
+ return 1;
39
+ }
40
+ profile = await (deps.readNamedProfile ?? readNamedProfile)(name);
41
+ if (!profile || profile.baseUrl.replace(/\/+$/, "") !== baseUrl.replace(/\/+$/, "")) {
42
+ stderr("The connected YAGNI profile is missing or its URL changed. Re-run yagni connect for this client.\n");
43
+ return 1;
44
+ }
45
+ }
46
+ else {
47
+ profile = await readProfile();
48
+ }
23
49
  let creds = credentialsFromProfile(profile);
24
50
  if (!creds?.token) {
25
51
  stderr(`Not logged in to environment "${profile.name}" (${profile.baseUrl}). Run \`yagni login\` first.\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code",
3
- "version": "1.0.9",
3
+ "version": "1.1.0",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "fbd1d838bd6701d0e8135e415210ca1974bc4269"
61
+ "yagniSourceSha": "0ba465bf11818ab831d132603f7dff64e5cf99c5"
62
62
  }