agenticloops 0.1.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/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # agenticloops
2
+
3
+ Install and run **agentic loops** — recurring AI agents defined in a single
4
+ [`LOOP.md`](https://github.com/5dive-ai/loops/blob/main/SPEC.md) file: a trigger,
5
+ a set of skills, and a prompt. One file defines it; any harness can run it.
6
+
7
+ The directory lives at **[agenticloops.dev](https://agenticloops.dev)**. This is
8
+ the install CLI — the loop-level analogue of `npx skills add`.
9
+
10
+ ```bash
11
+ # install a loop onto the harness in the current project (auto-detected)
12
+ npx agenticloops install <owner/repo>
13
+
14
+ # target a specific harness
15
+ npx agenticloops install <owner/repo> --harness=5dive
16
+
17
+ # search the directory
18
+ npx agenticloops find research
19
+
20
+ # what's installed here + global install counts
21
+ npx agenticloops list
22
+ ```
23
+
24
+ ## Commands
25
+
26
+ | Command | What it does |
27
+ |---|---|
28
+ | `install <owner/loop>` | Fetch + validate the `LOOP.md`, install its skills, pre-flight its `requires`, and register the recurring job on your harness. |
29
+ | `find <query>` | Search the public directory. |
30
+ | `list` | Loops installed on this machine + their global install counts. |
31
+ | `update [<slug>]` | Re-fetch + re-install (all, or one). |
32
+
33
+ You can also point `install` / `validate` at a **local path** (`./my-loop` or a
34
+ `LOOP.md`) while you're authoring.
35
+
36
+ ## How install works
37
+
38
+ Per the [spec](https://github.com/5dive-ai/loops/blob/main/SPEC.md):
39
+
40
+ 1. **Fetch + validate** the `LOOP.md` against spec v0.1 (required: `name`,
41
+ `description`, a `schedule`/`event` trigger).
42
+ 2. **Install skills** (`skills:`). `owner/repo/skill` paths are fetched directly;
43
+ bare names resolve against the skills registry; anything the harness already
44
+ provides is used as-is. An unresolvable skill is skipped with a warning — a
45
+ loop degrades rather than refusing to install.
46
+ 3. **Pre-flight `requires`** (`cli` / `secrets` / `mcp` / `network`). This is
47
+ **declare-and-check**: missing binaries, secrets, and servers are reported and
48
+ you're prompted or the install refuses. Nothing is **ever auto-installed** —
49
+ binaries, secrets, and MCP servers run code or carry trust, so that consent is
50
+ yours.
51
+ 4. **Register the trigger** on the harness (a recurring 5dive task, a GitHub
52
+ Actions workflow, a cron scaffold, …).
53
+
54
+ ### Harnesses
55
+
56
+ `5dive` · `github-actions` · `claude-code` · `cursor` · `cron`
57
+
58
+ Auto-detected from the current directory/env. A loop needs **scheduling**, so a
59
+ run-only harness (an IDE) can run the agent but can't honor a recurring trigger —
60
+ the installer warns you and suggests a scheduling harness.
61
+
62
+ ## Telemetry — anonymous, opt-out, and exactly this
63
+
64
+ On a **successful install**, the CLI sends one fire-and-forget ping to
65
+ `agenticloops.dev/api/install` so the directory can show real install counts
66
+ (like npm download counts):
67
+
68
+ ```json
69
+ { "slug": "ci-analyst", "ts": "2026-06-30T12:00:00.000Z" }
70
+ ```
71
+
72
+ That is the **entire** payload. The loop slug is a public id. We do **not** send
73
+ or store your IP, username, machine id, repo, prompt, or any other field. Updates
74
+ don't re-count. Failure is silent and never blocks an install.
75
+
76
+ **Opt out** any of these ways:
77
+
78
+ ```bash
79
+ npx agenticloops install <owner/repo> --no-telemetry
80
+ AGENTICLOOPS_NO_TELEMETRY=1 npx agenticloops install <owner/repo>
81
+ DO_NOT_TRACK=1 npx agenticloops install <owner/repo> # cross-tool standard
82
+ ```
83
+
84
+ ## License
85
+
86
+ MIT. Spec + directory: [github.com/5dive-ai/loops](https://github.com/5dive-ai/loops).
package/bin/cli.mjs ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ // agenticloops — install and run agentic loops (recurring AI agents in one
3
+ // LOOP.md). One file defines it; any harness can run it. https://agenticloops.dev
4
+ import { install } from "../src/install.mjs";
5
+ import { loadRegistry, search } from "../src/registry.mjs";
6
+ import { listRecords, parseSchedule } from "../src/schedule.mjs";
7
+ import { fetchInstalls } from "../src/telemetry.mjs";
8
+ import { c, sym, fail, info, CliError } from "../src/util.mjs";
9
+
10
+ const VERSION = "0.1.0";
11
+
12
+ function parseArgs(argv) {
13
+ const flags = {};
14
+ const positional = [];
15
+ for (const a of argv) {
16
+ if (a.startsWith("--")) {
17
+ const [k, v] = a.slice(2).split("=");
18
+ flags[k] = v === undefined ? true : v;
19
+ } else positional.push(a);
20
+ }
21
+ return { positional, flags };
22
+ }
23
+
24
+ const HELP = `${c.bold("agenticloops")} — install and run agentic loops ${c.dim("v" + VERSION)}
25
+
26
+ ${c.bold("Usage")}
27
+ npx agenticloops install <owner/loop> [--harness=<id>] [--no-telemetry] [--yes] [--dry-run]
28
+ npx agenticloops find <query>
29
+ npx agenticloops list
30
+ npx agenticloops update [<slug>]
31
+
32
+ ${c.bold("Commands")}
33
+ install Fetch + validate a LOOP.md, install its skills, pre-flight its
34
+ requirements, and register the recurring job on your harness.
35
+ find Search the public directory (agenticloops.dev) for loops.
36
+ list Show loops installed on this machine + their install counts.
37
+ update Re-fetch + re-install a loop (all, or one slug).
38
+
39
+ ${c.bold("Harnesses")} 5dive · github-actions · claude-code · cursor · cron
40
+ Auto-detected from the current directory/env. A loop needs SCHEDULING, so a
41
+ run-only harness (an IDE) is warned about; target a scheduler to run on time.
42
+
43
+ ${c.bold("Flags")}
44
+ --harness=<id> Target a specific harness instead of auto-detecting.
45
+ --no-telemetry Don't send the anonymous install ping (slug + ts only).
46
+ Also honored: AGENTICLOOPS_NO_TELEMETRY=1, DO_NOT_TRACK=1.
47
+ --yes Assume yes to prompts (non-interactive installs).
48
+ --dry-run Show what would happen; change nothing, send nothing.
49
+
50
+ Spec: https://github.com/5dive-ai/loops/blob/main/SPEC.md · MIT`;
51
+
52
+ async function cmdInstall(positional, flags) {
53
+ const ref = positional[0];
54
+ if (!ref) throw new CliError("usage: agenticloops install <owner/loop>", 2);
55
+ await install(ref, {
56
+ harness: typeof flags.harness === "string" ? flags.harness : undefined,
57
+ noTelemetry: !!flags["no-telemetry"],
58
+ yes: !!flags.yes,
59
+ dryRun: !!flags["dry-run"],
60
+ });
61
+ }
62
+
63
+ async function cmdFind(positional) {
64
+ const query = positional.join(" ");
65
+ const { loops, url } = await loadRegistry();
66
+ if (!loops.length) throw new CliError("could not load the loop directory", 1);
67
+ const hits = search(loops, query);
68
+ if (!hits.length) {
69
+ info(`no loops match "${query}" ${c.dim("(" + loops.length + " in the directory)")}`);
70
+ return;
71
+ }
72
+ const installs = await fetchInstalls().catch(() => ({}));
73
+ process.stderr.write(`\n${c.dim(`${hits.length} of ${loops.length} loops · ${url}`)}\n\n`);
74
+ for (const l of hits.slice(0, 25)) {
75
+ const n = installs[l.slug];
76
+ const badge = l.imported ? c.dim(" [community]") : "";
77
+ const count = typeof n === "number" ? c.dim(` ${n}↓`) : "";
78
+ process.stdout.write(
79
+ `${c.bold(l.slug)}${badge}${count}\n ${c.cyan(l.jobTitle || "")} — ${l.tagline || ""}\n` +
80
+ (l.source?.repo ? ` ${c.dim("install: agenticloops install " + l.source.repo)}\n` : "") +
81
+ "\n",
82
+ );
83
+ }
84
+ }
85
+
86
+ async function cmdList() {
87
+ const recs = listRecords();
88
+ if (!recs.length) {
89
+ info("no loops installed on this machine yet. Try: agenticloops find <query>");
90
+ return;
91
+ }
92
+ const installs = await fetchInstalls().catch(() => ({}));
93
+ process.stderr.write(`\n${c.dim(recs.length + " installed")}\n\n`);
94
+ for (const r of recs) {
95
+ const n = installs[r.slug];
96
+ const sched = r.cron ? r.cron : r.trigger?.value || "?";
97
+ process.stdout.write(
98
+ `${c.bold(r.slug)} ${c.dim("on " + r.harness)}${typeof n === "number" ? c.dim(" " + n + "↓ globally") : ""}\n` +
99
+ ` ${c.cyan(sched)} — ${(r.description || "").split("\n")[0]}\n` +
100
+ ` ${c.dim(r.source || r.ref)}\n\n`,
101
+ );
102
+ }
103
+ }
104
+
105
+ async function cmdUpdate(positional, flags) {
106
+ const recs = listRecords();
107
+ const targets = positional[0] ? recs.filter((r) => r.slug === positional[0]) : recs;
108
+ if (!targets.length) {
109
+ info(positional[0] ? `"${positional[0]}" is not installed` : "nothing installed to update");
110
+ return;
111
+ }
112
+ for (const r of targets) {
113
+ info(`updating ${c.bold(r.slug)} ${c.dim("(" + r.ref + ")")}`);
114
+ await install(r.ref, {
115
+ harness: r.harness,
116
+ yes: true,
117
+ noTelemetry: true, // an update isn't a new install — don't double-count
118
+ dryRun: !!flags["dry-run"],
119
+ });
120
+ }
121
+ }
122
+
123
+ async function main() {
124
+ const argv = process.argv.slice(2);
125
+ const { positional, flags } = parseArgs(argv);
126
+ const cmd = positional.shift(); // first non-flag token is the command
127
+
128
+ if (flags.version || flags.v || cmd === "version") {
129
+ process.stdout.write(VERSION + "\n");
130
+ return;
131
+ }
132
+ if (!cmd || flags.help || flags.h || cmd === "help") {
133
+ process.stdout.write(HELP + "\n");
134
+ return;
135
+ }
136
+
137
+ switch (cmd) {
138
+ case "install":
139
+ case "add":
140
+ return cmdInstall(positional, flags);
141
+ case "find":
142
+ case "search":
143
+ return cmdFind(positional, flags);
144
+ case "list":
145
+ case "ls":
146
+ return cmdList(positional, flags);
147
+ case "update":
148
+ case "upgrade":
149
+ return cmdUpdate(positional, flags);
150
+ default:
151
+ throw new CliError(`unknown command "${cmd}". Run: agenticloops --help`, 2);
152
+ }
153
+ }
154
+
155
+ main().catch((e) => {
156
+ if (e instanceof CliError) {
157
+ fail(e.message);
158
+ process.exit(e.code || 1);
159
+ }
160
+ fail(e?.stack || String(e));
161
+ process.exit(1);
162
+ });
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "agenticloops",
3
+ "version": "0.1.0",
4
+ "description": "Install and run agentic loops — recurring AI agents defined in a single LOOP.md file. One file defines it; any harness can run it.",
5
+ "type": "module",
6
+ "bin": {
7
+ "agenticloops": "bin/cli.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test"
19
+ },
20
+ "keywords": [
21
+ "agentic-loop",
22
+ "agentic-loops",
23
+ "ai-agent",
24
+ "scheduler",
25
+ "loop",
26
+ "skills",
27
+ "claude",
28
+ "cron",
29
+ "automation"
30
+ ],
31
+ "homepage": "https://agenticloops.dev",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/5dive-ai/agenticloops.git"
35
+ },
36
+ "license": "MIT",
37
+ "dependencies": {
38
+ "yaml": "^2.4.5"
39
+ }
40
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,18 @@
1
+ // Central constants. The telemetry + registry endpoints live on agenticloops.dev.
2
+ export const SITE = "https://agenticloops.dev";
3
+
4
+ // Anonymous install counter (POST {slug, ts}) + tallies (GET). See src/telemetry.mjs.
5
+ export const TELEMETRY_URL = process.env.AGENTICLOOPS_TELEMETRY_URL || `${SITE}/api/install`;
6
+
7
+ // The bundled directory the site builds from + the canonical spec registry.
8
+ export const SITE_REGISTRY_URL = `${SITE}/loops.json`;
9
+ export const SPEC_REGISTRY_URL =
10
+ "https://raw.githubusercontent.com/5dive-ai/loops/main/index.json";
11
+
12
+ // skills.sh-style index used to resolve BARE skill names (§3.1 tier 3).
13
+ export const SKILLS_REGISTRY_URL = "https://skills.sh/index.json";
14
+
15
+ export const SPEC_VERSION = "0.1";
16
+
17
+ // User-Agent so server logs can attribute pings to the CLI (still anonymous).
18
+ export const UA = `agenticloops-cli/0.1.0 (+${SITE})`;
@@ -0,0 +1,68 @@
1
+ // Harness detection + capabilities. A harness is anything that can resolve the
2
+ // loop's skills, honor its trigger, and run the prompt. Crucially, a loop needs
3
+ // SCHEDULING — a harness that can only run interactively (an IDE) can run the
4
+ // agent but cannot honor a recurring trigger; we warn when targeting one.
5
+ import { existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { execSync } from "node:child_process";
8
+
9
+ function has(bin) {
10
+ try {
11
+ execSync(`command -v ${bin}`, { stdio: "ignore" });
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ // Each harness: id, label, canSchedule, and a detect() that inspects cwd/env.
19
+ export const HARNESSES = [
20
+ {
21
+ id: "5dive",
22
+ label: "5dive runtime",
23
+ canSchedule: true,
24
+ detect: () => has("5dive") || existsSync("/var/lib/5dive"),
25
+ },
26
+ {
27
+ id: "github-actions",
28
+ label: "GitHub Actions",
29
+ canSchedule: true,
30
+ detect: (cwd) => existsSync(join(cwd, ".github", "workflows")) || !!process.env.GITHUB_ACTIONS,
31
+ },
32
+ {
33
+ id: "claude-code",
34
+ label: "Claude Code",
35
+ canSchedule: false,
36
+ detect: (cwd) => existsSync(join(cwd, ".claude")) || has("claude"),
37
+ },
38
+ {
39
+ id: "cursor",
40
+ label: "Cursor",
41
+ canSchedule: false,
42
+ detect: (cwd) => existsSync(join(cwd, ".cursor")),
43
+ },
44
+ {
45
+ id: "cron",
46
+ label: "system cron",
47
+ canSchedule: true,
48
+ detect: () => has("crontab"),
49
+ },
50
+ ];
51
+
52
+ export function getHarness(id) {
53
+ return HARNESSES.find((h) => h.id === id);
54
+ }
55
+
56
+ // Auto-detect: prefer a schedulable harness when several are present, since a
57
+ // loop's whole point is the recurring trigger. Returns { harness, all }.
58
+ export function detectHarness(cwd = process.cwd()) {
59
+ const all = HARNESSES.filter((h) => {
60
+ try {
61
+ return h.detect(cwd);
62
+ } catch {
63
+ return false;
64
+ }
65
+ });
66
+ const scheduler = all.find((h) => h.canSchedule);
67
+ return { harness: scheduler || all[0] || null, all };
68
+ }
@@ -0,0 +1,165 @@
1
+ // The install flow (SPEC §3): fetch+validate LOOP.md -> install skills ->
2
+ // pre-flight `requires` (prompt-or-refuse, never auto-install) -> register the
3
+ // scheduled job -> (on success) anonymous telemetry ping.
4
+ import { fetchLoopMd, parseLoopMd, validateManifest, triggerOf } from "./loop.mjs";
5
+ import { detectHarness, getHarness } from "./harness.mjs";
6
+ import { planSkills, installSkill } from "./skills.mjs";
7
+ import { preflight } from "./preflight.mjs";
8
+ import { parseSchedule, registerTrigger, saveRecord } from "./schedule.mjs";
9
+ import { pingInstall, telemetryDisabled } from "./telemetry.mjs";
10
+ import { loadRegistry, resolveSlug } from "./registry.mjs";
11
+ import { c, sym, info, ok, warn, fail, step, confirm, ask, CliError } from "./util.mjs";
12
+
13
+ export async function install(ref, opts = {}) {
14
+ const assumeYes = !!opts.yes;
15
+ const dryRun = !!opts.dryRun;
16
+
17
+ // 0. Resolve a bare slug to owner/repo via the registry, if needed.
18
+ let loopRef = ref;
19
+ if (!ref.includes("/")) {
20
+ const { loops } = await loadRegistry();
21
+ const resolved = resolveSlug(loops, ref);
22
+ if (!resolved)
23
+ throw new CliError(
24
+ `"${ref}" is a bare slug I can't resolve to a repo. Use owner/repo, or find it: agenticloops find ${ref}`,
25
+ 4,
26
+ );
27
+ info(`resolved ${c.bold(ref)} -> ${c.bold(resolved)}`);
28
+ loopRef = resolved;
29
+ }
30
+
31
+ // 1. Fetch + validate the LOOP.md.
32
+ step("Fetching loop");
33
+ const fetched = await fetchLoopMd(loopRef);
34
+ const { manifest, prompt } = parseLoopMd(fetched.raw);
35
+ const errs = validateManifest(manifest);
36
+ if (errs.length) {
37
+ errs.forEach((e) => fail(e));
38
+ throw new CliError("LOOP.md failed validation against spec v0.1", 3);
39
+ }
40
+ const slug = manifest.name;
41
+ const trig = triggerOf(manifest);
42
+ ok(`${c.bold(manifest.name)} — ${manifest.description?.split("\n")[0] ?? ""}`);
43
+ info(`trigger: ${trig.kind === "event" ? `on ${trig.value}` : trig.value}`);
44
+
45
+ // 2. Pick the harness (auto-detect unless --harness given) + warn if it can't schedule.
46
+ step("Target harness");
47
+ let harness;
48
+ if (opts.harness) {
49
+ harness = getHarness(opts.harness);
50
+ if (!harness) throw new CliError(`unknown harness "${opts.harness}"`, 2);
51
+ info(`harness: ${c.bold(harness.label)}`);
52
+ } else {
53
+ const det = detectHarness();
54
+ harness = det.harness;
55
+ if (!harness)
56
+ throw new CliError(
57
+ "no harness detected here. Pass --harness=<5dive|github-actions|claude-code|cron|cursor>",
58
+ 4,
59
+ );
60
+ info(`auto-detected: ${c.bold(harness.label)}${det.all.length > 1 ? c.dim(` (of ${det.all.map((h) => h.id).join(", ")})`) : ""}`);
61
+ }
62
+ if (trig.needsScheduler && !harness.canSchedule) {
63
+ warn(
64
+ `${harness.label} can run the agent but cannot honor a recurring schedule. ` +
65
+ `The loop will be installed but won't fire on its own — target a scheduling harness ` +
66
+ `(--harness=5dive | github-actions | cron) to schedule it.`,
67
+ );
68
+ if (!(await confirm("Install anyway (unscheduled)?", { fallback: false, assumeYes }))) {
69
+ throw new CliError("aborted — no scheduling harness", 1);
70
+ }
71
+ }
72
+
73
+ // 3. Install skills (§3.1) — host-satisfied skipped, paths fetched, bare resolved.
74
+ const skillPlan = await planSkills(manifest.skills || [], opts.hostSkills || []);
75
+ if (skillPlan.length) {
76
+ step("Skills");
77
+ for (const s of skillPlan) {
78
+ if (s.action === "host") info(`${s.id} ${c.dim("(provided by harness — skipped)")}`);
79
+ else if (s.action === "unresolved") warn(`${s.id} — unresolvable, skipped (loop will degrade)`);
80
+ else {
81
+ const r = installSkill(s, { dryRun });
82
+ if (r.ok) ok(`${s.id} ${c.dim("<- " + s.owner + "/" + s.repo)}`);
83
+ else warn(`${s.id} — install failed (exit ${r.status}); skipped`);
84
+ }
85
+ }
86
+ }
87
+
88
+ // 4. Pre-flight `requires` — declare-and-check, prompt-or-refuse, never auto-install.
89
+ const pf = preflight(manifest.requires, { harness });
90
+ const needsCheck =
91
+ (manifest.requires &&
92
+ (manifest.requires.cli || manifest.requires.secrets || manifest.requires.mcp || manifest.requires.network)) ||
93
+ false;
94
+ if (needsCheck) {
95
+ step("Pre-flight (requires)");
96
+ pf.cli.forEach((x) => (x.present ? ok(`cli ${x.bin}`) : fail(`cli ${x.bin} — not on PATH`)));
97
+ pf.secrets.forEach((x) =>
98
+ x.present ? ok(`secret ${x.name}`) : fail(`secret ${x.name} — not set`),
99
+ );
100
+ pf.mcp.forEach((x) => info(`mcp ${x.name} ${c.dim("(must be configured + consented — not auto-launched)")}`));
101
+ pf.network.forEach((x) => info(`network ${x.host} ${c.dim("(egress the loop will use)")}`));
102
+
103
+ if (pf.hasBlockers) {
104
+ if (pf.missing.cli.length)
105
+ warn(`missing binaries: ${pf.missing.cli.join(", ")} — install them yourself (never auto-installed)`);
106
+ if (pf.missing.secrets.length) {
107
+ warn(`missing secrets: ${pf.missing.secrets.join(", ")}`);
108
+ // Offer to capture values interactively; they are stored host-side only,
109
+ // never written into LOOP.md or sent anywhere.
110
+ for (const name of pf.missing.secrets) {
111
+ if (process.stdin.isTTY && !assumeYes) {
112
+ const v = await ask(` set ${name} now? (leave blank to skip)`);
113
+ if (v) process.env[name] = v;
114
+ }
115
+ }
116
+ }
117
+ const stillMissing =
118
+ pf.missing.cli.length > 0 ||
119
+ pf.missing.secrets.some((n) => !process.env[n]);
120
+ if (stillMissing && !(await confirm("Some requirements are unmet. Install anyway?", { fallback: false, assumeYes }))) {
121
+ throw new CliError("aborted — unmet requirements (nothing was auto-installed)", 1);
122
+ }
123
+ }
124
+ }
125
+
126
+ // 5. Register the trigger on the harness + persist the local record.
127
+ step("Register");
128
+ const sched = trig.needsScheduler ? parseSchedule(manifest.schedule) : { cron: null };
129
+ if (trig.needsScheduler && !sched.cron) {
130
+ warn(`could not parse schedule "${manifest.schedule}" to cron — registering as-is`);
131
+ }
132
+ const record = {
133
+ slug,
134
+ ref: loopRef,
135
+ source: fetched.url,
136
+ harness: harness.id,
137
+ trigger: trig,
138
+ cron: sched.cron || null,
139
+ description: manifest.description,
140
+ installedAt: new Date().toISOString(),
141
+ };
142
+ if (!dryRun) saveRecord(slug, record, fetched.raw);
143
+ const reg = registerTrigger(harness.id, {
144
+ slug,
145
+ name: manifest.name,
146
+ prompt,
147
+ cron: sched.cron || manifest.schedule || "",
148
+ dryRun,
149
+ });
150
+ if (reg.ok) ok(reg.detail || `registered on ${harness.label}`);
151
+ else warn(`trigger registration reported a problem: ${reg.detail || reg.status}`);
152
+
153
+ // 6. Anonymous telemetry (opt-out) — only on a successful install.
154
+ const optedOut = telemetryDisabled({ flag: opts.noTelemetry });
155
+ if (!dryRun) {
156
+ const t = await pingInstall(slug, { disabled: optedOut });
157
+ if (optedOut) info(c.dim("telemetry: opted out"));
158
+ else if (t.sent) info(c.dim("telemetry: counted (anonymous: slug + ts only)"));
159
+ }
160
+
161
+ step(`${sym.ok} Installed ${c.bold(slug)} on ${harness.label}${dryRun ? c.dim(" (dry-run)") : ""}`);
162
+ if (reg.scheduled === false && trig.needsScheduler)
163
+ info(`it won't fire until you wire a scheduler — see ${c.cyan("agenticloops list")}`);
164
+ return record;
165
+ }
package/src/loop.mjs ADDED
@@ -0,0 +1,113 @@
1
+ // Fetch, parse, and validate a LOOP.md against the spec (v0.1).
2
+ import { readFileSync, existsSync, statSync } from "node:fs";
3
+ import { join, isAbsolute } from "node:path";
4
+ import { parse as parseYaml } from "yaml";
5
+ import { fetchText, CliError } from "./util.mjs";
6
+
7
+ const BRANCHES = ["main", "master", "HEAD"];
8
+
9
+ // A ref points at the local filesystem if it's a path to a LOOP.md or a dir
10
+ // containing one. Lets you install/validate a loop you're authoring locally.
11
+ function localLoopPath(ref) {
12
+ if (!(ref.startsWith(".") || isAbsolute(ref) || ref.endsWith("LOOP.md"))) return null;
13
+ const p = isAbsolute(ref) ? ref : join(process.cwd(), ref);
14
+ if (!existsSync(p)) return null;
15
+ const file = statSync(p).isDirectory() ? join(p, "LOOP.md") : p;
16
+ return existsSync(file) ? file : null;
17
+ }
18
+
19
+ // A loop ref is a GitHub shorthand:
20
+ // owner/repo -> LOOP.md at repo root
21
+ // owner/repo/sub/dir -> LOOP.md inside that subdirectory
22
+ // (A bare registry slug is resolved upstream in install.mjs before we get here.)
23
+ export function parseRef(ref) {
24
+ const parts = String(ref).trim().replace(/^\/+|\/+$/g, "").split("/");
25
+ if (parts.length < 2) {
26
+ throw new CliError(
27
+ `"${ref}" is not a valid loop reference — expected owner/repo or owner/repo/path`,
28
+ 2,
29
+ );
30
+ }
31
+ const [owner, repo, ...rest] = parts;
32
+ const subpath = rest.join("/");
33
+ return { owner, repo, subpath, slug: rest.length ? rest[rest.length - 1] : repo };
34
+ }
35
+
36
+ function rawUrl(owner, repo, branch, subpath) {
37
+ const dir = subpath ? `${subpath}/` : "";
38
+ return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${dir}LOOP.md`;
39
+ }
40
+
41
+ // Try each branch in turn; return the first LOOP.md found.
42
+ export async function fetchLoopMd(ref) {
43
+ const local = localLoopPath(ref);
44
+ if (local) {
45
+ const slug = ref.replace(/\/?LOOP\.md$/, "").split("/").filter(Boolean).pop() || "local-loop";
46
+ return { raw: readFileSync(local, "utf8"), url: local, owner: null, repo: null, subpath: null, slug, branch: null, local: true };
47
+ }
48
+ const { owner, repo, subpath, slug } = parseRef(ref);
49
+ for (const branch of BRANCHES) {
50
+ const url = rawUrl(owner, repo, branch, subpath);
51
+ const r = await fetchText(url);
52
+ if (r.ok) return { raw: r.text, url, owner, repo, subpath, slug, branch };
53
+ }
54
+ throw new CliError(
55
+ `No LOOP.md found for ${owner}/${repo}${subpath ? "/" + subpath : ""} ` +
56
+ `(tried ${BRANCHES.join(", ")}). Is the repo public and does it contain a LOOP.md?`,
57
+ 4,
58
+ );
59
+ }
60
+
61
+ const FRONTMATTER_RE = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/;
62
+
63
+ // Split a LOOP.md into { manifest, prompt }. Manifest is YAML frontmatter; the
64
+ // body is the starter prompt (markdown).
65
+ export function parseLoopMd(raw) {
66
+ const m = raw.match(FRONTMATTER_RE);
67
+ if (!m) {
68
+ throw new CliError("LOOP.md has no YAML frontmatter (expected a leading --- block)", 3);
69
+ }
70
+ let manifest;
71
+ try {
72
+ manifest = parseYaml(m[1]) || {};
73
+ } catch (e) {
74
+ throw new CliError(`LOOP.md frontmatter is not valid YAML: ${e.message}`, 3);
75
+ }
76
+ return { manifest, prompt: (m[2] || "").trim() };
77
+ }
78
+
79
+ // Validate the manifest against the required-field rules in §2. Returns a list
80
+ // of human-readable error strings (empty = valid). Unknown fields are ignored
81
+ // per §5 (forward-compatible).
82
+ export function validateManifest(manifest) {
83
+ const errs = [];
84
+ const m = manifest || {};
85
+ if (!m.name || typeof m.name !== "string") errs.push("missing required `name`");
86
+ else if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(m.name))
87
+ errs.push("`name` must be kebab-case, ≤ 64 chars");
88
+ if (!m.description || typeof m.description !== "string")
89
+ errs.push("missing required `description`");
90
+ if (!m.schedule && !m.event)
91
+ errs.push("missing a trigger — set `schedule` (or `event`)");
92
+
93
+ if (m.tier && !["frontier", "standard", "fast"].includes(m.tier))
94
+ errs.push("`tier` must be one of frontier | standard | fast (never a vendor model)");
95
+ if (m.effort && !["low", "medium", "high"].includes(m.effort))
96
+ errs.push("`effort` must be low | medium | high");
97
+ if (m.concurrency && !["skip", "queue", "replace", "allow"].includes(m.concurrency))
98
+ errs.push("`concurrency` must be skip | queue | replace | allow");
99
+ if (m.skills && !Array.isArray(m.skills)) errs.push("`skills` must be a list");
100
+ if (m.requires && typeof m.requires !== "object") errs.push("`requires` must be a mapping");
101
+
102
+ return errs;
103
+ }
104
+
105
+ // A trigger that a harness must be able to honor. `schedule` accepts human
106
+ // grammar or raw cron; we don't expand it here — adapters do — but we surface a
107
+ // label and whether scheduling (vs event) is needed.
108
+ export function triggerOf(manifest) {
109
+ if (manifest.event) {
110
+ return { kind: "event", value: String(manifest.event), needsScheduler: false };
111
+ }
112
+ return { kind: "schedule", value: String(manifest.schedule), needsScheduler: true };
113
+ }
@@ -0,0 +1,35 @@
1
+ // Pre-flight the loop's `requires` (§3.2). This is DECLARE-AND-CHECK: we verify
2
+ // the environment and prompt-or-refuse for what's missing. We NEVER auto-install
3
+ // a binary, mint a secret, or launch an MCP server — that consent is the user's.
4
+ import { execSync } from "node:child_process";
5
+
6
+ function onPath(bin) {
7
+ try {
8
+ execSync(`command -v ${bin}`, { stdio: "ignore" });
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ // Returns a structured report. `missing` lists what blocks a clean install.
16
+ export function preflight(requires = {}, { harness } = {}) {
17
+ const r = requires || {};
18
+ const cli = (r.cli || []).map((bin) => ({ bin, present: onPath(bin) }));
19
+ const secrets = (r.secrets || []).map((name) => ({
20
+ name,
21
+ present: typeof process.env[name] === "string" && process.env[name] !== "",
22
+ }));
23
+ // MCP + network are environment expectations we can't fully verify from here:
24
+ // we surface them for consent rather than asserting pass/fail.
25
+ const mcp = (r.mcp || []).map((name) => ({ name }));
26
+ const network = (r.network || []).map((host) => ({ host }));
27
+
28
+ const missing = {
29
+ cli: cli.filter((x) => !x.present).map((x) => x.bin),
30
+ secrets: secrets.filter((x) => !x.present).map((x) => x.name),
31
+ };
32
+ const hasBlockers = missing.cli.length > 0 || missing.secrets.length > 0;
33
+
34
+ return { cli, secrets, mcp, network, missing, hasBlockers, harness };
35
+ }
@@ -0,0 +1,47 @@
1
+ // The public directory — used by `find` and to resolve a bare slug to an
2
+ // installable owner/repo. We read the site's bundled loops.json (richest: core
3
+ // + imported with source attribution) and fall back to the spec index.json.
4
+ import { fetchJson } from "./util.mjs";
5
+ import { SITE_REGISTRY_URL, SPEC_REGISTRY_URL } from "./config.mjs";
6
+
7
+ export async function loadRegistry() {
8
+ for (const url of [SITE_REGISTRY_URL, SPEC_REGISTRY_URL]) {
9
+ const r = await fetchJson(url);
10
+ if (r.ok && r.json) {
11
+ const loops = Array.isArray(r.json) ? r.json : r.json.loops || [];
12
+ if (loops.length) return { loops, url };
13
+ }
14
+ }
15
+ return { loops: [], url: null };
16
+ }
17
+
18
+ const hay = (l) =>
19
+ [l.slug, l.jobTitle, l.tagline, l.description, ...(l.tags || [])]
20
+ .filter(Boolean)
21
+ .join(" ")
22
+ .toLowerCase();
23
+
24
+ export function search(loops, query) {
25
+ if (!query) return loops;
26
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
27
+ return loops
28
+ .map((l) => {
29
+ const h = hay(l);
30
+ const score = terms.reduce((s, t) => s + (h.includes(t) ? 1 : 0), 0);
31
+ return { l, score };
32
+ })
33
+ .filter((x) => x.score > 0)
34
+ .sort((a, b) => b.score - a.score)
35
+ .map((x) => x.l);
36
+ }
37
+
38
+ // Resolve a registry slug to an installable owner/repo[/path], when the entry
39
+ // records its source. Core 5dive loops live under the canonical loops repo.
40
+ export function resolveSlug(loops, slug) {
41
+ const l = loops.find((x) => x.slug === slug);
42
+ if (!l) return null;
43
+ if (l.source && l.source.repo) return l.source.repo; // "owner/repo" or "owner/repo/path"
44
+ if (l.repo) return l.repo;
45
+ if (l.imported) return null; // imported but no resolvable source
46
+ return `5dive-ai/loops/${slug}`; // core loop convention
47
+ }
@@ -0,0 +1,156 @@
1
+ // Trigger registration. Two halves:
2
+ // 1. parseSchedule() — the human grammar in §2 -> 5-field cron (or passthrough)
3
+ // 2. harness adapters — actually register the recurring job on the target
4
+ // Every install also persists a local record under the state dir, so `list`
5
+ // and `update` work regardless of how the harness schedules.
6
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ export const STATE_DIR = join(homedir(), ".agenticloops", "loops");
12
+
13
+ const DOW = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
14
+
15
+ // Human schedule grammar -> cron. Throws nothing; returns { cron } or { cron:null, raw }.
16
+ export function parseSchedule(input) {
17
+ if (!input) return { cron: null, raw: input };
18
+ const s = String(input).trim().toLowerCase();
19
+
20
+ // Already a 5-field cron expression.
21
+ if (/^[\d*/,\-?]+(\s+[\d*/,\-?a-z]+){4}$/.test(s)) return { cron: s };
22
+
23
+ const at = s.match(/@\s*(\d{1,2}):(\d{2})/);
24
+ const hh = at ? Number(at[1]) : 0;
25
+ const mm = at ? Number(at[2]) : 0;
26
+
27
+ let m;
28
+ if ((m = s.match(/^every\s+(\d+)\s*h(ours?)?$/))) return { cron: `0 */${m[1]} * * *` };
29
+ if ((m = s.match(/^every\s+(\d+)\s*m(in(utes?)?)?$/))) return { cron: `*/${m[1]} * * * *` };
30
+ if (/^hourly$/.test(s)) return { cron: "0 * * * *" };
31
+ if (/^daily/.test(s)) return { cron: `${mm} ${hh} * * *` };
32
+ if (/^weekdays?/.test(s)) return { cron: `${mm} ${hh} * * 1-5` };
33
+ if (/^weekly/.test(s)) return { cron: `${mm} ${hh} * * 0` };
34
+ if ((m = s.match(/^(?:every\s+)?(sun|mon|tue|wed|thu|fri|sat)/))) {
35
+ return { cron: `${mm} ${hh} * * ${DOW[m[1]]}` };
36
+ }
37
+ return { cron: null, raw: input };
38
+ }
39
+
40
+ export function recordPath(slug) {
41
+ return join(STATE_DIR, slug);
42
+ }
43
+
44
+ // Persist the installed loop locally (the source of truth for list/update).
45
+ export function saveRecord(slug, data, raw) {
46
+ const dir = recordPath(slug);
47
+ mkdirSync(dir, { recursive: true });
48
+ writeFileSync(join(dir, "LOOP.md"), raw, "utf8");
49
+ writeFileSync(join(dir, "meta.json"), JSON.stringify(data, null, 2), "utf8");
50
+ }
51
+
52
+ export function listRecords() {
53
+ if (!existsSync(STATE_DIR)) return [];
54
+ return readdirSync(STATE_DIR)
55
+ .map((slug) => {
56
+ try {
57
+ return JSON.parse(readFileSync(join(STATE_DIR, slug, "meta.json"), "utf8"));
58
+ } catch {
59
+ return null;
60
+ }
61
+ })
62
+ .filter(Boolean);
63
+ }
64
+
65
+ export function removeRecord(slug) {
66
+ const dir = recordPath(slug);
67
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
68
+ }
69
+
70
+ // --- harness adapters: register the recurring trigger -----------------------
71
+
72
+ function registerOn5dive({ name, prompt, cron, dryRun }) {
73
+ const args = ["task", "add", name, "--body", prompt, "--recurring", cron];
74
+ if (dryRun) return { ok: true, detail: `5dive ${args.join(" ")}`, skipped: "dry-run" };
75
+ const res = spawnSync("5dive", args, { stdio: "inherit" });
76
+ return { ok: res.status === 0, detail: `5dive ${args.join(" ")}`, status: res.status };
77
+ }
78
+
79
+ function registerGithubActions({ slug, name, cron, prompt, dryRun }) {
80
+ const wf = join(process.cwd(), ".github", "workflows", `loop-${slug}.yml`);
81
+ const yml = `# Generated by agenticloops — agentic loop "${name}"
82
+ name: loop-${slug}
83
+ on:
84
+ schedule:
85
+ - cron: "${cron}"
86
+ workflow_dispatch: {}
87
+ jobs:
88
+ run:
89
+ runs-on: ubuntu-latest
90
+ steps:
91
+ - uses: actions/checkout@v4
92
+ # TODO: invoke your agent harness with the starter prompt below.
93
+ # The prompt is committed at .agenticloops/${slug}.prompt.md
94
+ - run: echo "run loop ${slug}"
95
+ `;
96
+ if (dryRun) return { ok: true, detail: `write ${wf}`, skipped: "dry-run" };
97
+ mkdirSync(join(process.cwd(), ".github", "workflows"), { recursive: true });
98
+ writeFileSync(wf, yml, "utf8");
99
+ mkdirSync(join(process.cwd(), ".agenticloops"), { recursive: true });
100
+ writeFileSync(join(process.cwd(), ".agenticloops", `${slug}.prompt.md`), prompt, "utf8");
101
+ return { ok: true, detail: `wrote ${wf}` };
102
+ }
103
+
104
+ // Generic cron: write a runner stub + the exact crontab line, but DON'T edit
105
+ // the user's crontab silently (invasive). The runner holds the starter prompt;
106
+ // the user drops in their harness's run command and installs the line.
107
+ function registerCron({ slug, name, prompt, cron, dryRun }) {
108
+ const dir = recordPath(slug);
109
+ const runner = join(dir, "run.sh");
110
+ const line = `${cron} ${runner} # agenticloops loop "${name}"`;
111
+ if (dryRun) return { ok: true, scheduled: false, detail: `would scaffold ${runner} + crontab line` };
112
+ mkdirSync(dir, { recursive: true });
113
+ writeFileSync(
114
+ runner,
115
+ `#!/usr/bin/env bash
116
+ # agenticloops runner for "${name}". Schedule: ${cron}
117
+ # The starter prompt is below. Replace the TODO with your harness's run command
118
+ # (e.g. \`claude -p\`, \`codex exec\`, or \`5dive agent send <agent>\`).
119
+ set -euo pipefail
120
+ PROMPT="$(cat "$(dirname "$0")/prompt.md")"
121
+ # TODO: pipe "$PROMPT" into your agent harness here.
122
+ echo "$PROMPT"
123
+ `,
124
+ { mode: 0o755 },
125
+ );
126
+ writeFileSync(join(dir, "prompt.md"), prompt, "utf8");
127
+ return {
128
+ ok: true,
129
+ scheduled: false,
130
+ detail: `scaffolded ${runner} — add to crontab: ${line}`,
131
+ };
132
+ }
133
+
134
+ // For an interactive-only harness we record the loop but cannot honor the
135
+ // trigger; the caller has already warned. We still write the local record so
136
+ // the user can wire their own scheduler.
137
+ function registerLocalOnly({ slug }) {
138
+ return {
139
+ ok: true,
140
+ detail: `recorded under ${recordPath(slug)} (no scheduler — wire cron or a scheduling harness)`,
141
+ scheduled: false,
142
+ };
143
+ }
144
+
145
+ export function registerTrigger(harnessId, ctx) {
146
+ switch (harnessId) {
147
+ case "5dive":
148
+ return { scheduled: true, ...registerOn5dive(ctx) };
149
+ case "github-actions":
150
+ return { scheduled: true, ...registerGithubActions(ctx) };
151
+ case "cron":
152
+ return registerCron(ctx);
153
+ default:
154
+ return registerLocalOnly(ctx);
155
+ }
156
+ }
package/src/skills.mjs ADDED
@@ -0,0 +1,66 @@
1
+ // Skill resolution + install (§3.1). Three tiers, in order:
2
+ // 1. host-satisfied — harness already provides it -> use as-is, fetch nothing
3
+ // 2. path-resolved — owner/repo/skill -> fetch from that public repo
4
+ // 3. registry-resolved — a bare name -> the skills.sh index's canonical entry
5
+ // An unresolvable skill is skipped with a warning, never a hard failure (§3.1).
6
+ import { spawnSync } from "node:child_process";
7
+ import { fetchJson } from "./util.mjs";
8
+ import { SKILLS_REGISTRY_URL } from "./config.mjs";
9
+
10
+ // Normalise one `skills:` entry into { id, owner, repo } or { id, bare:true }.
11
+ export function parseSkillEntry(entry) {
12
+ if (entry && typeof entry === "object") {
13
+ // { id, source: "owner/repo" }
14
+ const src = entry.source ? String(entry.source).split("/") : [];
15
+ return { id: entry.id, owner: src[0], repo: src[1] };
16
+ }
17
+ const parts = String(entry).split("/");
18
+ if (parts.length >= 3) return { id: parts[2], owner: parts[0], repo: parts[1] };
19
+ if (parts.length === 2) return { id: parts[1], owner: parts[0], repo: parts[1] };
20
+ return { id: parts[0], bare: true };
21
+ }
22
+
23
+ async function resolveBare(id) {
24
+ const r = await fetchJson(SKILLS_REGISTRY_URL);
25
+ if (!r.ok || !r.json) return null;
26
+ // The skills.sh index is a name -> owner/repo map (shape-tolerant lookup).
27
+ const idx = r.json;
28
+ const hit =
29
+ idx[id] ||
30
+ (Array.isArray(idx.skills) && idx.skills.find((s) => s.name === id || s.id === id)) ||
31
+ (Array.isArray(idx) && idx.find((s) => s.name === id || s.id === id));
32
+ if (!hit) return null;
33
+ const src = (hit.source || hit.repo || hit || "").toString().split("/");
34
+ if (src.length >= 2) return { id, owner: src[0], repo: src[1] };
35
+ return null;
36
+ }
37
+
38
+ // Build an install plan without side effects. `hostSkills` = names the harness
39
+ // already provides (skipped). Returns [{ id, action, owner?, repo? }].
40
+ export async function planSkills(skills = [], hostSkills = []) {
41
+ const host = new Set(hostSkills);
42
+ const plan = [];
43
+ for (const raw of skills) {
44
+ const s = parseSkillEntry(raw);
45
+ if (host.has(s.id)) {
46
+ plan.push({ id: s.id, action: "host" });
47
+ continue;
48
+ }
49
+ if (s.owner && s.repo) {
50
+ plan.push({ id: s.id, action: "fetch", owner: s.owner, repo: s.repo });
51
+ continue;
52
+ }
53
+ const resolved = await resolveBare(s.id);
54
+ if (resolved) plan.push({ id: resolved.id, action: "fetch", owner: resolved.owner, repo: resolved.repo });
55
+ else plan.push({ id: s.id, action: "unresolved" });
56
+ }
57
+ return plan;
58
+ }
59
+
60
+ // Execute one fetch via the skills.sh CLI: `npx skills add <owner/repo> --skill <id>`.
61
+ export function installSkill({ owner, repo, id }, { dryRun = false } = {}) {
62
+ const args = ["--yes", "skills", "add", `${owner}/${repo}`, "--skill", id];
63
+ if (dryRun) return { ok: true, cmd: `npx ${args.join(" ")}`, skipped: "dry-run" };
64
+ const res = spawnSync("npx", args, { stdio: "inherit" });
65
+ return { ok: res.status === 0, cmd: `npx ${args.join(" ")}`, status: res.status };
66
+ }
@@ -0,0 +1,51 @@
1
+ // Anonymous install telemetry. On a SUCCESSFUL install the CLI fires a single
2
+ // fire-and-forget ping: { slug, ts } and nothing else. No PII, no IP logging
3
+ // intent, no machine id. Opt out with --no-telemetry, AGENTICLOOPS_NO_TELEMETRY=1,
4
+ // or the cross-tool DO_NOT_TRACK=1 standard. Failure is silent — telemetry must
5
+ // never block or break an install.
6
+ import { TELEMETRY_URL, UA } from "./config.mjs";
7
+
8
+ export function telemetryDisabled({ flag = false } = {}) {
9
+ return (
10
+ flag ||
11
+ process.env.AGENTICLOOPS_NO_TELEMETRY === "1" ||
12
+ process.env.DO_NOT_TRACK === "1"
13
+ );
14
+ }
15
+
16
+ export async function pingInstall(slug, { disabled = false, timeout = 4000 } = {}) {
17
+ if (disabled || telemetryDisabled()) return { sent: false, reason: "opted-out" };
18
+ const ctrl = new AbortController();
19
+ const t = setTimeout(() => ctrl.abort(), timeout);
20
+ try {
21
+ const r = await fetch(TELEMETRY_URL, {
22
+ method: "POST",
23
+ signal: ctrl.signal,
24
+ headers: { "content-type": "application/json", "user-agent": UA },
25
+ // ts is the client's send time; the server stamps its own authoritative
26
+ // time. slug is the only identifying field, and it's a public loop id.
27
+ body: JSON.stringify({ slug, ts: new Date().toISOString() }),
28
+ });
29
+ return { sent: r.ok, status: r.status };
30
+ } catch (e) {
31
+ return { sent: false, reason: e.name === "AbortError" ? "timeout" : e.message };
32
+ } finally {
33
+ clearTimeout(t);
34
+ }
35
+ }
36
+
37
+ // GET the per-slug tallies (used by `list` to annotate, and by the site UI).
38
+ export async function fetchInstalls({ timeout = 6000 } = {}) {
39
+ const ctrl = new AbortController();
40
+ const t = setTimeout(() => ctrl.abort(), timeout);
41
+ try {
42
+ const r = await fetch(TELEMETRY_URL, { signal: ctrl.signal, headers: { "user-agent": UA } });
43
+ if (!r.ok) return {};
44
+ const j = await r.json();
45
+ return j.installs || j.counts || j || {};
46
+ } catch {
47
+ return {};
48
+ } finally {
49
+ clearTimeout(t);
50
+ }
51
+ }
package/src/util.mjs ADDED
@@ -0,0 +1,98 @@
1
+ // Small shared helpers — TTY-aware colour, prompts, fetch-with-timeout.
2
+ import { createInterface } from "node:readline";
3
+
4
+ const useColor =
5
+ process.stdout.isTTY && process.env.NO_COLOR === undefined && process.env.TERM !== "dumb";
6
+
7
+ const wrap = (open, close) => (s) => (useColor ? `\x1b[${open}m${s}\x1b[${close}m` : String(s));
8
+ export const c = {
9
+ bold: wrap(1, 22),
10
+ dim: wrap(2, 22),
11
+ red: wrap(31, 39),
12
+ green: wrap(32, 39),
13
+ yellow: wrap(33, 39),
14
+ blue: wrap(34, 39),
15
+ cyan: wrap(36, 39),
16
+ };
17
+
18
+ export const sym = {
19
+ ok: c.green("✓"),
20
+ warn: c.yellow("⚠"),
21
+ err: c.red("✗"),
22
+ info: c.cyan("›"),
23
+ bullet: c.dim("•"),
24
+ };
25
+
26
+ export function info(msg) {
27
+ process.stderr.write(`${sym.info} ${msg}\n`);
28
+ }
29
+ export function ok(msg) {
30
+ process.stderr.write(`${sym.ok} ${msg}\n`);
31
+ }
32
+ export function warn(msg) {
33
+ process.stderr.write(`${sym.warn} ${c.yellow(msg)}\n`);
34
+ }
35
+ export function fail(msg) {
36
+ process.stderr.write(`${sym.err} ${c.red(msg)}\n`);
37
+ }
38
+ export function step(msg) {
39
+ process.stderr.write(`\n${c.bold(msg)}\n`);
40
+ }
41
+
42
+ export class CliError extends Error {
43
+ constructor(message, code = 1) {
44
+ super(message);
45
+ this.code = code;
46
+ }
47
+ }
48
+
49
+ // Yes/no prompt. Non-interactive (no TTY) returns `fallback` so CI doesn't hang.
50
+ export async function confirm(question, { fallback = false, assumeYes = false } = {}) {
51
+ if (assumeYes) return true;
52
+ if (!process.stdin.isTTY) return fallback;
53
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
54
+ try {
55
+ const ans = (await new Promise((res) => rl.question(`${question} ${c.dim("[y/N]")} `, res)))
56
+ .trim()
57
+ .toLowerCase();
58
+ return ans === "y" || ans === "yes";
59
+ } finally {
60
+ rl.close();
61
+ }
62
+ }
63
+
64
+ // Free-text prompt (used for missing secrets). Returns "" non-interactively.
65
+ export async function ask(question) {
66
+ if (!process.stdin.isTTY) return "";
67
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
68
+ try {
69
+ return (await new Promise((res) => rl.question(`${question} `, res))).trim();
70
+ } finally {
71
+ rl.close();
72
+ }
73
+ }
74
+
75
+ export async function fetchText(url, { timeout = 15000 } = {}) {
76
+ const { UA } = await import("./config.mjs");
77
+ const ctrl = new AbortController();
78
+ const t = setTimeout(() => ctrl.abort(), timeout);
79
+ try {
80
+ const r = await fetch(url, { signal: ctrl.signal, headers: { "user-agent": UA } });
81
+ if (!r.ok) return { ok: false, status: r.status };
82
+ return { ok: true, status: r.status, text: await r.text() };
83
+ } catch (e) {
84
+ return { ok: false, status: 0, error: e.message };
85
+ } finally {
86
+ clearTimeout(t);
87
+ }
88
+ }
89
+
90
+ export async function fetchJson(url, opts) {
91
+ const r = await fetchText(url, opts);
92
+ if (!r.ok) return r;
93
+ try {
94
+ return { ok: true, status: r.status, json: JSON.parse(r.text) };
95
+ } catch (e) {
96
+ return { ok: false, status: r.status, error: `bad JSON: ${e.message}` };
97
+ }
98
+ }