@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 +32 -0
- package/dist/cli.js +1 -1
- package/dist/connectClaudeCode.d.ts +4 -3
- package/dist/connectClaudeCode.js +70 -37
- package/dist/connectCodex.d.ts +6 -2
- package/dist/connectCodex.js +43 -22
- package/dist/connectFiles.d.ts +5 -0
- package/dist/connectFiles.js +49 -0
- package/dist/extension/hooks.d.ts +13 -3
- package/dist/extension/hooks.js +170 -5
- package/dist/extension/mcp/cliConfig.d.ts +1 -1
- package/dist/extension/mcp/cliConfig.js +1 -1
- package/dist/extension/mcp/config.d.ts +2 -0
- package/dist/extension/mcp/config.js +2 -0
- package/dist/extension/mcp/tools.js +69 -6
- package/dist/extension/pipeline/scrubSecrets.js +5 -0
- package/dist/extension/resilientFetch.d.ts +3 -3
- package/dist/extension/resilientFetch.js +7 -4
- package/dist/extension/sandbox/config.d.ts +6 -1
- package/dist/extension/sandbox/config.js +19 -1
- package/dist/extension/sandbox/manager.d.ts +9 -2
- package/dist/extension/sandbox/manager.js +30 -5
- package/dist/extension/sandbox/panel.js +2 -1
- package/dist/extension/sandbox/session.js +7 -0
- package/dist/extension/sandbox/worktreeGit.d.ts +57 -0
- package/dist/extension/sandbox/worktreeGit.js +157 -0
- package/dist/mcpCommand.d.ts +7 -0
- package/dist/mcpCommand.js +161 -18
- package/dist/refresh.js +5 -2
- package/dist/token.d.ts +2 -1
- package/dist/token.js +29 -3
- package/package.json +2 -2
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
|
|
13
|
-
*
|
|
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
|
|
13
|
-
*
|
|
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 {
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
|
-
import {
|
|
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() !== "" &&
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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() !== "" &&
|
|
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
|
-
// (
|
|
95
|
-
if (
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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`);
|
package/dist/connectCodex.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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;
|
package/dist/connectCodex.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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 (!
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
151
|
+
writeConfigAtomic(path, text);
|
|
152
|
+
return backupPath;
|
|
153
|
+
}
|
|
154
|
+
export async function connectCodexCommand(opts, deps = {}) {
|
|
143
155
|
try {
|
|
144
|
-
|
|
145
|
-
renameSync(tmp, path);
|
|
156
|
+
return await runConnectCodexCommand(opts, deps);
|
|
146
157
|
}
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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;
|