@sna-sdk/core 0.1.1 → 0.3.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
@@ -6,10 +6,13 @@ Server runtime for [Skills-Native Applications](https://github.com/neuradex/sna)
6
6
 
7
7
  - **Skill event pipeline** — emit, SSE streaming, and hook scripts
8
8
  - **Dispatch** — unified event dispatcher with validation, session lifecycle, and cleanup (`sna dispatch` CLI + programmatic API)
9
- - **SQLite database** — schema and `getDb()` for `skill_events`
10
- - **Hono server factory** — `createSnaApp()` with events, emit, agent, and run routes
11
- - **Lifecycle CLI** — `sna api:up`, `sna api:down`, `sna dispatch`, `sna validate`
9
+ - **SQLite database** — schema and `getDb()` for `skill_events`, `chat_sessions`, `chat_messages`
10
+ - **Hono server factory** — `createSnaApp()` with events, emit, agent, chat, and run routes
11
+ - **WebSocket API** — `attachWebSocket()` wrapping all HTTP routes over a single WS connection
12
+ - **One-shot execution** — `POST /agent/run-once` for single-request LLM calls
13
+ - **CLI** — `sna up/down/status`, `sna dispatch`, `sna gen client`, `sna tu` (mock API testing)
12
14
  - **Agent providers** — Claude Code and Codex process management
15
+ - **Multi-session** — `SessionManager` with event pub/sub, permission management, and session metadata
13
16
 
14
17
  ## Install
15
18
 
@@ -53,10 +56,14 @@ Event types: `start` | `progress` | `milestone` | `complete` | `error`
53
56
  ### Mount server routes
54
57
 
55
58
  ```ts
56
- import { createSnaApp } from "@sna-sdk/core/server";
59
+ import { createSnaApp, attachWebSocket } from "@sna-sdk/core/server";
60
+ import { serve } from "@hono/node-server";
57
61
 
58
62
  const sna = createSnaApp();
59
- // Provides: GET /events (SSE), POST /emit, GET /health, POST /agent/start
63
+ // HTTP: GET /health, GET /events (SSE), POST /emit, /agent/*, /chat/*
64
+ const server = serve({ fetch: sna.fetch, port: 3099 });
65
+ // WS: ws://localhost:3099/ws — all routes available over WebSocket
66
+ attachWebSocket(server, sessionManager);
60
67
  ```
61
68
 
62
69
  ### Access the database
@@ -71,11 +78,13 @@ const db = getDb(); // SQLite instance (data/sna.db)
71
78
 
72
79
  | Import path | Contents |
73
80
  |-------------|----------|
74
- | `@sna-sdk/core` | `DEFAULT_SNA_PORT`, `DEFAULT_SNA_URL`, `dispatchOpen`, `dispatchSend`, `dispatchClose`, `createDispatchHandle`, `SEND_TYPES`, `loadSkillsManifest`, types |
75
- | `@sna-sdk/core/server` | `createSnaApp()`, route handlers, `SessionManager` |
76
- | `@sna-sdk/core/db/schema` | `getDb()`, `SkillEvent` type |
81
+ | `@sna-sdk/core` | `DEFAULT_SNA_PORT`, `DEFAULT_SNA_URL`, `dispatchOpen`, `dispatchSend`, `dispatchClose`, `createDispatchHandle`, types (`AgentEvent`, `Session`, `SessionInfo`, `ChatSession`, `ChatMessage`, `SkillEvent`, etc.) |
82
+ | `@sna-sdk/core/server` | `createSnaApp()`, `attachWebSocket()`, route handlers, `SessionManager` |
83
+ | `@sna-sdk/core/server/routes/agent` | `createAgentRoutes()`, `runOnce()` |
84
+ | `@sna-sdk/core/db/schema` | `getDb()`, `ChatSession`, `ChatMessage`, `SkillEvent` types |
77
85
  | `@sna-sdk/core/providers` | Agent provider factory, `ClaudeCodeProvider` |
78
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 |
79
88
 
80
89
  ## Documentation
81
90
 
@@ -5,6 +5,7 @@ import path from "path";
5
5
  import { logger } from "../../lib/logger.js";
6
6
  const SHELL = process.env.SHELL || "/bin/zsh";
7
7
  function resolveClaudePath(cwd) {
8
+ if (process.env.SNA_CLAUDE_COMMAND) return process.env.SNA_CLAUDE_COMMAND;
8
9
  const cached = path.join(cwd, ".sna/claude-path");
9
10
  if (fs.existsSync(cached)) {
10
11
  const p = fs.readFileSync(cached, "utf8").trim();
@@ -38,6 +39,7 @@ class ClaudeCodeProcess {
38
39
  this.emitter = new EventEmitter();
39
40
  this._alive = true;
40
41
  this._sessionId = null;
42
+ this._initEmitted = false;
41
43
  this.buffer = "";
42
44
  this.proc = proc;
43
45
  proc.stdout.on("data", (chunk) => {
@@ -77,6 +79,29 @@ class ClaudeCodeProcess {
77
79
  this._alive = false;
78
80
  this.emitter.emit("error", err);
79
81
  });
82
+ if (options.history?.length) {
83
+ if (!options.prompt) {
84
+ throw new Error("history requires a prompt \u2014 the last stdin message must be a user message");
85
+ }
86
+ for (const msg of options.history) {
87
+ if (msg.role === "user") {
88
+ const line = JSON.stringify({
89
+ type: "user",
90
+ message: { role: "user", content: msg.content }
91
+ });
92
+ this.proc.stdin.write(line + "\n");
93
+ } else if (msg.role === "assistant") {
94
+ const line = JSON.stringify({
95
+ type: "assistant",
96
+ message: {
97
+ role: "assistant",
98
+ content: [{ type: "text", text: msg.content }]
99
+ }
100
+ });
101
+ this.proc.stdin.write(line + "\n");
102
+ }
103
+ }
104
+ }
80
105
  if (options.prompt) {
81
106
  this.send(options.prompt);
82
107
  }
@@ -89,16 +114,42 @@ class ClaudeCodeProcess {
89
114
  }
90
115
  /**
91
116
  * Send a user message to the persistent Claude process via stdin.
117
+ * Accepts plain string or content block array (text + images).
92
118
  */
93
119
  send(input) {
94
120
  if (!this._alive || !this.proc.stdin.writable) return;
121
+ const content = typeof input === "string" ? input : input;
95
122
  const msg = JSON.stringify({
96
123
  type: "user",
97
- message: { role: "user", content: input }
124
+ message: { role: "user", content }
98
125
  });
99
126
  logger.log("stdin", msg.slice(0, 200));
100
127
  this.proc.stdin.write(msg + "\n");
101
128
  }
129
+ interrupt() {
130
+ if (!this._alive || !this.proc.stdin.writable) return;
131
+ const msg = JSON.stringify({
132
+ type: "control_request",
133
+ request: { subtype: "interrupt" }
134
+ });
135
+ this.proc.stdin.write(msg + "\n");
136
+ }
137
+ setModel(model) {
138
+ if (!this._alive || !this.proc.stdin.writable) return;
139
+ const msg = JSON.stringify({
140
+ type: "control_request",
141
+ request: { subtype: "set_model", model }
142
+ });
143
+ this.proc.stdin.write(msg + "\n");
144
+ }
145
+ setPermissionMode(mode) {
146
+ if (!this._alive || !this.proc.stdin.writable) return;
147
+ const msg = JSON.stringify({
148
+ type: "control_request",
149
+ request: { subtype: "set_permission_mode", permission_mode: mode }
150
+ });
151
+ this.proc.stdin.write(msg + "\n");
152
+ }
102
153
  kill() {
103
154
  if (this._alive) {
104
155
  this._alive = false;
@@ -115,6 +166,8 @@ class ClaudeCodeProcess {
115
166
  switch (msg.type) {
116
167
  case "system": {
117
168
  if (msg.subtype === "init") {
169
+ if (this._initEmitted) return null;
170
+ this._initEmitted = true;
118
171
  return {
119
172
  type: "init",
120
173
  message: `Agent ready (${msg.model ?? "unknown"})`,
@@ -196,7 +249,15 @@ class ClaudeCodeProcess {
196
249
  timestamp: Date.now()
197
250
  };
198
251
  }
199
- if (msg.subtype === "error" || msg.is_error) {
252
+ if (msg.subtype === "error_during_execution" && msg.is_error === false) {
253
+ return {
254
+ type: "interrupted",
255
+ message: "Turn interrupted by user",
256
+ data: { durationMs: msg.duration_ms, costUsd: msg.total_cost_usd },
257
+ timestamp: Date.now()
258
+ };
259
+ }
260
+ if (msg.subtype?.startsWith("error") || msg.is_error) {
200
261
  return {
201
262
  type: "error",
202
263
  message: msg.result ?? msg.error ?? "Unknown error",
@@ -227,14 +288,18 @@ class ClaudeCodeProvider {
227
288
  }
228
289
  }
229
290
  spawn(options) {
230
- const claudePath = resolveClaudePath(options.cwd);
231
- const hookScript = path.join(options.cwd, "node_modules/@sna-sdk/core/dist/scripts/hook.js");
291
+ const claudeCommand = resolveClaudePath(options.cwd);
292
+ const claudeParts = claudeCommand.split(/\s+/);
293
+ const claudePath = claudeParts[0];
294
+ const claudePrefix = claudeParts.slice(1);
295
+ const hookScript = new URL("../../scripts/hook.js", import.meta.url).pathname;
296
+ const sessionId = options.env?.SNA_SESSION_ID ?? "default";
232
297
  const sdkSettings = {};
233
298
  if (options.permissionMode !== "bypassPermissions") {
234
299
  sdkSettings.hooks = {
235
300
  PreToolUse: [{
236
301
  matcher: ".*",
237
- hooks: [{ type: "command", command: `node "${hookScript}"` }]
302
+ hooks: [{ type: "command", command: `node "${hookScript}" --session=${sessionId}` }]
238
303
  }]
239
304
  };
240
305
  }
@@ -283,12 +348,13 @@ class ClaudeCodeProvider {
283
348
  delete cleanEnv.CLAUDECODE;
284
349
  delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
285
350
  delete cleanEnv.CLAUDE_CODE_SESSION_ACCESS_TOKEN;
286
- const proc = spawn(claudePath, args, {
351
+ delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN;
352
+ const proc = spawn(claudePath, [...claudePrefix, ...args], {
287
353
  cwd: options.cwd,
288
354
  env: cleanEnv,
289
355
  stdio: ["pipe", "pipe", "pipe"]
290
356
  });
291
- logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${claudePath} ${args.join(" ")}`);
357
+ logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${claudeCommand} ${args.join(" ")}`);
292
358
  return new ClaudeCodeProcess(proc, options);
293
359
  }
294
360
  }
@@ -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,8 +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;
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
+ 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;
19
25
  /** Kill the agent process. */
20
26
  kill(): void;
21
27
  /** Whether the process is still running. */
@@ -30,12 +36,33 @@ interface AgentProcess {
30
36
  /**
31
37
  * Options for spawning an agent session.
32
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
+ }
33
54
  interface SpawnOptions {
34
55
  cwd: string;
35
56
  prompt?: string;
36
57
  model?: string;
37
58
  permissionMode?: "default" | "acceptEdits" | "bypassPermissions" | "plan";
38
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[];
39
66
  /**
40
67
  * Additional CLI flags passed directly to the agent binary.
41
68
  * e.g. ["--system-prompt", "You are...", "--append-system-prompt", "Also...", "--mcp-config", "path"]
@@ -54,4 +81,4 @@ interface AgentProvider {
54
81
  spawn(options: SpawnOptions): AgentProcess;
55
82
  }
56
83
 
57
- export type { AgentEvent, AgentProcess, AgentProvider, SpawnOptions };
84
+ export type { AgentEvent, AgentProcess, AgentProvider, ContentBlock, HistoryMessage, SpawnOptions };
@@ -6,6 +6,7 @@ interface ChatSession {
6
6
  label: string;
7
7
  type: "main" | "background";
8
8
  meta: string | null;
9
+ cwd: string | null;
9
10
  created_at: string;
10
11
  }
11
12
  interface ChatMessage {
package/dist/db/schema.js CHANGED
@@ -2,11 +2,20 @@ import { createRequire } from "node:module";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  const DB_PATH = path.join(process.cwd(), "data/sna.db");
5
+ const NATIVE_DIR = path.join(process.cwd(), ".sna/native");
5
6
  let _db = null;
7
+ function loadBetterSqlite3() {
8
+ const nativeEntry = path.join(NATIVE_DIR, "node_modules", "better-sqlite3");
9
+ if (fs.existsSync(nativeEntry)) {
10
+ const req2 = createRequire(path.join(NATIVE_DIR, "noop.js"));
11
+ return req2("better-sqlite3");
12
+ }
13
+ const req = createRequire(import.meta.url);
14
+ return req("better-sqlite3");
15
+ }
6
16
  function getDb() {
7
17
  if (!_db) {
8
- const req = createRequire(import.meta.url);
9
- const BetterSqlite3 = req("better-sqlite3");
18
+ const BetterSqlite3 = loadBetterSqlite3();
10
19
  const dir = path.dirname(DB_PATH);
11
20
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
12
21
  _db = new BetterSqlite3(DB_PATH);
@@ -28,6 +37,12 @@ function migrateChatSessionsMeta(db) {
28
37
  if (cols.length > 0 && !cols.some((c) => c.name === "meta")) {
29
38
  db.exec("ALTER TABLE chat_sessions ADD COLUMN meta TEXT");
30
39
  }
40
+ if (cols.length > 0 && !cols.some((c) => c.name === "cwd")) {
41
+ db.exec("ALTER TABLE chat_sessions ADD COLUMN cwd TEXT");
42
+ }
43
+ if (cols.length > 0 && !cols.some((c) => c.name === "last_start_config")) {
44
+ db.exec("ALTER TABLE chat_sessions ADD COLUMN last_start_config TEXT");
45
+ }
31
46
  }
32
47
  function initSchema(db) {
33
48
  migrateSkillEvents(db);
@@ -38,6 +53,8 @@ function initSchema(db) {
38
53
  label TEXT NOT NULL DEFAULT '',
39
54
  type TEXT NOT NULL DEFAULT 'main',
40
55
  meta TEXT,
56
+ cwd TEXT,
57
+ last_start_config TEXT,
41
58
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
42
59
  );
43
60
 
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';
@@ -5,6 +5,7 @@ declare const tags: {
5
5
  readonly stdin: string;
6
6
  readonly stdout: string;
7
7
  readonly route: string;
8
+ readonly ws: string;
8
9
  readonly err: string;
9
10
  };
10
11
  type Tag = keyof typeof tags;
@@ -19,6 +19,7 @@ const tags = {
19
19
  stdin: chalk.bold.green(" IN "),
20
20
  stdout: chalk.bold.yellow(" OUT "),
21
21
  route: chalk.bold.blue(" API "),
22
+ ws: chalk.bold.green(" WS "),
22
23
  err: chalk.bold.red(" ERR ")
23
24
  };
24
25
  const tagPlain = {
@@ -28,6 +29,7 @@ const tagPlain = {
28
29
  stdin: " IN ",
29
30
  stdout: " OUT ",
30
31
  route: " API ",
32
+ ws: " WS ",
31
33
  err: " ERR "
32
34
  };
33
35
  function appendFile(tag, args) {
@@ -24,7 +24,7 @@ process.stdin.on("end", async () => {
24
24
  allow();
25
25
  return;
26
26
  }
27
- const sessionId = process.env.SNA_SESSION_ID ?? "default";
27
+ const sessionId = process.argv.find((a) => a.startsWith("--session="))?.slice(10) ?? process.env.SNA_SESSION_ID ?? "default";
28
28
  const apiUrl = `http://localhost:${port}`;
29
29
  const res = await fetch(`${apiUrl}/agent/permission-request?session=${encodeURIComponent(sessionId)}`, {
30
30
  method: "POST",
@@ -16,6 +16,10 @@ const SNA_API_LOG_FILE = path.join(STATE_DIR, "sna-api.log");
16
16
  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
+ 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");
19
23
  function ensureStateDir() {
20
24
  if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
21
25
  }
@@ -76,8 +80,57 @@ async function checkSnaApiHealth(port) {
76
80
  return false;
77
81
  }
78
82
  }
83
+ function ensureNativeDeps() {
84
+ const marker = path.join(NATIVE_DIR, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node");
85
+ if (fs.existsSync(marker)) {
86
+ try {
87
+ const { createRequire } = require("module");
88
+ const req = createRequire(path.join(NATIVE_DIR, "noop.js"));
89
+ const BS3 = req("better-sqlite3");
90
+ new BS3(":memory:").close();
91
+ return;
92
+ } catch (err) {
93
+ if (!err.message?.includes("NODE_MODULE_VERSION")) return;
94
+ step("Native binary version mismatch \u2014 reinstalling...");
95
+ }
96
+ }
97
+ let version;
98
+ try {
99
+ const pkgPath = require.resolve("better-sqlite3/package.json", { paths: [SNA_CORE_DIR, ROOT] });
100
+ version = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
101
+ } catch {
102
+ version = "^12.0.0";
103
+ }
104
+ step(`Installing isolated better-sqlite3@${version} in .sna/native/`);
105
+ fs.mkdirSync(NATIVE_DIR, { recursive: true });
106
+ fs.writeFileSync(path.join(NATIVE_DIR, "package.json"), JSON.stringify({
107
+ name: "sna-native-deps",
108
+ private: true,
109
+ dependencies: { "better-sqlite3": version }
110
+ }));
111
+ try {
112
+ execSync("npm install --no-package-lock --ignore-scripts", { cwd: NATIVE_DIR, stdio: "pipe" });
113
+ execSync("npx --yes prebuild-install -r napi", {
114
+ cwd: path.join(NATIVE_DIR, "node_modules", "better-sqlite3"),
115
+ stdio: "pipe"
116
+ });
117
+ step("Native deps ready");
118
+ } catch (err) {
119
+ try {
120
+ execSync("npm rebuild better-sqlite3", { cwd: NATIVE_DIR, stdio: "pipe" });
121
+ step("Native deps ready (compiled from source)");
122
+ } catch {
123
+ console.error(`
124
+ \u2717 Failed to install isolated better-sqlite3: ${err.message}`);
125
+ console.error(` Try manually: cd .sna/native && npm install
126
+ `);
127
+ process.exit(1);
128
+ }
129
+ }
130
+ }
79
131
  async function cmdApiUp() {
80
132
  const standaloneEntry = path.join(SNA_CORE_DIR, "dist/server/standalone.js");
133
+ ensureNativeDeps();
81
134
  const existingPort = process.env.SNA_PORT ?? readSnaApiPort();
82
135
  if (existingPort && isPortInUse(existingPort)) {
83
136
  const healthy = await checkSnaApiHealth(existingPort);
@@ -144,6 +197,161 @@ function cmdApiDown() {
144
197
  }
145
198
  clearSnaApiState();
146
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
+ default:
216
+ console.log(`
217
+ sna tu \u2014 Test utilities (mock Anthropic API)
218
+
219
+ Commands:
220
+ sna tu api:up Start mock Anthropic API server
221
+ sna tu api:down Stop mock API server
222
+ sna tu api:log Show mock API request/response log
223
+ sna tu api:log -f Follow log in real-time (tail -f)
224
+ sna tu claude ... Run claude with mock API env vars (proxy)
225
+
226
+ Flow:
227
+ 1. sna tu api:up \u2192 mock server on random port
228
+ 2. sna tu claude "say hi" \u2192 real claude \u2192 mock API \u2192 mock response
229
+ 3. sna tu api:log -f \u2192 watch requests/responses in real-time
230
+ 4. sna tu api:down \u2192 cleanup
231
+
232
+ All requests/responses are logged to .sna/mock-api.log
233
+ `);
234
+ }
235
+ }
236
+ function cmdTuApiUp() {
237
+ ensureStateDir();
238
+ const existingPid = readPidFile(MOCK_API_PID_FILE);
239
+ const existingPort = readPortFile(MOCK_API_PORT_FILE);
240
+ if (existingPid && isProcessRunning(existingPid)) {
241
+ step(`Mock API already running on :${existingPort} (pid=${existingPid})`);
242
+ return;
243
+ }
244
+ const scriptDir = path.dirname(new URL(import.meta.url).pathname);
245
+ const mockEntry = path.join(scriptDir, "../testing/mock-api.js");
246
+ const mockEntrySrc = path.join(scriptDir, "../testing/mock-api.ts");
247
+ const resolvedMockEntry = fs.existsSync(mockEntry) ? mockEntry : mockEntrySrc;
248
+ if (!fs.existsSync(resolvedMockEntry)) {
249
+ console.error("\u2717 Mock API server not found. Run pnpm build first.");
250
+ process.exit(1);
251
+ }
252
+ const logStream = fs.openSync(MOCK_API_LOG_FILE, "w");
253
+ const startScript = `
254
+ import { startMockAnthropicServer } from "${resolvedMockEntry.replace(/\\/g, "/")}";
255
+ const mock = await startMockAnthropicServer();
256
+ const fs = await import("fs");
257
+ fs.writeFileSync("${MOCK_API_PORT_FILE.replace(/\\/g, "/")}", String(mock.port));
258
+ console.log("Mock Anthropic API ready on :" + mock.port);
259
+ // Keep alive
260
+ process.on("SIGTERM", () => { mock.close(); process.exit(0); });
261
+ `;
262
+ const child = spawn("node", ["--import", "tsx", "-e", startScript], {
263
+ cwd: ROOT,
264
+ detached: true,
265
+ stdio: ["ignore", logStream, logStream]
266
+ });
267
+ child.unref();
268
+ fs.writeFileSync(MOCK_API_PID_FILE, String(child.pid));
269
+ for (let i = 0; i < 20; i++) {
270
+ if (fs.existsSync(MOCK_API_PORT_FILE) && fs.readFileSync(MOCK_API_PORT_FILE, "utf8").trim()) break;
271
+ execSync("sleep 0.3", { stdio: "pipe" });
272
+ }
273
+ const port = readPortFile(MOCK_API_PORT_FILE);
274
+ if (port) {
275
+ step(`Mock Anthropic API \u2192 http://localhost:${port} (log: .sna/mock-api.log)`);
276
+ } else {
277
+ console.error("\u2717 Mock API failed to start. Check .sna/mock-api.log");
278
+ }
279
+ }
280
+ function cmdTuApiDown() {
281
+ const pid = readPidFile(MOCK_API_PID_FILE);
282
+ if (pid && isProcessRunning(pid)) {
283
+ try {
284
+ process.kill(pid, "SIGTERM");
285
+ } catch {
286
+ }
287
+ console.log(` Mock API \u2713 stopped (pid=${pid})`);
288
+ } else {
289
+ console.log(` Mock API \u2014 not running`);
290
+ }
291
+ try {
292
+ fs.unlinkSync(MOCK_API_PID_FILE);
293
+ } catch {
294
+ }
295
+ try {
296
+ fs.unlinkSync(MOCK_API_PORT_FILE);
297
+ } catch {
298
+ }
299
+ }
300
+ function cmdTuApiLog(args2) {
301
+ if (!fs.existsSync(MOCK_API_LOG_FILE)) {
302
+ console.log("No log file. Start mock API with: sna tu api:up");
303
+ return;
304
+ }
305
+ const follow = args2.includes("-f") || args2.includes("--follow");
306
+ if (follow) {
307
+ execSync(`tail -f "${MOCK_API_LOG_FILE}"`, { stdio: "inherit" });
308
+ } else {
309
+ execSync(`cat "${MOCK_API_LOG_FILE}"`, { stdio: "inherit" });
310
+ }
311
+ }
312
+ function cmdTuClaude(args2) {
313
+ const port = readPortFile(MOCK_API_PORT_FILE);
314
+ if (!port) {
315
+ console.error("\u2717 Mock API not running. Start with: sna tu api:up");
316
+ process.exit(1);
317
+ }
318
+ const claudePath = resolveAndCacheClaudePath();
319
+ const mockConfigDir = path.join(STATE_DIR, "mock-claude-config");
320
+ fs.mkdirSync(mockConfigDir, { recursive: true });
321
+ const env = {
322
+ PATH: process.env.PATH ?? "",
323
+ HOME: process.env.HOME ?? "",
324
+ SHELL: process.env.SHELL ?? "/bin/zsh",
325
+ TERM: process.env.TERM ?? "xterm-256color",
326
+ LANG: process.env.LANG ?? "en_US.UTF-8",
327
+ ANTHROPIC_BASE_URL: `http://localhost:${port}`,
328
+ ANTHROPIC_API_KEY: "sk-test-mock-sna",
329
+ CLAUDE_CONFIG_DIR: mockConfigDir
330
+ };
331
+ try {
332
+ execSync(`"${claudePath}" ${args2.map((a) => `"${a}"`).join(" ")}`, {
333
+ stdio: "inherit",
334
+ env,
335
+ cwd: ROOT
336
+ });
337
+ } catch (e) {
338
+ process.exit(e.status ?? 1);
339
+ }
340
+ }
341
+ function readPidFile(filePath) {
342
+ try {
343
+ return parseInt(fs.readFileSync(filePath, "utf8").trim(), 10) || null;
344
+ } catch {
345
+ return null;
346
+ }
347
+ }
348
+ function readPortFile(filePath) {
349
+ try {
350
+ return fs.readFileSync(filePath, "utf8").trim() || null;
351
+ } catch {
352
+ return null;
353
+ }
354
+ }
147
355
  function isPortInUse(port) {
148
356
  try {
149
357
  execSync(`lsof -ti:${port}`, { stdio: "pipe" });
@@ -556,7 +764,12 @@ Lifecycle:
556
764
  sna restart Stop + start
557
765
  sna init [--force] Initialize .claude/settings.json and skills
558
766
  sna validate Check project setup (skills.json, hooks, deps)
767
+
768
+ Tools:
559
769
  sna dispatch Unified event dispatcher (open/send/close)
770
+ sna gen client Generate typed skill client + .sna/skills.json
771
+ sna api:up Start standalone SNA API server
772
+ sna api:down Stop SNA API server
560
773
 
561
774
  Workflow:
562
775
  sna new <skill> [--param val ...] Create a task from a workflow.yml
@@ -566,7 +779,15 @@ Workflow:
566
779
  sna <task-id> cancel Cancel a running task
567
780
  sna tasks List all tasks with status
568
781
 
569
- Task IDs are 10-digit timestamps (MMDDHHmmss), e.g. 0317143052
782
+ Testing:
783
+ sna tu api:up Start mock Anthropic API server
784
+ sna tu api:down Stop mock API server
785
+ sna tu api:log Show mock API request/response log
786
+ sna tu api:log -f Follow log in real-time
787
+ sna tu claude ... Run claude with mock API (isolated env, no account pollution)
788
+
789
+ Set SNA_CLAUDE_COMMAND to override claude binary in SDK.
790
+ See: docs/testing.md
570
791
 
571
792
  Run "sna help workflow" for workflow.yml specification.
572
793
  Run "sna help submit" for data submission patterns.`);
@@ -766,6 +987,9 @@ Run "sna help submit" for data submission patterns.`);
766
987
  cmdApiDown();
767
988
  cmdApiUp();
768
989
  break;
990
+ case "tu":
991
+ cmdTu(args);
992
+ break;
769
993
  case "restart":
770
994
  cmdDown();
771
995
  console.log();