synabun 2026.4.21 → 2026.4.25-beta.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +161 -881
  2. package/README.md +1 -1
  3. package/hooks/claude-code/prompt-submit.mjs +39 -18
  4. package/hooks/claude-code/session-start.mjs +10 -3
  5. package/hooks/claude-code/shared.mjs +39 -2
  6. package/hooks/claude-code/stop.mjs +33 -6
  7. package/mcp-server/src/index.ts +9 -1
  8. package/mcp-server/src/services/neural-interface.ts +42 -14
  9. package/mcp-server/src/services/profiles.ts +8 -7
  10. package/mcp-server/src/tools/browser-advanced.ts +12 -10
  11. package/mcp-server/src/tools/browser-interact.ts +39 -38
  12. package/mcp-server/src/tools/browser-navigate.ts +12 -4
  13. package/mcp-server/src/tools/loop.ts +7 -3
  14. package/mcp-server/src/tools/profile.ts +19 -1
  15. package/neural-interface/lib/loop-logger.js +103 -0
  16. package/neural-interface/lib/mcp-installer.js +109 -0
  17. package/neural-interface/lib/plugin-installer.js +264 -0
  18. package/neural-interface/package.json +1 -1
  19. package/neural-interface/public/shared/api.js +66 -17
  20. package/neural-interface/public/shared/cdx/cdx-icons.js +5 -4
  21. package/neural-interface/public/shared/cdx/cdx-panel.js +44 -14
  22. package/neural-interface/public/shared/cdx/cdx-render.js +170 -43
  23. package/neural-interface/public/shared/cdx/cdx-styles.js +23 -10
  24. package/neural-interface/public/shared/cdx/cdx-tabs.js +384 -70
  25. package/neural-interface/public/shared/constants.js +3 -2
  26. package/neural-interface/public/shared/html-shell.js +32 -11
  27. package/neural-interface/public/shared/ocp/ocp-panel.js +45 -28
  28. package/neural-interface/public/shared/ocp/ocp-render.js +50 -43
  29. package/neural-interface/public/shared/ocp/ocp-tabs.js +693 -82
  30. package/neural-interface/public/shared/styles.css +635 -0
  31. package/neural-interface/public/shared/ui-automation-studio.js +38 -12
  32. package/neural-interface/public/shared/ui-claude-panel.js +216 -21
  33. package/neural-interface/public/shared/ui-file-explorer.js +38 -18
  34. package/neural-interface/public/shared/ui-menubar.js +34 -10
  35. package/neural-interface/public/shared/ui-navbar.js +8 -6
  36. package/neural-interface/public/shared/ui-schedules-studio.js +1464 -0
  37. package/neural-interface/public/shared/ui-settings.js +274 -50
  38. package/neural-interface/public/shared/ui-sidepanel-layout.js +18 -7
  39. package/neural-interface/public/shared/ui-sidepanel-tray.js +287 -0
  40. package/neural-interface/public/shared/ui-sync.js +9 -5
  41. package/neural-interface/public/shared/ui-terminal.js +76 -27
  42. package/neural-interface/public/shared/ui-tool-updates.js +49 -15
  43. package/neural-interface/public/shared/ui-tutorial-steps.js +4 -2
  44. package/neural-interface/public/shared/ui-tutorial.js +24 -18
  45. package/neural-interface/public/shared/ui-whiteboard.js +79 -31
  46. package/neural-interface/public/shared/vterm-buffer.js +9 -8
  47. package/neural-interface/public/variant/2d/main.js +9 -7
  48. package/neural-interface/public/variant/3d/main.js +10 -8
  49. package/neural-interface/server.js +1281 -252
  50. package/package.json +1 -1
  51. package/skills/leonardo/SKILL.md +5 -1
  52. package/skills/leonardo/modules/image-prompter.md +5 -1
  53. package/skills/leonardo/modules/video-prompter.md +5 -1
  54. package/skills/synabun/SKILL.md +44 -26
  55. package/skills/synabun/modules/audit.md +40 -36
  56. package/skills/synabun/modules/changelog.md +22 -18
  57. package/skills/synabun/modules/memorize.md +19 -15
  58. package/skills/synabun/modules/schedule.md +285 -0
  59. package/CLAUDE.md +0 -217
@@ -13,7 +13,7 @@ const returnSnapshotField = z.object({
13
13
 
14
14
  const tabIdField = z.string().optional().describe('Target a specific tab within the session. Auto-resolved from environment if omitted.');
15
15
 
16
- function formatClickHints(result: Record<string, unknown>): string {
16
+ function formatClickHints(result: Record<string, unknown>): string {
17
17
  const hints = result.hints as Array<{ role: string; text: string; ariaLabel: string; placeholder: string; selector?: string; nth?: number }> | undefined;
18
18
  if (!hints?.length) return '';
19
19
  const heading = result.hintsLabel as string | undefined;
@@ -24,8 +24,13 @@ function formatClickHints(result: Record<string, unknown>): string {
24
24
  msg += `\n- ${h.role}: "${label.substring(0, 60)}"${sel}`;
25
25
  }
26
26
  msg += '\n\nRetry browser_click/fill with one of the selectors above, or use browser_snapshot with mode="interactive".';
27
- return msg;
28
- }
27
+ return msg;
28
+ }
29
+
30
+ function locationSuffix(result: Record<string, unknown>): string {
31
+ if (ni.isBrowserCompactMode()) return '';
32
+ return result.url ? ` — ${result.url} "${result.title}"` : '';
33
+ }
29
34
 
30
35
  // ── browser_click ──
31
36
 
@@ -61,7 +66,7 @@ export async function handleBrowserClick(args: {
61
66
  if (result.error) return text(`Click failed: ${result.error}${formatClickHints(result)}`);
62
67
 
63
68
  const healed = result.healed ? ' (auto-healed via textHint)' : '';
64
- let msg = `Clicked "${args.selector}"${healed} — now at ${result.url} "${result.title}"`;
69
+ let msg = `Clicked "${args.selector}"${healed}${locationSuffix(result)}`;
65
70
  if (args.returnSnapshot) {
66
71
  const snap = formatInlineSnapshot(result, {
67
72
  mode: args.returnSnapshot.mode,
@@ -94,37 +99,36 @@ export async function handleBrowserFill(args: { selector: string; value: string;
94
99
  const result = await ni.fill(resolved.sessionId, args.selector, args.value, args.nthMatch, resolved.tabId, args.textHint);
95
100
  if (result.error) return text(`Fill failed: ${result.error}${formatClickHints(result)}`);
96
101
 
97
- const location = result.url ? ` — ${result.url} "${result.title}"` : '';
98
- const healed = result.healed ? ' (auto-healed via textHint)' : '';
99
- return text(`Filled "${args.selector}"${healed} with "${args.value.slice(0, 100)}"${location}`);
100
- }
102
+ const healed = result.healed ? ' (auto-healed via textHint)' : '';
103
+ return text(`Filled "${args.selector}"${healed} with "${args.value.slice(0, 100)}"${locationSuffix(result)}`);
104
+ }
101
105
 
102
106
  // ── browser_type ──
103
107
 
104
108
  export const browserTypeSchema = {
105
109
  selector: z.string().optional().describe('Playwright selector for the element to type into. If omitted, types into the currently focused element using keyboard events.'),
106
110
  text: z.string().describe('The text to type character by character (appends to existing content).'),
107
- nthMatch: z.coerce.number().int().min(0).optional().describe('If the selector matches multiple elements, type into the Nth (0-indexed).'),
108
- textHint: z.string().optional().describe('Optional auto-heal hint. If the primary selector matches 0 elements, server retries once with role=*[name~="<textHint>"] before failing.'),
109
- sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
110
- tabId: tabIdField,
111
- };
111
+ nthMatch: z.coerce.number().int().min(0).optional().describe('If the selector matches multiple elements, type into the Nth (0-indexed).'),
112
+ textHint: z.string().optional().describe('Optional auto-heal hint. If the primary selector matches 0 elements, server retries once with role=*[name~="<textHint>"] before failing.'),
113
+ mode: z.enum(['sequential', 'insert']).optional().describe('Typing strategy. "sequential" sends key events. "insert" bulk-inserts text faster but may skip keydown handlers. Codex fast mode defaults to insert.'),
114
+ sessionId: z.string().optional().describe('Browser session ID. If omitted, auto-selects the only open session.'),
115
+ tabId: tabIdField,
116
+ };
112
117
 
113
118
  export const browserTypeDescription =
114
119
  'Type text character-by-character (simulates real keystrokes; appends). Provide a selector to target, or omit to type into the focused element. Prefer over browser_fill for contenteditable/rich-text editors. Call browser_cheatsheet for per-platform compose selectors.';
115
120
 
116
- export async function handleBrowserType(args: { selector?: string; text: string; nthMatch?: number; textHint?: string; sessionId?: string; tabId?: string }) {
117
- const resolved = await ni.resolveSession(args.sessionId, undefined, args.tabId);
118
- if ('error' in resolved) return text(resolved.error);
119
-
120
- const result = await ni.type(resolved.sessionId, args.selector ?? null, args.text, args.nthMatch, resolved.tabId, args.textHint);
121
- if (result.error) return text(`Type failed: ${result.error}${formatClickHints(result)}`);
122
-
123
- const target = args.selector ? `"${args.selector}"` : 'focused element';
124
- const location = result.url ? ` — ${result.url} "${result.title}"` : '';
125
- const healed = result.healed ? ' (auto-healed via textHint)' : '';
126
- return text(`Typed "${args.text.slice(0, 100)}" into ${target}${healed}${location}`);
127
- }
121
+ export async function handleBrowserType(args: { selector?: string; text: string; nthMatch?: number; textHint?: string; mode?: 'sequential' | 'insert'; sessionId?: string; tabId?: string }) {
122
+ const resolved = await ni.resolveSession(args.sessionId, undefined, args.tabId);
123
+ if ('error' in resolved) return text(resolved.error);
124
+
125
+ const result = await ni.type(resolved.sessionId, args.selector ?? null, args.text, args.nthMatch, resolved.tabId, args.textHint, args.mode);
126
+ if (result.error) return text(`Type failed: ${result.error}${formatClickHints(result)}`);
127
+
128
+ const target = args.selector ? `"${args.selector}"` : 'focused element';
129
+ const healed = result.healed ? ' (auto-healed via textHint)' : '';
130
+ return text(`Typed "${args.text.slice(0, 100)}" into ${target}${healed}${locationSuffix(result)}`);
131
+ }
128
132
 
129
133
  // ── browser_hover ──
130
134
 
@@ -146,10 +150,9 @@ export async function handleBrowserHover(args: { selector: string; nthMatch?: nu
146
150
  const result = await ni.hover(resolved.sessionId, args.selector, args.nthMatch, resolved.tabId, args.textHint);
147
151
  if (result.error) return text(`Hover failed: ${result.error}${formatClickHints(result)}`);
148
152
 
149
- const location = result.url ? ` — ${result.url} "${result.title}"` : '';
150
- const healed = result.healed ? ' (auto-healed via textHint)' : '';
151
- return text(`Hovered over "${args.selector}"${healed}${location}`);
152
- }
153
+ const healed = result.healed ? ' (auto-healed via textHint)' : '';
154
+ return text(`Hovered over "${args.selector}"${healed}${locationSuffix(result)}`);
155
+ }
153
156
 
154
157
  // ── browser_select ──
155
158
 
@@ -171,9 +174,8 @@ export async function handleBrowserSelect(args: { selector: string; value: strin
171
174
  const result = await ni.selectOption(resolved.sessionId, args.selector, args.value, args.nthMatch, resolved.tabId);
172
175
  if (result.error) return text(`Select failed: ${result.error}${formatClickHints(result)}`);
173
176
 
174
- const location = result.url ? ` — ${result.url} "${result.title}"` : '';
175
- return text(`Selected "${args.value}" in "${args.selector}"${location}`);
176
- }
177
+ return text(`Selected "${args.value}" in "${args.selector}"${locationSuffix(result)}`);
178
+ }
177
179
 
178
180
  // ── browser_press ──
179
181
 
@@ -193,9 +195,8 @@ export async function handleBrowserPress(args: { key: string; sessionId?: string
193
195
  const result = await ni.pressKey(resolved.sessionId, args.key, resolved.tabId);
194
196
  if (result.error) return text(`Press failed: ${result.error}`);
195
197
 
196
- const location = result.url ? ` — ${result.url} "${result.title}"` : '';
197
- return text(`Pressed "${args.key}"${location}`);
198
- }
198
+ return text(`Pressed "${args.key}"${locationSuffix(result)}`);
199
+ }
199
200
 
200
201
  // ── browser_scroll ──
201
202
 
@@ -236,7 +237,7 @@ export async function handleBrowserScroll(args: {
236
237
  if (result.error) return text(`Scroll failed: ${result.error}`);
237
238
 
238
239
  const target = args.selector ? `"${args.selector}"` : 'page';
239
- let msg = `Scrolled ${args.direction} ${args.distance ?? 500}px in ${target} — now at ${result.url} "${result.title}"`;
240
+ let msg = `Scrolled ${args.direction} ${args.distance ?? 500}px in ${target}${locationSuffix(result)}`;
240
241
  if (args.returnSnapshot) {
241
242
  const snap = formatInlineSnapshot(result, {
242
243
  mode: args.returnSnapshot.mode,
@@ -268,5 +269,5 @@ export async function handleBrowserUpload(args: { selector: string; filePaths: s
268
269
  const result = await ni.upload(resolved.sessionId, args.selector, args.filePaths, args.nthMatch, resolved.tabId);
269
270
  if (result.error) return text(`Upload failed: ${result.error}${formatClickHints(result)}`);
270
271
 
271
- return text(`Uploaded ${args.filePaths.length} file(s) via "${args.selector}" — now at ${result.url} "${result.title}"`);
272
- }
272
+ return text(`Uploaded ${args.filePaths.length} file(s) via "${args.selector}"${locationSuffix(result)}`);
273
+ }
@@ -13,6 +13,14 @@ const returnSnapshotField = z.object({
13
13
  format: z.enum(['text', 'json']).optional(),
14
14
  }).optional().describe('If set, the server also captures a snapshot after the action and returns it in the same response — one round-trip instead of two. Matches browser_snapshot params.');
15
15
 
16
+ function formatBrowserLocation(result: Record<string, unknown>, action: string): string {
17
+ if (ni.isBrowserCompactMode()) {
18
+ const title = typeof result.title === 'string' && result.title ? ` "${result.title}"` : '';
19
+ return `${action}${title}`;
20
+ }
21
+ return `${action} ${result.url} — "${result.title}"`;
22
+ }
23
+
16
24
  // ── browser_navigate ──
17
25
 
18
26
  export const browserNavigateSchema = {
@@ -42,7 +50,7 @@ export async function handleBrowserNavigate(args: {
42
50
  const result = await ni.navigate(resolved.sessionId, args.url, resolved.tabId, rs);
43
51
  if (result.error) return text(`Navigation failed: ${result.error}`);
44
52
 
45
- let msg = `Navigated to ${result.url} — "${result.title}"`;
53
+ let msg = formatBrowserLocation(result, 'Navigated');
46
54
  if (args.returnSnapshot) {
47
55
  const snap = formatInlineSnapshot(result, {
48
56
  mode: args.returnSnapshot.mode,
@@ -70,7 +78,7 @@ export async function handleBrowserGoBack(args: { sessionId?: string; tabId?: st
70
78
  const result = await ni.goBack(resolved.sessionId, resolved.tabId);
71
79
  if (result.error) return text(`Go back failed: ${result.error}`);
72
80
 
73
- return text(`Went back to ${result.url} — "${result.title}"`);
81
+ return text(formatBrowserLocation(result, 'Went back to'));
74
82
  }
75
83
 
76
84
  // ── browser_go_forward ──
@@ -89,7 +97,7 @@ export async function handleBrowserGoForward(args: { sessionId?: string; tabId?:
89
97
  const result = await ni.goForward(resolved.sessionId, resolved.tabId);
90
98
  if (result.error) return text(`Go forward failed: ${result.error}`);
91
99
 
92
- return text(`Went forward to ${result.url} — "${result.title}"`);
100
+ return text(formatBrowserLocation(result, 'Went forward to'));
93
101
  }
94
102
 
95
103
  // ── browser_reload ──
@@ -108,5 +116,5 @@ export async function handleBrowserReload(args: { sessionId?: string; tabId?: st
108
116
  const result = await ni.reload(resolved.sessionId, resolved.tabId);
109
117
  if (result.error) return text(`Reload failed: ${result.error}`);
110
118
 
111
- return text(`Reloaded — now at ${result.url} "${result.title}"`);
119
+ return text(formatBrowserLocation(result, 'Reloaded'));
112
120
  }
@@ -53,13 +53,17 @@ function resolveSessionId(explicit?: string): string | null {
53
53
  } catch { /* dir read failed */ }
54
54
  }
55
55
 
56
- // Fallback: scan for any active loop file (legacy — no terminal scoping)
56
+ // Fallback: STRICT — only match legacy loops without terminalSessionId.
57
+ // Previously this scanned ALL active loops and returned the first, which
58
+ // let one Claude session claim another's scheduled loop when env was unset.
59
+ // Server-launched loops always set terminalSessionId; manual/legacy loops
60
+ // (created without server) don't, so they remain claimable here.
57
61
  try {
58
- const files = readdirSync(LOOP_DIR).filter(f => f.endsWith('.json'));
62
+ const files = readdirSync(LOOP_DIR).filter(f => f.endsWith('.json') && !f.startsWith('pending-'));
59
63
  for (const file of files) {
60
64
  try {
61
65
  const data = JSON.parse(readFileSync(join(LOOP_DIR, file), 'utf-8'));
62
- if (data?.active) return file.replace('.json', '');
66
+ if (data?.active && !data.terminalSessionId) return file.replace('.json', '');
63
67
  } catch { /* skip corrupt files */ }
64
68
  }
65
69
  } catch { /* dir read failed */ }
@@ -14,13 +14,31 @@ export const profileSchema = {
14
14
  export const profileDescription =
15
15
  'Switch MCP tool profiles at runtime without restarting. Use "get" to see current profile and available presets. Use "set" to enable/disable tool groups dynamically — the client will be notified of the tool list change.';
16
16
 
17
+ const ALWAYS_ON_TOOL_COUNT = 10;
18
+ const GROUP_TOOL_ESTIMATES: Record<string, number> = {
19
+ git: 1,
20
+ image: 1,
21
+ whiteboard: 3,
22
+ card: 4,
23
+ tictactoe: 2,
24
+ browser: 20,
25
+ browser_twitter: 1,
26
+ browser_facebook: 1,
27
+ browser_tiktok: 4,
28
+ browser_whatsapp: 2,
29
+ browser_instagram: 5,
30
+ browser_linkedin: 8,
31
+ leonardo: 5,
32
+ discord: 8,
33
+ };
34
+
17
35
  export async function handleProfile(args: { action: string; profile?: string }) {
18
36
  if (args.action === 'get') {
19
37
  const { profile, activeGroups } = getActiveProfile();
20
38
  const presets = Object.entries(PROFILE_PRESETS).map(([name, groups]) => ({
21
39
  name,
22
40
  groups,
23
- toolEstimate: name === 'core' ? '~11' : name === 'standard' ? '~22' : name === 'browser' ? '~61' : '~74',
41
+ toolEstimate: `~${ALWAYS_ON_TOOL_COUNT + groups.reduce((sum, group) => sum + (GROUP_TOOL_ESTIMATES[group] || 0), 0)}`,
24
42
  }));
25
43
  return text(JSON.stringify({
26
44
  currentProfile: profile,
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Per-loop file logger keyed by terminalSessionId.
3
+ *
4
+ * Writes to data/logs/loop-{terminalSessionId}.log.
5
+ * Format: ISO timestamp | tag | message | JSON metadata
6
+ * Mirrors to console with [loop:{tag}] prefix so existing console workflow still works.
7
+ *
8
+ * Auto-rotates: keeps newest 50 logfiles, deletes older.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, appendFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
12
+ import { resolve, join } from 'node:path';
13
+
14
+ const PACKAGE_ROOT = resolve(new URL('..', import.meta.url).pathname, '..');
15
+ const DATA_HOME = process.env.SYNABUN_DATA_HOME || PACKAGE_ROOT;
16
+ const LOG_DIR = resolve(DATA_HOME, 'data', 'logs');
17
+ const MAX_LOG_FILES = 50;
18
+
19
+ let _ensuredDir = false;
20
+ let _rotated = false;
21
+
22
+ function ensureDir() {
23
+ if (_ensuredDir) return;
24
+ try {
25
+ if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
26
+ _ensuredDir = true;
27
+ } catch (err) {
28
+ console.warn('[loop-logger] mkdir failed:', err.message);
29
+ }
30
+ }
31
+
32
+ function rotateOnce() {
33
+ if (_rotated) return;
34
+ _rotated = true;
35
+ try {
36
+ if (!existsSync(LOG_DIR)) return;
37
+ const files = readdirSync(LOG_DIR)
38
+ .filter(f => f.startsWith('loop-') && f.endsWith('.log'))
39
+ .map(f => {
40
+ const fp = join(LOG_DIR, f);
41
+ let mtime = 0;
42
+ try { mtime = statSync(fp).mtimeMs; } catch { /* ok */ }
43
+ return { f, fp, mtime };
44
+ })
45
+ .sort((a, b) => b.mtime - a.mtime);
46
+ if (files.length <= MAX_LOG_FILES) return;
47
+ for (const entry of files.slice(MAX_LOG_FILES)) {
48
+ try { unlinkSync(entry.fp); } catch { /* ok */ }
49
+ }
50
+ } catch (err) {
51
+ console.warn('[loop-logger] rotate failed:', err.message);
52
+ }
53
+ }
54
+
55
+ export function loopLogPath(terminalSessionId) {
56
+ if (!terminalSessionId) return null;
57
+ return resolve(LOG_DIR, `loop-${terminalSessionId}.log`);
58
+ }
59
+
60
+ /**
61
+ * Append one structured log entry to the per-loop file AND mirror to console.
62
+ * @param {string|null} terminalSessionId — if null, only logs to console
63
+ * @param {string} tag — short identifier (launch, driver, pty, hook, ui, etc.)
64
+ * @param {string} msg — human-readable message
65
+ * @param {object} [meta] — additional structured data (will be JSON-stringified)
66
+ */
67
+ export function loopLog(terminalSessionId, tag, msg, meta = undefined) {
68
+ const ts = new Date().toISOString();
69
+ const safeMeta = (meta && Object.keys(meta).length > 0) ? safeJson(meta) : '';
70
+ const line = `${ts} | ${tag} | ${msg}${safeMeta ? ' | ' + safeMeta : ''}\n`;
71
+
72
+ // Console mirror
73
+ console.log(`[loop:${tag}] ${msg}${safeMeta ? ' ' + safeMeta : ''}`);
74
+
75
+ if (!terminalSessionId) return;
76
+
77
+ ensureDir();
78
+ rotateOnce();
79
+ try {
80
+ appendFileSync(loopLogPath(terminalSessionId), line);
81
+ } catch (err) {
82
+ console.warn(`[loop-logger] append failed for ${terminalSessionId}:`, err.message);
83
+ }
84
+ }
85
+
86
+ function safeJson(obj) {
87
+ try {
88
+ return JSON.stringify(obj, (_k, v) => {
89
+ if (typeof v === 'string' && v.length > 800) return v.slice(0, 800) + `…(+${v.length - 800})`;
90
+ return v;
91
+ });
92
+ } catch {
93
+ return '<unserializable>';
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Convenience wrapper for hooks that may not know terminalSessionId yet.
99
+ * Reads SYNABUN_TERMINAL_SESSION env var.
100
+ */
101
+ export function loopLogFromEnv(tag, msg, meta) {
102
+ loopLog(process.env.SYNABUN_TERMINAL_SESSION || null, tag, msg, meta);
103
+ }
@@ -331,6 +331,115 @@ export function detectServer(repoPath) {
331
331
  return { ok: true, detected, attempts };
332
332
  }
333
333
 
334
+ // OS-aware install hints for self-fetching runtime binaries the detected
335
+ // command depends on. Used by the preflight check in checkSelfFetcherAvailable().
336
+ const SELF_FETCHER_INSTALL_HINTS = {
337
+ uvx: { mac: 'brew install uv', linux: 'curl -LsSf https://astral.sh/uv/install.sh | sh', win: 'winget install astral-sh.uv or powershell -c "irm https://astral.sh/uv/install.ps1 | iex"' },
338
+ uv: { mac: 'brew install uv', linux: 'curl -LsSf https://astral.sh/uv/install.sh | sh', win: 'winget install astral-sh.uv or powershell -c "irm https://astral.sh/uv/install.ps1 | iex"' },
339
+ pipx: { mac: 'brew install pipx', linux: 'python3 -m pip install --user pipx && pipx ensurepath', win: 'python -m pip install --user pipx && pipx ensurepath' },
340
+ npx: { mac: 'brew install node', linux: 'install Node.js (https://nodejs.org)', win: 'winget install OpenJS.NodeJS' },
341
+ bunx: { mac: 'brew install oven-sh/bun/bun', linux: 'curl -fsSL https://bun.sh/install | bash', win: 'powershell -c "irm bun.sh/install.ps1 | iex"' },
342
+ bun: { mac: 'brew install oven-sh/bun/bun', linux: 'curl -fsSL https://bun.sh/install | bash', win: 'powershell -c "irm bun.sh/install.ps1 | iex"' },
343
+ pnpm: { mac: 'brew install pnpm', linux: 'npm install -g pnpm', win: 'npm install -g pnpm' },
344
+ yarn: { mac: 'brew install yarn', linux: 'npm install -g yarn', win: 'npm install -g yarn' },
345
+ };
346
+
347
+ function installHintFor(cmd) {
348
+ const h = SELF_FETCHER_INSTALL_HINTS[cmd];
349
+ if (!h) return null;
350
+ const platform = process.platform; // 'darwin' | 'linux' | 'win32'
351
+ if (platform === 'darwin') return h.mac;
352
+ if (platform === 'win32') return h.win;
353
+ return h.linux;
354
+ }
355
+
356
+ // Probe whether a command exists on the user's PATH. OS-agnostic.
357
+ // On Windows uses `where`, elsewhere `command -v`. Handles .exe resolution on
358
+ // Windows (`where uvx` finds `uvx.exe`).
359
+ export function checkCommandOnPath(cmd) {
360
+ if (!cmd) return false;
361
+ const isWin = process.platform === 'win32';
362
+ const probeCmd = isWin ? 'where' : 'sh';
363
+ const probeArgs = isWin ? [cmd] : ['-c', `command -v ${cmd}`];
364
+ try {
365
+ const r = spawnSync(probeCmd, probeArgs, { stdio: 'ignore', timeout: 5000 });
366
+ return r.status === 0;
367
+ } catch { return false; }
368
+ }
369
+
370
+ // For self-fetching commands (uvx/npx/pipx/...) verify the runtime is available.
371
+ // Returns { available, command, installHint } so the install endpoint can warn
372
+ // the user before registering something that will fail at launch.
373
+ export function checkSelfFetcherAvailable(detected) {
374
+ if (!detected || !detected.command) return { available: true, command: null, installHint: null };
375
+ const cmd = String(detected.command).toLowerCase();
376
+ const available = checkCommandOnPath(detected.command);
377
+ return {
378
+ available,
379
+ command: detected.command,
380
+ installHint: available ? null : installHintFor(cmd),
381
+ };
382
+ }
383
+
384
+ // Returns true if the detected command fetches its package at runtime
385
+ // (uvx / uv run / npx / pipx / bunx / dlx) so local dependency install is unnecessary.
386
+ export function commandSelfFetches(detected) {
387
+ if (!detected || !detected.command) return false;
388
+ const cmd = String(detected.command).toLowerCase();
389
+ const args = Array.isArray(detected.args) ? detected.args.map(a => String(a).toLowerCase()) : [];
390
+ if (cmd === 'uvx' || cmd === 'pipx' || cmd === 'bunx') return true;
391
+ if (cmd === 'npx') return true;
392
+ if (cmd === 'uv' && args[0] === 'run') return true;
393
+ if (cmd === 'pnpm' && args[0] === 'dlx') return true;
394
+ if (cmd === 'yarn' && args[0] === 'dlx') return true;
395
+ return false;
396
+ }
397
+
398
+ function detectClaudePlugin(repoPath) {
399
+ const manifestPath = join(repoPath, '.claude-plugin', 'plugin.json');
400
+ if (!existsSync(manifestPath)) return null;
401
+ const manifest = tryReadJson(manifestPath);
402
+ if (!manifest || !manifest.name) return null;
403
+ const marketplacePath = join(repoPath, '.claude-plugin', 'marketplace.json');
404
+ const marketplace = existsSync(marketplacePath) ? tryReadJson(marketplacePath) : null;
405
+ const hooks = manifest.hooks && typeof manifest.hooks === 'object' ? Object.keys(manifest.hooks) : [];
406
+ return { manifest, marketplace, hooks };
407
+ }
408
+
409
+ function scanHints(repoPath) {
410
+ const hints = [];
411
+ if (existsSync(join(repoPath, '.claude-plugin', 'plugin.json'))) hints.push('claude-plugin');
412
+ if (existsSync(join(repoPath, '.codex'))) hints.push('codex');
413
+ if (existsSync(join(repoPath, 'gemini-extension.json'))) hints.push('gemini');
414
+ if (existsSync(join(repoPath, '.cursor'))) hints.push('cursor');
415
+ if (existsSync(join(repoPath, '.windsurf'))) hints.push('windsurf');
416
+ return hints;
417
+ }
418
+
419
+ // Classify a cloned repo. MCP wins if both present (preserves existing behavior).
420
+ export function classifyRepo(repoPath) {
421
+ if (!existsSync(repoPath)) return { ok: false, kind: 'unknown', error: `Repo path does not exist: ${repoPath}` };
422
+ const hints = scanHints(repoPath);
423
+ const mcp = detectServer(repoPath);
424
+ if (mcp.ok) {
425
+ const out = { ok: true, kind: 'mcp', detected: mcp.detected, attempts: mcp.attempts, hints };
426
+ const plugin = detectClaudePlugin(repoPath);
427
+ if (plugin) out.alsoClaudePlugin = { manifest: plugin.manifest, marketplace: plugin.marketplace };
428
+ return out;
429
+ }
430
+ const plugin = detectClaudePlugin(repoPath);
431
+ if (plugin) {
432
+ return { ok: true, kind: 'claude-plugin', plugin: { manifest: plugin.manifest, marketplace: plugin.marketplace, hooks: plugin.hooks }, hints };
433
+ }
434
+ return {
435
+ ok: false,
436
+ kind: 'unknown',
437
+ error: "Couldn't identify the repo type. Looked for: mcp.json / smithery.*, package.json, pyproject.toml, README mcpServers block, and .claude-plugin/plugin.json.",
438
+ hints,
439
+ mcpError: mcp.error,
440
+ };
441
+ }
442
+
334
443
  export async function installDependencies({ dataHome, name, repoPath, onLine }) {
335
444
  const pm = detectPackageManager(repoPath);
336
445
  const hasPkg = existsSync(join(repoPath, 'package.json'));