@calamity-inc/slave 0.4.0 → 0.4.1
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 +4 -13
- package/package.json +2 -1
- package/src/core.js +157 -31
- package/src/main.js +26 -2
- package/src/skills.js +63 -0
- package/src/state.js +4 -3
package/README.md
CHANGED
|
@@ -39,25 +39,16 @@ Subcommands:
|
|
|
39
39
|
- `--list-profiles` — list all profiles
|
|
40
40
|
- `--delete-profile <name>` — delete a profile
|
|
41
41
|
- `--update-api-key <provider>` — update an API Key
|
|
42
|
+
- `--logs` — get info about logs
|
|
43
|
+
- `--skills` — get info about skills
|
|
42
44
|
|
|
43
45
|
## Security
|
|
44
46
|
|
|
45
47
|
The model can't run commands on your system; it only gets limited access to your filesystem:
|
|
46
|
-
-
|
|
47
|
-
- `.git` folder
|
|
48
|
+
- Restricted to the working directory and its subdirectories.
|
|
49
|
+
- Can't read the `.git` folder, `.env` files, or any files excluded by `.slaveignore`. Although the model will still be able to know of their existence.
|
|
48
50
|
- Files that are `.gitignore`d can't be written to.
|
|
49
|
-
- You can provide a `.slaveignore` for files that the model should not be allowed to read or write. It will still be able to know of their existence.
|
|
50
51
|
|
|
51
52
|
However, these restrictions are only as good as your practices; you should:
|
|
52
53
|
- Only run `slave` in Git-tracked folders and be sure that anything you don't want to lose is committed or at least staged so you can always revert any changes made by the model.
|
|
53
54
|
- Review changes to your code before running anything, including tests.
|
|
54
|
-
|
|
55
|
-
## Logs
|
|
56
|
-
|
|
57
|
-
Each invocation that prompts a model creates a log file:
|
|
58
|
-
|
|
59
|
-
- Linux: `~/.local/state/slave-nodejs`, or `$XDG_STATE_HOME/slave-nodejs` if `XDG_STATE_HOME` is set
|
|
60
|
-
- macOS: `~/Library/Logs/slave-nodejs`
|
|
61
|
-
- Windows: `%LOCALAPPDATA%\slave-nodejs\Log`
|
|
62
|
-
|
|
63
|
-
When a new log file is created, any log file older than 30 days is deleted.
|
package/package.json
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
"type": "git",
|
|
5
5
|
"url": "git+https://github.com/calamity-inc/slave.git"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.1",
|
|
8
8
|
"type": "commonjs",
|
|
9
9
|
"bin": {
|
|
10
10
|
"slave": "src/main.js"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@anthropic-ai/sdk": "^1.3.0",
|
|
14
|
+
"chalk": "^5",
|
|
14
15
|
"commander": "^15.0.0",
|
|
15
16
|
"env-paths": "^4.0.0",
|
|
16
17
|
"ignore": "^7.0.13",
|
package/src/core.js
CHANGED
|
@@ -5,9 +5,11 @@ const { randomInt } = require("node:crypto");
|
|
|
5
5
|
const fs = require("node:fs");
|
|
6
6
|
const path = require("node:path");
|
|
7
7
|
|
|
8
|
+
const chalk = require("chalk").default;
|
|
8
9
|
const ignore = require("ignore");
|
|
9
10
|
|
|
10
11
|
const { logWrite } = require("./log.js");
|
|
12
|
+
const { skills } = require("./skills.js");
|
|
11
13
|
|
|
12
14
|
const slaveignore = ignore();
|
|
13
15
|
if (fs.existsSync(".slaveignore")) {
|
|
@@ -23,7 +25,7 @@ const isOutOfReach = (p, accessType) => {
|
|
|
23
25
|
return true;
|
|
24
26
|
}
|
|
25
27
|
const segs = rel.split(path.sep);
|
|
26
|
-
if (segs.includes(".
|
|
28
|
+
if (segs.includes(".git") || segs.some(seg => seg.startsWith(".env"))) {
|
|
27
29
|
return true;
|
|
28
30
|
}
|
|
29
31
|
if (rel && (slaveignore.ignores(rel) || (accessType == "write" && gitignore.ignores(rel)))) {
|
|
@@ -32,6 +34,11 @@ const isOutOfReach = (p, accessType) => {
|
|
|
32
34
|
return false;
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
const isPathWithinFolder = (folder, p) => {
|
|
38
|
+
const rel = path.relative(folder, p);
|
|
39
|
+
return !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
40
|
+
};
|
|
41
|
+
|
|
35
42
|
const fixPath = (path, accessType) => {
|
|
36
43
|
if (path.startsWith("/")) {
|
|
37
44
|
path = path.substring(1);
|
|
@@ -54,6 +61,27 @@ const writeFileFromModel = (filePath, data, usesCRLF) => {
|
|
|
54
61
|
fs.writeFileSync(filePath, usesCRLF ? normalized.replaceAll("\n", "\r\n") : normalized, "utf8");
|
|
55
62
|
};
|
|
56
63
|
|
|
64
|
+
const getSystemPrompt = () => {
|
|
65
|
+
const entries = Object.entries(skills);
|
|
66
|
+
if (entries.length == 0) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return "You have the following skills available:\n" + entries.map(([name, skill]) => `- ${name}: ${skill.description}`).join("\n");
|
|
70
|
+
};
|
|
71
|
+
const getSkillResourcePath = (name, resourcePath) => {
|
|
72
|
+
if (Object.hasOwn(skills, name)) {
|
|
73
|
+
const absResourcePath = path.join(skills[name].path, resourcePath);
|
|
74
|
+
if (isPathWithinFolder(skills[name].path, absResourcePath)) {
|
|
75
|
+
if (fs.existsSync(absResourcePath)) {
|
|
76
|
+
return [true, absResourcePath];
|
|
77
|
+
}
|
|
78
|
+
return [false, "No such resource"];
|
|
79
|
+
}
|
|
80
|
+
return [false, "I'm gonna call the alignment police on you!!!"];
|
|
81
|
+
}
|
|
82
|
+
return [false, "No such skill"];
|
|
83
|
+
};
|
|
84
|
+
|
|
57
85
|
let sandbox_name;
|
|
58
86
|
const getSandboxName = () => {
|
|
59
87
|
return sandbox_name;
|
|
@@ -68,8 +96,18 @@ const ensureSandbox = () => {
|
|
|
68
96
|
execFileSync("docker", ["run", "-d", "--name", sandbox_name, "ghcr.io/calamity-inc/slave-sandbox", "sleep", "infinity"], { encoding: "utf8" });
|
|
69
97
|
}
|
|
70
98
|
};
|
|
99
|
+
const copyFileToSandbox = (hostPath, sbxPath) => {
|
|
100
|
+
ensureSandbox();
|
|
101
|
+
try {
|
|
102
|
+
execFileSync("docker", ["cp", "-L", hostPath, `${sandbox_name}:${sbxPath}`], { encoding: "utf8" });
|
|
103
|
+
return "OK";
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
return (e.stdout ?? e.message).trimEnd();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
71
109
|
|
|
72
|
-
const functions = {
|
|
110
|
+
const functions = Object.assign(Object.create(null), {
|
|
73
111
|
listFiles: ({ path }) => {
|
|
74
112
|
path ??= ".";
|
|
75
113
|
path = fixPath(path);
|
|
@@ -159,10 +197,6 @@ const functions = {
|
|
|
159
197
|
},
|
|
160
198
|
sandboxExec: ({ command, args }) => {
|
|
161
199
|
ensureSandbox();
|
|
162
|
-
args ??= [];
|
|
163
|
-
if (typeof args == "string") {
|
|
164
|
-
args = JSON.parse(args);
|
|
165
|
-
}
|
|
166
200
|
const result = spawnSync("docker", [ "exec", sandbox_name, command, ...args ], { encoding: "utf8" });
|
|
167
201
|
const output = (result.stdout ?? "") + (result.stderr ?? "");
|
|
168
202
|
if (result.error) {
|
|
@@ -181,32 +215,49 @@ const functions = {
|
|
|
181
215
|
if (!fs.existsSync(hostPath) || !fs.statSync(hostPath).isFile()) {
|
|
182
216
|
return "Host path must point to a file";
|
|
183
217
|
}
|
|
184
|
-
|
|
185
|
-
try {
|
|
186
|
-
execFileSync("docker", ["cp", "-L", hostPath, `${sandbox_name}:${sbxPath}`], { encoding: "utf8" });
|
|
187
|
-
return "OK";
|
|
188
|
-
}
|
|
189
|
-
catch (e) {
|
|
190
|
-
return (e.stdout ?? e.message).trimEnd();
|
|
191
|
-
}
|
|
218
|
+
return copyFileToSandbox(hostPath, sbxPath);
|
|
192
219
|
},
|
|
193
220
|
sandboxCopyOut: ({ sbxPath, hostPath }) => {
|
|
194
221
|
hostPath = fixPath(hostPath, "write");
|
|
195
222
|
if (!hostPath) {
|
|
196
223
|
return "You don't have write access to this file";
|
|
197
224
|
}
|
|
198
|
-
|
|
225
|
+
const isCreate = !fs.existsSync(hostPath);
|
|
226
|
+
if (!isCreate && !fs.statSync(hostPath).isFile()) {
|
|
199
227
|
return "Host path must point to a file";
|
|
200
228
|
}
|
|
201
229
|
ensureSandbox();
|
|
230
|
+
if (isCreate) {
|
|
231
|
+
fs.mkdirSync(path.dirname(hostPath), { recursive: true });
|
|
232
|
+
}
|
|
202
233
|
execFileSync("docker", ["cp", "-L", `${sandbox_name}:${sbxPath}`, hostPath], { encoding: "utf8" });
|
|
203
234
|
return "OK";
|
|
204
235
|
},
|
|
236
|
+
readSkill: ({ name }) => {
|
|
237
|
+
if (Object.hasOwn(skills, name)) {
|
|
238
|
+
return readFileForModel(path.join(skills[name].path, "SKILL.md"));
|
|
239
|
+
}
|
|
240
|
+
return "No such skill";
|
|
241
|
+
},
|
|
242
|
+
readSkillResource: ({ name, path }) => {
|
|
243
|
+
const [ok, res] = getSkillResourcePath(name, path)
|
|
244
|
+
if (ok) {
|
|
245
|
+
return readFileForModel(res);
|
|
246
|
+
}
|
|
247
|
+
return res;
|
|
248
|
+
},
|
|
249
|
+
copySkillResourceToSandbox: ({ name, path, sbxPath }) => {
|
|
250
|
+
const [ok, res] = getSkillResourcePath(name, path)
|
|
251
|
+
if (ok) {
|
|
252
|
+
return copyFileToSandbox(res, sbxPath);
|
|
253
|
+
}
|
|
254
|
+
return res;
|
|
255
|
+
},
|
|
205
256
|
/*system: ({ command }) => {
|
|
206
257
|
//process.exit(1);
|
|
207
258
|
return "It's interesting that you called this tool! Unfortunately for you, it does nothing.";
|
|
208
259
|
}*/
|
|
209
|
-
};
|
|
260
|
+
});
|
|
210
261
|
|
|
211
262
|
const tools = [];
|
|
212
263
|
const initTools = (fs, sandbox, web_search) => {
|
|
@@ -228,6 +279,7 @@ const initTools = (fs, sandbox, web_search) => {
|
|
|
228
279
|
{
|
|
229
280
|
type: "function",
|
|
230
281
|
name: "findFiles",
|
|
282
|
+
description: "Searches the contents of files for a literal text string",
|
|
231
283
|
parameters: {
|
|
232
284
|
type: "object",
|
|
233
285
|
properties: {
|
|
@@ -334,6 +386,54 @@ const initTools = (fs, sandbox, web_search) => {
|
|
|
334
386
|
);
|
|
335
387
|
}
|
|
336
388
|
}
|
|
389
|
+
if (Object.keys(skills).length) {
|
|
390
|
+
tools.push(
|
|
391
|
+
{
|
|
392
|
+
type: "function",
|
|
393
|
+
name: "readSkill",
|
|
394
|
+
parameters: {
|
|
395
|
+
type: "object",
|
|
396
|
+
properties: {
|
|
397
|
+
name: { type: "string" },
|
|
398
|
+
},
|
|
399
|
+
required: [ "name" ],
|
|
400
|
+
additionalProperties: false,
|
|
401
|
+
},
|
|
402
|
+
strict: true,
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
type: "function",
|
|
406
|
+
name: "readSkillResource",
|
|
407
|
+
parameters: {
|
|
408
|
+
type: "object",
|
|
409
|
+
properties: {
|
|
410
|
+
name: { type: "string" },
|
|
411
|
+
path: { type: "string" },
|
|
412
|
+
},
|
|
413
|
+
required: [ "name", "path" ],
|
|
414
|
+
additionalProperties: false,
|
|
415
|
+
},
|
|
416
|
+
strict: true,
|
|
417
|
+
},
|
|
418
|
+
);
|
|
419
|
+
if (sandbox) {
|
|
420
|
+
tools.push({
|
|
421
|
+
type: "function",
|
|
422
|
+
name: "copySkillResourceToSandbox",
|
|
423
|
+
parameters: {
|
|
424
|
+
type: "object",
|
|
425
|
+
properties: {
|
|
426
|
+
name: { type: "string" },
|
|
427
|
+
path: { type: "string" },
|
|
428
|
+
sbxPath: { type: "string" },
|
|
429
|
+
},
|
|
430
|
+
required: [ "name", "path", "sbxPath" ],
|
|
431
|
+
additionalProperties: false,
|
|
432
|
+
},
|
|
433
|
+
strict: true,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
337
437
|
if (web_search) {
|
|
338
438
|
tools.push({ type: "web_search" });
|
|
339
439
|
}
|
|
@@ -344,10 +444,8 @@ const toolToAnthropic = (t) => {
|
|
|
344
444
|
return {
|
|
345
445
|
name: t.name,
|
|
346
446
|
description: t.description,
|
|
347
|
-
input_schema:
|
|
348
|
-
|
|
349
|
-
properties: t.parameters.properties,
|
|
350
|
-
}
|
|
447
|
+
input_schema: t.parameters,
|
|
448
|
+
strict: t.strict,
|
|
351
449
|
};
|
|
352
450
|
}
|
|
353
451
|
else if (t.type == "web_search") {
|
|
@@ -377,6 +475,7 @@ const useTool = (name, args, argsStr) => {
|
|
|
377
475
|
const openai_generateResponseOptionallyUsingTools = async (client, model, input, state) => {
|
|
378
476
|
let response = await client.responses.create({
|
|
379
477
|
model,
|
|
478
|
+
instructions: getSystemPrompt(),
|
|
380
479
|
previous_response_id: state,
|
|
381
480
|
input,
|
|
382
481
|
tools,
|
|
@@ -399,6 +498,7 @@ const openai_generateResponseOptionallyUsingTools = async (client, model, input,
|
|
|
399
498
|
|
|
400
499
|
response = await client.responses.create({
|
|
401
500
|
model,
|
|
501
|
+
instructions: getSystemPrompt(),
|
|
402
502
|
previous_response_id: response.id,
|
|
403
503
|
input: outputs,
|
|
404
504
|
tools,
|
|
@@ -413,17 +513,43 @@ const openai_generateResponseOptionallyUsingTools = async (client, model, input,
|
|
|
413
513
|
};
|
|
414
514
|
};
|
|
415
515
|
|
|
516
|
+
const anthropic_streamMessage = (client, res, params) => {
|
|
517
|
+
const stream = client.messages.stream(params);
|
|
518
|
+
let thought = "";
|
|
519
|
+
stream.on("thinking", (delta) => {
|
|
520
|
+
thought += delta;
|
|
521
|
+
const i = thought.indexOf("\n\n");
|
|
522
|
+
if (i != -1) {
|
|
523
|
+
console.log(chalk.gray(thought.substring(0, i)));
|
|
524
|
+
logWrite(`[Thought summary: ${thought.substring(0, i)}]`);
|
|
525
|
+
res.used_tools = true; // Need a newline before response just the same
|
|
526
|
+
thought = thought.substring(i + 2);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
stream.on("contentBlock", () => {
|
|
530
|
+
if (thought) {
|
|
531
|
+
console.log(chalk.gray(thought));
|
|
532
|
+
logWrite(`[Thought summary: ${thought}]`);
|
|
533
|
+
res.used_tools = true; // Need a newline before response just the same
|
|
534
|
+
thought = "";
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
return stream.finalMessage();
|
|
538
|
+
};
|
|
539
|
+
|
|
416
540
|
const anthropic_generateResponseOptionallyUsingTools = async (client, model, input, state) => {
|
|
417
541
|
state = JSON.parse(state || "[]");
|
|
418
542
|
state.push({ role: "user", content: input });
|
|
419
|
-
|
|
543
|
+
const res = { used_tools: false };
|
|
420
544
|
|
|
421
545
|
const anthropicTools = tools.map(t => toolToAnthropic(t));
|
|
422
546
|
|
|
423
|
-
let message = await client
|
|
547
|
+
let message = await anthropic_streamMessage(client, res, {
|
|
424
548
|
model,
|
|
425
|
-
max_tokens:
|
|
549
|
+
max_tokens: 64000,
|
|
550
|
+
thinking: { type: "adaptive", display: "summarized" },
|
|
426
551
|
cache_control: { type: "ephemeral" },
|
|
552
|
+
system: getSystemPrompt(),
|
|
427
553
|
messages: state,
|
|
428
554
|
tools: anthropicTools,
|
|
429
555
|
});
|
|
@@ -440,17 +566,19 @@ const anthropic_generateResponseOptionallyUsingTools = async (client, model, inp
|
|
|
440
566
|
};
|
|
441
567
|
});
|
|
442
568
|
state.push({ role: "user", content: outputs });
|
|
443
|
-
used_tools = true;
|
|
569
|
+
res.used_tools = true;
|
|
444
570
|
}
|
|
445
571
|
|
|
446
572
|
/*if (message.stop_reason == "max_tokens" && message.content[message.content.length].type == "thinking") {
|
|
447
573
|
state.push({ role: "user", content: "Please continue from where you left off." });
|
|
448
574
|
}*/
|
|
449
575
|
|
|
450
|
-
message = await client
|
|
576
|
+
message = await anthropic_streamMessage(client, res, {
|
|
451
577
|
model,
|
|
452
|
-
max_tokens:
|
|
578
|
+
max_tokens: 64000,
|
|
579
|
+
thinking: { type: "adaptive", display: "summarized" },
|
|
453
580
|
cache_control: { type: "ephemeral" },
|
|
581
|
+
system: getSystemPrompt(),
|
|
454
582
|
messages: state,
|
|
455
583
|
tools: anthropicTools,
|
|
456
584
|
});
|
|
@@ -464,11 +592,9 @@ const anthropic_generateResponseOptionallyUsingTools = async (client, model, inp
|
|
|
464
592
|
}
|
|
465
593
|
|
|
466
594
|
//console.log(message);
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
used_tools,
|
|
471
|
-
};
|
|
595
|
+
res.output_text = message.content.find((item) => item.type == "text").text;
|
|
596
|
+
res.state = JSON.stringify(state);
|
|
597
|
+
return res;
|
|
472
598
|
};
|
|
473
599
|
|
|
474
600
|
module.exports = {
|
package/src/main.js
CHANGED
|
@@ -22,6 +22,8 @@ async function main() {
|
|
|
22
22
|
.option("--list-profiles", "list all profiles")
|
|
23
23
|
.option("--delete-profile <name>", "delete a profile")
|
|
24
24
|
.option("--update-api-key <provider>", "update an API key")
|
|
25
|
+
.option("--logs", "get info about logs")
|
|
26
|
+
.option("--skills", "get info about skills")
|
|
25
27
|
.helpOption("-?, --help", "display help")
|
|
26
28
|
.parse();
|
|
27
29
|
const options = program.opts();
|
|
@@ -39,7 +41,7 @@ async function main() {
|
|
|
39
41
|
process.exit(1);
|
|
40
42
|
}
|
|
41
43
|
const name = options.createProfile || options.updateProfile;
|
|
42
|
-
if (
|
|
44
|
+
if (Object.hasOwn(state.profiles, name)) {
|
|
43
45
|
if (options.createProfile) {
|
|
44
46
|
console.error("Profile already exists. Did you mean --update-profile?");
|
|
45
47
|
process.exit(1);
|
|
@@ -88,7 +90,7 @@ async function main() {
|
|
|
88
90
|
process.exit(0);
|
|
89
91
|
}
|
|
90
92
|
if (options.deleteProfile) {
|
|
91
|
-
if (
|
|
93
|
+
if (Object.hasOwn(state.profiles, options.deleteProfile)) {
|
|
92
94
|
delete state.profiles[options.deleteProfile];
|
|
93
95
|
saveState();
|
|
94
96
|
console.log(`Deleted ${options.deleteProfile} profile.`);
|
|
@@ -104,6 +106,24 @@ async function main() {
|
|
|
104
106
|
console.log("Saved.");
|
|
105
107
|
process.exit(0);
|
|
106
108
|
}
|
|
109
|
+
if (options.logs) {
|
|
110
|
+
const { logDir } = require("./paths.js");
|
|
111
|
+
console.log(`Each invocation that prompts a model creates a log file in ${logDir}`);
|
|
112
|
+
console.log(`When a new log file is created, any log file older than 30 days is deleted.`);
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
if (options.skills) {
|
|
116
|
+
const { skillsDir, skills } = require("./skills.js");
|
|
117
|
+
console.log(`Skills are loaded from ${skillsDir}`);
|
|
118
|
+
if (Object.keys(skills).length) {
|
|
119
|
+
console.log();
|
|
120
|
+
console.log(`Currently there's ${Object.keys(skills).length} skill${Object.keys(skills).length == 1 ? "" : "s"}:`);
|
|
121
|
+
for (const [name, skill] of Object.entries(skills)) {
|
|
122
|
+
console.log(`- ${name}: ${skill.description}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
107
127
|
|
|
108
128
|
const { markdownToConsoleText } = require("./markdown.js");
|
|
109
129
|
|
|
@@ -134,6 +154,7 @@ async function main() {
|
|
|
134
154
|
|
|
135
155
|
const { initTools, getSandboxName, setSandboxName, anthropic_generateResponseOptionallyUsingTools, openai_generateResponseOptionallyUsingTools } = require("./core.js");
|
|
136
156
|
const { logWrite, logWriteBoth } = require("./log.js");
|
|
157
|
+
const { skills } = require("./skills.js");
|
|
137
158
|
|
|
138
159
|
let previous_provider_state;
|
|
139
160
|
const overrides = [profileToString(profile)];
|
|
@@ -148,6 +169,9 @@ async function main() {
|
|
|
148
169
|
}
|
|
149
170
|
const filesystemEnabled = options.fs !== false && !options.nofs;
|
|
150
171
|
initTools(filesystemEnabled, options.sandbox, options.web);
|
|
172
|
+
if (Object.keys(skills).length) {
|
|
173
|
+
overrides.push(`${Object.keys(skills).length} skill${Object.keys(skills).length == 1 ? "" : "s"}`);
|
|
174
|
+
}
|
|
151
175
|
if (options.web) {
|
|
152
176
|
overrides.push("web search enabled");
|
|
153
177
|
}
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { configDir } = require("./paths.js");
|
|
6
|
+
|
|
7
|
+
const skillsDir = path.join(configDir, "skills");
|
|
8
|
+
|
|
9
|
+
const unquote = (value) => {
|
|
10
|
+
const first = value[0];
|
|
11
|
+
if ((first === "\"" || first === "'") && value.at(-1) === first && value.length >= 2) {
|
|
12
|
+
return value.slice(1, -1);
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const parseFrontmatter = (text) => {
|
|
18
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
19
|
+
if (!match) {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const fields = {};
|
|
24
|
+
let currentKey = null;
|
|
25
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
26
|
+
const keyValue = /^([\w-]+):\s*(.*)$/.exec(line);
|
|
27
|
+
if (keyValue) {
|
|
28
|
+
currentKey = keyValue[1];
|
|
29
|
+
const raw = keyValue[2].trim();
|
|
30
|
+
// Block scalars (| or >) are folded into a single line below.
|
|
31
|
+
fields[currentKey] = /^[>|][+-]?$/.test(raw) ? "" : unquote(raw);
|
|
32
|
+
}
|
|
33
|
+
else if (currentKey && /^\s+\S/.test(line)) {
|
|
34
|
+
// Indented continuation line of a multi-line value.
|
|
35
|
+
fields[currentKey] = `${fields[currentKey]} ${line.trim()}`.trim();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return fields;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
42
|
+
const skills = Object.create(null);
|
|
43
|
+
for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
const skillPath = path.join(skillsDir, entry.name);
|
|
46
|
+
const skillFile = path.join(skillPath, "SKILL.md");
|
|
47
|
+
if (!fs.existsSync(skillFile)) {
|
|
48
|
+
console.log(`Invalid skill (missing SKILL.md): ${skillPath}`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const frontmatter = parseFrontmatter(fs.readFileSync(skillFile, "utf8"));
|
|
52
|
+
if (!frontmatter.description) {
|
|
53
|
+
console.log(`Invalid skill (missing description): ${skillPath}`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
skills[frontmatter.name || entry.name] = {
|
|
57
|
+
path: skillPath,
|
|
58
|
+
description: frontmatter.description,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { skillsDir, skills };
|
package/src/state.js
CHANGED
|
@@ -6,9 +6,10 @@ const { createInterface } = require("node:readline/promises");
|
|
|
6
6
|
const { configDir } = require("./paths.js");
|
|
7
7
|
|
|
8
8
|
const state = fs.existsSync(path.join(configDir, "state.json")) ? JSON.parse(fs.readFileSync(path.join(configDir, "state.json"), "utf8")) : {};
|
|
9
|
-
|
|
10
|
-
state.
|
|
11
|
-
state.
|
|
9
|
+
const nullProto = (obj) => Object.assign(Object.create(null), obj);
|
|
10
|
+
state.profiles = nullProto(state.profiles);
|
|
11
|
+
state.apiKeys = nullProto(state.apiKeys);
|
|
12
|
+
state.providerStates = nullProto(state.providerStates);
|
|
12
13
|
|
|
13
14
|
const promptForApiKey = async (provider) => {
|
|
14
15
|
const readline = createInterface({ input: process.stdin, output: process.stdout });
|