@astrale-os/cli 0.7.0-alpha.0 → 0.8.0-alpha.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.
Files changed (105) hide show
  1. package/README.md +3 -2
  2. package/dist/astrale.js +118 -95
  3. package/package.json +2 -3
  4. package/src/commands/studio.ts +13 -0
  5. package/src/lib/__tests__/view-assets.test.ts +114 -0
  6. package/src/lib/instance.ts +0 -11
  7. package/src/lib/login-flow.ts +4 -41
  8. package/src/lib/view/assets.ts +64 -0
  9. package/src/lib/view/server.ts +4 -37
  10. package/studio/client/dist/assets/index-BaqEuIQJ.js +109 -0
  11. package/studio/client/dist/assets/index-jRdExahh.css +1 -0
  12. package/studio/client/dist/index.html +2 -2
  13. package/studio/server/agent/ask.test.ts +74 -0
  14. package/studio/server/agent/ask.ts +20 -22
  15. package/studio/server/agent/bridge/client.test.ts +49 -0
  16. package/studio/server/agent/bridge/client.ts +37 -0
  17. package/studio/server/agent/bridge/grant.test.ts +62 -0
  18. package/studio/server/agent/bridge/grant.ts +84 -0
  19. package/studio/server/agent/bridge/routes.test.ts +151 -0
  20. package/studio/server/agent/bridge/routes.ts +148 -0
  21. package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
  22. package/studio/server/agent/conversation.test.ts +84 -0
  23. package/studio/server/agent/conversation.ts +110 -0
  24. package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
  25. package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
  26. package/studio/server/agent/harness/claude/adapter.ts +71 -0
  27. package/studio/server/agent/harness/claude/ask.ts +114 -0
  28. package/studio/server/agent/harness/claude/capabilities.ts +27 -0
  29. package/studio/server/agent/harness/claude/command.ts +63 -0
  30. package/studio/server/agent/harness/claude/events.ts +215 -0
  31. package/studio/server/agent/harness/claude/loadout.ts +132 -0
  32. package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
  33. package/studio/server/agent/harness/claude/mcp.ts +41 -0
  34. package/studio/server/agent/harness/claude/skills.ts +43 -0
  35. package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
  36. package/studio/server/agent/harness/codex/adapter.ts +116 -0
  37. package/studio/server/agent/harness/codex/ask.test.ts +176 -0
  38. package/studio/server/agent/harness/codex/ask.ts +181 -0
  39. package/studio/server/agent/harness/codex/command.test.ts +69 -0
  40. package/studio/server/agent/harness/codex/command.ts +43 -0
  41. package/studio/server/agent/harness/codex/events.test.ts +94 -0
  42. package/studio/server/agent/harness/codex/events.ts +132 -0
  43. package/studio/server/agent/harness/codex/exec.ts +113 -0
  44. package/studio/server/agent/harness/codex/loadout.ts +63 -0
  45. package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
  46. package/studio/server/agent/harness/codex/mcp.ts +27 -0
  47. package/studio/server/agent/harness/codex/models.test.ts +167 -0
  48. package/studio/server/agent/harness/codex/models.ts +211 -0
  49. package/studio/server/agent/harness/codex/skills.ts +33 -0
  50. package/studio/server/agent/harness/gateway/config.test.ts +98 -0
  51. package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
  52. package/studio/server/agent/harness/gateway/token.test.ts +133 -0
  53. package/studio/server/agent/harness/gateway/token.ts +166 -0
  54. package/studio/server/agent/harness/mock/adapter.ts +190 -0
  55. package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
  56. package/studio/server/agent/harness/process.test.ts +46 -0
  57. package/studio/server/agent/harness/process.ts +104 -0
  58. package/studio/server/agent/harness/registry.ts +37 -0
  59. package/studio/server/agent/harness/selection.test.ts +75 -0
  60. package/studio/server/agent/harness/selection.ts +77 -0
  61. package/studio/server/agent/harness/skills.test.ts +95 -0
  62. package/studio/server/agent/harness/skills.ts +100 -0
  63. package/studio/server/agent/layout.test.ts +95 -0
  64. package/studio/server/agent/notify.ts +12 -0
  65. package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
  66. package/studio/server/agent/prompts/ask.ts +34 -0
  67. package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
  68. package/studio/server/agent/prompts/turn.ts +57 -0
  69. package/studio/server/agent/routes.test.ts +176 -0
  70. package/studio/server/agent/routes.ts +214 -0
  71. package/studio/server/agent/run/completion.ts +193 -0
  72. package/studio/server/agent/run/coordinator.test.ts +337 -0
  73. package/studio/server/agent/run/coordinator.ts +104 -0
  74. package/studio/server/agent/run/live-state.ts +73 -0
  75. package/studio/server/agent/run/preparation.ts +162 -0
  76. package/studio/server/agent/run/transcript.test.ts +47 -0
  77. package/studio/server/agent/run/transcript.ts +30 -0
  78. package/studio/server/agent/run/usage.test.ts +46 -0
  79. package/studio/server/{state → agent/run}/usage.ts +4 -4
  80. package/studio/server/agent/stream.ts +37 -0
  81. package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
  82. package/studio/server/api-agent-loadout.test.ts +69 -0
  83. package/studio/server/api.ts +3 -155
  84. package/studio/server/index.ts +6 -5
  85. package/studio/server/state/comments.test.ts +29 -0
  86. package/studio/server/state/comments.ts +3 -3
  87. package/studio/server/state/settings.test.ts +78 -0
  88. package/studio/server/state/settings.ts +28 -1
  89. package/studio/shared/agent-effort.test.ts +12 -0
  90. package/studio/shared/agent-effort.ts +15 -0
  91. package/studio/shared/agent-models.test.ts +9 -0
  92. package/studio/shared/agent-models.ts +14 -0
  93. package/studio/shared/settings-values.test.ts +18 -0
  94. package/studio/shared/settings-values.ts +20 -0
  95. package/studio/shared/types.ts +58 -7
  96. package/src/connect-core.test.ts +0 -42
  97. package/src/connect-core.ts +0 -53
  98. package/studio/client/dist/assets/index-DAC1a9vW.js +0 -109
  99. package/studio/client/dist/assets/index-huaFafBC.css +0 -1
  100. package/studio/server/agent/bridge.ts +0 -188
  101. package/studio/server/agent/claude.ts +0 -678
  102. package/studio/server/agent/mock.ts +0 -186
  103. package/studio/server/agent/registry.ts +0 -29
  104. package/studio/server/agent/runner.ts +0 -487
  105. package/studio/server/state/harness-token.ts +0 -0
@@ -0,0 +1,63 @@
1
+ import type { AgentTurnInput, AskInput } from '../adapter'
2
+
3
+ const PERMISSION_MODE = process.env.DOMAIN_STUDIO_AGENT_PERMISSION || 'bypassPermissions'
4
+ const EXTRA_ARGS = (process.env.DOMAIN_STUDIO_CLAUDE_ARGS || '').split(' ').filter(Boolean)
5
+ const ULTRACODE_SETTINGS = JSON.stringify({ ultracode: true })
6
+
7
+ function permissionMode(access?: AgentTurnInput['access']): string {
8
+ return (
9
+ process.env.DOMAIN_STUDIO_AGENT_PERMISSION ||
10
+ (access === 'workspace' ? 'acceptEdits' : PERMISSION_MODE)
11
+ )
12
+ }
13
+
14
+ function appendEffort(args: string[], effort?: AgentTurnInput['effort']): void {
15
+ if (!effort) return
16
+ if (effort === 'ultracode') {
17
+ args.push('--effort', 'xhigh', '--settings', ULTRACODE_SETTINGS)
18
+ return
19
+ }
20
+ args.push('--effort', effort)
21
+ }
22
+
23
+ export function buildClaudeProbeArgs(model?: string): string[] {
24
+ const args = ['-p', '--output-format', 'stream-json', '--verbose', ...EXTRA_ARGS]
25
+ if (model) args.push('--model', model)
26
+ return args
27
+ }
28
+
29
+ export function buildClaudeTurnArgs(input: AgentTurnInput, mcpPath?: string): string[] {
30
+ const args = [
31
+ '-p',
32
+ '--output-format',
33
+ 'stream-json',
34
+ '--verbose',
35
+ '--permission-mode',
36
+ permissionMode(input.access),
37
+ ]
38
+ if (input.sessionId) args.push('--resume', input.sessionId)
39
+ if (input.model) args.push('--model', input.model)
40
+ appendEffort(args, input.effort)
41
+ if (mcpPath) args.push('--mcp-config', mcpPath)
42
+ if (input.appendSystemPrompt) args.push('--append-system-prompt', input.appendSystemPrompt)
43
+ args.push(...EXTRA_ARGS)
44
+ return args
45
+ }
46
+
47
+ export function buildClaudeAskArgs(input: AskInput): string[] {
48
+ const args = [
49
+ '-p',
50
+ '--output-format',
51
+ 'stream-json',
52
+ '--verbose',
53
+ '--permission-mode',
54
+ permissionMode(input.access),
55
+ ]
56
+ if (input.model) args.push('--model', input.model)
57
+ appendEffort(args, input.effort)
58
+ if (input.sessionId) args.push('--resume', input.sessionId, '--fork-session')
59
+ args.push('--no-session-persistence')
60
+ if (input.appendSystemPrompt) args.push('--append-system-prompt', input.appendSystemPrompt)
61
+ args.push(...EXTRA_ARGS)
62
+ return args
63
+ }
@@ -0,0 +1,215 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ import type { AgentTurnInput, AgentTurnResult } from '../adapter'
4
+
5
+ import { childEnvironment, terminateProcessTree } from '../process'
6
+ import { buildClaudeTurnArgs } from './command'
7
+ import { writeClaudeMcpConfig } from './mcp'
8
+
9
+ const RESUME_REJECTED =
10
+ /no conversation found|session (?:id .*)?(?:not found|does not exist|no longer exists|expired)|could not (?:find|load|resume) .*session|unknown session|invalid session id/i
11
+
12
+ function toolTarget(name: string, input: Record<string, unknown> | undefined): string {
13
+ if (!input) return ''
14
+ const text = (value: unknown) =>
15
+ typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value)
16
+ switch (name) {
17
+ case 'Edit':
18
+ case 'Write':
19
+ case 'MultiEdit':
20
+ case 'NotebookEdit':
21
+ return text(input.file_path ?? input.path ?? input.notebook_path)
22
+ case 'Read':
23
+ return text(input.file_path)
24
+ case 'Bash':
25
+ return text(input.command).slice(0, 200)
26
+ case 'Grep':
27
+ case 'Glob':
28
+ return text(input.pattern)
29
+ case 'Task':
30
+ return text(input.description)
31
+ default:
32
+ return text(Object.values(input)[0]).slice(0, 200)
33
+ }
34
+ }
35
+
36
+ /** Execute one Claude stream-json turn and normalize its result. */
37
+ export function runClaudeTurn(bin: string, input: AgentTurnInput): Promise<AgentTurnResult> {
38
+ const mcp = writeClaudeMcpConfig(input.root, input.mcpServers)
39
+ return new Promise((resolve) => {
40
+ let resolvedSession = input.sessionId
41
+ let finalText = ''
42
+ let costUsd: number | undefined
43
+ let numTurns: number | undefined
44
+ let tokens: number | undefined
45
+ let isError = false
46
+ let errorMessage: string | undefined
47
+ let sawResult = false
48
+ let stderr = ''
49
+ let settled = false
50
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined
51
+ let onAbort = () => {}
52
+ const finish = (result: AgentTurnResult) => {
53
+ if (settled) return
54
+ settled = true
55
+ if (forceKillTimer) clearTimeout(forceKillTimer)
56
+ input.signal.removeEventListener('abort', onAbort)
57
+ mcp.dispose()
58
+ resolve(result)
59
+ }
60
+
61
+ let child: ReturnType<typeof spawn>
62
+ try {
63
+ child = spawn(bin, buildClaudeTurnArgs(input, mcp.path), {
64
+ cwd: input.root,
65
+ stdio: ['pipe', 'pipe', 'pipe'],
66
+ env: childEnvironment(input.env),
67
+ detached: process.platform !== 'win32',
68
+ })
69
+ } catch (error) {
70
+ finish({
71
+ sessionId: resolvedSession,
72
+ finalText,
73
+ isError: true,
74
+ errorMessage: `failed to spawn ${bin}: ${String(error)}`,
75
+ })
76
+ return
77
+ }
78
+
79
+ onAbort = () => {
80
+ terminateProcessTree(child)
81
+ forceKillTimer = setTimeout(() => terminateProcessTree(child, 'SIGKILL'), 2_000)
82
+ forceKillTimer.unref?.()
83
+ }
84
+ if (input.signal.aborted) onAbort()
85
+ else input.signal.addEventListener('abort', onAbort, { once: true })
86
+
87
+ child.stdin?.write(input.prompt)
88
+ child.stdin?.end()
89
+
90
+ const handleLine = (line: string) => {
91
+ let event: any
92
+ try {
93
+ event = JSON.parse(line)
94
+ } catch {
95
+ return
96
+ }
97
+ switch (event.type) {
98
+ case 'system':
99
+ if (event.subtype === 'init') {
100
+ if (event.session_id) resolvedSession = event.session_id
101
+ input.onEvent({ kind: 'status', text: 'session started' })
102
+ }
103
+ return
104
+ case 'assistant':
105
+ if (!Array.isArray(event.message?.content)) return
106
+ for (const block of event.message.content) {
107
+ if (block.type === 'text' && block.text?.trim())
108
+ input.onEvent({ kind: 'message', text: block.text.trim() })
109
+ else if (block.type === 'thinking' && block.thinking?.trim())
110
+ input.onEvent({ kind: 'thinking', text: block.thinking.trim() })
111
+ else if (block.type === 'tool_use')
112
+ input.onEvent({
113
+ kind: 'tool',
114
+ text: block.name,
115
+ tool: block.name,
116
+ target: toolTarget(block.name, block.input),
117
+ })
118
+ }
119
+ return
120
+ case 'result': {
121
+ sawResult = true
122
+ if (typeof event.result === 'string') finalText = event.result
123
+ if (typeof event.total_cost_usd === 'number') costUsd = event.total_cost_usd
124
+ if (typeof event.num_turns === 'number') numTurns = event.num_turns
125
+ if (event.usage && typeof event.usage === 'object') {
126
+ const usage = event.usage as Record<string, number | undefined>
127
+ tokens =
128
+ (usage.input_tokens ?? 0) +
129
+ (usage.output_tokens ?? 0) +
130
+ (usage.cache_read_input_tokens ?? 0) +
131
+ (usage.cache_creation_input_tokens ?? 0)
132
+ }
133
+ if (event.session_id) resolvedSession = event.session_id
134
+ if (
135
+ event.is_error ||
136
+ event.subtype === 'error_during_execution' ||
137
+ event.subtype === 'error_max_turns'
138
+ ) {
139
+ isError = true
140
+ errorMessage = event.subtype || 'agent error'
141
+ }
142
+ return
143
+ }
144
+ case 'rate_limit_event':
145
+ if (event.rate_limit_info?.status && event.rate_limit_info.status !== 'allowed')
146
+ input.onEvent({
147
+ kind: 'status',
148
+ text: `rate limit: ${event.rate_limit_info.status}`,
149
+ })
150
+ }
151
+ }
152
+
153
+ let buffer = ''
154
+ child.stdout?.setEncoding('utf8')
155
+ child.stdout?.on('data', (chunk: string) => {
156
+ buffer += chunk
157
+ let newline: number
158
+ while ((newline = buffer.indexOf('\n')) >= 0) {
159
+ const line = buffer.slice(0, newline).trim()
160
+ buffer = buffer.slice(newline + 1)
161
+ if (line) handleLine(line)
162
+ }
163
+ })
164
+ child.stderr?.setEncoding('utf8')
165
+ child.stderr?.on('data', (chunk: string) => {
166
+ stderr += chunk
167
+ })
168
+ child.on('error', (error) =>
169
+ finish({
170
+ sessionId: resolvedSession,
171
+ finalText,
172
+ costUsd,
173
+ numTurns,
174
+ tokens,
175
+ isError: true,
176
+ errorMessage: `failed to spawn ${bin}: ${error.message}`,
177
+ }),
178
+ )
179
+ child.on('close', (code) => {
180
+ const trailing = buffer.trim()
181
+ if (trailing) handleLine(trailing)
182
+ if (input.signal.aborted)
183
+ return finish({
184
+ sessionId: resolvedSession,
185
+ finalText,
186
+ costUsd,
187
+ numTurns,
188
+ tokens,
189
+ isError: true,
190
+ errorMessage: 'canceled',
191
+ })
192
+ if (code !== 0) {
193
+ isError = true
194
+ errorMessage =
195
+ errorMessage || `claude exited ${code}${stderr ? `: ${stderr.slice(-400)}` : ''}`
196
+ } else if (!sawResult) {
197
+ isError = true
198
+ errorMessage = 'claude exited without a result event'
199
+ }
200
+ finish({
201
+ sessionId: resolvedSession,
202
+ finalText,
203
+ costUsd,
204
+ numTurns,
205
+ tokens,
206
+ isError,
207
+ errorMessage,
208
+ resumeRejected:
209
+ !!input.sessionId &&
210
+ (isError || code !== 0) &&
211
+ RESUME_REJECTED.test(`${errorMessage ?? ''}\n${stderr}`),
212
+ })
213
+ })
214
+ })
215
+ }
@@ -0,0 +1,132 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ import type { HarnessLoadout } from '../../../../shared/types'
4
+ import type { HarnessHealth, HarnessLoadoutOptions } from '../adapter'
5
+
6
+ import { captureCommand, childEnvironment, terminateProcessTree } from '../process'
7
+ import { reconcileLoadedSkills } from '../skills'
8
+ import { buildClaudeProbeArgs } from './command'
9
+ import { scanClaudeSkills } from './skills'
10
+
11
+ interface InitProbe {
12
+ ok: boolean
13
+ detail?: string
14
+ init?: any
15
+ }
16
+
17
+ /** Probe the installed Claude binary and version. */
18
+ export async function probeClaudeHealth(bin: string, signal?: AbortSignal): Promise<HarnessHealth> {
19
+ const result = await captureCommand(bin, ['--version'], process.cwd(), { signal })
20
+ return {
21
+ ok: result.code === 0,
22
+ version: result.code === 0 ? result.stdout.trim() || undefined : undefined,
23
+ bin,
24
+ detail:
25
+ result.code === 0 ? undefined : result.stderr.trim() || `\`${bin}\` was not found on PATH`,
26
+ }
27
+ }
28
+
29
+ /** Inspect Claude's authoritative runtime initialization event. */
30
+ export async function loadClaudeConfiguration(
31
+ bin: string,
32
+ root: string,
33
+ options?: HarnessLoadoutOptions,
34
+ ): Promise<HarnessLoadout> {
35
+ const now = Date.now()
36
+ const probe = await probeClaudeInit(bin, root, options)
37
+ if (!probe.ok || !probe.init)
38
+ return {
39
+ ok: false,
40
+ detail: probe.detail,
41
+ tools: [],
42
+ mcpServers: [],
43
+ skills: [],
44
+ agents: [],
45
+ builtinCommandCount: 0,
46
+ probedAt: now,
47
+ }
48
+
49
+ const init = probe.init
50
+ const slash: string[] = Array.isArray(init.slash_commands) ? init.slash_commands : []
51
+ const installed = scanClaudeSkills(root)
52
+ const skillCommands = new Set(installed.map((skill) => skill.command))
53
+ return {
54
+ ok: true,
55
+ model: init.model,
56
+ nativeModel: options?.model ? undefined : init.model,
57
+ modelSource: options?.model ? 'studio' : 'runtime',
58
+ permissionMode: init.permissionMode,
59
+ apiKeySource: init.apiKeySource,
60
+ cwd: init.cwd,
61
+ tools: Array.isArray(init.tools) ? init.tools : [],
62
+ mcpServers: Array.isArray(init.mcp_servers)
63
+ ? init.mcp_servers.map((server: any) => ({
64
+ name: String(server?.name ?? ''),
65
+ status: String(server?.status ?? 'unknown'),
66
+ }))
67
+ : [],
68
+ skills: reconcileLoadedSkills(installed, slash),
69
+ agents: Array.isArray(init.agents) ? init.agents : [],
70
+ builtinCommandCount: slash.filter((command) => !skillCommands.has(command)).length,
71
+ probedAt: now,
72
+ source: 'runtime',
73
+ }
74
+ }
75
+
76
+ function probeClaudeInit(
77
+ bin: string,
78
+ root: string,
79
+ options?: HarnessLoadoutOptions,
80
+ ): Promise<InitProbe> {
81
+ return new Promise((resolve) => {
82
+ let child: ReturnType<typeof spawn>
83
+ try {
84
+ child = spawn(bin, buildClaudeProbeArgs(options?.model), {
85
+ cwd: root,
86
+ stdio: ['pipe', 'pipe', 'ignore'],
87
+ env: childEnvironment(options?.env),
88
+ detached: process.platform !== 'win32',
89
+ })
90
+ } catch (error) {
91
+ resolve({ ok: false, detail: `failed to spawn ${bin}: ${String(error)}` })
92
+ return
93
+ }
94
+ let done = false
95
+ const finish = (result: InitProbe) => {
96
+ if (done) return
97
+ done = true
98
+ clearTimeout(timer)
99
+ terminateProcessTree(child, 'SIGKILL')
100
+ resolve(result)
101
+ }
102
+ const timer = setTimeout(() => finish({ ok: false, detail: 'loadout probe timed out' }), 15_000)
103
+ child.on('error', (error) =>
104
+ finish({ ok: false, detail: `failed to spawn ${bin}: ${error.message}` }),
105
+ )
106
+ child.on('close', () => finish({ ok: false, detail: 'probe ended before an init event' }))
107
+ child.stdin?.write('.')
108
+ child.stdin?.end()
109
+
110
+ let buffer = ''
111
+ child.stdout?.setEncoding('utf8')
112
+ child.stdout?.on('data', (chunk: string) => {
113
+ buffer += chunk
114
+ let newline: number
115
+ while ((newline = buffer.indexOf('\n')) >= 0) {
116
+ const line = buffer.slice(0, newline).trim()
117
+ buffer = buffer.slice(newline + 1)
118
+ if (!line) continue
119
+ let event: any
120
+ try {
121
+ event = JSON.parse(line)
122
+ } catch {
123
+ continue
124
+ }
125
+ if (event.type === 'system' && event.subtype === 'init') {
126
+ finish({ ok: true, init: event })
127
+ return
128
+ }
129
+ }
130
+ })
131
+ })
132
+ }
@@ -0,0 +1,36 @@
1
+ import { afterEach, expect, test } from 'bun:test'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { writeClaudeMcpConfig } from './mcp'
7
+
8
+ const roots: string[] = []
9
+
10
+ afterEach(() => {
11
+ while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
12
+ })
13
+
14
+ test('serializes a structured MCP grant into Claude JSON', () => {
15
+ const root = mkdtempSync(join(tmpdir(), 'studio-claude-mcp-'))
16
+ roots.push(root)
17
+ const config = writeClaudeMcpConfig(root, [
18
+ {
19
+ name: 'domain-studio',
20
+ command: '/opt/bun',
21
+ args: ['/studio/bridge/stdio.ts', '--config', '/domain/bridge.json'],
22
+ env: { MODE: 'test value' },
23
+ },
24
+ ])
25
+ expect(JSON.parse(readFileSync(config.path!, 'utf8'))).toEqual({
26
+ mcpServers: {
27
+ 'domain-studio': {
28
+ command: '/opt/bun',
29
+ args: ['/studio/bridge/stdio.ts', '--config', '/domain/bridge.json'],
30
+ env: { MODE: 'test value' },
31
+ },
32
+ },
33
+ })
34
+ config.dispose()
35
+ expect(existsSync(config.path!)).toBe(false)
36
+ })
@@ -0,0 +1,41 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ import type { HarnessMcpServer } from '../adapter'
4
+
5
+ import { removeState, statePath, writeJson } from '../../../state/store'
6
+
7
+ export interface TemporaryMcpConfig {
8
+ path?: string
9
+ dispose(): void
10
+ }
11
+
12
+ /** Serialize Studio MCP grants into Claude Code's temporary JSON format. */
13
+ export function writeClaudeMcpConfig(
14
+ root: string,
15
+ servers: HarnessMcpServer[] | undefined,
16
+ ): TemporaryMcpConfig {
17
+ if (!servers?.length) return { dispose: () => {} }
18
+ const rel = `.cache/agent/claude-mcp-${randomUUID()}.json`
19
+ writeJson(root, rel, {
20
+ mcpServers: Object.fromEntries(
21
+ servers.map((server) => [
22
+ server.name,
23
+ {
24
+ command: server.command,
25
+ ...(server.args?.length ? { args: server.args } : {}),
26
+ ...(server.env && Object.keys(server.env).length ? { env: server.env } : {}),
27
+ },
28
+ ]),
29
+ ),
30
+ })
31
+ return {
32
+ path: statePath(root, rel),
33
+ dispose: () => {
34
+ try {
35
+ removeState(root, rel)
36
+ } catch {
37
+ /* best-effort temp cleanup */
38
+ }
39
+ },
40
+ }
41
+ }
@@ -0,0 +1,43 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ import type { LoadoutSkill } from '../../../../shared/types'
6
+
7
+ import { scanAncestors, scanSkillDir } from '../skills'
8
+
9
+ function installedPluginDirs(): { plugin: string; installPath: string }[] {
10
+ const file = join(homedir(), '.claude', 'plugins', 'installed_plugins.json')
11
+ let parsed: any
12
+ try {
13
+ parsed = JSON.parse(readFileSync(file, 'utf8'))
14
+ } catch {
15
+ return []
16
+ }
17
+ const out: { plugin: string; installPath: string }[] = []
18
+ const seen = new Set<string>()
19
+ for (const [key, entries] of Object.entries(parsed?.plugins ?? {})) {
20
+ const plugin = String(key).split('@')[0]
21
+ for (const entry of Array.isArray(entries) ? entries : []) {
22
+ const installPath = (entry as any)?.installPath
23
+ if (typeof installPath === 'string' && !seen.has(installPath)) {
24
+ seen.add(installPath)
25
+ out.push({ plugin, installPath })
26
+ }
27
+ }
28
+ }
29
+ return out
30
+ }
31
+
32
+ /** Discover the skills visible to Claude Code for one domain root. */
33
+ export function scanClaudeSkills(root: string): LoadoutSkill[] {
34
+ const out: LoadoutSkill[] = []
35
+ const seen = new Set<string>()
36
+ const home = homedir()
37
+ scanAncestors(root, ['.claude/skills', '.agents/skills'], out, seen)
38
+ scanSkillDir(join(home, '.claude', 'skills'), 'user', undefined, '', true, out, seen)
39
+ scanSkillDir(join(home, '.agents', 'skills'), 'user', undefined, '', true, out, seen)
40
+ for (const { plugin, installPath } of installedPluginDirs())
41
+ scanSkillDir(join(installPath, 'skills'), 'plugin', plugin, `${plugin}:`, true, out, seen)
42
+ return out
43
+ }