@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.
- package/README.md +3 -2
- package/dist/astrale.js +118 -95
- package/package.json +2 -3
- package/src/commands/studio.ts +13 -0
- package/src/lib/__tests__/view-assets.test.ts +114 -0
- package/src/lib/instance.ts +0 -11
- package/src/lib/login-flow.ts +4 -41
- package/src/lib/view/assets.ts +64 -0
- package/src/lib/view/server.ts +4 -37
- package/studio/client/dist/assets/index-BaqEuIQJ.js +109 -0
- package/studio/client/dist/assets/index-jRdExahh.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.test.ts +74 -0
- package/studio/server/agent/ask.ts +20 -22
- package/studio/server/agent/bridge/client.test.ts +49 -0
- package/studio/server/agent/bridge/client.ts +37 -0
- package/studio/server/agent/bridge/grant.test.ts +62 -0
- package/studio/server/agent/bridge/grant.ts +84 -0
- package/studio/server/agent/bridge/routes.test.ts +151 -0
- package/studio/server/agent/bridge/routes.ts +148 -0
- package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
- package/studio/server/agent/conversation.test.ts +84 -0
- package/studio/server/agent/conversation.ts +110 -0
- package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
- package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
- package/studio/server/agent/harness/claude/adapter.ts +71 -0
- package/studio/server/agent/harness/claude/ask.ts +114 -0
- package/studio/server/agent/harness/claude/capabilities.ts +27 -0
- package/studio/server/agent/harness/claude/command.ts +63 -0
- package/studio/server/agent/harness/claude/events.ts +215 -0
- package/studio/server/agent/harness/claude/loadout.ts +132 -0
- package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
- package/studio/server/agent/harness/claude/mcp.ts +41 -0
- package/studio/server/agent/harness/claude/skills.ts +43 -0
- package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
- package/studio/server/agent/harness/codex/adapter.ts +116 -0
- package/studio/server/agent/harness/codex/ask.test.ts +176 -0
- package/studio/server/agent/harness/codex/ask.ts +181 -0
- package/studio/server/agent/harness/codex/command.test.ts +69 -0
- package/studio/server/agent/harness/codex/command.ts +43 -0
- package/studio/server/agent/harness/codex/events.test.ts +94 -0
- package/studio/server/agent/harness/codex/events.ts +132 -0
- package/studio/server/agent/harness/codex/exec.ts +113 -0
- package/studio/server/agent/harness/codex/loadout.ts +63 -0
- package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
- package/studio/server/agent/harness/codex/mcp.ts +27 -0
- package/studio/server/agent/harness/codex/models.test.ts +167 -0
- package/studio/server/agent/harness/codex/models.ts +211 -0
- package/studio/server/agent/harness/codex/skills.ts +33 -0
- package/studio/server/agent/harness/gateway/config.test.ts +98 -0
- package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
- package/studio/server/agent/harness/gateway/token.test.ts +133 -0
- package/studio/server/agent/harness/gateway/token.ts +166 -0
- package/studio/server/agent/harness/mock/adapter.ts +190 -0
- package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
- package/studio/server/agent/harness/process.test.ts +46 -0
- package/studio/server/agent/harness/process.ts +104 -0
- package/studio/server/agent/harness/registry.ts +37 -0
- package/studio/server/agent/harness/selection.test.ts +75 -0
- package/studio/server/agent/harness/selection.ts +77 -0
- package/studio/server/agent/harness/skills.test.ts +95 -0
- package/studio/server/agent/harness/skills.ts +100 -0
- package/studio/server/agent/layout.test.ts +95 -0
- package/studio/server/agent/notify.ts +12 -0
- package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
- package/studio/server/agent/prompts/ask.ts +34 -0
- package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
- package/studio/server/agent/prompts/turn.ts +57 -0
- package/studio/server/agent/routes.test.ts +176 -0
- package/studio/server/agent/routes.ts +214 -0
- package/studio/server/agent/run/completion.ts +193 -0
- package/studio/server/agent/run/coordinator.test.ts +337 -0
- package/studio/server/agent/run/coordinator.ts +104 -0
- package/studio/server/agent/run/live-state.ts +73 -0
- package/studio/server/agent/run/preparation.ts +162 -0
- package/studio/server/agent/run/transcript.test.ts +47 -0
- package/studio/server/agent/run/transcript.ts +30 -0
- package/studio/server/agent/run/usage.test.ts +46 -0
- package/studio/server/{state → agent/run}/usage.ts +4 -4
- package/studio/server/agent/stream.ts +37 -0
- package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
- package/studio/server/api-agent-loadout.test.ts +69 -0
- package/studio/server/api.ts +3 -155
- package/studio/server/index.ts +6 -5
- package/studio/server/state/comments.test.ts +29 -0
- package/studio/server/state/comments.ts +3 -3
- package/studio/server/state/settings.test.ts +78 -0
- package/studio/server/state/settings.ts +28 -1
- package/studio/shared/agent-effort.test.ts +12 -0
- package/studio/shared/agent-effort.ts +15 -0
- package/studio/shared/agent-models.test.ts +9 -0
- package/studio/shared/agent-models.ts +14 -0
- package/studio/shared/settings-values.test.ts +18 -0
- package/studio/shared/settings-values.ts +20 -0
- package/studio/shared/types.ts +58 -7
- package/src/connect-core.test.ts +0 -42
- package/src/connect-core.ts +0 -53
- package/studio/client/dist/assets/index-DAC1a9vW.js +0 -109
- package/studio/client/dist/assets/index-huaFafBC.css +0 -1
- package/studio/server/agent/bridge.ts +0 -188
- package/studio/server/agent/claude.ts +0 -678
- package/studio/server/agent/mock.ts +0 -186
- package/studio/server/agent/registry.ts +0 -29
- package/studio/server/agent/runner.ts +0 -487
- 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
|
+
}
|