llm-switcher 1.2.12 → 1.3.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/CHANGELOG.md +28 -0
- package/README.md +109 -19
- package/README.vi.md +107 -19
- package/config.example.json +13 -1
- package/formats.mjs +128 -12
- package/hook-status.mjs +5 -4
- package/mcp.mjs +6 -5
- package/package.json +1 -1
- package/plugin.mjs +80 -9
- package/proxy.mjs +155 -18
- package/shim.mjs +62 -20
- package/skills/llm-switcher/SKILL.md +6 -6
- package/state.mjs +50 -13
- package/switch.mjs +15 -9
- package/tests/agy.e2e.test.mjs +534 -0
- package/tests/agy.unit.test.mjs +190 -0
- package/tests/dashboard.e2e.test.mjs +18 -17
- package/tests/formats.test.mjs +83 -2
- package/tests/gateway.e2e.test.mjs +20 -2
- package/tests/mcp.test.mjs +5 -5
- package/tests/plugin.test.mjs +4 -2
- package/tests/shim.test.mjs +165 -0
- package/tests/state.test.mjs +8 -8
- package/ui.html +51 -7
- package/verify-gateway.mjs +35 -0
package/formats.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// client --parse--> IR --emit--> upstream --events--> client
|
|
7
7
|
//
|
|
8
|
-
// Client (input) : anthropic
|
|
8
|
+
// Client (input) : anthropic (Claude Code) | responses (Codex) | codeassist (Antigravity CLI)
|
|
9
9
|
// Upstream (out) : openai-chat | anthropic | vertex
|
|
10
10
|
//
|
|
11
11
|
// IR shape:
|
|
@@ -663,16 +663,111 @@ function responsesToIR(payload) {
|
|
|
663
663
|
return ir;
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
//
|
|
670
|
-
|
|
666
|
+
// The Antigravity CLI (agy) wraps a Gemini request in a Code Assist envelope:
|
|
667
|
+
// {project, requestId, request: {contents, systemInstruction, tools, generationConfig}, model}.
|
|
668
|
+
// The CLI names every function call, and gives a tool result back as {"output": "<text>"} in a
|
|
669
|
+
// turn of either role, so the result is matched by id, never by its position.
|
|
670
|
+
function codeAssistToIR(envelope) {
|
|
671
|
+
const req = envelope?.request || {};
|
|
672
|
+
const ir = baseIR();
|
|
673
|
+
ir.model = envelope?.model || '';
|
|
674
|
+
|
|
675
|
+
const sys = req.systemInstruction?.parts;
|
|
676
|
+
if (Array.isArray(sys)) ir.system = sys.map(p => p.text || '').filter(Boolean).join('\n\n');
|
|
677
|
+
|
|
678
|
+
// An id the CLI omitted is made up per name, and its result takes the oldest open id of that name.
|
|
679
|
+
// A result that names its id closes that id, so a later id-less result never takes it again.
|
|
680
|
+
const pendingByName = new Map();
|
|
681
|
+
let callSeq = 0;
|
|
682
|
+
let orphanSeq = 0;
|
|
683
|
+
for (const c of Array.isArray(req.contents) ? req.contents : []) {
|
|
684
|
+
const parts = Array.isArray(c?.parts) ? c.parts : [];
|
|
685
|
+
const texts = [];
|
|
686
|
+
const toolCalls = [];
|
|
687
|
+
for (const p of parts) {
|
|
688
|
+
if (!p || typeof p !== 'object') continue;
|
|
689
|
+
if (typeof p.text === 'string' && p.text && p.thought !== true) texts.push(p.text);
|
|
690
|
+
if (p.functionCall) {
|
|
691
|
+
const name = p.functionCall.name || '';
|
|
692
|
+
const id = p.functionCall.id || `call_agy_${callSeq++}_${name}`;
|
|
693
|
+
if (!pendingByName.has(name)) pendingByName.set(name, []);
|
|
694
|
+
pendingByName.get(name).push(id);
|
|
695
|
+
const sig = p.thoughtSignature || null;
|
|
696
|
+
rememberToolSignature(id, sig);
|
|
697
|
+
toolCalls.push({ id, name, args: p.functionCall.args ?? {}, sig });
|
|
698
|
+
}
|
|
699
|
+
if (p.functionResponse) {
|
|
700
|
+
const fr = p.functionResponse;
|
|
701
|
+
const name = fr.name || '';
|
|
702
|
+
const queue = pendingByName.get(name) || [];
|
|
703
|
+
let id = fr.id;
|
|
704
|
+
if (id) {
|
|
705
|
+
const at = queue.indexOf(id);
|
|
706
|
+
if (at >= 0) queue.splice(at, 1);
|
|
707
|
+
} else {
|
|
708
|
+
id = queue.length ? queue.shift() : `call_agy_orphan_${orphanSeq++}_${name}`;
|
|
709
|
+
}
|
|
710
|
+
const out = fr.response?.output ?? fr.response;
|
|
711
|
+
ir.messages.push({ role: 'tool', toolCallId: id, name: name || null, content: typeof out === 'string' ? out : JSON.stringify(out ?? '') });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
if (c.role === 'model') {
|
|
715
|
+
const msg = { role: 'assistant' };
|
|
716
|
+
if (texts.length) msg.content = texts.join('');
|
|
717
|
+
if (toolCalls.length) msg.toolCalls = toolCalls;
|
|
718
|
+
if (msg.content !== undefined || msg.toolCalls) ir.messages.push(msg);
|
|
719
|
+
} else if (texts.length) {
|
|
720
|
+
ir.messages.push({ role: 'user', content: texts.join('') });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const fns = (req.tools || []).flatMap(t => t.functionDeclarations || []);
|
|
725
|
+
if (fns.length) {
|
|
726
|
+
ir.tools = fns.filter(f => f.name).map(f => ({
|
|
727
|
+
name: f.name, description: f.description || '',
|
|
728
|
+
parameters: sanitizeJsonSchema(lowerSchemaTypes(f.parameters || {}))
|
|
729
|
+
}));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const gc = req.generationConfig || {};
|
|
733
|
+
if (typeof gc.maxOutputTokens === 'number') ir.params.maxTokens = gc.maxOutputTokens;
|
|
734
|
+
if (typeof gc.temperature === 'number') ir.params.temperature = gc.temperature;
|
|
735
|
+
if (typeof gc.topP === 'number') ir.params.topP = gc.topP;
|
|
736
|
+
if (Array.isArray(gc.stopSequences)) ir.params.stop = stopList(gc.stopSequences);
|
|
737
|
+
const th = gc.thinkingConfig;
|
|
738
|
+
if (th && typeof th === 'object') {
|
|
739
|
+
if (th.thinkingBudget === 0) ir.thinking = { type: 'disabled' };
|
|
740
|
+
else if (th.thinkingBudget > 0) ir.thinking = { type: 'enabled', budget: clampBudget(th.thinkingBudget) };
|
|
741
|
+
else if (th.thinkingLevel) ir.thinking = { type: 'enabled', budget: effortToBudget(String(th.thinkingLevel).toLowerCase()), effort: String(th.thinkingLevel).toLowerCase() };
|
|
742
|
+
else if (th.includeThoughts) ir.thinking = { type: 'enabled', budget: 2048 };
|
|
743
|
+
}
|
|
744
|
+
return ir;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// The CLI writes Gemini schema types in upper case (OBJECT, STRING); JSON Schema wants lower case.
|
|
748
|
+
function lowerSchemaTypes(schema) {
|
|
749
|
+
if (Array.isArray(schema)) return schema.map(lowerSchemaTypes);
|
|
750
|
+
if (!schema || typeof schema !== 'object') return schema;
|
|
751
|
+
const out = {};
|
|
752
|
+
for (const [k, v] of Object.entries(schema)) {
|
|
753
|
+
out[k] = k === 'type' && typeof v === 'string' ? v.toLowerCase()
|
|
754
|
+
: k === 'properties' && v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([pk, pv]) => [pk, lowerSchemaTypes(pv)]))
|
|
755
|
+
: (k === 'items' || k === 'anyOf' || k === 'oneOf' || k === 'allOf') ? lowerSchemaTypes(v) : v;
|
|
756
|
+
}
|
|
757
|
+
return out;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// R5: one input per tool — `anthropic` for Claude Code, `responses` for Codex, `codeassist` for
|
|
761
|
+
// the Antigravity CLI. The openai-chat and vertex routes were removed with their parsers; a format
|
|
762
|
+
// that reaches here anyway is an error, never a silent fallback to another protocol (a different
|
|
763
|
+
// API key!). The UPSTREAM formats are a different axis: emitUpstreamBody still speaks openai-chat,
|
|
764
|
+
// anthropic and vertex to whatever provider the profile points at.
|
|
671
765
|
function parseToIR(clientFormat, payload) {
|
|
672
766
|
switch (clientFormat) {
|
|
673
767
|
case 'anthropic': return anthropicToIR(payload);
|
|
674
768
|
case 'responses': return responsesToIR(payload);
|
|
675
|
-
|
|
769
|
+
case 'codeassist': return codeAssistToIR(payload);
|
|
770
|
+
default: throw new Error(`Unsupported input format: ${clientFormat}. Only anthropic (Claude Code), responses (Codex) and codeassist (agy) accept requests.`);
|
|
676
771
|
}
|
|
677
772
|
}
|
|
678
773
|
|
|
@@ -2119,7 +2214,7 @@ function vertexUsage(prompt, completion, cached, reasoning) {
|
|
|
2119
2214
|
return u;
|
|
2120
2215
|
}
|
|
2121
2216
|
|
|
2122
|
-
function buildVertexMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, sig }) {
|
|
2217
|
+
function buildVertexMessage({ model, think, text, tools, finish, prompt, completion, cached, reasoning, sig, ids = false }) {
|
|
2123
2218
|
const parts = [];
|
|
2124
2219
|
const thinking = (think || []).join('');
|
|
2125
2220
|
if (thinking) {
|
|
@@ -2130,7 +2225,7 @@ function buildVertexMessage({ model, think, text, tools, finish, prompt, complet
|
|
|
2130
2225
|
const body = (text || []).join('');
|
|
2131
2226
|
if (body) parts.push({ text: body });
|
|
2132
2227
|
for (const tc of (tools || [])) {
|
|
2133
|
-
const part = { functionCall: { name: tc.name || 'tool', args: parseArgs(tc.args) } };
|
|
2228
|
+
const part = { functionCall: { ...(ids && tc.id ? { id: tc.id } : {}), name: tc.name || 'tool', args: parseArgs(tc.args) } };
|
|
2134
2229
|
const tsig = tc.sig || lookupToolSignature(tc.id);
|
|
2135
2230
|
if (tsig) part.thoughtSignature = tsig;
|
|
2136
2231
|
parts.push(part);
|
|
@@ -2143,7 +2238,8 @@ function buildVertexMessage({ model, think, text, tools, finish, prompt, complet
|
|
|
2143
2238
|
};
|
|
2144
2239
|
}
|
|
2145
2240
|
|
|
2146
|
-
|
|
2241
|
+
// ids: name each functionCall, as Code Assist does. Plain Vertex has no call id.
|
|
2242
|
+
function createVertexStream(emit, model, { ids = false } = {}) {
|
|
2147
2243
|
const cand = (parts) => emit(null, { candidates: [{ content: { role: 'model', parts }, index: 0 }], modelVersion: model });
|
|
2148
2244
|
// Tool args arrive from OpenAI/Anthropic as deltas; Vertex needs complete functionCalls -> buffer them, emit at the end.
|
|
2149
2245
|
const tools = new Map();
|
|
@@ -2170,7 +2266,7 @@ function createVertexStream(emit, model) {
|
|
|
2170
2266
|
const parts = [...tools.values()]
|
|
2171
2267
|
.filter(t => t.name)
|
|
2172
2268
|
.map(t => {
|
|
2173
|
-
const part = { functionCall: { name: t.name, args: parseArgs(t.args || '{}') } };
|
|
2269
|
+
const part = { functionCall: { ...(ids && t.id ? { id: t.id } : {}), name: t.name, args: parseArgs(t.args || '{}') } };
|
|
2174
2270
|
const tsig = t.sig || lookupToolSignature(t.id);
|
|
2175
2271
|
if (tsig) part.thoughtSignature = tsig;
|
|
2176
2272
|
return part;
|
|
@@ -2187,12 +2283,31 @@ function createVertexStream(emit, model) {
|
|
|
2187
2283
|
};
|
|
2188
2284
|
}
|
|
2189
2285
|
|
|
2286
|
+
// --- Code Assist (Antigravity CLI) ---
|
|
2287
|
+
// The same Gemini chunks, each inside {"response": ..., "traceId": ...}, with call ids. An error is
|
|
2288
|
+
// sent bare, as Code Assist sends one.
|
|
2289
|
+
// An id to tell answers apart in a log, not a secret: this module imports nothing.
|
|
2290
|
+
function codeAssistTrace() {
|
|
2291
|
+
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
function createCodeAssistStream(emit, model) {
|
|
2295
|
+
const traceId = codeAssistTrace();
|
|
2296
|
+
const responseId = `agy-${traceId}`;
|
|
2297
|
+
return createVertexStream((e, d) => emit(e, d?.error ? d : { response: { ...d, responseId }, traceId }), model, { ids: true });
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
function buildCodeAssistMessage(args) {
|
|
2301
|
+
const traceId = codeAssistTrace();
|
|
2302
|
+
return { response: { ...buildVertexMessage({ ...args, ids: true }), responseId: `agy-${traceId}` }, traceId };
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2190
2305
|
export {
|
|
2191
2306
|
canonFinish, chatFinish, anthropicStopReason,
|
|
2192
2307
|
smartReasoning, smartText, smartToolCalls, smartUsage, smartFinish,
|
|
2193
2308
|
firstChoice, smartDelta, sanitizeJsonSchema, toGeminiSchema, splitParts,
|
|
2194
2309
|
budgetToEffort, effortToBudget, clampBudget, parseArgs, stringifyArgs,
|
|
2195
|
-
anthropicToIR, responsesToIR, parseToIR,
|
|
2310
|
+
anthropicToIR, responsesToIR, codeAssistToIR, parseToIR,
|
|
2196
2311
|
healToolPairs, healAnthropicPayload, rememberToolSignature, lookupToolSignature, estimateTokens, irToChatBody, irToAnthropicBody, irToVertexBody, emitUpstreamBody,
|
|
2197
2312
|
normalizeUpstream, createUpstreamNormalizer, createCollector,
|
|
2198
2313
|
createThinkTagSplitter, splitThinkTags,
|
|
@@ -2200,5 +2315,6 @@ export {
|
|
|
2200
2315
|
createChatStream, buildChatMessage,
|
|
2201
2316
|
createResponsesStream, buildResponsesMessage,
|
|
2202
2317
|
vertexFinish, createVertexStream, buildVertexMessage,
|
|
2318
|
+
createCodeAssistStream, buildCodeAssistMessage,
|
|
2203
2319
|
isAntigravityModel
|
|
2204
2320
|
};
|
package/hook-status.mjs
CHANGED
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
//
|
|
4
4
|
// The shim toast fires only when the shim runs, and that needs the shim directory first on PATH.
|
|
5
5
|
// This runs inside the tool, so it reports even when the launcher is the person's own script. Claude
|
|
6
|
-
// Code and
|
|
6
|
+
// Code, Codex and agy each read one JSON object from stdout and take `systemMessage` from it.
|
|
7
7
|
//
|
|
8
8
|
// Two rules hold, whatever happens:
|
|
9
9
|
// - It prints exactly one JSON object and exits 0. A hook must never fail a session.
|
|
10
10
|
// - A tool with no profile gets an empty object. Silence for a tool on its official endpoint.
|
|
11
11
|
//
|
|
12
|
-
// Usage: node hook-status.mjs <claude|codex>
|
|
12
|
+
// Usage: node hook-status.mjs <claude|codex|agy>
|
|
13
13
|
import fs from 'node:fs';
|
|
14
14
|
|
|
15
|
-
const TOOLS = ['claude', 'codex'];
|
|
15
|
+
const TOOLS = ['claude', 'codex', 'agy'];
|
|
16
|
+
const ROUTE_FILE = { claude: 'routeClaude', codex: 'routeCodex', agy: 'routeAgy' };
|
|
16
17
|
|
|
17
18
|
const readOrEmpty = (file) => {
|
|
18
19
|
try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
|
|
@@ -25,7 +26,7 @@ async function message(tool) {
|
|
|
25
26
|
|
|
26
27
|
// The route file is written with the env file of the same tool, so it is empty exactly when that
|
|
27
28
|
// tool is not routed. One read answers both "is the switcher on" and "is this tool on".
|
|
28
|
-
const route = readOrEmpty(
|
|
29
|
+
const route = readOrEmpty(s.paths[ROUTE_FILE[tool]]).trim();
|
|
29
30
|
if (!route || !fs.existsSync(s.paths.activeFlag)) return {};
|
|
30
31
|
|
|
31
32
|
const port = s.resolvePort([], s.loadConfig() || {});
|
package/mcp.mjs
CHANGED
|
@@ -80,7 +80,7 @@ async function postSwitch(port, target, profile) {
|
|
|
80
80
|
const TOOLS = [
|
|
81
81
|
{
|
|
82
82
|
name: 'switcher_status',
|
|
83
|
-
description: 'Get live status of LLM Switcher gateway: port, the
|
|
83
|
+
description: 'Get live status of LLM Switcher gateway: port, the three tool targets (Claude Code, Codex, Antigravity CLI), and the active profile of each.',
|
|
84
84
|
inputSchema: {
|
|
85
85
|
type: 'object',
|
|
86
86
|
properties: {}
|
|
@@ -98,14 +98,14 @@ const TOOLS = [
|
|
|
98
98
|
},
|
|
99
99
|
{
|
|
100
100
|
name: 'switcher_switch_profile',
|
|
101
|
-
description: 'Programmatically change the active profile for one tool: Claude Code (claude)
|
|
101
|
+
description: 'Programmatically change the active profile for one tool: Claude Code (claude), Codex (codex) or the Antigravity CLI (agy).',
|
|
102
102
|
inputSchema: {
|
|
103
103
|
type: 'object',
|
|
104
104
|
properties: {
|
|
105
105
|
target: {
|
|
106
106
|
type: 'string',
|
|
107
|
-
description: 'Tool to switch: "claude" (Claude Code)
|
|
108
|
-
enum: ['claude', 'codex']
|
|
107
|
+
description: 'Tool to switch: "claude" (Claude Code), "codex" (Codex) or "agy" (Antigravity CLI)',
|
|
108
|
+
enum: ['claude', 'codex', 'agy']
|
|
109
109
|
},
|
|
110
110
|
profile: {
|
|
111
111
|
type: 'string',
|
|
@@ -165,6 +165,7 @@ async function handleToolCall(name, args) {
|
|
|
165
165
|
`Active Profiles by tool:`,
|
|
166
166
|
` - Claude Code (/v1/messages) : [${activeMap.claude || 'OFF'}]`,
|
|
167
167
|
` - Codex CLI (/v1/responses) : [${activeMap.codex || 'OFF'}]`,
|
|
168
|
+
` - agy CLI (/v1internal:*) : [${activeMap.agy || 'OFF'}]`,
|
|
168
169
|
'',
|
|
169
170
|
`Available Profiles in config: ${Object.keys(cfg.profiles || {}).join(', ')}`,
|
|
170
171
|
`Dashboard Web UI: http://127.0.0.1:${port}/ui`
|
|
@@ -217,7 +218,7 @@ async function handleToolCall(name, args) {
|
|
|
217
218
|
if (args?.verbose) {
|
|
218
219
|
findings.push('');
|
|
219
220
|
findings.push('--- Routing variables of this process ---');
|
|
220
|
-
const names = Object.keys(process.env).filter(k => /^(ANTHROPIC_BASE_URL|OPENAI_BASE_URL|HTTPS?_PROXY|https?_proxy|NO_PROXY|no_proxy|CODEX_CA_CERTIFICATE|LLM_SWITCHER_[A-Z0-9_]+)$/.test(k)).sort();
|
|
221
|
+
const names = Object.keys(process.env).filter(k => /^(ANTHROPIC_BASE_URL|OPENAI_BASE_URL|CLOUD_CODE_URL|HTTPS?_PROXY|https?_proxy|NO_PROXY|no_proxy|CODEX_CA_CERTIFICATE|LLM_SWITCHER_[A-Z0-9_]+)$/.test(k)).sort();
|
|
221
222
|
for (const k of names) findings.push(`${k}=${withoutCredentials(process.env[k])}`);
|
|
222
223
|
if (!names.length) findings.push('(none set)');
|
|
223
224
|
}
|
package/package.json
CHANGED
package/plugin.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ============================================================
|
|
2
|
-
// plugin.mjs - installs the launch hook into the
|
|
2
|
+
// plugin.mjs - installs the launch hook into the coding tools, and edits no configuration.
|
|
3
3
|
//
|
|
4
4
|
// WHY A HOOK AT ALL
|
|
5
5
|
// The shim raises a notice at launch, but only when the shim runs, and that needs the shim
|
|
@@ -38,6 +38,10 @@ export function claudeSkillsDir(env = process.env, home = os.homedir()) {
|
|
|
38
38
|
export function codexHome(env = process.env, home = os.homedir()) {
|
|
39
39
|
return env.CODEX_HOME || path.join(home, '.codex');
|
|
40
40
|
}
|
|
41
|
+
// The global customization root of the Antigravity CLI.
|
|
42
|
+
export function agyHome(home = os.homedir()) {
|
|
43
|
+
return path.join(home, '.gemini', 'antigravity-cli');
|
|
44
|
+
}
|
|
41
45
|
|
|
42
46
|
export function renderClaudePlugin(version = 'dev') {
|
|
43
47
|
return `${JSON.stringify({
|
|
@@ -171,24 +175,83 @@ function uninstallCodex(opts) {
|
|
|
171
175
|
return { ok: true, path: file };
|
|
172
176
|
}
|
|
173
177
|
|
|
178
|
+
// ---- Antigravity CLI (agy) -------------------------------------------------------------------
|
|
179
|
+
//
|
|
180
|
+
// agy reads hooks.json from its customization root: one key per named hook. It runs a hook command
|
|
181
|
+
// with its quotes kept literally, so the path goes unquoted, with forward slashes. A path with a
|
|
182
|
+
// space cannot be written that way, and then agy is skipped.
|
|
183
|
+
//
|
|
184
|
+
// agy 1.2.14 loads this global file but does not run its hooks; only a workspace's .agents/hooks.json
|
|
185
|
+
// runs (measured 2026-10-03). The entry is ready for a release that runs global hooks. Until then the
|
|
186
|
+
// shim toast is the launch notice for agy.
|
|
187
|
+
export const AGY_HOOK_NAME = 'llm-switcher-status';
|
|
188
|
+
export const AGY_GLOBAL_HOOKS_NOTE = 'agy 1.2.14 loads global hooks but does not run them yet; the shim toast is the notice';
|
|
189
|
+
|
|
190
|
+
function agyHooksPath(opts) {
|
|
191
|
+
return path.join(opts.agyHome || agyHome(), 'hooks.json');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function agyHookCommand(script = HOOK_SCRIPT) {
|
|
195
|
+
const p = script.split(path.sep).join('/');
|
|
196
|
+
return /\s/.test(p) ? null : `node ${p} agy`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
200
|
+
|
|
201
|
+
// A space in the path is a limit of agy, not a fault: the install is skipped, never failed.
|
|
202
|
+
function installAgy(opts) {
|
|
203
|
+
const script = opts.hookScript || HOOK_SCRIPT;
|
|
204
|
+
const command = agyHookCommand(script);
|
|
205
|
+
if (!command) return { ok: true, skipped: true, reason: `${script} holds a space, and agy cannot run a quoted path` };
|
|
206
|
+
const file = agyHooksPath(opts);
|
|
207
|
+
const cur = readJsonFile(file);
|
|
208
|
+
if (cur.error) return { ok: false, reason: `${file} does not parse: ${cur.error}, so nothing was changed` };
|
|
209
|
+
if (!cur.missing && !isPlainObject(cur.value)) return { ok: false, reason: `${file} does not hold a JSON object, so nothing was changed` };
|
|
210
|
+
const doc = cur.missing ? {} : cur.value;
|
|
211
|
+
doc[AGY_HOOK_NAME] = { SessionStart: [{ type: 'command', command, timeout: 10 }] };
|
|
212
|
+
writeAtomic(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
213
|
+
return { ok: true, path: file };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function uninstallAgy(opts) {
|
|
217
|
+
const file = agyHooksPath(opts);
|
|
218
|
+
const cur = readJsonFile(file);
|
|
219
|
+
if (cur.missing) return { ok: true, path: file, absent: true };
|
|
220
|
+
if (cur.error) return { ok: false, reason: `${file} does not parse: ${cur.error}, so nothing was changed` };
|
|
221
|
+
// install never writes into such a file, so the hook cannot be in it.
|
|
222
|
+
if (!isPlainObject(cur.value)) return { ok: true, path: file, absent: true };
|
|
223
|
+
const doc = cur.value;
|
|
224
|
+
if (!Object.hasOwn(doc, AGY_HOOK_NAME)) return { ok: true, path: file, absent: true };
|
|
225
|
+
delete doc[AGY_HOOK_NAME];
|
|
226
|
+
// agy has no room for a mark in this file, so it is removed only when nothing else is left in it.
|
|
227
|
+
if (Object.keys(doc).length === 0) fs.rmSync(file, { force: true });
|
|
228
|
+
else writeAtomic(file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
229
|
+
return { ok: true, path: file };
|
|
230
|
+
}
|
|
231
|
+
|
|
174
232
|
// ---- The three commands ----------------------------------------------------------------------
|
|
175
233
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
234
|
+
// Each tool runs on its own: a fault in one file must not stop the other tools.
|
|
235
|
+
function summarize(steps) {
|
|
236
|
+
const out = { installed: [], skipped: [], failed: [], paths: {} };
|
|
237
|
+
for (const [tool, step] of Object.entries(steps)) {
|
|
238
|
+
let r;
|
|
239
|
+
try { r = step(); } catch (err) { r = { ok: false, reason: err.message }; }
|
|
240
|
+
if (r.skipped) out.skipped.push({ tool, reason: r.reason });
|
|
241
|
+
else if (r.ok) out.installed.push(tool);
|
|
242
|
+
else out.failed.push({ tool, reason: r.reason });
|
|
180
243
|
if (r.path) out.paths[tool] = r.path;
|
|
181
244
|
}
|
|
182
245
|
return out;
|
|
183
246
|
}
|
|
184
247
|
|
|
185
|
-
/** { installed: ['claude','codex'],
|
|
248
|
+
/** { installed: ['claude','codex','agy'], skipped: [{ tool, reason }], failed: [{ tool, reason }], paths } */
|
|
186
249
|
export function installPlugin(opts = {}, version = 'dev') {
|
|
187
|
-
return summarize({ claude: installClaude(opts, version), codex: installCodex(opts) });
|
|
250
|
+
return summarize({ claude: () => installClaude(opts, version), codex: () => installCodex(opts), agy: () => installAgy(opts) });
|
|
188
251
|
}
|
|
189
252
|
|
|
190
253
|
export function uninstallPlugin(opts = {}) {
|
|
191
|
-
return summarize({ claude: uninstallClaude(opts), codex: uninstallCodex(opts) });
|
|
254
|
+
return summarize({ claude: () => uninstallClaude(opts), codex: () => uninstallCodex(opts), agy: () => uninstallAgy(opts) });
|
|
192
255
|
}
|
|
193
256
|
|
|
194
257
|
export function pluginStatus(opts = {}) {
|
|
@@ -197,8 +260,16 @@ export function pluginStatus(opts = {}) {
|
|
|
197
260
|
const file = codexHooksPath(opts);
|
|
198
261
|
const codexDoc = readJsonFile(file);
|
|
199
262
|
const codexGroups = Array.isArray(codexDoc.value?.hooks?.SessionStart) ? codexDoc.value.hooks.SessionStart : [];
|
|
263
|
+
const agyDoc = readJsonFile(agyHooksPath(opts));
|
|
200
264
|
return {
|
|
201
265
|
claude: { installed: claudeManifest.value?.name === PLUGIN_NAME, path: dir, id: `${PLUGIN_NAME}@skills-dir` },
|
|
202
|
-
codex: { installed: codexGroups.some(isOurs), path: file }
|
|
266
|
+
codex: { installed: codexGroups.some(isOurs), path: file },
|
|
267
|
+
agy: {
|
|
268
|
+
installed: isPlainObject(agyDoc.value) && Boolean(agyDoc.value[AGY_HOOK_NAME]),
|
|
269
|
+
path: agyHooksPath(opts),
|
|
270
|
+
note: AGY_GLOBAL_HOOKS_NOTE,
|
|
271
|
+
...(agyDoc.error ? { error: `does not parse: ${agyDoc.error}` }
|
|
272
|
+
: !agyDoc.missing && !isPlainObject(agyDoc.value) ? { error: 'does not hold a JSON object' } : {})
|
|
273
|
+
}
|
|
203
274
|
};
|
|
204
275
|
}
|