@agentstep/agent-sdk 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.
Files changed (105) hide show
  1. package/package.json +45 -0
  2. package/src/auth/middleware.ts +38 -0
  3. package/src/backends/claude/args.ts +88 -0
  4. package/src/backends/claude/index.ts +193 -0
  5. package/src/backends/claude/permission-hook.ts +152 -0
  6. package/src/backends/claude/tool-bridge.ts +211 -0
  7. package/src/backends/claude/translator.ts +209 -0
  8. package/src/backends/claude/wrapper-script.ts +45 -0
  9. package/src/backends/codex/args.ts +69 -0
  10. package/src/backends/codex/auth.ts +35 -0
  11. package/src/backends/codex/index.ts +57 -0
  12. package/src/backends/codex/setup.ts +37 -0
  13. package/src/backends/codex/translator.ts +223 -0
  14. package/src/backends/codex/wrapper-script.ts +26 -0
  15. package/src/backends/factory/args.ts +45 -0
  16. package/src/backends/factory/auth.ts +30 -0
  17. package/src/backends/factory/index.ts +56 -0
  18. package/src/backends/factory/setup.ts +34 -0
  19. package/src/backends/factory/translator.ts +139 -0
  20. package/src/backends/factory/wrapper-script.ts +33 -0
  21. package/src/backends/gemini/args.ts +44 -0
  22. package/src/backends/gemini/auth.ts +30 -0
  23. package/src/backends/gemini/index.ts +53 -0
  24. package/src/backends/gemini/setup.ts +34 -0
  25. package/src/backends/gemini/translator.ts +139 -0
  26. package/src/backends/gemini/wrapper-script.ts +26 -0
  27. package/src/backends/opencode/args.ts +53 -0
  28. package/src/backends/opencode/auth.ts +53 -0
  29. package/src/backends/opencode/index.ts +70 -0
  30. package/src/backends/opencode/mcp.ts +67 -0
  31. package/src/backends/opencode/setup.ts +54 -0
  32. package/src/backends/opencode/translator.ts +168 -0
  33. package/src/backends/opencode/wrapper-script.ts +46 -0
  34. package/src/backends/registry.ts +38 -0
  35. package/src/backends/shared/ndjson.ts +29 -0
  36. package/src/backends/shared/translator-types.ts +69 -0
  37. package/src/backends/shared/wrap-prompt.ts +17 -0
  38. package/src/backends/types.ts +85 -0
  39. package/src/config/index.ts +95 -0
  40. package/src/db/agents.ts +185 -0
  41. package/src/db/api_keys.ts +78 -0
  42. package/src/db/batch.ts +142 -0
  43. package/src/db/client.ts +81 -0
  44. package/src/db/environments.ts +127 -0
  45. package/src/db/events.ts +208 -0
  46. package/src/db/memory.ts +143 -0
  47. package/src/db/migrations.ts +295 -0
  48. package/src/db/proxy.ts +37 -0
  49. package/src/db/sessions.ts +295 -0
  50. package/src/db/vaults.ts +110 -0
  51. package/src/errors.ts +53 -0
  52. package/src/handlers/agents.ts +194 -0
  53. package/src/handlers/batch.ts +41 -0
  54. package/src/handlers/docs.ts +87 -0
  55. package/src/handlers/environments.ts +154 -0
  56. package/src/handlers/events.ts +234 -0
  57. package/src/handlers/index.ts +12 -0
  58. package/src/handlers/memory.ts +141 -0
  59. package/src/handlers/openapi.ts +14 -0
  60. package/src/handlers/sessions.ts +223 -0
  61. package/src/handlers/stream.ts +76 -0
  62. package/src/handlers/threads.ts +26 -0
  63. package/src/handlers/ui/app.js +984 -0
  64. package/src/handlers/ui/index.html +112 -0
  65. package/src/handlers/ui/style.css +164 -0
  66. package/src/handlers/ui.ts +1281 -0
  67. package/src/handlers/vaults.ts +99 -0
  68. package/src/http.ts +35 -0
  69. package/src/index.ts +104 -0
  70. package/src/init.ts +227 -0
  71. package/src/openapi/registry.ts +8 -0
  72. package/src/openapi/schemas.ts +625 -0
  73. package/src/openapi/spec.ts +691 -0
  74. package/src/providers/apple.ts +220 -0
  75. package/src/providers/daytona.ts +217 -0
  76. package/src/providers/docker.ts +264 -0
  77. package/src/providers/e2b.ts +203 -0
  78. package/src/providers/fly.ts +276 -0
  79. package/src/providers/modal.ts +222 -0
  80. package/src/providers/podman.ts +206 -0
  81. package/src/providers/registry.ts +28 -0
  82. package/src/providers/shared.ts +11 -0
  83. package/src/providers/sprites.ts +55 -0
  84. package/src/providers/types.ts +73 -0
  85. package/src/providers/vercel.ts +208 -0
  86. package/src/proxy/forward.ts +111 -0
  87. package/src/queue/index.ts +111 -0
  88. package/src/sessions/actor.ts +53 -0
  89. package/src/sessions/bus.ts +155 -0
  90. package/src/sessions/driver.ts +818 -0
  91. package/src/sessions/grader.ts +120 -0
  92. package/src/sessions/interrupt.ts +14 -0
  93. package/src/sessions/sweeper.ts +136 -0
  94. package/src/sessions/threads.ts +126 -0
  95. package/src/sessions/tools.ts +50 -0
  96. package/src/shutdown.ts +78 -0
  97. package/src/sprite/client.ts +294 -0
  98. package/src/sprite/exec.ts +161 -0
  99. package/src/sprite/lifecycle.ts +339 -0
  100. package/src/sprite/pool.ts +65 -0
  101. package/src/sprite/setup.ts +159 -0
  102. package/src/state.ts +61 -0
  103. package/src/types.ts +339 -0
  104. package/src/util/clock.ts +7 -0
  105. package/src/util/ids.ts +11 -0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Gemini backend: drives Google's `gemini -p` on sprites.dev containers.
3
+ *
4
+ * Gemini CLI uses `-p` for prompt mode (like claude) and accepts the prompt
5
+ * on stdin. The wrapper script reads env vars then pipes remaining stdin to
6
+ * gemini.
7
+ *
8
+ * Custom tool re-entry is NOT supported by gemini — gemini has no equivalent
9
+ * of claude's --input-format stream-json. buildTurn rejects
10
+ * toolResults.length > 0 with an invalid_request_error.
11
+ */
12
+ import { ApiError } from "../../errors";
13
+ import type { Backend, BuildTurnInput, BuildTurnResult } from "../types";
14
+ import type { TranslatorOptions } from "../shared/translator-types";
15
+ import { wrapPromptWithSystem } from "../shared/wrap-prompt";
16
+ import { buildGeminiArgs } from "./args";
17
+ import { buildGeminiAuthEnv, validateGeminiRuntime } from "./auth";
18
+ import { createGeminiTranslator } from "./translator";
19
+ import { GEMINI_WRAPPER_PATH } from "./wrapper-script";
20
+ import { prepareGeminiOnSprite } from "./setup";
21
+
22
+ function buildTurn(input: BuildTurnInput): BuildTurnResult {
23
+ const { agent, backendSessionId, promptText, toolResults } = input;
24
+ if (toolResults.length > 0) {
25
+ throw new ApiError(
26
+ 400,
27
+ "invalid_request_error",
28
+ "gemini backend does not support user.custom_tool_result re-entry in v1",
29
+ );
30
+ }
31
+ const argv = buildGeminiArgs({ agent, backendSessionId });
32
+ const env = buildGeminiAuthEnv();
33
+ const wrappedPrompt = wrapPromptWithSystem(promptText, agent.system);
34
+ return { argv, env, stdin: wrappedPrompt };
35
+ }
36
+
37
+ export const geminiBackend: Backend = {
38
+ name: "gemini" as Backend["name"],
39
+ wrapperPath: GEMINI_WRAPPER_PATH,
40
+ buildTurn,
41
+ createTranslator: (opts: TranslatorOptions) => createGeminiTranslator(opts),
42
+ prepareOnSprite: (name, provider) => prepareGeminiOnSprite(name, provider),
43
+
44
+ validateRuntime: validateGeminiRuntime,
45
+ };
46
+
47
+ export {
48
+ buildGeminiArgs,
49
+ buildGeminiAuthEnv,
50
+ createGeminiTranslator,
51
+ prepareGeminiOnSprite,
52
+ GEMINI_WRAPPER_PATH,
53
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Install gemini CLI on a freshly-created sprite.
3
+ *
4
+ * Mirrors codex/setup.ts with the same sentinel + symlink pattern.
5
+ * Gemini CLI is installed via npm from the @google/gemini-cli package.
6
+ */
7
+ import type { ContainerProvider } from "../../providers/types";
8
+ import { installGeminiWrapper } from "./wrapper-script";
9
+
10
+ const SENTINEL_NAME = ".claude-agents-gemini-installed";
11
+
12
+ export async function prepareGeminiOnSprite(spriteName: string, provider: ContainerProvider): Promise<void> {
13
+ await installGeminiWrapper(spriteName, provider);
14
+
15
+ const script = [
16
+ "set -euo pipefail",
17
+ `SENTINEL="$HOME/${SENTINEL_NAME}"`,
18
+ 'if [ -f "$SENTINEL" ]; then exit 0; fi',
19
+ "npm install -g @google/gemini-cli",
20
+ "PREFIX=$(npm config get prefix)",
21
+ 'if [ "$PREFIX" != "/usr/local" ]; then ln -sf "$PREFIX/bin/gemini" /usr/local/bin/gemini; fi',
22
+ '/usr/local/bin/gemini --version || $PREFIX/bin/gemini --version',
23
+ 'touch "$SENTINEL"',
24
+ ].join(" && ");
25
+
26
+ const result = await provider.exec(spriteName, ["bash", "-c", script], {
27
+ timeoutMs: 5 * 60_000,
28
+ });
29
+ if (result.exit_code !== 0) {
30
+ throw new Error(
31
+ `gemini install failed (${result.exit_code}): ${result.stderr.slice(0, 500)}`,
32
+ );
33
+ }
34
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Stateful translator: Gemini CLI stream-json NDJSON -> Managed Agents events.
3
+ *
4
+ * Gemini CLI event model (emitted by `gemini -p --output-format stream-json`):
5
+ * - `init` — session init with `session_id` and `model`
6
+ * - `message` — assistant text message (role: "assistant", content: string)
7
+ * - `tool_use` — tool invocation (tool_name, tool_id, parameters)
8
+ * - `tool_result` — tool output (tool_id, output, is_error?)
9
+ * - `result` — end of turn with status and usage stats
10
+ *
11
+ * Maps to Managed Agents events using the same Translator interface as other
12
+ * backends.
13
+ */
14
+ import type {
15
+ ToolClass,
16
+ TranslatedEvent,
17
+ Translator,
18
+ TranslatorOptions,
19
+ TurnResult,
20
+ } from "../shared/translator-types";
21
+
22
+ export function createGeminiTranslator(opts: TranslatorOptions): Translator {
23
+ const toolClass = new Map<string, ToolClass>();
24
+ let sessionId: string | null = null;
25
+ let lastText = "";
26
+ let sawCustom = false;
27
+
28
+ // Usage from the result event
29
+ let inputTokens = 0;
30
+ let outputTokens = 0;
31
+ let costUsd = 0;
32
+ let numTurns = 0;
33
+ let sawResult = false;
34
+
35
+ function classify(name: string): ToolClass {
36
+ if (opts.customToolNames.has(name)) return "custom";
37
+ return "builtin";
38
+ }
39
+
40
+ function translate(raw: Record<string, unknown>): TranslatedEvent[] {
41
+ const out: TranslatedEvent[] = [];
42
+ if (!raw || typeof raw !== "object") return out;
43
+ const type = String(raw.type ?? "");
44
+
45
+ if (type === "init") {
46
+ if (typeof raw.session_id === "string") sessionId = raw.session_id;
47
+ return out;
48
+ }
49
+
50
+ if (type === "message" && raw.role === "assistant") {
51
+ const content = typeof raw.content === "string" ? raw.content : "";
52
+ if (content) {
53
+ lastText = content;
54
+ out.push({
55
+ type: "agent.message",
56
+ payload: { content: [{ type: "text", text: content }] },
57
+ });
58
+ }
59
+ return out;
60
+ }
61
+
62
+ if (type === "tool_use") {
63
+ const toolName = String(raw.tool_name ?? "unknown");
64
+ const toolId = String(raw.tool_id ?? "");
65
+ const parameters = (raw.parameters ?? {}) as Record<string, unknown>;
66
+
67
+ const cls = classify(toolName);
68
+ toolClass.set(toolId, cls);
69
+ if (cls === "custom") sawCustom = true;
70
+
71
+ const useType = cls === "custom" ? "agent.custom_tool_use" : "agent.tool_use";
72
+ out.push({
73
+ type: useType,
74
+ payload: {
75
+ tool_use_id: toolId,
76
+ name: toolName,
77
+ input: parameters,
78
+ },
79
+ });
80
+ return out;
81
+ }
82
+
83
+ if (type === "tool_result") {
84
+ const toolId = String(raw.tool_id ?? "");
85
+ const output = typeof raw.output === "string" ? raw.output : JSON.stringify(raw.output ?? "");
86
+ const isError = raw.is_error === true;
87
+ const cls = toolClass.get(toolId);
88
+
89
+ // Only emit tool_result for builtin tools — custom tools are handled
90
+ // by the client via user.custom_tool_result
91
+ if (cls !== "custom") {
92
+ out.push({
93
+ type: "agent.tool_result",
94
+ payload: {
95
+ tool_use_id: toolId,
96
+ content: output,
97
+ is_error: isError,
98
+ },
99
+ });
100
+ }
101
+ return out;
102
+ }
103
+
104
+ if (type === "result") {
105
+ sawResult = true;
106
+ const stats = (raw.stats ?? {}) as Record<string, unknown>;
107
+ if (typeof stats.input_tokens === "number") inputTokens = stats.input_tokens;
108
+ if (typeof stats.output_tokens === "number") outputTokens = stats.output_tokens;
109
+ if (typeof stats.cost_usd === "number") costUsd = stats.cost_usd;
110
+ if (typeof stats.num_turns === "number") numTurns = stats.num_turns;
111
+ return out;
112
+ }
113
+
114
+ // Unknown event type — drop silently, translator is forward-compatible.
115
+ return out;
116
+ }
117
+
118
+ function getTurnResult(): TurnResult | null {
119
+ if (!sawResult && !lastText) return null;
120
+ return {
121
+ stopReason: sawCustom ? "custom_tool_call" : "end_turn",
122
+ usage: {
123
+ input_tokens: inputTokens,
124
+ output_tokens: outputTokens,
125
+ cache_read_input_tokens: 0,
126
+ cache_creation_input_tokens: 0,
127
+ cost_usd: costUsd,
128
+ },
129
+ num_turns: numTurns || 1,
130
+ };
131
+ }
132
+
133
+ return {
134
+ translate,
135
+ getBackendSessionId: () => sessionId,
136
+ getTurnResult,
137
+ sawCustomToolUse: () => sawCustom,
138
+ };
139
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Sprite wrapper script for gemini.
3
+ *
4
+ * Same structure as the claude/codex wrapper: gemini accepts the prompt on
5
+ * stdin via `-p`, so the wrapper reads env vars from stdin until a blank
6
+ * line, then execs gemini with the remaining stdin piped through as the
7
+ * prompt.
8
+ */
9
+ import type { ContainerProvider } from "../../providers/types";
10
+
11
+ export const GEMINI_WRAPPER_PATH = "/tmp/.gemini-wrapper";
12
+
13
+ const SPRITE_WRAPPER_SCRIPT = [
14
+ "#!/bin/bash",
15
+ 'while IFS= read -r line; do [ -z "$line" ] && break; export "$line"; done',
16
+ 'exec gemini "$@"',
17
+ ].join("\n");
18
+
19
+ export async function installGeminiWrapper(spriteName: string, provider: ContainerProvider): Promise<void> {
20
+ const escaped = SPRITE_WRAPPER_SCRIPT.replace(/'/g, "'\\''");
21
+ await provider.exec(spriteName, [
22
+ "bash",
23
+ "-c",
24
+ `printf '%s' '${escaped}' > ${GEMINI_WRAPPER_PATH} && chmod +x ${GEMINI_WRAPPER_PATH}`,
25
+ ]);
26
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Build the `opencode run` argv for one turn.
3
+ *
4
+ * Ported from
5
+ *
6
+ *
7
+ * Opencode-specific constraints:
8
+ * - No --max-turns (opencode has no equivalent; silently ignored)
9
+ * - No --allowed-tools / --disallowed-tools (tools are configured via
10
+ * ~/.opencode.json permissions, not per-agent CLI flags — agents with
11
+ * a non-empty `tools` field are rejected at create time)
12
+ * - No --permission-mode (opencode `run` is non-interactive by design)
13
+ * - No --system-prompt flag — system prompt is wrapped into the user
14
+ * prompt text via `wrapOpencodePrompt`
15
+ * - No --mcp-config flag — MCP config is delivered via the
16
+ * OPENCODE_CONFIG_CONTENT env var (see mcp.ts)
17
+ */
18
+ import type { Agent } from "../../types";
19
+
20
+ export interface BuildOpencodeArgsInput {
21
+ agent: Agent;
22
+ /** Prior turn's opencode sessionID, if any, for --session resume */
23
+ backendSessionId: string | null;
24
+ }
25
+
26
+ export function buildOpencodeArgs(input: BuildOpencodeArgsInput): string[] {
27
+ const args = ["run", "--format", "json"];
28
+ if (input.backendSessionId) {
29
+ args.push("--session", input.backendSessionId);
30
+ }
31
+ if (input.agent.model) {
32
+ args.push("--model", input.agent.model);
33
+ }
34
+ return args;
35
+ }
36
+
37
+ /**
38
+ * Wrap the user prompt with an optional system prompt prefix.
39
+ *
40
+ * Opencode's `run` subcommand has no `--system-prompt` flag, so
41
+ * prepends the system prompt to the user message with a
42
+ * separator, exactly as ported here.
43
+ *
44
+ * Verbatim from
45
+ *
46
+ */
47
+ export function wrapOpencodePrompt(
48
+ prompt: string,
49
+ systemPrompt: string | null | undefined,
50
+ ): string {
51
+ if (!systemPrompt) return prompt;
52
+ return `Instructions: ${systemPrompt}\n\n---\n\n${prompt}`;
53
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Auth env + create-time validation for the opencode backend.
3
+ *
4
+ * Opencode is multi-provider: it reads `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
5
+ * and other provider-specific env vars at startup and routes model calls
6
+ * based on the `provider/model` prefix in the `--model` flag. We forward
7
+ * every provider key we know about; opencode picks the right one based on
8
+ * the agent's model.
9
+ *
10
+ * Opencode refuses `sk-ant-oat*` OAuth tokens per Anthropic ToS. See
11
+ *
12
+ * — opencode provider only forwards `ANTHROPIC_API_KEY`
13
+ * and warns on OAuth tokens. We enforce the "at least one provider key"
14
+ * invariant at agent create time (and belt-and-braces at first turn) so
15
+ * the user doesn't discover the mismatch inside an opaque opencode error
16
+ * stream.
17
+ *
18
+ * Spike (2026-04-10) verified on a real sprite that opencode picks
19
+ * up `OPENAI_API_KEY` as an env var without any config file.
20
+ */
21
+ import { getConfig } from "../../config";
22
+
23
+ export function buildOpencodeAuthEnv(): Record<string, string> {
24
+ const cfg = getConfig();
25
+ const env: Record<string, string> = {};
26
+ if (cfg.anthropicApiKey) {
27
+ env.ANTHROPIC_API_KEY = cfg.anthropicApiKey;
28
+ }
29
+ if (cfg.openAiApiKey) {
30
+ env.OPENAI_API_KEY = cfg.openAiApiKey;
31
+ }
32
+ if (cfg.claudeToken && !cfg.anthropicApiKey) {
33
+ console.warn(
34
+ "[opencode] CLAUDE_CODE_OAUTH_TOKEN cannot drive opencode — set ANTHROPIC_API_KEY",
35
+ );
36
+ }
37
+ return env;
38
+ }
39
+
40
+ /**
41
+ * Returns null if opencode can run with the current config, or an error
42
+ * message if it can't. Used both at agent create time (`validateAgentCreation`
43
+ * hook) and at first-turn time (`validateRuntime` hook).
44
+ *
45
+ * Opencode is multi-provider so we accept either a valid Anthropic key or
46
+ * an OpenAI key. The agent's `model` field is still `provider/model` shaped
47
+ * (e.g. `openai/gpt-4o-mini`) and opencode routes based on the prefix.
48
+ */
49
+ export function validateOpencodeRuntime(): string | null {
50
+ const cfg = getConfig();
51
+ if (cfg.anthropicApiKey || cfg.openAiApiKey) return null;
52
+ return "opencode backend requires at least one provider key: ANTHROPIC_API_KEY or OPENAI_API_KEY (opencode does not accept sk-ant-oat OAuth tokens per Anthropic ToS)";
53
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Opencode backend: drives sst/opencode-ai's `opencode run` on sprites.dev
3
+ * containers.
4
+ *
5
+ * Ported from
6
+ *
7
+ * (the opencode provider), adapted for our sprite-only
8
+ * execution model. Opencompletions only ran opencode in its local backend;
9
+ * this adapter is the first time opencode runs inside a sprites.dev sprite,
10
+ * so the wrapper script + install flow are new (see wrapper-script.ts and
11
+ * setup.ts).
12
+ *
13
+ * Custom tool re-entry (the stream-json user frame path claude uses) is NOT
14
+ * supported by opencode — `opencode run` has no equivalent input format.
15
+ * buildTurn rejects `toolResults.length > 0` with an invalid_request_error.
16
+ */
17
+ import { ApiError } from "../../errors";
18
+ import type { Backend, BuildTurnInput, BuildTurnResult } from "../types";
19
+ import type { TranslatorOptions } from "../shared/translator-types";
20
+ import { wrapPromptWithSystem } from "../shared/wrap-prompt";
21
+ import { buildOpencodeArgs } from "./args";
22
+ import { buildOpencodeAuthEnv, validateOpencodeRuntime } from "./auth";
23
+ import { buildOpencodeMcpEnv } from "./mcp";
24
+ import { createOpencodeTranslator } from "./translator";
25
+ import { OPENCODE_WRAPPER_PATH, installOpencodeWrapper } from "./wrapper-script";
26
+ import { prepareOpencodeOnSprite } from "./setup";
27
+
28
+ function buildTurn(input: BuildTurnInput): BuildTurnResult {
29
+ const { agent, backendSessionId, promptText, toolResults } = input;
30
+
31
+ if (toolResults.length > 0) {
32
+ throw new ApiError(
33
+ 400,
34
+ "invalid_request_error",
35
+ "opencode backend does not support user.custom_tool_result re-entry in v1",
36
+ );
37
+ }
38
+
39
+ const argv = buildOpencodeArgs({ agent, backendSessionId });
40
+ const wrappedPrompt = wrapPromptWithSystem(promptText, agent.system);
41
+ const env = {
42
+ ...buildOpencodeAuthEnv(),
43
+ ...buildOpencodeMcpEnv(agent),
44
+ };
45
+ // stdin is the raw wrapped prompt — the driver prepends the env block.
46
+ // The opencode wrapper script captures this via PROMPT=$(cat) and
47
+ // re-passes it to `opencode` as a trailing positional argv.
48
+ return { argv, env, stdin: wrappedPrompt };
49
+ }
50
+
51
+ export const opencodeBackend: Backend = {
52
+ name: "opencode",
53
+ wrapperPath: OPENCODE_WRAPPER_PATH,
54
+ buildTurn,
55
+ createTranslator: (opts: TranslatorOptions) => createOpencodeTranslator(opts),
56
+ prepareOnSprite: (name, provider) => prepareOpencodeOnSprite(name, provider),
57
+
58
+ validateRuntime: validateOpencodeRuntime,
59
+ };
60
+
61
+ // Re-exports for tests and other modules
62
+ export {
63
+ buildOpencodeArgs,
64
+ buildOpencodeAuthEnv,
65
+ buildOpencodeMcpEnv,
66
+ createOpencodeTranslator,
67
+ installOpencodeWrapper,
68
+ prepareOpencodeOnSprite,
69
+ OPENCODE_WRAPPER_PATH,
70
+ };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * MCP config translation for opencode.
3
+ *
4
+ * Opencode loads its MCP config from the `OPENCODE_CONFIG_CONTENT` env var
5
+ * (not from a `--mcp-config` CLI flag). The JSON shape is slightly
6
+ * different from claude's:
7
+ *
8
+ * Claude: Opencode:
9
+ * type: "stdio" ---> type: "local", command: [cmd, ...args]
10
+ * type: "http" ---> type: "remote"
11
+ * type: "sse" ---> type: "remote"
12
+ *
13
+ * Ported verbatim from
14
+ *
15
+ */
16
+ import type { Agent, McpServerConfig } from "../../types";
17
+
18
+ /** Opencode's MCP server config shape (distinct from claude's). */
19
+ export interface OpencodeMcpServer {
20
+ type?: "local" | "remote";
21
+ url?: string;
22
+ command?: string | string[];
23
+ headers?: Record<string, string>;
24
+ env?: Record<string, string>;
25
+ }
26
+
27
+ export function mcpConfigToOpencode(
28
+ mcpConfig: Record<string, McpServerConfig>,
29
+ ): Record<string, OpencodeMcpServer> {
30
+ const mcp: Record<string, OpencodeMcpServer> = {};
31
+
32
+ for (const [name, server] of Object.entries(mcpConfig)) {
33
+ const entry: OpencodeMcpServer = {
34
+ url: server.url,
35
+ headers: server.headers,
36
+ env: server.env,
37
+ };
38
+
39
+ if (server.type === "stdio") {
40
+ entry.type = "local";
41
+ // stdio uses command:string + args:array; opencode wants command:array
42
+ if (typeof server.command === "string") {
43
+ entry.command = [server.command, ...(server.args || [])];
44
+ } else if (Array.isArray(server.command)) {
45
+ entry.command = [...server.command, ...(server.args || [])];
46
+ }
47
+ } else if (server.type === "http" || server.type === "sse") {
48
+ entry.type = "remote";
49
+ }
50
+
51
+ mcp[name] = entry;
52
+ }
53
+
54
+ return mcp;
55
+ }
56
+
57
+ /**
58
+ * Return env vars that carry the agent's MCP config to opencode.
59
+ * Returns an empty map if the agent has no MCP servers.
60
+ */
61
+ export function buildOpencodeMcpEnv(agent: Agent): Record<string, string> {
62
+ if (!agent.mcp_servers || Object.keys(agent.mcp_servers).length === 0) {
63
+ return {};
64
+ }
65
+ const opencodeMcp = mcpConfigToOpencode(agent.mcp_servers);
66
+ return { OPENCODE_CONFIG_CONTENT: JSON.stringify({ mcp: opencodeMcp }) };
67
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Install opencode on a freshly-created sprite.
3
+ *
4
+ * Idempotent via a sentinel file at OPENCODE_INSTALLED_SENTINEL. First call
5
+ * takes ~10 seconds on sprites.dev's base image.
6
+ * Subsequent calls for the same sprite are instant (sentinel short-circuit).
7
+ *
8
+ * findings:
9
+ * - sprites.dev base image has node v22.20.0 pre-installed via nvm at
10
+ * `/.sprite/languages/node/nvm/versions/node/v22.20.0/`
11
+ * - `npm install -g opencode-ai` succeeds in 9s and installs opencode-ai@1.4.1
12
+ * - npm creates the symlink `<prefix>/bin/opencode` but that dir is NOT on
13
+ * the default PATH (only `/.sprite/bin` is)
14
+ * - Workaround: `ln -sf $(npm config get prefix)/bin/opencode /usr/local/bin/opencode`
15
+ * works without sudo and makes `opencode` directly invokable as
16
+ * `/usr/local/bin/opencode` (which IS on PATH)
17
+ */
18
+ import type { ContainerProvider } from "../../providers/types";
19
+ import { installOpencodeWrapper } from "./wrapper-script";
20
+
21
+ // Use $HOME-relative sentinel so it works on any container runtime
22
+ // (sprites.dev HOME=/home/sprite, Docker/Apple HOME=/root or /home/node)
23
+ const SENTINEL_NAME = ".claude-agents-opencode-installed";
24
+
25
+ export async function prepareOpencodeOnSprite(spriteName: string, provider: ContainerProvider): Promise<void> {
26
+ await installOpencodeWrapper(spriteName, provider);
27
+
28
+ // Install opencode binary, sentinel-guarded for idempotency.
29
+ // Uses /usr/local/bin/opencode for verification (not `which`) because
30
+ // some container exec contexts have a minimal PATH that doesn't include
31
+ // /usr/local/bin even though the binary is there.
32
+ const script = [
33
+ "set -euo pipefail",
34
+ `SENTINEL="$HOME/${SENTINEL_NAME}"`,
35
+ 'if [ -f "$SENTINEL" ]; then exit 0; fi',
36
+ "npm install -g opencode-ai",
37
+ "PREFIX=$(npm config get prefix)",
38
+ // Only symlink if npm prefix differs from /usr/local — otherwise the
39
+ // symlink overwrites npm's existing binary with a circular self-reference.
40
+ // This happens on Docker/Apple containers where PREFIX=/usr/local.
41
+ 'if [ "$PREFIX" != "/usr/local" ]; then ln -sf "$PREFIX/bin/opencode" /usr/local/bin/opencode; fi',
42
+ '/usr/local/bin/opencode --version || $PREFIX/bin/opencode --version',
43
+ 'touch "$SENTINEL"',
44
+ ].join(" && ");
45
+
46
+ const result = await provider.exec(spriteName, ["bash", "-c", script], {
47
+ timeoutMs: 5 * 60_000, // 5 minutes — cold install typically <30s but leave headroom
48
+ });
49
+ if (result.exit_code !== 0) {
50
+ throw new Error(
51
+ `opencode install failed (${result.exit_code}): ${result.stderr.slice(0, 500)}`,
52
+ );
53
+ }
54
+ }