@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.
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;