@calamity-inc/slave 0.1.0 → 0.2.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/.slaveignore +1 -0
- package/README.md +15 -7
- package/calamity-inc-slave-0.2.0.tgz +0 -0
- package/index.js +133 -71
- package/package.json +3 -2
package/.slaveignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.state.json
|
package/README.md
CHANGED
|
@@ -2,17 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Vibe coding without the kaboom factor.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Setup
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
```
|
|
7
|
+
1. Install slave: `npm i -g @calamity-inc/slave`
|
|
8
|
+
2. Create a default profile, e.g. `slave --create-profile default openai gpt-6-luna`
|
|
10
9
|
|
|
11
10
|
## Usage
|
|
12
11
|
|
|
13
12
|
In any directory:
|
|
14
13
|
```
|
|
15
|
-
slave [
|
|
14
|
+
slave [options] [--] How do I use the 'slave' command?
|
|
16
15
|
```
|
|
17
16
|
|
|
18
17
|
You can also pipe text in:
|
|
@@ -20,19 +19,28 @@ You can also pipe text in:
|
|
|
20
19
|
cat yap.txt | slave Summarize this text
|
|
21
20
|
```
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
Options:
|
|
24
23
|
- `-c`, `--cont`, `--continue` — add your previous invocation to the context of this one
|
|
24
|
+
- `--amend` — retry the previous invocation, using the context from before it
|
|
25
25
|
- `-w`, `--web` — allow the model to search the web
|
|
26
|
-
- `-n`, `--no-fs`, `--nofs` —
|
|
26
|
+
- `-n`, `--no-fs`, `--nofs` — disable filesystem access
|
|
27
|
+
- `-p, --profile <name>` — use the specified profile
|
|
27
28
|
|
|
28
29
|
Single-character flags can be combined, so `slave -cw prompt` is equivalent to `slave -c -w prompt`.
|
|
29
30
|
|
|
31
|
+
Subcommands:
|
|
32
|
+
- `--create-profile <name> <provider> <model>` — creates a profile
|
|
33
|
+
- `provider` can be `openai` or an OpenAI-compatible base URL, e.g. `http://127.0.0.1:1234/v1` for [LM Studio](https://lmstudio.ai/developers).
|
|
34
|
+
- `--delete-profile <name>` — deletes a profile
|
|
35
|
+
- `--update-api-key <provider>` — updates an API Key
|
|
36
|
+
|
|
30
37
|
## Sandboxing / Security
|
|
31
38
|
|
|
32
39
|
The model gets no command prompt access and limited access to your filesystem:
|
|
33
40
|
- Only within the working directory and subdirectories.
|
|
34
41
|
- `.git` folder and `.env` file can't be accessed by the model, tho it knows they exist, if they do.
|
|
35
42
|
- Files that are `.gitignore`d can't be written to.
|
|
43
|
+
- 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.
|
|
36
44
|
|
|
37
45
|
However, these restrictions are only as good as your practices; you should:
|
|
38
46
|
- 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.
|
|
Binary file
|
package/index.js
CHANGED
|
@@ -5,42 +5,43 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { parseEnv } from "node:util";
|
|
6
6
|
import { createInterface } from "node:readline/promises";
|
|
7
7
|
import ignore from "ignore";
|
|
8
|
+
import { Command } from "commander";
|
|
8
9
|
import { marked } from "marked";
|
|
9
10
|
import { markedTerminal } from "marked-terminal";
|
|
10
11
|
import OpenAI from "openai";
|
|
11
12
|
|
|
12
13
|
const configDir = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const state = fs.existsSync(path.join(configDir, ".state.json")) ? JSON.parse(fs.readFileSync(path.join(configDir, ".state.json"), "utf8")) : {};
|
|
15
|
+
state.profiles ??= {};
|
|
16
|
+
state.apiKeys ??= {};
|
|
17
|
+
if (!state.apiKeys.openai && fs.existsSync(path.join(configDir, ".env"))) {
|
|
18
|
+
const env = parseEnv(fs.readFileSync(path.join(configDir, ".env"), "utf8"));
|
|
19
|
+
if (env.OPENAI_API_KEY) {
|
|
20
|
+
state.apiKeys.openai = env.OPENAI_API_KEY;
|
|
18
21
|
}
|
|
19
|
-
|
|
22
|
+
}
|
|
23
|
+
const promptForApiKey = async (provider) => {
|
|
20
24
|
const readline = createInterface({ input: process.stdin, output: process.stdout });
|
|
21
25
|
try {
|
|
22
|
-
|
|
26
|
+
state.apiKeys[provider] = (await readline.question("Enter your API key: ")).trim();
|
|
23
27
|
}
|
|
24
28
|
finally {
|
|
25
29
|
readline.close();
|
|
26
30
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
};
|
|
32
|
+
const saveState = () => {
|
|
33
|
+
fs.writeFileSync(path.join(configDir, ".state.json"), JSON.stringify(state, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
34
|
+
fs.chmodSync(path.join(configDir, ".state.json"), 0o600);
|
|
35
|
+
};
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
37
|
+
const slaveignore = ignore();
|
|
38
|
+
if (fs.existsSync(".slaveignore")) {
|
|
39
|
+
slaveignore.add(fs.readFileSync(".slaveignore", "utf8").replaceAll("/\n", "\n"));
|
|
34
40
|
}
|
|
35
|
-
const client = new OpenAI({
|
|
36
|
-
apiKey: env.OPENAI_API_KEY,
|
|
37
|
-
});
|
|
38
|
-
|
|
39
41
|
const gitignore = ignore();
|
|
40
42
|
if (fs.existsSync(".gitignore")) {
|
|
41
43
|
gitignore.add(fs.readFileSync(".gitignore", "utf8").replaceAll("/\n", "\n"));
|
|
42
44
|
}
|
|
43
|
-
|
|
44
45
|
const isOutOfReach = (p, type) => {
|
|
45
46
|
const rel = path.relative(".", p);
|
|
46
47
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
@@ -50,12 +51,26 @@ const isOutOfReach = (p, type) => {
|
|
|
50
51
|
if (segs.includes(".env") || segs.includes(".git")) {
|
|
51
52
|
return true;
|
|
52
53
|
}
|
|
53
|
-
if (type == "write" &&
|
|
54
|
+
if (rel && (slaveignore.ignores(rel) || (type == "write" && gitignore.ignores(rel)))) {
|
|
54
55
|
return true;
|
|
55
56
|
}
|
|
56
57
|
return false;
|
|
57
58
|
};
|
|
58
59
|
|
|
60
|
+
const readFileForModel = (filePath) => fs.readFileSync(filePath, "utf8").replaceAll("\r\n", "\n");
|
|
61
|
+
const writeFileFromModel = (filePath, data, usesCRLF) => {
|
|
62
|
+
if (fs.existsSync(filePath)) {
|
|
63
|
+
if (usesCRLF === undefined) {
|
|
64
|
+
usesCRLF = fs.readFileSync(filePath, "utf8").includes("\r\n");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
const normalized = data.replaceAll("\r\n", "\n");
|
|
71
|
+
fs.writeFileSync(filePath, usesCRLF ? normalized.replaceAll("\n", "\r\n") : normalized, "utf8");
|
|
72
|
+
};
|
|
73
|
+
|
|
59
74
|
const functions = {
|
|
60
75
|
ls: ({ path }) => {
|
|
61
76
|
path ??= ".";
|
|
@@ -93,7 +108,7 @@ const functions = {
|
|
|
93
108
|
.filter(file => !isOutOfReach(file));
|
|
94
109
|
const res = [];
|
|
95
110
|
for (const file of files) {
|
|
96
|
-
const matches =
|
|
111
|
+
const matches = readFileForModel(file).split(substring).length - 1;
|
|
97
112
|
if (matches) {
|
|
98
113
|
res.push(`${file} has ${matches} match${matches == 1 ? "" : "es"}`);
|
|
99
114
|
}
|
|
@@ -104,25 +119,27 @@ const functions = {
|
|
|
104
119
|
if (isOutOfReach(path)) {
|
|
105
120
|
return "Out of your reach";
|
|
106
121
|
}
|
|
107
|
-
return
|
|
122
|
+
return readFileForModel(path);
|
|
108
123
|
},
|
|
109
124
|
writeFile: ({ path, data }) => {
|
|
110
125
|
if (isOutOfReach(path, "write")) {
|
|
111
126
|
return "Out of your reach";
|
|
112
127
|
}
|
|
113
|
-
|
|
128
|
+
writeFileFromModel(path, data);
|
|
114
129
|
return "OK";
|
|
115
130
|
},
|
|
116
131
|
fileFindAndReplace: ({ path, substring, replacement, expectedMatches }) => {
|
|
117
132
|
if (isOutOfReach(path, "write")) {
|
|
118
133
|
return "Out of your reach";
|
|
119
134
|
}
|
|
120
|
-
const
|
|
135
|
+
const fileContent = fs.readFileSync(path, "utf8");
|
|
136
|
+
const usesCRLF = fileContent.includes("\r\n");
|
|
137
|
+
const arr = fileContent.replaceAll("\r\n", "\n").split(substring);
|
|
121
138
|
const matches = arr.length - 1;
|
|
122
139
|
if (matches == 0 || (expectedMatches && matches != expectedMatches)) {
|
|
123
140
|
return `There were ${matches} match${matches == 1 ? "" : "es"}`;
|
|
124
141
|
}
|
|
125
|
-
|
|
142
|
+
writeFileFromModel(path, arr.join(replacement), usesCRLF);
|
|
126
143
|
return "OK";
|
|
127
144
|
},
|
|
128
145
|
system: ({ command }) => {
|
|
@@ -220,8 +237,7 @@ let tools = [
|
|
|
220
237
|
},*/
|
|
221
238
|
];
|
|
222
239
|
|
|
223
|
-
const generateResponseOptionallyUsingTools = async (input, previous_response_id) => {
|
|
224
|
-
const model = "gpt-6-luna";
|
|
240
|
+
const generateResponseOptionallyUsingTools = async (client, model, input, previous_response_id) => {
|
|
225
241
|
let response = await client.responses.create({
|
|
226
242
|
model,
|
|
227
243
|
previous_response_id,
|
|
@@ -268,54 +284,90 @@ marked.use(markedTerminal());
|
|
|
268
284
|
const markdownToConsoleText = (md) => {
|
|
269
285
|
const _console_error = console.error; // prevent unintended output about unknown languages, e.g. "```env\ndeez\n```\n"
|
|
270
286
|
console.error = undefined;
|
|
271
|
-
const text = marked.parse(md);
|
|
287
|
+
const text = marked.parse(md).trimEnd() + "\n";
|
|
272
288
|
console.error = _console_error;
|
|
273
289
|
return text;
|
|
274
290
|
};
|
|
275
291
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
292
|
+
|
|
293
|
+
const program = new Command();
|
|
294
|
+
program
|
|
295
|
+
.name("slave")
|
|
296
|
+
.usage("[options] [--] [prompt...]")
|
|
297
|
+
.description("Vibe coding without the kaboom factor.")
|
|
298
|
+
.argument("[prompt...]")
|
|
299
|
+
.option("-c, --continue", "add your previous invocation to the context of this one").option("--cont")
|
|
300
|
+
.option("--amend", "retry the previous invocation, using the context from before it")
|
|
301
|
+
.option("-w, --web", "allow the model to search the web")
|
|
302
|
+
.option("-n, --no-fs", "disable filesystem access").option("--nofs")
|
|
303
|
+
.option("-p, --profile <name>", "profile name", "default")
|
|
304
|
+
.option("--create-profile <name> <provider> <model>", "create a profile")
|
|
305
|
+
.option("--delete-profile <name>", "delete a profile") // TODO
|
|
306
|
+
.option("--update-api-key <provider>", "update an API key") // TODO
|
|
307
|
+
.helpOption("-?, --help", "display help")
|
|
308
|
+
.parse();
|
|
309
|
+
const options = program.opts();
|
|
310
|
+
|
|
311
|
+
if (options.createProfile) {
|
|
312
|
+
if (!program.args[0] || !program.args[1]) {
|
|
313
|
+
console.log("Syntax: slave --create-profile <name> <provider> <model>");
|
|
314
|
+
process.exit(1);
|
|
296
315
|
}
|
|
297
|
-
|
|
298
|
-
|
|
316
|
+
const provider = program.args[0];
|
|
317
|
+
if (provider != "openai" && !provider.startsWith("http://") && !provider.startsWith("https://")) {
|
|
318
|
+
console.log(`Invalid provider: ${provider}`);
|
|
319
|
+
process.exit(1);
|
|
299
320
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
break;
|
|
321
|
+
const isCreate = !(options.createProfile in state.profiles);
|
|
322
|
+
state.profiles[options.createProfile] = { provider, model: program.args[1] };
|
|
323
|
+
if (!state.apiKeys[provider]) {
|
|
324
|
+
await promptForApiKey(provider);
|
|
305
325
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
326
|
+
saveState();
|
|
327
|
+
console.log(`${isCreate ? "Created" : "Updated"} ${options.createProfile} profile.`);
|
|
328
|
+
process.exit(0);
|
|
329
|
+
}
|
|
330
|
+
if (options.deleteProfile) {
|
|
331
|
+
delete state.profiles[options.deleteProfile];
|
|
332
|
+
saveState();
|
|
333
|
+
console.log(`Deleted ${options.deleteProfile} profile.`);
|
|
334
|
+
process.exit(0);
|
|
335
|
+
}
|
|
336
|
+
if (options.updateApiKey) {
|
|
337
|
+
await promptForApiKey(options.updateApiKey);
|
|
338
|
+
saveState();
|
|
339
|
+
console.log("Saved.");
|
|
340
|
+
process.exit(0);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const profile = state.profiles[options.profile];
|
|
344
|
+
if (!profile) {
|
|
345
|
+
if (options.profile == "default") {
|
|
346
|
+
console.log(markdownToConsoleText("You did not specify a profile, but a default profile does not exist. Use e.g. `slave --create-profile default openai gpt-6-luna` to create one."));
|
|
309
347
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
? [argument]
|
|
313
|
-
: [...argument.slice(1)].map((flag) => `-${flag}`);
|
|
314
|
-
for (const flag of flags) {
|
|
315
|
-
processFlag(flag);
|
|
348
|
+
else {
|
|
349
|
+
console.log(markdownToConsoleText(`No such profile. Use e.g. \`slave --create-profile ${options.profile} openai gpt-6-luna\` to create it.`));
|
|
316
350
|
}
|
|
351
|
+
process.exit(1);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
let prompt = program.args.join(" ").trim();
|
|
355
|
+
let previous_response_id;
|
|
356
|
+
const overrides = [profile.provider.startsWith("http") ? `${profile.model} via ${profile.provider}` : `${profile.provider}/${profile.model}`];
|
|
357
|
+
if (options.continue || options.cont) {
|
|
358
|
+
previous_response_id = state.previous_response_id;
|
|
359
|
+
}
|
|
360
|
+
else if (options.amend) {
|
|
361
|
+
previous_response_id = state.previous_previous_response_id;
|
|
362
|
+
}
|
|
363
|
+
if (options.web) {
|
|
364
|
+
tools.push({ type: "web_search" });
|
|
365
|
+
overrides.push("web search enabled");
|
|
366
|
+
}
|
|
367
|
+
if (options.fs == false || options.nofs) {
|
|
368
|
+
tools = tools.filter(tool => tool.type != "function");
|
|
369
|
+
overrides.push("no filesystem access");
|
|
317
370
|
}
|
|
318
|
-
let prompt = process.argv.slice(i).join(" ").trim();
|
|
319
371
|
let stdinText = "";
|
|
320
372
|
if (!process.stdin.isTTY) {
|
|
321
373
|
const chunks = [];
|
|
@@ -325,7 +377,7 @@ if (!process.stdin.isTTY) {
|
|
|
325
377
|
stdinText = Buffer.concat(chunks).toString("utf8");
|
|
326
378
|
}
|
|
327
379
|
if (!prompt) {
|
|
328
|
-
console.log("
|
|
380
|
+
console.log("No prompt given.");
|
|
329
381
|
process.exit(1);
|
|
330
382
|
}
|
|
331
383
|
|
|
@@ -334,13 +386,23 @@ if (stdinText) {
|
|
|
334
386
|
overrides.push("stdin provided");
|
|
335
387
|
prompt = `${stdinText}\n\n${prompt}`;
|
|
336
388
|
}
|
|
337
|
-
|
|
338
|
-
|
|
389
|
+
console.log(`(${overrides.join(", ")})\n`);
|
|
390
|
+
|
|
391
|
+
//console.log({ model: profile.model, prompt, previous_response_id });
|
|
392
|
+
|
|
393
|
+
const clientOpts = {
|
|
394
|
+
apiKey: state.apiKeys[profile.provider],
|
|
395
|
+
};
|
|
396
|
+
if (profile.provider != "openai") {
|
|
397
|
+
clientOpts.baseURL = profile.provider;
|
|
398
|
+
clientOpts.apiKey ||= "dummy";
|
|
339
399
|
}
|
|
340
|
-
|
|
341
|
-
const response = await generateResponseOptionallyUsingTools(prompt, previous_response_id);
|
|
342
|
-
|
|
400
|
+
const client = new OpenAI(clientOpts);
|
|
401
|
+
const response = await generateResponseOptionallyUsingTools(client, profile.model, prompt, previous_response_id);
|
|
402
|
+
state.previous_previous_response_id = previous_response_id;
|
|
403
|
+
state.previous_response_id = response.id;
|
|
404
|
+
saveState();
|
|
343
405
|
if (response.used_tools) {
|
|
344
406
|
console.log();
|
|
345
407
|
}
|
|
346
|
-
console.log(markdownToConsoleText(response.output_text)
|
|
408
|
+
console.log(markdownToConsoleText(response.output_text) + "\n");
|
package/package.json
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
"type": "git",
|
|
5
5
|
"url": "git+https://github.com/calamity-inc/slave.git"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.
|
|
7
|
+
"version": "0.2.0",
|
|
8
8
|
"type": "module",
|
|
9
9
|
"bin": {
|
|
10
|
-
"slave": "
|
|
10
|
+
"slave": "index.js"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
+
"commander": "^15.0.0",
|
|
13
14
|
"ignore": "^7.0.13",
|
|
14
15
|
"marked": "^12",
|
|
15
16
|
"marked-terminal": "7.0.0",
|