@sna-sdk/core 0.0.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/bin/sna.js +18 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +104 -0
- package/dist/core/providers/claude-code.d.ts +9 -0
- package/dist/core/providers/claude-code.js +257 -0
- package/dist/core/providers/codex.d.ts +18 -0
- package/dist/core/providers/codex.js +14 -0
- package/dist/core/providers/index.d.ts +14 -0
- package/dist/core/providers/index.js +22 -0
- package/dist/core/providers/types.d.ts +52 -0
- package/dist/core/providers/types.js +0 -0
- package/dist/db/schema.d.ts +13 -0
- package/dist/db/schema.js +41 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +6 -0
- package/dist/lib/logger.d.ts +18 -0
- package/dist/lib/logger.js +50 -0
- package/dist/lib/sna-run.d.ts +25 -0
- package/dist/lib/sna-run.js +74 -0
- package/dist/scripts/emit.d.ts +2 -0
- package/dist/scripts/emit.js +48 -0
- package/dist/scripts/hook.d.ts +2 -0
- package/dist/scripts/hook.js +34 -0
- package/dist/scripts/init-db.d.ts +2 -0
- package/dist/scripts/init-db.js +3 -0
- package/dist/scripts/sna.d.ts +2 -0
- package/dist/scripts/sna.js +650 -0
- package/dist/scripts/workflow.d.ts +112 -0
- package/dist/scripts/workflow.js +622 -0
- package/dist/server/index.d.ts +30 -0
- package/dist/server/index.js +43 -0
- package/dist/server/routes/agent.d.ts +8 -0
- package/dist/server/routes/agent.js +148 -0
- package/dist/server/routes/emit.d.ts +11 -0
- package/dist/server/routes/emit.js +15 -0
- package/dist/server/routes/events.d.ts +12 -0
- package/dist/server/routes/events.js +54 -0
- package/dist/server/routes/run.d.ts +19 -0
- package/dist/server/routes/run.js +51 -0
- package/dist/server/session-manager.d.ts +64 -0
- package/dist/server/session-manager.js +101 -0
- package/dist/server/standalone.js +820 -0
- package/package.json +91 -0
- package/skills/sna-down/SKILL.md +23 -0
- package/skills/sna-up/SKILL.md +40 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import { execSync, spawn } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { cmdNew, cmdWorkflow, cmdCancel, cmdTasks } from "./workflow.js";
|
|
5
|
+
const ROOT = process.cwd();
|
|
6
|
+
const STATE_DIR = path.join(ROOT, ".sna");
|
|
7
|
+
const PID_FILE = path.join(STATE_DIR, "server.pid");
|
|
8
|
+
const PORT_FILE = path.join(STATE_DIR, "port");
|
|
9
|
+
const LOG_FILE = path.join(STATE_DIR, "server.log");
|
|
10
|
+
const SNA_API_PID_FILE = path.join(STATE_DIR, "sna-api.pid");
|
|
11
|
+
const SNA_API_PORT_FILE = path.join(STATE_DIR, "sna-api.port");
|
|
12
|
+
const SNA_API_LOG_FILE = path.join(STATE_DIR, "sna-api.log");
|
|
13
|
+
const PORT = process.env.PORT ?? "3000";
|
|
14
|
+
const DB_PATH = path.join(ROOT, "data/app.db");
|
|
15
|
+
const CLAUDE_PATH_FILE = path.join(STATE_DIR, "claude-path");
|
|
16
|
+
const SNA_CORE_DIR = path.join(ROOT, "node_modules/sna");
|
|
17
|
+
function ensureStateDir() {
|
|
18
|
+
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
function isProcessRunning(pid) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function readPid() {
|
|
29
|
+
if (!fs.existsSync(PID_FILE)) return null;
|
|
30
|
+
const raw = fs.readFileSync(PID_FILE, "utf8").trim();
|
|
31
|
+
const pid = parseInt(raw);
|
|
32
|
+
return isNaN(pid) ? null : pid;
|
|
33
|
+
}
|
|
34
|
+
function writePid(pid) {
|
|
35
|
+
ensureStateDir();
|
|
36
|
+
fs.writeFileSync(PID_FILE, String(pid));
|
|
37
|
+
fs.writeFileSync(PORT_FILE, String(PORT));
|
|
38
|
+
}
|
|
39
|
+
function clearState() {
|
|
40
|
+
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
|
|
41
|
+
if (fs.existsSync(PORT_FILE)) fs.unlinkSync(PORT_FILE);
|
|
42
|
+
}
|
|
43
|
+
function readSnaApiPid() {
|
|
44
|
+
if (!fs.existsSync(SNA_API_PID_FILE)) return null;
|
|
45
|
+
const raw = fs.readFileSync(SNA_API_PID_FILE, "utf8").trim();
|
|
46
|
+
const pid = parseInt(raw);
|
|
47
|
+
return isNaN(pid) ? null : pid;
|
|
48
|
+
}
|
|
49
|
+
function readSnaApiPort() {
|
|
50
|
+
if (!fs.existsSync(SNA_API_PORT_FILE)) return null;
|
|
51
|
+
return fs.readFileSync(SNA_API_PORT_FILE, "utf8").trim() || null;
|
|
52
|
+
}
|
|
53
|
+
function clearSnaApiState() {
|
|
54
|
+
for (const f of [SNA_API_PID_FILE, SNA_API_PORT_FILE]) {
|
|
55
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function findFreePort() {
|
|
59
|
+
const net = require("net");
|
|
60
|
+
const srv = net.createServer();
|
|
61
|
+
srv.listen(0);
|
|
62
|
+
const port = String(srv.address().port);
|
|
63
|
+
srv.close();
|
|
64
|
+
return port;
|
|
65
|
+
}
|
|
66
|
+
async function checkSnaApiHealth(port) {
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(`http://localhost:${port}/health`, {
|
|
69
|
+
signal: AbortSignal.timeout(2e3)
|
|
70
|
+
});
|
|
71
|
+
const json = await res.json();
|
|
72
|
+
return json.name === "sna";
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function cmdApiUp() {
|
|
78
|
+
const standaloneEntry = path.join(SNA_CORE_DIR, "dist/server/standalone.js");
|
|
79
|
+
const existingPort = process.env.SNA_PORT ?? readSnaApiPort();
|
|
80
|
+
if (existingPort && isPortInUse(existingPort)) {
|
|
81
|
+
const healthy = await checkSnaApiHealth(existingPort);
|
|
82
|
+
if (healthy) {
|
|
83
|
+
step(`SNA API already running on :${existingPort} \u2014 reusing`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!fs.existsSync(standaloneEntry)) {
|
|
88
|
+
console.error(`
|
|
89
|
+
\u2717 SNA standalone server not found: ${standaloneEntry}`);
|
|
90
|
+
console.error(` Run "pnpm build" in sna-core.
|
|
91
|
+
`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
const staleApiPid = readSnaApiPid();
|
|
95
|
+
if (staleApiPid && isProcessRunning(staleApiPid)) {
|
|
96
|
+
try {
|
|
97
|
+
process.kill(staleApiPid, "SIGTERM");
|
|
98
|
+
} catch {
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const port = process.env.SNA_PORT ?? (existingPort && !isPortInUse(existingPort) ? existingPort : findFreePort());
|
|
102
|
+
ensureStateDir();
|
|
103
|
+
const logStream = fs.openSync(SNA_API_LOG_FILE, "w");
|
|
104
|
+
const child = spawn("node", [standaloneEntry], {
|
|
105
|
+
cwd: ROOT,
|
|
106
|
+
detached: true,
|
|
107
|
+
stdio: ["ignore", logStream, logStream],
|
|
108
|
+
env: { ...process.env, SNA_PORT: port }
|
|
109
|
+
});
|
|
110
|
+
child.unref();
|
|
111
|
+
fs.writeFileSync(SNA_API_PID_FILE, String(child.pid));
|
|
112
|
+
fs.writeFileSync(SNA_API_PORT_FILE, port);
|
|
113
|
+
step(`SNA API server \u2192 http://localhost:${port}`);
|
|
114
|
+
}
|
|
115
|
+
function cmdApiDown() {
|
|
116
|
+
const pid = readSnaApiPid();
|
|
117
|
+
const port = readSnaApiPort();
|
|
118
|
+
if (pid && isProcessRunning(pid)) {
|
|
119
|
+
try {
|
|
120
|
+
process.kill(pid, "SIGTERM");
|
|
121
|
+
} catch {
|
|
122
|
+
}
|
|
123
|
+
for (let i = 0; i < 6; i++) {
|
|
124
|
+
if (!isProcessRunning(pid)) break;
|
|
125
|
+
execSync("sleep 0.5", { stdio: "pipe" });
|
|
126
|
+
}
|
|
127
|
+
if (isProcessRunning(pid)) {
|
|
128
|
+
try {
|
|
129
|
+
process.kill(pid, "SIGKILL");
|
|
130
|
+
} catch {
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
console.log(` SNA API \u2713 stopped (pid=${pid})`);
|
|
134
|
+
} else {
|
|
135
|
+
console.log(` SNA API \u2014 not running`);
|
|
136
|
+
}
|
|
137
|
+
if (port) {
|
|
138
|
+
try {
|
|
139
|
+
execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null`, { stdio: "pipe" });
|
|
140
|
+
} catch {
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
clearSnaApiState();
|
|
144
|
+
}
|
|
145
|
+
function isPortInUse(port) {
|
|
146
|
+
try {
|
|
147
|
+
execSync(`lsof -ti:${port}`, { stdio: "pipe" });
|
|
148
|
+
return true;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function resolveAndCacheClaudePath() {
|
|
154
|
+
const SHELL = process.env.SHELL || "/bin/zsh";
|
|
155
|
+
const candidates = [
|
|
156
|
+
"/opt/homebrew/bin/claude",
|
|
157
|
+
"/usr/local/bin/claude",
|
|
158
|
+
`${process.env.HOME}/.local/bin/claude`
|
|
159
|
+
];
|
|
160
|
+
for (const p of candidates) {
|
|
161
|
+
try {
|
|
162
|
+
execSync(`test -x "${p}"`, { stdio: "pipe" });
|
|
163
|
+
fs.writeFileSync(CLAUDE_PATH_FILE, p);
|
|
164
|
+
return p;
|
|
165
|
+
} catch {
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const resolved = execSync(`${SHELL} -l -c "which claude"`, { encoding: "utf8" }).trim();
|
|
170
|
+
fs.writeFileSync(CLAUDE_PATH_FILE, resolved);
|
|
171
|
+
return resolved;
|
|
172
|
+
} catch {
|
|
173
|
+
return "claude";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function openBrowser(url) {
|
|
177
|
+
try {
|
|
178
|
+
execSync(`open "${url}"`, { stdio: "ignore" });
|
|
179
|
+
} catch {
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function step(label) {
|
|
183
|
+
console.log(` \u2713 ${label}`);
|
|
184
|
+
}
|
|
185
|
+
function cmdUp() {
|
|
186
|
+
console.log("\u25B6 Skills-Native App \u2014 startup\n");
|
|
187
|
+
const existingPid = readPid();
|
|
188
|
+
if (existingPid && isProcessRunning(existingPid)) {
|
|
189
|
+
const port = fs.existsSync(PORT_FILE) ? fs.readFileSync(PORT_FILE, "utf8").trim() : PORT;
|
|
190
|
+
console.log(`Already running (pid=${existingPid})`);
|
|
191
|
+
console.log(`\u2192 http://localhost:${port}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const node = execSync("node --version", { encoding: "utf8" }).trim();
|
|
196
|
+
const pnpmVersion = execSync("pnpm --version", { encoding: "utf8" }).trim();
|
|
197
|
+
step(`Node ${node} / pnpm ${pnpmVersion}`);
|
|
198
|
+
} catch {
|
|
199
|
+
console.error("\n\u2717 Node.js or pnpm not found.");
|
|
200
|
+
console.error(" Install Node.js: https://nodejs.org");
|
|
201
|
+
console.error(" Then: npm install -g pnpm");
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
const nmPath = path.join(ROOT, "node_modules");
|
|
205
|
+
const pkgPath = path.join(ROOT, "package.json");
|
|
206
|
+
const needsInstall = !fs.existsSync(nmPath) || fs.statSync(pkgPath).mtimeMs > fs.statSync(nmPath).mtimeMs;
|
|
207
|
+
if (needsInstall) {
|
|
208
|
+
process.stdout.write(" \u2026 Installing dependencies");
|
|
209
|
+
try {
|
|
210
|
+
execSync("pnpm install --frozen-lockfile", { cwd: ROOT, stdio: "pipe" });
|
|
211
|
+
console.log("\r \u2713 Dependencies installed ");
|
|
212
|
+
} catch {
|
|
213
|
+
execSync("pnpm install", { cwd: ROOT, stdio: "pipe" });
|
|
214
|
+
console.log("\r \u2713 Dependencies installed ");
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
step("Dependencies ready");
|
|
218
|
+
}
|
|
219
|
+
cmdInit();
|
|
220
|
+
if (!fs.existsSync(DB_PATH)) {
|
|
221
|
+
process.stdout.write(" \u2026 Setting up database");
|
|
222
|
+
execSync("pnpm db:init", { cwd: ROOT, stdio: "pipe" });
|
|
223
|
+
console.log("\r \u2713 Database initialized ");
|
|
224
|
+
} else {
|
|
225
|
+
step("Database ready");
|
|
226
|
+
}
|
|
227
|
+
if (isPortInUse(PORT)) {
|
|
228
|
+
process.stdout.write(` \u2026 Port ${PORT} busy \u2014 freeing`);
|
|
229
|
+
try {
|
|
230
|
+
execSync(`lsof -ti:${PORT} | xargs kill -9`, { stdio: "pipe" });
|
|
231
|
+
} catch {
|
|
232
|
+
}
|
|
233
|
+
console.log(`\r \u2713 Port ${PORT} cleared `);
|
|
234
|
+
}
|
|
235
|
+
ensureStateDir();
|
|
236
|
+
const claudePath = resolveAndCacheClaudePath();
|
|
237
|
+
step(`Claude binary: ${claudePath}`);
|
|
238
|
+
const standaloneEntry = path.join(SNA_CORE_DIR, "dist/server/standalone.js");
|
|
239
|
+
if (fs.existsSync(standaloneEntry)) {
|
|
240
|
+
const staleApiPid = readSnaApiPid();
|
|
241
|
+
const staleApiPort = readSnaApiPort();
|
|
242
|
+
if (staleApiPid && isProcessRunning(staleApiPid)) {
|
|
243
|
+
try {
|
|
244
|
+
process.kill(staleApiPid, "SIGTERM");
|
|
245
|
+
} catch {
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (staleApiPort && isPortInUse(staleApiPort)) {
|
|
249
|
+
try {
|
|
250
|
+
execSync(`lsof -ti:${staleApiPort} | xargs kill -9`, { stdio: "pipe" });
|
|
251
|
+
} catch {
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const snaApiPort = process.env.SNA_PORT ?? findFreePort();
|
|
255
|
+
const snaApiLogStream = fs.openSync(SNA_API_LOG_FILE, "w");
|
|
256
|
+
const snaApiChild = spawn("node", [standaloneEntry], {
|
|
257
|
+
cwd: ROOT,
|
|
258
|
+
detached: true,
|
|
259
|
+
stdio: ["ignore", snaApiLogStream, snaApiLogStream],
|
|
260
|
+
env: { ...process.env, SNA_PORT: snaApiPort }
|
|
261
|
+
});
|
|
262
|
+
snaApiChild.unref();
|
|
263
|
+
ensureStateDir();
|
|
264
|
+
fs.writeFileSync(SNA_API_PID_FILE, String(snaApiChild.pid));
|
|
265
|
+
fs.writeFileSync(SNA_API_PORT_FILE, snaApiPort);
|
|
266
|
+
step(`SNA API server \u2192 http://localhost:${snaApiPort}`);
|
|
267
|
+
}
|
|
268
|
+
const logStream = fs.openSync(LOG_FILE, "w");
|
|
269
|
+
const child = spawn("pnpm", ["dev"], {
|
|
270
|
+
cwd: ROOT,
|
|
271
|
+
detached: true,
|
|
272
|
+
stdio: ["ignore", logStream, logStream],
|
|
273
|
+
env: { ...process.env, PORT }
|
|
274
|
+
});
|
|
275
|
+
child.unref();
|
|
276
|
+
writePid(child.pid);
|
|
277
|
+
process.stdout.write(" \u2026 Starting web server");
|
|
278
|
+
let ready = false;
|
|
279
|
+
for (let i = 0; i < 30; i++) {
|
|
280
|
+
execSync("sleep 1");
|
|
281
|
+
if (isPortInUse(PORT)) {
|
|
282
|
+
ready = true;
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
process.stdout.write(".");
|
|
286
|
+
}
|
|
287
|
+
console.log(ready ? "\r \u2713 Web server running " : "\r \u25B3 Web server starting (check .sna/server.log)");
|
|
288
|
+
const url = `http://localhost:${PORT}`;
|
|
289
|
+
openBrowser(url);
|
|
290
|
+
console.log(`
|
|
291
|
+
\u2713 SNA is up
|
|
292
|
+
|
|
293
|
+
App \u2192 ${url}
|
|
294
|
+
|
|
295
|
+
Logs: .sna/server.log`);
|
|
296
|
+
}
|
|
297
|
+
function cmdDown() {
|
|
298
|
+
console.log("\u25A0 Stopping Skills-Native App...\n");
|
|
299
|
+
const pid = readPid();
|
|
300
|
+
if (!pid) {
|
|
301
|
+
console.log(" Not running (no PID file)");
|
|
302
|
+
clearState();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (!isProcessRunning(pid)) {
|
|
306
|
+
console.log(" Process already gone");
|
|
307
|
+
clearState();
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
process.kill(-pid, "SIGTERM");
|
|
312
|
+
} catch {
|
|
313
|
+
try {
|
|
314
|
+
process.kill(pid, "SIGKILL");
|
|
315
|
+
} catch {
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
let freed = false;
|
|
319
|
+
for (let i = 0; i < 10; i++) {
|
|
320
|
+
execSync("sleep 0.5");
|
|
321
|
+
if (!isPortInUse(PORT)) {
|
|
322
|
+
freed = true;
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
clearState();
|
|
327
|
+
console.log(` Web server \u2713 (pid=${pid} stopped)`);
|
|
328
|
+
if (!freed) console.log(` Note: port ${PORT} may still be in use briefly`);
|
|
329
|
+
const snaApiPid = readSnaApiPid();
|
|
330
|
+
if (snaApiPid && isProcessRunning(snaApiPid)) {
|
|
331
|
+
try {
|
|
332
|
+
process.kill(snaApiPid, "SIGTERM");
|
|
333
|
+
} catch {
|
|
334
|
+
}
|
|
335
|
+
console.log(` SNA API \u2713 (pid=${snaApiPid} stopped)`);
|
|
336
|
+
}
|
|
337
|
+
clearSnaApiState();
|
|
338
|
+
console.log("\n\u2713 SNA is down");
|
|
339
|
+
}
|
|
340
|
+
function cmdStatus() {
|
|
341
|
+
const pid = readPid();
|
|
342
|
+
const port = fs.existsSync(PORT_FILE) ? fs.readFileSync(PORT_FILE, "utf8").trim() : PORT;
|
|
343
|
+
console.log("\u2500\u2500 SNA Status \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
344
|
+
if (pid && isProcessRunning(pid)) {
|
|
345
|
+
console.log(` Web server \u2713 running (pid=${pid}, port=${port})`);
|
|
346
|
+
console.log(` URL http://localhost:${port}`);
|
|
347
|
+
} else {
|
|
348
|
+
console.log(` Web server \u2717 stopped`);
|
|
349
|
+
if (pid) clearState();
|
|
350
|
+
}
|
|
351
|
+
const snaApiPid = readSnaApiPid();
|
|
352
|
+
const snaApiPort = readSnaApiPort();
|
|
353
|
+
if (snaApiPid && isProcessRunning(snaApiPid)) {
|
|
354
|
+
console.log(` SNA API \u2713 running (pid=${snaApiPid}, port=${snaApiPort ?? "?"})`);
|
|
355
|
+
} else {
|
|
356
|
+
console.log(` SNA API \u2717 stopped`);
|
|
357
|
+
if (snaApiPid) clearSnaApiState();
|
|
358
|
+
}
|
|
359
|
+
if (fs.existsSync(DB_PATH)) {
|
|
360
|
+
const stat = fs.statSync(DB_PATH);
|
|
361
|
+
const kb = (stat.size / 1024).toFixed(1);
|
|
362
|
+
console.log(` Database \u2713 ${kb} KB (${DB_PATH})`);
|
|
363
|
+
} else {
|
|
364
|
+
console.log(` Database \u2717 not initialized`);
|
|
365
|
+
}
|
|
366
|
+
console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
367
|
+
}
|
|
368
|
+
function cmdInit(force2 = false) {
|
|
369
|
+
console.log(`\u25B6 SNA \u2014 project init${force2 ? " (--force)" : ""}
|
|
370
|
+
`);
|
|
371
|
+
const claudeDir = path.join(ROOT, ".claude");
|
|
372
|
+
const settingsPath = path.join(claudeDir, "settings.json");
|
|
373
|
+
if (!fs.existsSync(claudeDir)) {
|
|
374
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
375
|
+
}
|
|
376
|
+
const hookCommand = `node "$CLAUDE_PROJECT_DIR"/node_modules/sna/dist/scripts/hook.js`;
|
|
377
|
+
const permissionHook = {
|
|
378
|
+
matcher: ".*",
|
|
379
|
+
hooks: [{ type: "command", async: true, command: hookCommand }]
|
|
380
|
+
};
|
|
381
|
+
let settings = {};
|
|
382
|
+
if (fs.existsSync(settingsPath)) {
|
|
383
|
+
try {
|
|
384
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
385
|
+
} catch {
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const hooks = settings.hooks ?? {};
|
|
389
|
+
const existing = hooks.PermissionRequest ?? [];
|
|
390
|
+
const alreadySet = existing.some(
|
|
391
|
+
(entry) => entry.hooks?.some((h) => h.command === hookCommand)
|
|
392
|
+
);
|
|
393
|
+
if (!alreadySet) {
|
|
394
|
+
hooks.PermissionRequest = [...existing, permissionHook];
|
|
395
|
+
settings.hooks = hooks;
|
|
396
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
397
|
+
step(".claude/settings.json \u2014 PermissionRequest hook registered");
|
|
398
|
+
} else {
|
|
399
|
+
step(".claude/settings.json \u2014 hook already set, skipped");
|
|
400
|
+
}
|
|
401
|
+
const claudeMdTemplate = path.join(ROOT, "node_modules/sna/CLAUDE.md.template");
|
|
402
|
+
const claudeMdDest = path.join(claudeDir, "CLAUDE.md");
|
|
403
|
+
if (fs.existsSync(claudeMdTemplate)) {
|
|
404
|
+
if (force2 || !fs.existsSync(claudeMdDest)) {
|
|
405
|
+
fs.copyFileSync(claudeMdTemplate, claudeMdDest);
|
|
406
|
+
step(".claude/CLAUDE.md \u2014 created");
|
|
407
|
+
} else {
|
|
408
|
+
step(".claude/CLAUDE.md \u2014 already exists, skipped");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const snaCoreSkillsDir = path.join(ROOT, "node_modules/sna/skills");
|
|
412
|
+
const destSkillsDir = path.join(claudeDir, "skills");
|
|
413
|
+
if (fs.existsSync(snaCoreSkillsDir)) {
|
|
414
|
+
const skillNames = fs.readdirSync(snaCoreSkillsDir);
|
|
415
|
+
for (const skillName of skillNames) {
|
|
416
|
+
const src = path.join(snaCoreSkillsDir, skillName, "SKILL.md");
|
|
417
|
+
if (!fs.existsSync(src)) continue;
|
|
418
|
+
const destDir = path.join(destSkillsDir, skillName);
|
|
419
|
+
const dest = path.join(destDir, "SKILL.md");
|
|
420
|
+
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
421
|
+
if (force2 || !fs.existsSync(dest)) {
|
|
422
|
+
fs.copyFileSync(src, dest);
|
|
423
|
+
step(`.claude/skills/${skillName}/SKILL.md \u2014 installed`);
|
|
424
|
+
} else {
|
|
425
|
+
step(`.claude/skills/${skillName}/SKILL.md \u2014 already exists, skipped`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
console.log("\n\u2713 SNA init complete");
|
|
430
|
+
}
|
|
431
|
+
function printHelp() {
|
|
432
|
+
console.log(`sna \u2014 Skills-Native Application CLI
|
|
433
|
+
|
|
434
|
+
Usage:
|
|
435
|
+
sna <command> [options]
|
|
436
|
+
|
|
437
|
+
Lifecycle:
|
|
438
|
+
sna up Start all services (DB, WebSocket, dev server)
|
|
439
|
+
sna down Stop all services
|
|
440
|
+
sna status Show running services
|
|
441
|
+
sna restart Stop + start
|
|
442
|
+
sna init [--force] Initialize .claude/settings.json and skills
|
|
443
|
+
|
|
444
|
+
Workflow:
|
|
445
|
+
sna new <skill> [--param val ...] Create a task from a workflow.yml
|
|
446
|
+
sna <task-id> start Resume a paused task (retries on error)
|
|
447
|
+
sna <task-id> next [--key val] Submit scalar data (CLI flags)
|
|
448
|
+
sna <task-id> next <<'EOF' ... EOF Submit structured data (stdin JSON)
|
|
449
|
+
sna <task-id> cancel Cancel a running task
|
|
450
|
+
sna tasks List all tasks with status
|
|
451
|
+
|
|
452
|
+
Task IDs are 10-digit timestamps (MMDDHHmmss), e.g. 0317143052
|
|
453
|
+
|
|
454
|
+
Run "sna help workflow" for workflow.yml specification.
|
|
455
|
+
Run "sna help submit" for data submission patterns.`);
|
|
456
|
+
}
|
|
457
|
+
function printWorkflowHelp() {
|
|
458
|
+
console.log(`sna help workflow \u2014 workflow.yml specification
|
|
459
|
+
|
|
460
|
+
A workflow defines the steps a skill must follow. The CLI enforces
|
|
461
|
+
step ordering, data validation, and event emission automatically.
|
|
462
|
+
|
|
463
|
+
Location:
|
|
464
|
+
.claude/skills/<skill-name>/workflow.yml
|
|
465
|
+
|
|
466
|
+
Structure:
|
|
467
|
+
version: 1
|
|
468
|
+
skill: <skill-name>
|
|
469
|
+
|
|
470
|
+
params: # CLI flags for "sna new"
|
|
471
|
+
query:
|
|
472
|
+
type: string # string | integer | number | boolean
|
|
473
|
+
required: true
|
|
474
|
+
|
|
475
|
+
steps: # executed in order
|
|
476
|
+
- id: <unique-id>
|
|
477
|
+
name: "Step display name"
|
|
478
|
+
|
|
479
|
+
# === Step type A: exec (CLI auto-executes) ===
|
|
480
|
+
exec: "curl -s http://..." # shell command, {{param}} interpolated
|
|
481
|
+
extract: # parse JSON response into context
|
|
482
|
+
field_name: ".json_key" # ".key", ".a.b.c", ".[0]", "[.[] | .key]", "."
|
|
483
|
+
event: "Message with {{field_name}}" # emitted as milestone
|
|
484
|
+
timeout: 60000 # optional, ms (default: 30000)
|
|
485
|
+
|
|
486
|
+
# === Step type B: instruction (model does work) ===
|
|
487
|
+
instruction: | # displayed to the model
|
|
488
|
+
Do this task. Use {{param}} from context.
|
|
489
|
+
|
|
490
|
+
# --- Option 1: structured data via stdin JSON ---
|
|
491
|
+
submit:
|
|
492
|
+
type: array # array | object
|
|
493
|
+
items: # field definitions
|
|
494
|
+
company_name: { type: string, required: true }
|
|
495
|
+
url: { type: string, required: true }
|
|
496
|
+
notes: { type: string } # required defaults to false
|
|
497
|
+
handler: | # CLI executes with {{submitted}} = JSON string
|
|
498
|
+
curl -s -X POST http://localhost:3000/api/endpoint \\
|
|
499
|
+
-H 'Content-Type: application/json' -d '{{submitted}}'
|
|
500
|
+
extract: # parse handler response into context
|
|
501
|
+
registered: ".registered"
|
|
502
|
+
event: "{{registered}} items processed"
|
|
503
|
+
timeout: 60000 # optional, ms (default: 30000)
|
|
504
|
+
|
|
505
|
+
# --- Option 2: scalar values via CLI flags ---
|
|
506
|
+
data:
|
|
507
|
+
- key: count
|
|
508
|
+
when: after
|
|
509
|
+
type: integer # string | integer | number | boolean | json
|
|
510
|
+
label: "\u4EF6\u6570"
|
|
511
|
+
event: "{{count}} items found"
|
|
512
|
+
|
|
513
|
+
complete: "Done: {{field}}" # interpolated with context
|
|
514
|
+
error: "Failed: {{error}}" # {{error}} is auto-set on failure
|
|
515
|
+
|
|
516
|
+
Execution rules:
|
|
517
|
+
- exec steps auto-chain: if multiple exec steps are consecutive,
|
|
518
|
+
CLI runs them all without stopping.
|
|
519
|
+
- instruction steps pause: CLI displays the instruction and waits
|
|
520
|
+
for "sna <id> next" with the required data.
|
|
521
|
+
- Events are emitted to SQLite skill_events automatically.
|
|
522
|
+
- Task state is saved to .sna/tasks/<id>.json after each step.
|
|
523
|
+
|
|
524
|
+
Error recovery:
|
|
525
|
+
- If a task errors, "sna <id> start" retries from the failed step.
|
|
526
|
+
- The step and task status are reset to in_progress automatically.
|
|
527
|
+
- exec steps are re-executed; instruction steps re-display.
|
|
528
|
+
|
|
529
|
+
Cancel:
|
|
530
|
+
- "sna <id> cancel" permanently stops a task.
|
|
531
|
+
- Status is set to "cancelled" and an error event is emitted.
|
|
532
|
+
- Cancelled tasks cannot be resumed \u2014 create a new task instead.
|
|
533
|
+
|
|
534
|
+
Task management:
|
|
535
|
+
- "sna tasks" lists all tasks with ID, skill, status, and current step.
|
|
536
|
+
- Task state files: .sna/tasks/<id>.json`);
|
|
537
|
+
}
|
|
538
|
+
function printSubmitHelp() {
|
|
539
|
+
console.log(`sna help submit \u2014 data submission patterns
|
|
540
|
+
|
|
541
|
+
Workflow steps can receive data in two ways:
|
|
542
|
+
|
|
543
|
+
1. Structured data (stdin JSON) \u2014 for complex/bulk data
|
|
544
|
+
Used when the step has "submit" + "handler" in workflow.yml.
|
|
545
|
+
|
|
546
|
+
The model submits JSON via stdin:
|
|
547
|
+
sna <task-id> next <<'EOF'
|
|
548
|
+
[
|
|
549
|
+
{"company_name": "Foo Corp", "url": "https://foo.co", "form_url": "https://foo.co/contact"},
|
|
550
|
+
{"company_name": "Bar Inc", "url": "https://bar.io", "form_url": "https://bar.io/inquiry"}
|
|
551
|
+
]
|
|
552
|
+
EOF
|
|
553
|
+
|
|
554
|
+
Flow:
|
|
555
|
+
stdin JSON \u2192 CLI validates against submit schema
|
|
556
|
+
\u2192 CLI executes handler (e.g. curl to app API)
|
|
557
|
+
\u2192 CLI extracts fields from API response
|
|
558
|
+
\u2192 fields saved to task context
|
|
559
|
+
\u2192 event emitted with interpolated message
|
|
560
|
+
|
|
561
|
+
The handler template uses {{submitted}} for the raw JSON string.
|
|
562
|
+
The API response is parsed and fields are extracted via "extract".
|
|
563
|
+
The API is the source of truth \u2014 not the model's self-report.
|
|
564
|
+
|
|
565
|
+
2. Scalar values (CLI flags) \u2014 for simple key-value pairs
|
|
566
|
+
Used when the step has "data" with "when: after" in workflow.yml.
|
|
567
|
+
|
|
568
|
+
sna <task-id> next --registered-count 8 --skipped-count 3
|
|
569
|
+
|
|
570
|
+
Flags use --kebab-case, mapped to snake_case context keys.
|
|
571
|
+
Each value is validated against the declared type.
|
|
572
|
+
|
|
573
|
+
Validation errors:
|
|
574
|
+
- Missing required fields \u2192 error + re-display expected format
|
|
575
|
+
- Wrong types (e.g. "abc" for integer) \u2192 error + expected format
|
|
576
|
+
- Empty stdin when submit is expected \u2192 error + JSON example
|
|
577
|
+
- Invalid JSON \u2192 error + JSON example`);
|
|
578
|
+
}
|
|
579
|
+
const [, , command, ...args] = process.argv;
|
|
580
|
+
const force = args.includes("--force");
|
|
581
|
+
const wantsHelp = args.includes("--help") || args.includes("-h");
|
|
582
|
+
const isTaskId = /^\d{10}[a-z]?$/.test(command ?? "");
|
|
583
|
+
if (command === "help" || command === "--help" || command === "-h" || !command && !isTaskId) {
|
|
584
|
+
const topic = args[0];
|
|
585
|
+
if (topic === "workflow") printWorkflowHelp();
|
|
586
|
+
else if (topic === "submit") printSubmitHelp();
|
|
587
|
+
else printHelp();
|
|
588
|
+
} else if (command === "new") {
|
|
589
|
+
if (wantsHelp) {
|
|
590
|
+
console.log(`Usage: sna new <skill> [--param val ...]
|
|
591
|
+
|
|
592
|
+
Create a new task from .claude/skills/<skill>/workflow.yml.
|
|
593
|
+
Exec steps are auto-executed. Stops at the first instruction step.
|
|
594
|
+
|
|
595
|
+
Example:
|
|
596
|
+
sna new company-search --query "\u6771\u4EAC\u306ESaaS\u4F01\u696D"
|
|
597
|
+
|
|
598
|
+
Run "sna help workflow" for workflow.yml specification.`);
|
|
599
|
+
} else {
|
|
600
|
+
cmdNew(args);
|
|
601
|
+
}
|
|
602
|
+
} else if (command === "tasks") {
|
|
603
|
+
cmdTasks();
|
|
604
|
+
} else if (isTaskId) {
|
|
605
|
+
if (wantsHelp) {
|
|
606
|
+
console.log(`Usage: sna <task-id> <start|next|cancel> [options]
|
|
607
|
+
|
|
608
|
+
Commands:
|
|
609
|
+
sna <id> start Resume task from current step (retries on error)
|
|
610
|
+
sna <id> next --key val Submit scalar values
|
|
611
|
+
sna <id> next <<'EOF' ... EOF Submit JSON via stdin
|
|
612
|
+
sna <id> cancel Cancel task
|
|
613
|
+
|
|
614
|
+
Task state: .sna/tasks/<id>.json
|
|
615
|
+
|
|
616
|
+
Run "sna help submit" for data submission patterns.`);
|
|
617
|
+
} else if (args[0] === "cancel") {
|
|
618
|
+
cmdCancel(command);
|
|
619
|
+
} else {
|
|
620
|
+
cmdWorkflow(command, args);
|
|
621
|
+
}
|
|
622
|
+
} else {
|
|
623
|
+
switch (command) {
|
|
624
|
+
case "init":
|
|
625
|
+
cmdInit(force);
|
|
626
|
+
break;
|
|
627
|
+
case "up":
|
|
628
|
+
cmdUp();
|
|
629
|
+
break;
|
|
630
|
+
case "down":
|
|
631
|
+
cmdDown();
|
|
632
|
+
break;
|
|
633
|
+
case "status":
|
|
634
|
+
cmdStatus();
|
|
635
|
+
break;
|
|
636
|
+
case "api:up":
|
|
637
|
+
cmdApiUp();
|
|
638
|
+
break;
|
|
639
|
+
case "api:down":
|
|
640
|
+
cmdApiDown();
|
|
641
|
+
break;
|
|
642
|
+
case "restart":
|
|
643
|
+
cmdDown();
|
|
644
|
+
console.log();
|
|
645
|
+
cmdUp();
|
|
646
|
+
break;
|
|
647
|
+
default:
|
|
648
|
+
printHelp();
|
|
649
|
+
}
|
|
650
|
+
}
|