@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 +7 -1
- package/dist/core/providers/cc-history-adapter.d.ts +37 -0
- package/dist/core/providers/cc-history-adapter.js +70 -0
- package/dist/core/providers/claude-code.js +57 -7
- package/dist/core/providers/types.d.ts +32 -5
- package/dist/db/schema.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/scripts/sna.js +193 -1
- package/dist/scripts/tu-oneshot.d.ts +2 -0
- package/dist/scripts/tu-oneshot.js +66 -0
- package/dist/server/api-types.d.ts +22 -0
- package/dist/server/history-builder.d.ts +16 -0
- package/dist/server/history-builder.js +25 -0
- package/dist/server/image-store.d.ts +23 -0
- package/dist/server/image-store.js +34 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -0
- package/dist/server/routes/agent.js +91 -10
- package/dist/server/routes/chat.js +22 -0
- package/dist/server/session-manager.d.ts +31 -4
- package/dist/server/session-manager.js +85 -12
- package/dist/server/standalone.js +488 -51
- package/dist/server/ws.js +103 -9
- package/dist/testing/mock-api.d.ts +35 -0
- package/dist/testing/mock-api.js +160 -0
- package/package.json +6 -1
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
|
-
- **
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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';
|
package/dist/scripts/sna.js
CHANGED
|
@@ -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
|
-
|
|
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,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();
|