llm-switcher 1.2.12 → 1.3.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/formats.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // client --parse--> IR --emit--> upstream --events--> client
7
7
  //
8
- // Client (input) : anthropic | openai-chat | responses (Codex) | vertex
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
- // R5: two inputs, one per tool — `anthropic` for Claude Code, `responses` for Codex. The
667
- // openai-chat and vertex routes were removed with their parsers; a format that reaches here
668
- // anyway is an error, never a silent fallback to another protocol (a different API key!).
669
- // The UPSTREAM formats are a different axis and are untouched: emitUpstreamBody still speaks
670
- // openai-chat, anthropic and vertex to whatever provider the profile points at.
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
- default: throw new Error(`Unsupported input format: ${clientFormat}. Only anthropic (Claude Code) and responses (Codex) accept requests.`);
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
- function createVertexStream(emit, model) {
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 Codex both read one JSON object from stdout and show `systemMessage` to the person.
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(tool === 'codex' ? s.paths.routeCodex : s.paths.routeClaude).trim();
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 two tool targets (Claude Code, Codex), and the active profile of each.',
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) or Codex (codex).',
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) or "codex" (Codex)',
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-switcher",
3
- "version": "1.2.12",
3
+ "version": "1.3.0",
4
4
  "description": "Zero-dependency multi-protocol edge gateway & provider switcher for Claude Code and Codex, with OpenAI-compatible, Anthropic and Vertex upstreams",
5
5
  "keywords": [
6
6
  "llm",
package/plugin.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // ============================================================
2
- // plugin.mjs - installs the launch hook into the two coding tools, and edits no configuration.
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.7 loads this global file but does not run its hooks; only a workspace's .agents/hooks.json
185
+ // runs (measured 2026-10-02). 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.7 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
- function summarize(results) {
177
- const out = { installed: [], failed: [], paths: {} };
178
- for (const [tool, r] of Object.entries(results)) {
179
- if (r.ok) out.installed.push(tool); else out.failed.push({ tool, reason: r.reason });
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'], failed: [{ tool, reason }], paths: { 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
  }