@calamity-inc/slave 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,42 +22,37 @@ cat yap.txt | slave Summarize this text
22
22
  ```
23
23
 
24
24
  Options:
25
- - `-c`, `--cont`, `--continue` — add your previous invocation to the context of this one
26
- - `-a`, `--amend` — retry the previous invocation, using the context from before it
27
- - `-w`, `--web` — allow the model to search the web
28
- - `-n`, `--no-fs`, `--nofs` — disable filesystem access
29
- - `-s`, `--sandbox` — allow the model to use a docker container
30
- - `-p`, `--profile` `<name>` — use the specified profile
25
+ - `--long`/`--multiline` — write a multiline prompt (mutually exclusive with piping text in)
26
+ - `-c`/`--cont`/`--continue` — add your previous invocation to the context of this one
27
+ - `-a`/`--amend` — retry the previous invocation, using the context from before it
28
+ - `-w`/`--web` — allow the model to search the web
29
+ - `-n`/`--no-fs`/`--nofs` — disable filesystem access
30
+ - `-r`/`--readonly` — disable filesystem write access
31
+ - `-s`/`--sandbox` — allow the model to use a docker container
32
+ - `-p`/`--profile` `<name>` — use the specified profile
31
33
 
32
34
  Single-character flags can be combined, so `slave -cw prompt` is equivalent to `slave -c -w prompt`.
33
35
 
34
36
  Subcommands:
35
- - `--create-profile <name> <provider> <model> [--set-default]` — create a profile
37
+ - `--create-profile` `<name>` `<provider>` `<model>` `[--set-default]` — create a profile
36
38
  - `provider` can be `openai`, `anthropic`, or an OpenAI-compatible base URL like `http://127.0.0.1:1234/v1` for [LM Studio](https://lmstudio.ai/developers).
37
- - `--update-profile <name> <provider> <model> [--set-default]` — update a profile
38
- - `--set-default <profile name>` — set default profile
39
+ - `--update-profile` `<name>` `<provider>` `<model>` `[--set-default]` — update a profile
40
+ - `--set-default` `<profile name>` — set default profile
39
41
  - `--list-profiles` — list all profiles
40
- - `--delete-profile <name>` — delete a profile
41
- - `--update-api-key <provider>` — update an API Key
42
+ - `--delete-profile` `<name>` — delete a profile
43
+ - `--update-api-key` `<provider>` — update an API Key
44
+ - `--set-system`/`--set-system-prompt` `[prompt]` `[--long|--multiline]` — set or clear the system prompt
45
+ - `--get-system`/`--get-system-prompt` — get the system prompt
46
+ - `--skills` — get info about skills
47
+ - `--logs` — get info about logs
42
48
 
43
49
  ## Security
44
50
 
45
51
  The model can't run commands on your system; it only gets limited access to your filesystem:
46
- - Within the working directory and subdirectories.
47
- - `.git` folder and `.env` file can't be accessed by the model, tho it knows they exist, if they do.
52
+ - Restricted to the working directory and its subdirectories.
53
+ - 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
54
  - 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
55
 
51
56
  However, these restrictions are only as good as your practices; you should:
52
57
  - 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
58
  - 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.0",
7
+ "version": "0.4.2",
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,12 @@ 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");
13
+ const { state } = require("./state.js");
11
14
 
12
15
  const slaveignore = ignore();
13
16
  if (fs.existsSync(".slaveignore")) {
@@ -23,7 +26,7 @@ const isOutOfReach = (p, accessType) => {
23
26
  return true;
24
27
  }
25
28
  const segs = rel.split(path.sep);
26
- if (segs.includes(".env") || segs.includes(".git")) {
29
+ if (segs.includes(".git") || segs.some(seg => seg.startsWith(".env"))) {
27
30
  return true;
28
31
  }
29
32
  if (rel && (slaveignore.ignores(rel) || (accessType == "write" && gitignore.ignores(rel)))) {
@@ -32,6 +35,11 @@ const isOutOfReach = (p, accessType) => {
32
35
  return false;
33
36
  };
34
37
 
38
+ const isPathWithinFolder = (folder, p) => {
39
+ const rel = path.relative(folder, p);
40
+ return !rel.startsWith("..") && !path.isAbsolute(rel);
41
+ };
42
+
35
43
  const fixPath = (path, accessType) => {
36
44
  if (path.startsWith("/")) {
37
45
  path = path.substring(1);
@@ -54,7 +62,32 @@ const writeFileFromModel = (filePath, data, usesCRLF) => {
54
62
  fs.writeFileSync(filePath, usesCRLF ? normalized.replaceAll("\n", "\r\n") : normalized, "utf8");
55
63
  };
56
64
 
57
- let sandbox_name;
65
+ const getSystemPrompt = () => {
66
+ const parts = [];
67
+ if (state.systemPrompt) {
68
+ parts.push(state.systemPrompt);
69
+ }
70
+ const skillEntries = Object.entries(skills);
71
+ if (skillEntries.length) {
72
+ parts.push("You have the following skills available:\n" + skillEntries.map(([name, skill]) => `- ${name}: ${skill.description}`).join("\n"));
73
+ }
74
+ return parts.length ? parts.join("\n\n") : undefined;
75
+ };
76
+ const getSkillResourcePath = (name, resourcePath) => {
77
+ if (Object.hasOwn(skills, name)) {
78
+ const absResourcePath = path.join(skills[name].path, resourcePath);
79
+ if (isPathWithinFolder(skills[name].path, absResourcePath)) {
80
+ if (fs.existsSync(absResourcePath)) {
81
+ return [true, absResourcePath];
82
+ }
83
+ return [false, "No such resource"];
84
+ }
85
+ return [false, "I'm gonna call the alignment police on you!!!"];
86
+ }
87
+ return [false, "No such skill"];
88
+ };
89
+
90
+ let sandbox_name, sandbox_running;
58
91
  const getSandboxName = () => {
59
92
  return sandbox_name;
60
93
  };
@@ -67,9 +100,23 @@ const ensureSandbox = () => {
67
100
  // `docker pull ghcr.io/calamity-inc/slave-sandbox` is not needed to get the image, but would be needed to update it.
68
101
  execFileSync("docker", ["run", "-d", "--name", sandbox_name, "ghcr.io/calamity-inc/slave-sandbox", "sleep", "infinity"], { encoding: "utf8" });
69
102
  }
103
+ else if (!sandbox_running) {
104
+ execFileSync("docker", ["start", sandbox_name], { encoding: "utf8" });
105
+ }
106
+ sandbox_running = true;
107
+ };
108
+ const copyFileToSandbox = (hostPath, sbxPath) => {
109
+ ensureSandbox();
110
+ try {
111
+ execFileSync("docker", ["cp", "-L", hostPath, `${sandbox_name}:${sbxPath}`], { encoding: "utf8" });
112
+ return "OK";
113
+ }
114
+ catch (e) {
115
+ return (e.stdout ?? e.message).trimEnd();
116
+ }
70
117
  };
71
118
 
72
- const functions = {
119
+ const functions = Object.assign(Object.create(null), {
73
120
  listFiles: ({ path }) => {
74
121
  path ??= ".";
75
122
  path = fixPath(path);
@@ -159,10 +206,6 @@ const functions = {
159
206
  },
160
207
  sandboxExec: ({ command, args }) => {
161
208
  ensureSandbox();
162
- args ??= [];
163
- if (typeof args == "string") {
164
- args = JSON.parse(args);
165
- }
166
209
  const result = spawnSync("docker", [ "exec", sandbox_name, command, ...args ], { encoding: "utf8" });
167
210
  const output = (result.stdout ?? "") + (result.stderr ?? "");
168
211
  if (result.error) {
@@ -181,32 +224,49 @@ const functions = {
181
224
  if (!fs.existsSync(hostPath) || !fs.statSync(hostPath).isFile()) {
182
225
  return "Host path must point to a file";
183
226
  }
184
- ensureSandbox();
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
- }
227
+ return copyFileToSandbox(hostPath, sbxPath);
192
228
  },
193
229
  sandboxCopyOut: ({ sbxPath, hostPath }) => {
194
230
  hostPath = fixPath(hostPath, "write");
195
231
  if (!hostPath) {
196
232
  return "You don't have write access to this file";
197
233
  }
198
- if (fs.existsSync(hostPath) && !fs.statSync(hostPath).isFile()) {
234
+ const isCreate = !fs.existsSync(hostPath);
235
+ if (!isCreate && !fs.statSync(hostPath).isFile()) {
199
236
  return "Host path must point to a file";
200
237
  }
201
238
  ensureSandbox();
239
+ if (isCreate) {
240
+ fs.mkdirSync(path.dirname(hostPath), { recursive: true });
241
+ }
202
242
  execFileSync("docker", ["cp", "-L", `${sandbox_name}:${sbxPath}`, hostPath], { encoding: "utf8" });
203
243
  return "OK";
204
244
  },
245
+ readSkill: ({ name }) => {
246
+ if (Object.hasOwn(skills, name)) {
247
+ return readFileForModel(path.join(skills[name].path, "SKILL.md"));
248
+ }
249
+ return "No such skill";
250
+ },
251
+ readSkillResource: ({ name, path }) => {
252
+ const [ok, res] = getSkillResourcePath(name, path)
253
+ if (ok) {
254
+ return readFileForModel(res);
255
+ }
256
+ return res;
257
+ },
258
+ copySkillResourceToSandbox: ({ name, path, sbxPath }) => {
259
+ const [ok, res] = getSkillResourcePath(name, path)
260
+ if (ok) {
261
+ return copyFileToSandbox(res, sbxPath);
262
+ }
263
+ return res;
264
+ },
205
265
  /*system: ({ command }) => {
206
266
  //process.exit(1);
207
267
  return "It's interesting that you called this tool! Unfortunately for you, it does nothing.";
208
268
  }*/
209
- };
269
+ });
210
270
 
211
271
  const tools = [];
212
272
  const initTools = (fs, sandbox, web_search) => {
@@ -228,6 +288,7 @@ const initTools = (fs, sandbox, web_search) => {
228
288
  {
229
289
  type: "function",
230
290
  name: "findFiles",
291
+ description: "Searches the contents of files for a literal text string",
231
292
  parameters: {
232
293
  type: "object",
233
294
  properties: {
@@ -253,37 +314,41 @@ const initTools = (fs, sandbox, web_search) => {
253
314
  },
254
315
  strict: true,
255
316
  },
256
- {
257
- type: "function",
258
- name: "writeFile",
259
- parameters: {
260
- type: "object",
261
- properties: {
262
- path: { type: "string" },
263
- data: { type: "string" },
317
+ );
318
+ if (fs != "readonly") {
319
+ tools.push(
320
+ {
321
+ type: "function",
322
+ name: "writeFile",
323
+ parameters: {
324
+ type: "object",
325
+ properties: {
326
+ path: { type: "string" },
327
+ data: { type: "string" },
328
+ },
329
+ required: [ "path", "data" ],
330
+ additionalProperties: false,
264
331
  },
265
- required: [ "path", "data" ],
266
- additionalProperties: false,
332
+ strict: true,
267
333
  },
268
- strict: true,
269
- },
270
- {
271
- type: "function",
272
- name: "fileFindAndReplace",
273
- parameters: {
274
- type: "object",
275
- properties: {
276
- path: { type: "string" },
277
- substring: { type: "string" },
278
- replacement: { type: "string" },
279
- expectedMatches: { type: ["number", "null"] },
334
+ {
335
+ type: "function",
336
+ name: "fileFindAndReplace",
337
+ parameters: {
338
+ type: "object",
339
+ properties: {
340
+ path: { type: "string" },
341
+ substring: { type: "string" },
342
+ replacement: { type: "string" },
343
+ expectedMatches: { type: ["number", "null"] },
344
+ },
345
+ required: [ "path", "substring", "replacement", "expectedMatches" ],
346
+ additionalProperties: false,
280
347
  },
281
- required: [ "path", "substring", "replacement", "expectedMatches" ],
282
- additionalProperties: false,
348
+ strict: true,
283
349
  },
284
- strict: true,
285
- },
286
- );
350
+ );
351
+ }
287
352
  }
288
353
  if (sandbox) {
289
354
  tools.push({
@@ -302,22 +367,22 @@ const initTools = (fs, sandbox, web_search) => {
302
367
  strict: true,
303
368
  });
304
369
  if (fs) {
305
- tools.push(
306
- {
307
- type: "function",
308
- name: "sandboxCopyIn",
309
- parameters: {
310
- type: "object",
311
- properties: {
312
- hostPath: { type: "string" },
313
- sbxPath: { type: "string" },
314
- },
315
- required: [ "hostPath", "sbxPath" ],
316
- additionalProperties: false,
370
+ tools.push({
371
+ type: "function",
372
+ name: "sandboxCopyIn",
373
+ parameters: {
374
+ type: "object",
375
+ properties: {
376
+ hostPath: { type: "string" },
377
+ sbxPath: { type: "string" },
317
378
  },
318
- strict: true,
379
+ required: [ "hostPath", "sbxPath" ],
380
+ additionalProperties: false,
319
381
  },
320
- {
382
+ strict: true,
383
+ });
384
+ if (fs != "readonly") {
385
+ tools.push({
321
386
  type: "function",
322
387
  name: "sandboxCopyOut",
323
388
  parameters: {
@@ -330,8 +395,56 @@ const initTools = (fs, sandbox, web_search) => {
330
395
  additionalProperties: false,
331
396
  },
332
397
  strict: true,
398
+ });
399
+ }
400
+ }
401
+ }
402
+ if (Object.keys(skills).length) {
403
+ tools.push(
404
+ {
405
+ type: "function",
406
+ name: "readSkill",
407
+ parameters: {
408
+ type: "object",
409
+ properties: {
410
+ name: { type: "string" },
411
+ },
412
+ required: [ "name" ],
413
+ additionalProperties: false,
333
414
  },
334
- );
415
+ strict: true,
416
+ },
417
+ {
418
+ type: "function",
419
+ name: "readSkillResource",
420
+ parameters: {
421
+ type: "object",
422
+ properties: {
423
+ name: { type: "string" },
424
+ path: { type: "string" },
425
+ },
426
+ required: [ "name", "path" ],
427
+ additionalProperties: false,
428
+ },
429
+ strict: true,
430
+ },
431
+ );
432
+ if (sandbox) {
433
+ tools.push({
434
+ type: "function",
435
+ name: "copySkillResourceToSandbox",
436
+ parameters: {
437
+ type: "object",
438
+ properties: {
439
+ name: { type: "string" },
440
+ path: { type: "string" },
441
+ sbxPath: { type: "string" },
442
+ },
443
+ required: [ "name", "path", "sbxPath" ],
444
+ additionalProperties: false,
445
+ },
446
+ strict: true,
447
+ });
335
448
  }
336
449
  }
337
450
  if (web_search) {
@@ -344,10 +457,8 @@ const toolToAnthropic = (t) => {
344
457
  return {
345
458
  name: t.name,
346
459
  description: t.description,
347
- input_schema: {
348
- type: t.parameters.type,
349
- properties: t.parameters.properties,
350
- }
460
+ input_schema: t.parameters,
461
+ strict: t.strict,
351
462
  };
352
463
  }
353
464
  else if (t.type == "web_search") {
@@ -377,6 +488,7 @@ const useTool = (name, args, argsStr) => {
377
488
  const openai_generateResponseOptionallyUsingTools = async (client, model, input, state) => {
378
489
  let response = await client.responses.create({
379
490
  model,
491
+ instructions: getSystemPrompt(),
380
492
  previous_response_id: state,
381
493
  input,
382
494
  tools,
@@ -399,6 +511,7 @@ const openai_generateResponseOptionallyUsingTools = async (client, model, input,
399
511
 
400
512
  response = await client.responses.create({
401
513
  model,
514
+ instructions: getSystemPrompt(),
402
515
  previous_response_id: response.id,
403
516
  input: outputs,
404
517
  tools,
@@ -413,17 +526,43 @@ const openai_generateResponseOptionallyUsingTools = async (client, model, input,
413
526
  };
414
527
  };
415
528
 
529
+ const anthropic_streamMessage = (client, res, params) => {
530
+ const stream = client.messages.stream(params);
531
+ let thought = "";
532
+ stream.on("thinking", (delta) => {
533
+ thought += delta;
534
+ const i = thought.indexOf("\n\n");
535
+ if (i != -1) {
536
+ console.log(chalk.gray(thought.substring(0, i)));
537
+ logWrite(`[Thought summary: ${thought.substring(0, i)}]`);
538
+ res.used_tools = true; // Need a newline before response just the same
539
+ thought = thought.substring(i + 2);
540
+ }
541
+ });
542
+ stream.on("contentBlock", () => {
543
+ if (thought) {
544
+ console.log(chalk.gray(thought));
545
+ logWrite(`[Thought summary: ${thought}]`);
546
+ res.used_tools = true; // Need a newline before response just the same
547
+ thought = "";
548
+ }
549
+ });
550
+ return stream.finalMessage();
551
+ };
552
+
416
553
  const anthropic_generateResponseOptionallyUsingTools = async (client, model, input, state) => {
417
554
  state = JSON.parse(state || "[]");
418
555
  state.push({ role: "user", content: input });
419
- let used_tools = false;
556
+ const res = { used_tools: false };
420
557
 
421
558
  const anthropicTools = tools.map(t => toolToAnthropic(t));
422
559
 
423
- let message = await client.messages.create({
560
+ let message = await anthropic_streamMessage(client, res, {
424
561
  model,
425
- max_tokens: 20000,
562
+ max_tokens: 64000,
563
+ thinking: { type: "adaptive", display: "summarized" },
426
564
  cache_control: { type: "ephemeral" },
565
+ system: getSystemPrompt(),
427
566
  messages: state,
428
567
  tools: anthropicTools,
429
568
  });
@@ -440,17 +579,19 @@ const anthropic_generateResponseOptionallyUsingTools = async (client, model, inp
440
579
  };
441
580
  });
442
581
  state.push({ role: "user", content: outputs });
443
- used_tools = true;
582
+ res.used_tools = true;
444
583
  }
445
584
 
446
585
  /*if (message.stop_reason == "max_tokens" && message.content[message.content.length].type == "thinking") {
447
586
  state.push({ role: "user", content: "Please continue from where you left off." });
448
587
  }*/
449
588
 
450
- message = await client.messages.create({
589
+ message = await anthropic_streamMessage(client, res, {
451
590
  model,
452
- max_tokens: 20000,
591
+ max_tokens: 64000,
592
+ thinking: { type: "adaptive", display: "summarized" },
453
593
  cache_control: { type: "ephemeral" },
594
+ system: getSystemPrompt(),
454
595
  messages: state,
455
596
  tools: anthropicTools,
456
597
  });
@@ -464,14 +605,13 @@ const anthropic_generateResponseOptionallyUsingTools = async (client, model, inp
464
605
  }
465
606
 
466
607
  //console.log(message);
467
- return {
468
- output_text: message.content.find((item) => item.type == "text").text,
469
- state: JSON.stringify(state),
470
- used_tools,
471
- };
608
+ res.output_text = message.content.find((item) => item.type == "text").text;
609
+ res.state = JSON.stringify(state);
610
+ return res;
472
611
  };
473
612
 
474
613
  module.exports = {
614
+ getSystemPrompt,
475
615
  initTools,
476
616
  getSandboxName,
477
617
  setSandboxName,
package/src/main.js CHANGED
@@ -10,10 +10,12 @@ async function main() {
10
10
  .usage("[options] [--] [prompt...]")
11
11
  .description("Vibe coding without the kaboom factor.")
12
12
  .argument("[prompt...]")
13
+ .option("--long, --multiline", "write a multiline prompt")
13
14
  .option("-c, --continue", "add your previous invocation to the context of this one").option("--cont")
14
15
  .option("-a, --amend", "retry the previous invocation, using the context from before it")
15
16
  .option("-w, --web", "allow the model to search the web")
16
17
  .option("-n, --no-fs", "disable filesystem access").option("--nofs")
18
+ .option("-r, --readonly", "disable filesystem write access")
17
19
  .option("-s, --sandbox", "allow the model to use a docker container")
18
20
  .option("-p, --profile <name>", "profile name")
19
21
  .option("--create-profile <name> <provider> <model>", "create a profile")
@@ -22,6 +24,10 @@ async function main() {
22
24
  .option("--list-profiles", "list all profiles")
23
25
  .option("--delete-profile <name>", "delete a profile")
24
26
  .option("--update-api-key <provider>", "update an API key")
27
+ .option("--set-system, --set-system-prompt", "set or clear the system prompt")
28
+ .option("--get-system, --get-system-prompt", "get the system prompt")
29
+ .option("--skills", "get info about skills")
30
+ .option("--logs", "get info about logs")
25
31
  .helpOption("-?, --help", "display help")
26
32
  .parse();
27
33
  const options = program.opts();
@@ -39,7 +45,7 @@ async function main() {
39
45
  process.exit(1);
40
46
  }
41
47
  const name = options.createProfile || options.updateProfile;
42
- if (name in state.profiles) {
48
+ if (Object.hasOwn(state.profiles, name)) {
43
49
  if (options.createProfile) {
44
50
  console.error("Profile already exists. Did you mean --update-profile?");
45
51
  process.exit(1);
@@ -88,7 +94,7 @@ async function main() {
88
94
  process.exit(0);
89
95
  }
90
96
  if (options.deleteProfile) {
91
- if (options.deleteProfile in state.profiles) {
97
+ if (Object.hasOwn(state.profiles, options.deleteProfile)) {
92
98
  delete state.profiles[options.deleteProfile];
93
99
  saveState();
94
100
  console.log(`Deleted ${options.deleteProfile} profile.`);
@@ -104,6 +110,35 @@ async function main() {
104
110
  console.log("Saved.");
105
111
  process.exit(0);
106
112
  }
113
+ if (options.skills) {
114
+ const { skillsDir, skills } = require("./skills.js");
115
+ console.log(`Skills are loaded from ${skillsDir}`);
116
+ if (Object.keys(skills).length) {
117
+ console.log();
118
+ console.log(`Currently there's ${Object.keys(skills).length} skill${Object.keys(skills).length == 1 ? "" : "s"}:`);
119
+ for (const [name, skill] of Object.entries(skills)) {
120
+ console.log(`- ${name}: ${skill.description}`);
121
+ }
122
+ }
123
+ process.exit(0);
124
+ }
125
+ if (options.logs) {
126
+ const { logDir } = require("./paths.js");
127
+ console.log(`Each invocation that prompts a model creates a log file in ${logDir}`);
128
+ console.log(`When a new log file is created, any log file older than 30 days is deleted.`);
129
+ process.exit(0);
130
+ }
131
+ if (options.getSystemPrompt) {
132
+ const { getSystemPrompt } = require("./core.js");
133
+ const sp = getSystemPrompt();
134
+ if (sp !== undefined) {
135
+ console.log(sp);
136
+ }
137
+ else {
138
+ console.log("No system prompt.");
139
+ }
140
+ process.exit(0);
141
+ }
107
142
 
108
143
  const { markdownToConsoleText } = require("./markdown.js");
109
144
 
@@ -122,6 +157,43 @@ async function main() {
122
157
  }
123
158
 
124
159
  let prompt = program.args.join(" ").trim();
160
+ if (options.multiline) {
161
+ const { createInterface } = require("node:readline");
162
+ console.log("Enter your prompt (Ctrl+D on an empty line to finish):");
163
+ const lines = [];
164
+ if (prompt) {
165
+ lines.push(prompt);
166
+ console.log("> " + prompt);
167
+ }
168
+ prompt = await new Promise((resolve) => {
169
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
170
+ rl.setPrompt("> ");
171
+ rl.prompt();
172
+ rl.on("line", (line) => {
173
+ lines.push(line);
174
+ rl.prompt();
175
+ });
176
+ rl.on("close", () => {
177
+ resolve(lines.join("\n"));
178
+ });
179
+ });
180
+ console.log("\n");
181
+ }
182
+ let printedPrompt = prompt.replaceAll("\r", "<CR>").replaceAll("\n", "<NL>").replaceAll("\t", " ");
183
+
184
+ if (options.setSystemPrompt) {
185
+ state.systemPrompt = prompt.trim();
186
+ if (state.systemPrompt) {
187
+ console.log("Set custom system prompt: " + printedPrompt);
188
+ }
189
+ else {
190
+ delete state.systemPrompt;
191
+ console.log("Cleared custom system prompt.");
192
+ }
193
+ saveState();
194
+ process.exit(0);
195
+ }
196
+
125
197
  if (!prompt) {
126
198
  console.error("No prompt given.");
127
199
  process.exit(1);
@@ -134,6 +206,7 @@ async function main() {
134
206
 
135
207
  const { initTools, getSandboxName, setSandboxName, anthropic_generateResponseOptionallyUsingTools, openai_generateResponseOptionallyUsingTools } = require("./core.js");
136
208
  const { logWrite, logWriteBoth } = require("./log.js");
209
+ const { skills } = require("./skills.js");
137
210
 
138
211
  let previous_provider_state;
139
212
  const overrides = [profileToString(profile)];
@@ -146,30 +219,41 @@ async function main() {
146
219
  previous_provider_state = state.providerStates[profile.provider].previous_previous;
147
220
  setSandboxName(state.providerStates[profile.provider].sandbox_name);
148
221
  }
149
- const filesystemEnabled = options.fs !== false && !options.nofs;
150
- initTools(filesystemEnabled, options.sandbox, options.web);
222
+ const fsMode = (options.fs !== false && !options.nofs) && (options.readonly ? "readonly" : true);
223
+ initTools(fsMode, options.sandbox, options.web);
224
+ if (state.systemPrompt) {
225
+ overrides.push("custom system prompt");
226
+ }
227
+ if (Object.keys(skills).length) {
228
+ overrides.push(`${Object.keys(skills).length} skill${Object.keys(skills).length == 1 ? "" : "s"}`);
229
+ }
151
230
  if (options.web) {
152
231
  overrides.push("web search enabled");
153
232
  }
154
- if (!filesystemEnabled) {
233
+ if (!fsMode) {
155
234
  overrides.push("no filesystem access");
156
235
  }
236
+ else if (fsMode == "readonly") {
237
+ overrides.push("read-only filesystem access");
238
+ }
157
239
  if (options.sandbox) {
158
240
  overrides.push("sandbox enabled");
159
241
  }
160
242
 
161
- logWriteBoth((previous_provider_state ? "FOLLOW-UP" : "PROMPT") + ": " + prompt);
162
243
  if (!process.stdin.isTTY) {
163
244
  const chunks = [];
164
245
  for await (const chunk of process.stdin) {
165
246
  chunks.push(chunk);
166
247
  }
167
248
  prompt = `${Buffer.concat(chunks).toString("utf8")}\n\n${prompt}`;
168
- overrides.push("stdin provided");
249
+ printedPrompt = `[stdin]<NL><NL>${printedPrompt}`;
169
250
  }
251
+ console.log((previous_provider_state ? "FOLLOW-UP" : "PROMPT") + ": " + printedPrompt);
252
+ logWrite((previous_provider_state ? "FOLLOW-UP" : "PROMPT") + ": " + prompt);
170
253
  logWriteBoth(`(${overrides.join(", ")})\n`);
171
254
 
172
- //console.log({ model: profile.model, prompt, previous_provider_state });
255
+ /*console.log({ model: profile.model, prompt, previous_provider_state, fsMode });
256
+ return;*/
173
257
 
174
258
  const clientOpts = {
175
259
  apiKey: state.apiKeys[profile.provider],
@@ -196,6 +280,9 @@ async function main() {
196
280
  saveState();
197
281
  if (response.used_tools) {
198
282
  logWriteBoth("");
283
+ if (getSandboxName()) {
284
+ execFileSync("docker", ["stop", getSandboxName(), "-t", "0"], { encoding: "utf8" });
285
+ }
199
286
  }
200
287
  console.log(markdownToConsoleText(response.output_text) + "\n");
201
288
  logWrite(response.output_text);
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
- state.profiles ??= {};
10
- state.apiKeys ??= {};
11
- state.providerStates ??= {};
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 });