@yagni-app/code-staging 0.3.0-staging.1091.1 → 0.3.0-staging.1096.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/dist/cli.js +12 -0
- package/dist/connectClaudeCode.d.ts +77 -0
- package/dist/connectClaudeCode.js +228 -0
- package/dist/connectCodex.d.ts +75 -0
- package/dist/connectCodex.js +201 -0
- package/dist/extension/execPolicy.d.ts +17 -1
- package/dist/extension/execPolicy.js +164 -33
- package/dist/extension/guardian.js +1 -1
- package/dist/login.d.ts +4 -2
- package/dist/login.js +19 -4
- package/dist/token.d.ts +25 -0
- package/dist/token.js +45 -0
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -21,8 +21,10 @@ import { PI_CONFIG_NAME } from "./branding.js";
|
|
|
21
21
|
import { claudeCompatArgs } from "./claudeCompat.js";
|
|
22
22
|
import { agentDir, credentialsDir, piPackageDir } from "./credentials.js";
|
|
23
23
|
import { DISTRIBUTION } from "./distribution.js";
|
|
24
|
+
import { connectCommand } from "./connectClaudeCode.js";
|
|
24
25
|
import { login } from "./login.js";
|
|
25
26
|
import { logout } from "./logout.js";
|
|
27
|
+
import { tokenCommand } from "./token.js";
|
|
26
28
|
import { buildLaunch } from "./launch.js";
|
|
27
29
|
import { runDoctor } from "./doctor.js";
|
|
28
30
|
import { installProcessCrashHandlers } from "./crashReport.js";
|
|
@@ -286,6 +288,10 @@ export const HELP_TEXT = [
|
|
|
286
288
|
" yagni login Authorize the active environment (device-code flow).",
|
|
287
289
|
" yagni logout Revoke and clear the active environment's token.",
|
|
288
290
|
" yagni doctor Check that everything is ready (green/red checklist).",
|
|
291
|
+
" yagni connect claude-code Route Claude Code through the YAGNI model proxy",
|
|
292
|
+
" (--project scopes to this repo; --off disconnects).",
|
|
293
|
+
" yagni connect codex Route Codex CLI through the YAGNI model proxy.",
|
|
294
|
+
" yagni token Output the active environment's API token (for helpers).",
|
|
289
295
|
" yagni use <name> Switch the active environment (sticky).",
|
|
290
296
|
" Presets: prod, local. Others need --base-url <url>.",
|
|
291
297
|
" yagni profiles List saved environments; the active one is marked.",
|
|
@@ -415,6 +421,12 @@ export async function main(argv) {
|
|
|
415
421
|
if (command === "doctor") {
|
|
416
422
|
return runDoctor();
|
|
417
423
|
}
|
|
424
|
+
if (command === "connect") {
|
|
425
|
+
return connectCommand(rest);
|
|
426
|
+
}
|
|
427
|
+
if (command === "token") {
|
|
428
|
+
return tokenCommand();
|
|
429
|
+
}
|
|
418
430
|
if (command === "upgrade") {
|
|
419
431
|
return upgradeCommand(rest, { current: cliVersion() });
|
|
420
432
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yagni connect claude-code` — point Claude Code at the YAGNI model proxy.
|
|
3
|
+
*
|
|
4
|
+
* Writes the three settings Claude Code needs into `~/.claude/settings.json`
|
|
5
|
+
* (or `.claude/settings.local.json` with `--project`):
|
|
6
|
+
*
|
|
7
|
+
* env.ANTHROPIC_BASE_URL the active environment's base URL (Claude Code
|
|
8
|
+
* appends /v1/messages — the proxy's Anthropic-
|
|
9
|
+
* dialect route)
|
|
10
|
+
* env.ANTHROPIC_CUSTOM_HEADERS `x-yagni-caller: claude-code`, the
|
|
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
|
|
14
|
+
* ever baked into a settings file and a rotated
|
|
15
|
+
* token is picked up automatically
|
|
16
|
+
*
|
|
17
|
+
* Everything else in the file is preserved verbatim. `--off` removes exactly
|
|
18
|
+
* the managed keys (the helper only when it is ours). The planner is pure and
|
|
19
|
+
* the writer is atomic (temp + rename, symlink-refusing) — the same guard
|
|
20
|
+
* rails as the launcher's settings seeding, except a corrupt file is an ERROR
|
|
21
|
+
* here rather than a silent back-off: the user asked for a config change, so
|
|
22
|
+
* failing quietly would be lying.
|
|
23
|
+
*/
|
|
24
|
+
import { type Profile } from "./profiles.js";
|
|
25
|
+
export declare const CALLER_HEADER_LINE = "x-yagni-caller: claude-code";
|
|
26
|
+
export interface ConnectPlanInput {
|
|
27
|
+
/** The active environment's base URL (no trailing slash). */
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
/** The apiKeyHelper command, e.g. `yagni token`. */
|
|
30
|
+
helperCommand: string;
|
|
31
|
+
}
|
|
32
|
+
export interface ConnectPlan {
|
|
33
|
+
settings: Record<string, unknown>;
|
|
34
|
+
/** Human-readable `key: old → new` lines for the summary. */
|
|
35
|
+
changes: string[];
|
|
36
|
+
/** Keys that belonged to something else and were replaced — surfaced loudly. */
|
|
37
|
+
replaced: string[];
|
|
38
|
+
}
|
|
39
|
+
type Settings = Record<string, unknown>;
|
|
40
|
+
/**
|
|
41
|
+
* Merge our caller header into an existing ANTHROPIC_CUSTOM_HEADERS value:
|
|
42
|
+
* foreign header lines are preserved, a stale x-yagni-caller line is replaced,
|
|
43
|
+
* ours lands last. Newline-separated per the Claude Code contract.
|
|
44
|
+
*/
|
|
45
|
+
export declare function mergeCustomHeaders(existing: unknown): string;
|
|
46
|
+
/** Pure: the settings object after connecting, plus what changed. */
|
|
47
|
+
export declare function planConnect(existing: Settings, input: ConnectPlanInput): ConnectPlan;
|
|
48
|
+
export interface DisconnectPlan {
|
|
49
|
+
settings: Settings;
|
|
50
|
+
removed: string[];
|
|
51
|
+
/** A foreign apiKeyHelper we refused to touch, if any. */
|
|
52
|
+
keptForeignHelper?: string;
|
|
53
|
+
}
|
|
54
|
+
/** Pure: the settings object after `--off` — managed keys out, all else kept. */
|
|
55
|
+
export declare function planDisconnect(existing: Settings, helperCommand: string): DisconnectPlan;
|
|
56
|
+
export declare function settingsPathFor(scope: "user" | "project", cwd: string, home?: string): string;
|
|
57
|
+
/** Read + parse a settings file. Missing → {}. Corrupt/symlink/non-object → throws. */
|
|
58
|
+
export declare function readSettings(path: string): Settings;
|
|
59
|
+
/** Atomic write (temp + rename), preserving an existing file's permissions. */
|
|
60
|
+
export declare function writeSettings(path: string, settings: Settings): void;
|
|
61
|
+
export interface ConnectArgs {
|
|
62
|
+
target?: string;
|
|
63
|
+
project: boolean;
|
|
64
|
+
off: boolean;
|
|
65
|
+
}
|
|
66
|
+
export declare function parseConnectArgs(args: string[]): ConnectArgs;
|
|
67
|
+
export interface ConnectDeps {
|
|
68
|
+
readProfile?: () => Promise<Profile>;
|
|
69
|
+
cwd?: string;
|
|
70
|
+
home?: string;
|
|
71
|
+
env?: NodeJS.ProcessEnv;
|
|
72
|
+
stdout?: (text: string) => void;
|
|
73
|
+
stderr?: (text: string) => void;
|
|
74
|
+
}
|
|
75
|
+
export declare function connectCommand(args: string[], deps?: ConnectDeps): Promise<number>;
|
|
76
|
+
export {};
|
|
77
|
+
//# sourceMappingURL=connectClaudeCode.d.ts.map
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yagni connect claude-code` — point Claude Code at the YAGNI model proxy.
|
|
3
|
+
*
|
|
4
|
+
* Writes the three settings Claude Code needs into `~/.claude/settings.json`
|
|
5
|
+
* (or `.claude/settings.local.json` with `--project`):
|
|
6
|
+
*
|
|
7
|
+
* env.ANTHROPIC_BASE_URL the active environment's base URL (Claude Code
|
|
8
|
+
* appends /v1/messages — the proxy's Anthropic-
|
|
9
|
+
* dialect route)
|
|
10
|
+
* env.ANTHROPIC_CUSTOM_HEADERS `x-yagni-caller: claude-code`, the
|
|
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
|
|
14
|
+
* ever baked into a settings file and a rotated
|
|
15
|
+
* token is picked up automatically
|
|
16
|
+
*
|
|
17
|
+
* Everything else in the file is preserved verbatim. `--off` removes exactly
|
|
18
|
+
* the managed keys (the helper only when it is ours). The planner is pure and
|
|
19
|
+
* the writer is atomic (temp + rename, symlink-refusing) — the same guard
|
|
20
|
+
* rails as the launcher's settings seeding, except a corrupt file is an ERROR
|
|
21
|
+
* here rather than a silent back-off: the user asked for a config change, so
|
|
22
|
+
* failing quietly would be lying.
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, lstatSync, readFileSync, renameSync, rmSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { DISTRIBUTION } from "./distribution.js";
|
|
28
|
+
import { credentialsFromProfile, readActiveProfile } from "./profiles.js";
|
|
29
|
+
export const CALLER_HEADER_LINE = "x-yagni-caller: claude-code";
|
|
30
|
+
function envOf(settings) {
|
|
31
|
+
const env = settings.env;
|
|
32
|
+
return env && typeof env === "object" && !Array.isArray(env) ? { ...env } : {};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Merge our caller header into an existing ANTHROPIC_CUSTOM_HEADERS value:
|
|
36
|
+
* foreign header lines are preserved, a stale x-yagni-caller line is replaced,
|
|
37
|
+
* ours lands last. Newline-separated per the Claude Code contract.
|
|
38
|
+
*/
|
|
39
|
+
export function mergeCustomHeaders(existing) {
|
|
40
|
+
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));
|
|
42
|
+
return [...kept, CALLER_HEADER_LINE].join("\n");
|
|
43
|
+
}
|
|
44
|
+
/** Pure: the settings object after connecting, plus what changed. */
|
|
45
|
+
export function planConnect(existing, input) {
|
|
46
|
+
const env = envOf(existing);
|
|
47
|
+
const changes = [];
|
|
48
|
+
const replaced = [];
|
|
49
|
+
const note = (key, prior, next) => {
|
|
50
|
+
if (prior === next)
|
|
51
|
+
return;
|
|
52
|
+
changes.push(prior === undefined ? `${key} = ${next}` : `${key}: ${String(prior)} → ${next}`);
|
|
53
|
+
if (prior !== undefined)
|
|
54
|
+
replaced.push(key);
|
|
55
|
+
};
|
|
56
|
+
note("env.ANTHROPIC_BASE_URL", env.ANTHROPIC_BASE_URL, input.baseUrl);
|
|
57
|
+
env.ANTHROPIC_BASE_URL = input.baseUrl;
|
|
58
|
+
const headers = mergeCustomHeaders(env.ANTHROPIC_CUSTOM_HEADERS);
|
|
59
|
+
if (env.ANTHROPIC_CUSTOM_HEADERS !== headers) {
|
|
60
|
+
changes.push(`env.ANTHROPIC_CUSTOM_HEADERS = ${headers.replaceAll("\n", " | ")}`);
|
|
61
|
+
}
|
|
62
|
+
env.ANTHROPIC_CUSTOM_HEADERS = headers;
|
|
63
|
+
note("apiKeyHelper", existing.apiKeyHelper, input.helperCommand);
|
|
64
|
+
return {
|
|
65
|
+
settings: { ...existing, env, apiKeyHelper: input.helperCommand },
|
|
66
|
+
changes,
|
|
67
|
+
replaced,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** Pure: the settings object after `--off` — managed keys out, all else kept. */
|
|
71
|
+
export function planDisconnect(existing, helperCommand) {
|
|
72
|
+
const env = envOf(existing);
|
|
73
|
+
const removed = [];
|
|
74
|
+
const out = { ...existing };
|
|
75
|
+
if (env.ANTHROPIC_BASE_URL !== undefined) {
|
|
76
|
+
removed.push("env.ANTHROPIC_BASE_URL");
|
|
77
|
+
delete env.ANTHROPIC_BASE_URL;
|
|
78
|
+
}
|
|
79
|
+
if (typeof env.ANTHROPIC_CUSTOM_HEADERS === "string") {
|
|
80
|
+
const kept = env.ANTHROPIC_CUSTOM_HEADERS
|
|
81
|
+
.split("\n")
|
|
82
|
+
.filter((l) => l.trim() !== "" && !/^x-yagni-caller\s*:/i.test(l));
|
|
83
|
+
if (kept.length !== env.ANTHROPIC_CUSTOM_HEADERS.split("\n").filter((l) => l.trim() !== "").length) {
|
|
84
|
+
removed.push("env.ANTHROPIC_CUSTOM_HEADERS (x-yagni-caller line)");
|
|
85
|
+
}
|
|
86
|
+
if (kept.length === 0)
|
|
87
|
+
delete env.ANTHROPIC_CUSTOM_HEADERS;
|
|
88
|
+
else
|
|
89
|
+
env.ANTHROPIC_CUSTOM_HEADERS = kept.join("\n");
|
|
90
|
+
}
|
|
91
|
+
let keptForeignHelper;
|
|
92
|
+
if (typeof out.apiKeyHelper === "string") {
|
|
93
|
+
// 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)) {
|
|
96
|
+
removed.push("apiKeyHelper");
|
|
97
|
+
delete out.apiKeyHelper;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
keptForeignHelper = out.apiKeyHelper;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (Object.keys(env).length === 0 && out.env !== undefined)
|
|
104
|
+
delete out.env;
|
|
105
|
+
else
|
|
106
|
+
out.env = env;
|
|
107
|
+
return { settings: out, removed, ...(keptForeignHelper ? { keptForeignHelper } : {}) };
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// File I/O
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
export function settingsPathFor(scope, cwd, home = homedir()) {
|
|
113
|
+
return scope === "project"
|
|
114
|
+
? join(cwd, ".claude", "settings.local.json")
|
|
115
|
+
: join(home, ".claude", "settings.json");
|
|
116
|
+
}
|
|
117
|
+
/** Read + parse a settings file. Missing → {}. Corrupt/symlink/non-object → throws. */
|
|
118
|
+
export function readSettings(path) {
|
|
119
|
+
if (!existsSync(path))
|
|
120
|
+
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
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new Error(`${path} is not valid JSON. Fix or remove it, then re-run.`);
|
|
130
|
+
}
|
|
131
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
132
|
+
throw new Error(`${path} does not hold a JSON object. Fix or remove it, then re-run.`);
|
|
133
|
+
}
|
|
134
|
+
return parsed;
|
|
135
|
+
}
|
|
136
|
+
/** Atomic write (temp + rename), preserving an existing file's permissions. */
|
|
137
|
+
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);
|
|
150
|
+
}
|
|
151
|
+
export function parseConnectArgs(args) {
|
|
152
|
+
let target;
|
|
153
|
+
let project = false;
|
|
154
|
+
let off = false;
|
|
155
|
+
for (const a of args) {
|
|
156
|
+
if (a === "--project")
|
|
157
|
+
project = true;
|
|
158
|
+
else if (a === "--off")
|
|
159
|
+
off = true;
|
|
160
|
+
else if (!a.startsWith("-") && target === undefined)
|
|
161
|
+
target = a;
|
|
162
|
+
}
|
|
163
|
+
return { target, project, off };
|
|
164
|
+
}
|
|
165
|
+
export async function connectCommand(args, deps = {}) {
|
|
166
|
+
const stdout = deps.stdout ?? ((t) => process.stdout.write(t));
|
|
167
|
+
const stderr = deps.stderr ?? ((t) => process.stderr.write(t));
|
|
168
|
+
const { target, project, off } = parseConnectArgs(args);
|
|
169
|
+
if (target === "codex") {
|
|
170
|
+
if (project) {
|
|
171
|
+
stderr("`connect codex` has no --project scope: Codex reads one user-level config.toml.\n");
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
const { connectCodexCommand } = await import("./connectCodex.js");
|
|
175
|
+
return connectCodexCommand({ off }, deps);
|
|
176
|
+
}
|
|
177
|
+
if (target !== "claude-code") {
|
|
178
|
+
stderr(target
|
|
179
|
+
? `Unknown connect target "${target}". Supported: claude-code, codex.\n`
|
|
180
|
+
: `Usage: ${DISTRIBUTION.commandName} connect <claude-code|codex> [--project] [--off]\n`);
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
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);
|
|
186
|
+
let existing;
|
|
187
|
+
try {
|
|
188
|
+
existing = readSettings(path);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
stderr(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
if (off) {
|
|
195
|
+
const plan = planDisconnect(existing, DISTRIBUTION.commandName);
|
|
196
|
+
writeSettings(path, plan.settings);
|
|
197
|
+
stdout(plan.removed.length > 0
|
|
198
|
+
? `✓ Claude Code disconnected from YAGNI.\n ${path}\n Removed: ${plan.removed.join(", ")}\n`
|
|
199
|
+
: `Nothing to disconnect in ${path}.\n`);
|
|
200
|
+
if (plan.keptForeignHelper) {
|
|
201
|
+
stdout(` Kept apiKeyHelper (${plan.keptForeignHelper}) since it is not YAGNI's.\n`);
|
|
202
|
+
}
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
const creds = credentialsFromProfile(profile);
|
|
206
|
+
if (!creds?.token) {
|
|
207
|
+
stderr(`Not logged in to environment "${profile.name}" (${profile.baseUrl}). Run \`${DISTRIBUTION.commandName} login\` first.\n`);
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
const plan = planConnect(existing, { baseUrl: profile.baseUrl, helperCommand });
|
|
211
|
+
writeSettings(path, plan.settings);
|
|
212
|
+
stdout(`✓ Claude Code connected to YAGNI (${profile.name} → ${profile.baseUrl}).\n`);
|
|
213
|
+
stdout(` ${path}\n`);
|
|
214
|
+
for (const change of plan.changes)
|
|
215
|
+
stdout(` ${change}\n`);
|
|
216
|
+
for (const key of plan.replaced) {
|
|
217
|
+
stdout(` Replaced an existing ${key}. \`connect claude-code --off\` removes YAGNI's value but cannot restore the old one.\n`);
|
|
218
|
+
}
|
|
219
|
+
const shellEnv = deps.env ?? process.env;
|
|
220
|
+
for (const key of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]) {
|
|
221
|
+
if (shellEnv[key]) {
|
|
222
|
+
stdout(` ⚠ ${key} is set in your shell and OUTRANKS the settings written here. Unset it or Claude Code will keep using it.\n`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
stdout(` Restart Claude Code to pick this up. Model picks map to YAGNI tiers (fable→peak, opus→advanced, sonnet→standard, haiku→efficient).\n`);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=connectClaudeCode.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yagni connect codex` — point Codex CLI at the YAGNI model proxy.
|
|
3
|
+
*
|
|
4
|
+
* Writes into `~/.codex/config.toml` (respecting CODEX_HOME):
|
|
5
|
+
*
|
|
6
|
+
* model_provider = "yagni" the active provider
|
|
7
|
+
* model = "advanced" a YAGNI tier — Codex sends it
|
|
8
|
+
* verbatim and the proxy's catalog
|
|
9
|
+
* enforcement validates it
|
|
10
|
+
* [model_providers.yagni] base_url → `<base>/v1` (Codex
|
|
11
|
+
* appends /responses — the proxy's
|
|
12
|
+
* Responses-dialect route),
|
|
13
|
+
* wire_api "responses" (the only
|
|
14
|
+
* wire current Codex speaks),
|
|
15
|
+
* x-yagni-caller: codex attribution,
|
|
16
|
+
* and auth.command = `yagni token` —
|
|
17
|
+
* Codex's command-backed bearer
|
|
18
|
+
* token, so no secret is ever baked
|
|
19
|
+
* into the config and rotation rides
|
|
20
|
+
* the same refresh client as
|
|
21
|
+
* everything else.
|
|
22
|
+
*
|
|
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`
|
|
25
|
+
* next to it before rewriting and say so — never silently eat a user's notes.
|
|
26
|
+
* Everything else follows the claude-code connector's contract: managed keys
|
|
27
|
+
* only, atomic write, symlink refusal, corrupt file is a loud error, `--off`
|
|
28
|
+
* removes exactly what we own.
|
|
29
|
+
*/
|
|
30
|
+
import { type Profile } from "./profiles.js";
|
|
31
|
+
export declare const CODEX_PROVIDER_ID = "yagni";
|
|
32
|
+
export declare const CODEX_DEFAULT_TIER = "advanced";
|
|
33
|
+
type TomlTable = Record<string, unknown>;
|
|
34
|
+
export declare function codexConfigPath(home?: string, env?: NodeJS.ProcessEnv): string;
|
|
35
|
+
export interface CodexConnectPlan {
|
|
36
|
+
config: TomlTable;
|
|
37
|
+
changes: string[];
|
|
38
|
+
replaced: string[];
|
|
39
|
+
}
|
|
40
|
+
/** Pure: the config after connecting, plus what changed. */
|
|
41
|
+
export declare function planConnectCodex(existing: TomlTable, input: {
|
|
42
|
+
baseUrl: string;
|
|
43
|
+
commandName: string;
|
|
44
|
+
}): CodexConnectPlan;
|
|
45
|
+
export interface CodexDisconnectPlan {
|
|
46
|
+
config: TomlTable;
|
|
47
|
+
removed: string[];
|
|
48
|
+
}
|
|
49
|
+
/** Pure: the config after `--off` — our provider and its selection out. */
|
|
50
|
+
export declare function planDisconnectCodex(existing: TomlTable): CodexDisconnectPlan;
|
|
51
|
+
export interface CodexConfigFile {
|
|
52
|
+
config: TomlTable;
|
|
53
|
+
/** The raw text, kept so a comment-carrying file can be backed up. */
|
|
54
|
+
raw: string | null;
|
|
55
|
+
}
|
|
56
|
+
/** Read + parse the Codex config. Missing → {}. Corrupt/symlink → throws. */
|
|
57
|
+
export declare function readCodexConfig(path: string): CodexConfigFile;
|
|
58
|
+
/**
|
|
59
|
+
* Atomic write; when the original text carried comments (which a parse →
|
|
60
|
+
* 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
|
+
*/
|
|
63
|
+
export declare function writeCodexConfig(path: string, config: TomlTable, originalRaw: string | null): string | null;
|
|
64
|
+
export interface ConnectCodexDeps {
|
|
65
|
+
readProfile?: () => Promise<Profile>;
|
|
66
|
+
home?: string;
|
|
67
|
+
env?: NodeJS.ProcessEnv;
|
|
68
|
+
stdout?: (text: string) => void;
|
|
69
|
+
stderr?: (text: string) => void;
|
|
70
|
+
}
|
|
71
|
+
export declare function connectCodexCommand(opts: {
|
|
72
|
+
off: boolean;
|
|
73
|
+
}, deps?: ConnectCodexDeps): Promise<number>;
|
|
74
|
+
export {};
|
|
75
|
+
//# sourceMappingURL=connectCodex.d.ts.map
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yagni connect codex` — point Codex CLI at the YAGNI model proxy.
|
|
3
|
+
*
|
|
4
|
+
* Writes into `~/.codex/config.toml` (respecting CODEX_HOME):
|
|
5
|
+
*
|
|
6
|
+
* model_provider = "yagni" the active provider
|
|
7
|
+
* model = "advanced" a YAGNI tier — Codex sends it
|
|
8
|
+
* verbatim and the proxy's catalog
|
|
9
|
+
* enforcement validates it
|
|
10
|
+
* [model_providers.yagni] base_url → `<base>/v1` (Codex
|
|
11
|
+
* appends /responses — the proxy's
|
|
12
|
+
* Responses-dialect route),
|
|
13
|
+
* wire_api "responses" (the only
|
|
14
|
+
* wire current Codex speaks),
|
|
15
|
+
* x-yagni-caller: codex attribution,
|
|
16
|
+
* and auth.command = `yagni token` —
|
|
17
|
+
* Codex's command-backed bearer
|
|
18
|
+
* token, so no secret is ever baked
|
|
19
|
+
* into the config and rotation rides
|
|
20
|
+
* the same refresh client as
|
|
21
|
+
* everything else.
|
|
22
|
+
*
|
|
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`
|
|
25
|
+
* next to it before rewriting and say so — never silently eat a user's notes.
|
|
26
|
+
* Everything else follows the claude-code connector's contract: managed keys
|
|
27
|
+
* only, atomic write, symlink refusal, corrupt file is a loud error, `--off`
|
|
28
|
+
* removes exactly what we own.
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
34
|
+
import { DISTRIBUTION } from "./distribution.js";
|
|
35
|
+
import { credentialsFromProfile, readActiveProfile } from "./profiles.js";
|
|
36
|
+
export const CODEX_PROVIDER_ID = "yagni";
|
|
37
|
+
export const CODEX_DEFAULT_TIER = "advanced";
|
|
38
|
+
export function codexConfigPath(home = homedir(), env = process.env) {
|
|
39
|
+
const codexHome = env.CODEX_HOME?.trim() ? env.CODEX_HOME.trim() : join(home, ".codex");
|
|
40
|
+
return join(codexHome, "config.toml");
|
|
41
|
+
}
|
|
42
|
+
function providersOf(config) {
|
|
43
|
+
const providers = config.model_providers;
|
|
44
|
+
return providers && typeof providers === "object" && !Array.isArray(providers)
|
|
45
|
+
? { ...providers }
|
|
46
|
+
: {};
|
|
47
|
+
}
|
|
48
|
+
/** Pure: the config after connecting, plus what changed. */
|
|
49
|
+
export function planConnectCodex(existing, input) {
|
|
50
|
+
const changes = [];
|
|
51
|
+
const replaced = [];
|
|
52
|
+
const note = (key, prior, next) => {
|
|
53
|
+
if (prior === next)
|
|
54
|
+
return;
|
|
55
|
+
changes.push(prior === undefined ? `${key} = ${next}` : `${key}: ${String(prior)} → ${next}`);
|
|
56
|
+
if (prior !== undefined)
|
|
57
|
+
replaced.push(key);
|
|
58
|
+
};
|
|
59
|
+
note("model_provider", existing.model_provider, CODEX_PROVIDER_ID);
|
|
60
|
+
note("model", existing.model, CODEX_DEFAULT_TIER);
|
|
61
|
+
const providers = providersOf(existing);
|
|
62
|
+
if (providers[CODEX_PROVIDER_ID] === undefined) {
|
|
63
|
+
changes.push(`model_providers.${CODEX_PROVIDER_ID} = (provider block)`);
|
|
64
|
+
}
|
|
65
|
+
providers[CODEX_PROVIDER_ID] = {
|
|
66
|
+
name: "YAGNI",
|
|
67
|
+
base_url: `${input.baseUrl}/v1`,
|
|
68
|
+
wire_api: "responses",
|
|
69
|
+
http_headers: { "x-yagni-caller": "codex" },
|
|
70
|
+
auth: { command: input.commandName, args: ["token"] },
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
config: {
|
|
74
|
+
...existing,
|
|
75
|
+
model_provider: CODEX_PROVIDER_ID,
|
|
76
|
+
model: CODEX_DEFAULT_TIER,
|
|
77
|
+
model_providers: providers,
|
|
78
|
+
},
|
|
79
|
+
changes,
|
|
80
|
+
replaced,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Pure: the config after `--off` — our provider and its selection out. */
|
|
84
|
+
export function planDisconnectCodex(existing) {
|
|
85
|
+
const removed = [];
|
|
86
|
+
const out = { ...existing };
|
|
87
|
+
const providers = providersOf(existing);
|
|
88
|
+
if (providers[CODEX_PROVIDER_ID] !== undefined) {
|
|
89
|
+
removed.push(`model_providers.${CODEX_PROVIDER_ID}`);
|
|
90
|
+
delete providers[CODEX_PROVIDER_ID];
|
|
91
|
+
}
|
|
92
|
+
if (Object.keys(providers).length === 0)
|
|
93
|
+
delete out.model_providers;
|
|
94
|
+
else
|
|
95
|
+
out.model_providers = providers;
|
|
96
|
+
// The model selection is only ours when it points at our provider — a
|
|
97
|
+
// foreign model_provider (and its paired model) is left untouched.
|
|
98
|
+
if (out.model_provider === CODEX_PROVIDER_ID) {
|
|
99
|
+
removed.push("model_provider");
|
|
100
|
+
delete out.model_provider;
|
|
101
|
+
if (typeof out.model === "string") {
|
|
102
|
+
removed.push("model");
|
|
103
|
+
delete out.model;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { config: out, removed };
|
|
107
|
+
}
|
|
108
|
+
/** Read + parse the Codex config. Missing → {}. Corrupt/symlink → throws. */
|
|
109
|
+
export function readCodexConfig(path) {
|
|
110
|
+
if (!existsSync(path))
|
|
111
|
+
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
|
+
const raw = readFileSync(path, "utf8");
|
|
116
|
+
let parsed;
|
|
117
|
+
try {
|
|
118
|
+
parsed = parseToml(raw);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new Error(`${path} is not valid TOML. Fix or remove it, then re-run.`);
|
|
122
|
+
}
|
|
123
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
124
|
+
throw new Error(`${path} does not hold a TOML table. Fix or remove it, then re-run.`);
|
|
125
|
+
}
|
|
126
|
+
return { config: parsed, raw };
|
|
127
|
+
}
|
|
128
|
+
const COMMENT_RE = /^\s*#|\s#/m;
|
|
129
|
+
/**
|
|
130
|
+
* Atomic write; when the original text carried comments (which a parse →
|
|
131
|
+
* 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.
|
|
133
|
+
*/
|
|
134
|
+
export function writeCodexConfig(path, config, originalRaw) {
|
|
135
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
136
|
+
let backupPath = null;
|
|
137
|
+
if (originalRaw !== null && COMMENT_RE.test(originalRaw)) {
|
|
138
|
+
backupPath = `${path}.yagni-backup`;
|
|
139
|
+
writeFileSync(backupPath, originalRaw);
|
|
140
|
+
}
|
|
141
|
+
const existingMode = existsSync(path) ? lstatSync(path).mode & 0o777 : undefined;
|
|
142
|
+
const tmp = join(dirname(path), `.${DISTRIBUTION.commandName}-connect-codex-${process.pid}.tmp`);
|
|
143
|
+
try {
|
|
144
|
+
writeFileSync(tmp, `${stringifyToml(config)}\n`, existingMode !== undefined ? { mode: existingMode } : {});
|
|
145
|
+
renameSync(tmp, path);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
rmSync(tmp, { force: true });
|
|
149
|
+
}
|
|
150
|
+
return backupPath;
|
|
151
|
+
}
|
|
152
|
+
export async function connectCodexCommand(opts, deps = {}) {
|
|
153
|
+
const stdout = deps.stdout ?? ((t) => process.stdout.write(t));
|
|
154
|
+
const stderr = deps.stderr ?? ((t) => process.stderr.write(t));
|
|
155
|
+
const env = deps.env ?? process.env;
|
|
156
|
+
const path = codexConfigPath(deps.home, env);
|
|
157
|
+
const profile = await (deps.readProfile ?? readActiveProfile)();
|
|
158
|
+
let file;
|
|
159
|
+
try {
|
|
160
|
+
file = readCodexConfig(path);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
stderr(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
if (opts.off) {
|
|
167
|
+
const plan = planDisconnectCodex(file.config);
|
|
168
|
+
if (plan.removed.length === 0) {
|
|
169
|
+
stdout(`Nothing to disconnect in ${path}.\n`);
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
const backup = writeCodexConfig(path, plan.config, file.raw);
|
|
173
|
+
stdout(`✓ Codex disconnected from YAGNI.\n ${path}\n Removed: ${plan.removed.join(", ")}\n`);
|
|
174
|
+
if (backup)
|
|
175
|
+
stdout(` Comments in the original were preserved at ${backup}.\n`);
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
const creds = credentialsFromProfile(profile);
|
|
179
|
+
if (!creds?.token) {
|
|
180
|
+
stderr(`Not logged in to environment "${profile.name}" (${profile.baseUrl}). Run \`${DISTRIBUTION.commandName} login\` first.\n`);
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
const plan = planConnectCodex(file.config, {
|
|
184
|
+
baseUrl: profile.baseUrl,
|
|
185
|
+
commandName: DISTRIBUTION.commandName,
|
|
186
|
+
});
|
|
187
|
+
const backup = writeCodexConfig(path, plan.config, file.raw);
|
|
188
|
+
stdout(`✓ Codex connected to YAGNI (${profile.name} → ${profile.baseUrl}).\n`);
|
|
189
|
+
stdout(` ${path}\n`);
|
|
190
|
+
for (const change of plan.changes)
|
|
191
|
+
stdout(` ${change}\n`);
|
|
192
|
+
for (const key of plan.replaced) {
|
|
193
|
+
stdout(` Replaced an existing ${key}. \`connect codex --off\` removes YAGNI's value but cannot restore the old one.\n`);
|
|
194
|
+
}
|
|
195
|
+
if (backup) {
|
|
196
|
+
stdout(` Your config had comments, which a rewrite cannot keep. The original is at ${backup}.\n`);
|
|
197
|
+
}
|
|
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`);
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=connectCodex.js.map
|
|
@@ -35,7 +35,18 @@
|
|
|
35
35
|
* bundler), and external dependencies aren't resolvable from the bundled path.
|
|
36
36
|
*/
|
|
37
37
|
export type TokenEntry = string | {
|
|
38
|
-
op: "pipe" | "and" | "or" | "semi" | "
|
|
38
|
+
op: "pipe" | "and" | "or" | "semi" | "substitution";
|
|
39
|
+
} | {
|
|
40
|
+
op: "redirect";
|
|
41
|
+
direction: "out";
|
|
42
|
+
fd: "stdout" | "stderr";
|
|
43
|
+
target: string;
|
|
44
|
+
append: boolean;
|
|
45
|
+
} | {
|
|
46
|
+
op: "redirect";
|
|
47
|
+
direction: "in";
|
|
48
|
+
} | {
|
|
49
|
+
op: "background";
|
|
39
50
|
};
|
|
40
51
|
/**
|
|
41
52
|
* Parse a shell command string into tokens and control operators.
|
|
@@ -48,6 +59,11 @@ export type TokenEntry = string | {
|
|
|
48
59
|
* - `#` comments (start-of-word to end-of-line, outside quotes)
|
|
49
60
|
* - Shell constructs we flag as unanalyzable: $(), backticks (INCLUDING
|
|
50
61
|
* inside double quotes — bash executes those), >, <, background &
|
|
62
|
+
* - Redirect metadata: stdout/stderr redirects carry fd + target so that
|
|
63
|
+
* safe redirects (2>/dev/null, 2>&1) can be distinguished from unsafe ones
|
|
64
|
+
* (> file.txt). Stdin redirects (<, <<) carry no metadata — they always
|
|
65
|
+
* floor. Background & emits a distinct `background` op (not `semi`) so
|
|
66
|
+
* hasUnhandledConstructs can always catch it.
|
|
51
67
|
*
|
|
52
68
|
* Does NOT handle: variable expansion, glob patterns, heredocs beyond the
|
|
53
69
|
* redirect flag, nested subshells beyond depth tracking. Commands using
|
|
@@ -45,6 +45,11 @@
|
|
|
45
45
|
* - `#` comments (start-of-word to end-of-line, outside quotes)
|
|
46
46
|
* - Shell constructs we flag as unanalyzable: $(), backticks (INCLUDING
|
|
47
47
|
* inside double quotes — bash executes those), >, <, background &
|
|
48
|
+
* - Redirect metadata: stdout/stderr redirects carry fd + target so that
|
|
49
|
+
* safe redirects (2>/dev/null, 2>&1) can be distinguished from unsafe ones
|
|
50
|
+
* (> file.txt). Stdin redirects (<, <<) carry no metadata — they always
|
|
51
|
+
* floor. Background & emits a distinct `background` op (not `semi`) so
|
|
52
|
+
* hasUnhandledConstructs can always catch it.
|
|
48
53
|
*
|
|
49
54
|
* Does NOT handle: variable expansion, glob patterns, heredocs beyond the
|
|
50
55
|
* redirect flag, nested subshells beyond depth tracking. Commands using
|
|
@@ -149,11 +154,13 @@ export function shellParse(command) {
|
|
|
149
154
|
i += 2;
|
|
150
155
|
}
|
|
151
156
|
else {
|
|
152
|
-
// Single & — background operator.
|
|
153
|
-
//
|
|
154
|
-
//
|
|
157
|
+
// Single & — background operator. Emits a distinct `background` op
|
|
158
|
+
// (not `semi`) so hasUnhandledConstructs can always catch it and
|
|
159
|
+
// floor the command. The command before it must still be rule-
|
|
160
|
+
// matched: `rm -rf / &` has to stay forbidden, so emit a separator
|
|
161
|
+
// rather than gluing.
|
|
155
162
|
pushCurrent();
|
|
156
|
-
tokens.push({ op: "
|
|
163
|
+
tokens.push({ op: "background" });
|
|
157
164
|
hasConstruct = true;
|
|
158
165
|
i++;
|
|
159
166
|
}
|
|
@@ -163,18 +170,124 @@ export function shellParse(command) {
|
|
|
163
170
|
tokens.push({ op: "semi" });
|
|
164
171
|
i++;
|
|
165
172
|
continue;
|
|
166
|
-
case ">":
|
|
167
|
-
case "<":
|
|
173
|
+
case ">": {
|
|
168
174
|
pushCurrent();
|
|
169
|
-
|
|
175
|
+
// Check for a preceding fd digit: `2>` → stderr, `1>` → stdout.
|
|
176
|
+
// The digit was emitted as a string token — pop it and use as fd.
|
|
177
|
+
let fd = "stdout";
|
|
178
|
+
if (tokens.length > 0 && typeof tokens[tokens.length - 1] === "string") {
|
|
179
|
+
const last = tokens[tokens.length - 1];
|
|
180
|
+
if (last === "2") {
|
|
181
|
+
fd = "stderr";
|
|
182
|
+
tokens.pop();
|
|
183
|
+
}
|
|
184
|
+
else if (last === "1") {
|
|
185
|
+
fd = "stdout";
|
|
186
|
+
tokens.pop();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
let append = false;
|
|
190
|
+
i++;
|
|
191
|
+
if (command[i] === ">") {
|
|
192
|
+
append = true;
|
|
193
|
+
i++;
|
|
194
|
+
}
|
|
195
|
+
while (command[i] === " " || command[i] === "\t")
|
|
196
|
+
i++;
|
|
197
|
+
// Read the target — handles quoted targets (mirrors the main loop's
|
|
198
|
+
// quote logic), fd merges (&N), and bare words.
|
|
199
|
+
let target = "";
|
|
200
|
+
if (command[i] === "&") {
|
|
201
|
+
// fd merge: &1, &2, etc.
|
|
202
|
+
i++;
|
|
203
|
+
let digits = "";
|
|
204
|
+
while (command[i] >= "0" && command[i] <= "9") {
|
|
205
|
+
digits += command[i];
|
|
206
|
+
i++;
|
|
207
|
+
}
|
|
208
|
+
target = "&" + digits;
|
|
209
|
+
}
|
|
210
|
+
else if (command[i] === "'") {
|
|
211
|
+
i++;
|
|
212
|
+
while (i < command.length && command[i] !== "'") {
|
|
213
|
+
target += command[i];
|
|
214
|
+
i++;
|
|
215
|
+
}
|
|
216
|
+
if (i < command.length)
|
|
217
|
+
i++;
|
|
218
|
+
}
|
|
219
|
+
else if (command[i] === '"') {
|
|
220
|
+
i++;
|
|
221
|
+
while (i < command.length && command[i] !== '"') {
|
|
222
|
+
if (command[i] === "\\" && i + 1 < command.length) {
|
|
223
|
+
target += command[i + 1];
|
|
224
|
+
i += 2;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
target += command[i];
|
|
228
|
+
i++;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (i < command.length)
|
|
232
|
+
i++;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
while (i < command.length &&
|
|
236
|
+
command[i] !== " " && command[i] !== "\t" &&
|
|
237
|
+
command[i] !== "\n" && command[i] !== "\r" &&
|
|
238
|
+
command[i] !== "|" && command[i] !== "&" &&
|
|
239
|
+
command[i] !== ";" && command[i] !== ">" &&
|
|
240
|
+
command[i] !== "<") {
|
|
241
|
+
target += command[i];
|
|
242
|
+
i++;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
tokens.push({ op: "redirect", direction: "out", fd, target, append });
|
|
170
246
|
hasConstruct = true;
|
|
171
|
-
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
case "<": {
|
|
250
|
+
pushCurrent();
|
|
251
|
+
// Stdin redirect — always floors (changes program behavior).
|
|
172
252
|
i++;
|
|
173
|
-
if (command[i] ===
|
|
174
|
-
i++; //
|
|
253
|
+
if (command[i] === "<")
|
|
254
|
+
i++; // << heredoc — skip delimiter
|
|
175
255
|
while (command[i] === " " || command[i] === "\t")
|
|
176
256
|
i++;
|
|
257
|
+
// Consume the target (filename or heredoc delimiter) so it doesn't
|
|
258
|
+
// appear as a segment token — mirrors the > case's target reading.
|
|
259
|
+
if (command[i] === "'") {
|
|
260
|
+
i++;
|
|
261
|
+
while (i < command.length && command[i] !== "'")
|
|
262
|
+
i++;
|
|
263
|
+
if (i < command.length)
|
|
264
|
+
i++;
|
|
265
|
+
}
|
|
266
|
+
else if (command[i] === '"') {
|
|
267
|
+
i++;
|
|
268
|
+
while (i < command.length && command[i] !== '"') {
|
|
269
|
+
if (command[i] === "\\" && i + 1 < command.length)
|
|
270
|
+
i += 2;
|
|
271
|
+
else
|
|
272
|
+
i++;
|
|
273
|
+
}
|
|
274
|
+
if (i < command.length)
|
|
275
|
+
i++;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
while (i < command.length &&
|
|
279
|
+
command[i] !== " " && command[i] !== "\t" &&
|
|
280
|
+
command[i] !== "\n" && command[i] !== "\r" &&
|
|
281
|
+
command[i] !== "|" && command[i] !== "&" &&
|
|
282
|
+
command[i] !== ";" && command[i] !== ">" &&
|
|
283
|
+
command[i] !== "<") {
|
|
284
|
+
i++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
tokens.push({ op: "redirect", direction: "in" });
|
|
288
|
+
hasConstruct = true;
|
|
177
289
|
continue;
|
|
290
|
+
}
|
|
178
291
|
case "$":
|
|
179
292
|
if (command[i + 1] === "(") {
|
|
180
293
|
pushCurrent();
|
|
@@ -213,9 +326,10 @@ export function shellParse(command) {
|
|
|
213
326
|
}
|
|
214
327
|
pushCurrent();
|
|
215
328
|
// If we detected constructs but didn't emit them as operator tokens
|
|
216
|
-
// (e.g.
|
|
217
|
-
//
|
|
218
|
-
|
|
329
|
+
// (e.g. double-quoted substitution), surface that via a trailing
|
|
330
|
+
// substitution token so hasUnhandledConstructs sees it. Background & now
|
|
331
|
+
// emits its own distinct op, so it no longer relies on this fallback.
|
|
332
|
+
if (hasConstruct && !tokens.some((t) => typeof t === "object" && (t.op === "redirect" || t.op === "substitution" || t.op === "background"))) {
|
|
219
333
|
tokens.push({ op: "substitution" });
|
|
220
334
|
}
|
|
221
335
|
return tokens;
|
|
@@ -294,47 +408,58 @@ const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
|
294
408
|
export function tokenize(command) {
|
|
295
409
|
return shellParse(command).filter((t) => typeof t === "string");
|
|
296
410
|
}
|
|
297
|
-
/**
|
|
298
|
-
const SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
|
|
411
|
+
/** Ops that split compound commands into segments (pipe, &&, ||, ;, background &). */
|
|
412
|
+
const SPLIT_OPS = new Set(["pipe", "and", "or", "semi", "background"]);
|
|
413
|
+
/** Ops that are safe splittable separators — they do NOT trigger the construct floor.
|
|
414
|
+
* Background & is in SPLIT_OPS (splits segments) but NOT here (always floors). */
|
|
415
|
+
const SAFE_SPLIT_OPS = new Set(["pipe", "and", "or", "semi"]);
|
|
416
|
+
/** A redirect to /dev/null (discard) or &N (fd merge) is safe — no file created. */
|
|
417
|
+
function isSafeRedirect(t) {
|
|
418
|
+
if (typeof t === "string")
|
|
419
|
+
return false;
|
|
420
|
+
if (t.op !== "redirect")
|
|
421
|
+
return false;
|
|
422
|
+
if (t.direction === "in")
|
|
423
|
+
return false; // stdin redirect — always floor
|
|
424
|
+
if (t.target === "/dev/null")
|
|
425
|
+
return true; // discard stderr/stdout — safe
|
|
426
|
+
if (t.target.startsWith("&") && t.target.length > 1)
|
|
427
|
+
return true; // fd merge (2>&1, 1>&2) — safe; bare "&" (>& with no digit) is NOT safe
|
|
428
|
+
return false; // > file.txt, >> file.txt — unsafe
|
|
429
|
+
}
|
|
299
430
|
/**
|
|
300
431
|
* Detect whether the command uses shell constructs we can't statically
|
|
301
|
-
* classify (command substitution, redirects, background &) — anything
|
|
302
|
-
* is NOT a splittable operator
|
|
303
|
-
* carrying them is never auto-allowed, but
|
|
432
|
+
* classify (command substitution, unsafe redirects, background &) — anything
|
|
433
|
+
* that is NOT a safe splittable operator or a safe redirect. These impose a
|
|
434
|
+
* floor of `prompt`: a command carrying them is never auto-allowed, but
|
|
435
|
+
* forbidden matches still win.
|
|
304
436
|
*/
|
|
305
437
|
function hasUnhandledConstructs(command) {
|
|
306
|
-
return shellParse(command).some((t) => typeof t === "object" &&
|
|
438
|
+
return shellParse(command).some((t) => typeof t === "object" && !SAFE_SPLIT_OPS.has(t.op) && !isSafeRedirect(t));
|
|
307
439
|
}
|
|
308
440
|
/**
|
|
309
441
|
* Split a command into token-array segments at control operators (|, &&, ||,
|
|
310
|
-
* ;, newline). Redirect targets
|
|
311
|
-
*
|
|
312
|
-
* carried through (never re-joined into strings)
|
|
442
|
+
* ;, newline, background &). Redirect targets are consumed inside the
|
|
443
|
+
* tokenizer's > / < cases (stored on the redirect op), so no skipNext logic
|
|
444
|
+
* is needed. Token arrays are carried through (never re-joined into strings)
|
|
445
|
+
* so quoting survives.
|
|
313
446
|
*/
|
|
314
447
|
function splitSegmentsTokens(command) {
|
|
315
448
|
const parsed = shellParse(command);
|
|
316
449
|
const segments = [];
|
|
317
450
|
let current = [];
|
|
318
|
-
let skipNext = false;
|
|
319
451
|
for (const t of parsed) {
|
|
320
452
|
if (typeof t === "object") {
|
|
321
453
|
if (SPLIT_OPS.has(t.op)) {
|
|
322
454
|
if (current.length > 0)
|
|
323
455
|
segments.push(current);
|
|
324
456
|
current = [];
|
|
325
|
-
skipNext = false;
|
|
326
|
-
}
|
|
327
|
-
else if (t.op === "redirect") {
|
|
328
|
-
skipNext = true;
|
|
329
457
|
}
|
|
330
|
-
// substitution ops are construct markers;
|
|
331
|
-
//
|
|
458
|
+
// redirect and substitution ops are construct markers; their targets
|
|
459
|
+
// are consumed inside the tokenizer, and the inner text of $(...) is
|
|
460
|
+
// handled by dangerScan via extractSubstitutions.
|
|
332
461
|
}
|
|
333
462
|
else {
|
|
334
|
-
if (skipNext) {
|
|
335
|
-
skipNext = false;
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
463
|
current.push(t);
|
|
339
464
|
}
|
|
340
465
|
}
|
|
@@ -510,6 +635,9 @@ function classifySegmentTokens(rawTokens, policy, opts) {
|
|
|
510
635
|
const tailResult = classifySegmentTokens(tail, policy, { ...opts, depth: opts.depth + 1 });
|
|
511
636
|
if (tailResult.decision === "forbidden")
|
|
512
637
|
return tailResult;
|
|
638
|
+
if (tailResult.decision === "allow" && !neverAllow) {
|
|
639
|
+
return { decision: "allow", justification: "xargs forwards to a read-only command" };
|
|
640
|
+
}
|
|
513
641
|
}
|
|
514
642
|
if (opts.forbiddenOnly)
|
|
515
643
|
return { decision: "allow", justification: "no forbidden match" };
|
|
@@ -727,6 +855,7 @@ export const DEFAULT_EXEC_POLICY = {
|
|
|
727
855
|
{ pattern: ["jq"], decision: "allow", justification: "filter JSON to stdout" },
|
|
728
856
|
{ pattern: ["stat"], decision: "allow", justification: "show file metadata" },
|
|
729
857
|
{ pattern: ["file"], decision: "allow", justification: "identify file type" },
|
|
858
|
+
{ pattern: ["strings"], decision: "allow", justification: "extract printable strings from binary files (read-only)" },
|
|
730
859
|
{ pattern: ["basename"], decision: "allow", justification: "strip directory from path" },
|
|
731
860
|
{ pattern: ["dirname"], decision: "allow", justification: "extract directory from path" },
|
|
732
861
|
{ pattern: ["realpath"], decision: "allow", justification: "resolve a path" },
|
|
@@ -760,7 +889,9 @@ export const DEFAULT_EXEC_POLICY = {
|
|
|
760
889
|
{ pattern: ["node", "--version"], decision: "allow", justification: "check node version" },
|
|
761
890
|
{ pattern: ["node", "-v"], decision: "allow", justification: "check node version" },
|
|
762
891
|
{ pattern: ["npm", "ls"], decision: "allow", justification: "list installed packages" },
|
|
892
|
+
{ pattern: ["npm", "list"], decision: "allow", justification: "list installed packages" },
|
|
763
893
|
{ pattern: ["pnpm", "ls"], decision: "allow", justification: "list installed packages" },
|
|
894
|
+
{ pattern: ["pnpm", "list"], decision: "allow", justification: "list installed packages" },
|
|
764
895
|
{ pattern: ["pnpm", "--version"], decision: "allow", justification: "check pnpm version" },
|
|
765
896
|
{ pattern: ["tsc", "--version"], decision: "allow", justification: "check typescript version" },
|
|
766
897
|
// --- prompt: potentially destructive but context-dependent ---
|
package/dist/login.d.ts
CHANGED
|
@@ -34,8 +34,10 @@ type OpenRunner = (cmd: string, args: string[]) => Promise<void>;
|
|
|
34
34
|
*
|
|
35
35
|
* macOS: open <url>
|
|
36
36
|
* Linux: xdg-open <url>
|
|
37
|
-
* Windows:
|
|
38
|
-
* without
|
|
37
|
+
* Windows: rundll32 url.dll,FileProtocolHandler <url> (opens the default
|
|
38
|
+
* browser without going through cmd.exe — `cmd /c start <url>`
|
|
39
|
+
* re-parses its arguments as a shell line, so a hostile URL with
|
|
40
|
+
* `&`/`^` metacharacters could execute commands)
|
|
39
41
|
*/
|
|
40
42
|
export declare function resolveOpenCommand(url: string, platform?: NodeJS.Platform): {
|
|
41
43
|
cmd: string;
|
package/dist/login.js
CHANGED
|
@@ -31,14 +31,17 @@ function isTimeoutError(err) {
|
|
|
31
31
|
*
|
|
32
32
|
* macOS: open <url>
|
|
33
33
|
* Linux: xdg-open <url>
|
|
34
|
-
* Windows:
|
|
35
|
-
* without
|
|
34
|
+
* Windows: rundll32 url.dll,FileProtocolHandler <url> (opens the default
|
|
35
|
+
* browser without going through cmd.exe — `cmd /c start <url>`
|
|
36
|
+
* re-parses its arguments as a shell line, so a hostile URL with
|
|
37
|
+
* `&`/`^` metacharacters could execute commands)
|
|
36
38
|
*/
|
|
37
39
|
export function resolveOpenCommand(url, platform = process.platform) {
|
|
38
40
|
if (platform === "darwin")
|
|
39
41
|
return { cmd: "open", args: [url] };
|
|
40
|
-
if (platform === "win32")
|
|
41
|
-
return { cmd: "
|
|
42
|
+
if (platform === "win32") {
|
|
43
|
+
return { cmd: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
|
|
44
|
+
}
|
|
42
45
|
return { cmd: "xdg-open", args: [url] };
|
|
43
46
|
}
|
|
44
47
|
const spawnRunner = (cmd, args) => new Promise((resolve, reject) => {
|
|
@@ -50,6 +53,18 @@ const spawnRunner = (cmd, args) => new Promise((resolve, reject) => {
|
|
|
50
53
|
* URL is still printed for manual copy. `runner` is injectable for tests.
|
|
51
54
|
*/
|
|
52
55
|
export const realOpenUrl = (url, runner = spawnRunner) => {
|
|
56
|
+
// Only ever hand http(s) URLs to the OS opener — refuse file:, javascript:,
|
|
57
|
+
// or custom schemes a compromised server response could try to smuggle in.
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = new URL(url);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return Promise.resolve();
|
|
64
|
+
}
|
|
65
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
66
|
+
return Promise.resolve();
|
|
67
|
+
}
|
|
53
68
|
const { cmd, args } = resolveOpenCommand(url);
|
|
54
69
|
return runner(cmd, args).catch(() => {
|
|
55
70
|
// Silently fail — the user can still copy the URL manually.
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yagni token` — print the active environment's API token to stdout.
|
|
3
|
+
*
|
|
4
|
+
* This is the credential feed for `yagni connect claude-code`: Claude Code's
|
|
5
|
+
* `apiKeyHelper` runs this command (re-running on 401 and on a TTL), so the
|
|
6
|
+
* contract is strict — stdout carries the TOKEN AND NOTHING ELSE (Claude Code
|
|
7
|
+
* v2.1.227+ rejects helpers that print banners), and every notice goes to
|
|
8
|
+
* stderr. Riding the same refresh-at-launch client as the launcher means a
|
|
9
|
+
* token that enters the 7-day rotation window is rotated and persisted here
|
|
10
|
+
* too, so a Claude Code session keeps working without the user ever running
|
|
11
|
+
* `yagni` itself.
|
|
12
|
+
*/
|
|
13
|
+
import { maybeRefreshAtLaunch } from "./refresh.js";
|
|
14
|
+
import type { Credentials } from "./credentials.js";
|
|
15
|
+
import { type Profile } from "./profiles.js";
|
|
16
|
+
export interface TokenCommandDeps {
|
|
17
|
+
readProfile?: () => Promise<Profile>;
|
|
18
|
+
refresh?: typeof maybeRefreshAtLaunch;
|
|
19
|
+
persist?: (name: string, creds: Credentials) => Promise<void>;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
stdout?: (text: string) => void;
|
|
22
|
+
stderr?: (text: string) => void;
|
|
23
|
+
}
|
|
24
|
+
export declare function tokenCommand(deps?: TokenCommandDeps): Promise<number>;
|
|
25
|
+
//# sourceMappingURL=token.d.ts.map
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yagni token` — print the active environment's API token to stdout.
|
|
3
|
+
*
|
|
4
|
+
* This is the credential feed for `yagni connect claude-code`: Claude Code's
|
|
5
|
+
* `apiKeyHelper` runs this command (re-running on 401 and on a TTL), so the
|
|
6
|
+
* contract is strict — stdout carries the TOKEN AND NOTHING ELSE (Claude Code
|
|
7
|
+
* v2.1.227+ rejects helpers that print banners), and every notice goes to
|
|
8
|
+
* stderr. Riding the same refresh-at-launch client as the launcher means a
|
|
9
|
+
* token that enters the 7-day rotation window is rotated and persisted here
|
|
10
|
+
* too, so a Claude Code session keeps working without the user ever running
|
|
11
|
+
* `yagni` itself.
|
|
12
|
+
*/
|
|
13
|
+
import { classifyTokenExpiry } from "./launch.js";
|
|
14
|
+
import { maybeRefreshAtLaunch } from "./refresh.js";
|
|
15
|
+
import { credentialsFromProfile, persistProfileTokenRotation, readActiveProfile, } from "./profiles.js";
|
|
16
|
+
export async function tokenCommand(deps = {}) {
|
|
17
|
+
const readProfile = deps.readProfile ?? readActiveProfile;
|
|
18
|
+
const refresh = deps.refresh ?? maybeRefreshAtLaunch;
|
|
19
|
+
const persist = deps.persist ?? persistProfileTokenRotation;
|
|
20
|
+
const stdout = deps.stdout ?? ((t) => process.stdout.write(t));
|
|
21
|
+
const stderr = deps.stderr ?? ((t) => process.stderr.write(t));
|
|
22
|
+
const profile = await readProfile();
|
|
23
|
+
let creds = credentialsFromProfile(profile);
|
|
24
|
+
if (!creds?.token) {
|
|
25
|
+
stderr(`Not logged in to environment "${profile.name}" (${profile.baseUrl}). Run \`yagni login\` first.\n`);
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
const outcome = await refresh(creds, {
|
|
29
|
+
persist: (c) => persist(profile.name, c),
|
|
30
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
31
|
+
});
|
|
32
|
+
for (const warning of outcome.warnings)
|
|
33
|
+
stderr(`${warning}\n`);
|
|
34
|
+
creds = outcome.creds;
|
|
35
|
+
// An expired token would send the consumer into a silent 401 loop — fail
|
|
36
|
+
// loudly instead so Claude Code surfaces a helper error the user can act on.
|
|
37
|
+
const nowMs = deps.now ? deps.now() : Date.now();
|
|
38
|
+
if (classifyTokenExpiry(creds.expiresAt, nowMs).kind === "expired") {
|
|
39
|
+
stderr("Your YAGNI Code session has expired. Run `yagni login` to re-authenticate.\n");
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
stdout(`${creds.token}\n`);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=token.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "0.3.0-staging.
|
|
3
|
+
"version": "0.3.0-staging.1096.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)",
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@earendil-works/pi-coding-agent": "0.84.1",
|
|
38
38
|
"@earendil-works/pi-tui": "0.84.1",
|
|
39
|
+
"smol-toml": "^1.8.0",
|
|
39
40
|
"typebox": "^1.3.11"
|
|
40
41
|
},
|
|
41
|
-
"yagniSourceSha": "
|
|
42
|
+
"yagniSourceSha": "04ba3799e7c6310f337b80067ba44ee257d69df3"
|
|
42
43
|
}
|