@tanstack/ai-claude-code 0.1.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.
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Claude Code workspace projector — the reference implementation the other
3
+ * harness projectors (codex, opencode) mirror.
4
+ *
5
+ * `withSandbox` surfaces a portable `WorkspaceProjection` (skills, plugins, a
6
+ * secret resolver, and a one-time marker path) via a capability. Each harness
7
+ * adapter reads it in its `chatStream` setup and projects those inputs into the
8
+ * CLI's native format. For Claude Code that means:
9
+ *
10
+ * - MCP servers → a project-scoped `.mcp.json` at the workspace root.
11
+ * - gitSkill repos → linked under `.claude/skills/<basename>`.
12
+ * - agentSkill → no reliable claude primitive pulls a public skill by
13
+ * bare name, so we warn and skip rather than invent one.
14
+ * - plugins → `claude plugin install <name>` (best-effort).
15
+ *
16
+ * The secret-bearing `.mcp.json` is (re)written on EVERY call, re-resolving
17
+ * secrets each time, so claude always reads current values and a snapshot can
18
+ * never serve a stale or rotated secret. Only the safe, idempotent, non-secret
19
+ * operations (gitSkill links, plugin installs, agentSkill handling) are guarded
20
+ * by a one-time marker file under the workspace.
21
+ *
22
+ * External-convention caveat: the `.mcp.json` location/shape, the skills dir,
23
+ * and the plugin-install command are verified against the installed `claude`
24
+ * CLI. Where claude has no clean primitive (agentSkill by bare name) we no-op
25
+ * with a warning instead of fabricating a command.
26
+ */
27
+ import { isSecretRef, resolveGitSkillDir } from '@tanstack/ai-sandbox'
28
+ import type {
29
+ BearerRef,
30
+ SandboxHandle,
31
+ SecretRef,
32
+ WorkspaceProjection,
33
+ WorkspaceSkill,
34
+ } from '@tanstack/ai-sandbox'
35
+
36
+ /** POSIX single-quote escape for embedding a value in a shell command. */
37
+ function shellQuote(value: string): string {
38
+ return `'${value.replace(/'/g, `'\\''`)}'`
39
+ }
40
+
41
+ /** Last path segment of a `gitSkill` clone dir, used as the skills-dir name. */
42
+ function basenameOf(path: string): string {
43
+ const segments = path.split('/').filter((segment) => segment !== '')
44
+ return segments[segments.length - 1] ?? path
45
+ }
46
+
47
+ /** True when `value` is a `bearer(ref)` marker created by `@tanstack/ai-sandbox`. */
48
+ function isBearerMarker(value: unknown): value is BearerRef {
49
+ return (
50
+ typeof value === 'object' &&
51
+ value !== null &&
52
+ isSecretRef((value as { __bearerRef?: unknown }).__bearerRef)
53
+ )
54
+ }
55
+
56
+ /**
57
+ * Resolve a single MCP header value: a `SecretRef` resolves to its plaintext, a
58
+ * `bearer(ref)` marker resolves to `Bearer <plaintext>`, and a plain string is
59
+ * passed through unchanged.
60
+ */
61
+ function resolveHeaderValue(
62
+ value: string | SecretRef | BearerRef,
63
+ resolveSecret: (ref: SecretRef) => string,
64
+ ): string {
65
+ if (isSecretRef(value)) return resolveSecret(value)
66
+ if (isBearerMarker(value)) return `Bearer ${resolveSecret(value.__bearerRef)}`
67
+ return value
68
+ }
69
+
70
+ /** A claude-format HTTP MCP server entry. */
71
+ interface ClaudeMcpServer {
72
+ type: 'http'
73
+ url: string
74
+ headers: Record<string, string>
75
+ }
76
+
77
+ /**
78
+ * Build claude's project-scoped MCP config from the `{ kind: 'mcp' }` skills,
79
+ * resolving every header value (SecretRef / bearer / string). Returns
80
+ * `undefined` when there are no MCP skills so the caller can skip the write.
81
+ */
82
+ function buildMcpConfig(
83
+ skills: Array<WorkspaceSkill>,
84
+ resolveSecret: (ref: SecretRef) => string,
85
+ ): { mcpServers: Record<string, ClaudeMcpServer> } | undefined {
86
+ const mcpServers: Record<string, ClaudeMcpServer> = {}
87
+ let count = 0
88
+ for (const skill of skills) {
89
+ if (skill.kind !== 'mcp') continue
90
+ count += 1
91
+ const headers: Record<string, string> = {}
92
+ const rawHeaders = skill.config.headers ?? {}
93
+ for (const [name, value] of Object.entries(rawHeaders)) {
94
+ headers[name] = resolveHeaderValue(value, resolveSecret)
95
+ }
96
+ const rawUrl = skill.config['url']
97
+ const url = typeof rawUrl === 'string' ? rawUrl : ''
98
+ mcpServers[skill.name] = { type: 'http', url, headers }
99
+ }
100
+ return count > 0 ? { mcpServers } : undefined
101
+ }
102
+
103
+ /**
104
+ * Write the project-scoped `.mcp.json`, re-resolving every secret. This runs on
105
+ * EVERY projection call (never gated by the marker) so claude always reads the
106
+ * current secret values and a snapshot can never serve a stale or rotated one.
107
+ * With `snapshot:'after-run'` the file may still be captured in the image, so
108
+ * secret-bearing MCP material is best used with the default `after-setup`
109
+ * strategy. When there are no MCP skills the write is skipped.
110
+ */
111
+ async function projectMcpServers(
112
+ handle: SandboxHandle,
113
+ projection: WorkspaceProjection,
114
+ ): Promise<void> {
115
+ const config = buildMcpConfig(projection.skills, projection.resolveSecret)
116
+ if (config === undefined) return
117
+ const target = `${projection.root}/.mcp.json`
118
+ await handle.fs.write(target, JSON.stringify(config, null, 2))
119
+ }
120
+
121
+ /**
122
+ * Ensure each cloned `gitSkill` repo is available under claude's project skills
123
+ * dir (`<root>/.claude/skills/<basename>`) via a symlink, falling back to a
124
+ * recursive copy on platforms without `ln -s`.
125
+ */
126
+ async function projectGitSkills(
127
+ handle: SandboxHandle,
128
+ projection: WorkspaceProjection,
129
+ ): Promise<void> {
130
+ const skillsDir = `${projection.root}/.claude/skills`
131
+ let madeDir = false
132
+ for (const skill of projection.skills) {
133
+ if (skill.kind !== 'git') continue
134
+ if (!madeDir) {
135
+ await handle.fs.mkdir(skillsDir)
136
+ madeDir = true
137
+ }
138
+ const source = skill.into ?? resolveGitSkillDir(projection.root, skill)
139
+ const target = `${skillsDir}/${basenameOf(source)}`
140
+ const lnCmd = `ln -s ${shellQuote(source)} ${shellQuote(target)}`
141
+ const result = await handle.process.exec(lnCmd, { cwd: projection.root })
142
+ if (result.exitCode !== 0) {
143
+ const cpCmd = `cp -r ${shellQuote(source)} ${shellQuote(target)}`
144
+ const copied = await handle.process.exec(cpCmd, { cwd: projection.root })
145
+ if (copied.exitCode !== 0) {
146
+ console.warn(
147
+ `[claude-code] failed to link gitSkill "${skill.repo}" into ${target}: ${copied.stderr.trim()}`,
148
+ )
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * `agentSkill` references a public skill by bare name. Claude Code has no
156
+ * primitive to fetch a skill from a bare name (skills resolve from local
157
+ * `.claude/skills/` dirs and plugin marketplaces), so we warn and skip rather
158
+ * than fabricate a command.
159
+ */
160
+ function projectAgentSkills(projection: WorkspaceProjection): void {
161
+ for (const skill of projection.skills) {
162
+ if (skill.kind !== 'agent-skill') continue
163
+ console.warn(
164
+ `[claude-code] agentSkill "${skill.name}" cannot be projected: Claude Code has ` +
165
+ 'no command to install a public skill by bare name. Provide it as a gitSkill ' +
166
+ 'or a plugin instead. Skipping.',
167
+ )
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Install each declared plugin via `claude plugin install <name>`. Plugin
173
+ * installs are best-effort: a failure (no marketplace, network, …) warns but
174
+ * never throws, so a missing plugin can't break the run.
175
+ */
176
+ async function projectPlugins(
177
+ handle: SandboxHandle,
178
+ projection: WorkspaceProjection,
179
+ ): Promise<void> {
180
+ for (const name of projection.plugins) {
181
+ const cmd = `claude plugin install ${shellQuote(name)}`
182
+ try {
183
+ const result = await handle.process.exec(cmd, { cwd: projection.root })
184
+ if (result.exitCode !== 0) {
185
+ console.warn(
186
+ `[claude-code] "claude plugin install ${name}" exited ${result.exitCode}: ${result.stderr.trim()}`,
187
+ )
188
+ }
189
+ } catch (error: unknown) {
190
+ const message = error instanceof Error ? error.message : String(error)
191
+ console.warn(
192
+ `[claude-code] failed to install plugin "${name}": ${message}`,
193
+ )
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Project a `WorkspaceProjection` into the Claude Code sandbox. Safe to call on
200
+ * every `chatStream`. The secret-bearing `.mcp.json` is (re)written on every
201
+ * call, re-resolving secrets, so claude always reads current values and a
202
+ * snapshot can never serve a stale or rotated secret. The safe, idempotent,
203
+ * non-secret operations (gitSkill links, plugin installs, agentSkill handling)
204
+ * are guarded by a one-time marker so they run only on the first call after
205
+ * create/restore.
206
+ *
207
+ * @param handle - The sandbox handle (`fs` + `process`).
208
+ * @param projection - The portable workspace inputs from `withSandbox`.
209
+ */
210
+ export async function projectClaudeWorkspace(
211
+ handle: SandboxHandle,
212
+ projection: WorkspaceProjection,
213
+ ): Promise<void> {
214
+ // Always re-resolve and rewrite the secret-bearing MCP config so rotated
215
+ // secrets re-apply and snapshots can't serve stale values.
216
+ await projectMcpServers(handle, projection)
217
+
218
+ // Gate only the safe, idempotent, non-secret operations on the marker.
219
+ if (await handle.fs.exists(projection.markerPath)) return
220
+
221
+ await projectGitSkills(handle, projection)
222
+ projectAgentSkills(projection)
223
+ await projectPlugins(handle, projection)
224
+
225
+ await handle.fs.write(projection.markerPath, '')
226
+ }