@yagni-app/code-staging 1.0.9-staging.1306.1 → 1.0.9-staging.1324.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,6 +36,38 @@ flag) — ask your workspace admin. Maintainers: the alpha checklist is
36
36
  `docs/yagni-code-alpha.md`; publish-day steps live in
37
37
  `docs/yagni-code-publishing.md`.
38
38
 
39
+ ## Connect Claude Code or Codex
40
+
41
+ After `yagni login`, connect either installed client to the YAGNI model proxy:
42
+
43
+ ```bash
44
+ yagni connect claude-code
45
+ yagni connect codex
46
+ ```
47
+
48
+ Restart the client after connecting. Keep `yagni` on its PATH: the client runs
49
+ `yagni token --profile <name> --base-url <url>` to obtain refreshed credentials.
50
+ The connection stays bound to the selected environment even after `yagni use`
51
+ switches your active environment. If you change that profile's URL, log in and
52
+ connect again. Temporary `YAGNI_BASE_URL` overrides must match the saved profile.
53
+
54
+ Claude uses `~/.claude/settings.json` (or `CLAUDE_CONFIG_DIR`). For project scope,
55
+ run `yagni connect claude-code --project` from the repository root; it writes
56
+ `.claude/settings.local.json`. In a Git worktree, current Claude versions may
57
+ use the main checkout's project settings. Remove conflicting Anthropic credentials
58
+ or alternate provider settings before connecting; higher-priority project,
59
+ managed, or shell settings can override the generated configuration.
60
+
61
+ Codex uses `~/.codex/config.toml` (or `CODEX_HOME`) and selects the `advanced` tier.
62
+ A selected Codex profile or project config can override these user defaults.
63
+ Existing TOML comments are saved in `config.toml.yagni-backup`. If a later
64
+ rewrite has different comments or settings, a separate backup with a unique
65
+ suffix preserves that version; the command prints its location.
66
+
67
+ Use the matching command with `--off` to remove the YAGNI connection. This
68
+ preserves unrelated settings but does not restore model or gateway values
69
+ replaced when connecting. No API token is stored in either client's configuration.
70
+
39
71
  ## Develop
40
72
 
41
73
  Working in this monorepo, install from the repo instead of the registry. Build
package/dist/cli.js CHANGED
@@ -687,7 +687,7 @@ export async function main(argv) {
687
687
  return feedbackCommand(rest, {}, cliVersion());
688
688
  }
689
689
  if (command === "token") {
690
- return tokenCommand();
690
+ return tokenCommand({}, rest);
691
691
  }
692
692
  if (command === "upgrade") {
693
693
  return upgradeCommand(rest, { current: cliVersion() });
@@ -9,8 +9,8 @@
9
9
  * dialect route)
10
10
  * env.ANTHROPIC_CUSTOM_HEADERS `x-yagni-caller: claude-code`, the
11
11
  * attribution label the usage report rolls up on
12
- * apiKeyHelper `yagni token` the credential comes from the
13
- * profile at call time, so nothing secret is
12
+ * apiKeyHelper `yagni token --profile --base-url …`
13
+ * credentials stay bound to this environment, so nothing secret is
14
14
  * ever baked into a settings file and a rotated
15
15
  * token is picked up automatically
16
16
  *
@@ -53,7 +53,7 @@ export interface DisconnectPlan {
53
53
  }
54
54
  /** Pure: the settings object after `--off` — managed keys out, all else kept. */
55
55
  export declare function planDisconnect(existing: Settings, helperCommand: string): DisconnectPlan;
56
- export declare function settingsPathFor(scope: "user" | "project", cwd: string, home?: string): string;
56
+ export declare function settingsPathFor(scope: "user" | "project", cwd: string, home?: string, env?: NodeJS.ProcessEnv): string;
57
57
  /** Read + parse a settings file. Missing → {}. Corrupt/symlink/non-object → throws. */
58
58
  export declare function readSettings(path: string): Settings;
59
59
  /** Atomic write (temp + rename), preserving an existing file's permissions. */
@@ -66,6 +66,7 @@ export interface ConnectArgs {
66
66
  export declare function parseConnectArgs(args: string[]): ConnectArgs;
67
67
  export interface ConnectDeps {
68
68
  readProfile?: () => Promise<Profile>;
69
+ readNamedProfile?: (name: string) => Promise<Profile | null>;
69
70
  cwd?: string;
70
71
  home?: string;
71
72
  env?: NodeJS.ProcessEnv;
@@ -9,8 +9,8 @@
9
9
  * dialect route)
10
10
  * env.ANTHROPIC_CUSTOM_HEADERS `x-yagni-caller: claude-code`, the
11
11
  * attribution label the usage report rolls up on
12
- * apiKeyHelper `yagni token` the credential comes from the
13
- * profile at call time, so nothing secret is
12
+ * apiKeyHelper `yagni token --profile --base-url …`
13
+ * credentials stay bound to this environment, so nothing secret is
14
14
  * ever baked into a settings file and a rotated
15
15
  * token is picked up automatically
16
16
  *
@@ -21,11 +21,13 @@
21
21
  * here rather than a silent back-off: the user asked for a config change, so
22
22
  * failing quietly would be lying.
23
23
  */
24
- import { existsSync, lstatSync, readFileSync, renameSync, rmSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
24
+ import { readFileSync } from "node:fs";
25
25
  import { homedir } from "node:os";
26
- import { dirname, join } from "node:path";
26
+ import { join } from "node:path";
27
+ import { configFileStat, writeConfigAtomic, connectionFailureHint } from "./connectFiles.js";
28
+ import { classifyTokenExpiry } from "./launch.js";
27
29
  import { DISTRIBUTION } from "./distribution.js";
28
- import { credentialsFromProfile, readActiveProfile } from "./profiles.js";
30
+ import { credentialsFromProfile, readActiveProfile, readProfile as readNamedProfile } from "./profiles.js";
29
31
  export const CALLER_HEADER_LINE = "x-yagni-caller: claude-code";
30
32
  function envOf(settings) {
31
33
  const env = settings.env;
@@ -38,7 +40,7 @@ function envOf(settings) {
38
40
  */
39
41
  export function mergeCustomHeaders(existing) {
40
42
  const lines = typeof existing === "string" && existing.length > 0 ? existing.split("\n") : [];
41
- const kept = lines.filter((l) => l.trim() !== "" && !/^x-yagni-caller\s*:/i.test(l));
43
+ const kept = lines.filter((l) => l.trim() !== "" && !/^\s*x-yagni-caller\s*:/i.test(l));
42
44
  return [...kept, CALLER_HEADER_LINE].join("\n");
43
45
  }
44
46
  /** Pure: the settings object after connecting, plus what changed. */
@@ -49,7 +51,7 @@ export function planConnect(existing, input) {
49
51
  const note = (key, prior, next) => {
50
52
  if (prior === next)
51
53
  return;
52
- changes.push(prior === undefined ? `${key} = ${next}` : `${key}: ${String(prior)} ${next}`);
54
+ changes.push(`${key} ${prior === undefined ? "set" : "updated"}`);
53
55
  if (prior !== undefined)
54
56
  replaced.push(key);
55
57
  };
@@ -57,7 +59,7 @@ export function planConnect(existing, input) {
57
59
  env.ANTHROPIC_BASE_URL = input.baseUrl;
58
60
  const headers = mergeCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS);
59
61
  if (env.ANTHROPIC_CUSTOM_HEADERS !== headers) {
60
- changes.push(`env.ANTHROPIC_CUSTOM_HEADERS = ${headers.replaceAll("\n", " | ")}`);
62
+ changes.push("env.ANTHROPIC_CUSTOM_HEADERS updated");
61
63
  }
62
64
  env.ANTHROPIC_CUSTOM_HEADERS = headers;
63
65
  note("apiKeyHelper", existing.apiKeyHelper, input.helperCommand);
@@ -72,14 +74,15 @@ export function planDisconnect(existing, helperCommand) {
72
74
  const env = envOf(existing);
73
75
  const removed = [];
74
76
  const out = { ...existing };
75
- if (env.ANTHROPIC_BASE_URL !== undefined) {
77
+ const ownsHelper = typeof existing.apiKeyHelper === "string" && isYagniHelper(existing.apiKeyHelper, helperCommand);
78
+ if (ownsHelper && env.ANTHROPIC_BASE_URL !== undefined) {
76
79
  removed.push("env.ANTHROPIC_BASE_URL");
77
80
  delete env.ANTHROPIC_BASE_URL;
78
81
  }
79
82
  if (typeof env.ANTHROPIC_CUSTOM_HEADERS === "string") {
80
83
  const kept = env.ANTHROPIC_CUSTOM_HEADERS
81
84
  .split("\n")
82
- .filter((l) => l.trim() !== "" && !/^x-yagni-caller\s*:/i.test(l));
85
+ .filter((l) => l.trim() !== "" && !/^\s*x-yagni-caller\s*:/i.test(l));
83
86
  if (kept.length !== env.ANTHROPIC_CUSTOM_HEADERS.split("\n").filter((l) => l.trim() !== "").length) {
84
87
  removed.push("env.ANTHROPIC_CUSTOM_HEADERS (x-yagni-caller line)");
85
88
  }
@@ -91,8 +94,8 @@ export function planDisconnect(existing, helperCommand) {
91
94
  let keptForeignHelper;
92
95
  if (typeof out.apiKeyHelper === "string") {
93
96
  // Only remove a helper that is ours — `yagni token` under any install path
94
- // (npx wrapper, absolute bin). Someone else's gateway helper stays.
95
- if (new RegExp(`(^|[/\\s])${helperCommand.split(" ")[0]}\\s+token\\s*$`).test(out.apiKeyHelper)) {
97
+ // (absolute bin). Someone else's gateway helper stays.
98
+ if (ownsHelper) {
96
99
  removed.push("apiKeyHelper");
97
100
  delete out.apiKeyHelper;
98
101
  }
@@ -102,25 +105,31 @@ export function planDisconnect(existing, helperCommand) {
102
105
  }
103
106
  if (Object.keys(env).length === 0 && out.env !== undefined)
104
107
  delete out.env;
105
- else
108
+ else if (Object.keys(env).length > 0)
106
109
  out.env = env;
107
110
  return { settings: out, removed, ...(keptForeignHelper ? { keptForeignHelper } : {}) };
108
111
  }
109
112
  // ---------------------------------------------------------------------------
110
113
  // File I/O
111
114
  // ---------------------------------------------------------------------------
112
- export function settingsPathFor(scope, cwd, home = homedir()) {
115
+ function isYagniHelper(helper, command) {
116
+ const name = command.split(" ")[0].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
117
+ return new RegExp(`^(?:[^\\s]+/)?${name}\\s+token(?:\\s+--profile\\s+[a-zA-Z0-9_-]+\\s+--base-url\\s+.+)?\\s*$`).test(helper);
118
+ }
119
+ function shellArg(value) {
120
+ if (/^[a-zA-Z0-9_/:.=-]+$/.test(value))
121
+ return value;
122
+ return "'" + value.replaceAll("'", "'\\''") + "'";
123
+ }
124
+ export function settingsPathFor(scope, cwd, home = homedir(), env = process.env) {
113
125
  return scope === "project"
114
126
  ? join(cwd, ".claude", "settings.local.json")
115
- : join(home, ".claude", "settings.json");
127
+ : join(env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude"), "settings.json");
116
128
  }
117
129
  /** Read + parse a settings file. Missing → {}. Corrupt/symlink/non-object → throws. */
118
130
  export function readSettings(path) {
119
- if (!existsSync(path))
131
+ if (!configFileStat(path))
120
132
  return {};
121
- if (lstatSync(path).isSymbolicLink()) {
122
- throw new Error(`${path} is a symlink; refusing to rewrite it. Point yagni at the real file.`);
123
- }
124
133
  let parsed;
125
134
  try {
126
135
  parsed = JSON.parse(readFileSync(path, "utf8"));
@@ -135,18 +144,7 @@ export function readSettings(path) {
135
144
  }
136
145
  /** Atomic write (temp + rename), preserving an existing file's permissions. */
137
146
  export function writeSettings(path, settings) {
138
- const existingMode = existsSync(path) ? lstatSync(path).mode & 0o777 : undefined;
139
- mkdirSync(dirname(path), { recursive: true });
140
- const tmp = join(dirname(path), `.${DISTRIBUTION.commandName}-connect-${process.pid}.tmp`);
141
- try {
142
- writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`);
143
- renameSync(tmp, path);
144
- }
145
- finally {
146
- rmSync(tmp, { force: true });
147
- }
148
- if (existingMode !== undefined)
149
- chmodSync(path, existingMode);
147
+ writeConfigAtomic(path, `${JSON.stringify(settings, null, 2)}\n`);
150
148
  }
151
149
  export function parseConnectArgs(args) {
152
150
  let target;
@@ -159,13 +157,32 @@ export function parseConnectArgs(args) {
159
157
  off = true;
160
158
  else if (!a.startsWith("-") && target === undefined)
161
159
  target = a;
160
+ else
161
+ throw new Error(`Unexpected connect argument: ${a}`);
162
162
  }
163
163
  return { target, project, off };
164
164
  }
165
165
  export async function connectCommand(args, deps = {}) {
166
+ try {
167
+ return await runConnectCommand(args, deps);
168
+ }
169
+ catch (err) {
170
+ (deps.stderr ?? ((t) => process.stderr.write(t)))(`Could not update connection settings. ${connectionFailureHint(err)}\n`);
171
+ return 1;
172
+ }
173
+ }
174
+ async function runConnectCommand(args, deps) {
166
175
  const stdout = deps.stdout ?? ((t) => process.stdout.write(t));
167
176
  const stderr = deps.stderr ?? ((t) => process.stderr.write(t));
168
- const { target, project, off } = parseConnectArgs(args);
177
+ let parsed;
178
+ try {
179
+ parsed = parseConnectArgs(args);
180
+ }
181
+ catch (err) {
182
+ stderr(`${err instanceof Error ? err.message : "Invalid connect arguments"}\n`);
183
+ return 1;
184
+ }
185
+ const { target, project, off } = parsed;
169
186
  if (target === "codex") {
170
187
  if (project) {
171
188
  stderr("`connect codex` has no --project scope: Codex reads one user-level config.toml.\n");
@@ -180,9 +197,7 @@ export async function connectCommand(args, deps = {}) {
180
197
  : `Usage: ${DISTRIBUTION.commandName} connect <claude-code|codex> [--project] [--off]\n`);
181
198
  return 1;
182
199
  }
183
- const profile = await (deps.readProfile ?? readActiveProfile)();
184
- const helperCommand = `${DISTRIBUTION.commandName} token`;
185
- const path = settingsPathFor(project ? "project" : "user", deps.cwd ?? process.cwd(), deps.home);
200
+ const path = settingsPathFor(project ? "project" : "user", deps.cwd ?? process.cwd(), deps.home, deps.env);
186
201
  let existing;
187
202
  try {
188
203
  existing = readSettings(path);
@@ -193,20 +208,38 @@ export async function connectCommand(args, deps = {}) {
193
208
  }
194
209
  if (off) {
195
210
  const plan = planDisconnect(existing, DISTRIBUTION.commandName);
196
- writeSettings(path, plan.settings);
211
+ if (plan.removed.length > 0)
212
+ writeSettings(path, plan.settings);
197
213
  stdout(plan.removed.length > 0
198
214
  ? `✓ Claude Code disconnected from YAGNI.\n ${path}\n Removed: ${plan.removed.join(", ")}\n`
199
215
  : `Nothing to disconnect in ${path}.\n`);
200
216
  if (plan.keptForeignHelper) {
201
- stdout(` Kept apiKeyHelper (${plan.keptForeignHelper}) since it is not YAGNI's.\n`);
217
+ stdout(" Kept the existing apiKeyHelper since it is not YAGNI's.\n");
202
218
  }
203
219
  return 0;
204
220
  }
221
+ const profile = await (deps.readProfile ?? readActiveProfile)();
222
+ const helperCommand = `${DISTRIBUTION.commandName} token --profile ${shellArg(profile.name)} --base-url ${shellArg(profile.baseUrl)}`;
223
+ const stored = await (deps.readNamedProfile ?? readNamedProfile)(profile.name);
224
+ if (stored && stored.baseUrl.replace(/\/+$/, "") !== profile.baseUrl.replace(/\/+$/, "")) {
225
+ stderr("The active URL override differs from the saved profile. Save the environment with yagni use --base-url and log in before connecting. No settings changed.\n");
226
+ return 1;
227
+ }
228
+ if (classifyTokenExpiry(profile.expiresAt, Date.now()).kind === "expired") {
229
+ stderr("Your YAGNI Code session has expired. Run yagni login before connecting. No settings changed.\n");
230
+ return 1;
231
+ }
205
232
  const creds = credentialsFromProfile(profile);
206
233
  if (!creds?.token) {
207
234
  stderr(`Not logged in to environment "${profile.name}" (${profile.baseUrl}). Run \`${DISTRIBUTION.commandName} login\` first.\n`);
208
235
  return 1;
209
236
  }
237
+ const conflicts = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY"]
238
+ .filter((key) => envOf(existing)[key] && envOf(existing)[key] !== "0");
239
+ if (conflicts.length > 0) {
240
+ stderr(`Remove conflicting settings.env keys before connecting: ${conflicts.join(", ")}. No settings changed.\n`);
241
+ return 1;
242
+ }
210
243
  const plan = planConnect(existing, { baseUrl: profile.baseUrl, helperCommand });
211
244
  writeSettings(path, plan.settings);
212
245
  stdout(`✓ Claude Code connected to YAGNI (${profile.name} → ${profile.baseUrl}).\n`);
@@ -21,7 +21,7 @@
21
21
  * everything else.
22
22
  *
23
23
  * TOML cannot be comment-preservingly round-tripped by a parser, so when the
24
- * existing config carries comments we save a one-time `config.toml.yagni-backup`
24
+ * existing config carries comments we save a `config.toml.yagni-backup`
25
25
  * next to it before rewriting and say so — never silently eat a user's notes.
26
26
  * Everything else follows the claude-code connector's contract: managed keys
27
27
  * only, atomic write, symlink refusal, corrupt file is a loud error, `--off`
@@ -41,6 +41,7 @@ export interface CodexConnectPlan {
41
41
  export declare function planConnectCodex(existing: TomlTable, input: {
42
42
  baseUrl: string;
43
43
  commandName: string;
44
+ profileName?: string;
44
45
  }): CodexConnectPlan;
45
46
  export interface CodexDisconnectPlan {
46
47
  config: TomlTable;
@@ -58,11 +59,14 @@ export declare function readCodexConfig(path: string): CodexConfigFile;
58
59
  /**
59
60
  * Atomic write; when the original text carried comments (which a parse →
60
61
  * stringify round-trip cannot preserve), a `config.toml.yagni-backup` copy of
61
- * the original is written first. Returns the backup path when one was made.
62
+ * the original is written first. Later differing originals get unique backup
63
+ * names; an identical first backup can be reused. Returns the current original
64
+ * backup path, or null when no backup was needed.
62
65
  */
63
66
  export declare function writeCodexConfig(path: string, config: TomlTable, originalRaw: string | null): string | null;
64
67
  export interface ConnectCodexDeps {
65
68
  readProfile?: () => Promise<Profile>;
69
+ readNamedProfile?: (name: string) => Promise<Profile | null>;
66
70
  home?: string;
67
71
  env?: NodeJS.ProcessEnv;
68
72
  stdout?: (text: string) => void;
@@ -21,18 +21,21 @@
21
21
  * everything else.
22
22
  *
23
23
  * TOML cannot be comment-preservingly round-tripped by a parser, so when the
24
- * existing config carries comments we save a one-time `config.toml.yagni-backup`
24
+ * existing config carries comments we save a `config.toml.yagni-backup`
25
25
  * next to it before rewriting and say so — never silently eat a user's notes.
26
26
  * Everything else follows the claude-code connector's contract: managed keys
27
27
  * only, atomic write, symlink refusal, corrupt file is a loud error, `--off`
28
28
  * removes exactly what we own.
29
29
  */
30
- import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
30
+ import { randomUUID } from "node:crypto";
31
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
32
+ import { configFileStat, writeConfigAtomic, connectionFailureHint } from "./connectFiles.js";
31
33
  import { homedir } from "node:os";
32
34
  import { dirname, join } from "node:path";
33
35
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
36
+ import { classifyTokenExpiry } from "./launch.js";
34
37
  import { DISTRIBUTION } from "./distribution.js";
35
- import { credentialsFromProfile, readActiveProfile } from "./profiles.js";
38
+ import { credentialsFromProfile, readActiveProfile, readProfile as readNamedProfile } from "./profiles.js";
36
39
  export const CODEX_PROVIDER_ID = "yagni";
37
40
  export const CODEX_DEFAULT_TIER = "advanced";
38
41
  export function codexConfigPath(home = homedir(), env = process.env) {
@@ -67,7 +70,7 @@ export function planConnectCodex(existing, input) {
67
70
  base_url: `${input.baseUrl}/v1`,
68
71
  wire_api: "responses",
69
72
  http_headers: { "x-yagni-caller": "codex" },
70
- auth: { command: input.commandName, args: ["token"] },
73
+ auth: { command: input.commandName, args: ["token", "--profile", input.profileName ?? "prod", "--base-url", input.baseUrl], timeout_ms: 20_000 },
71
74
  };
72
75
  return {
73
76
  config: {
@@ -107,11 +110,8 @@ export function planDisconnectCodex(existing) {
107
110
  }
108
111
  /** Read + parse the Codex config. Missing → {}. Corrupt/symlink → throws. */
109
112
  export function readCodexConfig(path) {
110
- if (!existsSync(path))
113
+ if (!configFileStat(path))
111
114
  return { config: {}, raw: null };
112
- if (lstatSync(path).isSymbolicLink()) {
113
- throw new Error(`${path} is a symlink; refusing to rewrite it. Point yagni at the real file.`);
114
- }
115
115
  const raw = readFileSync(path, "utf8");
116
116
  let parsed;
117
117
  try {
@@ -125,36 +125,46 @@ export function readCodexConfig(path) {
125
125
  }
126
126
  return { config: parsed, raw };
127
127
  }
128
- const COMMENT_RE = /^\s*#|\s#/m;
129
128
  /**
130
129
  * Atomic write; when the original text carried comments (which a parse →
131
130
  * stringify round-trip cannot preserve), a `config.toml.yagni-backup` copy of
132
- * the original is written first. Returns the backup path when one was made.
131
+ * the original is written first. Later differing originals get unique backup
132
+ * names; an identical first backup can be reused. Returns the current original
133
+ * backup path, or null when no backup was needed.
133
134
  */
134
135
  export function writeCodexConfig(path, config, originalRaw) {
136
+ configFileStat(path);
137
+ const text = `${stringifyToml(config)}\n`;
135
138
  mkdirSync(dirname(path), { recursive: true });
136
139
  let backupPath = null;
137
- if (originalRaw !== null && COMMENT_RE.test(originalRaw)) {
140
+ // Conservatively keep hashes inside strings too, so inline comments cannot be missed.
141
+ if (originalRaw !== null && originalRaw.includes("#")) {
138
142
  backupPath = `${path}.yagni-backup`;
139
- writeFileSync(backupPath, originalRaw);
143
+ const existingBackup = configFileStat(backupPath);
144
+ if (existingBackup && readFileSync(backupPath, "utf8") !== originalRaw) {
145
+ backupPath = `${backupPath}-${randomUUID()}`;
146
+ }
147
+ if (!existingBackup || backupPath !== `${path}.yagni-backup`) {
148
+ writeFileSync(backupPath, originalRaw, { flag: "wx", mode: 0o600 });
149
+ }
140
150
  }
141
- const existingMode = existsSync(path) ? lstatSync(path).mode & 0o777 : undefined;
142
- const tmp = join(dirname(path), `.${DISTRIBUTION.commandName}-connect-codex-${process.pid}.tmp`);
151
+ writeConfigAtomic(path, text);
152
+ return backupPath;
153
+ }
154
+ export async function connectCodexCommand(opts, deps = {}) {
143
155
  try {
144
- writeFileSync(tmp, `${stringifyToml(config)}\n`, existingMode !== undefined ? { mode: existingMode } : {});
145
- renameSync(tmp, path);
156
+ return await runConnectCodexCommand(opts, deps);
146
157
  }
147
- finally {
148
- rmSync(tmp, { force: true });
158
+ catch (err) {
159
+ (deps.stderr ?? ((t) => process.stderr.write(t)))(`Could not update Codex settings. ${connectionFailureHint(err)}\n`);
160
+ return 1;
149
161
  }
150
- return backupPath;
151
162
  }
152
- export async function connectCodexCommand(opts, deps = {}) {
163
+ async function runConnectCodexCommand(opts, deps) {
153
164
  const stdout = deps.stdout ?? ((t) => process.stdout.write(t));
154
165
  const stderr = deps.stderr ?? ((t) => process.stderr.write(t));
155
166
  const env = deps.env ?? process.env;
156
167
  const path = codexConfigPath(deps.home, env);
157
- const profile = await (deps.readProfile ?? readActiveProfile)();
158
168
  let file;
159
169
  try {
160
170
  file = readCodexConfig(path);
@@ -175,6 +185,16 @@ export async function connectCodexCommand(opts, deps = {}) {
175
185
  stdout(` Comments in the original were preserved at ${backup}.\n`);
176
186
  return 0;
177
187
  }
188
+ const profile = await (deps.readProfile ?? readActiveProfile)();
189
+ const stored = await (deps.readNamedProfile ?? readNamedProfile)(profile.name);
190
+ if (stored && stored.baseUrl.replace(/\/+$/, "") !== profile.baseUrl.replace(/\/+$/, "")) {
191
+ stderr("The active URL override differs from the saved profile. Save the environment with yagni use --base-url and log in before connecting. No settings changed.\n");
192
+ return 1;
193
+ }
194
+ if (classifyTokenExpiry(profile.expiresAt, Date.now()).kind === "expired") {
195
+ stderr("Your YAGNI Code session has expired. Run yagni login before connecting. No settings changed.\n");
196
+ return 1;
197
+ }
178
198
  const creds = credentialsFromProfile(profile);
179
199
  if (!creds?.token) {
180
200
  stderr(`Not logged in to environment "${profile.name}" (${profile.baseUrl}). Run \`${DISTRIBUTION.commandName} login\` first.\n`);
@@ -183,6 +203,7 @@ export async function connectCodexCommand(opts, deps = {}) {
183
203
  const plan = planConnectCodex(file.config, {
184
204
  baseUrl: profile.baseUrl,
185
205
  commandName: DISTRIBUTION.commandName,
206
+ profileName: profile.name,
186
207
  });
187
208
  const backup = writeCodexConfig(path, plan.config, file.raw);
188
209
  stdout(`✓ Codex connected to YAGNI (${profile.name} → ${profile.baseUrl}).\n`);
@@ -195,7 +216,7 @@ export async function connectCodexCommand(opts, deps = {}) {
195
216
  if (backup) {
196
217
  stdout(` Your config had comments, which a rewrite cannot keep. The original is at ${backup}.\n`);
197
218
  }
198
- stdout(` Restart Codex to pick this up. It runs \`${DISTRIBUTION.commandName} token\` for credentials and serves the "${CODEX_DEFAULT_TIER}" tier; edit \`model\` to any YAGNI tier to change that.\n`);
219
+ stdout(` Restart Codex to pick this up. Its credential helper stays bound to environment "${profile.name}" and serves the "${CODEX_DEFAULT_TIER}" tier; edit \`model\` to any YAGNI tier to change that.\n`);
199
220
  return 0;
200
221
  }
201
222
  //# sourceMappingURL=connectCodex.js.map
@@ -0,0 +1,5 @@
1
+ import { type Stats } from "node:fs";
2
+ export declare function configFileStat(path: string): Stats | undefined;
3
+ export declare function writeConfigAtomic(path: string, contents: string): void;
4
+ export declare function connectionFailureHint(error: unknown): string;
5
+ //# sourceMappingURL=connectFiles.d.ts.map
@@ -0,0 +1,49 @@
1
+ import { lstatSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ export function configFileStat(path) {
4
+ const stat = lstatSync(path, { throwIfNoEntry: false });
5
+ if (stat && !stat.isFile())
6
+ throw Object.assign(new Error(`${path} is a symlink or not a regular file; refusing to rewrite it.`), { code: "YAGNI_CONFIG_NOT_REGULAR" });
7
+ return stat;
8
+ }
9
+ export function writeConfigAtomic(path, contents) {
10
+ const mode = configFileStat(path)?.mode;
11
+ mkdirSync(dirname(path), { recursive: true });
12
+ const tempDir = mkdtempSync(join(dirname(path), ".yagni-connect-"));
13
+ try {
14
+ const temp = join(tempDir, "config");
15
+ writeFileSync(temp, contents, { flag: "wx", mode: mode === undefined ? 0o600 : mode & 0o777 });
16
+ configFileStat(path);
17
+ renameSync(temp, path);
18
+ }
19
+ finally {
20
+ rmSync(tempDir, { recursive: true, force: true });
21
+ }
22
+ }
23
+ export function connectionFailureHint(error) {
24
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
25
+ switch (code) {
26
+ case "ENOSPC":
27
+ case "EDQUOT":
28
+ return "The disk is full or its storage quota was reached. Free space and try again.";
29
+ case "EROFS":
30
+ return "The configuration filesystem is read-only. Make it writable and try again.";
31
+ case "EACCES":
32
+ case "EPERM":
33
+ return "Permission was denied. Check the configuration directory and file permissions, then try again.";
34
+ case "ENOTDIR":
35
+ case "EISDIR":
36
+ case "YAGNI_CONFIG_NOT_REGULAR":
37
+ return "A configuration or backup path is a symlink or has the wrong file type. Use a regular file in a writable directory.";
38
+ case "EMFILE":
39
+ case "ENFILE":
40
+ return "Too many files are open. Close unused applications and try again.";
41
+ case "ENOENT":
42
+ return "A configuration path is missing. Check the configuration directory and try again.";
43
+ case "EEXIST":
44
+ return "A configuration or backup path is already occupied. Check that path and try again.";
45
+ default:
46
+ return "Try again. If the problem persists, contact support with the command you ran.";
47
+ }
48
+ }
49
+ //# sourceMappingURL=connectFiles.js.map
@@ -6,8 +6,8 @@
6
6
  * at lifecycle events. The config format mirrors Claude Code's `settings.json`
7
7
  * hooks shape so a user can copy-paste between them.
8
8
  *
9
- * Supported events (8): SessionStart, UserPromptSubmit, PreToolUse,
10
- * PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionEnd.
9
+ * Supported events (9): SessionStart, UserPromptSubmit, PreToolUse,
10
+ * PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionEnd, Stop.
11
11
  *
12
12
  * Exit code semantics (matching Claude Code / Codex):
13
13
  * 0 = success; stdout parsed as JSON for structured decisions
@@ -32,7 +32,7 @@ export interface HookGroup {
32
32
  /** The hooks section of config.json. */
33
33
  export type HooksConfig = Record<string, HookGroup[]>;
34
34
  /** Supported Claude Code event names. */
35
- export type HookEventName = "SessionStart" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PermissionRequest" | "PreCompact" | "PostCompact" | "SessionEnd";
35
+ export type HookEventName = "SessionStart" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "PermissionRequest" | "PreCompact" | "PostCompact" | "SessionEnd" | "Stop";
36
36
  declare const SUPPORTED_EVENTS: readonly HookEventName[];
37
37
  /** Result of a PreToolUse hook evaluation. */
38
38
  export type PreToolUseHookResult = {
@@ -82,6 +82,16 @@ export declare function parsePermissionRequestOutput(stdout: string): Permission
82
82
  export declare function parseAdditionalContext(stdout: string): string | null;
83
83
  /** Check if stdout JSON has continue: false (for PreCompact cancellation). */
84
84
  export declare function parseCompactCancel(stdout: string): boolean;
85
+ /**
86
+ * A short upload-safe label for a hook-exec exception, for the always-on
87
+ * trail line. Tiers, in order: a Node error code ("ENOENT", "ETIMEDOUT"),
88
+ * the message's first token (a machine word like "spawn"), the constructor
89
+ * name, and typeof for non-Error throws (execImpl is an injected seam —
90
+ * a string throw is possible). Message-derived tiers are scrubbed — a
91
+ * message can begin with content (a credentials URL), and this label rides
92
+ * the default-on (upload-safe) tier.
93
+ */
94
+ export declare function hookErrorLabel(err: unknown): string;
85
95
  interface HookExecutorOptions {
86
96
  config: HooksConfig;
87
97
  env?: NodeJS.ProcessEnv;
@@ -6,8 +6,8 @@
6
6
  * at lifecycle events. The config format mirrors Claude Code's `settings.json`
7
7
  * hooks shape so a user can copy-paste between them.
8
8
  *
9
- * Supported events (8): SessionStart, UserPromptSubmit, PreToolUse,
10
- * PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionEnd.
9
+ * Supported events (9): SessionStart, UserPromptSubmit, PreToolUse,
10
+ * PostToolUse, PermissionRequest, PreCompact, PostCompact, SessionEnd, Stop.
11
11
  *
12
12
  * Exit code semantics (matching Claude Code / Codex):
13
13
  * 0 = success; stdout parsed as JSON for structured decisions
@@ -21,8 +21,10 @@ import { existsSync, readFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
  import { homedir } from "node:os";
23
23
  import { codeStateHome } from "./stateHome.js";
24
+ import { isDriverCaller } from "./config.js";
24
25
  import { isDebug } from "./diagnostics.js";
25
26
  import { logEvent } from "./errorSink.js";
27
+ import { scrubSecrets } from "./pipeline/scrubSecrets.js";
26
28
  const SUPPORTED_EVENTS = [
27
29
  "SessionStart",
28
30
  "UserPromptSubmit",
@@ -32,6 +34,7 @@ const SUPPORTED_EVENTS = [
32
34
  "PreCompact",
33
35
  "PostCompact",
34
36
  "SessionEnd",
37
+ "Stop",
35
38
  ];
36
39
  // ---------------------------------------------------------------------------
37
40
  // Config loading
@@ -301,8 +304,72 @@ export function parseCompactCancel(stdout) {
301
304
  return parsed.continue === false;
302
305
  }
303
306
  // ---------------------------------------------------------------------------
307
+ // Stop capture
308
+ // ---------------------------------------------------------------------------
309
+ /**
310
+ * Extract the last assistant message's text and stopReason from an
311
+ * agent_end event. Walks backwards (the shape cmux/state.ts walks forwards)
312
+ * so an aborted final turn with no text still reports its stopReason —
313
+ * the reason is what decides whether Stop hooks fire at all.
314
+ */
315
+ function lastAssistantInfo(event) {
316
+ const messages = event?.messages;
317
+ if (!Array.isArray(messages))
318
+ return undefined;
319
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
320
+ const message = messages[index];
321
+ if (!message || typeof message !== "object")
322
+ continue;
323
+ const typed = message;
324
+ if (typed.role !== "assistant")
325
+ continue;
326
+ const text = typeof typed.content === "string"
327
+ ? typed.content.trim() || undefined
328
+ : textFromBlocks(typed.content);
329
+ return { text, stopReason: typeof typed.stopReason === "string" ? typed.stopReason : undefined };
330
+ }
331
+ return undefined;
332
+ }
333
+ /** Join text blocks from a content array (mirrors cmux's textFromContent). */
334
+ function textFromBlocks(content) {
335
+ if (!Array.isArray(content))
336
+ return undefined;
337
+ const parts = [];
338
+ for (const block of content) {
339
+ if (!block || typeof block !== "object")
340
+ continue;
341
+ const typed = block;
342
+ if (typed.type === "text" && typeof typed.text === "string" && typed.text.trim()) {
343
+ parts.push(typed.text);
344
+ }
345
+ }
346
+ return parts.join("\n") || undefined;
347
+ }
348
+ // ---------------------------------------------------------------------------
304
349
  // Diagnostic logging
305
350
  // ---------------------------------------------------------------------------
351
+ /**
352
+ * A short upload-safe label for a hook-exec exception, for the always-on
353
+ * trail line. Tiers, in order: a Node error code ("ENOENT", "ETIMEDOUT"),
354
+ * the message's first token (a machine word like "spawn"), the constructor
355
+ * name, and typeof for non-Error throws (execImpl is an injected seam —
356
+ * a string throw is possible). Message-derived tiers are scrubbed — a
357
+ * message can begin with content (a credentials URL), and this label rides
358
+ * the default-on (upload-safe) tier.
359
+ */
360
+ export function hookErrorLabel(err) {
361
+ const code = err?.code;
362
+ // Scrub BEFORE slicing: a secret-shaped value longer than the 40-char cap
363
+ // would be split first and the fragment could match no pattern.
364
+ if (typeof code === "string" && code)
365
+ return scrubSecrets(code).slice(0, 40);
366
+ if (err instanceof Error) {
367
+ if (err.message)
368
+ return scrubSecrets(err.message.split(/[\s:]+/)[0]).slice(0, 40);
369
+ return err.constructor.name;
370
+ }
371
+ return typeof err;
372
+ }
306
373
  function logHookEvent(env, payload) {
307
374
  if (!isDebug(env))
308
375
  return;
@@ -656,6 +723,64 @@ export function registerHooks(pi, deps = {}) {
656
723
  }
657
724
  });
658
725
  }
726
+ // --- Stop (agent finished responding; driver turn complete) ---
727
+ // Two-phase capture, mirroring the cmux bridge: agent_settled carries no
728
+ // payload, so the last assistant message is captured at agent_end and
729
+ // consumed at settled. Firing is VOID, not awaited — pi awaits settled
730
+ // handlers before unblocking the TUI's prompt loop and RPC waitForIdle,
731
+ // so an awaited hook would keep a finished session looking busy for up
732
+ // to the full hook timeout.
733
+ const stopGroups = config["Stop"] ?? [];
734
+ if (stopGroups.length > 0 && isDriverCaller(env)) {
735
+ let pendingStop;
736
+ pi.on("agent_end", (event) => {
737
+ pendingStop = lastAssistantInfo(event);
738
+ });
739
+ pi.on("agent_settled", (_event, ctx) => {
740
+ // hasUI is fixed per session — checking it first keeps the capture
741
+ // consume order-independent (a headless session simply never fires).
742
+ if (!ctx.hasUI)
743
+ return;
744
+ const captured = pendingStop;
745
+ if (!captured)
746
+ return;
747
+ // Mid-retry/compaction/continuation settles are not the turn's end —
748
+ // pi sets isIdle only once no automatic follow-up work will run. The
749
+ // capture is NOT consumed here: a settle that isn't idle leaves it for
750
+ // the genuine end-of-turn settle (an agent_end between the two
751
+ // overwrites it with the continuation's own final message).
752
+ try {
753
+ if (!ctx.isIdle())
754
+ return;
755
+ }
756
+ catch {
757
+ return;
758
+ }
759
+ pendingStop = undefined;
760
+ // Claude Code parity: its query loop returns before running Stop hooks
761
+ // when the user aborted or the model errored — "finished, your move"
762
+ // would be a lie for a killed turn. Retry/continuation agent_ends
763
+ // overwrite the capture, so an error followed by a successful retry
764
+ // still fires (with the retry's message).
765
+ if (captured.stopReason === "aborted" || captured.stopReason === "error")
766
+ return;
767
+ const cwd = ctx.cwd;
768
+ const inputJson = JSON.stringify({
769
+ session_id: sessionId,
770
+ cwd,
771
+ hook_event_name: "Stop",
772
+ stop_hook_active: false,
773
+ ...(captured.text ? { last_assistant_message: captured.text } : {}),
774
+ });
775
+ void (async () => {
776
+ for (const group of filterByTrust(stopGroups, trusted(ctx))) {
777
+ for (const entry of group.hooks) {
778
+ await runSideEffectHook(entry.command, inputJson, cwd, "Stop", ctx, env, execImpl);
779
+ }
780
+ }
781
+ })();
782
+ });
783
+ }
659
784
  }
660
785
  /** Run a side-effect-only hook (no control effects, output ignored). */
661
786
  async function runSideEffectHook(command, inputJson, cwd, eventName, ctx, env, execImpl) {
@@ -665,6 +790,13 @@ async function runSideEffectHook(command, inputJson, cwd, eventName, ctx, env, e
665
790
  YAGNI_HOOK_CWD: cwd,
666
791
  YAGNI_HOOK_SESSION_ID: env.YAGNI_SESSION_ID ?? "",
667
792
  });
793
+ const failed = !!result.error || (result.exitCode !== 0 && result.exitCode !== null);
794
+ // One outcome, two tiers: the debug line carries the full detail
795
+ // (stderr, timing) and is YAGNI_DEBUG-gated; the warn line is always-on
796
+ // and upload-safe, so "hook fired and failed" is distinguishable from
797
+ // "hook never fired" in a default session. The command rides the warn
798
+ // line scrubbed (below) so N hooks on one event produce distinguishable
799
+ // lines without leaking anything the user embedded in the command.
668
800
  logHookEvent(env, {
669
801
  event: eventName,
670
802
  command,
@@ -674,14 +806,47 @@ async function runSideEffectHook(command, inputJson, cwd, eventName, ctx, env, e
674
806
  ...(result.error ? { error: result.error } : {}),
675
807
  ...(result.stderr.trim() ? { stderr: result.stderr.trim().slice(0, 512) } : {}),
676
808
  });
677
- if (result.error || (result.exitCode !== 0 && result.exitCode !== null)) {
809
+ if (failed) {
810
+ logEvent({
811
+ source: "hooks",
812
+ level: "warn",
813
+ event: "hook_failed",
814
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
815
+ fields: {
816
+ event_name: eventName,
817
+ // Scrubbed at write time: the trail file stores lines raw (scrubbing
818
+ // otherwise happens only at readSessionTrail), and a user's hook
819
+ // command can embed secrets inline (curl -H "Authorization: Bearer …").
820
+ // The full, unscrubbed command stays on the YAGNI_DEBUG tier.
821
+ command: scrubSecrets(command).slice(0, 80),
822
+ exit_code: result.exitCode,
823
+ // Scrub the whole error string before taking the first colon token —
824
+ // result.error embeds a raw err.message (spawn failed/err paths in
825
+ // execHook), and the sibling hookErrorLabel path scrubs message-derived
826
+ // tiers the same way.
827
+ ...(result.error ? { error: scrubSecrets(result.error).split(":")[0] } : {}),
828
+ },
829
+ });
678
830
  if (ctx.hasUI && result.stderr.trim()) {
679
831
  ctx.ui.notify(`Hook '${eventName}' exited with code ${result.exitCode}: ${result.stderr.trim().slice(0, 200)}`, "warning");
680
832
  }
681
833
  }
682
834
  }
683
- catch {
684
- // Fail-soft: a hook error never breaks the session
835
+ catch (err) {
836
+ // Fail-soft: a hook error never breaks the session — but never silent.
837
+ // The label distinguishes "spawn ENOENT" from a timeout (see
838
+ // hookErrorLabel); the command is scrubbed like the sibling path's.
839
+ logEvent({
840
+ source: "hooks",
841
+ level: "warn",
842
+ event: "hook_exec_threw",
843
+ sessionId: env.YAGNI_SESSION_ID ?? undefined,
844
+ fields: {
845
+ event_name: eventName,
846
+ command: scrubSecrets(command).slice(0, 80),
847
+ error: hookErrorLabel(err),
848
+ },
849
+ });
685
850
  }
686
851
  }
687
852
  // ---------------------------------------------------------------------------
@@ -22,6 +22,11 @@ const PATTERNS = [
22
22
  [/\b([A-Za-z0-9_]*(?:secret|password|passwd|api[_-]?key|token|private[_-]?key|access[_-]?key)[A-Za-z0-9_]*)\b(\s*[:=]\s*)("[^"]+"|'[^']+'|`[^`]+`|[^\s"']+)/gi, "$1$2[REDACTED]"],
23
23
  // Long base64-ish blobs (likely keys/JWTs)
24
24
  [/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]"],
25
+ // Opaque bearer tokens in Authorization headers: no provider prefix, no
26
+ // secret-named key, and usually under the base64 length threshold — none of
27
+ // the patterns above catch them. Keep the scheme, redact the token. Header
28
+ // names and auth schemes are case-insensitive (RFC 9110) — hence the flag.
29
+ [/(\bauthorization\s*[:=]\s*["']?bearer\s+)([a-z0-9._~+/=-]+)/gi, "$1[REDACTED]"],
25
30
  ];
26
31
  export function scrubSecrets(text) {
27
32
  let out = text;
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-staging",
3
- "version": "1.0.9-staging.1306.1",
3
+ "version": "1.0.9-staging.1324.1",
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": "b15d63d89acab434f4e2c9f20d60337cffb4a0c3"
61
+ "yagniSourceSha": "30c899295c967f5ebaa0293a7e9c20396ae07ae2"
62
62
  }