runchr 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,107 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # runchr
2
+
3
+ Pull an agent stack into a folder. A stack is a combination of agent tools —
4
+ MCP servers, skills, agents, frameworks — that do one job, plus the model, the
5
+ harness, and the prompt to run it.
6
+
7
+ ```bash
8
+ npx runchr pull runchr/docs-current-refactor-agent
9
+ ```
10
+
11
+ It writes four files into `runchr-<slug>/`:
12
+
13
+ | file | what |
14
+ |---|---|
15
+ | `runchr.json` | the manifest — the machine-readable stack |
16
+ | `AGENTS.md` | the card: what the stack does, and the tools it uses |
17
+ | `mcp.json` | the MCP servers, as a `mcpServers` config (best effort) |
18
+ | `prompt.md` | the system prompt and the instructions |
19
+
20
+ It fetches before it writes, so a failure leaves the folder untouched, and it
21
+ only ever writes its own four files.
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ npx runchr pull <owner>/<slug>[@rev] [--dir <path>] [--json]
27
+ npx runchr install [<owner>/<slug>[@rev]] [--dir <path>] [--yes] [--json]
28
+ npx runchr check [<owner>/<slug>[@rev]] [--dir <path>] [--json]
29
+ ```
30
+
31
+ - `owner` — a member's login, or `runchr` for a mined or drafted stack.
32
+ - `@rev` — a specific revision (default: the current one).
33
+ - `--dir` — where to write (default: `runchr-<slug>`; `install` and `check`
34
+ default to the current directory).
35
+ - `--yes` — `install` only: run the tool installs. Without it, they are printed.
36
+ - `--json` — print the result as JSON instead of a summary.
37
+
38
+ ### `pull` vs `install`
39
+
40
+ `pull` writes the four files and stops.
41
+
42
+ `install` writes them, merges the stack's MCP servers into a project
43
+ `.mcp.json`, and — **only with `--yes`** — runs the tool installs. By default it
44
+ prints what it would run, so nothing executes without consent. An MCP server is
45
+ written as config (the harness runs it later); a `pip`/`npx`/`git` line is a
46
+ command; a path or a harness command is a note.
47
+
48
+ ```bash
49
+ npx runchr install runchr/docs-current-refactor-agent
50
+ # Applied runchr/docs-current-refactor-agent@1 into runchr-docs-current-refactor-agent
51
+ # MCP servers written to .mcp.json: context7, github-mcp-server
52
+ # Commands (not run — pass --yes to run):
53
+ # pip install aider-chat
54
+ ```
55
+
56
+ Point it at another host with `RUNCHR_API_URL`.
57
+
58
+ ### `runchr.json` is a lockfile
59
+
60
+ `install` writes `runchr.json` with the revision it applied. Commit it, and
61
+ **`install` with no argument reproduces that exact revision** — not whatever is
62
+ newest:
63
+
64
+ ```bash
65
+ npx runchr install # reads ./runchr.json, applies its revision
66
+ ```
67
+
68
+ The lockfile is left untouched when it is the input, so a clean checkout stays
69
+ clean on every CI run.
70
+
71
+ ### `check`
72
+
73
+ `check` answers the question a builder actually has repeatedly: **is this stack
74
+ still good?** It reports whether the pin is behind the current revision, and
75
+ which tools have been archived or gone quiet — with what people reach for
76
+ instead, ranked by the recipe graph (the tools used alongside the rest of *your*
77
+ stack) rather than by stars alone.
78
+
79
+ ```bash
80
+ npx runchr check
81
+ # runchr/docs-current-refactor-agent — pinned to revision 1; current is 2
82
+ # ok context7 (mcp-server) — ok
83
+ # !! dead-docs (mcp-server) — archived
84
+ # → gitmcp — used with 3 stacks like yours
85
+ # Something needs attention.
86
+ ```
87
+
88
+ It **exits non-zero when something needs attention**, so a scheduled job can
89
+ run it:
90
+
91
+ ```yaml
92
+ - run: npx runchr install --yes
93
+ - run: npx runchr check
94
+ ```
95
+
96
+ Nothing is executed without `--yes`, so this is safe to put in CI.
97
+
98
+ ## Where the stacks come from
99
+
100
+ [runchr.com](https://runchr.com) — a curated registry of agent artifacts. Browse
101
+ by the job your agent does, read the stack, then pull it. The catalog is also
102
+ open over HTTP (`https://api.runchr.com/api/tools`) and MCP
103
+ (`https://api.runchr.com/mcp`).
104
+
105
+ ## License
106
+
107
+ See the repository.
package/api.mjs ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The read side of the API, for the CLI. No token: reading is open.
3
+ */
4
+ export const DEFAULT_API = "https://api.runchr.com";
5
+
6
+ async function getJson(fetchImpl, url) {
7
+ const res = await fetchImpl(url);
8
+ if (!res.ok) {
9
+ throw new Error(`${res.status} ${res.statusText} for ${url}`);
10
+ }
11
+ return res.json();
12
+ }
13
+
14
+ function present(value) {
15
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
16
+ }
17
+
18
+ /**
19
+ * The card and the manifest for a stack. With a revision, the manifest is
20
+ * rebuilt from that revision's payload; its tool types come from the current
21
+ * manifest, and a ref the current stack no longer has is dropped.
22
+ *
23
+ * @param {{ owner: string, slug: string, revision?: number }} spec
24
+ * @param {{ apiBase?: string, fetchImpl?: (url: string) => Promise<Response> }} [options]
25
+ * @returns {Promise<{ manifest: any, card: any }>}
26
+ */
27
+ export async function fetchStack(
28
+ { owner, slug, revision },
29
+ { apiBase = process.env.RUNCHR_API_URL ?? DEFAULT_API, fetchImpl = fetch } = {},
30
+ ) {
31
+ const base = String(apiBase).replace(/\/$/, "");
32
+ const cardUrl = `${base}/api/recipes/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`;
33
+
34
+ const [cardBody, manifestBody] = await Promise.all([
35
+ getJson(fetchImpl, cardUrl),
36
+ getJson(fetchImpl, `${cardUrl}/manifest`),
37
+ ]);
38
+ const card = cardBody.recipe;
39
+ let manifest = manifestBody.manifest;
40
+
41
+ if (revision) {
42
+ const revBody = await getJson(fetchImpl, `${cardUrl}/revisions/${revision}`);
43
+ const payload = revBody.revision?.payload ?? {};
44
+ const byRef = new Map((manifest.tools ?? []).map((tool) => [tool.ref, tool]));
45
+ const rebuilt = {
46
+ manifestVersion: manifest.manifestVersion ?? 1,
47
+ name: `${owner}/${slug}`,
48
+ revision,
49
+ version: manifest.version,
50
+ tier: manifest.tier,
51
+ tools: (payload.tools ?? [])
52
+ .map((ref) => byRef.get(ref))
53
+ .filter((tool) => Boolean(tool)),
54
+ };
55
+ for (const key of ["model", "harness", "systemPrompt", "instructions", "resultUrl", "repoUrl"]) {
56
+ const value = present(payload[key]);
57
+ if (value) rebuilt[key] = value;
58
+ }
59
+ manifest = rebuilt;
60
+ }
61
+
62
+ return { manifest, card };
63
+ }
64
+
65
+ /**
66
+ * Whether a stack is still good: behind the current revision, or carrying a
67
+ * tool that has been archived or gone quiet. With a revision, the answer is
68
+ * about that pin — which is what the loader has committed.
69
+ *
70
+ * @param {{ owner: string, slug: string, revision?: number }} spec
71
+ * @param {{ apiBase?: string, fetchImpl?: (url: string) => Promise<Response> }} [options]
72
+ * @returns {Promise<any>}
73
+ */
74
+ export async function fetchCheck(
75
+ { owner, slug, revision },
76
+ { apiBase = process.env.RUNCHR_API_URL ?? DEFAULT_API, fetchImpl = fetch } = {},
77
+ ) {
78
+ const base = String(apiBase).replace(/\/$/, "");
79
+ const query = revision ? `?revision=${revision}` : "";
80
+ const body = await getJson(
81
+ fetchImpl,
82
+ `${base}/api/recipes/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}/check${query}`,
83
+ );
84
+ return body.check;
85
+ }
package/args.mjs ADDED
@@ -0,0 +1,70 @@
1
+ const USAGE = [
2
+ "usage:",
3
+ " runchr pull <owner>/<slug>[@rev] [--dir <path>] [--json]",
4
+ " runchr install [<owner>/<slug>[@rev]] [--dir <path>] [--yes] [--json]",
5
+ " runchr check [<owner>/<slug>[@rev]] [--dir <path>] [--json]",
6
+ ].join("\n");
7
+
8
+ /**
9
+ * `owner/slug` or `owner/slug@3`. The owner is the namespace, so a bare slug is
10
+ * refused rather than guessed — the API accepts one for back-compat, but the
11
+ * CLI should not quietly pick an owner.
12
+ *
13
+ * @param {string} spec
14
+ * @returns {{ owner: string, slug: string, revision?: number }}
15
+ */
16
+ export function parseSpec(spec) {
17
+ const raw = String(spec ?? "").trim();
18
+ const [path, rev] = raw.split("@");
19
+ const slash = path.indexOf("/");
20
+ if (slash < 1 || slash === path.length - 1) {
21
+ throw new Error("pull needs owner/slug, e.g. runchr pull runchr/context7-docs");
22
+ }
23
+ const owner = path.slice(0, slash);
24
+ const slug = path.slice(slash + 1);
25
+ if (rev === undefined || rev === "") {
26
+ return { owner, slug };
27
+ }
28
+ const revision = Number(rev);
29
+ if (!Number.isInteger(revision) || revision < 1) {
30
+ throw new Error(`@${rev} must be a revision number`);
31
+ }
32
+ return { owner, slug, revision };
33
+ }
34
+
35
+ /**
36
+ * `pull` writes the stack's files; `install` also applies them; `check` only
37
+ * asks whether they are still good.
38
+ *
39
+ * `install` and `check` may omit the spec: they then read the project's own
40
+ * `runchr.json`. That is the lockfile loop — the file is committed, and CI
41
+ * reproduces the pinned revision rather than whatever is newest.
42
+ *
43
+ * @param {string[]} argv
44
+ * @returns {{ command: "pull" | "install" | "check", spec?: string, dir?: string, yes: boolean, json: boolean }}
45
+ */
46
+ export function parseArgs(argv) {
47
+ const [command, ...rest] = argv ?? [];
48
+ if (!["pull", "install", "check"].includes(command)) throw new Error(USAGE);
49
+
50
+ let spec;
51
+ let dir;
52
+ let yes = false;
53
+ let json = false;
54
+ for (let i = 0; i < rest.length; i += 1) {
55
+ const arg = rest[i];
56
+ if (arg === "--json") {
57
+ json = true;
58
+ } else if (arg === "--yes") {
59
+ yes = true;
60
+ } else if (arg === "--dir") {
61
+ dir = rest[i + 1];
62
+ i += 1;
63
+ } else if (!arg.startsWith("-") && spec === undefined) {
64
+ spec = arg;
65
+ }
66
+ }
67
+ if (!spec && command === "pull") throw new Error(USAGE);
68
+
69
+ return { command, spec, dir, yes, json };
70
+ }
package/check.mjs ADDED
@@ -0,0 +1,53 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { parseSpec } from "./args.mjs";
4
+ import { fetchCheck } from "./api.mjs";
5
+
6
+ /**
7
+ * The project's lockfile, if it has one.
8
+ *
9
+ * `runchr.json` is what `install` writes and what `install`/`check` with no
10
+ * argument read. It is a lockfile in the npm sense: the committed revision is
11
+ * what a teammate and CI reproduce, not whatever is newest.
12
+ */
13
+ export function readLock(dir) {
14
+ const file = path.join(dir, "runchr.json");
15
+ if (!existsSync(file)) return undefined;
16
+ try {
17
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
18
+ return parsed && typeof parsed === "object" ? parsed : undefined;
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ }
23
+
24
+ /** `owner/slug@revision` from a manifest, or undefined when it is not one. */
25
+ export function lockSpec(lock) {
26
+ const name = String(lock?.name ?? "").trim();
27
+ if (!name.includes("/")) return undefined;
28
+ const revision = Number(lock?.revision);
29
+ return Number.isInteger(revision) && revision > 0 ? `${name}@${revision}` : name;
30
+ }
31
+
32
+ /**
33
+ * Ask whether the stack this project pins is still good: behind the current
34
+ * revision, or carrying a tool that has been archived or gone quiet.
35
+ *
36
+ * @param {{ spec?: string, dir?: string }} args
37
+ * @param {{ apiBase?: string, fetchImpl?: (url: string) => Promise<Response>, cwd?: string }} [options]
38
+ * @returns {Promise<{ dir: string, report: any }>}
39
+ */
40
+ export async function check({ spec, dir }, options = {}) {
41
+ const cwd = options.cwd ?? process.cwd();
42
+ const target = path.resolve(cwd, dir ?? ".");
43
+ const lock = readLock(target);
44
+ const resolved = spec ?? lockSpec(lock);
45
+ if (!resolved) {
46
+ throw new Error(
47
+ "check needs owner/slug, or a runchr.json in this directory (run runchr install first)",
48
+ );
49
+ }
50
+
51
+ const report = await fetchCheck(parseSpec(resolved), options);
52
+ return { dir: target, report };
53
+ }
package/files.mjs ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The files a pulled stack becomes. Pure: given a manifest and the card, it
3
+ * returns the file contents; the caller writes them.
4
+ */
5
+
6
+ /**
7
+ * The install line as a runnable config. Many MCP servers publish the whole
8
+ * `claude_desktop_config.json` block as their snippet, so a JSON object is
9
+ * unwrapped to the first server; `npx …` becomes a command; a URL stays a URL.
10
+ */
11
+ export function parseInstall(line) {
12
+ const value = String(line).trim();
13
+
14
+ if (value.startsWith("{")) {
15
+ try {
16
+ const parsed = JSON.parse(value);
17
+ const servers = parsed.mcpServers ?? parsed.mcp_servers;
18
+ const first = servers && typeof servers === "object" ? Object.values(servers)[0] : undefined;
19
+ if (first && typeof first === "object") return first;
20
+ } catch {
21
+ /* not JSON — fall through */
22
+ }
23
+ }
24
+
25
+ if (value.startsWith("npx ")) {
26
+ return { command: "npx", args: value.split(/\s+/).slice(1) };
27
+ }
28
+ if (/^https?:\/\//.test(value)) {
29
+ return { url: value };
30
+ }
31
+ return { install: value };
32
+ }
33
+
34
+ /**
35
+ * The MCP servers in a stack, as a `mcpServers` config. Other tools are skipped.
36
+ *
37
+ * @param {{ tools?: Array<{ type: string, ref: string, install?: string }> }} manifest
38
+ * @returns {{ mcpServers: Record<string, unknown> }}
39
+ */
40
+ export function mcpConfigFor(manifest) {
41
+ const mcpServers = {};
42
+ for (const tool of manifest.tools ?? []) {
43
+ if (tool.type !== "mcp-server") continue;
44
+ if (!tool.install) continue;
45
+ mcpServers[tool.ref] = parseInstall(tool.install);
46
+ }
47
+ return { mcpServers };
48
+ }
49
+
50
+ function section(title, body) {
51
+ const text = String(body ?? "").trim();
52
+ return text ? `## ${title}\n\n${text}\n` : "";
53
+ }
54
+
55
+ function agentsMd(manifest, card) {
56
+ const lines = [`# ${card.name ?? manifest.name}`, ""];
57
+ if (card.summary) lines.push(card.summary, "");
58
+ if (card.description) lines.push(card.description, "");
59
+
60
+ for (const [title, body] of [
61
+ ["What they did", card.whatTheyDid],
62
+ ["Outcome", card.outcome],
63
+ ["Caveats", card.caveats],
64
+ ["Wiring", card.wiring],
65
+ ]) {
66
+ const block = section(title, body);
67
+ if (block) lines.push(block);
68
+ }
69
+
70
+ lines.push("## Tools", "");
71
+ for (const tool of manifest.tools ?? []) {
72
+ const install = tool.install ? ` — \`${tool.install}\`` : "";
73
+ lines.push(`- \`${tool.ref}\` (${tool.type})${install}`);
74
+ }
75
+ lines.push("");
76
+
77
+ if (card.sourceUrl) lines.push(`Source: ${card.sourceUrl}`, "");
78
+ // The slug names the stack; the URL is how a reader who finds this file
79
+ // reaches runchr. A committed AGENTS.md is a small, honest ad.
80
+ const slug = String(manifest.name).split("/").pop();
81
+ lines.push(
82
+ `Pulled from runchr: https://runchr.com/recipes/${slug} (${manifest.tier}, revision ${manifest.revision}).`,
83
+ "",
84
+ );
85
+
86
+ return lines.join("\n");
87
+ }
88
+
89
+ function promptMd(manifest) {
90
+ const system = String(manifest.systemPrompt ?? "").trim();
91
+ const instructions = String(manifest.instructions ?? "").trim();
92
+ if (!system && !instructions) {
93
+ return "No prompt in this stack.\n";
94
+ }
95
+ const lines = [];
96
+ if (system) lines.push("# System prompt", "", system, "");
97
+ if (instructions) lines.push("# Instructions", "", instructions, "");
98
+ return lines.join("\n");
99
+ }
100
+
101
+ /**
102
+ * The four files a pull writes, keyed by file name.
103
+ *
104
+ * @param {{ manifest: any, card: any }} input
105
+ * @returns {Record<string, string>}
106
+ */
107
+ export function renderFiles({ manifest, card }) {
108
+ return {
109
+ "runchr.json": `${JSON.stringify(manifest, null, 2)}\n`,
110
+ "mcp.json": `${JSON.stringify(mcpConfigFor(manifest), null, 2)}\n`,
111
+ "AGENTS.md": agentsMd(manifest, card),
112
+ "prompt.md": promptMd(manifest),
113
+ };
114
+ }
package/index.mjs ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `runchr pull owner/slug[@rev] [--dir <path>] [--json]`
4
+ * `runchr install [owner/slug[@rev]] [--dir <path>] [--yes] [--json]`
5
+ * `runchr check [owner/slug[@rev]] [--dir <path>] [--json]`
6
+ *
7
+ * A zero-dependency loader. `pull` writes runchr.json, AGENTS.md, mcp.json, and
8
+ * prompt.md into a folder. `install` does that, merges the MCP servers into a
9
+ * project `.mcp.json`, and — only with `--yes` — runs the tool installs. With no
10
+ * spec, `install` reproduces the revision the project's own `runchr.json` pins,
11
+ * which is what makes that file a lockfile. `check` asks whether that pin is
12
+ * still good and exits non-zero when it is not, so CI can run it.
13
+ */
14
+ import { realpathSync } from "node:fs";
15
+ import { pathToFileURL } from "node:url";
16
+ import { parseArgs } from "./args.mjs";
17
+ import { pull } from "./pull.mjs";
18
+ import { install } from "./install.mjs";
19
+ import { check } from "./check.mjs";
20
+
21
+ const HEALTH = { ok: "ok", stale: "stale", archived: "archived", unknown: "unmeasured" };
22
+
23
+ function printCheck(result) {
24
+ const { report } = result;
25
+ const state = report.outdated
26
+ ? `pinned to revision ${report.installedRevision}; current is ${report.currentRevision}`
27
+ : `revision ${report.installedRevision} is current`;
28
+ process.stdout.write(`${report.name} — ${state}\n`);
29
+
30
+ for (const tool of report.tools) {
31
+ const label = HEALTH[tool.health] ?? tool.health;
32
+ process.stdout.write(` ${tool.health === "ok" ? "ok" : "!!"} ${tool.slug} (${tool.type}) — ${label}\n`);
33
+ for (const replacement of tool.replacements ?? []) {
34
+ process.stdout.write(` → ${replacement.slug} — ${replacement.reason}\n`);
35
+ }
36
+ }
37
+
38
+ process.stdout.write(report.ok ? "Nothing to do.\n" : "Something needs attention.\n");
39
+ }
40
+
41
+ function printPull(result) {
42
+ process.stdout.write(`Pulled ${result.manifest.name}@${result.manifest.revision} into ${result.dir}\n`);
43
+ for (const file of result.files) process.stdout.write(` ${file}\n`);
44
+ }
45
+
46
+ function printInstall(result, ran) {
47
+ process.stdout.write(`Applied ${result.manifest.name}@${result.manifest.revision} into ${result.dir}\n`);
48
+ if (result.fromLock) {
49
+ process.stdout.write(" (from runchr.json — the lockfile was left as it is)\n");
50
+ }
51
+ for (const file of result.files) process.stdout.write(` ${file}\n`);
52
+
53
+ const servers = Object.keys(result.plan.mcpServers);
54
+ if (servers.length > 0) {
55
+ process.stdout.write(`MCP servers written to .mcp.json: ${servers.join(", ")}\n`);
56
+ }
57
+
58
+ if (result.plan.commands.length > 0) {
59
+ process.stdout.write(ran ? "Ran:\n" : "Commands (not run — pass --yes to run):\n");
60
+ for (const step of result.plan.commands) process.stdout.write(` ${step.command}\n`);
61
+ }
62
+ for (const note of result.plan.notes) {
63
+ process.stdout.write(`Note — ${note.ref}: ${note.note}\n`);
64
+ }
65
+ }
66
+
67
+ export async function main(argv) {
68
+ const args = parseArgs(argv);
69
+
70
+ if (args.command === "check") {
71
+ const result = await check(args);
72
+ if (args.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
73
+ else printCheck(result);
74
+ // The exit code is the point when CI runs this: a stack that is behind or
75
+ // carrying a dead tool fails the build rather than scrolling past.
76
+ return result.report.ok ? 0 : 1;
77
+ }
78
+
79
+ if (args.command === "install") {
80
+ const result = await install(args);
81
+ if (args.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
82
+ else printInstall(result, args.yes);
83
+ return 0;
84
+ }
85
+
86
+ const result = await pull(args);
87
+ if (args.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
88
+ else printPull(result);
89
+ return 0;
90
+ }
91
+
92
+ // `process.argv[1]` is the path used to invoke the script. npm installs a bin as
93
+ // a symlink, so the path and the module's own url differ unless the real path is
94
+ // resolved first — without that, the CLI installed from npm would do nothing.
95
+ const invokedDirectly =
96
+ Boolean(process.argv[1]) &&
97
+ import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
98
+
99
+ if (invokedDirectly) {
100
+ main(process.argv.slice(2))
101
+ .then((code) => process.exit(code))
102
+ .catch((error) => {
103
+ process.stderr.write(`${error.message}\n`);
104
+ process.exit(1);
105
+ });
106
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Turn a stack's manifest into an install plan. Pure: it decides what would be
3
+ * done; `install.mjs` decides whether to do it.
4
+ *
5
+ * An MCP server is config (the harness runs it later). A line that is a shell
6
+ * command is a command — including `git clone` and `curl`, which is why the
7
+ * caller runs commands only with explicit consent. Everything else is a note:
8
+ * a path, a harness command, or nothing.
9
+ */
10
+ import { parseInstall } from "./files.mjs";
11
+
12
+ const SHELL_COMMAND =
13
+ /^(?:npx|npm|pnpm|yarn|bun|pip|pip3|pipx|uv|python|python3|brew|cargo|go|git|curl|wget|docker|make|bash|sh|node|deno)\b/;
14
+
15
+ /** One tool's install line as an action.
16
+ *
17
+ * @param {{ ref?: string, type?: string, install?: string }} tool
18
+ * @returns {{ ref: string, type: string, kind: "config" | "command" | "note", value: string }}
19
+ */
20
+ export function classifyTool(tool) {
21
+ const ref = tool?.ref ?? "";
22
+ const type = tool?.type ?? "";
23
+ const line = String(tool?.install ?? "").trim();
24
+
25
+ if (type === "mcp-server") {
26
+ return line
27
+ ? { ref, type, kind: "config", value: line }
28
+ : { ref, type, kind: "note", value: "no install line" };
29
+ }
30
+ if (SHELL_COMMAND.test(line)) return { ref, type, kind: "command", value: line };
31
+ if (line) return { ref, type, kind: "note", value: line };
32
+ return { ref, type, kind: "note", value: "no install line" };
33
+ }
34
+
35
+ /** The whole stack as a plan.
36
+ *
37
+ * @param {{ tools?: Array<{ ref?: string, type?: string, install?: string }> }} manifest
38
+ * @returns {{
39
+ * mcpServers: Record<string, unknown>,
40
+ * commands: { ref: string, command: string }[],
41
+ * notes: { ref: string, note: string }[],
42
+ * }}
43
+ */
44
+ export function installPlan(manifest) {
45
+ const mcpServers = {};
46
+ const commands = [];
47
+ const notes = [];
48
+
49
+ for (const tool of manifest?.tools ?? []) {
50
+ const classified = classifyTool(tool);
51
+ if (classified.kind === "config") {
52
+ mcpServers[classified.ref] = parseInstall(classified.value);
53
+ } else if (classified.kind === "command") {
54
+ commands.push({ ref: classified.ref, command: classified.value });
55
+ } else {
56
+ notes.push({ ref: classified.ref, note: classified.value });
57
+ }
58
+ }
59
+
60
+ return { mcpServers, commands, notes };
61
+ }
package/install.mjs ADDED
@@ -0,0 +1,95 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { parseSpec } from "./args.mjs";
5
+ import { fetchStack } from "./api.mjs";
6
+ import { lockSpec, readLock } from "./check.mjs";
7
+ import { renderFiles } from "./files.mjs";
8
+ import { installPlan } from "./install-plan.mjs";
9
+
10
+ function defaultRunCommand(command, cwd) {
11
+ execSync(command, { stdio: "inherit", cwd });
12
+ }
13
+
14
+ /**
15
+ * Merge the stack's MCP servers into an existing `.mcp.json`, keeping every
16
+ * other key and every server already there. A stack's servers win on a name
17
+ * clash — the stack is the thing the user asked for.
18
+ */
19
+ export function mergeMcpConfig(existing, mcpServers) {
20
+ const base = existing && typeof existing === "object" ? existing : {};
21
+ return {
22
+ ...base,
23
+ mcpServers: { ...(base.mcpServers ?? {}), ...mcpServers },
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Apply a stack: write the four files, merge its MCP servers into the project
29
+ * `.mcp.json`, and — only with `yes` — run the tool installs. Fetch first,
30
+ * write second, run third, so a failure leaves the earlier steps.
31
+ *
32
+ * With no spec it reads the project's own `runchr.json` and reproduces that
33
+ * revision. The lockfile is then left untouched: it is the input, so rewriting
34
+ * it from a rebuilt manifest would dirty a clean checkout on every CI run.
35
+ *
36
+ * @param {{ spec?: string, dir?: string, yes?: boolean }} args
37
+ * @param {{ apiBase?: string, fetchImpl?: (url: string) => Promise<Response>, cwd?: string, runCommand?: (command: string, cwd: string) => void }} [options]
38
+ * @returns {Promise<{ dir: string, files: string[], manifest: any, plan: any, ran: string[], fromLock: boolean }>}
39
+ */
40
+ export async function install(
41
+ { spec, dir, yes },
42
+ { apiBase, fetchImpl = fetch, cwd = process.cwd(), runCommand = defaultRunCommand } = {},
43
+ ) {
44
+ const target = path.resolve(cwd, dir ?? ".");
45
+ const fromLock = !spec;
46
+ const resolved = spec ?? lockSpec(readLock(target));
47
+ if (!resolved) {
48
+ throw new Error(
49
+ "install needs owner/slug, or a runchr.json in this directory (try runchr install runchr/docs-agent)",
50
+ );
51
+ }
52
+
53
+ const parsed = parseSpec(resolved);
54
+ const { manifest, card } = await fetchStack(parsed, { apiBase, fetchImpl });
55
+ const rendered = renderFiles({ manifest, card });
56
+ // The lockfile is the source when it is what we installed from.
57
+ const files = fromLock
58
+ ? Object.fromEntries(Object.entries(rendered).filter(([name]) => name !== "runchr.json"))
59
+ : rendered;
60
+
61
+ // `install` applies the stack to the project, so it lands in the current
62
+ // directory by default — `.mcp.json` and `AGENTS.md` are read from the project
63
+ // root, not from a subfolder. (`pull` keeps the `runchr-<slug>` folder.)
64
+ mkdirSync(target, { recursive: true });
65
+ for (const [name, content] of Object.entries(files)) {
66
+ writeFileSync(path.join(target, name), content, "utf8");
67
+ }
68
+
69
+ const plan = installPlan(manifest);
70
+
71
+ const mcpPath = path.join(target, ".mcp.json");
72
+ let existing = {};
73
+ if (existsSync(mcpPath)) {
74
+ try {
75
+ existing = JSON.parse(readFileSync(mcpPath, "utf8"));
76
+ } catch {
77
+ existing = {};
78
+ }
79
+ }
80
+ writeFileSync(
81
+ mcpPath,
82
+ `${JSON.stringify(mergeMcpConfig(existing, plan.mcpServers), null, 2)}\n`,
83
+ "utf8",
84
+ );
85
+
86
+ const ran = [];
87
+ if (yes) {
88
+ for (const step of plan.commands) {
89
+ runCommand(step.command, target);
90
+ ran.push(step.ref);
91
+ }
92
+ }
93
+
94
+ return { dir: target, files: Object.keys(files), manifest, plan, ran, fromLock };
95
+ }
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
- {
2
- "name": "runchr",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
1
+ {
2
+ "name": "runchr",
3
+ "version": "0.1.0",
4
+ "description": "Pull a runchr agent stack into a folder: runchr.json, AGENTS.md, mcp.json, prompt.md.",
5
+ "type": "module",
6
+ "bin": {
7
+ "runchr": "./index.mjs"
8
+ },
9
+ "files": [
10
+ "index.mjs",
11
+ "args.mjs",
12
+ "api.mjs",
13
+ "check.mjs",
14
+ "files.mjs",
15
+ "pull.mjs",
16
+ "install.mjs",
17
+ "install-plan.mjs",
18
+ "README.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "keywords": [
24
+ "agent",
25
+ "agents",
26
+ "mcp",
27
+ "stack",
28
+ "cli",
29
+ "runchr"
30
+ ],
31
+ "homepage": "https://runchr.com",
32
+ "bugs": {
33
+ "url": "mailto:hello@runchr.com"
34
+ }
6
35
  }
package/pull.mjs ADDED
@@ -0,0 +1,31 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { parseSpec } from "./args.mjs";
4
+ import { fetchStack } from "./api.mjs";
5
+ import { renderFiles } from "./files.mjs";
6
+
7
+ /**
8
+ * Pull a stack into a directory. Fetch first, render second, write third — so a
9
+ * failed fetch leaves the directory untouched. It only ever writes its own four
10
+ * files; it never deletes.
11
+ *
12
+ * @param {{ spec: string, dir?: string }} args
13
+ * @param {{ apiBase?: string, fetchImpl?: (url: string) => Promise<Response>, cwd?: string }} [options]
14
+ * @returns {Promise<{ dir: string, files: string[], manifest: any }>}
15
+ */
16
+ export async function pull(
17
+ { spec, dir },
18
+ { apiBase, fetchImpl = fetch, cwd = process.cwd() } = {},
19
+ ) {
20
+ const parsed = parseSpec(spec);
21
+ const { manifest, card } = await fetchStack(parsed, { apiBase, fetchImpl });
22
+ const files = renderFiles({ manifest, card });
23
+
24
+ const target = path.resolve(cwd, dir ?? `runchr-${parsed.slug}`);
25
+ mkdirSync(target, { recursive: true });
26
+ for (const [name, content] of Object.entries(files)) {
27
+ writeFileSync(path.join(target, name), content, "utf8");
28
+ }
29
+
30
+ return { dir: target, files: Object.keys(files), manifest };
31
+ }