@awak-app/simy-cli 0.7.20 → 0.7.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.
@@ -1,57 +1,112 @@
1
+ import { readRemoteSessionLists } from './remote-session-list.js';
1
2
  import { hostname } from 'node:os';
2
3
  import { stripVTControlCharacters } from 'node:util';
3
4
  import { readProviderLinks } from '../provider-links.js';
4
5
  import { openCodexAppServer, listCodexSessions } from './codex-app-server.js';
6
+ import { listClaudeSessions } from './claude-sessions.js';
5
7
  import { readCodexTitles } from './session-titles.js';
6
8
  import { sessionWorkState } from './work-state.js';
7
9
 
8
10
  export const terminalText = value => stripVTControlCharacters(String(value ?? '')).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').trim();
9
11
  export function filterSessionList(sessions, query = '') {
10
12
  const needle = query.trim().toLocaleLowerCase();
11
- return sessions.filter(row => !needle || [row.title, row.session, row.cwd].some(value => String(value || '').toLocaleLowerCase().includes(needle)));
13
+ return sessions.filter(row => !needle || [row.title, row.session, row.cwd, row.provider, row.hostLabel].some(value => String(value || '').toLocaleLowerCase().includes(needle)));
12
14
  }
13
15
 
14
16
  // List directly from the installed local provider. No agent, supervisor,
15
17
  // enrollment, archive reconciliation or provider turn is started by this view.
16
- export async function readLocalSessionList({ root, signal, includeArchived = false,
17
- openClient = openCodexAppServer, readLinks = readProviderLinks, readTitles = readCodexTitles } = {}) {
18
+ export async function readLocalSessionList({ root, signal, includeArchived = false, provider = 'all',
19
+ openClient = openCodexAppServer, listClaude = listClaudeSessions,
20
+ readLinks = readProviderLinks, readTitles = readCodexTitles } = {}) {
18
21
  const links = await readLinks(root);
19
- if (!links.codex.connected) throw Object.assign(new Error('Codex is disconnected from SIMY. Reconnect it before listing conversations.'), { code: 'provider_link_disconnected' });
20
- const client = await openClient({ signal, cancelOnAbort: true });
21
- let sessions;
22
- try {
23
- sessions = await listCodexSessions({ client, inspectState: true });
24
- if (includeArchived) sessions.push(...(await listCodexSessions({ client, archived: true })).map(row => ({ ...row, archived: true })));
25
- } finally { await client.close(); }
26
- const titles = await readTitles();
22
+ const providers = ['codex', 'claude'].filter(name => links[name]?.connected && (provider === 'all' || name === provider));
23
+ if (!providers.length) throw Object.assign(new Error('Codex and Claude Code are disconnected from SIMY. Reconnect a provider before listing conversations.'), { code: 'provider_link_disconnected' });
24
+ const results = await Promise.allSettled(providers.map(async provider => {
25
+ signal?.throwIfAborted();
26
+ if (provider === 'claude') return (await listClaude({ all: true, signal })).filter(row => includeArchived || !row.archived);
27
+ const client = await openClient({ signal, cancelOnAbort: true });
28
+ let sessions;
29
+ try {
30
+ sessions = await listCodexSessions({ client, all: true, signal, inspectState: true });
31
+ if (includeArchived) sessions.push(...(await listCodexSessions({ client, all: true, signal, archived: true })).map(row => ({ ...row, archived: true })));
32
+ } finally { await client.close(); }
33
+ const titles = await readTitles();
34
+ return sessions.map(row => ({ ...row, title: row.title || titles.get(row.session) }));
35
+ }));
27
36
  signal?.throwIfAborted();
28
37
  const currentLinks = await readLinks(root);
29
- if (!currentLinks.codex.connected || currentLinks.codex.epoch !== links.codex.epoch) throw Object.assign(new Error('Codex connection changed. Refresh the conversation list.'), { code: 'provider_link_changed' });
30
- return { host: hostname(), observedAt: new Date().toISOString(), sessions: [...new Map(sessions.map(row => [row.session, row])).values()].map(row => ({
31
- provider: 'codex', session: row.session, title: terminalText(row.title || titles.get(row.session) || row.session),
32
- cwd: terminalText(row.cwd), archived: row.archived === true,
33
- workState: sessionWorkState(row),
34
- })) };
38
+ for (const provider of providers) {
39
+ if (!currentLinks[provider]?.connected || currentLinks[provider].epoch !== links[provider].epoch)
40
+ throw Object.assign(new Error('Provider connection changed. Refresh the conversation list.'), { code: 'provider_link_changed' });
41
+ }
42
+ if (results.every(result => result.status === 'rejected')) throw results[0].reason;
43
+ const warnings = results.flatMap((result, index) => result.status === 'rejected'
44
+ ? [`${providers[index]}: ${terminalText(result.reason?.message || 'Conversation discovery unavailable')}`] : []);
45
+ const sessions = results.flatMap(result => result.status === 'fulfilled' ? result.value : []);
46
+ return { host: hostname(), observedAt: new Date().toISOString(), complete: warnings.length === 0, warnings,
47
+ sessions: [...new Map(sessions.map(row => [`${row.provider}:${row.session}`, row])).values()]
48
+ .sort((a, b) => (b.lastActivityAt || 0) - (a.lastActivityAt || 0) || a.provider.localeCompare(b.provider) || a.session.localeCompare(b.session))
49
+ .map(row => ({
50
+ provider: row.provider, session: row.session, title: terminalText(row.title || row.session),
51
+ cwd: terminalText(row.cwd), archived: row.archived === true,
52
+ workState: sessionWorkState(row),
53
+ })) };
35
54
  }
55
+ // Aggregate only explicitly registered hosts, preserving the host/provider/ID tuple.
56
+ export async function readSessionList({ root, signal, provider = 'all', localOnly = false, includeArchived = false,
57
+ readLinks = readProviderLinks, loadLocal = readLocalSessionList, loadRemote = readRemoteSessionLists } = {}) {
58
+ const links = await readLinks(root);
59
+ const providers = ['codex', 'claude'].filter(name => links[name]?.connected && (provider === 'all' || name === provider));
60
+ if (!providers.length) throw Object.assign(new Error('Reconnect a provider before listing conversations.'), { code: 'provider_link_disconnected' });
61
+ const [local, remote] = await Promise.allSettled([
62
+ loadLocal({ root, signal, provider, includeArchived, readLinks }),
63
+ localOnly ? [] : loadRemote({ root, signal, providers, includeArchived }),
64
+ ]);
65
+ signal?.throwIfAborted();
66
+ const current = await readLinks(root);
67
+ for (const name of providers) if (!current[name]?.connected || current[name].epoch !== links[name].epoch)
68
+ throw Object.assign(new Error('Provider connection changed. Refresh the conversation list.'), { code: 'provider_link_changed' });
69
+ const warnings = [], sessions = [], hosts = [];
70
+ if (local.status === 'fulfilled') {
71
+ sessions.push(...local.value.sessions.map(row => ({ ...row, hostId: 'local', hostLabel: local.value.host })));
72
+ warnings.push(...(local.value.warnings || []));
73
+ } else warnings.push(`local: ${terminalText(local.reason?.message)}`);
74
+ if (remote.status === 'rejected') warnings.push(`remote: ${terminalText(remote.reason?.message)}`);
75
+ else for (const result of remote.value) {
76
+ hosts.push({ id: result.host.id, label: result.host.label, state: result.error ? 'unavailable' : 'ready' });
77
+ if (result.error) { warnings.push(`${result.host.label}: ${terminalText(result.error)}`); continue; }
78
+ warnings.push(...(result.catalog.warnings || []).map(warning => `${result.host.label}: ${terminalText(warning)}`));
79
+ if (result.catalog.complete === false && !result.catalog.warnings?.length) warnings.push(`${result.host.label}: incomplete catalog`);
80
+ sessions.push(...result.catalog.sessions.map(row => ({
81
+ provider: row.provider, session: row.session, title: terminalText(row.title || row.session), cwd: terminalText(row.cwd),
82
+ archived: row.archived === true, hostId: result.host.id, hostLabel: result.host.label,
83
+ workState: { label: terminalText(row.workState?.label || '状態未確認') },
84
+ })));
85
+ }
86
+ return { host: hostname(), observedAt: new Date().toISOString(), complete: warnings.length === 0, warnings, hosts,
87
+ sessions: [...new Map(sessions.map(row => [`${row.hostId}:${row.provider}:${row.session}`, row])).values()] };
88
+ }
89
+
36
90
  export function formatSessionList(catalog, query = '') {
37
91
  const rows = filterSessionList(catalog.sessions, query);
38
- return [`SIMY · Codex会話一覧 · ${terminalText(catalog.host)}`,
92
+ return [`SIMY · Codex / Claude Code 会話一覧 · ${terminalText(catalog.host)}`,
39
93
  `表示 ${rows.length} / ${catalog.sessions.length} 件${query ? ` · 検索: ${terminalText(query)}` : ''}`,
94
+ ...(catalog.warnings || []).map(warning => `取得未完了: ${terminalText(warning)}`),
40
95
  ...rows.flatMap((row, index) => [
41
96
  `${index + 1}. ${terminalText(row.title)}`,
42
- ` ${terminalText(row.workState?.label || '状態未確認')} · ${row.session}`,
97
+ ` ${terminalText(row.hostLabel || catalog.host)} · ${row.provider === 'claude' ? 'Claude Code' : 'Codex'} · ${terminalText(row.workState?.label || '状態未確認')} · ${row.session}`,
43
98
  ` ${terminalText(row.cwd)}`,
44
99
  ]), ...(rows.length ? [] : ['一致する会話はありません。'])].join('\n') + '\n';
45
100
  }
46
101
 
47
102
  export async function runSessionListCommand(argv, { output = process.stdout, errors = process.stderr, input = process.stdin,
48
- load = readLocalSessionList, renderInteractive } = {}) {
49
- let query = '', json = false, interactive = false, includeArchived = false;
103
+ load = readSessionList, renderInteractive } = {}) {
104
+ let query = '', json = false, interactive = false, includeArchived = false, localOnly = false, provider = 'all';
50
105
  try {
51
106
  for (let i = 0; i < argv.length; i++) {
52
107
  const arg = argv[i];
53
108
  if (arg === '--help' || arg === '-h') {
54
- output.write('simy sessions [--query TEXT] [--json | --interactive] [--include-archived]\n\nShow local Codex conversations. No task is started or resumed.\n'); return 0;
109
+ output.write('simy sessions [--query TEXT] [--json | --interactive] [--include-archived] [--local-only] [--provider all|codex|claude]\n\nShow available Codex and Claude Code conversations on this host and registered SSH hosts. No task is started or resumed.\n'); return 0;
55
110
  }
56
111
  if (arg === '--query') {
57
112
  if (typeof argv[i + 1] !== 'string' || argv[i + 1].startsWith('--')) throw new Error('--query requires search text.');
@@ -59,6 +114,11 @@ export async function runSessionListCommand(argv, { output = process.stdout, err
59
114
  } else if (arg === '--json') json = true;
60
115
  else if (arg === '--interactive') interactive = true;
61
116
  else if (arg === '--include-archived') includeArchived = true;
117
+ else if (arg === '--local-only') localOnly = true;
118
+ else if (arg === '--provider') {
119
+ provider = argv[++i];
120
+ if (!['all', 'codex', 'claude'].includes(provider)) throw new Error('--provider requires all, codex or claude.');
121
+ }
62
122
  else throw new Error(`Unknown sessions option: ${terminalText(arg)}`);
63
123
  }
64
124
  if (json && interactive) throw new Error('Choose --json or --interactive.');
@@ -67,7 +127,7 @@ export async function runSessionListCommand(argv, { output = process.stdout, err
67
127
  const cancel = () => controller.abort();
68
128
  process.once('SIGINT', cancel); process.once('SIGTERM', cancel);
69
129
  try {
70
- const refresh = () => load({ includeArchived, signal: controller.signal });
130
+ const refresh = () => load({ includeArchived, localOnly, provider, signal: controller.signal });
71
131
  const catalog = await refresh();
72
132
  if (interactive) {
73
133
  const start = renderInteractive || (await import('../console/sessions.js')).runSessionListConsole;
@@ -100,14 +100,16 @@ export async function inspectSqmAgentSkill({ home = homedir() } = {}) {
100
100
  };
101
101
  }
102
102
 
103
- export async function installSqmAgentSkill({ home = homedir(), platforms = Object.keys(TARGETS) } = {}) {
103
+ export async function installSqmAgentSkill({ home = homedir(), platforms = Object.keys(TARGETS), migrateLegacyGate = true } = {}) {
104
104
  const requested = [...new Set(platforms)];
105
105
  if (!requested.length || requested.some((platform) => !Object.hasOwn(TARGETS, platform))) {
106
106
  throw new Error("platforms must contain codex and/or claude");
107
107
  }
108
108
  if (requested.includes("codex")) await installCodexPlugin({ home });
109
109
  if (requested.includes("claude")) await installClaudePlugin({ home });
110
- await retireLegacySqmGate({ home });
110
+ // Explicit single-provider repair must not migrate another provider's hooks.
111
+ // Ordinary setup and workflow updates retain the existing migration behavior.
112
+ if (migrateLegacyGate) await retireLegacySqmGate({ home });
111
113
  for (const platform of requested) {
112
114
  await atomicText(path.join(home, ...TARGETS[platform]), SKILL_CONTENT, 0o600);
113
115
  }
@@ -16,7 +16,7 @@ export function runProviderCommand(command, args, { env, cwd, timeout = 10_000,
16
16
  };
17
17
  const timer = setTimeout(() => {
18
18
  child.kill("SIGTERM");
19
- finish(new Error("Provider plugin command timed out."));
19
+ finish(Object.assign(new Error("Provider plugin command timed out."), { code: 'provider_timeout' }));
20
20
  }, timeout);
21
21
  timer.unref?.();
22
22
  const capture = (stream, append) => {
@@ -0,0 +1,105 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { mkdir, readFile, realpath, rm } from 'node:fs/promises';
4
+ import { resolveBackendExecutable } from '../backend-executable.js';
5
+ import { inspectProviderPlugins } from './provider-plugin-status.js';
6
+ import { runProviderCommand } from './provider-command.js';
7
+ import { inspectSqmAgentSkill, installSqmAgentSkill } from './agent-skill.js';
8
+ import { CLAUDE_MARKETPLACE, CLAUDE_PLUGIN_SELECTOR } from './claude-plugin.js';
9
+
10
+ const fail = reason => Object.assign(new Error('SIMY plugin repair could not be verified.'), { code: 'plugin_repair_failed', reason });
11
+
12
+ // Explicit user action only. Repair one provider, under the same lock as the
13
+ // background workflow updater. Never change SIMY consent, login or hook trust.
14
+ export async function repairProviderPlugin({ provider, home = homedir(),
15
+ codexHome = process.env.CODEX_HOME || join(home, '.codex'),
16
+ claudeHome = process.env.CLAUDE_CONFIG_DIR || join(home, '.claude'),
17
+ signal, authorize = async () => {}, onDiagnostic = () => {},
18
+ inspect = inspectProviderPlugins, inspectSources = inspectSqmAgentSkill,
19
+ prepare = installSqmAgentSkill, resolveExecutable = resolveBackendExecutable,
20
+ run = runProviderCommand,
21
+ } = {}) {
22
+ if (!['codex', 'claude'].includes(provider)) throw fail('unsupported_provider');
23
+ const startedAt = Date.now();
24
+ const check = async () => {
25
+ if (signal?.aborted) throw fail('cancelled');
26
+ await authorize();
27
+ if (signal?.aborted) throw fail('cancelled');
28
+ };
29
+ const options = { home, codexHome, claudeHome, providers: [provider], details: true,
30
+ timeoutMs: 20_000, signal, onDiagnostic, resolveExecutable, run };
31
+ const inventory = async () => (await inspect(options))[provider];
32
+ const command = async args => {
33
+ await check();
34
+ const executable = await resolveExecutable(provider, { signal, timeoutMs: 5_000 });
35
+ if (!executable) throw fail('cli_missing');
36
+ return run(executable, args, { cwd: home, signal, timeout: 20_000, maxBuffer: 1024 * 1024,
37
+ env: { ...process.env, HOME: home, ...(provider === 'codex' ? { CODEX_HOME: codexHome } : { CLAUDE_CONFIG_DIR: claudeHome }) } });
38
+ };
39
+ let locked = false;
40
+ const lock = join(home, '.simy', 'integrations', 'workflow-update.lock');
41
+ try {
42
+ await check();
43
+ let current = await inventory();
44
+ if (current?.state === 'enabled') return { state: 'repaired', plugin: current };
45
+ if (!['missing', 'disabled'].includes(current?.state)) throw fail(current?.reason || 'verification_failed');
46
+ if (provider === 'claude' && current.scope && current.scope !== 'user') throw fail('scope_requires_attention');
47
+ await mkdir(join(home, '.simy', 'integrations'), { recursive: true });
48
+ try { await mkdir(lock); locked = true; }
49
+ catch (error) { if (error.code === 'EEXIST') throw fail('busy'); throw error; }
50
+ await check();
51
+ // Reinspect after obtaining the lock; another updater may have finished.
52
+ current = await inventory();
53
+ if (current?.state === 'enabled') return { state: 'repaired', plugin: current };
54
+ if (!['missing', 'disabled'].includes(current?.state)) throw fail(current?.reason || 'verification_failed');
55
+ if (provider === 'claude' && current.scope && current.scope !== 'user') throw fail('scope_requires_attention');
56
+ let sources = await inspectSources({ home });
57
+ if (provider === 'codex' && current.state === 'disabled') {
58
+ const payload = JSON.parse((await command(['plugin', 'list', '--json'])).stdout);
59
+ const entry = payload.installed?.find(item => item.pluginId === current.selector);
60
+ if (entry?.source?.source !== 'local' || !entry.source.path ||
61
+ await realpath(entry.source.path) !== await realpath(sources.codex_plugin.path)) throw fail('source_conflict');
62
+ const marketplace = JSON.parse(await readFile(sources.codex_plugin.marketplace_path, 'utf8'));
63
+ if (current.selector !== `simy@${marketplace.name}`) throw fail('source_conflict');
64
+ }
65
+ if (provider === 'claude') {
66
+ const marketplaces = JSON.parse((await command(['plugin', 'marketplace', 'list', '--json'])).stdout);
67
+ if (!Array.isArray(marketplaces)) throw fail('invalid_inventory');
68
+ const existing = marketplaces.find(item => item.name === CLAUDE_MARKETPLACE);
69
+ if (existing && (existing.source !== 'directory' || !existing.path ||
70
+ await realpath(existing.path) !== await realpath(sources.claude_plugin.marketplace_root))) throw fail('source_conflict');
71
+ }
72
+ await check();
73
+ sources = await prepare({ home, platforms: [provider], migrateLegacyGate: false });
74
+ if (provider === 'codex') {
75
+ const marketplace = JSON.parse(await readFile(sources.codex_plugin.marketplace_path, 'utf8'));
76
+ if (!/^[A-Za-z0-9_-]+$/.test(marketplace.name || '')) throw fail('source_conflict');
77
+ const selector = `simy@${marketplace.name}`;
78
+ if (current.selector && current.selector !== selector) throw fail('source_conflict');
79
+ await command(['plugin', 'add', selector, '--json']);
80
+ } else {
81
+ // The managed marketplace installer preserves unrelated provider settings.
82
+ const marketplaces = JSON.parse((await command(['plugin', 'marketplace', 'list', '--json'])).stdout);
83
+ if (!Array.isArray(marketplaces)) throw fail('invalid_inventory');
84
+ const existing = marketplaces.find(item => item.name === CLAUDE_MARKETPLACE);
85
+ if (existing && (existing.source !== 'directory' || !existing.path ||
86
+ await realpath(existing.path) !== await realpath(sources.claude_plugin.marketplace_root))) throw fail('source_conflict');
87
+ if (!existing) {
88
+ await command(['plugin', 'marketplace', 'add', sources.claude_plugin.marketplace_root]);
89
+ }
90
+ await command(['plugin', current.state === 'missing' ? 'install' : 'enable', CLAUDE_PLUGIN_SELECTOR, '--scope', 'user']);
91
+ }
92
+ await check();
93
+ const verified = await inventory();
94
+ if (verified?.state !== 'enabled') throw fail(verified?.reason || 'verification_failed');
95
+ return { state: 'repaired', plugin: verified };
96
+ } catch (error) {
97
+ const reason = error?.reason || (signal?.aborted ? 'cancelled' : error?.code === 'provider_timeout' ? 'timeout' :
98
+ error instanceof SyntaxError ? 'invalid_inventory' :
99
+ ['EACCES', 'EPERM', 'EROFS'].includes(error?.code) ? 'permission_denied' : 'command_failed');
100
+ onDiagnostic({ provider, reason, elapsedMs: Date.now() - startedAt });
101
+ throw fail(reason);
102
+ } finally {
103
+ if (locked) await rm(lock, { recursive: true, force: true });
104
+ }
105
+ }
@@ -14,13 +14,22 @@ export async function inspectProviderPlugins({ home = homedir(),
14
14
  codexHome = process.env.CODEX_HOME || join(home, '.codex'),
15
15
  claudeHome = process.env.CLAUDE_CONFIG_DIR || join(home, '.claude'),
16
16
  resolveExecutable = resolveBackendExecutable, run = runProviderCommand,
17
- timeoutMs = 5_000, signal,
17
+ timeoutMs = 5_000, signal, providers = ['codex', 'claude'], details = false,
18
+ onDiagnostic = () => {},
18
19
  } = {}) {
20
+ if (providers.some(provider => !['codex', 'claude'].includes(provider))) throw new Error('Unsupported provider.');
19
21
  const inspect = async provider => {
22
+ const startedAt = Date.now();
23
+ const unavailable = reason => {
24
+ const elapsedMs = Date.now() - startedAt;
25
+ onDiagnostic({ provider, reason, elapsedMs });
26
+ return { ...unavailablePluginStatus(), ...(details ? { reason, elapsedMs } : {}) };
27
+ };
20
28
  try {
21
- if (signal?.aborted) return unavailablePluginStatus();
29
+ if (signal?.aborted) return unavailable('cancelled');
22
30
  const executable = await resolveExecutable(provider, { signal, timeoutMs });
23
- if (!executable || signal?.aborted) return unavailablePluginStatus();
31
+ if (signal?.aborted) return unavailable('cancelled');
32
+ if (!executable) return unavailable('cli_missing');
24
33
  const { stdout } = await run(executable, ['plugin', 'list', '--json'], {
25
34
  timeout: timeoutMs, maxBuffer: 1024 * 1024, signal, cwd: home,
26
35
  env: { ...process.env, HOME: home,
@@ -28,7 +37,7 @@ export async function inspectProviderPlugins({ home = homedir(),
28
37
  });
29
38
  const payload = JSON.parse(stdout);
30
39
  const entries = provider === 'codex' ? payload?.installed : payload;
31
- if (!Array.isArray(entries)) return unavailablePluginStatus();
40
+ if (!Array.isArray(entries)) return unavailable('invalid_inventory');
32
41
  const matches = entries.filter(entry => provider === 'codex'
33
42
  ? typeof entry?.pluginId === 'string' && /^simy@[A-Za-z0-9_-]+$/.test(entry.pluginId)
34
43
  : entry?.id === CLAUDE_PLUGIN_SELECTOR);
@@ -42,11 +51,14 @@ export async function inspectProviderPlugins({ home = homedir(),
42
51
  const entry = matches[0];
43
52
  if (!entry || provider === 'codex' && entry.installed === false) return { state: 'missing', installed: false, enabled: false };
44
53
  if (typeof entry.enabled !== 'boolean' || provider === 'codex' && entry.installed !== true ||
45
- provider === 'claude' && !scopes.includes(entry.scope)) return unavailablePluginStatus();
54
+ provider === 'claude' && !scopes.includes(entry.scope)) return unavailable('invalid_inventory');
46
55
  return { state: entry.enabled ? 'enabled' : 'disabled', installed: true, enabled: entry.enabled,
56
+ ...(details ? { selector: provider === 'codex' ? entry.pluginId : entry.id } : {}),
47
57
  ...(provider === 'claude' ? { scope: entry.scope, enablementContext: 'home' } : {}) };
48
- } catch { return unavailablePluginStatus(); }
58
+ } catch (error) {
59
+ return unavailable(signal?.aborted ? 'cancelled' : error?.code === 'provider_timeout' ? 'timeout' :
60
+ error instanceof SyntaxError ? 'invalid_inventory' : 'command_failed');
61
+ }
49
62
  };
50
- const [codex, claude] = await Promise.all([inspect('codex'), inspect('claude')]);
51
- return { codex, claude };
63
+ return Object.fromEntries(await Promise.all(providers.map(async provider => [provider, await inspect(provider)])));
52
64
  }