runchr 0.0.0-stage → 0.1.2
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 +107 -3
- package/api.mjs +85 -0
- package/args.mjs +70 -0
- package/check.mjs +53 -0
- package/files.mjs +122 -0
- package/index.mjs +106 -0
- package/install-plan.mjs +61 -0
- package/install.mjs +95 -0
- package/package.json +34 -5
- package/pull.mjs +31 -0
package/README.md
CHANGED
|
@@ -1,3 +1,107 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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,122 @@
|
|
|
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
|
+
// Who wrote the prompt travels with the pull: a reader who commits this file
|
|
85
|
+
// should know whether a person stands behind it. The prompt itself stays
|
|
86
|
+
// clean — it is fed to a model, not read by one.
|
|
87
|
+
if (manifest.systemPromptSource === "llm") {
|
|
88
|
+
lines.push(
|
|
89
|
+
"Prompt: generated from this stack's copy and tools — a person has not reviewed it.",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
lines.push("");
|
|
93
|
+
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function promptMd(manifest) {
|
|
98
|
+
const system = String(manifest.systemPrompt ?? "").trim();
|
|
99
|
+
const instructions = String(manifest.instructions ?? "").trim();
|
|
100
|
+
if (!system && !instructions) {
|
|
101
|
+
return "No prompt in this stack.\n";
|
|
102
|
+
}
|
|
103
|
+
const lines = [];
|
|
104
|
+
if (system) lines.push("# System prompt", "", system, "");
|
|
105
|
+
if (instructions) lines.push("# Instructions", "", instructions, "");
|
|
106
|
+
return lines.join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The four files a pull writes, keyed by file name.
|
|
111
|
+
*
|
|
112
|
+
* @param {{ manifest: any, card: any }} input
|
|
113
|
+
* @returns {Record<string, string>}
|
|
114
|
+
*/
|
|
115
|
+
export function renderFiles({ manifest, card }) {
|
|
116
|
+
return {
|
|
117
|
+
"runchr.json": `${JSON.stringify(manifest, null, 2)}\n`,
|
|
118
|
+
"mcp.json": `${JSON.stringify(mcpConfigFor(manifest), null, 2)}\n`,
|
|
119
|
+
"AGENTS.md": agentsMd(manifest, card),
|
|
120
|
+
"prompt.md": promptMd(manifest),
|
|
121
|
+
};
|
|
122
|
+
}
|
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
|
+
}
|
package/install-plan.mjs
ADDED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "runchr",
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
+
}
|