@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 +17 -8
- package/dist/core/providers/claude-code.js +73 -7
- package/dist/core/providers/types.d.ts +31 -4
- package/dist/db/schema.d.ts +1 -0
- package/dist/db/schema.js +19 -2
- package/dist/index.d.ts +1 -1
- package/dist/lib/logger.d.ts +1 -0
- package/dist/lib/logger.js +2 -0
- package/dist/scripts/hook.js +1 -1
- package/dist/scripts/sna.js +225 -1
- package/dist/server/api-types.d.ts +120 -0
- package/dist/server/api-types.js +13 -0
- package/dist/server/image-store.d.ts +23 -0
- package/dist/server/image-store.js +34 -0
- package/dist/server/index.d.ts +5 -2
- package/dist/server/index.js +7 -4
- package/dist/server/routes/agent.d.ts +21 -1
- package/dist/server/routes/agent.js +169 -65
- package/dist/server/routes/chat.js +30 -7
- package/dist/server/routes/emit.d.ts +11 -1
- package/dist/server/routes/emit.js +26 -0
- package/dist/server/session-manager.d.ts +74 -1
- package/dist/server/session-manager.js +261 -3
- package/dist/server/standalone.js +1146 -97
- package/dist/server/ws.d.ts +55 -0
- package/dist/server/ws.js +532 -0
- package/dist/testing/mock-api.d.ts +35 -0
- package/dist/testing/mock-api.js +140 -0
- package/package.json +9 -2
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
|
-
- **
|
|
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
|
-
//
|
|
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`, `
|
|
75
|
-
| `@sna-sdk/core/server` | `createSnaApp()`, route handlers, `SessionManager` |
|
|
76
|
-
| `@sna-sdk/core/
|
|
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
|
|
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 === "
|
|
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
|
|
231
|
-
const
|
|
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
|
-
|
|
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 ${
|
|
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 };
|
package/dist/db/schema.d.ts
CHANGED
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
|
|
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';
|
package/dist/lib/logger.d.ts
CHANGED
package/dist/lib/logger.js
CHANGED
|
@@ -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) {
|
package/dist/scripts/hook.js
CHANGED
|
@@ -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",
|
package/dist/scripts/sna.js
CHANGED
|
@@ -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
|
-
|
|
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();
|