@wrangled/cli 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/dist/api.js +192 -0
- package/dist/brand.js +9 -0
- package/dist/files.js +66 -0
- package/dist/guide.js +221 -0
- package/dist/main.js +2151 -0
- package/dist/playbooks.js +512 -0
- package/dist/pull.js +209 -0
- package/dist/runner/claim.js +42 -0
- package/dist/runner/claude.js +87 -0
- package/dist/runner/codex.js +167 -0
- package/dist/runner/harness.js +65 -0
- package/dist/runner/harnesses.js +10 -0
- package/dist/runner/redact.js +56 -0
- package/dist/runner/run.js +204 -0
- package/dist/skill-zip.js +189 -0
- package/dist/skills.js +131 -0
- package/package.json +34 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { api, ApiError, apiText, CliError, exitCodes, getSigned, writeCredentials } from "../api.js";
|
|
7
|
+
import { brand, envPrefix } from "../brand.js";
|
|
8
|
+
import { RUN_ROOT_ENV } from "../files.js";
|
|
9
|
+
import { installSkillFolder, unpackSkill } from "../skills.js";
|
|
10
|
+
import { parseClaim } from "./claim.js";
|
|
11
|
+
import { MAX_TURNS } from "./harness.js";
|
|
12
|
+
import { commandHarness, harnesses } from "./harnesses.js";
|
|
13
|
+
import { LogTail, redact, RUN_ERROR_MAX_BYTES, tailBytes } from "./redact.js";
|
|
14
|
+
export const RUNNER_TOKEN_ENV = `${envPrefix}_RUNNER_TOKEN`;
|
|
15
|
+
export const HARNESS_COMMAND_ENV = `${envPrefix}_RUNNER_HARNESS_COMMAND`;
|
|
16
|
+
export const API_URL_ENV = `${envPrefix}_API_URL`;
|
|
17
|
+
/** Set for a harness, so its agent, memory and proposal commands act as the run's skill's agent unless told otherwise. */
|
|
18
|
+
export const AGENT_ENV = `${envPrefix}_AGENT`;
|
|
19
|
+
const HEARTBEAT_MS = 60_000;
|
|
20
|
+
const IDLE_WAIT_MS = 60_000;
|
|
21
|
+
const FINISH_RETRY_WAITS_MS = [2_000, 5_000, 15_000];
|
|
22
|
+
/** What a harness may inherit from the runner's environment. Keys and tokens are never on it. */
|
|
23
|
+
const INHERITED_ENV = ["HOME", "USER", "LOGNAME", "SHELL", "TERM", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TZ"];
|
|
24
|
+
const stopMessages = {
|
|
25
|
+
deadline: "The run's deadline passed, so the runner stopped the harness.",
|
|
26
|
+
cancelled: "The run was cancelled, so the runner stopped the harness.",
|
|
27
|
+
stopped: "The runner was stopped, so it stopped the harness.",
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The environment a harness starts with, built from nothing: the allowlisted basics, the run's own `wr` first on
|
|
31
|
+
* PATH, its config folder, the API address, and the run folder, outside which `wr` reads and writes no files.
|
|
32
|
+
*/
|
|
33
|
+
export function harnessEnv(source, run) {
|
|
34
|
+
const env = {};
|
|
35
|
+
for (const name of INHERITED_ENV) {
|
|
36
|
+
const value = source[name];
|
|
37
|
+
if (value !== undefined)
|
|
38
|
+
env[name] = value;
|
|
39
|
+
}
|
|
40
|
+
env.PATH = [run.binDir, source.PATH || "/usr/bin:/bin"].join(":");
|
|
41
|
+
env.XDG_CONFIG_HOME = run.configHome;
|
|
42
|
+
env[API_URL_ENV] = run.apiUrl;
|
|
43
|
+
env[RUN_ROOT_ENV] = run.dir;
|
|
44
|
+
env[AGENT_ENV] = "skill";
|
|
45
|
+
return env;
|
|
46
|
+
}
|
|
47
|
+
/** The worker's prompt, then how a run keeps its log, notes and proposals as its skill's agent. */
|
|
48
|
+
export function runPrompt(prompt) {
|
|
49
|
+
const cli = brand.slug;
|
|
50
|
+
return `${prompt}
|
|
51
|
+
|
|
52
|
+
This is a scheduled run for one ${brand.name} workspace. Run \`${cli} agent start\` first: it opens this run's log and prints its run id and your notes from earlier runs (\`${cli} memory get\` prints them in full). Rewrite a note for the next run with \`${cli} memory set <note> --file <path>\`. Propose a draft ad for a person to approve with \`${cli} proposals create\`, giving the run id. Run \`${cli} agent finish <run-id> --status succeeded|failed --file <report.md>\` last, with a short Markdown report whose first line says what the run did.`;
|
|
53
|
+
}
|
|
54
|
+
const shellQuote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
|
|
55
|
+
/** A `wr` that runs this very CLI with this very Node. */
|
|
56
|
+
function cliShim() {
|
|
57
|
+
const main = fileURLToPath(new URL("../main.js", import.meta.url));
|
|
58
|
+
return `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(main)} "$@"\n`;
|
|
59
|
+
}
|
|
60
|
+
const runnerConnection = (options) => ({
|
|
61
|
+
apiUrl: options.apiUrl,
|
|
62
|
+
apiKey: options.token,
|
|
63
|
+
rejected: `The runner token in ${RUNNER_TOKEN_ENV} was rejected by ${options.apiUrl}.`,
|
|
64
|
+
});
|
|
65
|
+
/** Claims one run, does it in a fresh folder, reports how it ended, and removes the folder. */
|
|
66
|
+
export async function runOnce(options) {
|
|
67
|
+
const connection = runnerConnection(options);
|
|
68
|
+
const answer = await apiText(connection, "/api/runner/claim", options.runId === undefined ? {} : { runId: options.runId });
|
|
69
|
+
if (answer === "")
|
|
70
|
+
return { kind: "idle" };
|
|
71
|
+
const claim = parseClaim(JSON.parse(answer));
|
|
72
|
+
const secrets = [claim.runKey, options.token, ...(claim.billing.kind === "openrouter" ? [claim.billing.key] : [])];
|
|
73
|
+
const say = (line) => options.say(redact(line, secrets));
|
|
74
|
+
say(`Claimed run ${claim.runId}: ${claim.skill.name} version ${claim.skill.version} with ${claim.harness}.`);
|
|
75
|
+
const stop = new AbortController();
|
|
76
|
+
const stopWith = (reason) => {
|
|
77
|
+
if (!stop.signal.aborted)
|
|
78
|
+
stop.abort(reason);
|
|
79
|
+
};
|
|
80
|
+
const onStop = () => stopWith("stopped");
|
|
81
|
+
if (options.signal.aborted)
|
|
82
|
+
onStop();
|
|
83
|
+
options.signal.addEventListener("abort", onStop, { once: true });
|
|
84
|
+
const deadline = Math.min(claim.deadline.getTime(), options.maxSeconds === undefined ? Infinity : Date.now() + options.maxSeconds * 1000);
|
|
85
|
+
const deadlineTimer = setTimeout(() => stopWith("deadline"), Math.max(0, deadline - Date.now()));
|
|
86
|
+
const heartbeat = startHeartbeat(connection, claim.runId, () => stopWith("cancelled"), say);
|
|
87
|
+
const log = new LogTail();
|
|
88
|
+
let dir;
|
|
89
|
+
let report;
|
|
90
|
+
try {
|
|
91
|
+
dir = await mkdtemp(join(tmpdir(), `${brand.slug}-run-`));
|
|
92
|
+
const result = await execute(claim, dir, options, stop.signal, (text) => log.append(text));
|
|
93
|
+
report = finishReport(result, stop.signal.reason);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
report = { status: "failed", error: stop.signal.aborted ? stopMessages[stop.signal.reason] : `The runner failed: ${error.message}` };
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
clearTimeout(deadlineTimer);
|
|
100
|
+
heartbeat.stop();
|
|
101
|
+
options.signal.removeEventListener("abort", onStop);
|
|
102
|
+
if (dir !== undefined)
|
|
103
|
+
await rm(dir, { recursive: true, force: true }).catch((error) => say(`Could not remove ${dir}: ${error.message}`));
|
|
104
|
+
}
|
|
105
|
+
report.logTail = log.report(secrets);
|
|
106
|
+
if (report.error !== undefined)
|
|
107
|
+
report.error = tailBytes(redact(report.error, secrets), RUN_ERROR_MAX_BYTES);
|
|
108
|
+
try {
|
|
109
|
+
await finish(connection, claim.runId, report, say);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error instanceof ApiError && error.status === 409) {
|
|
113
|
+
say(`Run ${claim.runId} had already ended on the server (${error.message}).`);
|
|
114
|
+
return { kind: "ran", runId: claim.runId, status: "ended_elsewhere" };
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
say(`Run ${claim.runId} ${report.status}${report.error ? `: ${report.error}` : "."}`);
|
|
119
|
+
return { kind: "ran", runId: claim.runId, status: report.status };
|
|
120
|
+
}
|
|
121
|
+
/** Finish is idempotent on the server, so a network failure or a server error is worth another try before the run is lost. */
|
|
122
|
+
async function finish(connection, runId, report, say) {
|
|
123
|
+
for (const wait of [...FINISH_RETRY_WAITS_MS, undefined]) {
|
|
124
|
+
try {
|
|
125
|
+
await api(connection, `/api/runner/runs/${runId}/finish`, report);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
const retryable = error instanceof ApiError ? error.status >= 500 : error instanceof CliError && error.exitCode !== exitCodes.auth;
|
|
130
|
+
if (!retryable || wait === undefined)
|
|
131
|
+
throw error;
|
|
132
|
+
say(`Finishing run ${runId} failed (${error.message}); trying again.`);
|
|
133
|
+
await sleep(wait);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function finishReport(result, stopped) {
|
|
138
|
+
const { exitCode, tokens } = result;
|
|
139
|
+
if (stopped)
|
|
140
|
+
return { status: "failed", exitCode, tokens, error: stopMessages[stopped] };
|
|
141
|
+
if (exitCode === 0)
|
|
142
|
+
return { status: "succeeded", exitCode, tokens };
|
|
143
|
+
return { status: "failed", exitCode, tokens, error: `The harness exited with code ${exitCode}.` };
|
|
144
|
+
}
|
|
145
|
+
/** Puts the skill, the run key's login and a `wr` in `dir`, then runs the harness there. */
|
|
146
|
+
async function execute(claim, dir, options, signal, log) {
|
|
147
|
+
const skillsDir = join(dir, claim.harness === "claude" ? ".claude" : ".codex", "skills");
|
|
148
|
+
await installSkillFolder(skillsDir, claim.skill.name, unpackSkill(claim.skill, await getSigned(claim.skill.url)), []);
|
|
149
|
+
const configHome = join(dir, ".config");
|
|
150
|
+
await writeCredentials({ apiKey: claim.runKey, apiUrl: options.apiUrl }, configHome);
|
|
151
|
+
const binDir = join(dir, ".bin");
|
|
152
|
+
await mkdir(binDir);
|
|
153
|
+
await writeFile(join(binDir, brand.slug), cliShim(), { mode: 0o755 });
|
|
154
|
+
if (signal.aborted)
|
|
155
|
+
throw new Error("stopped before the harness started");
|
|
156
|
+
const harness = options.harnessCommand ? commandHarness(options.harnessCommand) : harnesses[claim.harness];
|
|
157
|
+
return harness({
|
|
158
|
+
dir,
|
|
159
|
+
skill: claim.skill.name,
|
|
160
|
+
prompt: runPrompt(claim.prompt),
|
|
161
|
+
model: claim.model,
|
|
162
|
+
billing: claim.billing,
|
|
163
|
+
aiCapUsd: claim.aiCapUsd,
|
|
164
|
+
maxTurns: MAX_TURNS,
|
|
165
|
+
env: harnessEnv(process.env, { dir, binDir, configHome, apiUrl: options.apiUrl }),
|
|
166
|
+
signal,
|
|
167
|
+
log,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** Beats now (moving the run to running) and every minute; a cancel, or a server that no longer knows the run, stops it. */
|
|
171
|
+
function startHeartbeat(connection, runId, cancel, say) {
|
|
172
|
+
const beat = async () => {
|
|
173
|
+
try {
|
|
174
|
+
const answer = await api(connection, `/api/runner/runs/${runId}/heartbeat`, {});
|
|
175
|
+
if (answer.cancel)
|
|
176
|
+
cancel();
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (error instanceof ApiError && error.status === 404)
|
|
180
|
+
cancel();
|
|
181
|
+
else
|
|
182
|
+
say(`Heartbeat for run ${runId} failed: ${error.message}`);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
void beat();
|
|
186
|
+
const timer = setInterval(() => void beat(), HEARTBEAT_MS);
|
|
187
|
+
return { stop: () => clearInterval(timer) };
|
|
188
|
+
}
|
|
189
|
+
/** Does runs one at a time until `signal` aborts: straight on after a run, a minute's wait after finding none. */
|
|
190
|
+
export async function runLoop(options) {
|
|
191
|
+
while (!options.signal.aborted) {
|
|
192
|
+
let ran = false;
|
|
193
|
+
try {
|
|
194
|
+
ran = (await runOnce(options)).kind === "ran";
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error instanceof CliError && error.exitCode === exitCodes.auth)
|
|
198
|
+
throw error;
|
|
199
|
+
options.say(redact(`error: ${error.message}`, [options.token]));
|
|
200
|
+
}
|
|
201
|
+
if (!ran)
|
|
202
|
+
await sleep(IDLE_WAIT_MS, undefined, { signal: options.signal }).catch(() => undefined);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// The server and the CLI each keep an identical copy of this file (src/lib/skills/skill-zip.ts and cli/src/skill-zip.ts);
|
|
2
|
+
// src/lib/cli-consistency.test.ts fails when they differ.
|
|
3
|
+
import { Inflate, zipSync } from "fflate";
|
|
4
|
+
export const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
5
|
+
/**
|
|
6
|
+
* A published skill's zip, before and after inflating. The zip cap stays under the hosting platform's 4.5 MB request
|
|
7
|
+
* limit. `files` counts files only; `entries` also counts the folder records `zip -r` writes.
|
|
8
|
+
*/
|
|
9
|
+
export const SKILL_LIMITS = { zipBytes: 4 * 1024 * 1024, files: 200, entries: 1000, unpackedBytes: 20 * 1024 * 1024 };
|
|
10
|
+
export const SKILL_MD = "SKILL.md";
|
|
11
|
+
export class SkillZipError extends Error {
|
|
12
|
+
}
|
|
13
|
+
const refuse = (reason) => {
|
|
14
|
+
throw new SkillZipError(`refused: ${reason}`);
|
|
15
|
+
};
|
|
16
|
+
const megabytes = (bytes) => `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`;
|
|
17
|
+
export function refuseOversizedZip(bytes) {
|
|
18
|
+
if (bytes > SKILL_LIMITS.zipBytes)
|
|
19
|
+
refuse(`zip is ${megabytes(bytes)}, the limit is ${megabytes(SKILL_LIMITS.zipBytes)}`);
|
|
20
|
+
}
|
|
21
|
+
export function refuseTooManyFiles(files) {
|
|
22
|
+
if (files > SKILL_LIMITS.files)
|
|
23
|
+
refuse(`zip holds ${files} files, the limit is ${SKILL_LIMITS.files}`);
|
|
24
|
+
}
|
|
25
|
+
export function refuseOversizedFiles(bytes) {
|
|
26
|
+
if (bytes > SKILL_LIMITS.unpackedBytes)
|
|
27
|
+
refuse(`files unpack to ${megabytes(bytes)}, the limit is ${megabytes(SKILL_LIMITS.unpackedBytes)}`);
|
|
28
|
+
}
|
|
29
|
+
/** Why a file name inside a skill cannot be written safely, or null when it can. Names are relative to the skill folder. */
|
|
30
|
+
export function unsafeFileName(name) {
|
|
31
|
+
if (name.includes("\\"))
|
|
32
|
+
return `backslash in ${name}`;
|
|
33
|
+
if (name.startsWith("/") || /^[A-Za-z]:/.test(name))
|
|
34
|
+
return `absolute path ${name}`;
|
|
35
|
+
if (name.includes("\0"))
|
|
36
|
+
return `NUL in ${JSON.stringify(name)}`;
|
|
37
|
+
const segments = name.split("/");
|
|
38
|
+
if (segments.includes(".."))
|
|
39
|
+
return `.. in ${name}`;
|
|
40
|
+
if (segments.some((s) => s === "" || s === "."))
|
|
41
|
+
return `empty or . segment in ${name}`;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/** Zips the files; each name is a `/`-separated path inside the skill folder. */
|
|
45
|
+
export function zipSkillFiles(files) {
|
|
46
|
+
const unix = (mode) => ({ os: 3, attrs: ((0o100000 | mode) << 16) >>> 0 });
|
|
47
|
+
return zipSync(Object.fromEntries(files.map((f) => [f.name, [f.data, unix(f.executable ? 0o755 : 0o644)]])), { level: 9 });
|
|
48
|
+
}
|
|
49
|
+
/** Reads and checks a skill's zip without trusting any size or name it declares. Throws SkillZipError naming the problem. */
|
|
50
|
+
export function readSkillZip(zip, skillName) {
|
|
51
|
+
refuseOversizedZip(zip.byteLength);
|
|
52
|
+
const files = unpack(zip);
|
|
53
|
+
const skillMd = files.find((f) => f.name === SKILL_MD) ?? refuse(`no ${SKILL_MD} at the top of the skill folder`);
|
|
54
|
+
const front = frontMatter(new TextDecoder().decode(skillMd.data));
|
|
55
|
+
const name = front.get("name") || refuse(`${SKILL_MD} has no name in its front matter`);
|
|
56
|
+
if (name !== skillName)
|
|
57
|
+
refuse(`${SKILL_MD} name '${name}' does not match skill '${skillName}'`);
|
|
58
|
+
const description = front.get("description") || refuse(`${SKILL_MD} has no description in its front matter`);
|
|
59
|
+
return { title: front.get("title") || name, description, files };
|
|
60
|
+
}
|
|
61
|
+
const EOCD = 0x06054b50;
|
|
62
|
+
const CENTRAL = 0x02014b50;
|
|
63
|
+
const LOCAL = 0x04034b50;
|
|
64
|
+
const SYMLINK = 0o120000;
|
|
65
|
+
const FILE_TYPE = 0o170000;
|
|
66
|
+
const INFLATE_STEP = 4096;
|
|
67
|
+
function unpack(zip) {
|
|
68
|
+
const view = new DataView(zip.buffer, zip.byteOffset, zip.byteLength);
|
|
69
|
+
const u16 = (at) => (at + 2 <= zip.byteLength ? view.getUint16(at, true) : refuse("zip is cut short"));
|
|
70
|
+
const u32 = (at) => (at + 4 <= zip.byteLength ? view.getUint32(at, true) : refuse("zip is cut short"));
|
|
71
|
+
let end = -1;
|
|
72
|
+
for (let at = zip.byteLength - 22; at >= Math.max(0, zip.byteLength - 22 - 0xffff); at--) {
|
|
73
|
+
if (view.getUint32(at, true) === EOCD) {
|
|
74
|
+
end = at;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (end === -1)
|
|
79
|
+
refuse("not a zip file");
|
|
80
|
+
const count = u16(end + 10);
|
|
81
|
+
let at = u32(end + 16);
|
|
82
|
+
if (count === 0xffff || at === 0xffffffff)
|
|
83
|
+
refuse("zip64 archives are not supported");
|
|
84
|
+
if (count > SKILL_LIMITS.entries)
|
|
85
|
+
refuse(`zip holds ${count} entries, the limit is ${SKILL_LIMITS.entries}`);
|
|
86
|
+
const entries = [];
|
|
87
|
+
for (let i = 0; i < count; i++) {
|
|
88
|
+
if (u32(at) !== CENTRAL)
|
|
89
|
+
refuse("zip's file list is damaged");
|
|
90
|
+
const flags = u16(at + 8);
|
|
91
|
+
const nameLength = u16(at + 28);
|
|
92
|
+
const name = new TextDecoder().decode(zip.subarray(at + 46, at + 46 + nameLength));
|
|
93
|
+
const mode = u32(at + 38) >>> 16;
|
|
94
|
+
const entry = { name, method: u16(at + 10), compressed: u32(at + 20), size: u32(at + 24), local: u32(at + 42), executable: (mode & 0o111) !== 0 };
|
|
95
|
+
at += 46 + nameLength + u16(at + 30) + u16(at + 32);
|
|
96
|
+
const unsafe = unsafeFileName(name.endsWith("/") ? name.slice(0, -1) : name);
|
|
97
|
+
if (unsafe)
|
|
98
|
+
refuse(unsafe);
|
|
99
|
+
if ((mode & FILE_TYPE) === SYMLINK)
|
|
100
|
+
refuse(`symlink at ${name}`);
|
|
101
|
+
if (flags & 1)
|
|
102
|
+
refuse(`${name} is encrypted`);
|
|
103
|
+
if (name.endsWith("/"))
|
|
104
|
+
continue;
|
|
105
|
+
if (entries.some((e) => e.name === name))
|
|
106
|
+
refuse(`${name} appears twice`);
|
|
107
|
+
if (entry.method !== 0 && entry.method !== 8)
|
|
108
|
+
refuse(`${name} uses an unsupported compression method`);
|
|
109
|
+
entries.push(entry);
|
|
110
|
+
}
|
|
111
|
+
refuseTooManyFiles(entries.length);
|
|
112
|
+
refuseOversizedFiles(entries.reduce((sum, e) => sum + e.size, 0));
|
|
113
|
+
let unpacked = 0;
|
|
114
|
+
return entries.map((entry) => {
|
|
115
|
+
if (u32(entry.local) !== LOCAL)
|
|
116
|
+
refuse(`zip entry ${entry.name} is damaged`);
|
|
117
|
+
const start = entry.local + 30 + u16(entry.local + 26) + u16(entry.local + 28);
|
|
118
|
+
if (start + entry.compressed > zip.byteLength)
|
|
119
|
+
refuse("zip is cut short");
|
|
120
|
+
const raw = zip.subarray(start, start + entry.compressed);
|
|
121
|
+
// A zip can declare small sizes and still inflate to gigabytes, so the inflated bytes are counted as they arrive.
|
|
122
|
+
const chunks = [];
|
|
123
|
+
let size = 0;
|
|
124
|
+
const take = (chunk) => {
|
|
125
|
+
size += chunk.byteLength;
|
|
126
|
+
unpacked += chunk.byteLength;
|
|
127
|
+
if (size > entry.size)
|
|
128
|
+
refuse(`${entry.name} unpacks to more than the zip says`);
|
|
129
|
+
if (unpacked > SKILL_LIMITS.unpackedBytes)
|
|
130
|
+
refuse(`files unpack to more than ${megabytes(SKILL_LIMITS.unpackedBytes)}`);
|
|
131
|
+
chunks.push(chunk);
|
|
132
|
+
};
|
|
133
|
+
if (entry.method === 0)
|
|
134
|
+
take(raw);
|
|
135
|
+
else {
|
|
136
|
+
const inflate = new Inflate((chunk) => take(chunk));
|
|
137
|
+
try {
|
|
138
|
+
let offset = 0;
|
|
139
|
+
do {
|
|
140
|
+
inflate.push(raw.subarray(offset, offset + INFLATE_STEP), offset + INFLATE_STEP >= raw.byteLength);
|
|
141
|
+
offset += INFLATE_STEP;
|
|
142
|
+
} while (offset < raw.byteLength);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error instanceof SkillZipError)
|
|
146
|
+
throw error;
|
|
147
|
+
refuse(`${entry.name} is damaged`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (size !== entry.size)
|
|
151
|
+
refuse(`${entry.name} unpacks to a different size than the zip says`);
|
|
152
|
+
const data = new Uint8Array(size);
|
|
153
|
+
let offset = 0;
|
|
154
|
+
for (const chunk of chunks) {
|
|
155
|
+
data.set(chunk, offset);
|
|
156
|
+
offset += chunk.byteLength;
|
|
157
|
+
}
|
|
158
|
+
return { name: entry.name, data, executable: entry.executable };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/** The `key: value` lines between the opening `---` fences. Quoted values lose their quotes; `>` and `|` blocks are joined. */
|
|
162
|
+
function frontMatter(text) {
|
|
163
|
+
const lines = text.replace(/^/, "").split(/\r?\n/);
|
|
164
|
+
const values = new Map();
|
|
165
|
+
if (lines[0]?.trim() !== "---")
|
|
166
|
+
return values;
|
|
167
|
+
const close = lines.findIndex((line, i) => i > 0 && line.trim() === "---");
|
|
168
|
+
if (close === -1)
|
|
169
|
+
refuse(`${SKILL_MD} front matter has no closing ---`);
|
|
170
|
+
for (let i = 1; i < close; i++) {
|
|
171
|
+
const match = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(lines[i]);
|
|
172
|
+
if (!match)
|
|
173
|
+
continue;
|
|
174
|
+
const key = match[1];
|
|
175
|
+
let value = match[2].trim();
|
|
176
|
+
if (/^[>|][-+]?$/.test(value)) {
|
|
177
|
+
const block = [];
|
|
178
|
+
while (i + 1 < close && /^(\s+\S|\s*$)/.test(lines[i + 1]))
|
|
179
|
+
block.push(lines[++i].trim());
|
|
180
|
+
value = block.join(value.startsWith(">") ? " " : "\n").trim();
|
|
181
|
+
}
|
|
182
|
+
else if (/^"(.*)"$/.test(value))
|
|
183
|
+
value = value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
184
|
+
else if (/^'(.*)'$/.test(value))
|
|
185
|
+
value = value.slice(1, -1).replace(/''/g, "'");
|
|
186
|
+
values.set(key, value);
|
|
187
|
+
}
|
|
188
|
+
return values;
|
|
189
|
+
}
|
package/dist/skills.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { CliError, exitCodes, readCredentials, resolveApiUrl, UnreadableCredentialsError } from "./api.js";
|
|
5
|
+
import { envPrefix } from "./brand.js";
|
|
6
|
+
import { firstSymlink, inRunFolder } from "./files.js";
|
|
7
|
+
import { readSkillZip, refuseOversizedFiles, refuseOversizedZip, refuseTooManyFiles, SKILL_NAME_PATTERN, SkillZipError, zipSkillFiles, } from "./skill-zip.js";
|
|
8
|
+
export const OPERATOR_TOKEN_ENV = `${envPrefix}_OPERATOR_TOKEN`;
|
|
9
|
+
// Env files hold secrets, and a published skill reaches every workspace.
|
|
10
|
+
const skipped = (entry) => [".git", "node_modules", ".DS_Store", ".env", ".envrc"].includes(entry) || entry.startsWith(".env.");
|
|
11
|
+
/** Operator commands (the skill library, promoting and dropping a worker's candidate) take the operator token, never the workspace API key. */
|
|
12
|
+
export async function operatorConnection(apiUrlFlag) {
|
|
13
|
+
const token = process.env[OPERATOR_TOKEN_ENV];
|
|
14
|
+
if (!token)
|
|
15
|
+
throw new CliError(`Set ${OPERATOR_TOKEN_ENV} to the server's OPERATOR_TOKEN for operator commands.`, exitCodes.auth);
|
|
16
|
+
const stored = await readCredentials().catch((error) => {
|
|
17
|
+
if (error instanceof UnreadableCredentialsError)
|
|
18
|
+
return null;
|
|
19
|
+
throw error;
|
|
20
|
+
});
|
|
21
|
+
const apiUrl = resolveApiUrl(apiUrlFlag, stored);
|
|
22
|
+
return { apiUrl, apiKey: token, rejected: `The operator token in ${OPERATOR_TOKEN_ENV} was rejected by ${apiUrl}.` };
|
|
23
|
+
}
|
|
24
|
+
/** `my-skill` or `my-skill@3`; a null version means the latest published one. */
|
|
25
|
+
export function parseSkillRef(ref) {
|
|
26
|
+
const match = /^([^@]+)(?:@([1-9]\d{0,8}))?$/.exec(ref);
|
|
27
|
+
if (!match || !SKILL_NAME_PATTERN.test(match[1])) {
|
|
28
|
+
throw new CliError(`'${ref}' is not a skill: use <name> or <name>@<version>, where the name is lowercase letters, digits and dashes.`, exitCodes.usage);
|
|
29
|
+
}
|
|
30
|
+
return { name: match[1], version: match[2] === undefined ? null : Number(match[2]) };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Every file under `dir`, refusing symlinks and skipping .git, node_modules, .DS_Store and env files (.env, .env.*,
|
|
34
|
+
* .envrc). The file count and sizes are checked before any file is read.
|
|
35
|
+
*/
|
|
36
|
+
export async function readSkillFolder(dir) {
|
|
37
|
+
const found = [];
|
|
38
|
+
let bytes = 0;
|
|
39
|
+
await inRunFolder(dir);
|
|
40
|
+
async function walk(relative) {
|
|
41
|
+
const entries = (await readdir(join(dir, ...relative))).filter((e) => !skipped(e)).sort();
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const name = [...relative, entry].join("/");
|
|
44
|
+
const path = join(dir, ...relative, entry);
|
|
45
|
+
const info = await lstat(path);
|
|
46
|
+
if (info.isSymbolicLink())
|
|
47
|
+
throw new CliError(`refused: symlink at ${name}`, exitCodes.error);
|
|
48
|
+
if (info.isDirectory())
|
|
49
|
+
await walk([...relative, entry]);
|
|
50
|
+
else if (info.isFile()) {
|
|
51
|
+
found.push({ name, path, executable: (info.mode & 0o111) !== 0 });
|
|
52
|
+
bytes += info.size;
|
|
53
|
+
checked(() => {
|
|
54
|
+
refuseTooManyFiles(found.length);
|
|
55
|
+
refuseOversizedFiles(bytes);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
else
|
|
59
|
+
throw new CliError(`refused: ${name} is not a regular file`, exitCodes.error);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
await walk([]);
|
|
63
|
+
return Promise.all(found.map(async (f) => ({ name: f.name, data: await readFile(f.path), executable: f.executable })));
|
|
64
|
+
}
|
|
65
|
+
/** Zips the files and checks them the way the server will, so a refusal comes before anything is sent. */
|
|
66
|
+
export function packSkill(name, files) {
|
|
67
|
+
return checked(() => {
|
|
68
|
+
const zip = zipSkillFiles(files);
|
|
69
|
+
refuseOversizedZip(zip.byteLength);
|
|
70
|
+
readSkillZip(zip, name);
|
|
71
|
+
return zip;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** The files of a downloaded version, once its bytes match the SHA-256 the server recorded at publish. */
|
|
75
|
+
export function unpackSkill(download, zip) {
|
|
76
|
+
if (createHash("sha256").update(zip).digest("hex") !== download.sha256) {
|
|
77
|
+
throw new CliError(`The download of ${download.name} version ${download.version} does not match its recorded sha256. Nothing was installed.`, exitCodes.error);
|
|
78
|
+
}
|
|
79
|
+
return checked(() => readSkillZip(zip, download.name).files);
|
|
80
|
+
}
|
|
81
|
+
function checked(run) {
|
|
82
|
+
try {
|
|
83
|
+
return run();
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error instanceof SkillZipError)
|
|
87
|
+
throw new CliError(error.message, exitCodes.error);
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Makes <skillsDir>/<name> hold exactly `files`: they are written to a sibling folder first, which then takes the old
|
|
93
|
+
* folder's place, so no file from an earlier version survives. Refuses to follow a symlink at any of `above` or the
|
|
94
|
+
* skill's own folder.
|
|
95
|
+
*/
|
|
96
|
+
export async function installSkillFolder(skillsDir, name, files, above) {
|
|
97
|
+
const target = await inRunFolder(join(skillsDir, name));
|
|
98
|
+
const linked = await firstSymlink([...above, target]);
|
|
99
|
+
if (linked)
|
|
100
|
+
throw new CliError(`Refusing to write through the symlink at ${linked}. Remove it and try again.`, exitCodes.error);
|
|
101
|
+
const suffix = randomBytes(6).toString("hex");
|
|
102
|
+
const staged = join(skillsDir, `.${name}.${suffix}.new`);
|
|
103
|
+
const old = join(skillsDir, `.${name}.${suffix}.old`);
|
|
104
|
+
await mkdir(staged, { recursive: true });
|
|
105
|
+
try {
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
const path = join(staged, ...file.name.split("/"));
|
|
108
|
+
await mkdir(dirname(path), { recursive: true });
|
|
109
|
+
await writeFile(path, file.data, { flag: "wx", mode: file.executable ? 0o755 : 0o644 });
|
|
110
|
+
}
|
|
111
|
+
const replacing = await rename(target, old).then(() => true, (error) => {
|
|
112
|
+
if (error.code === "ENOENT")
|
|
113
|
+
return false;
|
|
114
|
+
throw error;
|
|
115
|
+
});
|
|
116
|
+
try {
|
|
117
|
+
await rename(staged, target);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (replacing)
|
|
121
|
+
await rename(old, target);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
if (replacing)
|
|
125
|
+
await rm(old, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
await rm(staged, { recursive: true, force: true });
|
|
129
|
+
}
|
|
130
|
+
return target;
|
|
131
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wrangled/cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Command-line access to your marketing data warehouse, for people and coding agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"wr": "dist/main.js"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/bdavis905/wrangled.git",
|
|
12
|
+
"directory": "cli"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p ."
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.288",
|
|
28
|
+
"fflate": "^0.8.3"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^24",
|
|
32
|
+
"typescript": "^6.0.3"
|
|
33
|
+
}
|
|
34
|
+
}
|