shadok-ai 0.3.80 → 0.3.82

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.
@@ -48,17 +48,17 @@ absent, and you find out at 6am rather than now. A name that isn't in the vault
48
48
  is skipped just as silently. Your own shell having the key proves nothing about
49
49
  the guard's.
50
50
 
51
- `schedule.py env` prints exactly what a guard gets here. Run it before writing a
51
+ `schedule.mjs env` prints exactly what a guard gets here. Run it before writing a
52
52
  check that needs a secret.
53
53
 
54
54
  ## Commands
55
55
 
56
56
  ```
57
- python3 scripts/schedule.py add --schedule <spec> --prompt "<text>" [--check "<shell command>"] [--tz <zone>]
58
- python3 scripts/schedule.py list
59
- python3 scripts/schedule.py del <id>
60
- python3 scripts/schedule.py tz [<zone>|-]
61
- python3 scripts/schedule.py env
57
+ node scripts/schedule.mjs add --schedule <spec> --prompt "<text>" [--check "<shell command>"] [--tz <zone>]
58
+ node scripts/schedule.mjs list
59
+ node scripts/schedule.mjs del <id>
60
+ node scripts/schedule.mjs tz [<zone>|-]
61
+ node scripts/schedule.mjs env
62
62
  ```
63
63
 
64
64
  `<spec>`: `every:30m` · `every:2h` · `daily:09:00`.
@@ -69,8 +69,8 @@ A `daily` runs at that wall-clock time **in a timezone**, and the default is
69
69
  whatever the SERVER's machine is set to. On a machine running in UTC,
70
70
  `daily:09:00` fires at 11:00 for someone in Paris — silently.
71
71
 
72
- - `schedule.py tz` prints the timezone `daily` schedules actually use.
73
- - `schedule.py tz Europe/Paris` pins it for every `daily` on this instance,
72
+ - `schedule.mjs tz` prints the timezone `daily` schedules actually use.
73
+ - `schedule.mjs tz Europe/Paris` pins it for every `daily` on this instance,
74
74
  including the ones already scheduled (they're realigned immediately).
75
75
  - `--tz Europe/Paris` on `add` pins one schedule only.
76
76
 
@@ -81,7 +81,7 @@ An interval (`every:30m`) is a duration, so it has no timezone.
81
81
  ### Example — guarded monitoring (near-zero tokens)
82
82
 
83
83
  ```
84
- python3 scripts/schedule.py add \
84
+ node scripts/schedule.mjs add \
85
85
  --schedule daily:09:00 \
86
86
  --check "python3 $HOME/.claude/skills/google-ads/scripts/check_budget.py" \
87
87
  --prompt "Le monitoring a détecté une anomalie budget Google Ads. Rédige une alerte claire et actionnable pour l'équipe."
@@ -90,7 +90,7 @@ python3 scripts/schedule.py add \
90
90
  ### Example — plain recurring report (runs every time)
91
91
 
92
92
  ```
93
- python3 scripts/schedule.py add --schedule daily:18:00 \
93
+ node scripts/schedule.mjs add --schedule daily:18:00 \
94
94
  --prompt "Fais le point de la journée sur le compte et poste un court résumé."
95
95
  ```
96
96
 
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env node
2
+ // schedule — manage the CURRENT shadok-ai channel's scheduled prompts (crons)
3
+ // through the local server API. Reads SHADOK_SESSION_ID / SHADOK_PORT /
4
+ // SHADOK_AUTH from the env (injected by shadok-ai into every agent).
5
+ //
6
+ // Node, not Python: shadok-ai IS a Node program, so the runtime is guaranteed
7
+ // present wherever a skill runs. The Dockerfile's python3 is there to build
8
+ // native npm modules, not for skills — trimming it would have broken this
9
+ // silently. See context/scheduler-skill/test/ for the tests that now guard it.
10
+
11
+ import { pathToFileURL } from "node:url";
12
+
13
+ const TIMEOUT_MS = 15_000;
14
+
15
+ /** Exit the way the old `sys.exit("…")` did: message on stderr, code 1. */
16
+ function die(msg) {
17
+ process.stderr.write(msg + "\n");
18
+ process.exit(1);
19
+ }
20
+
21
+ /** A misuse of the command line — argparse's own exit code, kept on purpose. */
22
+ class UsageError extends Error {}
23
+
24
+ function env() {
25
+ const port = process.env.SHADOK_PORT;
26
+ const sid = process.env.SHADOK_SESSION_ID;
27
+ if (!port || !sid) die("Not inside a shadok-ai channel (SHADOK_PORT / SHADOK_SESSION_ID unset).");
28
+ return { base: `http://127.0.0.1:${port}`, sid, auth: process.env.SHADOK_AUTH ?? "" };
29
+ }
30
+
31
+ async function api(ctx, method, path, body) {
32
+ let res;
33
+ try {
34
+ res = await fetch(ctx.base + path, {
35
+ method,
36
+ headers: {
37
+ "content-type": "application/json",
38
+ ...(ctx.auth ? { Cookie: ctx.auth } : {}),
39
+ },
40
+ body: body === undefined ? undefined : JSON.stringify(body),
41
+ signal: AbortSignal.timeout(TIMEOUT_MS),
42
+ });
43
+ } catch (e) {
44
+ // The Python version had no branch for this and printed a traceback; a
45
+ // stopped cockpit is a normal thing to hit, so it gets a sentence.
46
+ die(`cannot reach the cockpit on ${ctx.base}: ${e.message}`);
47
+ }
48
+ const raw = await res.text();
49
+ if (!res.ok) die(`API error ${res.status}: ${raw.slice(0, 200)}`);
50
+ return raw ? JSON.parse(raw) : {};
51
+ }
52
+
53
+ /** `every:30m` · `every:2h` · `every:45` · `daily:09:00` → the API's schedule. */
54
+ export function parseSchedule(spec) {
55
+ const s = String(spec).trim().toLowerCase();
56
+ const bad = () => {
57
+ throw new UsageError(`bad schedule '${spec}' (use every:30m, every:2h, or daily:09:00)`);
58
+ };
59
+ if (s.startsWith("every:")) {
60
+ const m = /^(\d+)([hm]?)$/.exec(s.slice(6));
61
+ // Python raised on a non-numeric value; JS would quietly make it NaN and
62
+ // ship `everyMin: null` to the server, so the digits are checked here.
63
+ if (!m) bad();
64
+ const n = Number(m[1]);
65
+ return { kind: "interval", everyMin: m[2] === "h" ? n * 60 : n };
66
+ }
67
+ if (s.startsWith("daily:")) {
68
+ const m = /^(\d{1,2}):(\d{1,2})$/.exec(s.slice(6));
69
+ if (!m) bad();
70
+ return { kind: "daily", hour: Number(m[1]), minute: Number(m[2]) };
71
+ }
72
+ return bad();
73
+ }
74
+
75
+ const pad = (n) => String(n).padStart(2, "0");
76
+
77
+ export function label(s, tz) {
78
+ if (s.kind === "interval") return `every ${s.everyMin}m`;
79
+ // The timezone is always shown: a bare "daily 09:00" doesn't say 9am WHERE,
80
+ // and that is exactly what makes a UTC server look on time.
81
+ return `daily ${pad(s.hour)}:${pad(s.minute)}` + (tz ? ` (${tz})` : "");
82
+ }
83
+
84
+ /**
85
+ * The command line, kept byte-for-byte compatible with the argparse version:
86
+ * a renamed option would break every cron already registered by an agent.
87
+ */
88
+ export function parseArgs(argv) {
89
+ const [cmd, ...rest] = argv;
90
+ const known = {
91
+ add: { flags: ["--schedule", "--prompt", "--check", "--tz"], required: ["--schedule", "--prompt"], pos: 0 },
92
+ list: { flags: [], required: [], pos: 0 },
93
+ env: { flags: [], required: [], pos: 0 },
94
+ tz: { flags: [], required: [], pos: 1 },
95
+ del: { flags: [], required: [], pos: 1, posRequired: 1 },
96
+ };
97
+ if (!cmd || !(cmd in known)) {
98
+ throw new UsageError(`usage: schedule {add,list,del,tz,env} …\ninvalid choice: '${cmd ?? ""}'`);
99
+ }
100
+ const spec = known[cmd];
101
+ const opts = {};
102
+ const pos = [];
103
+ for (let i = 0; i < rest.length; i++) {
104
+ const a = rest[i];
105
+ if (a.startsWith("--")) {
106
+ const [name, inline] = a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a, null];
107
+ if (!spec.flags.includes(name)) throw new UsageError(`schedule ${cmd}: unrecognized argument ${name}`);
108
+ const v = inline ?? rest[++i];
109
+ if (v === undefined) throw new UsageError(`schedule ${cmd}: ${name} expects a value`);
110
+ opts[name.slice(2)] = v;
111
+ } else pos.push(a);
112
+ }
113
+ for (const r of spec.required) {
114
+ if (opts[r.slice(2)] === undefined) throw new UsageError(`schedule ${cmd}: the following arguments are required: ${r}`);
115
+ }
116
+ if (pos.length > spec.pos) throw new UsageError(`schedule ${cmd}: unrecognized arguments: ${pos.slice(spec.pos).join(" ")}`);
117
+ if (pos.length < (spec.posRequired ?? 0)) throw new UsageError(`schedule ${cmd}: the following arguments are required: id`);
118
+ return { cmd, opts, pos };
119
+ }
120
+
121
+ async function cmdAdd(ctx, opts) {
122
+ const body = { sessionId: ctx.sid, prompt: opts.prompt, schedule: parseSchedule(opts.schedule), enabled: true };
123
+ if (opts.check) body.check = opts.check;
124
+ if (opts.tz) body.tz = opts.tz;
125
+ const r = await api(ctx, "POST", "/crons", body);
126
+ console.log(
127
+ `scheduled [${String(r.id).slice(0, 8)}] ${label(r.schedule, r.timezone)}` +
128
+ (opts.check ? " +guard (0 tokens on quiet runs)" : ""),
129
+ );
130
+ }
131
+
132
+ async function cmdList(ctx) {
133
+ const all = await api(ctx, "GET", "/crons");
134
+ const mine = all.filter((c) => c.sessionId === ctx.sid);
135
+ if (!mine.length) return console.log("(no schedule on this channel)");
136
+ const tzinfo = await api(ctx, "GET", "/timezone");
137
+ for (const c of mine) {
138
+ const flags = (c.enabled ? "" : " (paused)") + (c.check ? " +guard" : "");
139
+ const tz = c.tz || tzinfo.timezone || tzinfo.system;
140
+ console.log(`[${String(c.id).slice(0, 8)}] ${label(c.schedule, tz)}${flags}\n ${String(c.prompt).slice(0, 100)}`);
141
+ }
142
+ }
143
+
144
+ async function cmdTz(ctx, zone) {
145
+ const r = zone
146
+ ? await api(ctx, "POST", "/timezone", { timezone: zone === "-" ? "" : zone })
147
+ : await api(ctx, "GET", "/timezone");
148
+ const cur = r.timezone;
149
+ console.log(
150
+ `daily schedules run in: ${cur || r.system}` + (cur ? "" : " (machine default — set one to pin it)"),
151
+ );
152
+ }
153
+
154
+ async function cmdEnv(ctx) {
155
+ // A guard runs server-side, not in this agent's process: it gets the secrets
156
+ // of the CHANNEL's profile, never whatever happens to sit in this shell.
157
+ // Agents that can't see that list assume the worst and hardcode the value
158
+ // into the check script — printing it is the fix.
159
+ const channels = await api(ctx, "GET", "/channels");
160
+ const ch = channels.find((c) => c.sessionId === ctx.sid);
161
+ if (!ch) {
162
+ console.log("this channel is not registered server-side: a guard would run from the");
163
+ console.log("server's own directory, with no profile secrets at all.");
164
+ return;
165
+ }
166
+ console.log(`guard cwd: ${ch.cwd || "(server default)"}`);
167
+ const pname = ch.profile;
168
+ if (!pname) {
169
+ console.log("guard secrets: NONE — this channel has no profile.");
170
+ console.log(" Secrets reach a guard only through the channel's profile; attach one");
171
+ console.log(" (web UI, the agent's profile picker) that lists the names you need.");
172
+ return;
173
+ }
174
+ const profiles = await api(ctx, "GET", "/profiles");
175
+ const prof = profiles.find((p) => p.name === pname);
176
+ if (!prof) {
177
+ // Same end result as "no secrets", but a very different cause: say which
178
+ // one, or the report reads as a working setup.
179
+ console.log(`guard secrets: NONE — the channel points at profile '${pname}', which no longer exists.`);
180
+ return;
181
+ }
182
+ const wanted = prof.secrets ?? [];
183
+ if (!wanted.length) return console.log(`guard secrets: NONE — profile '${pname}' lists no secret.`);
184
+ const vault = new Set((await api(ctx, "GET", "/secrets")).names ?? []);
185
+ const present = wanted.filter((n) => vault.has(n));
186
+ const missing = wanted.filter((n) => !vault.has(n));
187
+ console.log(`guard secrets (profile '${pname}'): ${present.length ? present.join(", ") : "NONE"}`);
188
+ // `secretsFor` skips an unknown name without a word, so a typo here looks
189
+ // exactly like a working guard until the day it runs.
190
+ if (missing.length) console.log(` referenced but NOT in the vault, so absent at run time: ${missing.join(", ")}`);
191
+ }
192
+
193
+ async function cmdDel(ctx, id) {
194
+ // `list` only prints 8 characters of an id: the server accepts that prefix.
195
+ // Print WHAT IT deleted — an older version announced "deleted" without ever
196
+ // deleting anything.
197
+ const r = await api(ctx, "DELETE", `/crons?id=${encodeURIComponent(id)}`);
198
+ console.log(`deleted ${String(r.id ?? id).slice(0, 8)}`);
199
+ }
200
+
201
+ export async function main(argv) {
202
+ let parsed;
203
+ try {
204
+ parsed = parseArgs(argv);
205
+ } catch (e) {
206
+ if (!(e instanceof UsageError)) throw e;
207
+ process.stderr.write(e.message + "\n");
208
+ process.exit(2);
209
+ }
210
+ const ctx = env();
211
+ try {
212
+ if (parsed.cmd === "add") await cmdAdd(ctx, parsed.opts);
213
+ else if (parsed.cmd === "list") await cmdList(ctx);
214
+ else if (parsed.cmd === "tz") await cmdTz(ctx, parsed.pos[0]);
215
+ else if (parsed.cmd === "env") await cmdEnv(ctx);
216
+ else if (parsed.cmd === "del") await cmdDel(ctx, parsed.pos[0]);
217
+ } catch (e) {
218
+ // A bad --schedule is a command-line mistake, not a server error.
219
+ if (e instanceof UsageError) die(e.message);
220
+ throw e;
221
+ }
222
+ }
223
+
224
+ // Importable by the tests, executable by the agents: only run when invoked
225
+ // directly, or importing this file would exit on a missing SHADOK_PORT.
226
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
227
+ await main(process.argv.slice(2));
228
+ }
@@ -0,0 +1,24 @@
1
+ import http from "node:http";
2
+
3
+ // A stand-in for the shadok-ai server, HTTP only: it records every request and
4
+ // answers from a route table. The shadok-ai-agents skill has its own
5
+ // `mock-server.mjs`, but that one is built around the WebSocket protocol and
6
+ // hardcodes pilotctl's two endpoints — these scripts speak plain HTTP to a
7
+ // different set of routes, so they get a helper of their own rather than a
8
+ // shared one bent to cover both.
9
+ export async function fakeCockpit(routes) {
10
+ const seen = [];
11
+ const server = http.createServer((req, res) => {
12
+ let raw = "";
13
+ req.on("data", (c) => (raw += c));
14
+ req.on("end", () => {
15
+ const [path, query = ""] = req.url.split("?");
16
+ seen.push({ method: req.method, path, query, body: raw ? JSON.parse(raw) : null, cookie: req.headers.cookie });
17
+ const route = routes[`${req.method} ${path}`] ?? routes[path];
18
+ res.writeHead(route?.status ?? 200, { "content-type": "application/json" });
19
+ res.end(JSON.stringify(route?.body ?? route ?? {}));
20
+ });
21
+ });
22
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
23
+ return { port: server.address().port, seen, close: () => new Promise((r) => server.close(r)) };
24
+ }
@@ -0,0 +1,152 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import path from "node:path";
4
+ import { execFile } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { fakeCockpit } from "./fake-cockpit.mjs";
7
+ import { parseSchedule, label, parseArgs } from "../scripts/schedule.mjs";
8
+
9
+ const SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "scripts", "schedule.mjs");
10
+
11
+ /** Runs the script with a controlled env, like an agent would. */
12
+ function run(args, env = {}) {
13
+ return new Promise((resolve) => {
14
+ execFile(
15
+ process.execPath,
16
+ [SCRIPT, ...args],
17
+ { env: { ...process.env, SHADOK_PORT: "", SHADOK_SESSION_ID: "", SHADOK_AUTH: "", ...env } },
18
+ (e, out, err) => resolve({ code: e ? (e.code ?? 1) : 0, out, err }),
19
+ );
20
+ });
21
+ }
22
+
23
+ const AGENT = (port) => ({ SHADOK_PORT: String(port), SHADOK_SESSION_ID: "sess-1", SHADOK_AUTH: "sk_auth=tok" });
24
+
25
+ test("parseSchedule understands minutes, hours and a bare number", () => {
26
+ assert.deepEqual(parseSchedule("every:30m"), { kind: "interval", everyMin: 30 });
27
+ assert.deepEqual(parseSchedule("every:2h"), { kind: "interval", everyMin: 120 });
28
+ assert.deepEqual(parseSchedule("every:45"), { kind: "interval", everyMin: 45 });
29
+ assert.deepEqual(parseSchedule("EVERY:15M"), { kind: "interval", everyMin: 15 });
30
+ });
31
+
32
+ test("parseSchedule reads a daily wall-clock time", () => {
33
+ assert.deepEqual(parseSchedule("daily:09:00"), { kind: "daily", hour: 9, minute: 0 });
34
+ assert.deepEqual(parseSchedule(" daily:18:30 "), { kind: "daily", hour: 18, minute: 30 });
35
+ });
36
+
37
+ test("parseSchedule refuses garbage instead of sending NaN to the server", () => {
38
+ for (const bad of ["every:soon", "daily:nine", "daily:09", "hourly", "", "every:"]) {
39
+ assert.throws(() => parseSchedule(bad), /bad schedule/, `accepted '${bad}'`);
40
+ }
41
+ });
42
+
43
+ test("label always names the timezone of a daily schedule", () => {
44
+ assert.equal(label({ kind: "daily", hour: 9, minute: 0 }, "Europe/Paris"), "daily 09:00 (Europe/Paris)");
45
+ assert.equal(label({ kind: "daily", hour: 9, minute: 5 }), "daily 09:05");
46
+ // An interval is a duration, so it has no timezone to show.
47
+ assert.equal(label({ kind: "interval", everyMin: 30 }, "Europe/Paris"), "every 30m");
48
+ });
49
+
50
+ test("parseArgs keeps the documented flags and their requirements", () => {
51
+ const r = parseArgs(["add", "--schedule", "daily:09:00", "--prompt", "hi", "--check", "sh x.sh", "--tz", "Europe/Paris"]);
52
+ assert.deepEqual(r.opts, { schedule: "daily:09:00", prompt: "hi", check: "sh x.sh", tz: "Europe/Paris" });
53
+ assert.throws(() => parseArgs(["add", "--prompt", "hi"]), /--schedule/);
54
+ assert.throws(() => parseArgs(["add", "--schedule", "every:5m"]), /--prompt/);
55
+ assert.throws(() => parseArgs(["del"]), /required/);
56
+ assert.equal(parseArgs(["del", "abc12345"]).pos[0], "abc12345");
57
+ assert.equal(parseArgs(["tz"]).pos.length, 0);
58
+ assert.equal(parseArgs(["tz", "Europe/Paris"]).pos[0], "Europe/Paris");
59
+ assert.throws(() => parseArgs(["nope"]), /invalid choice/);
60
+ });
61
+
62
+ test("refuses to run outside a shadok-ai channel", async () => {
63
+ const noPort = await run(["list"], { SHADOK_SESSION_ID: "sess-1" });
64
+ assert.notEqual(noPort.code, 0);
65
+ assert.match(noPort.err, /SHADOK_PORT/);
66
+ const noSid = await run(["list"], { SHADOK_PORT: "1" });
67
+ assert.notEqual(noSid.code, 0);
68
+ assert.match(noSid.err, /SHADOK_SESSION_ID/);
69
+ });
70
+
71
+ test("add posts the channel's own sessionId, the guard and the timezone", async () => {
72
+ const srv = await fakeCockpit({
73
+ "POST /crons": { body: { id: "abcdef0123456789", schedule: { kind: "daily", hour: 9, minute: 0 }, timezone: "Europe/Paris" } },
74
+ });
75
+ const res = await run(
76
+ ["add", "--schedule", "daily:09:00", "--prompt", "report", "--check", "sh guard.sh", "--tz", "Europe/Paris"],
77
+ AGENT(srv.port),
78
+ );
79
+ await srv.close();
80
+ assert.equal(res.code, 0, res.err);
81
+ assert.deepEqual(srv.seen[0].body, {
82
+ sessionId: "sess-1",
83
+ prompt: "report",
84
+ schedule: { kind: "daily", hour: 9, minute: 0 },
85
+ enabled: true,
86
+ check: "sh guard.sh",
87
+ tz: "Europe/Paris",
88
+ });
89
+ assert.equal(srv.seen[0].cookie, "sk_auth=tok");
90
+ assert.match(res.out, /scheduled \[abcdef01\] daily 09:00 \(Europe\/Paris\) \+guard/);
91
+ });
92
+
93
+ test("a bad schedule fails before anything is sent", async () => {
94
+ const srv = await fakeCockpit({});
95
+ const res = await run(["add", "--schedule", "every:soon", "--prompt", "x"], AGENT(srv.port));
96
+ await srv.close();
97
+ assert.notEqual(res.code, 0);
98
+ assert.match(res.err, /bad schedule/);
99
+ assert.equal(srv.seen.length, 0);
100
+ });
101
+
102
+ test("list only shows this channel's schedules, with their timezone", async () => {
103
+ const srv = await fakeCockpit({
104
+ "GET /crons": {
105
+ body: [
106
+ { id: "aaaaaaaa1111", sessionId: "sess-1", enabled: true, check: "sh x", schedule: { kind: "daily", hour: 9, minute: 0 }, prompt: "mine" },
107
+ { id: "bbbbbbbb2222", sessionId: "other", enabled: true, schedule: { kind: "interval", everyMin: 5 }, prompt: "someone else's" },
108
+ ],
109
+ },
110
+ "GET /timezone": { body: { timezone: "Europe/Paris", system: "UTC" } },
111
+ });
112
+ const res = await run(["list"], AGENT(srv.port));
113
+ await srv.close();
114
+ assert.match(res.out, /\[aaaaaaaa\] daily 09:00 \(Europe\/Paris\) \+guard/);
115
+ assert.doesNotMatch(res.out, /someone else's/);
116
+ });
117
+
118
+ test("del sends the 8-char prefix the listing prints, url-encoded", async () => {
119
+ const srv = await fakeCockpit({ "DELETE /crons": { body: { id: "abcdef0123456789" } } });
120
+ const res = await run(["del", "abcdef01"], AGENT(srv.port));
121
+ await srv.close();
122
+ assert.equal(srv.seen[0].query, "id=abcdef01");
123
+ assert.match(res.out, /deleted abcdef01/);
124
+ });
125
+
126
+ test("env names the secrets a guard really gets, and the ones it won't", async () => {
127
+ const srv = await fakeCockpit({
128
+ "GET /channels": { body: [{ sessionId: "sess-1", cwd: "/w/t", profile: "P" }] },
129
+ "GET /profiles": { body: [{ name: "P", secrets: ["HAVE", "MISSING"] }] },
130
+ "GET /secrets": { body: { names: ["HAVE"] } },
131
+ });
132
+ const res = await run(["env"], AGENT(srv.port));
133
+ await srv.close();
134
+ assert.match(res.out, /guard cwd: \/w\/t/);
135
+ assert.match(res.out, /guard secrets \(profile 'P'\): HAVE/);
136
+ assert.match(res.out, /NOT in the vault[^\n]*MISSING/);
137
+ });
138
+
139
+ test("env says which cause when a channel has no profile", async () => {
140
+ const srv = await fakeCockpit({ "GET /channels": { body: [{ sessionId: "sess-1", cwd: "/w/t" }] } });
141
+ const res = await run(["env"], AGENT(srv.port));
142
+ await srv.close();
143
+ assert.match(res.out, /guard secrets: NONE — this channel has no profile/);
144
+ });
145
+
146
+ test("an API error is reported, not swallowed", async () => {
147
+ const srv = await fakeCockpit({ "POST /crons": { status: 500, body: { error: "boom" } } });
148
+ const res = await run(["add", "--schedule", "every:5m", "--prompt", "x"], AGENT(srv.port));
149
+ await srv.close();
150
+ assert.notEqual(res.code, 0);
151
+ assert.match(res.err, /API error 500/);
152
+ });
@@ -18,8 +18,8 @@ automatically). If it is missing, say so rather than improvising.
18
18
  ## Store it
19
19
 
20
20
  ```bash
21
- gh auth token | ~/.claude/skills/shadok-secrets/scripts/secret.py set GITHUB_TOKEN --stdin
22
- ~/.claude/skills/shadok-secrets/scripts/secret.py list
21
+ gh auth token | node ~/.claude/skills/shadok-secrets/scripts/secret.mjs set GITHUB_TOKEN --stdin
22
+ node ~/.claude/skills/shadok-secrets/scripts/secret.mjs list
23
23
  ```
24
24
 
25
25
  ## The rules — these are the point
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ // secret — put a credential the agent OBTAINED into shadok-ai's vault, through
3
+ // the local API.
4
+ //
5
+ // The value is read from STDIN, never from argv: `ps` exposes a process's
6
+ // arguments to every user on the machine, so a token passed as a parameter
7
+ // leaks. There is deliberately no way to read a value back out — `list` prints
8
+ // names only, and there is no `get`.
9
+ //
10
+ // Reads SHADOK_PORT / SHADOK_AUTH from the env (injected into every agent).
11
+ //
12
+ // Node, not Python: shadok-ai IS a Node program, so the runtime is guaranteed
13
+ // present wherever a skill runs — the image's python3 exists to build native
14
+ // npm modules, and dropping it would have broken this in silence.
15
+
16
+ import { pathToFileURL } from "node:url";
17
+
18
+ const TIMEOUT_MS = 15_000;
19
+
20
+ /** Exit the way the old `sys.exit("…")` did: message on stderr, code 1. */
21
+ function die(msg) {
22
+ process.stderr.write(msg + "\n");
23
+ process.exit(1);
24
+ }
25
+
26
+ /** A misuse of the command line — argparse's own exit code, kept on purpose. */
27
+ class UsageError extends Error {}
28
+
29
+ function env() {
30
+ const port = process.env.SHADOK_PORT;
31
+ if (!port) die("Not inside a shadok-ai agent (SHADOK_PORT unset).");
32
+ return { base: `http://127.0.0.1:${port}`, auth: process.env.SHADOK_AUTH ?? "" };
33
+ }
34
+
35
+ async function api(ctx, method, path, body) {
36
+ let res;
37
+ try {
38
+ res = await fetch(ctx.base + path, {
39
+ method,
40
+ headers: {
41
+ "content-type": "application/json",
42
+ ...(ctx.auth ? { Cookie: ctx.auth } : {}),
43
+ },
44
+ body: body === undefined ? undefined : JSON.stringify(body),
45
+ signal: AbortSignal.timeout(TIMEOUT_MS),
46
+ });
47
+ } catch (e) {
48
+ die(`cannot reach the cockpit on ${ctx.base}: ${e.message}`);
49
+ }
50
+ const raw = await res.text();
51
+ if (res.status === 409) {
52
+ die(
53
+ "refused: a secret by that name already exists. Do NOT overwrite it " +
54
+ "yourself — tell the user and let them decide.",
55
+ );
56
+ }
57
+ if (!res.ok) die(`API error ${res.status}: ${raw.slice(0, 200)}`);
58
+ return raw ? JSON.parse(raw) : {};
59
+ }
60
+
61
+ /**
62
+ * The command line, kept identical to the argparse version. `--stdin` is
63
+ * REQUIRED rather than optional: that is what removes any way of passing a
64
+ * value in argv, so it is a security property, not ergonomics.
65
+ */
66
+ export function parseArgs(argv) {
67
+ const [cmd, ...rest] = argv;
68
+ if (cmd === "list") {
69
+ if (rest.length) throw new UsageError(`secret list: unrecognized arguments: ${rest.join(" ")}`);
70
+ return { cmd };
71
+ }
72
+ if (cmd !== "set") {
73
+ throw new UsageError(`usage: secret {list,set} …\ninvalid choice: '${cmd ?? ""}'`);
74
+ }
75
+ let name;
76
+ let stdin = false;
77
+ for (const a of rest) {
78
+ if (a === "--stdin") stdin = true;
79
+ else if (a.startsWith("-")) throw new UsageError(`secret set: unrecognized argument ${a}`);
80
+ else if (name === undefined) name = a;
81
+ else throw new UsageError(`secret set: unrecognized arguments: ${a}`);
82
+ }
83
+ if (name === undefined) throw new UsageError("secret set: the following arguments are required: name");
84
+ if (!stdin) {
85
+ throw new UsageError(
86
+ "secret set: the following arguments are required: --stdin (the value is read from stdin, never from argv)",
87
+ );
88
+ }
89
+ return { cmd, name };
90
+ }
91
+
92
+ function readStdin() {
93
+ return new Promise((resolve) => {
94
+ let buf = "";
95
+ process.stdin.setEncoding("utf8");
96
+ process.stdin.on("data", (c) => (buf += c));
97
+ process.stdin.on("end", () => resolve(buf));
98
+ process.stdin.on("error", () => resolve(buf));
99
+ });
100
+ }
101
+
102
+ export async function main(argv) {
103
+ let parsed;
104
+ try {
105
+ parsed = parseArgs(argv);
106
+ } catch (e) {
107
+ if (!(e instanceof UsageError)) throw e;
108
+ process.stderr.write(e.message + "\n");
109
+ process.exit(2);
110
+ }
111
+ const ctx = env();
112
+
113
+ if (parsed.cmd === "list") {
114
+ const names = (await api(ctx, "GET", "/secrets")).names ?? [];
115
+ console.log(names.length ? names.join("\n") : "(vault empty)");
116
+ return;
117
+ }
118
+
119
+ const value = (await readStdin()).trim();
120
+ if (!value) die("nothing on stdin — pipe the value in, e.g. `gh auth token | ... --stdin`");
121
+ await api(ctx, "PUT", "/secrets", { name: parsed.name, value });
122
+ // Never echo the value back: this output lands in the transcript, and may be
123
+ // mirrored to Telegram.
124
+ console.log(`stored ${parsed.name} in the vault`);
125
+ console.log("It reaches an agent only once attached to a profile (web Profiles panel).");
126
+ }
127
+
128
+ // Importable by the tests, executable by the agents: only run when invoked
129
+ // directly, or importing this file would exit on a missing SHADOK_PORT.
130
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
131
+ await main(process.argv.slice(2));
132
+ }
@@ -0,0 +1,21 @@
1
+ import http from "node:http";
2
+
3
+ // A stand-in for the cockpit: records what the script sent, answers `status`.
4
+ // Deliberately not shared with the shadok-ai-agents skill's `mock-server.mjs`,
5
+ // which is built around the WebSocket protocol and pilotctl's own routes.
6
+ export async function fakeCockpit(status, body) {
7
+ const seen = { body: undefined, cookie: undefined, method: undefined };
8
+ const server = http.createServer((req, res) => {
9
+ let raw = "";
10
+ req.on("data", (c) => (raw += c));
11
+ req.on("end", () => {
12
+ seen.method = req.method;
13
+ seen.body = raw ? JSON.parse(raw) : null;
14
+ seen.cookie = req.headers.cookie;
15
+ res.writeHead(status, { "content-type": "application/json" });
16
+ res.end(JSON.stringify(body));
17
+ });
18
+ });
19
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
20
+ return { port: server.address().port, seen, close: () => new Promise((r) => server.close(r)) };
21
+ }