outsrc 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/LICENSE +21 -0
- package/README.md +188 -0
- package/config.example.toml +46 -0
- package/dist/adapters.d.ts +110 -0
- package/dist/adapters.js +246 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +302 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.js +87 -0
- package/dist/engines.d.ts +44 -0
- package/dist/engines.js +69 -0
- package/dist/fs-home.d.ts +7 -0
- package/dist/fs-home.js +28 -0
- package/dist/git.d.ts +9 -0
- package/dist/git.js +27 -0
- package/dist/init.d.ts +54 -0
- package/dist/init.js +322 -0
- package/dist/job.d.ts +19 -0
- package/dist/job.js +112 -0
- package/dist/limits.d.ts +13 -0
- package/dist/limits.js +10 -0
- package/dist/mailbox.d.ts +122 -0
- package/dist/mailbox.js +503 -0
- package/dist/migrate.d.ts +11 -0
- package/dist/migrate.js +122 -0
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +36 -0
- package/dist/permissions.d.ts +2 -0
- package/dist/permissions.js +29 -0
- package/dist/plugin-contract.d.ts +12 -0
- package/dist/plugin-contract.js +79 -0
- package/dist/plugins.d.ts +44 -0
- package/dist/plugins.js +215 -0
- package/dist/prompt.d.ts +2 -0
- package/dist/prompt.js +20 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.js +127 -0
- package/dist/settings.d.ts +247 -0
- package/dist/settings.js +214 -0
- package/dist/state.d.ts +79 -0
- package/dist/state.js +61 -0
- package/dist/types.d.ts +181 -0
- package/dist/types.js +35 -0
- package/dist/workspace.d.ts +18 -0
- package/dist/workspace.js +66 -0
- package/dist/wrapper.d.ts +2 -0
- package/dist/wrapper.js +246 -0
- package/engines.lock.json +14 -0
- package/package.json +59 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { defaultConfigPath, defaultHome, loadConfigFile } from "./config.js";
|
|
3
|
+
import { migrateLegacy } from "./migrate.js";
|
|
4
|
+
import { changeSetting, listSettings } from "./settings.js";
|
|
5
|
+
import { defaultInitEnv, parseInitArgs, runInit } from "./init.js";
|
|
6
|
+
import { createMailbox } from "./mailbox.js";
|
|
7
|
+
import { installEngine, managedScript, readLock, resolveCommit, writeLockEntry } from "./engines.js";
|
|
8
|
+
import { checkPluginContract, pluginVersion } from "./plugin-contract.js";
|
|
9
|
+
import { isPluginAdapter, resolvePluginScript } from "./plugins.js";
|
|
10
|
+
import { DEFAULT_CALLER, parseCaller } from "./types.js";
|
|
11
|
+
function pluginReports(explicit) {
|
|
12
|
+
return ["codex-plugin", "grok-plugin"].flatMap((adapter) => {
|
|
13
|
+
const script = explicit[adapter] ?? managedScript(defaultHome(), adapter) ?? resolvePluginScript(adapter);
|
|
14
|
+
return script ? [checkPluginContract(adapter, script)] : [];
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function flagValue(argv, name) {
|
|
18
|
+
const index = argv.indexOf(name);
|
|
19
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
20
|
+
}
|
|
21
|
+
function hasFlag(argv, name) {
|
|
22
|
+
return argv.includes(name);
|
|
23
|
+
}
|
|
24
|
+
function takeFlag(argv, name) {
|
|
25
|
+
const index = argv.indexOf(name);
|
|
26
|
+
if (index < 0)
|
|
27
|
+
return { rest: argv };
|
|
28
|
+
const value = argv[index + 1];
|
|
29
|
+
if (value === undefined || value.startsWith("-"))
|
|
30
|
+
throw new Error(`${name} needs a value`);
|
|
31
|
+
return { value, rest: [...argv.slice(0, index), ...argv.slice(index + 2)] };
|
|
32
|
+
}
|
|
33
|
+
function parseMailboxArgs(argv) {
|
|
34
|
+
let rest = [...argv];
|
|
35
|
+
const callerFlag = takeFlag(rest, "--caller");
|
|
36
|
+
rest = callerFlag.rest;
|
|
37
|
+
const json = hasFlag(rest, "--json");
|
|
38
|
+
rest = rest.filter((arg) => arg !== "--json");
|
|
39
|
+
const names = ["--repo", "--target", "--message", "--thread", "--thread-id", "--request-id", "--model", "--effort", "--kind", "--base", "--ref", "--run-id", "--offset", "--limit"];
|
|
40
|
+
const flags = {};
|
|
41
|
+
for (const name of names) {
|
|
42
|
+
const taken = takeFlag(rest, name);
|
|
43
|
+
rest = taken.rest;
|
|
44
|
+
if (taken.value !== undefined)
|
|
45
|
+
flags[name] = taken.value;
|
|
46
|
+
}
|
|
47
|
+
if (rest.some((arg) => arg.startsWith("-")))
|
|
48
|
+
throw new Error(`unknown option: ${rest.find((arg) => arg.startsWith("-"))}`);
|
|
49
|
+
return {
|
|
50
|
+
caller: callerFlag.value !== undefined ? parseCaller(callerFlag.value) : undefined,
|
|
51
|
+
json,
|
|
52
|
+
flags,
|
|
53
|
+
positionals: rest,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function writeJson(body, pretty = true) {
|
|
57
|
+
process.stdout.write(`${pretty ? JSON.stringify(body, null, 2) : JSON.stringify(body)}\n`);
|
|
58
|
+
}
|
|
59
|
+
const HELP = `outsrc init [--repo <path>]... [--yes] [--json] [--plugins|--no-plugins] [--local claude,codex,grok]
|
|
60
|
+
[--max-jobs <n|unlimited>] [--max-run-minutes <n|unlimited>]
|
|
61
|
+
config [list] | config set <key> <value> | config unset <key>
|
|
62
|
+
doctor | list_repos | list_targets | targets | threads | prune | migrate
|
|
63
|
+
plugins [--codex <script>] [--grok <script>]
|
|
64
|
+
engines pin <codex-plugin|grok-plugin> <commit|branch|tag>
|
|
65
|
+
send --repo <alias> --target <name> --message <text> [--caller <id>] [--json]
|
|
66
|
+
[--thread-id <id>] [--request-id <id>] [--model <m>] [--effort <e>]
|
|
67
|
+
[--kind task|review|adversarial_review] [--base <ref>] [--ref <ref>]
|
|
68
|
+
inbox <thread_id> [--caller <id>] [--json]
|
|
69
|
+
history <thread_id> [--caller <id>] [--json]
|
|
70
|
+
log <thread_id> [--run-id <id>] [--offset <n>] [--limit <n>] [--caller <id>] [--json]
|
|
71
|
+
diff <thread_id> [--limit <n>] [--caller <id>] [--json]
|
|
72
|
+
stop <thread_id> [--caller <id>] [--json]
|
|
73
|
+
discard <thread_id> [--caller <id>] [--json]
|
|
74
|
+
|
|
75
|
+
init sets up config and optional local MCP registration for local CLIs.
|
|
76
|
+
Public install is the Outsrc bot from Bot Exchange; this CLI is for bot authors and advanced operators.
|
|
77
|
+
|
|
78
|
+
Mailbox commands print JSON (same shapes as the stdio MCP tools). Pass --caller so each
|
|
79
|
+
bot only sees its own threads; omit it on doctor/list_*/threads to act as the owner.
|
|
80
|
+
`;
|
|
81
|
+
const command = process.argv[2] ?? "help";
|
|
82
|
+
try {
|
|
83
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
84
|
+
process.stdout.write(HELP);
|
|
85
|
+
}
|
|
86
|
+
else if (command === "init") {
|
|
87
|
+
const options = parseInitArgs(process.argv.slice(3));
|
|
88
|
+
const env = defaultInitEnv(defaultConfigPath(defaultHome()), options.json);
|
|
89
|
+
try {
|
|
90
|
+
process.exitCode = await runInit(env, options);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
env.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (command === "engines") {
|
|
97
|
+
const [sub, adapter, ref] = process.argv.slice(3);
|
|
98
|
+
if (sub !== "pin" || !adapter || !isPluginAdapter(adapter) || !ref)
|
|
99
|
+
throw new Error("usage: outsrc engines pin <codex-plugin|grok-plugin> <commit|branch|tag>");
|
|
100
|
+
const current = readLock()[adapter];
|
|
101
|
+
if (!current)
|
|
102
|
+
throw new Error(`engines.lock.json has no ${adapter} entry`);
|
|
103
|
+
const commit = resolveCommit(current.source, ref);
|
|
104
|
+
const script = installEngine(defaultHome(), adapter, { ...current, commit });
|
|
105
|
+
const report = checkPluginContract(adapter, script);
|
|
106
|
+
if (report.problems.length) {
|
|
107
|
+
writeJson({ pinned: false, report });
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
const entry = { ...current, commit, version: pluginVersion(script) ?? "unknown" };
|
|
112
|
+
writeLockEntry(adapter, entry);
|
|
113
|
+
writeJson({ pinned: true, entry, next: "Run npm run smoke:plugins. Commit engines.lock.json if it passes; revert it if not." });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else if (command === "config") {
|
|
117
|
+
const [sub, key, value, ...extra] = process.argv.slice(3).filter((arg) => arg !== "--json");
|
|
118
|
+
const configPath = defaultConfigPath(defaultHome());
|
|
119
|
+
if (sub === undefined || sub === "list")
|
|
120
|
+
writeJson(listSettings(configPath));
|
|
121
|
+
else if ((sub === "set" && key && value !== undefined && extra.length === 0) || (sub === "unset" && key && value === undefined)) {
|
|
122
|
+
const change = changeSetting(configPath, key, sub === "set" ? value : undefined);
|
|
123
|
+
writeJson(change);
|
|
124
|
+
if (!change.ok)
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
else if (change.warning)
|
|
127
|
+
process.stderr.write(`Note: ${change.warning}\n`);
|
|
128
|
+
}
|
|
129
|
+
else
|
|
130
|
+
throw new Error("usage: outsrc config [list] | config set <key> <value> | config unset <key>");
|
|
131
|
+
}
|
|
132
|
+
else if (command === "plugins") {
|
|
133
|
+
const flags = process.argv.slice(3);
|
|
134
|
+
const codex = flagValue(flags, "--codex");
|
|
135
|
+
const grok = flagValue(flags, "--grok");
|
|
136
|
+
const reports = pluginReports({ ...(codex ? { "codex-plugin": codex } : {}), ...(grok ? { "grok-plugin": grok } : {}) });
|
|
137
|
+
writeJson({ plugins: reports });
|
|
138
|
+
if (reports.length === 0 || reports.some((report) => report.status !== "ok"))
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const home = defaultHome();
|
|
143
|
+
const config = loadConfigFile(defaultConfigPath(home));
|
|
144
|
+
const parsed = parseMailboxArgs(process.argv.slice(3));
|
|
145
|
+
// Owner CLI (no --caller) sees every thread for doctor/list/threads/prune.
|
|
146
|
+
// Mailbox mutations and per-thread reads default to "local" when --caller is omitted.
|
|
147
|
+
const ownerCommands = new Set(["doctor", "list_repos", "repos", "list_targets", "targets", "threads", "prune", "migrate"]);
|
|
148
|
+
const caller = parsed.caller ?? (ownerCommands.has(command) ? undefined : DEFAULT_CALLER);
|
|
149
|
+
const box = createMailbox({ home, config, ...(caller !== undefined ? { caller } : {}) });
|
|
150
|
+
switch (command) {
|
|
151
|
+
case "doctor": {
|
|
152
|
+
const targets = box.listTargets().targets;
|
|
153
|
+
const plugins = Object.values(config.targets).flatMap((target) => target.adapter && isPluginAdapter(target.adapter) && target.command ? [checkPluginContract(target.adapter, target.command)] : []);
|
|
154
|
+
const report = {
|
|
155
|
+
node: process.version,
|
|
156
|
+
config: defaultConfigPath(home),
|
|
157
|
+
repositories: box.listRepos().repos,
|
|
158
|
+
targets,
|
|
159
|
+
plugins,
|
|
160
|
+
note: "Checks configuration, executable discovery and the vendor plugin CLI contract. Does not authenticate providers or establish an execution security boundary.",
|
|
161
|
+
};
|
|
162
|
+
writeJson(report);
|
|
163
|
+
if (targets.some((target) => !target.available) || plugins.some((plugin) => plugin.status === "broken"))
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "list_repos":
|
|
168
|
+
case "repos":
|
|
169
|
+
writeJson(box.listRepos());
|
|
170
|
+
break;
|
|
171
|
+
case "list_targets":
|
|
172
|
+
case "targets":
|
|
173
|
+
writeJson(box.listTargets());
|
|
174
|
+
break;
|
|
175
|
+
case "threads":
|
|
176
|
+
writeJson(box.threads());
|
|
177
|
+
break;
|
|
178
|
+
case "migrate": {
|
|
179
|
+
const migrated = migrateLegacy({ home, config });
|
|
180
|
+
writeJson(migrated);
|
|
181
|
+
if (migrated.skipped.length)
|
|
182
|
+
process.exitCode = 1;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "prune":
|
|
186
|
+
writeJson(box.prune());
|
|
187
|
+
break;
|
|
188
|
+
case "send": {
|
|
189
|
+
const message = parsed.flags["--message"] ?? parsed.positionals[0];
|
|
190
|
+
if (!message)
|
|
191
|
+
throw new Error("send needs --message <text>");
|
|
192
|
+
const kind = parsed.flags["--kind"];
|
|
193
|
+
const threadId = parsed.flags["--thread-id"] ?? parsed.flags["--thread"];
|
|
194
|
+
const input = {
|
|
195
|
+
message,
|
|
196
|
+
...(parsed.flags["--repo"] !== undefined ? { repo: parsed.flags["--repo"] } : {}),
|
|
197
|
+
...(parsed.flags["--target"] !== undefined ? { target: parsed.flags["--target"] } : {}),
|
|
198
|
+
...(threadId !== undefined ? { thread_id: threadId } : {}),
|
|
199
|
+
...(parsed.flags["--request-id"] !== undefined ? { request_id: parsed.flags["--request-id"] } : {}),
|
|
200
|
+
...(parsed.flags["--model"] !== undefined ? { model: parsed.flags["--model"] } : {}),
|
|
201
|
+
...(parsed.flags["--effort"] !== undefined ? { effort: parsed.flags["--effort"] } : {}),
|
|
202
|
+
...(kind === "task" || kind === "review" || kind === "adversarial_review" ? { kind } : {}),
|
|
203
|
+
...(parsed.flags["--base"] !== undefined ? { base: parsed.flags["--base"] } : {}),
|
|
204
|
+
...(parsed.flags["--ref"] !== undefined ? { ref: parsed.flags["--ref"] } : {}),
|
|
205
|
+
};
|
|
206
|
+
const sent = await box.send(input);
|
|
207
|
+
if (sent.ok)
|
|
208
|
+
writeJson({ delivered: true, thread_id: sent.thread_id, run_id: sent.run_id });
|
|
209
|
+
else {
|
|
210
|
+
writeJson(sent);
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
}
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case "inbox": {
|
|
216
|
+
const threadId = parsed.positionals[0] ?? parsed.flags["--thread-id"] ?? parsed.flags["--thread"];
|
|
217
|
+
if (!threadId)
|
|
218
|
+
throw new Error("inbox needs <thread_id>");
|
|
219
|
+
const result = box.inbox(threadId);
|
|
220
|
+
writeJson(result);
|
|
221
|
+
if (!result.ok)
|
|
222
|
+
process.exitCode = 1;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case "history": {
|
|
226
|
+
const threadId = parsed.positionals[0] ?? parsed.flags["--thread-id"] ?? parsed.flags["--thread"];
|
|
227
|
+
if (!threadId)
|
|
228
|
+
throw new Error("history needs <thread_id>");
|
|
229
|
+
const result = box.history(threadId);
|
|
230
|
+
writeJson(result);
|
|
231
|
+
if (!result.ok)
|
|
232
|
+
process.exitCode = 1;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case "log": {
|
|
236
|
+
const threadId = parsed.positionals[0] ?? parsed.flags["--thread-id"] ?? parsed.flags["--thread"];
|
|
237
|
+
if (!threadId)
|
|
238
|
+
throw new Error("log needs <thread_id>");
|
|
239
|
+
const offset = parsed.flags["--offset"] !== undefined ? Number(parsed.flags["--offset"]) : undefined;
|
|
240
|
+
const limit = parsed.flags["--limit"] !== undefined ? Number(parsed.flags["--limit"]) : undefined;
|
|
241
|
+
if (offset !== undefined && !Number.isFinite(offset))
|
|
242
|
+
throw new Error("--offset must be a number");
|
|
243
|
+
if (limit !== undefined && !Number.isFinite(limit))
|
|
244
|
+
throw new Error("--limit must be a number");
|
|
245
|
+
const result = box.log({
|
|
246
|
+
thread_id: threadId,
|
|
247
|
+
...(parsed.flags["--run-id"] !== undefined ? { run_id: parsed.flags["--run-id"] } : {}),
|
|
248
|
+
...(offset !== undefined ? { offset } : {}),
|
|
249
|
+
...(limit !== undefined ? { limit } : {}),
|
|
250
|
+
});
|
|
251
|
+
writeJson(result);
|
|
252
|
+
if (!result.ok)
|
|
253
|
+
process.exitCode = 1;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
case "diff": {
|
|
257
|
+
const threadId = parsed.positionals[0] ?? parsed.flags["--thread-id"] ?? parsed.flags["--thread"];
|
|
258
|
+
if (!threadId)
|
|
259
|
+
throw new Error("diff needs <thread_id>");
|
|
260
|
+
const limit = parsed.flags["--limit"] !== undefined ? Number(parsed.flags["--limit"]) : undefined;
|
|
261
|
+
if (limit !== undefined && !Number.isFinite(limit))
|
|
262
|
+
throw new Error("--limit must be a number");
|
|
263
|
+
const result = box.diff({ thread_id: threadId, ...(limit !== undefined ? { limit } : {}) });
|
|
264
|
+
writeJson(result);
|
|
265
|
+
if ("ok" in result && result.ok === false)
|
|
266
|
+
process.exitCode = 1;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
case "stop": {
|
|
270
|
+
const threadId = parsed.positionals[0] ?? parsed.flags["--thread-id"] ?? parsed.flags["--thread"];
|
|
271
|
+
if (!threadId)
|
|
272
|
+
throw new Error("stop needs <thread_id>");
|
|
273
|
+
const result = box.stop(threadId);
|
|
274
|
+
writeJson(result);
|
|
275
|
+
if (!result.ok)
|
|
276
|
+
process.exitCode = 1;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
case "discard": {
|
|
280
|
+
const threadId = parsed.positionals[0] ?? parsed.flags["--thread-id"] ?? parsed.flags["--thread"];
|
|
281
|
+
if (!threadId)
|
|
282
|
+
throw new Error("discard needs <thread_id>");
|
|
283
|
+
const result = box.discard(threadId);
|
|
284
|
+
writeJson(result);
|
|
285
|
+
if (!result.ok)
|
|
286
|
+
process.exitCode = 1;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
default:
|
|
290
|
+
throw new Error(`Unknown command: ${command}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
296
|
+
process.exitCode = 1;
|
|
297
|
+
const mailbox = new Set(["send", "inbox", "history", "log", "diff", "stop", "discard"]);
|
|
298
|
+
if (mailbox.has(command))
|
|
299
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: message }, null, 2)}\n`);
|
|
300
|
+
else
|
|
301
|
+
process.stderr.write(`${message}\n`);
|
|
302
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Config } from "./types.js";
|
|
3
|
+
export declare const ConfigSchema: z.ZodObject<{
|
|
4
|
+
limits: z.ZodDefault<z.ZodObject<{
|
|
5
|
+
max_jobs: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodLiteral<"unlimited">]>>;
|
|
6
|
+
max_run_minutes: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodLiteral<"unlimited">]>>;
|
|
7
|
+
}, z.core.$strict>>;
|
|
8
|
+
repos: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
9
|
+
alias: z.ZodString;
|
|
10
|
+
path: z.ZodString;
|
|
11
|
+
setup: z.ZodOptional<z.ZodArray<z.ZodArray<z.ZodString>>>;
|
|
12
|
+
auto_commit: z.ZodOptional<z.ZodBoolean>;
|
|
13
|
+
retention_days: z.ZodOptional<z.ZodNumber>;
|
|
14
|
+
}, z.core.$strip>>>;
|
|
15
|
+
targets: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
16
|
+
adapter: z.ZodOptional<z.ZodEnum<{
|
|
17
|
+
claude: "claude";
|
|
18
|
+
codex: "codex";
|
|
19
|
+
"codex-plugin": "codex-plugin";
|
|
20
|
+
custom: "custom";
|
|
21
|
+
grok: "grok";
|
|
22
|
+
"grok-plugin": "grok-plugin";
|
|
23
|
+
}>>;
|
|
24
|
+
command: z.ZodOptional<z.ZodString>;
|
|
25
|
+
args: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
26
|
+
permissions: z.ZodOptional<z.ZodEnum<{
|
|
27
|
+
ask: "ask";
|
|
28
|
+
auto: "auto";
|
|
29
|
+
}>>;
|
|
30
|
+
models: z.ZodOptional<z.ZodObject<{
|
|
31
|
+
default: z.ZodString;
|
|
32
|
+
allowed: z.ZodArray<z.ZodString>;
|
|
33
|
+
}, z.core.$strip>>;
|
|
34
|
+
effort: z.ZodOptional<z.ZodObject<{
|
|
35
|
+
default: z.ZodString;
|
|
36
|
+
allowed: z.ZodArray<z.ZodString>;
|
|
37
|
+
}, z.core.$strip>>;
|
|
38
|
+
description: z.ZodOptional<z.ZodString>;
|
|
39
|
+
cost_note: z.ZodOptional<z.ZodString>;
|
|
40
|
+
}, z.core.$strip>>>;
|
|
41
|
+
}, z.core.$strip>;
|
|
42
|
+
export declare function loadConfigFile(path: string): Config;
|
|
43
|
+
/** Validate a parsed config document. Throws with the first problem. */
|
|
44
|
+
export declare function configFromDocument(document: unknown, path: string): Config;
|
|
45
|
+
export declare function defaultHome(): string;
|
|
46
|
+
export declare function defaultConfigPath(home?: string): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parse } from "smol-toml";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
import { managedScript } from "./engines.js";
|
|
6
|
+
import { isPluginAdapter, realScript, resolvePluginScript } from "./plugins.js";
|
|
7
|
+
import { DEFAULT_LIMITS } from "./limits.js";
|
|
8
|
+
const Options = z.object({ default: z.string(), allowed: z.array(z.string()).min(1) })
|
|
9
|
+
.refine((options) => options.allowed.includes(options.default), { message: "default must be one of allowed" });
|
|
10
|
+
const TargetSchema = z.object({
|
|
11
|
+
adapter: z.enum(["claude", "codex", "grok", "custom", "codex-plugin", "grok-plugin"]).optional(),
|
|
12
|
+
command: z.string().min(1).optional(),
|
|
13
|
+
args: z.array(z.string()).default([]),
|
|
14
|
+
permissions: z.enum(["auto", "ask"]).optional(),
|
|
15
|
+
models: Options.optional(),
|
|
16
|
+
effort: Options.optional(),
|
|
17
|
+
description: z.string().optional(),
|
|
18
|
+
cost_note: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
// A positive whole number, or "unlimited".
|
|
21
|
+
const Limit = z.union([z.number().int().positive(), z.literal("unlimited")]);
|
|
22
|
+
export const ConfigSchema = z.object({
|
|
23
|
+
limits: z.object({ max_jobs: Limit.optional(), max_run_minutes: Limit.optional() }).strict().default({}),
|
|
24
|
+
repos: z.array(z.object({
|
|
25
|
+
alias: z.string().min(1),
|
|
26
|
+
path: z.string().min(1),
|
|
27
|
+
setup: z.array(z.array(z.string()).min(1)).optional(),
|
|
28
|
+
auto_commit: z.boolean().optional(),
|
|
29
|
+
retention_days: z.number().positive().optional(),
|
|
30
|
+
})).default([]),
|
|
31
|
+
targets: z.record(z.string(), TargetSchema).default({}),
|
|
32
|
+
});
|
|
33
|
+
export function loadConfigFile(path) {
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = readFileSync(path, "utf8");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(`config not found: ${path}`);
|
|
40
|
+
}
|
|
41
|
+
return configFromDocument(parse(raw), path);
|
|
42
|
+
}
|
|
43
|
+
/** Validate a parsed config document. Throws with the first problem. */
|
|
44
|
+
export function configFromDocument(document, path) {
|
|
45
|
+
const parsed = ConfigSchema.parse(document);
|
|
46
|
+
const repos = parsed.repos.map((repo) => ({
|
|
47
|
+
alias: repo.alias,
|
|
48
|
+
path: repo.path,
|
|
49
|
+
...(repo.setup !== undefined ? { setup: repo.setup } : {}),
|
|
50
|
+
...(repo.auto_commit !== undefined ? { autoCommit: repo.auto_commit } : {}),
|
|
51
|
+
...(repo.retention_days !== undefined ? { retentionDays: repo.retention_days } : {}),
|
|
52
|
+
}));
|
|
53
|
+
const targets = {};
|
|
54
|
+
for (const [name, target] of Object.entries(parsed.targets)) {
|
|
55
|
+
const adapter = target.adapter;
|
|
56
|
+
let command = target.command;
|
|
57
|
+
if (command === undefined) {
|
|
58
|
+
// An unresolved plugin keeps an empty command so list_targets reports it unavailable instead of failing startup.
|
|
59
|
+
// outsrc's pinned engine wins; a Claude Code install of the same plugin is the fallback.
|
|
60
|
+
if (adapter !== undefined && isPluginAdapter(adapter))
|
|
61
|
+
command = managedScript(dirname(path), adapter) ?? resolvePluginScript(adapter) ?? "";
|
|
62
|
+
else
|
|
63
|
+
throw new Error(`target ${name} needs a command`);
|
|
64
|
+
}
|
|
65
|
+
else if (adapter !== undefined && isPluginAdapter(adapter)) {
|
|
66
|
+
command = realScript(command);
|
|
67
|
+
}
|
|
68
|
+
targets[name] = {
|
|
69
|
+
command,
|
|
70
|
+
args: target.args,
|
|
71
|
+
...(target.adapter !== undefined ? { adapter: target.adapter } : {}),
|
|
72
|
+
...(target.permissions !== undefined ? { permissions: target.permissions } : {}),
|
|
73
|
+
...(target.models !== undefined ? { models: target.models } : {}),
|
|
74
|
+
...(target.effort !== undefined ? { effort: target.effort } : {}),
|
|
75
|
+
...(target.description !== undefined ? { description: target.description } : {}),
|
|
76
|
+
...(target.cost_note !== undefined ? { costNote: target.cost_note } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const limit = (value, fallback) => value === "unlimited" ? null : value ?? fallback;
|
|
80
|
+
const limits = {
|
|
81
|
+
maxJobs: limit(parsed.limits.max_jobs, DEFAULT_LIMITS.max_jobs),
|
|
82
|
+
maxRunMinutes: limit(parsed.limits.max_run_minutes, DEFAULT_LIMITS.max_run_minutes),
|
|
83
|
+
};
|
|
84
|
+
return { repos, targets, limits };
|
|
85
|
+
}
|
|
86
|
+
export function defaultHome() { return process.env.OUTSRC_HOME ?? `${process.env.HOME}/.outsrc`; }
|
|
87
|
+
export function defaultConfigPath(home = defaultHome()) { return `${home}/config.toml`; }
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type PluginAdapter } from "./plugins.js";
|
|
3
|
+
export declare const LOCK_FILE: string;
|
|
4
|
+
declare const EntrySchema: z.ZodObject<{
|
|
5
|
+
source: z.ZodString;
|
|
6
|
+
path: z.ZodString;
|
|
7
|
+
version: z.ZodString;
|
|
8
|
+
commit: z.ZodString;
|
|
9
|
+
}, z.core.$strip>;
|
|
10
|
+
export type LockEntry = z.infer<typeof EntrySchema>;
|
|
11
|
+
declare const LockSchema: z.ZodObject<{
|
|
12
|
+
"codex-plugin": z.ZodOptional<z.ZodObject<{
|
|
13
|
+
source: z.ZodString;
|
|
14
|
+
path: z.ZodString;
|
|
15
|
+
version: z.ZodString;
|
|
16
|
+
commit: z.ZodString;
|
|
17
|
+
}, z.core.$strip>>;
|
|
18
|
+
"grok-plugin": z.ZodOptional<z.ZodObject<{
|
|
19
|
+
source: z.ZodString;
|
|
20
|
+
path: z.ZodString;
|
|
21
|
+
version: z.ZodString;
|
|
22
|
+
commit: z.ZodString;
|
|
23
|
+
}, z.core.$strip>>;
|
|
24
|
+
}, z.core.$strip>;
|
|
25
|
+
export type EngineLock = z.infer<typeof LockSchema>;
|
|
26
|
+
export declare function readLock(file?: string): EngineLock;
|
|
27
|
+
export declare function writeLockEntry(adapter: PluginAdapter, entry: LockEntry, file?: string): void;
|
|
28
|
+
export declare function managedScript(home: string, adapter: PluginAdapter, lock?: {
|
|
29
|
+
"codex-plugin"?: {
|
|
30
|
+
source: string;
|
|
31
|
+
path: string;
|
|
32
|
+
version: string;
|
|
33
|
+
commit: string;
|
|
34
|
+
} | undefined;
|
|
35
|
+
"grok-plugin"?: {
|
|
36
|
+
source: string;
|
|
37
|
+
path: string;
|
|
38
|
+
version: string;
|
|
39
|
+
commit: string;
|
|
40
|
+
} | undefined;
|
|
41
|
+
}): string | null;
|
|
42
|
+
export declare function installEngine(home: string, adapter: PluginAdapter, entry: LockEntry): string;
|
|
43
|
+
export declare function resolveCommit(source: string, ref: string): string;
|
|
44
|
+
export {};
|
package/dist/engines.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { ensureOutsrcHome } from "./fs-home.js";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { ENGINES, realScript } from "./plugins.js";
|
|
8
|
+
// The lock pins each vendor plugin engine to one commit that passed npm run smoke:plugins.
|
|
9
|
+
export const LOCK_FILE = join(dirname(fileURLToPath(import.meta.url)), "..", "engines.lock.json");
|
|
10
|
+
const COMMIT = /^[0-9a-f]{40}$/;
|
|
11
|
+
const EntrySchema = z.object({ source: z.string().min(1), path: z.string().min(1), version: z.string().min(1), commit: z.string().regex(COMMIT) });
|
|
12
|
+
const LockSchema = z.object({ "codex-plugin": EntrySchema.optional(), "grok-plugin": EntrySchema.optional() });
|
|
13
|
+
export function readLock(file = LOCK_FILE) {
|
|
14
|
+
return existsSync(file) ? LockSchema.parse(JSON.parse(readFileSync(file, "utf8"))) : {};
|
|
15
|
+
}
|
|
16
|
+
export function writeLockEntry(adapter, entry, file = LOCK_FILE) {
|
|
17
|
+
// Package lock lives in the repo/install tree, not ~/.outsrc.
|
|
18
|
+
writeFileSync(file, `${JSON.stringify({ ...readLock(file), [adapter]: EntrySchema.parse(entry) }, null, 2)}\n`);
|
|
19
|
+
}
|
|
20
|
+
function engineRoot(home, adapter, commit) {
|
|
21
|
+
return join(home, "engines", adapter, commit);
|
|
22
|
+
}
|
|
23
|
+
export function managedScript(home, adapter, lock = readLock()) {
|
|
24
|
+
const entry = lock[adapter];
|
|
25
|
+
if (!entry)
|
|
26
|
+
return null;
|
|
27
|
+
const script = join(engineRoot(home, adapter, entry.commit), ENGINES[adapter].script);
|
|
28
|
+
return existsSync(script) ? realScript(script) : null;
|
|
29
|
+
}
|
|
30
|
+
function git(args) {
|
|
31
|
+
return execFileSync("git", args, { encoding: "utf8", stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
32
|
+
}
|
|
33
|
+
// Fetches exactly the pinned commit and moves the plugin folder into place in one rename, so a rerun or a crash
|
|
34
|
+
// never leaves a half-written engine where resolution would find it.
|
|
35
|
+
export function installEngine(home, adapter, entry) {
|
|
36
|
+
const root = engineRoot(home, adapter, entry.commit);
|
|
37
|
+
const script = join(root, ENGINES[adapter].script);
|
|
38
|
+
if (existsSync(script))
|
|
39
|
+
return realScript(script);
|
|
40
|
+
ensureOutsrcHome(home);
|
|
41
|
+
mkdirSync(dirname(root), { recursive: true, mode: 0o700 });
|
|
42
|
+
const temp = mkdtempSync(join(dirname(root), ".fetch-"));
|
|
43
|
+
try {
|
|
44
|
+
git(["init", "-q", temp]);
|
|
45
|
+
git(["-C", temp, "fetch", "-q", "--depth", "1", entry.source, entry.commit]);
|
|
46
|
+
git(["-C", temp, "checkout", "-q", "FETCH_HEAD"]);
|
|
47
|
+
const head = git(["-C", temp, "rev-parse", "HEAD"]).trim();
|
|
48
|
+
if (head !== entry.commit)
|
|
49
|
+
throw new Error(`fetched ${head}, expected ${entry.commit}`);
|
|
50
|
+
const plugin = join(temp, entry.path);
|
|
51
|
+
if (!existsSync(join(plugin, ENGINES[adapter].script)))
|
|
52
|
+
throw new Error(`${entry.source} at ${entry.commit} has no ${entry.path}/${ENGINES[adapter].script}`);
|
|
53
|
+
if (!existsSync(root))
|
|
54
|
+
renameSync(plugin, root);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
rmSync(temp, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
return realScript(script);
|
|
60
|
+
}
|
|
61
|
+
export function resolveCommit(source, ref) {
|
|
62
|
+
if (COMMIT.test(ref))
|
|
63
|
+
return ref;
|
|
64
|
+
const line = git(["ls-remote", source, ref]).split("\n").find((text) => text.trim());
|
|
65
|
+
const commit = line?.split(/\s+/)[0];
|
|
66
|
+
if (!commit || !COMMIT.test(commit))
|
|
67
|
+
throw new Error(`${source} has no ref ${ref}`);
|
|
68
|
+
return commit;
|
|
69
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type WriteFileOptions } from "node:fs";
|
|
2
|
+
/** Create ~/.outsrc (or OUTSRC_HOME) as 0700. */
|
|
3
|
+
export declare function ensureOutsrcHome(home: string): void;
|
|
4
|
+
/** Write a file under the outsrc home with mode 0600. */
|
|
5
|
+
export declare function writeHomeFile(path: string, data: string | Uint8Array, options?: WriteFileOptions): void;
|
|
6
|
+
/** Append to a file under the outsrc home with mode 0600 (created if missing). */
|
|
7
|
+
export declare function appendHomeFile(path: string, data: string | Uint8Array): void;
|
package/dist/fs-home.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
/** Create ~/.outsrc (or OUTSRC_HOME) as 0700. */
|
|
3
|
+
export function ensureOutsrcHome(home) {
|
|
4
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
5
|
+
try {
|
|
6
|
+
chmodSync(home, 0o700);
|
|
7
|
+
}
|
|
8
|
+
catch { /* best-effort on platforms that ignore mode */ }
|
|
9
|
+
}
|
|
10
|
+
/** Write a file under the outsrc home with mode 0600. */
|
|
11
|
+
export function writeHomeFile(path, data, options) {
|
|
12
|
+
const opts = typeof options === "string"
|
|
13
|
+
? { encoding: options, mode: 0o600 }
|
|
14
|
+
: { ...(options ?? {}), mode: 0o600 };
|
|
15
|
+
writeFileSync(path, data, opts);
|
|
16
|
+
try {
|
|
17
|
+
chmodSync(path, 0o600);
|
|
18
|
+
}
|
|
19
|
+
catch { /* best-effort */ }
|
|
20
|
+
}
|
|
21
|
+
/** Append to a file under the outsrc home with mode 0600 (created if missing). */
|
|
22
|
+
export function appendHomeFile(path, data) {
|
|
23
|
+
appendFileSync(path, data, { mode: 0o600 });
|
|
24
|
+
try {
|
|
25
|
+
chmodSync(path, 0o600);
|
|
26
|
+
}
|
|
27
|
+
catch { /* best-effort */ }
|
|
28
|
+
}
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare function addWorktree(opts: {
|
|
2
|
+
repo: string;
|
|
3
|
+
worktree: string;
|
|
4
|
+
branch: string;
|
|
5
|
+
ref?: string;
|
|
6
|
+
}): void;
|
|
7
|
+
export declare function isAgentConfig(path: string): boolean;
|
|
8
|
+
/** Leave the checkout's agent settings out of a review worktree. Git still has them, so the diff still shows them. */
|
|
9
|
+
export declare function hideAgentConfig(worktree: string): string[];
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
export function addWorktree(opts) {
|
|
5
|
+
mkdirSync(dirname(opts.worktree), { recursive: true });
|
|
6
|
+
execFileSync("git", ["worktree", "add", "-b", opts.branch, opts.worktree, opts.ref ?? "HEAD"], { cwd: opts.repo, stdio: "pipe" });
|
|
7
|
+
}
|
|
8
|
+
// Project settings these CLIs load from the checkout: hooks, MCP servers and notify commands run as the user,
|
|
9
|
+
// outside any sandbox. Codex extends a trusted repository's trust to its worktrees, and Grok reads the Claude
|
|
10
|
+
// and Cursor locations too, so a branch under review could run code through any of them.
|
|
11
|
+
const AGENT_CONFIG_DIRS = new Set([".claude", ".codex", ".grok", ".cursor"]);
|
|
12
|
+
const AGENT_CONFIG_FILES = new Set([".mcp.json"]);
|
|
13
|
+
export function isAgentConfig(path) {
|
|
14
|
+
const segments = path.split("/");
|
|
15
|
+
return segments.slice(0, -1).some((segment) => AGENT_CONFIG_DIRS.has(segment)) || AGENT_CONFIG_FILES.has(segments.at(-1) ?? "");
|
|
16
|
+
}
|
|
17
|
+
/** Leave the checkout's agent settings out of a review worktree. Git still has them, so the diff still shows them. */
|
|
18
|
+
export function hideAgentConfig(worktree) {
|
|
19
|
+
const tracked = execFileSync("git", ["ls-files", "-z"], { cwd: worktree, encoding: "utf8" }).split("\0").filter(Boolean);
|
|
20
|
+
const hidden = tracked.filter(isAgentConfig);
|
|
21
|
+
if (hidden.length === 0)
|
|
22
|
+
return [];
|
|
23
|
+
execFileSync("git", ["update-index", "--skip-worktree", "-z", "--stdin"], { cwd: worktree, input: `${hidden.join("\0")}\0`, stdio: ["pipe", "pipe", "pipe"] });
|
|
24
|
+
for (const path of hidden)
|
|
25
|
+
rmSync(join(worktree, path), { force: true });
|
|
26
|
+
return hidden;
|
|
27
|
+
}
|