@sna-sdk/core 0.2.3 → 0.4.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 CHANGED
@@ -10,7 +10,7 @@ Server runtime for [Skills-Native Applications](https://github.com/neuradex/sna)
10
10
  - **Hono server factory** — `createSnaApp()` with events, emit, agent, chat, and run routes
11
11
  - **WebSocket API** — `attachWebSocket()` wrapping all HTTP routes over a single WS connection
12
12
  - **One-shot execution** — `POST /agent/run-once` for single-request LLM calls
13
- - **Lifecycle CLI** — `sna api:up`, `sna api:down`, `sna dispatch`, `sna validate`
13
+ - **CLI** — `sna up/down/status`, `sna dispatch`, `sna gen client`, `sna tu` (mock API testing)
14
14
  - **Agent providers** — Claude Code and Codex process management
15
15
  - **Multi-session** — `SessionManager` with event pub/sub, permission management, and session metadata
16
16
 
@@ -84,6 +84,12 @@ const db = getDb(); // SQLite instance (data/sna.db)
84
84
  | `@sna-sdk/core/db/schema` | `getDb()`, `ChatSession`, `ChatMessage`, `SkillEvent` types |
85
85
  | `@sna-sdk/core/providers` | Agent provider factory, `ClaudeCodeProvider` |
86
86
  | `@sna-sdk/core/lib/sna-run` | `snaRun()` helper for spawning Claude Code |
87
+ | `@sna-sdk/core/testing` | `startMockAnthropicServer()` for testing without real API calls |
88
+
89
+ **Environment Variables:**
90
+ - `SNA_DB_PATH` — Override SQLite database location (default: `process.cwd()/data/sna.db`)
91
+ - `SNA_CLAUDE_COMMAND` — Override claude binary path
92
+ - `SNA_PORT` — API server port (default: 3099)
87
93
 
88
94
  ## Documentation
89
95
 
@@ -0,0 +1,37 @@
1
+ import { HistoryMessage } from './types.js';
2
+
3
+ /**
4
+ * History injection for Claude Code via JSONL resume.
5
+ *
6
+ * Writes a JSONL session file and passes --resume <filepath> to CC.
7
+ * CC loads it as real multi-turn conversation history.
8
+ *
9
+ * Key discovery: --resume with a .jsonl file path bypasses CC's project
10
+ * directory lookup and calls loadMessagesFromJsonlPath directly.
11
+ * This is the only reliable way to inject synthetic history.
12
+ *
13
+ * Verified: real Claude Haiku correctly recalls injected context.
14
+ * Fallback: recalled-conversation XML if file write fails.
15
+ */
16
+
17
+ /**
18
+ * Write a JSONL session file for --resume <filepath>.
19
+ *
20
+ * Minimal format (verified working):
21
+ * {"parentUuid":null,"isSidechain":false,"type":"user","uuid":"...","timestamp":"...","cwd":"...","sessionId":"...","message":{"role":"user","content":"..."}}
22
+ * {"parentUuid":"<prev>","isSidechain":false,"type":"assistant","uuid":"...","timestamp":"...","cwd":"...","sessionId":"...","message":{"role":"assistant","content":[{"type":"text","text":"..."}]}}
23
+ */
24
+ declare function writeHistoryJsonl(history: HistoryMessage[], opts: {
25
+ cwd: string;
26
+ }): {
27
+ filePath: string;
28
+ extraArgs: string[];
29
+ } | null;
30
+ /**
31
+ * Pack history into a single assistant stdin message.
32
+ * CC treats type:"assistant" as context injection (no API call triggered).
33
+ * Used when file write fails.
34
+ */
35
+ declare function buildRecalledConversation(history: HistoryMessage[]): string;
36
+
37
+ export { buildRecalledConversation, writeHistoryJsonl };
@@ -0,0 +1,70 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ function writeHistoryJsonl(history, opts) {
4
+ for (let i = 1; i < history.length; i++) {
5
+ if (history[i].role === history[i - 1].role) {
6
+ throw new Error(
7
+ `History validation failed: consecutive ${history[i].role} at index ${i - 1} and ${i}. Messages must alternate user\u2194assistant. Merge tool results into text before injecting.`
8
+ );
9
+ }
10
+ }
11
+ try {
12
+ const dir = path.join(opts.cwd, ".sna", "history");
13
+ fs.mkdirSync(dir, { recursive: true });
14
+ const sessionId = crypto.randomUUID();
15
+ const filePath = path.join(dir, `${sessionId}.jsonl`);
16
+ const now = (/* @__PURE__ */ new Date()).toISOString();
17
+ const lines = [];
18
+ let prevUuid = null;
19
+ for (const msg of history) {
20
+ const uuid = crypto.randomUUID();
21
+ if (msg.role === "user") {
22
+ lines.push(JSON.stringify({
23
+ parentUuid: prevUuid,
24
+ isSidechain: false,
25
+ type: "user",
26
+ uuid,
27
+ timestamp: now,
28
+ cwd: opts.cwd,
29
+ sessionId,
30
+ message: { role: "user", content: msg.content }
31
+ }));
32
+ } else {
33
+ lines.push(JSON.stringify({
34
+ parentUuid: prevUuid,
35
+ isSidechain: false,
36
+ type: "assistant",
37
+ uuid,
38
+ timestamp: now,
39
+ cwd: opts.cwd,
40
+ sessionId,
41
+ message: {
42
+ role: "assistant",
43
+ content: [{ type: "text", text: msg.content }]
44
+ }
45
+ }));
46
+ }
47
+ prevUuid = uuid;
48
+ }
49
+ fs.writeFileSync(filePath, lines.join("\n") + "\n");
50
+ return { filePath, extraArgs: ["--resume", filePath] };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+ function buildRecalledConversation(history) {
56
+ const xml = history.map((msg) => `<${msg.role}>${msg.content}</${msg.role}>`).join("\n");
57
+ return JSON.stringify({
58
+ type: "assistant",
59
+ message: {
60
+ role: "assistant",
61
+ content: [{ type: "text", text: `<recalled-conversation>
62
+ ${xml}
63
+ </recalled-conversation>` }]
64
+ }
65
+ });
66
+ }
67
+ export {
68
+ buildRecalledConversation,
69
+ writeHistoryJsonl
70
+ };
@@ -2,9 +2,11 @@ import { spawn, execSync } from "child_process";
2
2
  import { EventEmitter } from "events";
3
3
  import fs from "fs";
4
4
  import path from "path";
5
+ import { writeHistoryJsonl, buildRecalledConversation } from "./cc-history-adapter.js";
5
6
  import { logger } from "../../lib/logger.js";
6
7
  const SHELL = process.env.SHELL || "/bin/zsh";
7
8
  function resolveClaudePath(cwd) {
9
+ if (process.env.SNA_CLAUDE_COMMAND) return process.env.SNA_CLAUDE_COMMAND;
8
10
  const cached = path.join(cwd, ".sna/claude-path");
9
11
  if (fs.existsSync(cached)) {
10
12
  const p = fs.readFileSync(cached, "utf8").trim();
@@ -38,6 +40,7 @@ class ClaudeCodeProcess {
38
40
  this.emitter = new EventEmitter();
39
41
  this._alive = true;
40
42
  this._sessionId = null;
43
+ this._initEmitted = false;
41
44
  this.buffer = "";
42
45
  this.proc = proc;
43
46
  proc.stdout.on("data", (chunk) => {
@@ -77,6 +80,10 @@ class ClaudeCodeProcess {
77
80
  this._alive = false;
78
81
  this.emitter.emit("error", err);
79
82
  });
83
+ if (options.history?.length && !options._historyViaResume) {
84
+ const line = buildRecalledConversation(options.history);
85
+ this.proc.stdin.write(line + "\n");
86
+ }
80
87
  if (options.prompt) {
81
88
  this.send(options.prompt);
82
89
  }
@@ -89,20 +96,41 @@ class ClaudeCodeProcess {
89
96
  }
90
97
  /**
91
98
  * Send a user message to the persistent Claude process via stdin.
99
+ * Accepts plain string or content block array (text + images).
92
100
  */
93
101
  send(input) {
94
102
  if (!this._alive || !this.proc.stdin.writable) return;
103
+ const content = typeof input === "string" ? input : input;
95
104
  const msg = JSON.stringify({
96
105
  type: "user",
97
- message: { role: "user", content: input }
106
+ message: { role: "user", content }
98
107
  });
99
108
  logger.log("stdin", msg.slice(0, 200));
100
109
  this.proc.stdin.write(msg + "\n");
101
110
  }
102
111
  interrupt() {
103
- if (this._alive) {
104
- this.proc.kill("SIGINT");
105
- }
112
+ if (!this._alive || !this.proc.stdin.writable) return;
113
+ const msg = JSON.stringify({
114
+ type: "control_request",
115
+ request: { subtype: "interrupt" }
116
+ });
117
+ this.proc.stdin.write(msg + "\n");
118
+ }
119
+ setModel(model) {
120
+ if (!this._alive || !this.proc.stdin.writable) return;
121
+ const msg = JSON.stringify({
122
+ type: "control_request",
123
+ request: { subtype: "set_model", model }
124
+ });
125
+ this.proc.stdin.write(msg + "\n");
126
+ }
127
+ setPermissionMode(mode) {
128
+ if (!this._alive || !this.proc.stdin.writable) return;
129
+ const msg = JSON.stringify({
130
+ type: "control_request",
131
+ request: { subtype: "set_permission_mode", permission_mode: mode }
132
+ });
133
+ this.proc.stdin.write(msg + "\n");
106
134
  }
107
135
  kill() {
108
136
  if (this._alive) {
@@ -120,6 +148,8 @@ class ClaudeCodeProcess {
120
148
  switch (msg.type) {
121
149
  case "system": {
122
150
  if (msg.subtype === "init") {
151
+ if (this._initEmitted) return null;
152
+ this._initEmitted = true;
123
153
  return {
124
154
  type: "init",
125
155
  message: `Agent ready (${msg.model ?? "unknown"})`,
@@ -201,6 +231,14 @@ class ClaudeCodeProcess {
201
231
  timestamp: Date.now()
202
232
  };
203
233
  }
234
+ if (msg.subtype === "error_during_execution" && msg.is_error === false) {
235
+ return {
236
+ type: "interrupted",
237
+ message: "Turn interrupted by user",
238
+ data: { durationMs: msg.duration_ms, costUsd: msg.total_cost_usd },
239
+ timestamp: Date.now()
240
+ };
241
+ }
204
242
  if (msg.subtype?.startsWith("error") || msg.is_error) {
205
243
  return {
206
244
  type: "error",
@@ -232,7 +270,10 @@ class ClaudeCodeProvider {
232
270
  }
233
271
  }
234
272
  spawn(options) {
235
- const claudePath = resolveClaudePath(options.cwd);
273
+ const claudeCommand = resolveClaudePath(options.cwd);
274
+ const claudeParts = claudeCommand.split(/\s+/);
275
+ const claudePath = claudeParts[0];
276
+ const claudePrefix = claudeParts.slice(1);
236
277
  const hookScript = new URL("../../scripts/hook.js", import.meta.url).pathname;
237
278
  const sessionId = options.env?.SNA_SESSION_ID ?? "default";
238
279
  const sdkSettings = {};
@@ -282,6 +323,14 @@ class ClaudeCodeProvider {
282
323
  if (options.permissionMode) {
283
324
  args.push("--permission-mode", options.permissionMode);
284
325
  }
326
+ if (options.history?.length && options.prompt) {
327
+ const result = writeHistoryJsonl(options.history, { cwd: options.cwd });
328
+ if (result) {
329
+ args.push(...result.extraArgs);
330
+ options._historyViaResume = true;
331
+ logger.log("agent", `history via JSONL resume \u2192 ${result.filePath}`);
332
+ }
333
+ }
285
334
  if (extraArgsClean.length > 0) {
286
335
  args.push(...extraArgsClean);
287
336
  }
@@ -289,12 +338,13 @@ class ClaudeCodeProvider {
289
338
  delete cleanEnv.CLAUDECODE;
290
339
  delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
291
340
  delete cleanEnv.CLAUDE_CODE_SESSION_ACCESS_TOKEN;
292
- const proc = spawn(claudePath, args, {
341
+ delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN;
342
+ const proc = spawn(claudePath, [...claudePrefix, ...args], {
293
343
  cwd: options.cwd,
294
344
  env: cleanEnv,
295
345
  stdio: ["pipe", "pipe", "pipe"]
296
346
  });
297
- logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${claudePath} ${args.join(" ")}`);
347
+ logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${claudeCommand} ${args.join(" ")}`);
298
348
  return new ClaudeCodeProcess(proc, options);
299
349
  }
300
350
  }
@@ -5,7 +5,7 @@
5
5
  * Codex JSONL, etc.) into these common types.
6
6
  */
7
7
  interface AgentEvent {
8
- type: "init" | "thinking" | "text_delta" | "assistant" | "tool_use" | "tool_result" | "permission_needed" | "milestone" | "error" | "complete";
8
+ type: "init" | "thinking" | "text_delta" | "assistant" | "tool_use" | "tool_result" | "permission_needed" | "milestone" | "interrupted" | "error" | "complete";
9
9
  message?: string;
10
10
  data?: Record<string, unknown>;
11
11
  timestamp: number;
@@ -14,10 +14,14 @@ interface AgentEvent {
14
14
  * A running agent process. Wraps a child_process with typed event handlers.
15
15
  */
16
16
  interface AgentProcess {
17
- /** Send a user message to the agent's stdin. */
18
- send(input: string): void;
19
- /** Interrupt the current turn (SIGINT). Process stays alive. */
17
+ /** Send a user message to the agent's stdin. Accepts string or content blocks (text + images). */
18
+ send(input: string | ContentBlock[]): void;
19
+ /** Interrupt the current turn. Process stays alive. */
20
20
  interrupt(): void;
21
+ /** Change model at runtime via control message. No restart needed. */
22
+ setModel(model: string): void;
23
+ /** Change permission mode at runtime via control message. No restart needed. */
24
+ setPermissionMode(mode: string): void;
21
25
  /** Kill the agent process. */
22
26
  kill(): void;
23
27
  /** Whether the process is still running. */
@@ -32,12 +36,35 @@ interface AgentProcess {
32
36
  /**
33
37
  * Options for spawning an agent session.
34
38
  */
39
+ type ContentBlock = {
40
+ type: "text";
41
+ text: string;
42
+ } | {
43
+ type: "image";
44
+ source: {
45
+ type: "base64";
46
+ media_type: string;
47
+ data: string;
48
+ };
49
+ };
50
+ interface HistoryMessage {
51
+ role: "user" | "assistant";
52
+ content: string;
53
+ }
35
54
  interface SpawnOptions {
36
55
  cwd: string;
37
56
  prompt?: string;
38
57
  model?: string;
39
58
  permissionMode?: "default" | "acceptEdits" | "bypassPermissions" | "plan";
40
59
  env?: Record<string, string>;
60
+ /**
61
+ * Conversation history to inject before the first prompt.
62
+ * Written to stdin as NDJSON — Claude Code treats these as prior conversation turns.
63
+ * Must alternate user→assistant. Assistant content is auto-wrapped in array format.
64
+ */
65
+ history?: HistoryMessage[];
66
+ /** @internal Set by provider when history was injected via JSONL resume. */
67
+ _historyViaResume?: boolean;
41
68
  /**
42
69
  * Additional CLI flags passed directly to the agent binary.
43
70
  * e.g. ["--system-prompt", "You are...", "--append-system-prompt", "Also...", "--mcp-config", "path"]
@@ -56,4 +83,4 @@ interface AgentProvider {
56
83
  spawn(options: SpawnOptions): AgentProcess;
57
84
  }
58
85
 
59
- export type { AgentEvent, AgentProcess, AgentProvider, SpawnOptions };
86
+ export type { AgentEvent, AgentProcess, AgentProvider, ContentBlock, HistoryMessage, SpawnOptions };
package/dist/db/schema.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
- const DB_PATH = path.join(process.cwd(), "data/sna.db");
4
+ const DB_PATH = process.env.SNA_DB_PATH ?? path.join(process.cwd(), "data/sna.db");
5
5
  const NATIVE_DIR = path.join(process.cwd(), ".sna/native");
6
6
  let _db = null;
7
7
  function loadBetterSqlite3() {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { ChatMessage, ChatSession, SkillEvent } from './db/schema.js';
2
- export { AgentEvent, AgentProcess, AgentProvider, SpawnOptions } from './core/providers/types.js';
2
+ export { AgentEvent, AgentProcess, AgentProvider, ContentBlock, HistoryMessage, SpawnOptions } from './core/providers/types.js';
3
3
  export { Session, SessionInfo, SessionManagerOptions, SessionState } from './server/session-manager.js';
4
4
  export { DispatchCloseOptions, DispatchEventType, DispatchOpenOptions, DispatchOpenResult, DispatchSendOptions, createHandle as createDispatchHandle, close as dispatchClose, open as dispatchOpen, send as dispatchSend } from './lib/dispatch.js';
5
5
  import 'better-sqlite3';
@@ -17,6 +17,9 @@ const PORT = process.env.PORT ?? "3000";
17
17
  const CLAUDE_PATH_FILE = path.join(STATE_DIR, "claude-path");
18
18
  const SNA_CORE_DIR = path.join(ROOT, "node_modules/@sna-sdk/core");
19
19
  const NATIVE_DIR = path.join(STATE_DIR, "native");
20
+ const MOCK_API_PID_FILE = path.join(STATE_DIR, "mock-api.pid");
21
+ const MOCK_API_PORT_FILE = path.join(STATE_DIR, "mock-api.port");
22
+ const MOCK_API_LOG_FILE = path.join(STATE_DIR, "mock-api.log");
20
23
  function ensureStateDir() {
21
24
  if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
22
25
  }
@@ -194,6 +197,178 @@ function cmdApiDown() {
194
197
  }
195
198
  clearSnaApiState();
196
199
  }
200
+ function cmdTu(args2) {
201
+ const sub = args2[0];
202
+ switch (sub) {
203
+ case "api:up":
204
+ cmdTuApiUp();
205
+ break;
206
+ case "api:down":
207
+ cmdTuApiDown();
208
+ break;
209
+ case "api:log":
210
+ cmdTuApiLog(args2.slice(1));
211
+ break;
212
+ case "claude":
213
+ cmdTuClaude(args2.slice(1));
214
+ break;
215
+ case "claude:oneshot":
216
+ cmdTuClaudeOneshot(args2.slice(1));
217
+ break;
218
+ default:
219
+ console.log(`
220
+ sna tu \u2014 Test utilities (mock Anthropic API)
221
+
222
+ Commands:
223
+ sna tu api:up Start mock Anthropic API server
224
+ sna tu api:down Stop mock API server
225
+ sna tu api:log Show mock API request/response log
226
+ sna tu api:log -f Follow log in real-time (tail -f)
227
+ sna tu claude ... Run claude with mock API env vars (proxy)
228
+ sna tu claude:oneshot ... One-shot: mock server \u2192 claude \u2192 results \u2192 cleanup
229
+
230
+ Flow:
231
+ 1. sna tu api:up \u2192 mock server on random port
232
+ 2. sna tu claude "say hi" \u2192 real claude \u2192 mock API \u2192 mock response
233
+ 3. sna tu api:log -f \u2192 watch requests/responses in real-time
234
+ 4. sna tu api:down \u2192 cleanup
235
+
236
+ All requests/responses are logged to .sna/mock-api.log
237
+ `);
238
+ }
239
+ }
240
+ function cmdTuApiUp() {
241
+ ensureStateDir();
242
+ const existingPid = readPidFile(MOCK_API_PID_FILE);
243
+ const existingPort = readPortFile(MOCK_API_PORT_FILE);
244
+ if (existingPid && isProcessRunning(existingPid)) {
245
+ step(`Mock API already running on :${existingPort} (pid=${existingPid})`);
246
+ return;
247
+ }
248
+ const scriptDir = path.dirname(new URL(import.meta.url).pathname);
249
+ const mockEntry = path.join(scriptDir, "../testing/mock-api.js");
250
+ const mockEntrySrc = path.join(scriptDir, "../testing/mock-api.ts");
251
+ const resolvedMockEntry = fs.existsSync(mockEntry) ? mockEntry : mockEntrySrc;
252
+ if (!fs.existsSync(resolvedMockEntry)) {
253
+ console.error("\u2717 Mock API server not found. Run pnpm build first.");
254
+ process.exit(1);
255
+ }
256
+ const logStream = fs.openSync(MOCK_API_LOG_FILE, "w");
257
+ const startScript = `
258
+ import { startMockAnthropicServer } from "${resolvedMockEntry.replace(/\\/g, "/")}";
259
+ const mock = await startMockAnthropicServer();
260
+ const fs = await import("fs");
261
+ fs.writeFileSync("${MOCK_API_PORT_FILE.replace(/\\/g, "/")}", String(mock.port));
262
+ console.log("Mock Anthropic API ready on :" + mock.port);
263
+ // Keep alive
264
+ process.on("SIGTERM", () => { mock.close(); process.exit(0); });
265
+ `;
266
+ const child = spawn("node", ["--import", "tsx", "-e", startScript], {
267
+ cwd: ROOT,
268
+ detached: true,
269
+ stdio: ["ignore", logStream, logStream]
270
+ });
271
+ child.unref();
272
+ fs.writeFileSync(MOCK_API_PID_FILE, String(child.pid));
273
+ for (let i = 0; i < 20; i++) {
274
+ if (fs.existsSync(MOCK_API_PORT_FILE) && fs.readFileSync(MOCK_API_PORT_FILE, "utf8").trim()) break;
275
+ execSync("sleep 0.3", { stdio: "pipe" });
276
+ }
277
+ const port = readPortFile(MOCK_API_PORT_FILE);
278
+ if (port) {
279
+ step(`Mock Anthropic API \u2192 http://localhost:${port} (log: .sna/mock-api.log)`);
280
+ } else {
281
+ console.error("\u2717 Mock API failed to start. Check .sna/mock-api.log");
282
+ }
283
+ }
284
+ function cmdTuApiDown() {
285
+ const pid = readPidFile(MOCK_API_PID_FILE);
286
+ if (pid && isProcessRunning(pid)) {
287
+ try {
288
+ process.kill(pid, "SIGTERM");
289
+ } catch {
290
+ }
291
+ console.log(` Mock API \u2713 stopped (pid=${pid})`);
292
+ } else {
293
+ console.log(` Mock API \u2014 not running`);
294
+ }
295
+ try {
296
+ fs.unlinkSync(MOCK_API_PID_FILE);
297
+ } catch {
298
+ }
299
+ try {
300
+ fs.unlinkSync(MOCK_API_PORT_FILE);
301
+ } catch {
302
+ }
303
+ }
304
+ function cmdTuApiLog(args2) {
305
+ if (!fs.existsSync(MOCK_API_LOG_FILE)) {
306
+ console.log("No log file. Start mock API with: sna tu api:up");
307
+ return;
308
+ }
309
+ const follow = args2.includes("-f") || args2.includes("--follow");
310
+ if (follow) {
311
+ execSync(`tail -f "${MOCK_API_LOG_FILE}"`, { stdio: "inherit" });
312
+ } else {
313
+ execSync(`cat "${MOCK_API_LOG_FILE}"`, { stdio: "inherit" });
314
+ }
315
+ }
316
+ function cmdTuClaude(args2) {
317
+ const port = readPortFile(MOCK_API_PORT_FILE);
318
+ if (!port) {
319
+ console.error("\u2717 Mock API not running. Start with: sna tu api:up");
320
+ process.exit(1);
321
+ }
322
+ const claudePath = resolveAndCacheClaudePath();
323
+ const mockConfigDir = path.join(STATE_DIR, "mock-claude-config");
324
+ fs.mkdirSync(mockConfigDir, { recursive: true });
325
+ const env = {
326
+ PATH: process.env.PATH ?? "",
327
+ HOME: process.env.HOME ?? "",
328
+ SHELL: process.env.SHELL ?? "/bin/zsh",
329
+ TERM: process.env.TERM ?? "xterm-256color",
330
+ LANG: process.env.LANG ?? "en_US.UTF-8",
331
+ ANTHROPIC_BASE_URL: `http://localhost:${port}`,
332
+ ANTHROPIC_API_KEY: "sk-test-mock-sna",
333
+ CLAUDE_CONFIG_DIR: mockConfigDir
334
+ };
335
+ try {
336
+ execSync(`"${claudePath}" ${args2.map((a) => `"${a}"`).join(" ")}`, {
337
+ stdio: "inherit",
338
+ env,
339
+ cwd: ROOT
340
+ });
341
+ } catch (e) {
342
+ process.exit(e.status ?? 1);
343
+ }
344
+ }
345
+ function cmdTuClaudeOneshot(args2) {
346
+ const scriptDir = path.dirname(new URL(import.meta.url).pathname);
347
+ const oneshotScript = fs.existsSync(path.join(scriptDir, "tu-oneshot.js")) ? path.join(scriptDir, "tu-oneshot.js") : path.join(scriptDir, "tu-oneshot.ts");
348
+ try {
349
+ execSync(`node --import tsx "${oneshotScript}" ${args2.map((a) => `"${a}"`).join(" ")}`, {
350
+ stdio: "inherit",
351
+ cwd: ROOT,
352
+ timeout: 9e4
353
+ });
354
+ } catch (e) {
355
+ process.exit(e.status ?? 1);
356
+ }
357
+ }
358
+ function readPidFile(filePath) {
359
+ try {
360
+ return parseInt(fs.readFileSync(filePath, "utf8").trim(), 10) || null;
361
+ } catch {
362
+ return null;
363
+ }
364
+ }
365
+ function readPortFile(filePath) {
366
+ try {
367
+ return fs.readFileSync(filePath, "utf8").trim() || null;
368
+ } catch {
369
+ return null;
370
+ }
371
+ }
197
372
  function isPortInUse(port) {
198
373
  try {
199
374
  execSync(`lsof -ti:${port}`, { stdio: "pipe" });
@@ -606,7 +781,12 @@ Lifecycle:
606
781
  sna restart Stop + start
607
782
  sna init [--force] Initialize .claude/settings.json and skills
608
783
  sna validate Check project setup (skills.json, hooks, deps)
784
+
785
+ Tools:
609
786
  sna dispatch Unified event dispatcher (open/send/close)
787
+ sna gen client Generate typed skill client + .sna/skills.json
788
+ sna api:up Start standalone SNA API server
789
+ sna api:down Stop SNA API server
610
790
 
611
791
  Workflow:
612
792
  sna new <skill> [--param val ...] Create a task from a workflow.yml
@@ -616,7 +796,16 @@ Workflow:
616
796
  sna <task-id> cancel Cancel a running task
617
797
  sna tasks List all tasks with status
618
798
 
619
- Task IDs are 10-digit timestamps (MMDDHHmmss), e.g. 0317143052
799
+ Testing:
800
+ sna tu api:up Start mock Anthropic API server
801
+ sna tu api:down Stop mock API server
802
+ sna tu api:log Show mock API request/response log
803
+ sna tu api:log -f Follow log in real-time
804
+ sna tu claude ... Run claude with mock API (isolated env)
805
+ sna tu claude:oneshot ... One-shot: mock server \u2192 claude \u2192 structured results \u2192 cleanup
806
+
807
+ Set SNA_CLAUDE_COMMAND to override claude binary in SDK.
808
+ See: docs/testing.md
620
809
 
621
810
  Run "sna help workflow" for workflow.yml specification.
622
811
  Run "sna help submit" for data submission patterns.`);
@@ -816,6 +1005,9 @@ Run "sna help submit" for data submission patterns.`);
816
1005
  cmdApiDown();
817
1006
  cmdApiUp();
818
1007
  break;
1008
+ case "tu":
1009
+ cmdTu(args);
1010
+ break;
819
1011
  case "restart":
820
1012
  cmdDown();
821
1013
  console.log();
@@ -0,0 +1,2 @@
1
+
2
+ export { }
@@ -0,0 +1,66 @@
1
+ import { startMockAnthropicServer } from "../testing/mock-api.js";
2
+ import { spawn } from "child_process";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ async function main() {
6
+ const ROOT = process.cwd();
7
+ const STATE_DIR = path.join(ROOT, ".sna");
8
+ const args = process.argv.slice(2);
9
+ let claudePath = "claude";
10
+ const cachedPath = path.join(STATE_DIR, "claude-path");
11
+ if (fs.existsSync(cachedPath)) {
12
+ claudePath = fs.readFileSync(cachedPath, "utf8").trim() || claudePath;
13
+ }
14
+ const mock = await startMockAnthropicServer();
15
+ const mockConfigDir = path.join(STATE_DIR, "mock-claude-oneshot");
16
+ fs.mkdirSync(mockConfigDir, { recursive: true });
17
+ const env = {
18
+ PATH: process.env.PATH ?? "",
19
+ HOME: process.env.HOME ?? "",
20
+ SHELL: process.env.SHELL ?? "/bin/zsh",
21
+ TERM: process.env.TERM ?? "xterm-256color",
22
+ LANG: process.env.LANG ?? "en_US.UTF-8",
23
+ ANTHROPIC_BASE_URL: `http://localhost:${mock.port}`,
24
+ ANTHROPIC_API_KEY: "sk-test-mock-oneshot",
25
+ CLAUDE_CONFIG_DIR: mockConfigDir
26
+ };
27
+ const stdoutPath = path.join(STATE_DIR, "mock-claude-stdout.log");
28
+ const stderrPath = path.join(STATE_DIR, "mock-claude-stderr.log");
29
+ const proc = spawn(claudePath, args, {
30
+ env,
31
+ cwd: ROOT,
32
+ stdio: ["ignore", "pipe", "pipe"]
33
+ });
34
+ let stdout = "";
35
+ let stderr = "";
36
+ proc.stdout.on("data", (d) => {
37
+ stdout += d.toString();
38
+ });
39
+ proc.stderr.on("data", (d) => {
40
+ stderr += d.toString();
41
+ });
42
+ proc.stdout.pipe(process.stdout);
43
+ proc.on("exit", (code) => {
44
+ fs.writeFileSync(stdoutPath, stdout);
45
+ fs.writeFileSync(stderrPath, stderr);
46
+ console.log(`
47
+ ${"\u2500".repeat(60)}`);
48
+ console.log(`Mock API: ${mock.requests.length} request(s)`);
49
+ for (const req of mock.requests) {
50
+ console.log(` model=${req.model} stream=${req.stream} messages=${req.messages?.length}`);
51
+ }
52
+ console.log(`
53
+ Log files:`);
54
+ console.log(` stdout: ${stdoutPath}`);
55
+ console.log(` stderr: ${stderrPath}`);
56
+ console.log(` api log: ${path.join(STATE_DIR, "mock-api-last-request.json")}`);
57
+ console.log(` config: ${mockConfigDir}`);
58
+ console.log(` exit: ${code}`);
59
+ mock.close();
60
+ process.exit(code ?? 0);
61
+ });
62
+ setTimeout(() => {
63
+ proc.kill();
64
+ }, 6e4);
65
+ }
66
+ main();