@awak-app/simy-cli 0.5.21 → 0.5.22

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.
@@ -0,0 +1,122 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rename, rm, lstat, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const SOURCE = fileURLToPath(new URL("../codex-plugins/simy/", import.meta.url));
8
+ export const CLAUDE_MARKETPLACE = "simy-local";
9
+ export const CLAUDE_PLUGIN_SELECTOR = `simy@${CLAUDE_MARKETPLACE}`;
10
+ const AUTHOR = "Specialist Doctors Inc.";
11
+
12
+ // Generate both providers from the maintained workflow source. Only provider
13
+ // terminology, manifest schema, and hook environment names differ.
14
+ export async function claudePluginFiles() {
15
+ const sourceManifest = JSON.parse(await readFile(path.join(SOURCE, ".codex-plugin/plugin.json"), "utf8"));
16
+ const files = new Map();
17
+ for (const entry of await readdir(path.join(SOURCE, "skills"), { withFileTypes: true })) {
18
+ if (!entry.isDirectory()) continue;
19
+ const relative = `skills/${entry.name}/SKILL.md`;
20
+ const content = await readFile(path.join(SOURCE, relative), "utf8");
21
+ files.set(relative, content.replaceAll("current Codex thread", "current Claude Code conversation"));
22
+ }
23
+ files.set("hooks/connection-reminder.mjs", await readFile(path.join(SOURCE, "hooks/connection-reminder.mjs"), "utf8"));
24
+ const hooks = JSON.parse(await readFile(path.join(SOURCE, "hooks/hooks.json"), "utf8"));
25
+ for (const groups of Object.values(hooks.hooks)) {
26
+ for (const group of groups) {
27
+ for (const hook of group.hooks) {
28
+ hook.command = hook.command.replace(/^node /, `${quoteRuntime(process.execPath)} `)
29
+ .replaceAll("process.env.PLUGIN_ROOT", "process.env.CLAUDE_PLUGIN_ROOT");
30
+ }
31
+ }
32
+ }
33
+ files.set("hooks/hooks.json", json(hooks));
34
+ const hash = createHash("sha256");
35
+ for (const [name, contents] of [...files].sort(([a], [b]) => a.localeCompare(b))) hash.update(name).update("\0").update(contents);
36
+ const manifest = {
37
+ name: "simy", version: `${sourceManifest.version.split("+")[0]}+claude.${hash.digest("hex").slice(0, 20)}`,
38
+ description: "SIMY quality checks, pull request review, and connection reminders for Claude Code.",
39
+ author: sourceManifest.author, repository: sourceManifest.repository,
40
+ license: sourceManifest.license, keywords: sourceManifest.keywords,
41
+ };
42
+ files.set(".claude-plugin/plugin.json", json(manifest));
43
+ return { files, manifest };
44
+ }
45
+
46
+ export async function inspectClaudePlugin({ home = homedir() } = {}) {
47
+ const root = marketplaceRoot(home);
48
+ const target = path.join(root, "plugins/simy");
49
+ const { files, manifest } = await claudePluginFiles();
50
+ let matches = true;
51
+ let exists = false;
52
+ for (const [name, contents] of files) {
53
+ const installed = await optionalRead(path.join(target, name));
54
+ if (name === ".claude-plugin/plugin.json") exists = installed !== null;
55
+ if (installed !== contents) matches = false;
56
+ }
57
+ const marketplacePath = path.join(root, ".claude-plugin/marketplace.json");
58
+ matches = matches && await optionalRead(marketplacePath) === json(marketplaceManifest());
59
+ return {
60
+ plugin_id: "simy", version: manifest.version, path: target,
61
+ marketplace_path: marketplacePath, marketplace_root: root, selector: CLAUDE_PLUGIN_SELECTOR,
62
+ installed: matches, state: matches ? "installed" : exists ? "outdated" : "missing",
63
+ };
64
+ }
65
+
66
+ export async function installClaudePlugin({ home = homedir() } = {}) {
67
+ const root = marketplaceRoot(home);
68
+ const target = path.join(root, "plugins/simy");
69
+ // Never overwrite an unrelated user plugin, including a symlink.
70
+ let targetInfo = null;
71
+ try { targetInfo = await lstat(target); } catch (error) { if (error.code !== "ENOENT") throw error; }
72
+ if (targetInfo) {
73
+ if (!targetInfo.isDirectory()) throw new Error("SIMY Claude plugin target is not a managed directory.");
74
+ let manifest;
75
+ try { manifest = JSON.parse(await readFile(path.join(target, ".claude-plugin/plugin.json"), "utf8")); }
76
+ catch { throw new Error("SIMY Claude plugin target has no valid managed manifest."); }
77
+ if (manifest.name !== "simy" || manifest.author?.name !== AUTHOR) throw new Error("SIMY Claude plugin target is not managed by SIMY.");
78
+ }
79
+ const marketplacePath = path.join(root, ".claude-plugin/marketplace.json");
80
+ const existing = await optionalRead(marketplacePath);
81
+ if (existing !== null && existing !== json(marketplaceManifest())) {
82
+ throw new Error("SIMY Claude marketplace contains unmanaged changes; installation skipped.");
83
+ }
84
+ const { files } = await claudePluginFiles();
85
+ const stage = path.join(root, `plugins/.simy-${process.pid}.tmp`);
86
+ const backup = path.join(root, `plugins/.simy-${process.pid}.bak`);
87
+ await mkdir(path.dirname(stage), { recursive: true, mode: 0o700 });
88
+ await rm(stage, { recursive: true, force: true });
89
+ for (const [name, contents] of files) {
90
+ await mkdir(path.dirname(path.join(stage, name)), { recursive: true, mode: 0o700 });
91
+ await writeFile(path.join(stage, name), contents, { mode: 0o600 });
92
+ }
93
+ let backedUp = false;
94
+ try {
95
+ try { await rename(target, backup); backedUp = true; } catch (error) { if (error.code !== "ENOENT") throw error; }
96
+ await rename(stage, target);
97
+ await mkdir(path.dirname(marketplacePath), { recursive: true, mode: 0o700 });
98
+ await writeFile(`${marketplacePath}.tmp`, json(marketplaceManifest()), { mode: 0o600 });
99
+ await rename(`${marketplacePath}.tmp`, marketplacePath);
100
+ if (backedUp) await rm(backup, { recursive: true, force: true });
101
+ } catch (error) {
102
+ await rm(stage, { recursive: true, force: true });
103
+ if (backedUp) {
104
+ await rm(target, { recursive: true, force: true });
105
+ await rename(backup, target);
106
+ }
107
+ throw error;
108
+ }
109
+ return inspectClaudePlugin({ home });
110
+ }
111
+
112
+ function marketplaceRoot(home) { return path.join(home, ".simy/integrations/claude-marketplace"); }
113
+ function marketplaceManifest() {
114
+ return { name: CLAUDE_MARKETPLACE, owner: { name: AUTHOR }, plugins: [{ name: "simy", source: "./plugins/simy" }] };
115
+ }
116
+ function json(value) { return `${JSON.stringify(value, null, 2)}\n`; }
117
+ async function optionalRead(file) {
118
+ try { return await readFile(file, "utf8"); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
119
+ }
120
+ function quoteRuntime(value) {
121
+ return process.platform === "win32" ? `"${value.replaceAll('"', '""')}"` : `'${value.replaceAll("'", "'\\''")}'`;
122
+ }
@@ -0,0 +1,38 @@
1
+ import spawn from "cross-spawn";
2
+
3
+ // cross-spawn resolves npm .cmd shims on Windows without interpolating command
4
+ // text. Keep updater commands bounded and preserve execFile-like test injection.
5
+ export function runProviderCommand(command, args, { env, timeout = 10_000, maxBuffer = 1024 * 1024 } = {}) {
6
+ return new Promise((resolve, reject) => {
7
+ const child = spawn(command, args, { env, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
8
+ let stdout = "";
9
+ let stderr = "";
10
+ let settled = false;
11
+ const finish = (error) => {
12
+ if (settled) return;
13
+ settled = true;
14
+ clearTimeout(timer);
15
+ error ? reject(error) : resolve({ stdout, stderr });
16
+ };
17
+ const timer = setTimeout(() => {
18
+ child.kill("SIGTERM");
19
+ finish(new Error("Provider plugin command timed out."));
20
+ }, timeout);
21
+ timer.unref?.();
22
+ const capture = (stream, append) => {
23
+ stream.setEncoding("utf8");
24
+ stream.on("data", (chunk) => {
25
+ if (settled) return;
26
+ append(chunk);
27
+ if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBuffer) {
28
+ child.kill("SIGTERM");
29
+ finish(new Error("Provider plugin command exceeded the output limit."));
30
+ }
31
+ });
32
+ };
33
+ capture(child.stdout, (chunk) => { stdout += chunk; });
34
+ capture(child.stderr, (chunk) => { stderr += chunk; });
35
+ child.on("error", finish);
36
+ child.on("close", (code) => finish(code === 0 ? null : new Error(`Provider plugin command exited with code ${code}.`)));
37
+ });
38
+ }
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import spawn from "cross-spawn";
2
2
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
@@ -79,7 +79,8 @@ export function createSqmLocalReviewWorker({
79
79
  workerId = "sqm-local-review",
80
80
  leaseSeconds = DEFAULT_LEASE_SECONDS,
81
81
  heartbeatIntervalMs = Math.max(10_000, Math.floor(leaseSeconds * 500)),
82
- runReview = runStructuredCodexReview,
82
+ getProvider = () => "codex",
83
+ runReview = (request, provider) => runStructuredProviderReview(provider, request),
83
84
  beginWork = () => () => {},
84
85
  onError = () => {},
85
86
  } = {}) {
@@ -88,34 +89,35 @@ export function createSqmLocalReviewWorker({
88
89
  let pollPromise = null;
89
90
  let closed = false;
90
91
 
91
- const leaseBody = (claim) => ({
92
+ const leaseBody = (claim, provider) => ({
93
+ executor_provider: provider,
92
94
  worker_id: workerId,
93
95
  lease_seconds: leaseSeconds,
94
96
  job_id: claim.job_id,
95
97
  lease_token: claim.lease_token,
96
98
  });
97
99
 
98
- const processClaim = async (claim) => {
100
+ const processClaim = async (claim, provider) => {
99
101
  activeJobId = claim.job_id;
100
102
  let heartbeatTimer = null;
101
103
  const heartbeat = () => {
102
- void api.request({ action: "heartbeat", ...leaseBody(claim) }).catch(onError);
104
+ void api.request({ action: "heartbeat", ...leaseBody(claim, provider) }).catch(onError);
103
105
  };
104
106
  heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs);
105
107
  heartbeatTimer.unref?.();
106
108
  try {
107
- const analysis = await runReview(claim.request);
109
+ const analysis = await runReview(claim.request, provider);
108
110
  const advanced = await api.request({
109
111
  action: "advance",
110
- ...leaseBody(claim),
112
+ ...leaseBody(claim, provider),
111
113
  analysis_output: analysis.output,
112
114
  executor_model: analysis.model,
113
115
  });
114
116
  if (advanced.completed) return advanced;
115
- const adjudication = await runReview(advanced.request);
117
+ const adjudication = await runReview(advanced.request, provider);
116
118
  return api.request({
117
119
  action: "complete",
118
- ...leaseBody(claim),
120
+ ...leaseBody(claim, provider),
119
121
  analysis_output: analysis.output,
120
122
  adjudication_output: adjudication.output,
121
123
  executor_model: adjudication.model || analysis.model,
@@ -132,16 +134,20 @@ export function createSqmLocalReviewWorker({
132
134
  },
133
135
  poll() {
134
136
  if (closed || pollPromise) return pollPromise || Promise.resolve(null);
137
+ const provider = getProvider();
138
+ if (provider == null) return Promise.resolve(null);
139
+ assertProvider(provider);
135
140
  const releaseWork = beginWork?.();
136
141
  if (!releaseWork) return Promise.resolve(null);
137
142
  pollPromise = (async () => {
138
143
  const claim = await api.request({
139
144
  action: "claim",
145
+ executor_provider: provider,
140
146
  worker_id: workerId,
141
147
  lease_seconds: leaseSeconds,
142
148
  });
143
149
  if (!claim?.claimed) return null;
144
- return processClaim(claim);
150
+ return processClaim(claim, provider);
145
151
  })()
146
152
  .catch((error) => {
147
153
  onError(error);
@@ -160,19 +166,27 @@ export function createSqmLocalReviewWorker({
160
166
  };
161
167
  }
162
168
 
163
- export async function runStructuredCodexReview(
169
+ export function runStructuredCodexReview(request, options) {
170
+ return runStructuredProviderReview("codex", request, options);
171
+ }
172
+
173
+ export async function runStructuredProviderReview(
174
+ provider,
164
175
  request,
165
176
  {
166
177
  spawnImpl = spawn,
167
178
  inspectExecutor = inspectDesktopExecutor,
168
179
  executionTimeoutMs = DEFAULT_EXECUTION_TIMEOUT_MS,
180
+ environment = process.env,
169
181
  } = {},
170
182
  ) {
183
+ assertProvider(provider);
171
184
  assertStructuredRequest(request);
172
- const inspection = await inspectExecutor("codex");
185
+ const label = provider === "claude" ? "Claude Code" : "Codex";
186
+ const inspection = await inspectExecutor(provider);
173
187
  if (!inspection?.compatible || !inspection.executable) {
174
- throw new SqmLocalReviewError("A compatible local Codex executable is required.", {
175
- code: "codex_local_unavailable",
188
+ throw new SqmLocalReviewError(`A compatible local ${label} executable is required.`, {
189
+ code: `${provider}_local_unavailable`,
176
190
  });
177
191
  }
178
192
  const workDir = await mkdtemp(join(tmpdir(), "simy-sqm-review-"));
@@ -185,9 +199,15 @@ export async function runStructuredCodexReview(
185
199
  "Return only JSON that strictly matches the provided output schema.",
186
200
  request.prompt,
187
201
  ].join("\n\n");
188
- await spawnCodex(
202
+ const stdout = await spawnReview(
189
203
  inspection.executable,
190
- [
204
+ provider === "claude" ? [
205
+ "--print", "--output-format", "json",
206
+ "--json-schema", JSON.stringify(request.output_schema),
207
+ "--no-session-persistence", "--tools", "",
208
+ "--setting-sources", "", "--settings", '{"disableAllHooks":true}',
209
+ "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
210
+ ] : [
191
211
  "exec",
192
212
  "--skip-git-repo-check",
193
213
  "--sandbox",
@@ -202,63 +222,93 @@ export async function runStructuredCodexReview(
202
222
  "-",
203
223
  ],
204
224
  prompt,
205
- { spawnImpl, executionTimeoutMs },
225
+ { spawnImpl, executionTimeoutMs, label, cwd: workDir, environment, provider },
206
226
  );
207
- const output = JSON.parse(await readFile(outputPath, "utf8"));
227
+ const envelope = provider === "claude" ? JSON.parse(stdout) : null;
228
+ if (envelope?.is_error || (envelope && envelope.subtype !== "success")) {
229
+ throw new Error("Claude Code did not complete a structured review");
230
+ }
231
+ const output = provider === "claude"
232
+ ? envelope?.structured_output
233
+ : JSON.parse(await readFile(outputPath, "utf8"));
208
234
  if (!output || Array.isArray(output) || typeof output !== "object") {
209
- throw new Error("Codex returned a non-object JSON value");
235
+ throw new Error(`${label} returned a non-object structured result`);
210
236
  }
211
237
  return {
212
238
  output,
213
- model: `codex-local/${inspection.installed_version || "unknown"}`,
239
+ model: `${provider}-local/${inspection.installed_version || "unknown"}`,
214
240
  };
215
241
  } catch (error) {
216
242
  if (error instanceof SqmLocalReviewError) throw error;
217
- throw new SqmLocalReviewError(`Local Codex review failed: ${errorMessage(error)}`, {
218
- code: "codex_local_review_failed",
243
+ throw new SqmLocalReviewError(`Local ${label} review failed: ${errorMessage(error)}`, {
244
+ code: `${provider}_local_review_failed`,
219
245
  });
220
246
  } finally {
221
247
  await rm(workDir, { recursive: true, force: true });
222
248
  }
223
249
  }
224
250
 
225
- function spawnCodex(command, args, prompt, { spawnImpl, executionTimeoutMs }) {
251
+ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, label, cwd, environment, provider }) {
226
252
  return new Promise((resolve, reject) => {
227
253
  const child = spawnImpl(command, args, {
228
- env: safeCodexEnvironment(process.env),
229
- stdio: ["pipe", "ignore", "pipe"],
254
+ env: safeReviewEnvironment(environment, provider),
255
+ cwd,
256
+ windowsHide: true,
257
+ stdio: ["pipe", "pipe", "pipe"],
230
258
  });
259
+ let stdout = "";
231
260
  let stderr = "";
232
261
  let settled = false;
233
262
  const timer = setTimeout(() => {
234
263
  child.kill?.("SIGTERM");
235
- finish(new Error(`Codex timed out after ${executionTimeoutMs}ms`));
264
+ finish(new Error(`${label} timed out after ${executionTimeoutMs}ms`));
236
265
  }, executionTimeoutMs);
237
266
  timer.unref?.();
238
267
  const finish = (error = null) => {
239
268
  if (settled) return;
240
269
  settled = true;
241
270
  clearTimeout(timer);
242
- error ? reject(error) : resolve();
271
+ error ? reject(error) : resolve(stdout);
243
272
  };
273
+ child.stdout?.setEncoding?.("utf8");
274
+ child.stdout?.on?.("data", (chunk) => {
275
+ if (settled) return;
276
+ stdout += chunk;
277
+ if (Buffer.byteLength(stdout, "utf8") > 2 * 1024 * 1024) {
278
+ child.kill?.("SIGTERM");
279
+ finish(new Error(`${label} structured output exceeded the size limit`));
280
+ }
281
+ });
282
+ child.stdin?.on?.("error", finish);
244
283
  child.stderr?.setEncoding?.("utf8");
245
284
  child.stderr?.on?.("data", (chunk) => {
246
285
  stderr = `${stderr}${chunk}`.slice(-4000);
247
286
  });
248
287
  child.on("error", finish);
249
288
  child.on("close", (code) => {
250
- finish(code === 0 ? null : new Error(stderr.trim() || `Codex exited with code ${code}`));
289
+ finish(code === 0 ? null : new Error(stderr.trim() || `${label} exited with code ${code}`));
251
290
  });
252
291
  child.stdin?.end(prompt);
253
292
  });
254
293
  }
255
294
 
256
- function safeCodexEnvironment(environment) {
295
+ function safeReviewEnvironment(environment, provider) {
257
296
  const allowed = [
258
- "HOME", "PATH", "CODEX_HOME", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR",
297
+ "HOME", "PATH", "CODEX_HOME", "CLAUDE_CONFIG_DIR",
298
+ "SystemRoot", "SYSTEMROOT", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "TEMP", "TMP", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR",
259
299
  "SSL_CERT_FILE", "SSL_CERT_DIR", "HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "NO_PROXY",
260
300
  ];
261
- return Object.fromEntries(allowed.filter((key) => environment[key]).map((key) => [key, environment[key]]));
301
+ // Credentials stay in the local provider process. Never send them to the
302
+ // control plane, and do not pass the other provider's credentials.
303
+ allowed.push(...(provider === "claude" ? [
304
+ "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN",
305
+ "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY",
306
+ "AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN",
307
+ "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "GOOGLE_APPLICATION_CREDENTIALS",
308
+ "ANTHROPIC_VERTEX_PROJECT_ID", "CLOUD_ML_REGION", "ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_FOUNDRY_RESOURCE",
309
+ ] : ["OPENAI_API_KEY", "OPENAI_BASE_URL"]));
310
+ const keys = new Set(allowed.map((key) => key.toLowerCase()));
311
+ return Object.fromEntries(Object.entries(environment).filter(([key, value]) => value && keys.has(key.toLowerCase())));
262
312
  }
263
313
 
264
314
  function assertStructuredRequest(request) {
@@ -277,3 +327,9 @@ function machineCode(value) {
277
327
  function errorMessage(error) {
278
328
  return error instanceof Error ? error.message : String(error);
279
329
  }
330
+
331
+ function assertProvider(provider) {
332
+ if (provider !== "codex" && provider !== "claude") {
333
+ throw new TypeError("Unsupported SQM local review provider");
334
+ }
335
+ }
@@ -58,8 +58,12 @@ export async function prepareDaemonWorkflows({
58
58
  try {
59
59
  const installed = await install();
60
60
  logger(
61
- "SIMY workflows installed. Restart Codex, then find and install SIMY in Plugins before using @simy.",
61
+ "SIMY workflows installed. In Codex, restart, then find and install SIMY in Plugins. In Claude Code, add the SIMY marketplace and install simy@simy-local before using /simy:sqm-loop or /simy:pr-review.",
62
62
  );
63
+ if (installed.claude_plugin?.marketplace_root) {
64
+ logger(`Claude Code: claude plugin marketplace add ${JSON.stringify(installed.claude_plugin.marketplace_root)}`);
65
+ logger("Then: claude plugin install simy@simy-local");
66
+ }
63
67
  return { outcome: "installed", status: installed };
64
68
  } catch (error) {
65
69
  errorLogger(`SIMY workflow installation failed: ${message(error)}. The daemon will still start.`);
@@ -73,7 +77,7 @@ export async function confirmWorkflowInstallation({
73
77
  } = {}) {
74
78
  output.write(
75
79
  [
76
- "SIMY can install or update its Codex plugin marketplace, Codex/Claude opt-in SQM skills.",
80
+ "SIMY can install or update its Codex and Claude Code plugin sources and opt-in SQM skills.",
77
81
  "Existing unrelated plugins and hooks will be preserved.",
78
82
  "",
79
83
  ].join("\n"),
@@ -1,13 +1,14 @@
1
- import { execFile } from "node:child_process";
2
1
  import { mkdir, readFile, realpath, rm, stat } from "node:fs/promises";
3
2
  import { homedir } from "node:os";
4
3
  import path from "node:path";
5
- import { promisify } from "node:util";
4
+
5
+ import { runProviderCommand } from "./provider-command.js";
6
+ import { refreshClaudePlugin } from "./claude-plugin-update.js";
6
7
 
7
8
  import { resolveBackendExecutable } from "../backend-executable.js";
8
9
  import { inspectSqmAgentSkill, installSqmAgentSkill, retireLegacySqmGate } from "./agent-skill.js";
9
10
 
10
- const execFileAsync = promisify(execFile);
11
+
11
12
  const CHECK_INTERVAL_MS = 60 * 60 * 1000;
12
13
 
13
14
  export function startWorkflowUpdates({ enabled, sync = syncInstalledWorkflows, intervalMs = CHECK_INTERVAL_MS,
@@ -32,8 +33,9 @@ export function startWorkflowUpdates({ enabled, sync = syncInstalledWorkflows, i
32
33
 
33
34
  export async function syncInstalledWorkflows({ home = homedir(),
34
35
  codexHome = process.env.CODEX_HOME || path.join(home, ".codex"),
36
+ claudeHome = process.env.CLAUDE_CONFIG_DIR || path.join(home, ".claude"),
35
37
  inspect = inspectSqmAgentSkill, install = installSqmAgentSkill,
36
- resolveExecutable = resolveBackendExecutable, run = execFileAsync,
38
+ resolveExecutable = resolveBackendExecutable, run = runProviderCommand,
37
39
  } = {}) {
38
40
  // Multiple daemons may share the same installation. Serialize updates and recover
39
41
  // an abandoned lock after more than the bounded Codex command timeouts.
@@ -52,7 +54,27 @@ export async function syncInstalledWorkflows({ home = homedir(),
52
54
  const platforms = Object.entries(status.platforms)
53
55
  .filter(([, entry]) => entry.state === "outdated").map(([platform]) => platform);
54
56
  if (platforms.length) status = await install({ home, platforms });
55
- if (!status.platforms.codex.installed) return { outcome: platforms.length || migrated ? "updated" : "unchanged" };
57
+ const results = {};
58
+ const failures = [];
59
+ for (const [provider, refresh] of [
60
+ ["codex", () => refreshCodexPlugin({ status, home, codexHome, resolveExecutable, run })],
61
+ ["claude", () => refreshClaudePlugin({ status, home, claudeHome, resolveExecutable, run })],
62
+ ]) {
63
+ try { results[provider] = await refresh(); }
64
+ catch (error) { failures.push(error); }
65
+ }
66
+ if (failures.length) throw new AggregateError(failures, failures.map((error) => error.message).join("; "));
67
+ if (results.claude.outcome === "updated") return { ...results.claude, providers: results };
68
+ if (results.codex.outcome !== "not_enabled") return results.codex;
69
+ return { outcome: platforms.length || migrated ? "updated" : results.claude.outcome === "current" ? "current" :
70
+ status.platforms.codex.installed || status.claude_plugin?.installed ? "not_enabled" : "unchanged" };
71
+ } finally {
72
+ await rm(lock, { recursive: true, force: true });
73
+ }
74
+ }
75
+
76
+ async function refreshCodexPlugin({ status, home, codexHome, resolveExecutable, run }) {
77
+ if (!status.platforms.codex.installed) return { outcome: "not_enabled" };
56
78
 
57
79
  const marketplace = JSON.parse(await readFile(status.codex_plugin.marketplace_path, "utf8"));
58
80
  if (typeof marketplace.name !== "string" || !/^[A-Za-z0-9_-]+$/.test(marketplace.name)) {
@@ -85,7 +107,4 @@ export async function syncInstalledWorkflows({ home = homedir(),
85
107
  throw new Error("Codex did not confirm the updated SIMY plugin cache; will retry.");
86
108
  }
87
109
  return { outcome: "updated", version: status.codex_plugin.version };
88
- } finally {
89
- await rm(lock, { recursive: true, force: true });
90
- }
91
110
  }