@miroshnik/elves 0.0.0 → 2026.1018.1
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/AGENTS.md +310 -0
- package/README.md +344 -0
- package/bin/ai-dev.ts +7 -0
- package/bin/elves.ts +2847 -0
- package/bin/shims/timeout +113 -0
- package/claude/CLAUDE.md +71 -0
- package/dist/github/scripts/github.js +3428 -0
- package/dist/spec/scripts/architecture.js +628 -0
- package/dist/spec/scripts/eslint-config.js +54 -0
- package/dist/spec/scripts/harness-qzb932c8.js +959 -0
- package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
- package/dist/spec/scripts/harness.js +53 -0
- package/dist/spec/scripts/knip-hints.cjs +34 -0
- package/docs/ci-concurrency.md +82 -0
- package/docs/cloud-sessions.md +204 -0
- package/docs/judgment.md +81 -0
- package/docs/parallel-checkouts.md +220 -0
- package/docs/pr-checks.md +117 -0
- package/docs/testing.md +304 -0
- package/package.json +70 -1
- package/skills/ci-wait/SKILL.md +180 -0
- package/skills/ci-wait/scripts/wait-ci.sh +377 -0
- package/skills/dashboard/SKILL.md +212 -0
- package/skills/dashboard/scripts/dashboard.ts +912 -0
- package/skills/dashboard/scripts/sessions.ts +772 -0
- package/skills/est/SKILL.md +222 -0
- package/skills/est/reference.md +381 -0
- package/skills/est/scripts/est.ts +4867 -0
- package/skills/github/SKILL.md +206 -0
- package/skills/github/locales/en.json +449 -0
- package/skills/github/locales/ru.json +449 -0
- package/skills/github/reference.md +1073 -0
- package/skills/github/scripts/github.ts +4390 -0
- package/skills/github/scripts/locale.ts +495 -0
- package/skills/github/scripts/package.json +3 -0
- package/skills/slot/SKILL.md +145 -0
- package/skills/slot/scripts/package.json +3 -0
- package/skills/slot/scripts/slot.ts +279 -0
- package/skills/spec/SKILL.md +247 -0
- package/skills/spec/canon.md +219 -0
- package/skills/spec/checks.md +709 -0
- package/skills/spec/ci.md +378 -0
- package/skills/spec/migration.md +125 -0
- package/skills/spec/reference.md +889 -0
- package/skills/spec/scripts/architecture.ts +839 -0
- package/skills/spec/scripts/c4.ts +184 -0
- package/skills/spec/scripts/eslint-config.ts +82 -0
- package/skills/spec/scripts/harness.ts +1483 -0
- package/skills/spec/scripts/knip-hints.cjs +34 -0
- package/skills/spec/scripts/knip-hints.d.cts +17 -0
- package/skills/spec/scripts/package-run.ts +22 -0
- package/skills/spec/scripts/package.json +3 -0
- package/skills/spec/scripts/spec-break.ts +232 -0
- package/skills/spec/scripts/spec-claims.ts +198 -0
- package/skills/spec/scripts/spec-diff.ts +1111 -0
- package/skills/spec/scripts/spec-doc.ts +744 -0
- package/skills/spec/scripts/spec-exceptions.ts +233 -0
- package/skills/spec/scripts/spec-publish.ts +214 -0
- package/skills/spec/scripts/spec-run.ts +235 -0
- package/skills/spec/scripts/spec-shard.ts +267 -0
- package/skills/spec/scripts/speclib.ts +826 -0
- package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
- package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
- package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
- package/skills/task-env/SKILL.md +229 -0
- package/skills/task-env/proxy/compose.yaml +48 -0
- package/skills/task-env/reference.md +246 -0
- package/skills/task-env/scripts/task-env.ts +981 -0
package/bin/elves.ts
ADDED
|
@@ -0,0 +1,2847 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
':' //; exec bun --no-env-file "$0" "$@"
|
|
3
|
+
/**
|
|
4
|
+
* elves — installs the flow: the shared rules, references and all Elves skills into a project or onto the machine;
|
|
5
|
+
* checks whether an install has fallen behind the latest Elves release, updates it and cuts a release.
|
|
6
|
+
*
|
|
7
|
+
* bunx @miroshnik/elves install — into the project: the git root (or the current directory)
|
|
8
|
+
* bunx @miroshnik/elves install -g — machine-wide: the canon, skills and hook
|
|
9
|
+
* elves install -g — the same from the npm package (npm i -g @miroshnik/elves)
|
|
10
|
+
* bun <Elves clone>/bin/elves.ts install -g --link — from a clone: symlinks to the clone, edits show at once
|
|
11
|
+
* bunx @miroshnik/elves check [-g] — is the install behind: 0 — no, 1 — yes, 2 — can't tell
|
|
12
|
+
* bunx @miroshnik/elves update [-g] — bring the install up to date
|
|
13
|
+
* bunx github:miroshnik/elves <command> — the fallback: the launcher from main (`toRelease`)
|
|
14
|
+
* bun <Elves clone>/bin/elves.ts release — cut a release: a tag and the list of flow changes
|
|
15
|
+
* bun <Elves checkout>/bin/elves.ts pack <tag> — CI on the release tag: the npm package @miroshnik/elves
|
|
16
|
+
* bun --no-env-file node_modules/.bin/elves spec-doc … — a project's package: the skills' scripts at its version
|
|
17
|
+
* elves slot --for <PID> — the machine queue's shim .agents/slot.sh in a project
|
|
18
|
+
*
|
|
19
|
+
* The shebang goes through `sh`: the second line — `exec bun --no-env-file` for sh, a string and a comment for TypeScript
|
|
20
|
+
* — starts Bun without the project's env files however the bin is called (`elves`, `node_modules/.bin/elves`, a
|
|
21
|
+
* package.json script, bunx), and `elves slot '<command>'` doesn't pass their keys on to the command; not `#!/usr/bin/env
|
|
22
|
+
* -S bun --no-env-file` — `env -S` isn't everywhere (busybox) (tests/standards/no-env-file, #592).
|
|
23
|
+
*
|
|
24
|
+
* The layout is as with skills (`npx skills`), on the machine (`-g`, #557): the canon `~/.agents/elves/` (AGENTS.md,
|
|
25
|
+
* claude/CLAUDE.md, docs/*.md) with Claude Code's rules `~/.claude/rules/elves*.md` — they load without the
|
|
26
|
+
* external-imports dialog in every project of the machine — and the global rule files of the other agents linking to it
|
|
27
|
+
* (`agentRules`), skills (`~/.agents/skills/<name>/`, Claude Code — symlinks in `~/.claude/skills`) and the hook. A
|
|
28
|
+
* project is thin (#552): no copy of the flow, the repository holds only `@miroshnik/elves` in devDependencies
|
|
29
|
+
* (PACKAGE: the project's version of the harness and the skills' scripts for its tests and CI), the manifest, the
|
|
30
|
+
* machine queue's shim `.agents/slot.sh`, a block in the project's `AGENTS.md` linking to the canon on the machine and
|
|
31
|
+
* the cloud session's SessionStart hook in `.claude/settings.json` (`cloudHook`: the cloud doesn't read the machine's
|
|
32
|
+
* settings, #610); updating the project is bumping the package version — a few lines in the branch of the current task.
|
|
33
|
+
* A project with a copy of an earlier release loads the canon twice until it moves — the hook names it (`twiceLines`);
|
|
34
|
+
* `install` and `update` move it to a thin one: the copy, its symlinks and `.gitignore` exceptions go (`removeCopy`),
|
|
35
|
+
* the imports and CI paths into the copy lead to the package (`rewriteCopyRefs`), the rest that names `.agents` is
|
|
36
|
+
* listed (`copyLeftovers`).
|
|
37
|
+
* `.agents/elves.json` — on the machine the Elves SHA it was installed from, the list of installed skills (by it a
|
|
38
|
+
* reinstall removes skills Elves no longer has and leaves others' alone), for a machine with `--link` — the clone path;
|
|
39
|
+
* in a project — only `auto`: whether the agent runs a task entirely by itself — answers to questions by
|
|
40
|
+
* recommendation, commit, push, fixing CI and merging its own PR on green checks (AGENTS.md, "What I do without asking
|
|
41
|
+
* and what needs a yes"), `language` — the language the agent works in in the project: dialog, tasks, prompts,
|
|
42
|
+
* documents (AGENTS.md, "Language"; the flow is written in English), and `env` — the task stack settings of the
|
|
43
|
+
* task-env skill. `install` in a project sets auto and language — by a question in the terminal or by
|
|
44
|
+
* the flags `--auto` / `--no-auto` and `--lang <code>`; by default auto is "no", the language is `en`, without a
|
|
45
|
+
* terminal and a flag — the previous value, `update` keeps it. An install of a previous version has no `language`
|
|
46
|
+
* field, and the flow before it worked only in Russian: its language is `ru` (`LEGACY_LANGUAGE` of the github skill's
|
|
47
|
+
* language module), `update` writes it. With auto `install` suggests the Claude Code permission rule for the merge
|
|
48
|
+
* (MERGE_HINT) — it doesn't change the machine's settings. The flow isn't installed into an Elves clone (the canon is
|
|
49
|
+
* at its root): `install` writes a single manifest there, with `auto` and `language`.
|
|
50
|
+
* The project's flow files are committed — the ones the project's git ignores (`.agents/`) the install un-ignores with
|
|
51
|
+
* exceptions in an Elves block at the end of `.gitignore`, `check` reports them as a mismatch (`gitignoreFlow`). A
|
|
52
|
+
* project's compose file `check` holds to the task isolation rules
|
|
53
|
+
* with the task-env skill's command (`composeCheck`): a violation is a mismatch too, `update` doesn't fix it; so is a
|
|
54
|
+
* project with standards or architecture without a `test:spec` script — `check` names the lines (`specScript`); a
|
|
55
|
+
* `test` that runs them a second time next to `test:spec` — an `○` line with `test` without them. The
|
|
56
|
+
* project's checks — the `package.json` scripts `lint`, `typecheck`, `test`, `test:*` — the install wraps in the
|
|
57
|
+
* machine queue, the slot skill (`packageScripts`); if such a script calls turbo and turbo.json doesn't pass the queue
|
|
58
|
+
* variables to tasks, it warns with a ready-made line (`turboEnv`).
|
|
59
|
+
*
|
|
60
|
+
* A copy on the machine without `--link` and a project's package version follow Elves releases — tags vYYYY.MM.DD — not
|
|
61
|
+
* main: the bunx package from main is only a launcher — it finds the latest release and restarts from it by SHA
|
|
62
|
+
* (`toRelease`); the project gets the release's npm version (`ownVersion`). The npm package is a release itself — its
|
|
63
|
+
* version (`npmRelease`): `install` from it installs that release without the network, `check` and `update` restart
|
|
64
|
+
* from the latest release's npm package.
|
|
65
|
+
* `check` is the same install as a dry run: the release package lists what it would change. A machine with `--link`
|
|
66
|
+
* is different: the clone is compared with `origin/main` after `git fetch`, `update` does `git pull --ff-only` and
|
|
67
|
+
* re-creates the links with the code of the updated clone.
|
|
68
|
+
* `update` says what changed — the agent tells the user: a copy and a project — every release after the installed one
|
|
69
|
+
* (a project — after its package version), newest first, with the list of changes its GitHub Release has (one
|
|
70
|
+
* function, `changeItems`, builds both); a `--link` clone — the flow commits it pulled.
|
|
71
|
+
*
|
|
72
|
+
* TypeScript under Bun, like the skills' scripts: bunx runs the package's bin by its shebang, and Bun strips types in
|
|
73
|
+
* node_modules too — so the installer imports the skills' values (the language module, the task-env compose files and
|
|
74
|
+
* proxy name) instead of holding copies. No dependencies, only node: APIs.
|
|
75
|
+
*/
|
|
76
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
77
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, readSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
78
|
+
import os from "node:os";
|
|
79
|
+
import path from "node:path";
|
|
80
|
+
import { isatty } from "node:tty";
|
|
81
|
+
import { fileURLToPath } from "node:url";
|
|
82
|
+
|
|
83
|
+
import { packageManager, specRoots } from "../skills/github/scripts/github.ts";
|
|
84
|
+
import locale from "../skills/github/scripts/locale.ts";
|
|
85
|
+
import { PACKAGE_BUNX, PACKAGE_RUN, packageRun } from "../skills/spec/scripts/package-run.ts";
|
|
86
|
+
import { COMPOSE_FILES, formerProxyName, proxyName } from "../skills/task-env/scripts/task-env.ts";
|
|
87
|
+
|
|
88
|
+
const SRC = fileURLToPath(new URL("..", import.meta.url)).replace(/\/$/, "");
|
|
89
|
+
const SOURCE = "miroshnik/elves";
|
|
90
|
+
/**
|
|
91
|
+
* The npm package: a project gets it in devDependencies — the harness and the skills' scripts at the project's version
|
|
92
|
+
* (`SCRIPTS`, `exports` of package.json); its version is the release tag (`npmVersion`), CI publishes it on the tag.
|
|
93
|
+
*/
|
|
94
|
+
const PACKAGE = "@miroshnik/elves";
|
|
95
|
+
/**
|
|
96
|
+
* The repository's name before the rename to Elves (#544): GitHub redirects it, installs of earlier releases still call
|
|
97
|
+
* it — a SessionStart hook with it is ours (`claudeHook`), a `--link` clone's origin with it moves to SOURCE
|
|
98
|
+
* (`cloneOrigin`), a clone with this package name is a clone (`isElvesClone`).
|
|
99
|
+
*/
|
|
100
|
+
const LEGACY_SOURCE = "miroshnik/ai-dev";
|
|
101
|
+
/**
|
|
102
|
+
* The command the output names — and the canon, the AGENTS.md block, the shim and the `.gitignore` block with it: the
|
|
103
|
+
* npm package through bunx, then the subcommand (#559). npm is reachable both on the machine and in a cloud session,
|
|
104
|
+
* where `github:` is 403 (#555); the package is a release itself (`npmRelease`), `check` and `update` from it go by
|
|
105
|
+
* the latest release. `bunx github:${SOURCE}` — the launcher from main (`toRelease`) — is the fallback.
|
|
106
|
+
*/
|
|
107
|
+
const RUN = `bunx ${PACKAGE}`;
|
|
108
|
+
|
|
109
|
+
/** The install's manifest: `.agents/elves.json`. */
|
|
110
|
+
const MANIFEST = ".agents/elves.json";
|
|
111
|
+
/** The rules canon in a project: a copy of AGENTS.md, claude/CLAUDE.md and docs/*.md. */
|
|
112
|
+
const CANON = ".agents/elves";
|
|
113
|
+
/** Claude Code's rules: symlinks to the canon's AGENTS.md and claude/CLAUDE.md. */
|
|
114
|
+
const RULES = { agents: ".claude/rules/elves.md", claude: ".claude/rules/elves-claude.md" };
|
|
115
|
+
/** The personal configuration directory, from the home directory (tests/standards/config-dir). */
|
|
116
|
+
const CONFIG = ".config/elves";
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The flow's names before the rename to Elves (#545). An install of an earlier release has them: `update` moves each to
|
|
120
|
+
* its new name and replaces a block instead of adding a second one, the skills' scripts read the former manifest and
|
|
121
|
+
* configuration directory until it does, `check` names what is left. A user's variable `update` can't rename — `check
|
|
122
|
+
* -g` names it with the new name; the launcher of an earlier release passes AI_DEV_RELEASE, the hook of an earlier
|
|
123
|
+
* release marks the agent's Bash with AI_DEV_AGENT_SHELL.
|
|
124
|
+
*/
|
|
125
|
+
const FORMER = {
|
|
126
|
+
/** The flow's former name itself — in the output about what is left under it. */
|
|
127
|
+
name: "ai-dev",
|
|
128
|
+
manifest: ".agents/ai-dev.json",
|
|
129
|
+
canon: ".agents/ai-dev",
|
|
130
|
+
rules: [".claude/rules/ai-dev.md", ".claude/rules/ai-dev-claude.md"],
|
|
131
|
+
begin: "<!-- ai-dev:begin",
|
|
132
|
+
end: "<!-- ai-dev:end -->",
|
|
133
|
+
giBegin: "# ai-dev:begin",
|
|
134
|
+
giEnd: "# ai-dev:end",
|
|
135
|
+
config: ".config/ai-dev",
|
|
136
|
+
release: "AI_DEV_RELEASE",
|
|
137
|
+
agentShell: "AI_DEV_AGENT_SHELL",
|
|
138
|
+
/** A user's variables under the former name: the new name → the former one. */
|
|
139
|
+
env: { ELVES_CONFIG_DIR: "AI_DEV_CONFIG_DIR", ELVES_PRIVATE: "AI_DEV_PRIVATE", ELVES_PROXY: "AI_DEV_PROXY", ELVES_PROXY_PORT: "AI_DEV_PROXY_PORT" },
|
|
140
|
+
/** The slot skill's queue variables: runner configs and `turbo.json` of projects pass them until renamed. */
|
|
141
|
+
slotEnv: ["AI_DEV_SLOT", "AI_DEV_SLOT_CPUS"],
|
|
142
|
+
};
|
|
143
|
+
/** What `check` adds to a line about a file or a directory under the former name. */
|
|
144
|
+
const FORMER_NOTE = `the flow's former name (${FORMER.name}) — update moves it`;
|
|
145
|
+
|
|
146
|
+
const BEGIN = "<!-- elves:begin";
|
|
147
|
+
const END = "<!-- elves:end -->";
|
|
148
|
+
/**
|
|
149
|
+
* The block in a project's AGENTS.md: the canon and the skills are on the machine (`install -g`), not in the
|
|
150
|
+
* repository — the agents without the machine's rules (Codex and others without a global rules file) read them by it.
|
|
151
|
+
*/
|
|
152
|
+
const BLOCK = [
|
|
153
|
+
`${BEGIN} — set by \`${RUN} install\`, don't edit by hand -->`,
|
|
154
|
+
`Shared rules — \`~/${CANON}/AGENTS.md\` on the machine (\`${RUN} install -g\`): read them before starting work (references — \`~/${CANON}/docs/\`).`,
|
|
155
|
+
"Skills — `~/.agents/skills/`.",
|
|
156
|
+
END,
|
|
157
|
+
].join("\n");
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Claude Code's SessionStart hook: at the start of a session — `check --hook` (machine and project) and the agent's Bash
|
|
161
|
+
* environment (`writeAgentShell`). Claude Code adds the output of a hook with code 0 to the context, with any other
|
|
162
|
+
* code — doesn't, so the code is always 0: both `check --hook` and the fallback `echo` if bunx didn't start.
|
|
163
|
+
* `--silent` — the first install of the package prints bunx's progress to stderr, and the hook's output goes into the
|
|
164
|
+
* context. The timeout keeps the network from holding up the start; an error doesn't block the session — the "First
|
|
165
|
+
* step of a session" rule in AGENTS.md is the safety net.
|
|
166
|
+
*/
|
|
167
|
+
const CHECK_FALLBACK = 'echo "elves check unavailable (exit $?): work by the current flow and say so"';
|
|
168
|
+
const HOOK_COMMAND = `bunx --silent github:${SOURCE} check --hook 2>&1 || ${CHECK_FALLBACK}`;
|
|
169
|
+
const HOOK_MATCHER = "startup|resume|clear";
|
|
170
|
+
const HOOK: SessionHook = { matcher: HOOK_MATCHER, hooks: [{ type: "command", command: HOOK_COMMAND, timeout: 60 }] };
|
|
171
|
+
|
|
172
|
+
/** A group of Claude Code's SessionStart hooks in a settings.json. */
|
|
173
|
+
interface SessionHook {
|
|
174
|
+
matcher: string;
|
|
175
|
+
hooks: { type: "command"; command: string; timeout: number }[];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Flow files in the clone: what `install` installs from it (a git pathspec). */
|
|
179
|
+
const FLOW = ["AGENTS.md", "claude/CLAUDE.md", ":(glob)docs/*.md", "skills"];
|
|
180
|
+
/** What makes a release necessary: the flow and the installer — projects get its code from the release too. */
|
|
181
|
+
const RELEASED = [...FLOW, "bin"];
|
|
182
|
+
/** Release tag: vYYYY.MM.DD, a same-day patch — vYYYY.MM.DD.N. */
|
|
183
|
+
const RELEASE_TAG = /^v(\d{4})\.(\d{2})\.(\d{2})(?:\.([1-9]\d*))?$/;
|
|
184
|
+
/** Elves releases on GitHub: a release's page is `${RELEASES}/tag/<tag>`. */
|
|
185
|
+
const RELEASES = `https://github.com/${SOURCE}/releases`;
|
|
186
|
+
/** Lines of the change list in `check` output — beyond that, "… and N more". */
|
|
187
|
+
const MAX_LINES = 20;
|
|
188
|
+
/** The compose file check — a command of the task-env skill, from the package `check` runs from. */
|
|
189
|
+
const TASK_ENV = path.join(SRC, "skills/task-env/scripts/task-env.ts");
|
|
190
|
+
/** bun start and `docker compose config` take a fraction of a second; the hook has 60 s for everything. */
|
|
191
|
+
const TASK_ENV_TIMEOUT = 30_000;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Help — `--help` or `-h` anywhere in the arguments (tests/standards/cli-help): a command's synopsis (the first
|
|
195
|
+
* paragraph), what it does and examples; `elves --help` — the synopses of all. Answered before the launcher goes to the
|
|
196
|
+
* release (`toRelease`) — by the launcher's own version: no network, at the cost of lagging behind a newer release. The
|
|
197
|
+
* dispatcher takes only the commands from here (their flags — `FLAGS`) and the skills' scripts (`SCRIPTS`).
|
|
198
|
+
*/
|
|
199
|
+
const HELP: Record<string, string> = {
|
|
200
|
+
install: `elves install [--auto | --no-auto] [--lang <code>]
|
|
201
|
+
elves install -g [--link]
|
|
202
|
+
|
|
203
|
+
The flow of the latest Elves release into the project (the git root or the current directory), thin — without a copy:
|
|
204
|
+
${PACKAGE} of the release in devDependencies of package.json (the project's package manager installs it, the lockfile
|
|
205
|
+
changes too), .agents/elves.json, the machine queue's shim .agents/slot.sh, a block in AGENTS.md linking to the canon
|
|
206
|
+
on the machine and the cloud session's SessionStart hook in .claude/settings.json — in a Claude Code cloud session
|
|
207
|
+
(CLAUDE_CODE_REMOTE=true) it installs a fresh Bun and the flow onto the session's machine and runs check --hook, outside
|
|
208
|
+
it exits at once; the package.json checks (lint, typecheck, test, test:*) go through the shim, the skills' scripts —
|
|
209
|
+
${PACKAGE_RUN} <script>, without the project's env files; on Yarn 2+, which runs the .ts bin by Node, both go through
|
|
210
|
+
the package of the project's version — ${PACKAGE_BUNX} <script>. A project with a copy of the flow
|
|
211
|
+
of an earlier release moves to a thin one: the copy, its symlinks and .gitignore exceptions go, imports and CI paths
|
|
212
|
+
into the copy lead to the package, what else names .agents is listed.
|
|
213
|
+
In a terminal asks whether the agent does tasks entirely by itself — answers by recommendation, commit, push,
|
|
214
|
+
fixing CI, merge on green checks (auto in .agents/elves.json, default — no); without a terminal — the --auto or
|
|
215
|
+
--no-auto flag, without the flag the previous answer stays; with auto prints the Claude Code permission rule for the
|
|
216
|
+
merge — Bash(gh pr merge *) in ~/.claude/settings.json: the owner adds it, install doesn't change the machine's
|
|
217
|
+
settings. --lang <code> — the project language the agent works in: dialog, tasks, prompts, documents (language in
|
|
218
|
+
.agents/elves.json, a BCP 47 code: ru, en, pt-BR); in a terminal — a question, en by default, without the flag and a
|
|
219
|
+
terminal — the previous one. In an Elves clone writes only auto and language, and its cloud hook — with zsh, the flow
|
|
220
|
+
from the clone itself.
|
|
221
|
+
-g — onto the machine: the canon in ~/.agents/elves with Claude Code's rules ~/.claude/rules/elves*.md and the global
|
|
222
|
+
rule files of Codex, Gemini CLI, Copilot, OpenCode and Amp linking to it (someone else's file stays), the skills
|
|
223
|
+
(~/.agents/skills, ~/.claude/skills) and the SessionStart hook; without a terminal too — a cloud session's project hook
|
|
224
|
+
or setup script (CLAUDE_CODE_REMOTE=true) gets the canon and skills without the hook: the cloud doesn't read
|
|
225
|
+
~/.claude/settings.json. From the npm package (npm i -g / bun add -g ${PACKAGE}) — the release of its version, without
|
|
226
|
+
the network. --link — from an Elves clone: symlinks to the clone instead of copies, edits show up immediately.
|
|
227
|
+
|
|
228
|
+
Examples:
|
|
229
|
+
elves install --auto --lang en
|
|
230
|
+
elves install -g`,
|
|
231
|
+
check: `elves check [-g]
|
|
232
|
+
elves check --hook
|
|
233
|
+
|
|
234
|
+
Whether the install is behind, changes nothing: 0 — up to date, 1 — behind, the project's git ignores flow files, its
|
|
235
|
+
compose file breaks task isolation or standards and architecture have no test:spec script, 2 — the check is
|
|
236
|
+
unavailable; the machine's copy and a project's package version are compared with the latest release (from the npm
|
|
237
|
+
package — by restarting from the latest release's package), a --link clone — with origin/main. For a project names the
|
|
238
|
+
mode (auto) and the language, holds the compose file to the task isolation rules (❌ per violation, ○ — not checked: no
|
|
239
|
+
docker compose; the task-env skill), names a test that runs standards and architecture again next to test:spec (○, the
|
|
240
|
+
ready test without them) and warns (!) if a script in slot calls turbo and turbo.json doesn't pass the queue variables
|
|
241
|
+
to tasks (the exit code stays the same); a thin project behind — update in this session's task (the package version in
|
|
242
|
+
package.json and the lockfile), a project with a copy of the flow — the move to a thin project in a task of its own: the
|
|
243
|
+
task new command of the github skill, one task per project. For the machine names the task-env proxy of *.localhost
|
|
244
|
+
addresses — running, not running or running
|
|
245
|
+
under the flow's former name, with the command (with Docker on the machine; the exit code stays the same), the user's
|
|
246
|
+
variables under the former name (AI_DEV_*) with the new one, run from the agent's Bash in Claude Code — a session
|
|
247
|
+
without the agent's Bash environment, for a --link clone run from an Elves clone — flow commits not in a release yet.
|
|
248
|
+
--hook — the machine and the project at once for the Claude Code SessionStart hook: the exit code is always 0, errors
|
|
249
|
+
go to the output; writes the agent's Bash environment to $CLAUDE_ENV_FILE — zsh without errors on a glob without matches
|
|
250
|
+
and on =word, timeout from bin/shims; a project with a copy of the canon next to the machine's rules — the canon loads
|
|
251
|
+
twice: names it and the project's update task that moves it to the thin project.
|
|
252
|
+
|
|
253
|
+
Examples:
|
|
254
|
+
elves check
|
|
255
|
+
elves check -g`,
|
|
256
|
+
update: `elves update [-g]
|
|
257
|
+
|
|
258
|
+
Brings it up to date: a --link clone — git pull --ff-only, the machine's copy — install of the latest release: the
|
|
259
|
+
canon, the skills and the hook; a project — install of the latest release: ${PACKAGE} in package.json and the
|
|
260
|
+
lockfile, a few lines in the branch of the current task; a project with a copy of the flow moves to a thin one (install
|
|
261
|
+
--help); from the npm package — by restarting from the latest release's package. The project's mode and language are
|
|
262
|
+
kept; an install of a previous version without a language gets ru: before the field the flow worked in Russian. In a
|
|
263
|
+
project install and update un-ignore flow files from .gitignore patterns with exceptions at its end and wrap the
|
|
264
|
+
package.json checks (lint, typecheck, test, test:*) in the machine queue slot; prints what changed: every release after
|
|
265
|
+
the installed one, newest first, with the list of changes of its GitHub Release (a --link clone — the flow commits it
|
|
266
|
+
pulled), and the git add of what changed. An install from before the rename to Elves — the ai-dev names
|
|
267
|
+
(.agents/ai-dev.json, .agents/ai-dev/, .claude/rules/ai-dev*.md, the blocks in AGENTS.md and .gitignore,
|
|
268
|
+
~/.config/ai-dev) move to the new ones with the same contents.
|
|
269
|
+
|
|
270
|
+
Examples:
|
|
271
|
+
elves update
|
|
272
|
+
elves update -g`,
|
|
273
|
+
release: `elves release [--dry-run]
|
|
274
|
+
|
|
275
|
+
From an Elves clone: a vYYYY.MM.DD tag on origin/main and a GitHub Release listing the flow changes since the previous
|
|
276
|
+
release; --dry-run — only show them. CI publishes the tag to npm (.github/workflows/npm.yml, pack --help) by trusted
|
|
277
|
+
publishing, without a token; the owner's step once — README, "Installation".
|
|
278
|
+
|
|
279
|
+
Examples:
|
|
280
|
+
elves release --dry-run`,
|
|
281
|
+
pack: `elves pack <tag>
|
|
282
|
+
|
|
283
|
+
From an Elves checkout, in CI on a release tag: the npm package ${PACKAGE} of that release — the version
|
|
284
|
+
from the tag into package.json (vYYYY.MM.DD.N → YYYY.MMDD.N: semver without leading zeros, versions sort as tags), the
|
|
285
|
+
release commit into its gitHead and the modules a project imports (exports of package.json: the harness, the
|
|
286
|
+
architecture model, the ESLint fragment, github) built for Node into dist/; then npm publish by trusted publishing (OIDC,
|
|
287
|
+
no token). Not a release tag — code 2, nothing changes.
|
|
288
|
+
|
|
289
|
+
Examples:
|
|
290
|
+
elves pack v2026.10.15.1`,
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The skills' scripts a project runs at its own Elves version — the package in its devDependencies, without a copy of
|
|
295
|
+
* the flow: `elves spec-doc …` from `node_modules/.bin` runs the script's `main` from this package with the same
|
|
296
|
+
* arguments, in this process; the project's paths (`.agents/elves.json`, `tests/`, the reports) come from the current
|
|
297
|
+
* directory and its git root, as with `bun <copy>/scripts/<script>.ts`. No release launcher (`toRelease`): the version
|
|
298
|
+
* is the project's. Help — the script's own `HELP` under the elves name; `--help` counts where the script reads it: a
|
|
299
|
+
* spec script — before `--`, slot — only as the first argument (the rest is its command). A script module runs its
|
|
300
|
+
* `main` itself only as the entry point (`import.meta.main`).
|
|
301
|
+
*/
|
|
302
|
+
const SCRIPTS: Record<string, { file: string; helpFirst?: boolean }> = {
|
|
303
|
+
...Object.fromEntries(["spec-doc", "spec-diff", "spec-publish", "spec-run", "spec-shard", "spec-claims", "spec-break", "spec-exceptions"].map((name) => [name, { file: `skills/spec/scripts/${name}.ts` }])),
|
|
304
|
+
slot: { file: "skills/slot/scripts/slot.ts", helpFirst: true },
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** A script of SCRIPTS: its help and its entry point. */
|
|
308
|
+
interface Script {
|
|
309
|
+
HELP: string;
|
|
310
|
+
main: (argv: string[]) => number | Promise<number>;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const loadScript = (cmd: string): Promise<Script> => import(path.join(SRC, SCRIPTS[cmd]!.file));
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The package's script in the current project — the form of its package.json: on Yarn 2+ through bunx (`packageRun`).
|
|
317
|
+
* The root — by `.git` up from the current directory, without git: help runs no external commands
|
|
318
|
+
* (tests/standards/cli-help).
|
|
319
|
+
*/
|
|
320
|
+
const projectRun = () => packageRun(packageManager(locale.projectRoot(process.cwd())).berry);
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* A script's help under the elves name: its synopsis and example lines start with the package's script in the project
|
|
324
|
+
* (`projectRun`: `PACKAGE_RUN <command>`, on Yarn 2+ — `PACKAGE_BUNX <command>`) — the agent copies an example as is:
|
|
325
|
+
* the form of package.json and CI and the project's version, while bare `elves` in the agent's shell is the machine's
|
|
326
|
+
* global package, if it's on PATH at all (tests/standards/no-env-file).
|
|
327
|
+
*/
|
|
328
|
+
const scriptHelp = async (cmd: string, run = projectRun()) => (await loadScript(cmd)).HELP.replace(new RegExp(`^( *)${cmd}(?= |$)`, "gm"), `$1${run} ${cmd}`).trimEnd();
|
|
329
|
+
|
|
330
|
+
/** `elves <script> …`: the script's help or its `main` with the arguments; the exit code is the script's. */
|
|
331
|
+
async function runScript(cmd: string, args: string[]): Promise<number> {
|
|
332
|
+
const options = SCRIPTS[cmd]!.helpFirst ? args.slice(0, 1) : args.includes("--") ? args.slice(0, args.indexOf("--")) : args;
|
|
333
|
+
if (options.some((a) => a === "--help" || a === "-h")) return note(await scriptHelp(cmd)), 0;
|
|
334
|
+
return (await loadScript(cmd)).main(args);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** `elves --help`: the synopsis of each command, the scripts' too, and how to run it. */
|
|
338
|
+
async function overview() {
|
|
339
|
+
const synopsis = (h: string) => h.split("\n\n")[0]!.replace(/^/gm, " ");
|
|
340
|
+
const run = projectRun();
|
|
341
|
+
const scripts = await Promise.all(Object.keys(SCRIPTS).map((cmd) => scriptHelp(cmd, run)));
|
|
342
|
+
return [
|
|
343
|
+
"elves — the rules and skills of the Elves flow: install, check, update, release, pack; skill scripts at a project's version",
|
|
344
|
+
"",
|
|
345
|
+
...Object.values(HELP).map(synopsis),
|
|
346
|
+
...scripts.map(synopsis),
|
|
347
|
+
"",
|
|
348
|
+
"elves <command> --help — the command's details and examples",
|
|
349
|
+
`Run: ${RUN} <command>; in a project with ${PACKAGE} in devDependencies — ${run} <command>`,
|
|
350
|
+
].join("\n");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The help the arguments ask for (`--help`, `-h`): of the command they start with, otherwise the overview; none — undefined. */
|
|
354
|
+
async function helpOf(argv: string[]): Promise<string | undefined> {
|
|
355
|
+
if (!argv.some((a) => a === "--help" || a === "-h")) return undefined;
|
|
356
|
+
const words = `${argv.join(" ")} `;
|
|
357
|
+
const cmd = Object.keys(HELP).find((c) => words.startsWith(`${c} `));
|
|
358
|
+
return cmd ? HELP[cmd] : overview();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Dry run (`check`): instead of writing to disk the install collects here what it would change, the flow files the
|
|
363
|
+
* project's git ignores (`❌`, the note has the rule, manual — an exception can't un-ignore it), and warnings about
|
|
364
|
+
* project settings the install doesn't touch (`!`, the note says what to fix); null — it writes.
|
|
365
|
+
*/
|
|
366
|
+
interface Change {
|
|
367
|
+
op: "+" | "-" | "~" | "❌" | "!";
|
|
368
|
+
path: string;
|
|
369
|
+
note?: string;
|
|
370
|
+
manual?: boolean;
|
|
371
|
+
}
|
|
372
|
+
let dry: Change[] | null = null;
|
|
373
|
+
|
|
374
|
+
/** The release tag the install runs from (`toRelease`); null — not a release: a clone or no releases yet. */
|
|
375
|
+
let release: string | null = null;
|
|
376
|
+
|
|
377
|
+
const note = (s: string) => dry || process.stdout.write(s + "\n");
|
|
378
|
+
const warn = (s: string) => dry || process.stderr.write("!! " + s + "\n");
|
|
379
|
+
/** A mismatch the install didn't fix. */
|
|
380
|
+
const fail = (s: string) => dry || process.stderr.write("❌ " + s + "\n");
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* What the install would change: `+` will appear, `-` will be removed, `~` will change, `❌` — git ignores a flow
|
|
384
|
+
* file, `!` — a warning (not falling behind: update won't fix it); a directory ends with `/`.
|
|
385
|
+
*/
|
|
386
|
+
const change = (op: Change["op"], p: string, extra: { note?: string; manual?: boolean } = {}) => dry?.push({ op, path: p, ...extra });
|
|
387
|
+
|
|
388
|
+
/** Paths from the project root the install wrote or removed: `update` names them for the commit. */
|
|
389
|
+
const touched: string[] = [];
|
|
390
|
+
const touch = (rel: string) => void touched.push(rel);
|
|
391
|
+
|
|
392
|
+
function lstat(p: string) {
|
|
393
|
+
try {
|
|
394
|
+
return lstatSync(p);
|
|
395
|
+
} catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function onPath(cmd: string) {
|
|
401
|
+
return (process.env.PATH ?? "").split(path.delimiter).some((d) => d && existsSync(path.join(d, cmd)));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function samePath(a: string, b: string) {
|
|
405
|
+
try {
|
|
406
|
+
return realpathSync(a) === realpathSync(b);
|
|
407
|
+
} catch {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** git in directory dir; an error throws. */
|
|
413
|
+
function git(dir: string, ...args: string[]) {
|
|
414
|
+
return execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** The first stderr line of a failed command. */
|
|
418
|
+
function reason(e: unknown) {
|
|
419
|
+
const err = e as { stderr?: string | Buffer; message?: string };
|
|
420
|
+
return (String(err.stderr ?? "").trim() || String(err.message)).split("\n")[0] ?? "";
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const short = (sha: string | null | undefined) => (sha ? sha.slice(0, 7) : null);
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The Elves SHA the install runs from: for a clone — HEAD; for the bunx package — the SHA of the spec bunx installed it
|
|
427
|
+
* by, `github:miroshnik/elves#<sha>` in the package.json of the bunx directory next to node_modules (bunx unpacks the
|
|
428
|
+
* package from GitHub without `.git`, its `.bun-tag` holds only 7 characters of the SHA). The package under its scope
|
|
429
|
+
* (`node_modules/@miroshnik/elves`) — the spec under the full name. The package from main — no SHA in the spec — null:
|
|
430
|
+
* it is only a launcher (`toRelease`). The npm package (`npmRelease`) — the commit `pack` wrote into its `gitHead`: its
|
|
431
|
+
* spec is a version, `npm i -g` has no package.json above it at all.
|
|
432
|
+
*/
|
|
433
|
+
function sourceSha(src: string) {
|
|
434
|
+
if (existsSync(path.join(src, ".git"))) {
|
|
435
|
+
try {
|
|
436
|
+
return git(src, "rev-parse", "HEAD");
|
|
437
|
+
} catch {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (npmRelease(src)) {
|
|
442
|
+
const head: unknown = ownPackage(src).gitHead;
|
|
443
|
+
return typeof head === "string" && /^[0-9a-f]{40}$/.test(head) ? head : null;
|
|
444
|
+
}
|
|
445
|
+
const scope = path.basename(path.dirname(src)).startsWith("@") ? path.basename(path.dirname(src)) : null;
|
|
446
|
+
const nm = scope ? path.dirname(path.dirname(src)) : path.dirname(src);
|
|
447
|
+
if (path.basename(nm) !== "node_modules") return null;
|
|
448
|
+
const name = scope ? `${scope}/${path.basename(src)}` : path.basename(src);
|
|
449
|
+
try {
|
|
450
|
+
const spec: unknown = JSON.parse(readFileSync(path.join(nm, "..", "package.json"), "utf8")).dependencies?.[name];
|
|
451
|
+
return /#([0-9a-f]{40})$/.exec(String(spec ?? ""))?.[1] ?? null;
|
|
452
|
+
} catch {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The package's own package.json; unreadable — empty. */
|
|
458
|
+
function ownPackage(src: string): { name?: unknown; version?: unknown; gitHead?: unknown } {
|
|
459
|
+
try {
|
|
460
|
+
return JSON.parse(readFileSync(path.join(src, "package.json"), "utf8")) ?? {};
|
|
461
|
+
} catch {
|
|
462
|
+
return {};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* The release of the npm package — `npm i -g`, `bun add -g`, a project's devDependencies: the package is a release
|
|
468
|
+
* itself, its version is the release tag (`npmVersion` back, `releaseTag`). A clone, a package from GitHub (the
|
|
469
|
+
* repository's version 0.0.0 — `pack` sets the release version only in CI) — null.
|
|
470
|
+
*/
|
|
471
|
+
function npmRelease(src: string): string | null {
|
|
472
|
+
if (existsSync(path.join(src, ".git"))) return null;
|
|
473
|
+
const { name, version } = ownPackage(src);
|
|
474
|
+
return name === PACKAGE && typeof version === "string" ? releaseTag(version) : null;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The release tag of an npm version — `npmVersion` back: YYYY.MMDD.N → vYYYY.MM.DD.N, .0 — without a patch; not a
|
|
479
|
+
* release version — null.
|
|
480
|
+
*/
|
|
481
|
+
function releaseTag(version: string): string | null {
|
|
482
|
+
const m = /^(\d{4})\.(\d{3,4})\.(\d+)$/.exec(version);
|
|
483
|
+
if (!m) return null;
|
|
484
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
485
|
+
const md = Number(m[2]);
|
|
486
|
+
const tag = `v${m[1]}.${pad(Math.floor(md / 100))}.${pad(md % 100)}${m[3] === "0" ? "" : `.${m[3]}`}`;
|
|
487
|
+
return npmVersion(tag) === version ? tag : null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Release tags in order: by date, then by patch number; negative — a is older. */
|
|
491
|
+
function compareTags(a: string, b: string) {
|
|
492
|
+
const key = (tag: string) => (RELEASE_TAG.exec(tag) ?? []).slice(1).map((n) => Number(n ?? 0));
|
|
493
|
+
const [ka, kb] = [key(a), key(b)];
|
|
494
|
+
return ka.reduce((r, n, i) => r || n - (kb[i] ?? 0), 0);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const lsRemote = (args: string[], cwd?: string) =>
|
|
498
|
+
execFileSync("git", ["ls-remote", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Elves releases in ascending order — RELEASE_TAG tags from `git ls-remote --tags remote` (by date, then by patch
|
|
502
|
+
* number); for an annotated tag — the commit SHA, not the tag object's. Other tags don't count. A git error throws.
|
|
503
|
+
* @param remote a URL or a remote name in cwd
|
|
504
|
+
*/
|
|
505
|
+
function releases(remote: string, cwd?: string): { tag: string; sha: string }[] {
|
|
506
|
+
const tags = new Map<string, string>();
|
|
507
|
+
for (const line of lsRemote(["--tags", remote], cwd).split("\n")) {
|
|
508
|
+
const [sha, ref = ""] = line.split("\t");
|
|
509
|
+
const m = /^refs\/tags\/([^^]+)(\^\{\})?$/.exec(ref);
|
|
510
|
+
// `tag^{}` comes after the tag: the annotated tag's commit
|
|
511
|
+
if (sha && m?.[1] && RELEASE_TAG.test(m[1]) && (m[2] || !tags.has(m[1]))) tags.set(m[1], sha);
|
|
512
|
+
}
|
|
513
|
+
return [...tags].map(([tag, sha]) => ({ tag, sha })).sort((a, b) => compareTags(a.tag, b.tag));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** The commit of the remote's HEAD — main; an empty repository — null. A git error throws. */
|
|
517
|
+
function mainHead(remote: string) {
|
|
518
|
+
return lsRemote([remote, "HEAD"]).split("\t")[0]?.trim() || null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Where releases and their history come from: ELVES_REPO (a mirror, tests) or GitHub. */
|
|
522
|
+
const repoUrl = () => process.env.ELVES_REPO || `https://github.com/${SOURCE}.git`;
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* A copy follows Elves releases, not main. bunx installs a package by its spec once and then runs it from its cache
|
|
526
|
+
* without the network: the package from main (`bunx github:miroshnik/elves`) stays as old as its first run. So it is
|
|
527
|
+
* only a launcher: it finds the latest release and restarts from it by the full SHA — a SHA doesn't change, the cache
|
|
528
|
+
* doesn't harm it; no releases yet — by the SHA of main's head. A package installed by a SHA (`sourceSha`: the restart
|
|
529
|
+
* or the user's own pin) works itself; ELVES_RELEASE — the tag the launcher restarted to. A clone installs itself as
|
|
530
|
+
* is. Tags come from ELVES_REPO (a mirror, tests) or GitHub. The launcher is as old as its first run, the release may be
|
|
531
|
+
* older than the launcher: the tag goes under the former name too (FORMER.release) — each reads its own.
|
|
532
|
+
* The npm package is a release itself (`npmRelease`): from it `install` installs its own version — no launcher and no
|
|
533
|
+
* network, as a cloud environment's setup script needs it (`github:` there would install from GitHub, not npm, and in
|
|
534
|
+
* the cloud session itself it is 403); `check` and `update` compare with and bring to the latest release —
|
|
535
|
+
* `toLatestNpm`.
|
|
536
|
+
* @returns the restarted process's code; null — work here
|
|
537
|
+
*/
|
|
538
|
+
function toRelease(argv: string[]): number | null {
|
|
539
|
+
if (existsSync(path.join(SRC, ".git"))) return null;
|
|
540
|
+
const own = npmRelease(SRC);
|
|
541
|
+
if (own) return (release = own), argv[0] === "install" ? null : toLatestNpm(own, argv);
|
|
542
|
+
if (sourceSha(SRC)) return (release = process.env.ELVES_RELEASE || process.env[FORMER.release] || null), null;
|
|
543
|
+
let to: { tag?: string; sha: string } | undefined;
|
|
544
|
+
try {
|
|
545
|
+
const head = (sha: string | null) => (sha ? { sha } : undefined);
|
|
546
|
+
to = releases(repoUrl()).at(-1) ?? head(mainHead(repoUrl()));
|
|
547
|
+
} catch (e) {
|
|
548
|
+
throw new Error(`can't find out the latest Elves release — ${reason(e)}`);
|
|
549
|
+
}
|
|
550
|
+
if (!to) return null;
|
|
551
|
+
const env = { ...process.env };
|
|
552
|
+
for (const name of ["ELVES_RELEASE", FORMER.release]) {
|
|
553
|
+
delete env[name];
|
|
554
|
+
if (to.tag) env[name] = to.tag;
|
|
555
|
+
}
|
|
556
|
+
const r = spawnSync("bunx", ["--silent", `github:${SOURCE}#${to.sha}`, ...argv], { stdio: "inherit", env });
|
|
557
|
+
if (r.error) throw new Error(`can't run ${to.tag ? `release ${to.tag}` : `Elves ${short(to.sha)}`} — ${reason(r.error)}`);
|
|
558
|
+
return r.status ?? 2;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* `check` and `update` from the npm package: the latest release — by the tags, as for the launcher (a tag and an npm
|
|
563
|
+
* version are one release, CI publishes on the tag); newer than this package — a restart from its npm package by the
|
|
564
|
+
* exact version, not from GitHub: npm is reachable wherever the package came from. ELVES_RELEASE — the tag the
|
|
565
|
+
* restart went to: the restarted package doesn't look it up a second time. This package is the latest — null.
|
|
566
|
+
* @returns the restarted process's code; null — work here
|
|
567
|
+
*/
|
|
568
|
+
function toLatestNpm(own: string, argv: string[]): number | null {
|
|
569
|
+
if (process.env.ELVES_RELEASE === own) return null;
|
|
570
|
+
let latest: { tag: string } | undefined;
|
|
571
|
+
try {
|
|
572
|
+
latest = releases(repoUrl()).at(-1);
|
|
573
|
+
} catch (e) {
|
|
574
|
+
throw new Error(`can't find out the latest Elves release — ${reason(e)}`);
|
|
575
|
+
}
|
|
576
|
+
if (!latest || compareTags(latest.tag, own) <= 0) return null;
|
|
577
|
+
const env: NodeJS.ProcessEnv = { ...process.env, ELVES_RELEASE: latest.tag };
|
|
578
|
+
delete env[FORMER.release];
|
|
579
|
+
const r = spawnSync("bunx", ["--silent", `${PACKAGE}@${npmVersion(latest.tag)}`, ...argv], { stdio: "inherit", env });
|
|
580
|
+
if (r.error) throw new Error(`can't run release ${latest.tag} — ${reason(r.error)}`);
|
|
581
|
+
return r.status ?? 2;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The symlink dst → target text, as link() writes it. */
|
|
585
|
+
function linkText(target: string, dst: string, root: string) {
|
|
586
|
+
const inRoot = !path.relative(root, target).startsWith("..");
|
|
587
|
+
return inRoot ? path.relative(path.dirname(dst), target) : target;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Symlink dst → target: inside root — relative (it is committed in a project), outside — absolute (a relative one
|
|
592
|
+
* breaks if the path to root goes through a symlink: macOS /var → /private/var). Replaces a symlink and an empty file;
|
|
593
|
+
* someone else's file or directory — a warning. A symlink that already points there stays untouched: an agent's
|
|
594
|
+
* sandbox refuses writes to `.claude/skills`, and an update without a new skill mustn't fail on it.
|
|
595
|
+
*/
|
|
596
|
+
function link(target: string, dst: string, root: string) {
|
|
597
|
+
const st = lstat(dst);
|
|
598
|
+
if (st && !st.isSymbolicLink() && !(st.isFile() && st.size === 0)) {
|
|
599
|
+
return warn(`${path.relative(root, dst)} already exists and isn't a symlink — left as is`), false;
|
|
600
|
+
}
|
|
601
|
+
const text = linkText(target, dst, root);
|
|
602
|
+
const same = Boolean(st?.isSymbolicLink()) && readlinkSync(dst) === text;
|
|
603
|
+
if (dry) {
|
|
604
|
+
if (!same) change(st ? "~" : "+", dst);
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
if (same) return true;
|
|
608
|
+
if (st) rmSync(dst);
|
|
609
|
+
mkdirSync(path.dirname(dst), { recursive: true });
|
|
610
|
+
symlinkSync(text, dst);
|
|
611
|
+
note(`${path.relative(root, dst)} → ${path.relative(root, target)}`);
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Removes a directory or a symlink. */
|
|
616
|
+
const remove = (p: string) => lstat(p) && rmSync(p, { recursive: true, force: true });
|
|
617
|
+
|
|
618
|
+
/** The directory's files (and symlinks) recursively: path from dir → full path. Finder's .DS_Store doesn't count. */
|
|
619
|
+
function walk(dir: string, rel = "", out = new Map<string, string>()) {
|
|
620
|
+
for (const e of readdirSync(path.join(dir, rel), { withFileTypes: true })) {
|
|
621
|
+
if (e.name === ".DS_Store") continue;
|
|
622
|
+
const r = path.join(rel, e.name);
|
|
623
|
+
if (e.isDirectory()) walk(dir, r, out);
|
|
624
|
+
else out.set(r, path.join(dir, r));
|
|
625
|
+
}
|
|
626
|
+
return out;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function sameFile(a: string, b: string) {
|
|
630
|
+
const [sa, sb] = [lstatSync(a), lstatSync(b)];
|
|
631
|
+
if (sa.isSymbolicLink() || sb.isSymbolicLink()) return sa.isSymbolicLink() && sb.isSymbolicLink() && readlinkSync(a) === readlinkSync(b);
|
|
632
|
+
return readFileSync(a).equals(readFileSync(b));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Dry run of a copy: how directory dst differs from the files expected (path in dst → source). */
|
|
636
|
+
function diffTree(expected: Map<string, string>, dst: string) {
|
|
637
|
+
const st = lstat(dst);
|
|
638
|
+
if (!st) return change("+", dst + "/");
|
|
639
|
+
if (!st.isDirectory()) return change("~", dst);
|
|
640
|
+
const actual = walk(dst);
|
|
641
|
+
for (const [rel, src] of expected) {
|
|
642
|
+
const have = actual.get(rel);
|
|
643
|
+
if (!have) change("+", path.join(dst, rel));
|
|
644
|
+
else if (!sameFile(src, have)) change("~", path.join(dst, rel));
|
|
645
|
+
}
|
|
646
|
+
for (const [rel, have] of actual) if (!expected.has(rel)) change("-", have);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Directory dst — a copy of directory src or, in linkMode, a symlink to it; the previous contents of dst go entirely. */
|
|
650
|
+
function placeDir(src: string, dst: string, root: string, linkMode: boolean) {
|
|
651
|
+
if (dry) {
|
|
652
|
+
if (!linkMode) return diffTree(walk(src), dst);
|
|
653
|
+
if (lstat(dst) && !lstat(dst)?.isSymbolicLink()) return change("~", dst + "/");
|
|
654
|
+
return void link(src, dst, root);
|
|
655
|
+
}
|
|
656
|
+
remove(dst);
|
|
657
|
+
if (linkMode) return void link(src, dst, root);
|
|
658
|
+
mkdirSync(path.dirname(dst), { recursive: true });
|
|
659
|
+
cpSync(src, dst, { recursive: true });
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Elves skills: skills/<name> directories with SKILL.md. */
|
|
663
|
+
function sourceSkills(src: string) {
|
|
664
|
+
return readdirSync(path.join(src, "skills"))
|
|
665
|
+
.filter((n) => existsSync(path.join(src, "skills", n, "SKILL.md")))
|
|
666
|
+
.sort();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* A file or a directory of the install under the flow's former name (FORMER) goes: the install writes its new name.
|
|
671
|
+
* `ours` — for a path that may be someone else's: the rules' symlinks go only if they lead into the former canon.
|
|
672
|
+
*/
|
|
673
|
+
function removeFormer(root: string, rel: string, ours = (_: string) => true) {
|
|
674
|
+
const p = path.join(root, rel);
|
|
675
|
+
const st = lstat(p);
|
|
676
|
+
if (!st || !ours(p)) return;
|
|
677
|
+
if (dry) return void change("-", st.isDirectory() ? p + "/" : p, { note: FORMER_NOTE });
|
|
678
|
+
rmSync(p, { recursive: true, force: true });
|
|
679
|
+
touch(rel);
|
|
680
|
+
note(`${rel}${st.isDirectory() ? "/" : ""} — removed: the flow's former name (${FORMER.name})`);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** The rules canon: `.agents/elves/` — a copy of AGENTS.md, claude/CLAUDE.md and docs/*.md or a symlink to the clone. */
|
|
684
|
+
function installRules(src: string, root: string, linkMode: boolean) {
|
|
685
|
+
const canon = path.join(root, CANON);
|
|
686
|
+
removeFormer(root, FORMER.canon);
|
|
687
|
+
if (linkMode) return placeDir(src, canon, root, true);
|
|
688
|
+
const docs = readdirSync(path.join(src, "docs")).filter((f) => f.endsWith(".md") && statSync(path.join(src, "docs", f)).isFile());
|
|
689
|
+
const files = ["AGENTS.md", "claude/CLAUDE.md", ...docs.map((f) => `docs/${f}`)];
|
|
690
|
+
if (dry) return diffTree(new Map(files.map((rel) => [rel, path.join(src, rel)])), canon);
|
|
691
|
+
remove(canon);
|
|
692
|
+
for (const rel of files) {
|
|
693
|
+
mkdirSync(path.dirname(path.join(canon, rel)), { recursive: true });
|
|
694
|
+
cpSync(path.join(src, rel), path.join(canon, rel));
|
|
695
|
+
}
|
|
696
|
+
note(`${path.relative(root, canon)}/ ← AGENTS.md, claude/CLAUDE.md, docs/ (${docs.length})`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** The install's `.agents/elves.json`. */
|
|
700
|
+
interface Manifest {
|
|
701
|
+
source?: string;
|
|
702
|
+
sha?: string;
|
|
703
|
+
tag?: string;
|
|
704
|
+
clone?: string;
|
|
705
|
+
skills?: string[];
|
|
706
|
+
auto?: boolean;
|
|
707
|
+
language?: string;
|
|
708
|
+
env?: unknown;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* The install's manifest: `.agents/elves.json`, until `update` moves it — the manifest of an earlier release under the
|
|
713
|
+
* flow's former name (FORMER.manifest); missing or broken — empty.
|
|
714
|
+
*/
|
|
715
|
+
function readManifest(root: string): Manifest {
|
|
716
|
+
const file = [MANIFEST, FORMER.manifest].map((f) => path.join(root, f)).find((f) => existsSync(f));
|
|
717
|
+
try {
|
|
718
|
+
return file ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
719
|
+
} catch {
|
|
720
|
+
return {};
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Skills: `.agents/skills/<name>` — a copy (or a symlink to the clone), Claude Code — a symlink from `.claude/skills`.
|
|
726
|
+
* Someone else's directory with the same name is left alone; skills from a previous install that Elves no longer
|
|
727
|
+
* has are removed. `.agents/elves.json` holds the source SHA, the release tag and the installed skills — the manifest
|
|
728
|
+
* under the former name (FORMER) is read and moved into it, its fields carried over; a dry run
|
|
729
|
+
* doesn't compare the SHA and the tag: the install is up to date if what it installs matches (a release that changes
|
|
730
|
+
* only the installer doesn't touch projects). auto and language — the project settings "a task entirely without
|
|
731
|
+
* asking" and the language the agent works in, always written; the machine doesn't have them (undefined). `env` —
|
|
732
|
+
* the project's task stack settings (the task-env skill: HTTP services and their ports): the project writes it, the
|
|
733
|
+
* install carries it over. A dry run doesn't compare them either: editing a field by hand doesn't make the install
|
|
734
|
+
* "behind".
|
|
735
|
+
* The order survives a run cut off midway (an agent's sandbox refuses writes to `.claude/skills`): skills Elves no
|
|
736
|
+
* longer has go while the old manifest still lists them, and the new manifest is written before the copies — a repeat
|
|
737
|
+
* run takes them for the flow's and finishes the install instead of leaving them as someone else's.
|
|
738
|
+
* @returns installed skills
|
|
739
|
+
*/
|
|
740
|
+
function installSkills(src: string, root: string, linkMode: boolean, claude: boolean, auto?: boolean, language?: string) {
|
|
741
|
+
const manifestPath = path.join(root, MANIFEST);
|
|
742
|
+
const before = readManifest(root).skills ?? [];
|
|
743
|
+
const names = sourceSkills(src);
|
|
744
|
+
const env = readManifest(root).env;
|
|
745
|
+
const installed = names.filter((name) => {
|
|
746
|
+
const dst = path.join(root, ".agents/skills", name);
|
|
747
|
+
const st = lstat(dst);
|
|
748
|
+
if (!st || st.isSymbolicLink() || before.includes(name)) return true;
|
|
749
|
+
warn(`${path.relative(root, dst)} already exists and isn't an Elves skill — left; remove it to install the Elves skill`);
|
|
750
|
+
return false;
|
|
751
|
+
});
|
|
752
|
+
for (const name of before.filter((n) => !names.includes(n))) {
|
|
753
|
+
const dst = path.join(root, ".agents/skills", name);
|
|
754
|
+
if (dry) {
|
|
755
|
+
if (lstat(dst)) change("-", dst + "/");
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
remove(dst);
|
|
759
|
+
if (lstat(path.join(root, ".claude/skills", name))?.isSymbolicLink()) rmSync(path.join(root, ".claude/skills", name));
|
|
760
|
+
note(`${path.relative(root, dst)} — removed: Elves no longer has it`);
|
|
761
|
+
}
|
|
762
|
+
const clone = linkMode ? path.resolve(src) : undefined; // a --link machine: check and update find the clone by it
|
|
763
|
+
if (dry) {
|
|
764
|
+
const m = readManifest(root);
|
|
765
|
+
if (m.source !== SOURCE || JSON.stringify(m.skills) !== JSON.stringify(installed) || m.clone !== clone || !existsSync(manifestPath)) change(existsSync(manifestPath) ? "~" : "+", manifestPath);
|
|
766
|
+
} else {
|
|
767
|
+
const sha = sourceSha(src);
|
|
768
|
+
const manifest = { source: SOURCE, ...(sha ? { sha } : {}), ...(release ? { tag: release } : {}), ...(clone ? { clone } : {}), skills: installed, ...(auto === undefined ? {} : { auto }), ...(language === undefined ? {} : { language }), ...(env === undefined ? {} : { env }) };
|
|
769
|
+
mkdirSync(path.dirname(manifestPath), { recursive: true });
|
|
770
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
771
|
+
}
|
|
772
|
+
// after the new one is written: a run cut off in between reads the new one
|
|
773
|
+
removeFormer(root, FORMER.manifest);
|
|
774
|
+
for (const name of installed) placeDir(path.join(src, "skills", name), path.join(root, ".agents/skills", name), root, linkMode);
|
|
775
|
+
if (!linkMode) note(`.agents/skills/ ← ${installed.join(", ")}`);
|
|
776
|
+
if (claude) for (const name of installed) link(path.join(root, ".agents/skills", name), path.join(root, ".claude/skills", name), root);
|
|
777
|
+
return installed;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* The machine queue's shim in a project (`slotShim`): a package.json script sources it before its command, in CI too
|
|
782
|
+
* (there the command goes at once, without a queue).
|
|
783
|
+
*/
|
|
784
|
+
const SLOT_SHIM = ".agents/slot.sh";
|
|
785
|
+
/**
|
|
786
|
+
* The shim: `elves slot --for $$` of the project's package, in `if command -v elves` — without elves on PATH (no
|
|
787
|
+
* node_modules yet, a checkout without the package) the command runs as is; elves failed — the script fails with its
|
|
788
|
+
* code, the command doesn't run (#552). The slot skill has no wrapper of its own: its spec runs scripts through this
|
|
789
|
+
* shim (#585).
|
|
790
|
+
*/
|
|
791
|
+
const SLOT_SHIM_TEXT = [
|
|
792
|
+
`# Elves: the machine queue for a package.json check (the slot skill); set by \`${RUN} install\`, don't edit by hand.`,
|
|
793
|
+
`# A script sources it: ". ${SLOT_SHIM} && <command>" — slot takes a slot for the script's own shell ($$), held until`,
|
|
794
|
+
"# the shell exits, and the shell exports the queue variables to the command. elves — from node_modules/.bin of the",
|
|
795
|
+
"# project's package: without it on PATH the command runs as is; elves failed — the script fails with its code.",
|
|
796
|
+
"if command -v elves >/dev/null 2>&1; then",
|
|
797
|
+
" ELVES_SLOT_EXPORT=$(elves slot --for $$) || exit",
|
|
798
|
+
' eval "$ELVES_SLOT_EXPORT"',
|
|
799
|
+
" unset ELVES_SLOT_EXPORT",
|
|
800
|
+
"fi",
|
|
801
|
+
"",
|
|
802
|
+
].join("\n");
|
|
803
|
+
/** A package.json script wrapped in slot: `. SLOT_SHIM && <command>`; an earlier release's copy — its slot.sh. */
|
|
804
|
+
const SLOT_WRAPPED = /^\. \.agents\/(?:skills\/slot\/scripts\/)?slot\.sh && ([^]*)$/;
|
|
805
|
+
/**
|
|
806
|
+
* A script wrapped with the command a string argument — the copy's slot.ts by any runtime (node too; with Bun's env
|
|
807
|
+
* files too — without `--no-env-file`), or the package's slot on Yarn 2+: `PACKAGE_BUNX slot`, before #608 — `elves
|
|
808
|
+
* slot`, which Yarn 2+ ran by Node; tools that read package.json scripts (knip) don't see the command.
|
|
809
|
+
*/
|
|
810
|
+
const SLOT_STRING = new RegExp(String.raw`^(?:(?:node|bun)(?: --no-env-file)? \.agents/skills/slot/scripts/slot\.ts|(?:elves|${locale.escapeRe(PACKAGE_BUNX)}) slot) '((?:[^']|'\\'')*)'$`);
|
|
811
|
+
/** The script's own command, without the slot wrapper of any install. */
|
|
812
|
+
const unslot = (cmd: string) => SLOT_WRAPPED.exec(cmd)?.[1] ?? SLOT_STRING.exec(cmd)?.[1]?.replaceAll("'\\''", "'") ?? cmd;
|
|
813
|
+
/**
|
|
814
|
+
* Yarn 2+ runs package.json scripts in its own shell (`@yarnpkg/shell`) — without `.`, `eval` and `export`, the shim
|
|
815
|
+
* can't be sourced there: the command stays a string argument of the package's slot, hidden from knip, but in the
|
|
816
|
+
* queue. The package there — `PACKAGE_BUNX`: Yarn 2+ runs a `.ts` bin by Node, past its shebang, with any `nodeLinker`,
|
|
817
|
+
* and with PnP there is no `node_modules` (#608). Yarn 2+ or not — the project's package manager by `packageManager` of
|
|
818
|
+
* the github skill (tests/standards/package-manager).
|
|
819
|
+
*/
|
|
820
|
+
const yarnShell = (root: string) => packageManager(root).berry;
|
|
821
|
+
/**
|
|
822
|
+
* Check scripts that go through the machine queue: `lint`, `typecheck`, `test`, `test:*`. Not `build` — hosting and
|
|
823
|
+
* Docker run it, where the shim or the package may be missing; not watch — long-running, it would hold a slot for long.
|
|
824
|
+
*/
|
|
825
|
+
const slotted = (name: string) => /^(?:lint|typecheck|test)$|^test:/.test(name) && !name.includes("watch");
|
|
826
|
+
/** A package.json script's command through the machine queue: `. SLOT_SHIM && <command>`, on Yarn 2+ — `PACKAGE_BUNX slot '<command>'`. */
|
|
827
|
+
const slotWrap = (yarn: boolean) => (cmd: string) => (yarn ? `${PACKAGE_BUNX} slot '${cmd.replaceAll("'", "'\\''")}'` : `. ${SLOT_SHIM} && ${cmd}`);
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* The shim — the same text in every project: an install of an earlier release (a copy) has none, a stale one is
|
|
831
|
+
* rewritten.
|
|
832
|
+
*/
|
|
833
|
+
function slotShim(root: string) {
|
|
834
|
+
const file = path.join(root, SLOT_SHIM);
|
|
835
|
+
const have = existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
836
|
+
if (have === SLOT_SHIM_TEXT) return;
|
|
837
|
+
if (dry) return void change(have === null ? "+" : "~", file);
|
|
838
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
839
|
+
writeFileSync(file, SLOT_SHIM_TEXT);
|
|
840
|
+
touch(SLOT_SHIM);
|
|
841
|
+
note(`${SLOT_SHIM} — the machine queue's shim: the slot script of the project's package`);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* A skill script the flow copy runs — `bun [--no-env-file] .agents/skills/<skill>/scripts/<script>.ts` of SCRIPTS — and
|
|
846
|
+
* the knip reporter of the copy (`knip --reporter …/knip-hints.cjs`, the harness's `report`): the package has them,
|
|
847
|
+
* the copy is gone. The script and its arguments are the same (SCRIPTS).
|
|
848
|
+
*/
|
|
849
|
+
const COPY_SCRIPT = /\bbun(?: --no-env-file)? (?:\.\/)?\.agents\/skills\/(?:spec|slot)\/scripts\/([\w-]+)\.ts\b/g;
|
|
850
|
+
const COPY_KNIP_HINTS = /(?:\.\/)?\.agents\/skills\/spec\/scripts\/knip-hints\.cjs\b/g;
|
|
851
|
+
/** The knip reporter in the project's package: `exports` of package.json don't have it, the file does. */
|
|
852
|
+
const PACKAGE_KNIP_HINTS = `node_modules/${PACKAGE}/skills/spec/scripts/knip-hints.cjs`;
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* A command that ran the copy's scripts — through the package: `<run> <script>`, `PACKAGE_RUN` by default; on Yarn 2+
|
|
856
|
+
* and in a CI job without the project's dependencies — `PACKAGE_BUNX` (`packageRun`, `ciFromCopy`). A script that isn't
|
|
857
|
+
* the package's stays — `copyLeftovers` names it.
|
|
858
|
+
*/
|
|
859
|
+
function fromCopy(text: string, run = PACKAGE_RUN) {
|
|
860
|
+
return text.replace(COPY_SCRIPT, (all, name: string) => (Object.hasOwn(SCRIPTS, name) ? `${run} ${name}` : all)).replace(COPY_KNIP_HINTS, PACKAGE_KNIP_HINTS);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* The project's package.json scripts. Checks — through the machine queue (the slot skill): `slotted` scripts in the
|
|
865
|
+
* root package.json are wrapped in `. SLOT_SHIM && <command>` — any agent and human call them. The script's shell
|
|
866
|
+
* sources the shim: it holds the slot and gets the queue variables, the command stays a plain command of the script —
|
|
867
|
+
* knip sees its binary, and `bun test` gives knip the test files and `preload` as entries; the command gets exactly the
|
|
868
|
+
* environment it would without the wrapper, no env files of Bun. A wrapper of a previous install — the copy's slot.sh,
|
|
869
|
+
* the command a string argument of slot.ts, hidden from knip — is rewritten; on Yarn 2+ the string is the wrapper
|
|
870
|
+
* (`yarnShell`). The copy's scripts in any script lead to the package (`fromCopy`), on Yarn 2+ — through bunx
|
|
871
|
+
* (`packageRun`). The indentation and the trailing newline stay as they were.
|
|
872
|
+
*/
|
|
873
|
+
function packageScripts(root: string) {
|
|
874
|
+
const file = path.join(root, "package.json");
|
|
875
|
+
if (!existsSync(file)) return;
|
|
876
|
+
const text = readFileSync(file, "utf8");
|
|
877
|
+
let pkg: { scripts?: Record<string, unknown> };
|
|
878
|
+
try {
|
|
879
|
+
pkg = JSON.parse(text);
|
|
880
|
+
} catch {
|
|
881
|
+
return warn("package.json — not JSON: the project's checks aren't wrapped in the slot queue");
|
|
882
|
+
}
|
|
883
|
+
const scripts = pkg.scripts;
|
|
884
|
+
if (!scripts || typeof scripts !== "object") return;
|
|
885
|
+
const yarn = yarnShell(root);
|
|
886
|
+
const wrap = slotWrap(yarn);
|
|
887
|
+
const run = packageRun(yarn);
|
|
888
|
+
const outside: string[] = [];
|
|
889
|
+
const previous: string[] = [];
|
|
890
|
+
const copy: string[] = [];
|
|
891
|
+
let hidden = false;
|
|
892
|
+
for (const [name, cmd] of Object.entries(scripts)) {
|
|
893
|
+
if (typeof cmd !== "string") continue;
|
|
894
|
+
const inner = slotted(name) ? unslot(cmd) : cmd;
|
|
895
|
+
const own = fromCopy(inner, run);
|
|
896
|
+
const want = slotted(name) ? wrap(own) : own;
|
|
897
|
+
if (cmd === want) continue;
|
|
898
|
+
scripts[name] = want;
|
|
899
|
+
if (own !== inner) copy.push(name);
|
|
900
|
+
if (slotted(name) && inner === cmd) outside.push(name);
|
|
901
|
+
else if (slotted(name) && wrap(inner) !== cmd) previous.push(name), (hidden ||= SLOT_STRING.test(cmd) && !yarn);
|
|
902
|
+
}
|
|
903
|
+
const changed = [...new Set([...outside, ...previous, ...copy])];
|
|
904
|
+
if (!changed.length) return;
|
|
905
|
+
if (dry) {
|
|
906
|
+
const notes = [
|
|
907
|
+
outside.length && `checks outside the slot queue: ${outside.join(", ")}`,
|
|
908
|
+
previous.length && `the slot wrapper of a previous install${hidden ? " — knip doesn't see the command" : ""}: ${previous.join(", ")}`,
|
|
909
|
+
copy.length && `the flow copy's scripts → ${yarn ? "the package through bunx" : "node_modules/.bin/elves"}: ${copy.join(", ")}`,
|
|
910
|
+
];
|
|
911
|
+
return void change("~", file, { note: notes.filter(Boolean).join("; ") });
|
|
912
|
+
}
|
|
913
|
+
const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? 2;
|
|
914
|
+
writeFileSync(file, JSON.stringify(pkg, null, indent) + (text.endsWith("\n") ? "\n" : ""));
|
|
915
|
+
touch("package.json");
|
|
916
|
+
const wrapped = [...outside, ...previous];
|
|
917
|
+
if (wrapped.length) note(`package.json — checks through the machine queue (slot): ${wrapped.join(", ")}`);
|
|
918
|
+
if (copy.length) note(`package.json — the flow copy's scripts through the package (${run} <script>): ${copy.join(", ")}`);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Queue variables a command in a slot needs: the runner's share of cores and "already in a slot" for a nested script
|
|
923
|
+
* in slot.
|
|
924
|
+
*/
|
|
925
|
+
const SLOT_ENV = ["ELVES_SLOT", "ELVES_SLOT_CPUS"];
|
|
926
|
+
/**
|
|
927
|
+
* The command calls turbo — `turbo run test`, `turbo test`, `npx turbo …` — but not the `--turbo` flag and not the
|
|
928
|
+
* `turbo.json` path.
|
|
929
|
+
*/
|
|
930
|
+
const TURBO = /(?:^|[\s;&|(])turbo(?=\s|$)/;
|
|
931
|
+
/** A JSON string goes as is; outside strings — a JSONC comment, then a trailing comma. */
|
|
932
|
+
const JSON_STRING = String.raw`("(?:[^"\\]|\\.)*")`;
|
|
933
|
+
const JSONC_COMMENT = new RegExp(`${JSON_STRING}|//[^\\n]*|/\\*[^]*?\\*/`, "g");
|
|
934
|
+
const TRAILING_COMMA = new RegExp(`${JSON_STRING}|,(?=\\s*[}\\]])`, "g");
|
|
935
|
+
|
|
936
|
+
/** turbo.json is JSONC: comments and trailing commas go, strings stay as they were. */
|
|
937
|
+
const parseJsonc = (text: string) => JSON.parse(text.replace(JSONC_COMMENT, (_, s) => s ?? "").replace(TRAILING_COMMA, (_, s) => s ?? ""));
|
|
938
|
+
|
|
939
|
+
/** A turbo variable mask: `*` — any characters, the rest — literally. */
|
|
940
|
+
function envMatch(mask: string, name: string) {
|
|
941
|
+
const literal = mask.split("*").map((s) => s.replace(/[.+?^$|()[\]{}\\]/g, "\\$&"));
|
|
942
|
+
return new RegExp(`^${literal.join(".*")}$`).test(name);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* turbo 2 in strict env mode (the default) passes to tasks only the variables named in turbo.json: a script in slot
|
|
947
|
+
* that calls turbo loses `ELVES_SLOT_CPUS` on the way — the runner takes the whole machine — and `ELVES_SLOT` — a
|
|
948
|
+
* nested script in slot waits for its own slot. The pass-through is `globalPassThroughEnv`, not `globalEnv`: it isn't
|
|
949
|
+
* part of the cache hash, and `ELVES_SLOT` is different on every run. The project owns turbo.json, and it may have
|
|
950
|
+
* comments (JSONC) — we don't edit it but warn with a ready-made line; in `check` — a `!` line, not falling behind.
|
|
951
|
+
* turbo.json can't be parsed — we stay silent: turbo itself will parse it. A turbo.json that passes the variables only
|
|
952
|
+
* under the flow's former names (FORMER.slotEnv: slot exports both for now) — the same line, which adds the new names
|
|
953
|
+
* and keeps the former ones: the project's runner configs may still read them.
|
|
954
|
+
*/
|
|
955
|
+
function turboEnv(root: string) {
|
|
956
|
+
let scripts: unknown;
|
|
957
|
+
try {
|
|
958
|
+
scripts = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).scripts;
|
|
959
|
+
} catch {
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (!scripts || typeof scripts !== "object") return;
|
|
963
|
+
const cmds = Object.entries(scripts).flatMap(([name, cmd]) => (slotted(name) && typeof cmd === "string" ? [unslot(cmd)] : []));
|
|
964
|
+
if (!cmds.some((cmd) => TURBO.test(cmd) && !/--env-mode[= ]loose\b/.test(cmd))) return;
|
|
965
|
+
const name = ["turbo.json", "turbo.jsonc"].find((f) => existsSync(path.join(root, f)));
|
|
966
|
+
if (!name) return;
|
|
967
|
+
let cfg;
|
|
968
|
+
try {
|
|
969
|
+
cfg = parseJsonc(readFileSync(path.join(root, name), "utf8"));
|
|
970
|
+
} catch {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (cfg?.envMode === "loose") return;
|
|
974
|
+
const list = (v: unknown) => (Array.isArray(v) ? v.filter((m): m is string => typeof m === "string") : []);
|
|
975
|
+
const masks = [...list(cfg?.globalPassThroughEnv), ...list(cfg?.globalEnv)];
|
|
976
|
+
const negated = (m: string, v: string) => m.startsWith("!") && envMatch(m.slice(1), v);
|
|
977
|
+
const reaches = (v: string) => masks.some((m) => !m.startsWith("!") && envMatch(m, v)) && !masks.some((m) => negated(m, v));
|
|
978
|
+
const missing = SLOT_ENV.filter((v) => !reaches(v));
|
|
979
|
+
if (!missing.length) return;
|
|
980
|
+
const keep = list(cfg?.globalPassThroughEnv).filter((m) => !SLOT_ENV.some((v) => negated(m, v)));
|
|
981
|
+
const line = `"globalPassThroughEnv": [${[...keep, ...missing.filter((v) => !keep.includes(v))].map((m) => JSON.stringify(m)).join(", ")}]`;
|
|
982
|
+
const former = FORMER.slotEnv.filter((v) => reaches(v));
|
|
983
|
+
const why = former.length
|
|
984
|
+
? `${name} passes the queue variables only under the flow's former names ${former.join(", ")} — runner configs and nested scripts in slot that read ${missing.join(", ")} don't get ${missing.length > 1 ? "them" : "it"}`
|
|
985
|
+
: `${missing.join(", ")} ${missing.length > 1 ? "don't reach" : "doesn't reach"} the runner: turbo in strict env mode passes to tasks only the variables named in ${name}`;
|
|
986
|
+
const text = `${why} — put at its root ${line}`;
|
|
987
|
+
if (dry) change("!", path.join(root, name), { note: text });
|
|
988
|
+
else warn(`${name} — ${text}`);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Claude Code: rules from `.claude/rules` load by themselves, whatever the CLAUDE.md. The symlinks under the former
|
|
993
|
+
* name go — Claude Code would load the rules twice; only ours, into the former canon.
|
|
994
|
+
*/
|
|
995
|
+
function claudeRules(root: string) {
|
|
996
|
+
const intoFormer = (p: string) => Boolean(lstat(p)?.isSymbolicLink()) && path.resolve(path.dirname(p), readlinkSync(p)).startsWith(path.join(root, FORMER.canon) + path.sep);
|
|
997
|
+
for (const rel of FORMER.rules) removeFormer(root, rel, intoFormer);
|
|
998
|
+
link(path.join(root, CANON, "AGENTS.md"), path.join(root, RULES.agents), root);
|
|
999
|
+
link(path.join(root, CANON, "claude/CLAUDE.md"), path.join(root, RULES.claude), root);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* A SessionStart hook in `<root>/.claude/settings.json`: the user's own settings and hooks stay in place, a hook of a
|
|
1004
|
+
* previous version (`ours`) is replaced, a repeated install doesn't duplicate it. A dry run names it with `what`.
|
|
1005
|
+
* @returns the file is written
|
|
1006
|
+
*/
|
|
1007
|
+
function sessionHook(root: string, hook: SessionHook, ours: (h: { command?: unknown }) => boolean, what: string): boolean {
|
|
1008
|
+
const file = path.join(root, ".claude/settings.json");
|
|
1009
|
+
let settings: { hooks?: Record<string, { hooks?: { command?: unknown }[] }[]> } = {};
|
|
1010
|
+
if (existsSync(file)) {
|
|
1011
|
+
try {
|
|
1012
|
+
settings = JSON.parse(readFileSync(file, "utf8"));
|
|
1013
|
+
} catch {
|
|
1014
|
+
return warn(`.claude/settings.json — not JSON: ${what} isn't installed, add it yourself: ${hook.hooks[0]!.command}`), false;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const groups = settings.hooks?.SessionStart ?? [];
|
|
1018
|
+
const count = groups.flatMap((g) => g.hooks ?? []).filter(ours).length;
|
|
1019
|
+
if (count === 1 && groups.some((g) => JSON.stringify(g) === JSON.stringify(hook))) return false;
|
|
1020
|
+
if (dry) return change(existsSync(file) ? "~" : "+", file, { note: what }), false;
|
|
1021
|
+
const rest = groups.map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !ours(h)) })).filter((g) => g.hooks.length);
|
|
1022
|
+
settings.hooks = { ...settings.hooks, SessionStart: [...rest, hook] };
|
|
1023
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1024
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
1025
|
+
note(`.claude/settings.json — ${what}`);
|
|
1026
|
+
return true;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* The SessionStart hook in `~/.claude/settings.json` (HOOK): a hook of a previous version (with npx too, with the
|
|
1031
|
+
* repository's name before the rename — LEGACY_SOURCE) is replaced. A dry run names what the hook gives: from a
|
|
1032
|
+
* terminal `check -g` can't see the agent's shell, a missing hook is how it names a missing Bash environment
|
|
1033
|
+
* (tests/capabilities/agent-shell).
|
|
1034
|
+
*/
|
|
1035
|
+
function claudeHook(home: string) {
|
|
1036
|
+
const ours = ({ command }: { command?: unknown }) => typeof command === "string" && [SOURCE, LEGACY_SOURCE].some((repo) => command.includes(`${repo} check --hook`));
|
|
1037
|
+
sessionHook(home, HOOK, ours, "the SessionStart hook: the flow check and the agent's Bash environment");
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* The cloud session's hook in the project's `.claude/settings.json` (`cloudHook`): a hook of a previous version — by
|
|
1042
|
+
* the cloud guard and the flow check in its command — is replaced; the file goes into the commit with the flow.
|
|
1043
|
+
*/
|
|
1044
|
+
function projectHook(root: string, clone: boolean) {
|
|
1045
|
+
const ours = ({ command }: { command?: unknown }) => typeof command === "string" && command.includes("CLAUDE_CODE_REMOTE") && command.includes("check --hook");
|
|
1046
|
+
if (sessionHook(root, cloudHook(clone), ours, "the cloud session's SessionStart hook: Bun, the flow and check --hook")) touch(PROJECT_SETTINGS);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* The block linking to the rules in the project's AGENTS.md: at the start of the file, a repeated install replaces it.
|
|
1051
|
+
* The block under the flow's former name (FORMER) is replaced in its place: the project's text around it stays, and
|
|
1052
|
+
* there is no second block.
|
|
1053
|
+
*/
|
|
1054
|
+
function agentsBlock(root: string) {
|
|
1055
|
+
const file = path.join(root, "AGENTS.md");
|
|
1056
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
1057
|
+
let out = text;
|
|
1058
|
+
let placed = false;
|
|
1059
|
+
for (const [begin, end] of [
|
|
1060
|
+
[BEGIN, END],
|
|
1061
|
+
[FORMER.begin, FORMER.end],
|
|
1062
|
+
] as const) {
|
|
1063
|
+
const b = out.indexOf(begin);
|
|
1064
|
+
const e = out.indexOf(end, b);
|
|
1065
|
+
if (b < 0 || e < 0) continue;
|
|
1066
|
+
// a second block goes with the empty lines after it
|
|
1067
|
+
out = placed ? out.slice(0, b) + out.slice(e + end.length).replace(/^\n+/, "") : out.slice(0, b) + BLOCK + out.slice(e + end.length);
|
|
1068
|
+
placed = true;
|
|
1069
|
+
}
|
|
1070
|
+
if (!placed) out = text ? `${BLOCK}\n\n${text}` : `${BLOCK}\n`;
|
|
1071
|
+
if (out !== text) {
|
|
1072
|
+
const former = text.includes(FORMER.begin) ? { note: `the block under the flow's former name (${FORMER.begin.slice(5)}) — update replaces it` } : {};
|
|
1073
|
+
if (dry) return change(text ? "~" : "+", file, former);
|
|
1074
|
+
writeFileSync(file, out);
|
|
1075
|
+
touch("AGENTS.md");
|
|
1076
|
+
}
|
|
1077
|
+
note(`AGENTS.md — a block linking to ~/${CANON}/AGENTS.md on the machine`);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const GI_BEGIN = "# elves:begin";
|
|
1081
|
+
const GI_END = "# elves:end";
|
|
1082
|
+
const GI_HEAD = `${GI_BEGIN} — flow files git would otherwise ignore; set by \`${RUN} install\`, don't edit by hand`;
|
|
1083
|
+
|
|
1084
|
+
/** `.gitignore` without the Elves block and the block under the flow's former name (FORMER); no block — as is. */
|
|
1085
|
+
function withoutGiBlock(text: string) {
|
|
1086
|
+
let out = text;
|
|
1087
|
+
for (const [begin, end] of [
|
|
1088
|
+
[GI_BEGIN, GI_END],
|
|
1089
|
+
[FORMER.giBegin, FORMER.giEnd],
|
|
1090
|
+
] as const) {
|
|
1091
|
+
const b = out.indexOf(begin);
|
|
1092
|
+
const e = out.indexOf(end, b);
|
|
1093
|
+
if (b < 0 || e < 0) continue;
|
|
1094
|
+
const rest = [out.slice(0, b).replace(/\n+$/, ""), out.slice(e + end.length).replace(/^\n+|\n+$/g, "")].filter(Boolean).join("\n");
|
|
1095
|
+
out = rest ? rest + "\n" : "";
|
|
1096
|
+
}
|
|
1097
|
+
return out;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** The block goes at the end of `.gitignore`: an exception works only if it comes after the ignoring rule. */
|
|
1101
|
+
function withGiBlock(base: string, lines: string[]) {
|
|
1102
|
+
if (!lines.length) return base;
|
|
1103
|
+
const block = [GI_HEAD, ...lines, GI_END].join("\n") + "\n";
|
|
1104
|
+
return base ? base.replace(/\n*$/, "\n\n") + block : block;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** A path as a `.gitignore` pattern: special characters — literally. */
|
|
1108
|
+
const glob = (p: string) => p.replace(/[\\*?[]/g, "\\$&");
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Order of the block's lines: the directory exception `!/a/`, then `/a/*` — the rest of its contents is ignored again,
|
|
1112
|
+
* then deeper exceptions.
|
|
1113
|
+
*/
|
|
1114
|
+
const giKey = (line: string) => line.replace(/^!?\//, "").replace(/\/$/, "/\0").replace(/\/\*$/, "/\x01");
|
|
1115
|
+
|
|
1116
|
+
/** Directories above a path from the root: `a/b/c` → `a`, `a/b`. */
|
|
1117
|
+
const dirsAbove = (p: string) => p.split("/").slice(0, -1).map((_, i, parts) => parts.slice(0, i + 1).join("/"));
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Which of the paths (from the project root) the project's git ignores: path → the rule `<source>:<line> «<pattern>»`.
|
|
1121
|
+
* Git doesn't name tracked ones — rules don't apply to them. local — a rule from the repository's `.gitignore`, not a
|
|
1122
|
+
* personal one (core.excludesFile, `.git/info/exclude`).
|
|
1123
|
+
*/
|
|
1124
|
+
function ignoredBy(root: string, paths: string[]) {
|
|
1125
|
+
const out = new Map<string, { rule: string; local: boolean }>();
|
|
1126
|
+
if (!paths.length) return out;
|
|
1127
|
+
const r = spawnSync("git", ["-C", root, "check-ignore", "-v", "-z", "--stdin"], { input: paths.join("\0") + "\0", encoding: "utf8" });
|
|
1128
|
+
if (r.error) throw r.error;
|
|
1129
|
+
if (r.status !== 0 && r.status !== 1) throw new Error(`git check-ignore — ${String(r.stderr).trim().split("\n")[0]}`);
|
|
1130
|
+
const home = os.homedir();
|
|
1131
|
+
const fields = r.stdout.split("\0");
|
|
1132
|
+
for (let i = 0; i + 3 < fields.length; i += 4) {
|
|
1133
|
+
const [source = "", line = "", pattern = "", p = ""] = fields.slice(i, i + 4);
|
|
1134
|
+
if (pattern.startsWith("!")) continue; // -v names exceptions too: the path isn't ignored
|
|
1135
|
+
const shown = source.startsWith(home + path.sep) ? `~${source.slice(home.length)}` : source;
|
|
1136
|
+
out.set(p, { rule: `${shown}:${line} «${pattern}»`, local: !path.isAbsolute(source) && path.basename(source) === ".gitignore" });
|
|
1137
|
+
}
|
|
1138
|
+
return out;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** The project's Claude Code settings: the cloud session's hook (`projectHook`). */
|
|
1142
|
+
const PROJECT_SETTINGS = ".claude/settings.json";
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* What the install puts into a project and what must go into the commit: the manifest, the shim — without it in the
|
|
1146
|
+
* commit a package.json check fails in CI and another checkout (#558, question 4), — AGENTS.md and the settings with
|
|
1147
|
+
* the cloud hook: a cloud session has only what the repository has.
|
|
1148
|
+
*/
|
|
1149
|
+
function flowEntries(root: string) {
|
|
1150
|
+
return [MANIFEST, SLOT_SHIM, "AGENTS.md", PROJECT_SETTINGS].map((p) => ({ path: p, dir: false })).filter((e) => lstat(path.join(root, e.path))?.isDirectory() === e.dir);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* An exception line for the flow copy's directories — the canon (under the former name too), the skills, their
|
|
1155
|
+
* symlinks in `.claude/rules` and `.claude/skills` — not the thin project's `.agents/elves.json` and the chain of its
|
|
1156
|
+
* `.claude/settings.json` (`!/.claude/`, `/.claude/*`).
|
|
1157
|
+
*/
|
|
1158
|
+
const COPY_EXCEPTION = new RegExp(`/(?:${[CANON, FORMER.canon, ".agents/skills", ".claude/rules", ".claude/skills"].map((p) => p.replaceAll(".", "\\.")).join("|")})/`);
|
|
1159
|
+
|
|
1160
|
+
/** The Elves block of `.gitignore` (or the one under the former name) has exceptions for the flow copy. */
|
|
1161
|
+
const copyGiBlock = (text: string) =>
|
|
1162
|
+
[
|
|
1163
|
+
[GI_BEGIN, GI_END],
|
|
1164
|
+
[FORMER.giBegin, FORMER.giEnd],
|
|
1165
|
+
].some(([begin = "", end = ""]) => {
|
|
1166
|
+
const b = text.indexOf(begin);
|
|
1167
|
+
const e = text.indexOf(end, b);
|
|
1168
|
+
return b >= 0 && e >= 0 && COPY_EXCEPTION.test(text.slice(b, e));
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
interface Ignored {
|
|
1172
|
+
rule: string;
|
|
1173
|
+
personal?: boolean;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* One pass: flow files that git ignores → exception lines and what is ignored (the path — a file or the topmost
|
|
1178
|
+
* ignored directory). A flow directory (`.agents/skills/spec/…/vendor/`) is un-ignored as a whole — everything in it
|
|
1179
|
+
* is ours. A directory above a flow file (`.claude/`) — by the chain `!/.claude/`, `/.claude/*`, `!/.claude/rules/` …
|
|
1180
|
+
* down to the file: the rest of it stays ignored, as before; only for a repository rule — for a personal rule
|
|
1181
|
+
* `/.claude/*` would hide the rest for everyone who doesn't have that rule (personal). The next pass lifts a deeper
|
|
1182
|
+
* pattern.
|
|
1183
|
+
*/
|
|
1184
|
+
function giRound(root: string, entries: { path: string; dir: boolean }[]) {
|
|
1185
|
+
const files = entries.flatMap((e) => (e.dir ? [...walk(path.join(root, e.path)).keys()].map((rel) => `${e.path}/${rel}`) : [e.path]));
|
|
1186
|
+
const ignored = ignoredBy(root, files);
|
|
1187
|
+
const lines: string[] = [];
|
|
1188
|
+
const found = new Map<string, Ignored>();
|
|
1189
|
+
if (!ignored.size) return { lines, found };
|
|
1190
|
+
const dirs = ignoredBy(root, [...new Set([...ignored.keys()].flatMap(dirsAbove))]);
|
|
1191
|
+
for (const [file, own] of ignored) {
|
|
1192
|
+
const entry = entries.find((e) => file === e.path || file.startsWith(e.path + "/"));
|
|
1193
|
+
const above = dirsAbove(file);
|
|
1194
|
+
const top = above.find((d) => dirs.has(d));
|
|
1195
|
+
const rule = top ? dirs.get(top) : undefined;
|
|
1196
|
+
if (!entry || !top || !rule) {
|
|
1197
|
+
lines.push(`!/${glob(file)}`);
|
|
1198
|
+
found.set(file, { rule: own.rule });
|
|
1199
|
+
} else if (top === entry.path || top.startsWith(entry.path + "/")) {
|
|
1200
|
+
lines.push(`!/${glob(top)}/`);
|
|
1201
|
+
found.set(top + "/", { rule: rule.rule });
|
|
1202
|
+
} else if (!rule.local) {
|
|
1203
|
+
found.set(top + "/", { rule: rule.rule, personal: true });
|
|
1204
|
+
} else {
|
|
1205
|
+
for (const d of above.slice(above.indexOf(top), dirsAbove(entry.path).length)) lines.push(`!/${glob(d)}/`, `/${glob(d)}/*`);
|
|
1206
|
+
lines.push(`!/${glob(entry.path)}${entry.dir ? "/" : ""}`);
|
|
1207
|
+
found.set(top + "/", { rule: rule.rule });
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
return { lines, found };
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** Why a path is ignored: the rule and, if an exception can't un-ignore it, what to do. */
|
|
1214
|
+
function ignoredNote(i: Ignored, stuck: boolean) {
|
|
1215
|
+
if (i.personal) return `${i.rule}: the directory is excluded outside the project, an exception in the project's .gitignore would hide the rest of it for everyone — fix the rule`;
|
|
1216
|
+
return stuck ? `${i.rule}: an exception at the end of .gitignore doesn't un-ignore it — fix the rule` : i.rule;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Flow files go into the commit: a file the project's git ignores is invisible to another checkout, CI and a cloud
|
|
1221
|
+
* session — without the shim a package.json check fails there. Exceptions — as an Elves block at the end
|
|
1222
|
+
* of the root `.gitignore` (giRound), rebuilt by every install: passes while new lines appear; not needed — removed,
|
|
1223
|
+
* the exceptions of a flow copy too. What an exception can't un-ignore — `❌`. A dry run only names what is ignored and
|
|
1224
|
+
* a block to rebuild. Not git — nothing to check.
|
|
1225
|
+
* @returns `.gitignore` changed
|
|
1226
|
+
*/
|
|
1227
|
+
function gitignoreFlow(root: string) {
|
|
1228
|
+
try {
|
|
1229
|
+
git(root, "rev-parse", "--git-dir");
|
|
1230
|
+
} catch {
|
|
1231
|
+
return false;
|
|
1232
|
+
}
|
|
1233
|
+
const entries = flowEntries(root);
|
|
1234
|
+
const file = path.join(root, ".gitignore");
|
|
1235
|
+
if (dry) {
|
|
1236
|
+
for (const [p, i] of giRound(root, entries).found) change("❌", path.join(root, p), { note: ignoredNote(i, false), manual: Boolean(i.personal) });
|
|
1237
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
1238
|
+
if (text.includes(FORMER.giBegin)) change("~", file, { note: `the block under the flow's former name (${FORMER.giBegin.slice(2)}) — update replaces it` });
|
|
1239
|
+
else if (copyGiBlock(text)) change("~", file, { note: "the flow copy's exceptions — update removes them" });
|
|
1240
|
+
return false;
|
|
1241
|
+
}
|
|
1242
|
+
const before = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
1243
|
+
const put = (text: string) => {
|
|
1244
|
+
if (text === (existsSync(file) ? readFileSync(file, "utf8") : "")) return;
|
|
1245
|
+
if (text) writeFileSync(file, text);
|
|
1246
|
+
else rmSync(file, { force: true });
|
|
1247
|
+
};
|
|
1248
|
+
const base = withoutGiBlock(before);
|
|
1249
|
+
let lines: string[] = [];
|
|
1250
|
+
const fixed = new Map<string, Ignored>();
|
|
1251
|
+
let stuck = new Map<string, Ignored>();
|
|
1252
|
+
try {
|
|
1253
|
+
put(base);
|
|
1254
|
+
for (let pass = 0; ; pass++) {
|
|
1255
|
+
const r = giRound(root, entries);
|
|
1256
|
+
const fresh = r.lines.filter((l) => !lines.includes(l));
|
|
1257
|
+
if (!fresh.length || pass === 10) {
|
|
1258
|
+
stuck = r.found;
|
|
1259
|
+
break;
|
|
1260
|
+
}
|
|
1261
|
+
for (const [p, i] of r.found) fixed.set(p, i);
|
|
1262
|
+
lines = [...new Set([...lines, ...fresh])].sort((a, b) => (giKey(a) < giKey(b) ? -1 : 1));
|
|
1263
|
+
put(withGiBlock(base, lines));
|
|
1264
|
+
}
|
|
1265
|
+
} catch (e) {
|
|
1266
|
+
put(before);
|
|
1267
|
+
return warn(`.gitignore not checked — ${reason(e)}`), false;
|
|
1268
|
+
}
|
|
1269
|
+
const after = withGiBlock(base, lines);
|
|
1270
|
+
put(after);
|
|
1271
|
+
for (const p of stuck.keys()) fixed.delete(p);
|
|
1272
|
+
if (fixed.size) note(`.gitignore — Elves exceptions: git ignored ${[...fixed].map(([p, i]) => `${p} (${i.rule})`).join(", ")}`);
|
|
1273
|
+
else if (after !== before && !lines.length) note(".gitignore — the Elves exceptions block removed: git doesn't ignore flow files");
|
|
1274
|
+
for (const [p, i] of stuck) fail(`${p} — ${ignoredNote(i, true)}`);
|
|
1275
|
+
if (after !== before) touch(".gitignore");
|
|
1276
|
+
return after !== before;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** The git repository root of the current directory; null — not a repository. */
|
|
1280
|
+
function gitRoot() {
|
|
1281
|
+
try {
|
|
1282
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
1283
|
+
} catch {
|
|
1284
|
+
return null;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** The project root: the git root or the current directory. */
|
|
1289
|
+
function projectRoot() {
|
|
1290
|
+
return gitRoot() ?? process.cwd();
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* An Elves clone: its canon is the root AGENTS.md, the flow needn't be installed into it. The package is PACKAGE; before
|
|
1295
|
+
* it — named after the repository, `elves`, and the repository's name before the rename (LEGACY_SOURCE): a clone or a
|
|
1296
|
+
* branch that hasn't pulled the rename yet is a clone too.
|
|
1297
|
+
*/
|
|
1298
|
+
function isElvesClone(root: string) {
|
|
1299
|
+
try {
|
|
1300
|
+
const name: unknown = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).name;
|
|
1301
|
+
return name === PACKAGE || [SOURCE, LEGACY_SOURCE].some((repo) => repo.split("/")[1] === name);
|
|
1302
|
+
} catch {
|
|
1303
|
+
return false;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** Whether the agent runs a task entirely by itself — `auto` of the project manifest; no field — "no". */
|
|
1308
|
+
const autoOf = (root: string) => readManifest(root).auto === true;
|
|
1309
|
+
|
|
1310
|
+
/** A BCP 47 language code: the language and optional subtags — `ru`, `en`, `pt-BR`, `zh-Hant`. */
|
|
1311
|
+
const LANGUAGE_CODE = locale.LANGUAGE_CODE;
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* The language the agent works in in the project — via the github skill's language module, the same one the skills'
|
|
1315
|
+
* scripts use: `language` of the manifest; a manifest without the field (an install of a previous version) — `ru`, no
|
|
1316
|
+
* manifest — `en`, the flow's language.
|
|
1317
|
+
*/
|
|
1318
|
+
const languageOf = (root: string) => locale.projectLanguage(root);
|
|
1319
|
+
|
|
1320
|
+
/** Lines with the project's mode and language in `check` output: the session start (hook) sees them. */
|
|
1321
|
+
const modeLines = (root: string) => [` tasks: ${autoOf(root) ? "entirely by itself" : "with permission"}`, ` language: ${languageOf(root)}`];
|
|
1322
|
+
|
|
1323
|
+
const CLONE_NOTE = "an Elves clone: the flow isn't installed into it — the canon is at its root";
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* An Elves clone: of the install it gets only the mode and the language, a manifest of two fields, and its cloud hook
|
|
1327
|
+
* (`cloudHook`) — the clone carries it in its commit, the install writes the same; the task stack settings (`env`), if
|
|
1328
|
+
* the clone has them, are carried over as in a project; the manifest under the former name goes.
|
|
1329
|
+
*/
|
|
1330
|
+
function installClone(root: string, auto: boolean, language: string) {
|
|
1331
|
+
const file = path.join(root, MANIFEST);
|
|
1332
|
+
const env = readManifest(root).env;
|
|
1333
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1334
|
+
writeFileSync(file, JSON.stringify({ auto, language, ...(env === undefined ? {} : { env }) }, null, 2) + "\n");
|
|
1335
|
+
removeFormer(root, FORMER.manifest);
|
|
1336
|
+
note(`${MANIFEST} — auto: ${auto}, language: ${language}; ${CLONE_NOTE}`);
|
|
1337
|
+
projectHook(root, true);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* The npm version of this package — the release the install runs from (`toRelease`: the tag the launcher restarted to,
|
|
1342
|
+
* the npm package's own version); a clone and the launcher from main without releases have none (#558, question 5).
|
|
1343
|
+
*/
|
|
1344
|
+
const ownVersion = () => (release ? npmVersion(release) : null);
|
|
1345
|
+
|
|
1346
|
+
/** This package in the output: the release tag, otherwise its SHA. */
|
|
1347
|
+
const ownLabel = () => release ?? short(sourceSha(SRC)) ?? "SHA unknown";
|
|
1348
|
+
|
|
1349
|
+
/** The project's PACKAGE in package.json — devDependencies or dependencies: the spec; none — undefined. */
|
|
1350
|
+
function projectPackage(root: string): string | undefined {
|
|
1351
|
+
try {
|
|
1352
|
+
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
1353
|
+
const spec: unknown = pkg?.devDependencies?.[PACKAGE] ?? pkg?.dependencies?.[PACKAGE];
|
|
1354
|
+
return typeof spec === "string" ? spec : undefined;
|
|
1355
|
+
} catch {
|
|
1356
|
+
return undefined;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/** The project's package version for the output: the release tag of an exact version, otherwise the spec as is. */
|
|
1361
|
+
const shownPackage = (spec: string | undefined) => (spec === undefined ? `without ${PACKAGE}` : (releaseTag(spec) ?? spec));
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* A project with a copy of the flow of an earlier release: the canon `.agents/elves/` (under the former name too) or a
|
|
1365
|
+
* manifest of a copy — the installed skills, the SHA; a thin project's manifest has only the mode, the language, env.
|
|
1366
|
+
*/
|
|
1367
|
+
function isCopy(root: string) {
|
|
1368
|
+
const m = readManifest(root);
|
|
1369
|
+
return [CANON, FORMER.canon, FORMER.manifest].some((p) => lstat(path.join(root, p))) || ["source", "sha", "tag", "skills"].some((k) => k in m);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/** Why the copy goes: the line of `install` and `update` about it. */
|
|
1373
|
+
const COPY_NOTE = "the flow copy — the canon and the skills are on the machine (install -g), the scripts in the package";
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* A copy of the flow of an earlier release goes (#558): the canon `.agents/elves/` (under the former name too), the skills
|
|
1377
|
+
* the manifest lists — without the list (an install before it) the ones Elves has — from `.agents/skills`, the symlinks
|
|
1378
|
+
* of Claude Code to them in `.claude/skills` and to the canon in `.claude/rules`; the project's own skills and files
|
|
1379
|
+
* stay (#558, question 6), emptied directories go. The manifest that lists the skills is rewritten after them
|
|
1380
|
+
* (`projectManifest`): a run cut off midway is finished by a repeat.
|
|
1381
|
+
*/
|
|
1382
|
+
function removeCopy(root: string) {
|
|
1383
|
+
if (!isCopy(root)) return;
|
|
1384
|
+
const listed = readManifest(root).skills;
|
|
1385
|
+
const named = Array.isArray(listed) ? listed.filter((s): s is string => typeof s === "string") : sourceSkills(SRC);
|
|
1386
|
+
const into = (dirs: string[]) => (p: string) => Boolean(lstat(p)?.isSymbolicLink()) && dirs.some((d) => (path.resolve(path.dirname(p), readlinkSync(p)) + path.sep).startsWith(path.join(root, d) + path.sep));
|
|
1387
|
+
const rules = [RULES.agents, RULES.claude, ...FORMER.rules].filter((rel) => into([CANON, FORMER.canon])(path.join(root, rel)));
|
|
1388
|
+
const skills = named.filter((name) => lstat(path.join(root, ".agents/skills", name)));
|
|
1389
|
+
const links = named.filter((name) => into([`.agents/skills/${name}`])(path.join(root, ".claude/skills", name)));
|
|
1390
|
+
const canons = [CANON, FORMER.canon].filter((rel) => lstat(path.join(root, rel)));
|
|
1391
|
+
if (dry) {
|
|
1392
|
+
// the skills — a line per directory, not per skill: the change list is capped, and a copy has a dozen of them
|
|
1393
|
+
for (const rel of rules) change("-", path.join(root, rel));
|
|
1394
|
+
if (skills.length) change("-", path.join(root, ".agents/skills") + "/", { note: `the flow's skills: ${skills.join(", ")}` });
|
|
1395
|
+
if (links.length) change("-", path.join(root, ".claude/skills") + "/", { note: `the symlinks to them: ${links.join(", ")}` });
|
|
1396
|
+
for (const rel of canons) change("-", path.join(root, rel) + "/");
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
const paths = [...rules, ...links.map((n) => `.claude/skills/${n}`), ...skills.map((n) => `.agents/skills/${n}`), ...canons];
|
|
1400
|
+
for (const rel of paths) rmSync(path.join(root, rel), { recursive: true, force: true }), touch(rel);
|
|
1401
|
+
const gone = [...rules, ...(skills.length ? [`.agents/skills/ (${skills.join(", ")})`] : []), ...(links.length ? [".claude/skills/ (their symlinks)"] : []), ...canons.map((rel) => `${rel}/`)];
|
|
1402
|
+
for (const rel of [".agents/skills", ".claude/skills", ".claude/rules", ".claude"]) {
|
|
1403
|
+
const p = path.join(root, rel);
|
|
1404
|
+
if (lstat(p)?.isDirectory() && !readdirSync(p).length) rmSync(p, { recursive: true });
|
|
1405
|
+
}
|
|
1406
|
+
if (gone.length) note(`${gone.join(", ")} — removed: ${COPY_NOTE}`);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/** The fields of a project's manifest; the rest are a copy's (`isCopy`). */
|
|
1410
|
+
const PROJECT_FIELDS = ["auto", "language", "env"];
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* The project's manifest: the mode, the language and the task stack settings (`env`, the task-env skill — the project
|
|
1414
|
+
* writes it, the install carries it over); the fields of a copy go, the manifest under the former name moves into it.
|
|
1415
|
+
* A dry run doesn't compare the values: auto and language edited by hand don't make the install behind.
|
|
1416
|
+
*/
|
|
1417
|
+
function projectManifest(root: string, auto: boolean, language: string) {
|
|
1418
|
+
const file = path.join(root, MANIFEST);
|
|
1419
|
+
const m = readManifest(root);
|
|
1420
|
+
if (dry) {
|
|
1421
|
+
if (!existsSync(file) || Object.keys(m).some((k) => !PROJECT_FIELDS.includes(k))) change(existsSync(file) ? "~" : "+", file);
|
|
1422
|
+
} else {
|
|
1423
|
+
const text = JSON.stringify({ auto, language, ...(m.env === undefined ? {} : { env: m.env }) }, null, 2) + "\n";
|
|
1424
|
+
if (!existsSync(file) || readFileSync(file, "utf8") !== text) {
|
|
1425
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
1426
|
+
writeFileSync(file, text);
|
|
1427
|
+
touch(MANIFEST);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
// after the new one is written: a run cut off in between reads the new one
|
|
1431
|
+
removeFormer(root, FORMER.manifest);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/** How long the project's package manager may install the dependencies: a big project with a cold cache — minutes. */
|
|
1435
|
+
const PM_TIMEOUT = 600_000;
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* The project's version of the package — PACKAGE of this release (`ownVersion`) in devDependencies of package.json, an
|
|
1439
|
+
* exact version (#558, question 1): the harness and the skills' scripts of its tests and CI. The edit keeps the
|
|
1440
|
+
* indentation and the key order, a new key goes to its alphabetical place; in dependencies it stays there. Then the
|
|
1441
|
+
* project's package manager (`packageManager` of the github skill) installs it and updates its lockfile; without a
|
|
1442
|
+
* lockfile — only package.json and a line with the command: the install doesn't create a lockfile the project doesn't
|
|
1443
|
+
* keep. The manager failed (no network, an agent's sandbox) — a warning with the command: package.json is right, the
|
|
1444
|
+
* lockfile follows it. No package.json — nothing to pin: no scripts or tests on JS. No version (a clone, the launcher
|
|
1445
|
+
* from main without releases) — the dependency stays as is, a missing one is a warning (#558, question 5).
|
|
1446
|
+
*/
|
|
1447
|
+
function packageDependency(root: string, version: string | null) {
|
|
1448
|
+
const file = path.join(root, "package.json");
|
|
1449
|
+
if (!existsSync(file)) return;
|
|
1450
|
+
const text = readFileSync(file, "utf8");
|
|
1451
|
+
let pkg: Record<string, unknown>;
|
|
1452
|
+
try {
|
|
1453
|
+
pkg = JSON.parse(text);
|
|
1454
|
+
} catch {
|
|
1455
|
+
return; // packageScripts names it
|
|
1456
|
+
}
|
|
1457
|
+
const object = (v: unknown) => (v && typeof v === "object" ? (v as Record<string, unknown>) : undefined);
|
|
1458
|
+
const section = object(pkg.dependencies)?.[PACKAGE] !== undefined ? "dependencies" : "devDependencies";
|
|
1459
|
+
const deps = object(pkg[section]) ?? {};
|
|
1460
|
+
const have = deps[PACKAGE];
|
|
1461
|
+
if (!version) {
|
|
1462
|
+
if (have === undefined) warn(`package.json — ${PACKAGE} isn't added: this installer isn't a release (a clone or no releases yet) — a release adds it: ${RUN} install`);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
if (have === version) return;
|
|
1466
|
+
const was = typeof have === "string" ? `${have} → ` : "";
|
|
1467
|
+
if (dry) return void change("~", file, { note: `${PACKAGE} ${was || "none → "}${version}` });
|
|
1468
|
+
const entries = Object.entries(deps);
|
|
1469
|
+
const at = entries.findIndex(([name]) => name > PACKAGE);
|
|
1470
|
+
if (have === undefined) entries.splice(at < 0 ? entries.length : at, 0, [PACKAGE, version]);
|
|
1471
|
+
pkg[section] = have === undefined ? Object.fromEntries(entries) : { ...deps, [PACKAGE]: version };
|
|
1472
|
+
const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? 2;
|
|
1473
|
+
writeFileSync(file, JSON.stringify(pkg, null, indent) + (text.endsWith("\n") ? "\n" : ""));
|
|
1474
|
+
touch("package.json");
|
|
1475
|
+
const pm = packageManager(root);
|
|
1476
|
+
const cmd = `${pm.name} install`;
|
|
1477
|
+
if (!pm.lockfile) return note(`package.json — ${PACKAGE} ${was}${version}; no lockfile — install the dependencies: ${cmd}`);
|
|
1478
|
+
const r = spawnSync(pm.name, ["install"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: PM_TIMEOUT });
|
|
1479
|
+
if (r.error || r.status !== 0) {
|
|
1480
|
+
const why = r.error ? r.error.message : (`${r.stderr}\n${r.stdout}`.split("\n").find((l) => /error/i.test(l)) ?? `exit code ${r.status ?? r.signal}`).trim();
|
|
1481
|
+
return warn(`package.json — ${PACKAGE} ${was}${version}, but ${pm.lockfile} isn't updated: ${cmd} — ${why}; run it yourself`);
|
|
1482
|
+
}
|
|
1483
|
+
touch(pm.lockfile);
|
|
1484
|
+
note(`package.json, ${pm.lockfile} — ${PACKAGE} ${was}${version} (${cmd})`);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* Lines of the project's tracked files that git grep finds by args, outside the flow copy and its symlinks (`.agents/`,
|
|
1489
|
+
* `.claude/skills/`, `.claude/rules/` — git greps a symlink's target path): the hook runs check at every session start,
|
|
1490
|
+
* git grep reads the index, not every file. Not git or nothing — none.
|
|
1491
|
+
*/
|
|
1492
|
+
function gitGrep(root: string, args: string[]): string[] {
|
|
1493
|
+
const outside = [".agents", ".claude/skills", ".claude/rules"].map((p) => `:(exclude)${p}`);
|
|
1494
|
+
const r = spawnSync("git", ["-C", root, "grep", "-I", ...args, "--", ".", ...outside], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
1495
|
+
return !r.error && r.status === 0 ? r.stdout.split("\n").filter(Boolean) : [];
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/** Source files whose imports may lead into the copy: JavaScript and TypeScript, configs too. */
|
|
1499
|
+
const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
|
|
1500
|
+
/** A workflow or an action of GitHub Actions: the copy's scripts go there by path. */
|
|
1501
|
+
const CI_FILE = /^\.github\/.*\.ya?ml$/;
|
|
1502
|
+
/** An import of a copy's module the package exports (`exports` of package.json): the harness, the model, the ESLint fragment, github. */
|
|
1503
|
+
const COPY_IMPORT = /(["'`])(?:\.{1,2}\/)*\.agents\/skills\/(spec\/scripts\/(?:harness|architecture|eslint-config)|github\/scripts\/github)(?:\.[cm]?[jt]s)?\1/g;
|
|
1504
|
+
/** The package's export of a copy's module: spec/scripts/harness → @miroshnik/elves/spec/harness, github/scripts/github → @miroshnik/elves/github. */
|
|
1505
|
+
const packageImport = (mod: string) => `${PACKAGE}/${mod.startsWith("spec/") ? mod.replace("/scripts/", "/") : "github"}`;
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* A step that installs the project's dependencies: `bun`, `npm`, `pnpm` or `yarn` with `install`, `ci`, `i`, a bare
|
|
1509
|
+
* `yarn`; a global install (`-g`, `--global`) brings a tool, not them.
|
|
1510
|
+
*/
|
|
1511
|
+
const INSTALL_STEP = /(?:^|[\s;&|(])(?:(?:bun|npm|pnpm|yarn)\s+(?:install|ci|i)(?![\w-])(?![^\n;&|]*\s(?:-g|--global)(?![\w-]))|yarn(?=[ \t]*(?:$|[;&|)]|--(?:frozen-lockfile|immutable)\b)))/m;
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Whether each job of a workflow installs the project's dependencies (`Bun.YAML`): a `run` step does (`INSTALL_STEP`) —
|
|
1515
|
+
* true; a local action (`uses: ./…`) may — null, unknown; otherwise false. Not a workflow (a composite action) or
|
|
1516
|
+
* not YAML — null: every job unknown.
|
|
1517
|
+
*/
|
|
1518
|
+
function jobInstalls(text: string): Map<string, boolean | null> | null {
|
|
1519
|
+
let jobs: unknown;
|
|
1520
|
+
try {
|
|
1521
|
+
jobs = (Bun.YAML.parse(text) as { jobs?: unknown } | null)?.jobs;
|
|
1522
|
+
} catch {
|
|
1523
|
+
return null;
|
|
1524
|
+
}
|
|
1525
|
+
if (!jobs || typeof jobs !== "object") return null;
|
|
1526
|
+
return new Map(
|
|
1527
|
+
Object.entries(jobs).map(([id, job]): [string, boolean | null] => {
|
|
1528
|
+
const steps: unknown = (job as { steps?: unknown } | null)?.steps;
|
|
1529
|
+
const all = (Array.isArray(steps) ? steps : []) as ({ run?: unknown; uses?: unknown } | null)[];
|
|
1530
|
+
if (all.some((s) => typeof s?.run === "string" && INSTALL_STEP.test(s.run))) return [id, true];
|
|
1531
|
+
return [id, all.some((s) => typeof s?.uses === "string" && s.uses.startsWith("./")) ? null : false];
|
|
1532
|
+
}),
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* The job of each line of a workflow — the key at the first indentation under the top-level `jobs:` and the lines to
|
|
1538
|
+
* the next one; outside the jobs — undefined. Lines of block scalars are deeper than the job's key.
|
|
1539
|
+
*/
|
|
1540
|
+
function jobOfLines(lines: string[]): (string | undefined)[] {
|
|
1541
|
+
let inJobs = false;
|
|
1542
|
+
let indent = -1;
|
|
1543
|
+
let job: string | undefined;
|
|
1544
|
+
return lines.map((line) => {
|
|
1545
|
+
const [, lead = "", rest = ""] = /^( *)(.*)$/.exec(line) ?? [];
|
|
1546
|
+
if (rest && !rest.startsWith("#")) {
|
|
1547
|
+
if (!lead) {
|
|
1548
|
+
inJobs = /^["']?jobs["']?\s*:\s*(?:#.*)?$/.test(rest);
|
|
1549
|
+
indent = -1;
|
|
1550
|
+
job = undefined;
|
|
1551
|
+
} else if (inJobs) {
|
|
1552
|
+
if (indent < 0) indent = lead.length;
|
|
1553
|
+
if (lead.length === indent) job = /^(["']?)(.+?)\1\s*:(?:\s|$)/.exec(rest)?.[2];
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
return job;
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* A GitHub Actions file with the copy's scripts — through the package by its job (#597): a job that installs the
|
|
1562
|
+
* project's dependencies — `PACKAGE_RUN`, the form of package.json; one that doesn't (the `spec-publish` and
|
|
1563
|
+
* `spec-diff` jobs of earlier `ci.md`) — `PACKAGE_BUNX`. Unknown — a local action, a composite action, a line outside
|
|
1564
|
+
* the jobs, not YAML — `PACKAGE_BUNX`, which works either way, and the job in `unknown` for a fix by hand (an empty
|
|
1565
|
+
* string — the file itself). On Yarn 2+ (`berry`) — `PACKAGE_BUNX` in every job, the form of its package.json: with PnP
|
|
1566
|
+
* `yarn install` puts no `node_modules/.bin` (#608).
|
|
1567
|
+
*/
|
|
1568
|
+
function ciFromCopy(text: string, berry: boolean) {
|
|
1569
|
+
const installs = jobInstalls(text);
|
|
1570
|
+
const lines = text.split("\n");
|
|
1571
|
+
const jobs = jobOfLines(lines);
|
|
1572
|
+
const unknown = new Set<string>();
|
|
1573
|
+
const out = lines.map((line, i) => {
|
|
1574
|
+
const job = jobs[i];
|
|
1575
|
+
const has = job === undefined ? null : (installs?.get(job) ?? null);
|
|
1576
|
+
const now = fromCopy(line, has && !berry ? PACKAGE_RUN : PACKAGE_BUNX);
|
|
1577
|
+
if (has === null && !berry && now !== fromCopy(line)) unknown.add(job ?? "");
|
|
1578
|
+
return now;
|
|
1579
|
+
});
|
|
1580
|
+
return { out: out.join("\n"), unknown: [...unknown] };
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Files of the project that lead into the copy (#558): imports of the copy's modules in tests and configs — to the
|
|
1585
|
+
* package's exports (`@miroshnik/elves/spec/harness` …), its scripts and the knip reporter in GitHub Actions — through
|
|
1586
|
+
* the package by the job (`ciFromCopy`), a job whether dependencies are installed is unknown — a `!` line;
|
|
1587
|
+
* package.json — `packageScripts`. Only tracked files that name `.agents/skills/`.
|
|
1588
|
+
*/
|
|
1589
|
+
function rewriteCopyRefs(root: string) {
|
|
1590
|
+
const done: string[] = [];
|
|
1591
|
+
const berry = packageManager(root).berry;
|
|
1592
|
+
for (const rel of gitGrep(root, ["-l", "-F", "-e", ".agents/skills/"])) {
|
|
1593
|
+
const ci = CI_FILE.test(rel);
|
|
1594
|
+
if (!ci && !SOURCE_FILE.test(rel)) continue;
|
|
1595
|
+
const file = path.join(root, rel);
|
|
1596
|
+
const text = readFileSync(file, "utf8");
|
|
1597
|
+
const { out, unknown } = ci ? ciFromCopy(text, berry) : { out: text.replace(COPY_IMPORT, (_, q: string, mod: string) => `${q}${packageImport(mod)}${q}`), unknown: [] };
|
|
1598
|
+
if (out === text) continue;
|
|
1599
|
+
if (dry) {
|
|
1600
|
+
const scripts = berry ? "the package through bunx (Yarn 2+)" : "the package: node_modules/.bin/elves, a job without dependencies — bunx";
|
|
1601
|
+
change("~", file, { note: ci ? `the flow copy's scripts → ${scripts}` : `imports of the flow copy → ${PACKAGE}` });
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
writeFileSync(file, out);
|
|
1605
|
+
touch(rel);
|
|
1606
|
+
done.push(rel);
|
|
1607
|
+
for (const job of unknown) {
|
|
1608
|
+
warn(`${rel}${job ? `, job ${job}` : ""} — whether the job installs the project's dependencies is unknown: the flow copy's scripts → ${PACKAGE_BUNX} <script>, it works without them; it installs them — ${PACKAGE_RUN} <script>, fix by hand`);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
if (done.length) note(`${done.join(", ")} — imports and scripts of the flow copy → ${PACKAGE}`);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* A mention of `.agents` that isn't the thin project's own: the manifest, the shim, `~/.agents` and `$HOME/.agents` of
|
|
1616
|
+
* the machine aren't one.
|
|
1617
|
+
*/
|
|
1618
|
+
const COPY_MENTION = /(?<![\w~}-]|~\/|HOME\/|HOME}\/)\.agents(?![\w-])(?!\/(?:elves\.json|slot\.sh)\b)/;
|
|
1619
|
+
|
|
1620
|
+
/**
|
|
1621
|
+
* What still names `.agents` once the copy is gone: exceptions `.agents/**` in linters, formatters and configs aren't
|
|
1622
|
+
* needed anymore, a path into the copy no rule rewrote — a line each for the agent to fix by hand (#558). `.gitignore`
|
|
1623
|
+
* doesn't count: its Elves block is the install's.
|
|
1624
|
+
*/
|
|
1625
|
+
function copyLeftovers(root: string) {
|
|
1626
|
+
const lines = gitGrep(root, ["-n", "-F", "-e", ".agents"]).flatMap((l) => {
|
|
1627
|
+
const m = /^(.+?):(\d+):(.*)$/.exec(l);
|
|
1628
|
+
return m?.[1] && m[1] !== ".gitignore" && COPY_MENTION.test(m[3] ?? "") ? [` ${m[1]}:${m[2]}: ${(m[3] ?? "").trim().slice(0, 120)}`] : [];
|
|
1629
|
+
});
|
|
1630
|
+
if (lines.length) note([`Still naming .agents — the flow copy is gone: exceptions for it in linters, formatters and configs aren't needed anymore, a path into it leads to ${PACKAGE} or a skill on the machine; fix by hand:`, ...capped(lines)].join("\n"));
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
/**
|
|
1634
|
+
* The project's thin flow (#552): a copy of an earlier release goes, then the manifest, the shim, the AGENTS.md block,
|
|
1635
|
+
* the cloud hook (#610), the package.json scripts, the files that led into the copy, the package version and the
|
|
1636
|
+
* `.gitignore` exceptions. The copy goes first — its manifest lists the skills to remove; the package manager runs
|
|
1637
|
+
* after every package.json edit. auto and language — the project's mode and language; only `install` sets them (a flag
|
|
1638
|
+
* or a question in the terminal), `update` and `check` go with the manifest's previous value.
|
|
1639
|
+
*/
|
|
1640
|
+
function installProject(root: string, auto = autoOf(root), language = languageOf(root)) {
|
|
1641
|
+
removeCopy(root);
|
|
1642
|
+
projectManifest(root, auto, language);
|
|
1643
|
+
slotShim(root);
|
|
1644
|
+
agentsBlock(root);
|
|
1645
|
+
projectHook(root, false);
|
|
1646
|
+
packageScripts(root);
|
|
1647
|
+
turboEnv(root);
|
|
1648
|
+
rewriteCopyRefs(root);
|
|
1649
|
+
packageDependency(root, ownVersion());
|
|
1650
|
+
gitignoreFlow(root);
|
|
1651
|
+
if (!dry) copyLeftovers(root);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
/**
|
|
1655
|
+
* Agents' global rule files: the agent is on the machine — its directory in the home directory (or the command on
|
|
1656
|
+
* PATH), the file — the canon's AGENTS.md. Cursor has no such file.
|
|
1657
|
+
*/
|
|
1658
|
+
const AGENT_RULES = [
|
|
1659
|
+
{ dir: ".codex", cmd: "codex", file: ".codex/AGENTS.md" },
|
|
1660
|
+
{ dir: ".gemini", file: ".gemini/GEMINI.md" },
|
|
1661
|
+
{ dir: ".copilot", file: ".copilot/copilot-instructions.md" },
|
|
1662
|
+
{ dir: ".config/opencode", file: ".config/opencode/AGENTS.md" },
|
|
1663
|
+
{ dir: ".config/amp", file: ".config/amp/AGENTS.md" },
|
|
1664
|
+
];
|
|
1665
|
+
|
|
1666
|
+
/**
|
|
1667
|
+
* The other agents get the machine's canon by their global rule file — a symlink to the canon's AGENTS.md, as before
|
|
1668
|
+
* #180: Codex glues it to the project's AGENTS.md, Gemini CLI, Copilot, OpenCode and Amp read it the same way. A file
|
|
1669
|
+
* is ours when it is missing, empty or a symlink into the canon — the current one or under the flow's former name
|
|
1670
|
+
* (FORMER.canon, a previous install); someone else's file or symlink — the user's own rules — stays, with a line
|
|
1671
|
+
* where the canon is.
|
|
1672
|
+
*/
|
|
1673
|
+
function agentRules(home: string, has: (dir: string, cmd?: string) => boolean) {
|
|
1674
|
+
const canons = [CANON, FORMER.canon].map((c) => path.join(home, c) + path.sep);
|
|
1675
|
+
const ours = (file: string) => {
|
|
1676
|
+
const st = lstat(file);
|
|
1677
|
+
if (!st || (st.isFile() && st.size === 0)) return true;
|
|
1678
|
+
return st.isSymbolicLink() && canons.some((c) => path.resolve(path.dirname(file), readlinkSync(file)).startsWith(c));
|
|
1679
|
+
};
|
|
1680
|
+
for (const { dir, cmd, file } of AGENT_RULES) {
|
|
1681
|
+
if (!has(dir, cmd)) continue;
|
|
1682
|
+
if (ours(path.join(home, file))) link(path.join(home, CANON, "AGENTS.md"), path.join(home, file), home);
|
|
1683
|
+
else note(`~/${file} — the user's own rules: left as is; the canon — ~/${CANON}/AGENTS.md`);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* The URL of a GitHub origin on the repository's name before the rename (LEGACY_SOURCE, the name is case-insensitive) —
|
|
1689
|
+
* the same URL on SOURCE: the form, the credentials, the port and `.git` stay; another repository or host — null.
|
|
1690
|
+
*/
|
|
1691
|
+
function renamedOrigin(url: string): string | null {
|
|
1692
|
+
const m = /^(.*?(?:^|[@/])(?:ssh\.)?github\.com(?::\d+)?[:/])([^/]+\/[^/]+?)((?:\.git)?\/?)$/i.exec(url.trim());
|
|
1693
|
+
return m?.[2]?.toLowerCase() === LEGACY_SOURCE ? `${m[1]}${SOURCE}${m[3]}` : null;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* A `--link` clone's origin — on the repository's current name: GitHub redirects git by the old name, but the skills
|
|
1698
|
+
* find the repository and answer "a checkout of repository X?" by the origin URL (tests/standards/origin-repo), and
|
|
1699
|
+
* GraphQL by the old name may not find it. The clone is cloned by the old name — `install -g --link` and `update -g`
|
|
1700
|
+
* (it reinstalls the links) move it, `check -g` names it. `.git/config` is shared with the clone's worktrees, a parallel
|
|
1701
|
+
* git may hold its lock: a failed move is a warning with the command, the install goes on.
|
|
1702
|
+
*/
|
|
1703
|
+
function cloneOrigin(clone: string) {
|
|
1704
|
+
let url;
|
|
1705
|
+
try {
|
|
1706
|
+
// the URL as configured: `remote get-url` would expand the user's insteadOf rules
|
|
1707
|
+
url = git(clone, "config", "--get", "remote.origin.url");
|
|
1708
|
+
} catch {
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
const to = renamedOrigin(url);
|
|
1712
|
+
if (!to) return;
|
|
1713
|
+
if (dry) return void change("~", clone, { note: `origin ${url} → ${to}: the repository is renamed` });
|
|
1714
|
+
try {
|
|
1715
|
+
git(clone, "remote", "set-url", "origin", to);
|
|
1716
|
+
note(`clone ${clone}: origin → ${to} — the repository is renamed`);
|
|
1717
|
+
} catch (e) {
|
|
1718
|
+
warn(`clone ${clone}: origin isn't moved to ${to} — ${reason(e)}; run: git -C ${clone} remote set-url origin ${to}`);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
/** A Claude Code cloud environment (claude.ai/code): its setup script and its sessions have this variable. */
|
|
1723
|
+
const remote = () => process.env.CLAUDE_CODE_REMOTE === "true";
|
|
1724
|
+
|
|
1725
|
+
/**
|
|
1726
|
+
* The machine (#557): the canon `~/.agents/elves/` — a copy or, with `--link`, a symlink to the clone — with Claude
|
|
1727
|
+
* Code's rules `~/.claude/rules/elves*.md` (the same `installRules` and `claudeRules` as in a project: what a previous
|
|
1728
|
+
* install left under the flow's former name moves), the other agents' rule files (`agentRules`), skills and the hook.
|
|
1729
|
+
* Claude Code is on the machine — `~/.claude`, `claude` on PATH or a cloud session: its project hook (`cloudHook`) or
|
|
1730
|
+
* its environment's setup script runs in a fresh home. The cloud doesn't read `~/.claude/settings.json` — no hook there.
|
|
1731
|
+
*/
|
|
1732
|
+
function installGlobal(src: string, linkMode: boolean) {
|
|
1733
|
+
const home = os.homedir();
|
|
1734
|
+
const has = (dir: string, cmd = "") => existsSync(path.join(home, dir)) || (cmd !== "" && onPath(cmd));
|
|
1735
|
+
const claude = has(".claude", "claude") || remote();
|
|
1736
|
+
installRules(src, home, linkMode);
|
|
1737
|
+
installSkills(src, home, linkMode, claude);
|
|
1738
|
+
agentRules(home, has);
|
|
1739
|
+
if (linkMode) cloneOrigin(src);
|
|
1740
|
+
|
|
1741
|
+
if (claude) {
|
|
1742
|
+
claudeRules(home);
|
|
1743
|
+
if (!remote()) claudeHook(home);
|
|
1744
|
+
// install.sh installed ~/.claude/CLAUDE.md as a symlink to claude/CLAUDE.md, and that one imported AGENTS.md
|
|
1745
|
+
const old = path.join(home, ".claude/CLAUDE.md");
|
|
1746
|
+
if (lstat(old)?.isSymbolicLink() && readlinkSync(old).endsWith(path.join("claude", "CLAUDE.md"))) {
|
|
1747
|
+
if (dry) change("-", old);
|
|
1748
|
+
else rmSync(old), note(".claude/CLAUDE.md — the old install.sh symlink removed: the canon comes from ~/.claude/rules");
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
if (has(".cursor")) note(`Cursor: sees the skills in ~/.agents/skills; it has no global rules file — paste ~/${CANON}/AGENTS.md into Settings → Rules → User Rules`);
|
|
1753
|
+
|
|
1754
|
+
// The default location of the personal configuration. ELVES_CONFIG_DIR is deliberately not read here: the
|
|
1755
|
+
// variable overrides the whole directory and needs no symlink — the skills' scripts look where it points
|
|
1756
|
+
// (tests/standards/config-dir).
|
|
1757
|
+
moveConfig(home);
|
|
1758
|
+
const priv = process.env.ELVES_PRIVATE || process.env[FORMER.env.ELVES_PRIVATE];
|
|
1759
|
+
if (priv) {
|
|
1760
|
+
const dst = path.join(home, CONFIG);
|
|
1761
|
+
const st = lstat(dst);
|
|
1762
|
+
if (st && !st.isSymbolicLink()) warn(`~/${CONFIG} already exists and isn't a symlink — move its contents to ${priv} and remove it`);
|
|
1763
|
+
else if (dry) {
|
|
1764
|
+
if (!st || readlinkSync(dst) !== priv) change(st ? "~" : "+", dst);
|
|
1765
|
+
} else {
|
|
1766
|
+
if (st) rmSync(dst);
|
|
1767
|
+
mkdirSync(path.dirname(dst), { recursive: true });
|
|
1768
|
+
symlinkSync(priv, dst);
|
|
1769
|
+
note(`${CONFIG} → ${priv}`);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/**
|
|
1775
|
+
* The personal configuration directory under the flow's former name (FORMER.config in the home directory) moves to
|
|
1776
|
+
* `~/.config/elves`: a directory — whole, a symlink (to a private checkout, `ELVES_PRIVATE`) — the same symlink. In its
|
|
1777
|
+
* place stays a symlink to the new one: the scripts of earlier releases — a project copy `update` hasn't brought yet —
|
|
1778
|
+
* keep the same directory, and the queue, the pins and the registry don't split in two; the skills' scripts read the
|
|
1779
|
+
* former directory until the move (tests/standards/config-dir). Both exist — not merged by the script: a warning with
|
|
1780
|
+
* what to do.
|
|
1781
|
+
*/
|
|
1782
|
+
function moveConfig(home: string) {
|
|
1783
|
+
const from = path.join(home, FORMER.config);
|
|
1784
|
+
const to = path.join(home, CONFIG);
|
|
1785
|
+
const st = lstat(from);
|
|
1786
|
+
if (!st) return;
|
|
1787
|
+
const backLink = path.basename(to);
|
|
1788
|
+
if (st.isSymbolicLink() && path.resolve(path.dirname(from), readlinkSync(from)) === to) return;
|
|
1789
|
+
if (lstat(to)) {
|
|
1790
|
+
const text = `~/${FORMER.config} and ~/${CONFIG} both exist — move what ~/${FORMER.config} has into ~/${CONFIG} yourself and replace it with a symlink: ln -sfn ${backLink} ~/${FORMER.config}`;
|
|
1791
|
+
if (dry) return void change("!", from, { note: text });
|
|
1792
|
+
return void warn(text);
|
|
1793
|
+
}
|
|
1794
|
+
if (dry) return void change("~", from + (st.isDirectory() ? "/" : ""), { note: `${FORMER_NOTE} to ~/${CONFIG}` });
|
|
1795
|
+
if (st.isSymbolicLink()) symlinkSync(readlinkSync(from), to), rmSync(from);
|
|
1796
|
+
else renameSync(from, to);
|
|
1797
|
+
symlinkSync(backLink, from);
|
|
1798
|
+
note(`${FORMER.config} → ${CONFIG}: the flow's former name; ${FORMER.config} stays a symlink to it for the scripts of earlier releases`);
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/** A dry-run install: what it would change. */
|
|
1802
|
+
function dryRun(fn: () => void) {
|
|
1803
|
+
dry = [];
|
|
1804
|
+
try {
|
|
1805
|
+
fn();
|
|
1806
|
+
return dry;
|
|
1807
|
+
} finally {
|
|
1808
|
+
dry = null;
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/** List lines, capped at MAX_LINES. */
|
|
1813
|
+
function capped(lines: string[]) {
|
|
1814
|
+
return lines.length > MAX_LINES ? [...lines.slice(0, MAX_LINES), ` … and ${lines.length - MAX_LINES} more`] : lines;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* Dry-run changes as ` ~ path` lines; on the machine the path is from `~/`, outside the home directory (a `--link`
|
|
1819
|
+
* clone) — absolute.
|
|
1820
|
+
*/
|
|
1821
|
+
function changeLines(changes: Change[], root: string, global: boolean) {
|
|
1822
|
+
const shown = (p: string) => {
|
|
1823
|
+
const rel = path.relative(root, p);
|
|
1824
|
+
return !global ? rel : rel.startsWith("..") ? path.resolve(p) : `~/${rel}`;
|
|
1825
|
+
};
|
|
1826
|
+
return changes.map((c) => ` ${c.op} ${shown(c.path)}${c.path.endsWith("/") ? "/" : ""}${c.note ? ` — ${c.note}` : ""}`);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* behind — falls behind: update will install something new; copy — the project keeps a copy of the flow: update moves
|
|
1831
|
+
* it to a thin one, in a task of its own; ignored — the project's git ignores flow files: update
|
|
1832
|
+
* will add exceptions; compose — the project's compose file breaks task isolation: update won't fix it; spec — the
|
|
1833
|
+
* project has standards or architecture without a `test:spec` script: update won't add it; run — the text names a
|
|
1834
|
+
* command to run.
|
|
1835
|
+
*/
|
|
1836
|
+
interface Result {
|
|
1837
|
+
code: 0 | 1 | 2;
|
|
1838
|
+
text: string;
|
|
1839
|
+
absent?: boolean;
|
|
1840
|
+
behind?: boolean;
|
|
1841
|
+
copy?: boolean;
|
|
1842
|
+
ignored?: boolean;
|
|
1843
|
+
compose?: boolean;
|
|
1844
|
+
spec?: boolean;
|
|
1845
|
+
run?: boolean;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
/**
|
|
1849
|
+
* The project's compose file against the task isolation rules (the task-env skill, tests/capabilities/task-env): a `❌`
|
|
1850
|
+
* line per violation, `○` — not checked (no docker compose, the config doesn't parse): that isn't a violation, and the
|
|
1851
|
+
* exit code of `check` doesn't change. No compose file — no lines, the script doesn't start. The script runs under the
|
|
1852
|
+
* same Bun as the installer.
|
|
1853
|
+
*/
|
|
1854
|
+
function composeCheck(root: string): { lines: string[]; violations: boolean } {
|
|
1855
|
+
const file = COMPOSE_FILES.find((f) => existsSync(path.join(root, f)));
|
|
1856
|
+
if (!file) return { lines: [], violations: false };
|
|
1857
|
+
const r = spawnSync(process.execPath, ["--no-env-file", TASK_ENV, "check", root], { encoding: "utf8", timeout: TASK_ENV_TIMEOUT });
|
|
1858
|
+
const notChecked = (why: string) => ({ lines: [` ○ ${file}: not checked — ${why}`], violations: false });
|
|
1859
|
+
if (r.error) return notChecked(`task-env: ${r.error.message}`);
|
|
1860
|
+
if (r.status === 0) return { lines: [], violations: false };
|
|
1861
|
+
const lines = r.stdout.split("\n").filter(Boolean).map((l) => ` ${l}`);
|
|
1862
|
+
if (r.status === 1 || (r.status === 2 && lines.length)) return { lines, violations: r.status === 1 };
|
|
1863
|
+
return notChecked(`task-env: ${r.stderr.trim().split("\n")[0] || `exit code ${r.status ?? r.signal}`}`);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
/**
|
|
1867
|
+
* A project with a copy moves to a thin one in a task of its own, one per project: a task session doesn't take it —
|
|
1868
|
+
* the copy's thousands of lines go, imports and CI change. A thin project's update is a few lines — the session's own
|
|
1869
|
+
* task does it. The title is constant, in the project language (the `updateTask` key of this package's github skill
|
|
1870
|
+
* dictionary): `task new` refuses a second open task with the same title and names the open one — an open update task
|
|
1871
|
+
* of the copy is the same work (#558, question 3). The script — not the project's copy: the line shows exactly when
|
|
1872
|
+
* the project has one, and its script may not know the flags printed here (`--branch-type` — since v2026.10.11, #431).
|
|
1873
|
+
*/
|
|
1874
|
+
const updateTaskLine = (root: string) =>
|
|
1875
|
+
`The task — one per project, an open one is named by the refusal: bun --no-env-file ${githubScript(false)} task new --title "${locale.names(languageOf(root)).updateTask}" --branch-type chore`;
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Test runners of a package.json command — called directly or by the package manager: the `test:spec` command over the
|
|
1879
|
+
* spec roots and the filter that excludes a root from `test` (Vitest `--exclude` adds to the config's; Jest's
|
|
1880
|
+
* `--testPathIgnorePatterns` replaces the config's list; `bun test --path-ignore-patterns` repeats).
|
|
1881
|
+
*/
|
|
1882
|
+
const RUNNERS = {
|
|
1883
|
+
vitest: { re: /^(?:(?:npx|bunx|yarn|pnpm(?: exec)?) )?vitest\b/, spec: "vitest run", exclude: (dir: string) => `--exclude '${dir}/**'` },
|
|
1884
|
+
jest: { re: /^(?:(?:npx|bunx|yarn|pnpm(?: exec)?) )?jest\b/, spec: "jest", exclude: (dir: string) => `--testPathIgnorePatterns=${dir}/` },
|
|
1885
|
+
bun: { re: /^bun test\b/, spec: "bun test", exclude: (dir: string) => `--path-ignore-patterns='${dir}/**'` },
|
|
1886
|
+
};
|
|
1887
|
+
|
|
1888
|
+
/**
|
|
1889
|
+
* The project's fast checks: with standards or architecture (`specRoots` of the github skill) the root package.json
|
|
1890
|
+
* needs a `test:spec` script — without it `github pr premerge` checks the merge with a moved main by typecheck alone
|
|
1891
|
+
* or by nothing, and a standard broken by two green PRs reaches main (#442). A mismatch that update doesn't fix — the
|
|
1892
|
+
* commands are the project's: a `❌` line with the ready scripts, in the slot wrapper `packageScripts` puts (its
|
|
1893
|
+
* `slotWrap`). `test:spec` — the runner of the `test` command, otherwise by the dependencies (Vitest, Jest, `bun test`
|
|
1894
|
+
* in a project on Bun — by `packageManager` of the github skill, otherwise Vitest — the spec skill's example), over the
|
|
1895
|
+
* roots the project has; next to it — `test` with the same roots excluded by the runner's filter, added to the command
|
|
1896
|
+
* under the wrapper (`unslot`): the roots run once, in `test:spec` (the spec skill, "Wiring into a repository").
|
|
1897
|
+
* `test` doesn't run them (names them already) or there is none — `test:spec` alone; `test` isn't a single runner call
|
|
1898
|
+
* — the exclusion in words.
|
|
1899
|
+
* With `test:spec` in place, a `test` that is a single runner call and doesn't name a root runs it a second time — an
|
|
1900
|
+
* `○` line with the same ready `test` (#487): extra time, not a hole in the check, so neither the exit code nor
|
|
1901
|
+
* `missing`. Any other `test` (turbo, a chain) — no line: its text doesn't tell what it runs. An exclusion in the
|
|
1902
|
+
* runner's config isn't seen in the text either — accepted noise: the ready line puts the exclusion in the script.
|
|
1903
|
+
*/
|
|
1904
|
+
function specScript(root: string): { lines: string[]; missing: boolean } {
|
|
1905
|
+
const roots = specRoots(root);
|
|
1906
|
+
let pkg: { scripts?: Record<string, unknown>; dependencies?: object; devDependencies?: object } = {};
|
|
1907
|
+
try {
|
|
1908
|
+
pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) ?? {};
|
|
1909
|
+
} catch {
|
|
1910
|
+
// no package.json or not JSON — no scripts
|
|
1911
|
+
}
|
|
1912
|
+
if (!roots.length) return { lines: [], missing: false };
|
|
1913
|
+
const test = typeof pkg.scripts?.test === "string" ? unslot(pkg.scripts.test) : undefined;
|
|
1914
|
+
const pm = packageManager(root);
|
|
1915
|
+
const byTest = Object.values(RUNNERS).find((r) => test !== undefined && r.re.test(test));
|
|
1916
|
+
// the install wraps checks in the shim — a copy too, when update moves it
|
|
1917
|
+
const wrap = slotWrap(pm.berry);
|
|
1918
|
+
// the roots test runs too — they would run twice, in test and in test:spec
|
|
1919
|
+
const twice = test === undefined ? [] : roots.filter((dir) => !test.includes(dir));
|
|
1920
|
+
// a filter goes to a single runner call only: after `&&` or a pipe it would reach another command
|
|
1921
|
+
const filtered = test !== undefined && byTest && twice.length && !/[;&|<>`$()]/.test(test) ? `"test": ${JSON.stringify(wrap([test, ...twice.map(byTest.exclude)].join(" ")))}` : undefined;
|
|
1922
|
+
if (pkg.scripts?.["test:spec"]) return { lines: filtered ? [` ○ package.json: test repeats test:spec — runs ${twice.join(", ")} a second time; put in "scripts": ${filtered}`] : [], missing: false };
|
|
1923
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1924
|
+
const runner = byTest ?? ("vitest" in deps ? RUNNERS.vitest : "jest" in deps ? RUNNERS.jest : pm.name === "bun" ? RUNNERS.bun : RUNNERS.vitest);
|
|
1925
|
+
const spec = `"test:spec": ${JSON.stringify(wrap([runner.spec, ...roots].join(" ")))}`;
|
|
1926
|
+
const lead = ` ❌ package.json: no test:spec script — pr premerge doesn't run ${roots.join(", ")} on the merge with a moved main`;
|
|
1927
|
+
if (!twice.length) return { lines: [`${lead}; add to "scripts": ${spec}`], missing: true };
|
|
1928
|
+
if (!filtered) return { lines: [`${lead}; add to "scripts": ${spec}; exclude ${twice.join(", ")} from test with the runner's filter — they don't run twice`], missing: true };
|
|
1929
|
+
return { lines: [`${lead}; put in "scripts" — test without them, they don't run twice: ${spec}, ${filtered}`], missing: true };
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/** The check failed: what and why. */
|
|
1933
|
+
const unavailable = (global: boolean, e: unknown): Result => ({ code: 2, text: `Elves ${global ? "on the machine" : "in the project"}: check unavailable — ${reason(e)}` });
|
|
1934
|
+
|
|
1935
|
+
/**
|
|
1936
|
+
* The clone a `--link` install on the machine refers to: the path from the manifest; for an install of a previous
|
|
1937
|
+
* version — the symlink of the former canon in the home directory (FORMER.canon; `update -g` removes it and writes the
|
|
1938
|
+
* path). null — a copy or not installed.
|
|
1939
|
+
*/
|
|
1940
|
+
function linkedClone(home: string) {
|
|
1941
|
+
const m = readManifest(home);
|
|
1942
|
+
if (typeof m.clone === "string") return m.clone;
|
|
1943
|
+
const canon = path.join(home, FORMER.canon);
|
|
1944
|
+
return lstat(canon)?.isSymbolicLink() ? path.resolve(path.dirname(canon), readlinkSync(canon)) : null;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/** The install on the machine (global) or in the project: the root and whether the flow is there. */
|
|
1948
|
+
function target(global: boolean) {
|
|
1949
|
+
const home = os.homedir();
|
|
1950
|
+
const root = global ? home : projectRoot();
|
|
1951
|
+
// the home directory is the machine's install, not a project; an Elves clone is the canon itself: its manifest
|
|
1952
|
+
// keeps only the mode. An install of an earlier release — by the former names (FORMER)
|
|
1953
|
+
const installed = [CANON, MANIFEST, FORMER.canon, FORMER.manifest].some((p) => lstat(path.join(root, p))) && (global || (!samePath(root, home) && !isElvesClone(root)));
|
|
1954
|
+
return { home, root, installed };
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
/** What a cloud session's environment needs (docs/cloud-sessions.md): Bun not older than the project's, zsh. */
|
|
1958
|
+
const CLOUD_BUN = "npm i -g bun@latest";
|
|
1959
|
+
// the image has no apt lists (2026-10-17: "Unable to locate package zsh") and its own /etc/zsh/zshrc: without
|
|
1960
|
+
// --force-confold dpkg asks about it and fails without a terminal
|
|
1961
|
+
const CLOUD_ZSH = "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::=--force-confold zsh";
|
|
1962
|
+
/** A thin project's flow on the cloud session's machine — the npm package, the latest release. */
|
|
1963
|
+
const CLOUD_FLOW = `${CLOUD_BUN} ${PACKAGE} && elves install -g`;
|
|
1964
|
+
|
|
1965
|
+
/** A hook step: quiet; failed — a line with what failed and the command for the session, the hook goes on. */
|
|
1966
|
+
const hookStep = (cmd: string, what: string, shown = cmd) => `{ ${cmd}; } >/dev/null 2>&1 || echo "Elves cloud hook: ${what} (exit $?) — run: ${shown}"`;
|
|
1967
|
+
|
|
1968
|
+
/**
|
|
1969
|
+
* The cloud session's SessionStart hook in a project's `.claude/settings.json` (#610). The cloud runs the project's
|
|
1970
|
+
* hooks — not `~/.claude/settings.json` (#552) — after Claude Code starts, on every start and resume, and the hook does
|
|
1971
|
+
* what an environment's setup script did, without the owner's step: a fresh Bun (the image's lags behind the
|
|
1972
|
+
* project's `packageManager`, `bun upgrade` is 403 there), the flow onto the session's machine and `check --hook` into
|
|
1973
|
+
* the context — the machine's hook (HOOK) isn't there. Outside the cloud it exits at once: the machine's hook runs
|
|
1974
|
+
* there. The canon already on the machine — a setup script, an earlier start in the same VM — nothing to install. A
|
|
1975
|
+
* failed step — a line with its command; the code is always 0: Claude Code drops the output of any other.
|
|
1976
|
+
* An Elves clone (`clone`) installs the flow from itself, `install -g --link` — the canon of the branch, as a clone on
|
|
1977
|
+
* the owner's machine, and npm has no release yet then (only the `0.0.0` stub), — and zsh for its tests
|
|
1978
|
+
* (tests/capabilities/agent-shell).
|
|
1979
|
+
*/
|
|
1980
|
+
function cloudHook(clone: boolean): SessionHook {
|
|
1981
|
+
const there = '[ -e "$HOME/.claude/rules/elves.md" ]';
|
|
1982
|
+
const elves = clone ? 'bun --no-env-file "$CLAUDE_PROJECT_DIR"/bin/elves.ts' : "elves";
|
|
1983
|
+
const steps = clone
|
|
1984
|
+
? [
|
|
1985
|
+
`${there} || ${hookStep(CLOUD_BUN, "Bun isn't updated")}`,
|
|
1986
|
+
`command -v zsh >/dev/null || ${hookStep(CLOUD_ZSH, "no zsh for tests/capabilities/agent-shell")}`,
|
|
1987
|
+
hookStep(`${elves} install -g --link`, "the flow isn't installed", "bun --no-env-file bin/elves.ts install -g --link"),
|
|
1988
|
+
]
|
|
1989
|
+
: [`${there} || { ${CLOUD_FLOW}; } >/dev/null 2>&1 || { echo "Elves cloud hook: the flow isn't installed (exit $?) — run: ${CLOUD_FLOW}"; exit 0; }`];
|
|
1990
|
+
const command = ['[ "$CLAUDE_CODE_REMOTE" = true ] || exit 0', ...steps, `${elves} check --hook 2>&1 || ${CHECK_FALLBACK}`].join("; ");
|
|
1991
|
+
return { matcher: HOOK_MATCHER, hooks: [{ type: "command", command, timeout: 120 }] };
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/** a < b for versions `1.4.2`: numeric parts, a pre-release suffix aside. */
|
|
1995
|
+
function olderVersion(a: string, b: string) {
|
|
1996
|
+
const [x, y] = [a, b].map((v) => (/^\d+(?:\.\d+)*/.exec(v)?.[0] ?? "").split(".").map(Number));
|
|
1997
|
+
for (let i = 0; i < Math.max(x!.length, y!.length); i++) if ((x![i] ?? 0) !== (y![i] ?? 0)) return (x![i] ?? 0) < (y![i] ?? 0);
|
|
1998
|
+
return false;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
/**
|
|
2002
|
+
* A Claude Code cloud session's environment (#598): the image's Bun lags behind the project's `packageManager` (1.4.2
|
|
2003
|
+
* without the `bun check` of 1.4.3: typecheck and tests are red), an Elves clone's tests need zsh
|
|
2004
|
+
* (tests/capabilities/agent-shell) the image doesn't have. The project's cloud hook installs them at the session's
|
|
2005
|
+
* start (`cloudHook`, #610); the line names an environment still without them — a project without the hook yet, a
|
|
2006
|
+
* failed step of the hook, a setup script's cache with an older Bun — with the command the session runs itself.
|
|
2007
|
+
* Doesn't change the check's code — the environment isn't the flow falling behind.
|
|
2008
|
+
*/
|
|
2009
|
+
function cloudLines(root: string): string[] {
|
|
2010
|
+
if (!remote()) return [];
|
|
2011
|
+
const pm = packageManager(root);
|
|
2012
|
+
const want = pm.name === "bun" ? pm.version : null;
|
|
2013
|
+
const have = process.versions.bun;
|
|
2014
|
+
const missing: string[] = [];
|
|
2015
|
+
const fix: string[] = [];
|
|
2016
|
+
if (want && have && olderVersion(have, want)) missing.push(`Bun ${have} is older than packageManager bun@${want}`), fix.push(CLOUD_BUN);
|
|
2017
|
+
if (isElvesClone(root) && !onPath("zsh")) missing.push("no zsh for tests/capabilities/agent-shell"), fix.push(CLOUD_ZSH);
|
|
2018
|
+
if (!missing.length) return [];
|
|
2019
|
+
return [` cloud environment: ${missing.join("; ")} — the session runs it itself: ${fix.join(" && ")}; at a cloud session's start the project's SessionStart hook does it (docs/cloud-sessions.md)`];
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* Whether the install on the machine (global) or in the project is behind. This package (SRC, the release —
|
|
2024
|
+
* `toRelease`) checks a copy on the machine and a project with a dry-run install, a `--link` clone — against
|
|
2025
|
+
* `origin/main`. A thin project behind — update in the session's task: a few lines; a project with a copy — the move in
|
|
2026
|
+
* a task of its own (`updateTaskLine`).
|
|
2027
|
+
*/
|
|
2028
|
+
function check(global: boolean): Result {
|
|
2029
|
+
const { home, root, installed } = target(global);
|
|
2030
|
+
const where = global ? "on the machine" : "in the project";
|
|
2031
|
+
// an Elves clone has nothing to compare against, but the session mode is needed in it — as in a project
|
|
2032
|
+
if (!installed && !global && isElvesClone(root)) return { code: 0, text: [`Elves ${where}: not installed — an Elves clone, the canon is at its root`, ...cloudLines(root), ...modeLines(root)].join("\n") };
|
|
2033
|
+
if (!installed) return { code: 0, text: `Elves ${where}: not installed`, absent: true };
|
|
2034
|
+
const clone = global ? linkedClone(home) : null;
|
|
2035
|
+
if (clone) return checkLink(home, clone);
|
|
2036
|
+
const copy = !global && isCopy(root);
|
|
2037
|
+
const changes = dryRun(() => (global ? installGlobal(SRC, false) : installProject(root)));
|
|
2038
|
+
const fresh = ownLabel();
|
|
2039
|
+
// mode and language are project settings: the last lines, the session start (hook) sees them too
|
|
2040
|
+
const mode = global ? [] : modeLines(root);
|
|
2041
|
+
// warnings — project settings that update doesn't fix: `!` lines, they don't change the exit code
|
|
2042
|
+
const warnings = changeLines(changes.filter((c) => c.op === "!"), root, global);
|
|
2043
|
+
// the compose file breaks task isolation — a mismatch (code 1) that update doesn't fix either; `○` — not checked
|
|
2044
|
+
const compose = global ? { lines: [], violations: false } : composeCheck(root);
|
|
2045
|
+
// standards without test:spec — a mismatch update doesn't add either
|
|
2046
|
+
const spec = global ? { lines: [], missing: false } : specScript(root);
|
|
2047
|
+
const project = [...warnings, ...compose.lines, ...spec.lines, ...(global ? [] : cloudLines(root)), ...mode];
|
|
2048
|
+
const stale = changes.filter((c) => c.op !== "❌" && c.op !== "!");
|
|
2049
|
+
const ignored = changes.filter((c) => c.op === "❌");
|
|
2050
|
+
if (!stale.length && !ignored.length) return { code: compose.violations || spec.missing ? 1 : 0, compose: compose.violations, spec: spec.missing, text: [`Elves ${where}: up to date (${fresh})`, ...project].join("\n") };
|
|
2051
|
+
const m = readManifest(root);
|
|
2052
|
+
const fixable = ignored.some((c) => !c.manual);
|
|
2053
|
+
const lost = "ignores flow files — they are missing from the commit, other checkouts and the cloud session:";
|
|
2054
|
+
const exceptions = fixable ? " — adds exceptions to .gitignore" : "";
|
|
2055
|
+
const installedAs = global || copy ? (m.tag ?? short(m.sha) ?? "without SHA") : shownPackage(projectPackage(root));
|
|
2056
|
+
const head = copy
|
|
2057
|
+
? `Elves ${where}: a copy of the flow (installed ${installedAs}) — move it to a thin project: ${PACKAGE} ${ownVersion() ?? fresh} in devDependencies, the canon and the skills on the machine`
|
|
2058
|
+
: stale.length
|
|
2059
|
+
? `Elves ${where}: behind — installed ${installedAs}, latest ${fresh}`
|
|
2060
|
+
: `Elves ${where}: up to date (${fresh}), but git ${lost}`;
|
|
2061
|
+
// update won't fix a personal rule — what to do is said in the ❌ line; a copy — in a task of its own, a thin project's
|
|
2062
|
+
// few lines — in the session's task
|
|
2063
|
+
const action = global
|
|
2064
|
+
? [`${stale.length ? "Update" : "Fix"}: ${RUN} update -g${exceptions}`]
|
|
2065
|
+
: copy
|
|
2066
|
+
? [`Move in a task of its own, not in this session's task: ${RUN} update${exceptions}`, updateTaskLine(root)]
|
|
2067
|
+
: [`${stale.length ? `Update in this session's task — ${PACKAGE} in package.json and the lockfile, a few lines` : "Fix in this session's task"}: ${RUN} update${exceptions}`];
|
|
2068
|
+
return {
|
|
2069
|
+
code: 1,
|
|
2070
|
+
behind: stale.length > 0,
|
|
2071
|
+
copy,
|
|
2072
|
+
ignored: ignored.length > 0,
|
|
2073
|
+
compose: compose.violations,
|
|
2074
|
+
spec: spec.missing,
|
|
2075
|
+
run: stale.length > 0 || fixable,
|
|
2076
|
+
text: [
|
|
2077
|
+
head,
|
|
2078
|
+
...capped(changeLines(stale, root, global)),
|
|
2079
|
+
...(stale.length && ignored.length ? [`Git ${lost}`] : []),
|
|
2080
|
+
...capped(changeLines(ignored, root, global)),
|
|
2081
|
+
...(stale.length || fixable ? action : []),
|
|
2082
|
+
...project,
|
|
2083
|
+
].join("\n"),
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
/**
|
|
2088
|
+
* A machine with `--link`: the clone against `origin/main` after `git fetch` — flow files the clone doesn't have yet
|
|
2089
|
+
* (the canon of the machine is the clone's: rules, references, skills), and missing links (a new skill, the rules, the
|
|
2090
|
+
* hook). Commits without flow files aren't falling behind.
|
|
2091
|
+
*/
|
|
2092
|
+
function checkLink(home: string, clone: string): Result {
|
|
2093
|
+
const where = `on the machine (clone ${clone})`;
|
|
2094
|
+
let head, main, behind, files;
|
|
2095
|
+
try {
|
|
2096
|
+
git(clone, "fetch", "--quiet", "origin", "main");
|
|
2097
|
+
head = git(clone, "rev-parse", "HEAD");
|
|
2098
|
+
main = git(clone, "rev-parse", "origin/main");
|
|
2099
|
+
behind = Number(git(clone, "rev-list", "--count", "HEAD..origin/main"));
|
|
2100
|
+
files = git(clone, "diff", "--no-renames", "--name-status", "HEAD...origin/main", "--", ...FLOW).split("\n").filter(Boolean);
|
|
2101
|
+
} catch (e) {
|
|
2102
|
+
return { code: 2, text: `Elves ${where}: check unavailable — ${reason(e)}` };
|
|
2103
|
+
}
|
|
2104
|
+
const all = dryRun(() => installGlobal(clone, true));
|
|
2105
|
+
// warnings — what update doesn't fix: `!` lines, not falling behind
|
|
2106
|
+
const links = all.filter((c) => c.op !== "!");
|
|
2107
|
+
const warnings = changeLines(all.filter((c) => c.op === "!"), home, true);
|
|
2108
|
+
if (!files.length && !links.length) {
|
|
2109
|
+
const ahead = behind ? `; origin/main ${short(main)} is ahead by ${behind}, the flow isn't affected` : "";
|
|
2110
|
+
return { code: 0, text: [`Elves ${where}: up to date (${short(head)}${ahead})`, ...warnings, ...unreleased(clone)].join("\n") };
|
|
2111
|
+
}
|
|
2112
|
+
const op = (status: string) => (status === "A" ? "+" : status === "D" ? "-" : "~");
|
|
2113
|
+
return {
|
|
2114
|
+
code: 1,
|
|
2115
|
+
behind: true,
|
|
2116
|
+
text: [
|
|
2117
|
+
`Elves ${where}: behind — clone ${short(head)}, origin/main ${short(main)}`,
|
|
2118
|
+
...capped([...files.map((l) => ` ${op(l.split("\t")[0] ?? "")} ${l.split("\t")[1]}`), ...changeLines(links, home, true)]),
|
|
2119
|
+
`Update: ${RUN} update -g`,
|
|
2120
|
+
...warnings,
|
|
2121
|
+
...unreleased(clone),
|
|
2122
|
+
].join("\n"),
|
|
2123
|
+
};
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
/**
|
|
2127
|
+
* A reminder to the owner on a machine with a `--link` clone: origin/main has flow changes that aren't in the latest
|
|
2128
|
+
* release — projects won't get them until it's cut. Only in an Elves clone's session: a release is the owner's step
|
|
2129
|
+
* there, in a project's session the line is noise at every start. Not falling behind: it doesn't change the check's
|
|
2130
|
+
* code. Can't tell — no line.
|
|
2131
|
+
*/
|
|
2132
|
+
function unreleased(clone: string): string[] {
|
|
2133
|
+
if (!isElvesClone(projectRoot())) return [];
|
|
2134
|
+
const cmd = `release it: bun --no-env-file ${path.join(clone, "bin/elves.ts")} release`;
|
|
2135
|
+
try {
|
|
2136
|
+
const last = releases("origin", clone).at(-1);
|
|
2137
|
+
if (!last) return [` not in a release: no releases yet — ${cmd}`];
|
|
2138
|
+
const n = Number(git(clone, "rev-list", "--count", "--first-parent", `${last.sha}..origin/main`, "--", ...RELEASED));
|
|
2139
|
+
return n ? [` not in a release: flow commits after ${last.tag} — ${n}; ${cmd}`] : [];
|
|
2140
|
+
} catch {
|
|
2141
|
+
return [];
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
/**
|
|
2146
|
+
* The machine's proxy for task addresses (the task-env skill): one Traefik per machine gives containers of task stacks
|
|
2147
|
+
* the addresses `<service>.<N>.<project>.localhost` by their labels. Its Compose project is the skill's `proxyName`
|
|
2148
|
+
* (`$ELVES_PROXY`, by default `elves-proxy`). A line for `check -g` and the hook: running, not running — with the
|
|
2149
|
+
* start command, Docker unavailable — with the reason. No task-env skill or no docker on the machine — no line: without
|
|
2150
|
+
* Docker there are no task stacks, and a line in every session would be noise. Asks docker itself — the skill's
|
|
2151
|
+
* `proxyRunning` folds "docker not found" into its reason. Doesn't change the check's code — the proxy isn't the flow
|
|
2152
|
+
* falling behind. Not running, while the proxy of an earlier release under the flow's former name (`formerProxyName` of
|
|
2153
|
+
* the skill) is — it holds the port: the line names it, and `proxy up` of the skill replaces it.
|
|
2154
|
+
*/
|
|
2155
|
+
function proxyLines(home: string): string[] {
|
|
2156
|
+
if (!existsSync(path.join(home, ".agents/skills/task-env/scripts/task-env.ts"))) return [];
|
|
2157
|
+
const name = proxyName();
|
|
2158
|
+
const ps = (project: string) =>
|
|
2159
|
+
spawnSync("docker", ["ps", "--all", "--filter", `label=com.docker.compose.project=${project}`, "--filter", "label=com.docker.compose.service=traefik", "--format", "{{.State}}"], { encoding: "utf8", timeout: 10_000 });
|
|
2160
|
+
const r = ps(name);
|
|
2161
|
+
const err = r.error as NodeJS.ErrnoException | undefined;
|
|
2162
|
+
if (err?.code === "ENOENT") return [];
|
|
2163
|
+
if (err || r.status !== 0) return [` proxy *.localhost: Docker unavailable — ${err ? (err.code === "ETIMEDOUT" ? "docker didn't answer in 10 s" : err.message) : String(r.stderr).trim().split("\n")[0]}`];
|
|
2164
|
+
const running = (x: ReturnType<typeof ps>) => !x.error && x.status === 0 && x.stdout.split("\n").includes("running");
|
|
2165
|
+
if (running(r)) return [` proxy *.localhost: running (${name})`];
|
|
2166
|
+
const up = "bun --no-env-file ~/.agents/skills/task-env/scripts/task-env.ts proxy up";
|
|
2167
|
+
const former = formerProxyName();
|
|
2168
|
+
if (running(ps(former))) return [` proxy *.localhost: running under the flow's former name (${former}) — replace it: ${up}`];
|
|
2169
|
+
return [` proxy *.localhost: not running — start: ${up}`];
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/**
|
|
2173
|
+
* A user's variables under the flow's former name (FORMER.env) — set where `update` doesn't reach (the shell profile):
|
|
2174
|
+
* a line each with the new name. The skills' scripts read them until renamed.
|
|
2175
|
+
*/
|
|
2176
|
+
function formerEnvLines(): string[] {
|
|
2177
|
+
return Object.entries(FORMER.env)
|
|
2178
|
+
.filter(([, former]) => process.env[former])
|
|
2179
|
+
.map(([name, former]) => ` ${former}: the flow's former name — rename it to ${name} where it is set (the shell profile)`);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* The agent's Bash environment (tests/capabilities/agent-shell). Claude Code's Bash tool — the user's zsh on macOS —
|
|
2184
|
+
* puts the text of the files SessionStart hooks write to `$CLAUDE_ENV_FILE` into every call, after the user's shell
|
|
2185
|
+
* snapshot and before the command's `eval` (checked on Claude Code 2.1.295): shell options and PATH from there hold
|
|
2186
|
+
* in the command itself, in subagents too. zsh breaks commands written for bash: a glob without matches is an error
|
|
2187
|
+
* (`no matches found: --include=*.ts`, and grep in a pipe reads it as "nothing found"), a word starting with `=` is a
|
|
2188
|
+
* command's path (`echo ====`, `[ a == b ]`), and macOS has no `timeout`. So: NO_NOMATCH and NO_EQUALS in zsh, this
|
|
2189
|
+
* package's `bin/shims` (`timeout`) at the end of PATH — a real `timeout` wins — and the AGENT_SHELL mark `check -g`
|
|
2190
|
+
* recognises the environment by.
|
|
2191
|
+
*/
|
|
2192
|
+
const AGENT_SHELL = "ELVES_AGENT_SHELL";
|
|
2193
|
+
|
|
2194
|
+
/** A word for sh: in single quotes. */
|
|
2195
|
+
const shQuote = (s: string) => `'${s.replaceAll("'", "'\\''")}'`;
|
|
2196
|
+
|
|
2197
|
+
function agentShellScript() {
|
|
2198
|
+
return [
|
|
2199
|
+
`# Elves: the agent's Bash environment, written by the SessionStart hook (${RUN} check --hook)`,
|
|
2200
|
+
`export ${AGENT_SHELL}=1`,
|
|
2201
|
+
'if [ -n "${ZSH_VERSION-}" ]; then setopt NO_NOMATCH NO_EQUALS; fi',
|
|
2202
|
+
`export PATH="\${PATH:+$PATH:}"${shQuote(path.join(SRC, "bin/shims"))}`,
|
|
2203
|
+
"",
|
|
2204
|
+
].join("\n");
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/** Without the environment the zsh pitfalls are back: what the agent does by hand meanwhile. */
|
|
2208
|
+
const SHELL_FALLBACK = "meanwhile in zsh quote globs ('*.ts') and words starting with = ('===='), use the Bash tool's timeout instead of timeout";
|
|
2209
|
+
|
|
2210
|
+
/**
|
|
2211
|
+
* The SessionStart hook: the agent's Bash environment into `$CLAUDE_ENV_FILE` — the file Claude Code gives this hook
|
|
2212
|
+
* (`~/.claude/session-env/<session>/sessionstart-hook-<N>.sh`); rewritten on every start, resume and clear, a repeat
|
|
2213
|
+
* doesn't add a second copy. Written — no line: the hook's output is read in every session.
|
|
2214
|
+
*/
|
|
2215
|
+
function writeAgentShell(): string[] {
|
|
2216
|
+
const file = process.env.CLAUDE_ENV_FILE;
|
|
2217
|
+
if (!file) return [` agent Bash: not set up — Claude Code gave the hook no CLAUDE_ENV_FILE; ${SHELL_FALLBACK}`];
|
|
2218
|
+
try {
|
|
2219
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
2220
|
+
writeFileSync(file, agentShellScript());
|
|
2221
|
+
return [];
|
|
2222
|
+
} catch (e) {
|
|
2223
|
+
return [` agent Bash: not set up — can't write CLAUDE_ENV_FILE ${file}: ${reason(e)}; ${SHELL_FALLBACK}`];
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
/**
|
|
2228
|
+
* `check -g` from the agent's Bash (Claude Code sets CLAUDECODE=1 in it): no AGENT_SHELL mark — the session didn't get
|
|
2229
|
+
* the environment. From a terminal the agent's shell can't be seen — no line. A session started before `update -g`
|
|
2230
|
+
* has the mark of the hook of an earlier release (FORMER.agentShell) — the environment is there.
|
|
2231
|
+
*/
|
|
2232
|
+
function agentShellLines(): string[] {
|
|
2233
|
+
if (process.env.CLAUDECODE !== "1" || process.env[AGENT_SHELL] || process.env[FORMER.agentShell]) return [];
|
|
2234
|
+
return [` agent Bash: not set up — this session got no environment from the Elves SessionStart hook (its output at the session start says why); ${SHELL_FALLBACK}`];
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/**
|
|
2238
|
+
* The check's result plus the machine's lines, installed or not, behind or not: the proxy (`proxyLines`), the agent's
|
|
2239
|
+
* Bash environment — the hook passes what writing it gave, `check -g` asks `agentShellLines` — and the user's variables
|
|
2240
|
+
* under the flow's former name (`formerEnvLines`).
|
|
2241
|
+
*/
|
|
2242
|
+
function withMachine(global: boolean, r: Result, shell = agentShellLines()): Result {
|
|
2243
|
+
if (!global || r.absent) return r;
|
|
2244
|
+
const lines = [...proxyLines(os.homedir()), ...shell, ...formerEnvLines()];
|
|
2245
|
+
return lines.length ? { ...r, text: [r.text, ...lines].join("\n") } : r;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
/**
|
|
2249
|
+
* The hook runs outside the Bash sandbox and doesn't see it, but the agent runs the commands it names inside: bunx
|
|
2250
|
+
* there can't write its cache `~/.bun/install/cache` ("unable to write files to tempdir: EPERM"), the install —
|
|
2251
|
+
* `.claude/skills` (`denied`).
|
|
2252
|
+
*/
|
|
2253
|
+
const SANDBOX_HINT = `In the Claude Code sandbox bunx can't write its cache ~/.bun/install/cache — prefix the command with BUN_INSTALL_CACHE_DIR="$TMPDIR/bun"; the sandbox refuses writes to .claude/ too — then the owner runs the command in their terminal.`;
|
|
2254
|
+
|
|
2255
|
+
/** One request or two of the github skill to GitHub; the hook has 60 s for everything. */
|
|
2256
|
+
const RESUME_TIMEOUT = 20_000;
|
|
2257
|
+
|
|
2258
|
+
/**
|
|
2259
|
+
* The session id from the hook's input: Claude Code passes JSON on stdin (`session_id`, `source` — startup, resume,
|
|
2260
|
+
* clear); a run by hand in a terminal has no input — then `CLAUDE_CODE_SESSION_ID`, as the agent's Bash has it.
|
|
2261
|
+
*/
|
|
2262
|
+
function hookSession(): string | null {
|
|
2263
|
+
let id: unknown;
|
|
2264
|
+
if (!isatty(0)) {
|
|
2265
|
+
try {
|
|
2266
|
+
id = JSON.parse(readFileSync(0, "utf8")).session_id;
|
|
2267
|
+
} catch {
|
|
2268
|
+
/* no input or not JSON */
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
return (typeof id === "string" && id) || process.env.CLAUDE_CODE_SESSION_ID || null;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
/**
|
|
2275
|
+
* The github skill script the agent runs: the machine's — a same-named machine skill wins over the project's in Claude
|
|
2276
|
+
* Code, — otherwise the project's copy, otherwise this package's. The commands it prints name its own paths.
|
|
2277
|
+
* withProject false — without the project's copy: a command for updating it (updateTaskLine) when it's behind.
|
|
2278
|
+
*/
|
|
2279
|
+
function githubScript(withProject = true): string {
|
|
2280
|
+
const rel = "skills/github/scripts/github.ts";
|
|
2281
|
+
const roots = [path.join(os.homedir(), ".agents"), ...(withProject ? [path.join(projectRoot(), ".agents")] : []), SRC];
|
|
2282
|
+
return roots.map((r) => path.join(r, rel)).find((f) => existsSync(f)) ?? path.join(SRC, rel);
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
/**
|
|
2286
|
+
* A background wait (ci-wait) dies with the app's process, and the session resumed after a restart doesn't know it has
|
|
2287
|
+
* to restart it (#433). The task pinned to the session and its PR are known to the github skill: `task resume` names
|
|
2288
|
+
* the wait; no pin — it prints nothing. Not filtered by `source`: the pin itself is the sign of a resumed session — a
|
|
2289
|
+
* new session and /clear have a new id. A failure — its lines and a `!` line: the hook's code stays 0.
|
|
2290
|
+
*/
|
|
2291
|
+
function resumeLines(): string[] {
|
|
2292
|
+
const id = hookSession();
|
|
2293
|
+
if (!id) return [];
|
|
2294
|
+
const r = spawnSync(process.execPath, ["--no-env-file", githubScript(), "task", "resume"], { encoding: "utf8", timeout: RESUME_TIMEOUT, env: { ...process.env, CLAUDE_CODE_SESSION_ID: id } });
|
|
2295
|
+
const lines = (r.stdout ?? "").split("\n").filter((l) => l.trim());
|
|
2296
|
+
if (!r.error && r.status === 0) return lines;
|
|
2297
|
+
const why = r.error ? r.error.message : String(r.stderr).trim().split("\n").filter(Boolean).pop() || `exit code ${r.status ?? r.signal}`;
|
|
2298
|
+
return [...lines, `! the session's task — github task resume: ${why}`];
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/**
|
|
2302
|
+
* The canon loads twice (#557): Claude Code reads the machine's rules (`~/.claude/rules/elves*.md`, `install -g`) and
|
|
2303
|
+
* the project's copy (`.claude/rules/elves*.md`, under the flow's former name too) — both, on every turn. Allowed for
|
|
2304
|
+
* the move (#180 removed the machine's rules only because of that copy, and projects with a copy are few): the hook
|
|
2305
|
+
* names it and the move — the project's update task, `update` turns the copy into the thin project. A rule that doesn't
|
|
2306
|
+
* load (a dangling symlink) doesn't count; an Elves clone has no copy, the home directory isn't a project.
|
|
2307
|
+
*/
|
|
2308
|
+
function twiceLines(home: string, root: string): string[] {
|
|
2309
|
+
const loads = (p: string) => {
|
|
2310
|
+
try {
|
|
2311
|
+
return statSync(p).isFile();
|
|
2312
|
+
} catch {
|
|
2313
|
+
return false;
|
|
2314
|
+
}
|
|
2315
|
+
};
|
|
2316
|
+
if (samePath(root, home) || isElvesClone(root)) return [];
|
|
2317
|
+
const machine = Object.values(RULES).filter((rel) => loads(path.join(home, rel)));
|
|
2318
|
+
const project = [...Object.values(RULES), ...FORMER.rules].filter((rel) => loads(path.join(root, rel)));
|
|
2319
|
+
if (!machine.length || !project.length) return [];
|
|
2320
|
+
return [` canon loaded twice: the machine's rules ${machine.map((r) => `~/${r}`).join(", ")} and the project's copy ${project.join(", ")} — Claude Code reads both on every turn until the project moves to the thin project: its update, in a task of its own`];
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
/**
|
|
2324
|
+
* The SessionStart hook: the agent's Bash environment, then the machine and the project in one output to the session
|
|
2325
|
+
* context, then the wait a resumed session restarts (not in the cloud: no Projects there); the code is always 0, errors
|
|
2326
|
+
* go to stdout. failed — the latest release couldn't be found: what's installed is "check unavailable", the environment
|
|
2327
|
+
* is written all the same.
|
|
2328
|
+
*/
|
|
2329
|
+
function hook(failed?: unknown) {
|
|
2330
|
+
const shell = writeAgentShell();
|
|
2331
|
+
const results: Result[] = [];
|
|
2332
|
+
for (const global of [true, false]) {
|
|
2333
|
+
try {
|
|
2334
|
+
results.push(withMachine(global, failed && target(global).installed ? unavailable(global, failed) : check(global), shell));
|
|
2335
|
+
} catch (e) {
|
|
2336
|
+
results.push(withMachine(global, unavailable(global, e), shell));
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
const [machine, project] = results;
|
|
2340
|
+
const shown = results.filter((r) => !r.absent);
|
|
2341
|
+
// a git repository without the flow — how to install it (an Elves clone is the canon itself, it isn't absent)
|
|
2342
|
+
if (project?.absent && gitRoot()) shown.push({ code: 0, run: true, text: `Elves in the project: not installed — install it: ${RUN} install --lang <project language>` });
|
|
2343
|
+
// the canon twice: the update task's line — once, the project's behind text may have printed it already
|
|
2344
|
+
const root = projectRoot();
|
|
2345
|
+
const twice = twiceLines(os.homedir(), root);
|
|
2346
|
+
if (twice.length) shown.push({ code: 0, text: [...twice, ...(project?.run ? [] : [updateTaskLine(root)])].join("\n") });
|
|
2347
|
+
// Projects are closed to a cloud session (403): task resume refuses there — a `!` line at every start
|
|
2348
|
+
const resume = remote() ? [] : resumeLines();
|
|
2349
|
+
if (!shown.length && !resume.length) return 0;
|
|
2350
|
+
const tail = [];
|
|
2351
|
+
// the machine's update touches only ~/.agents and the hook — the session does it; a thin project's — a few lines in
|
|
2352
|
+
// the branch of the session's task; a copy's move — a task of its own (updateTaskLine), the session works by the
|
|
2353
|
+
// current flow
|
|
2354
|
+
if (machine?.behind) tail.push("The machine is behind — update -g, tell the user what changed (update lists it), re-read what was updated.");
|
|
2355
|
+
if (project?.copy) {
|
|
2356
|
+
tail.push(
|
|
2357
|
+
"The project keeps a copy of the flow — moving it to a thin project is a task of its own, not in a task session: recommend it to the user and ask whether to queue its task (task new above): yes — a background executor (the github skill, «Dispatcher»); work by the current flow. The move task — update, tell the user what changed, fix what it names, run the project's checks, commit into its branch.",
|
|
2358
|
+
);
|
|
2359
|
+
} else if (project?.behind) {
|
|
2360
|
+
tail.push("The project's flow is behind — update in the session's task: it bumps the package version in package.json and the lockfile; tell the user what changed, commit it with the task.");
|
|
2361
|
+
}
|
|
2362
|
+
if (twice.length) {
|
|
2363
|
+
tail.push("The canon loads twice — the machine's rules and the project's copy: recommend the project's move to the thin project and ask whether to queue its update task (task new above); work by the current flow.");
|
|
2364
|
+
}
|
|
2365
|
+
if (shown.some((r) => r.ignored)) tail.push("Git ignores flow files — update adds exceptions to .gitignore, committed with the update; a rule outside the project — fix it yourself.");
|
|
2366
|
+
// the session has no task yet at its start, and one session is one task: the fix goes into a task of its own
|
|
2367
|
+
if (shown.some((r) => r.compose)) tail.push("The compose file breaks task isolation — each ❌ line names the rule and the service (the task-env skill); fixing it is a task of its own in the project.");
|
|
2368
|
+
if (shown.some((r) => r.spec)) tail.push("No test:spec script — pr premerge doesn't run standards and architecture on the merge; the ❌ line names the package.json scripts; adding them is a task of its own in the project.");
|
|
2369
|
+
if (shown.some((r) => r.code === 2)) tail.push("Check unavailable — work by the current flow and say so.");
|
|
2370
|
+
if (shown.some((r) => r.run)) tail.push(SANDBOX_HINT);
|
|
2371
|
+
note(["Elves flow (AGENTS.md, «First step of a session»):", ...shown.map((r) => r.text), ...resume, ...tail].join("\n"));
|
|
2372
|
+
return 0;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
/**
|
|
2376
|
+
* The flow copy's commit subject — in the project language, the `updateCommit` key of this package's github skill
|
|
2377
|
+
* dictionary: "Elves flow <tag>" in English, the same phrase translated in other languages.
|
|
2378
|
+
*/
|
|
2379
|
+
const commitSubject = (root: string, version: string | null | undefined) => locale.fill(locale.names(languageOf(root)).updateCommit, { version: version ?? "" }).trim();
|
|
2380
|
+
|
|
2381
|
+
/**
|
|
2382
|
+
* Where a thin project's update starts: the release of its package version and the release's SHA from the tags
|
|
2383
|
+
* (`releases`); `why` — the reason the SHA is unknown, for `whatChanged`.
|
|
2384
|
+
*/
|
|
2385
|
+
function packageFrom(root: string): { from: { sha?: string; tag?: string }; why: string } {
|
|
2386
|
+
const spec = projectPackage(root);
|
|
2387
|
+
const tag = spec === undefined ? null : releaseTag(spec);
|
|
2388
|
+
if (!tag) return { from: { tag: shownPackage(spec) }, why: spec === undefined ? `the project had no ${PACKAGE}` : `${PACKAGE} ${spec} isn't a release version` };
|
|
2389
|
+
try {
|
|
2390
|
+
const sha = releases(repoUrl()).find((r) => r.tag === tag)?.sha;
|
|
2391
|
+
return { from: { tag, ...(sha ? { sha } : {}) }, why: `no release ${tag} among the tags` };
|
|
2392
|
+
} catch (e) {
|
|
2393
|
+
return { from: { tag }, why: reason(e) };
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
/** A path for sh: as is, or in single quotes. */
|
|
2398
|
+
const shWord = (s: string) => (/^[\w./@:+-]+$/.test(s) ? s : shQuote(s));
|
|
2399
|
+
|
|
2400
|
+
/**
|
|
2401
|
+
* What goes into the commit of an update: what the install changed and the flow files git doesn't track yet (ignored
|
|
2402
|
+
* before the update added exceptions) — of them what exists now or what git tracks: `git add` of a removed path git
|
|
2403
|
+
* never tracked fails on the pathspec, and the commit with it.
|
|
2404
|
+
*/
|
|
2405
|
+
function committable(root: string, changed: string[]) {
|
|
2406
|
+
const tracked = (rel: string) => {
|
|
2407
|
+
try {
|
|
2408
|
+
return git(root, "ls-files", "--", rel) !== "";
|
|
2409
|
+
} catch {
|
|
2410
|
+
return false;
|
|
2411
|
+
}
|
|
2412
|
+
};
|
|
2413
|
+
const flow = flowEntries(root).map((e) => e.path).filter((rel) => !tracked(rel));
|
|
2414
|
+
return [...new Set([...changed, ...flow])].filter((rel) => lstat(path.join(root, rel)) || tracked(rel));
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
/**
|
|
2418
|
+
* Bring the install up to date. A machine with `--link` — `updateLink`; the machine's copy — `install` by this
|
|
2419
|
+
* package; a project — `installProject` with what changed and the commit of what it wrote: a thin project's — in the
|
|
2420
|
+
* session's task, a copy's move — in the update task's branch.
|
|
2421
|
+
*/
|
|
2422
|
+
function update(global: boolean) {
|
|
2423
|
+
if (global) {
|
|
2424
|
+
const home = os.homedir();
|
|
2425
|
+
const clone = linkedClone(home);
|
|
2426
|
+
if (clone) return updateLink(clone);
|
|
2427
|
+
const from = readManifest(home);
|
|
2428
|
+
installGlobal(SRC, false);
|
|
2429
|
+
note(whatChanged(from).join("\n"));
|
|
2430
|
+
return 0;
|
|
2431
|
+
}
|
|
2432
|
+
const root = projectRoot();
|
|
2433
|
+
if (isElvesClone(root)) return note(CLONE_NOTE), 0;
|
|
2434
|
+
const copy = isCopy(root);
|
|
2435
|
+
const { from, why } = copy ? { from: readManifest(root), why: undefined } : packageFrom(root);
|
|
2436
|
+
installProject(root);
|
|
2437
|
+
const version = ownLabel();
|
|
2438
|
+
const add = committable(root, touched);
|
|
2439
|
+
note(
|
|
2440
|
+
[
|
|
2441
|
+
...whatChanged(from, why),
|
|
2442
|
+
...(add.length
|
|
2443
|
+
? [
|
|
2444
|
+
copy ? "Commit the move into the update task's branch:" : "Commit it with the session's task:",
|
|
2445
|
+
` git add -A -- ${add.map(shWord).join(" ")} && git commit -m "chore(agents): ${commitSubject(root, version)}"`,
|
|
2446
|
+
]
|
|
2447
|
+
: []),
|
|
2448
|
+
].join("\n"),
|
|
2449
|
+
);
|
|
2450
|
+
return 0;
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
/**
|
|
2454
|
+
* What the update brought, for the agent to tell the user: every release after the installed one up to this package's,
|
|
2455
|
+
* newest first — the tag, its page and the list its GitHub Release has (`changeItems`); no releases yet or an installer
|
|
2456
|
+
* from a clone — also the flow commits after the last release up to this package's SHA. Can't tell (an install without
|
|
2457
|
+
* SHA, no network, the SHA isn't in the history) — a line with the reason: the flow is already installed, the update
|
|
2458
|
+
* doesn't fail over it.
|
|
2459
|
+
* @param from the manifest before the update; a thin project — the release of its package version (`packageFrom`)
|
|
2460
|
+
* @param why why `from` has no SHA
|
|
2461
|
+
*/
|
|
2462
|
+
function whatChanged(from: { sha?: unknown; tag?: unknown }, why = "installed without SHA"): string[] {
|
|
2463
|
+
const to = sourceSha(SRC);
|
|
2464
|
+
const sha = typeof from.sha === "string" ? from.sha : null;
|
|
2465
|
+
const was = (typeof from.tag === "string" && from.tag) || short(sha) || "without SHA";
|
|
2466
|
+
const now = ownLabel();
|
|
2467
|
+
if (sha && sha === to) return [`Elves ${now} was already installed — nothing changed`];
|
|
2468
|
+
const unavailable = (why: string) => [`Elves ${was} → ${now}: changes unavailable — ${why}; releases: ${RELEASES}`];
|
|
2469
|
+
if (!sha) return unavailable(why);
|
|
2470
|
+
if (!to) return unavailable("the SHA of this package is unknown");
|
|
2471
|
+
let groups;
|
|
2472
|
+
try {
|
|
2473
|
+
groups = releaseGroups(sha, to);
|
|
2474
|
+
} catch (e) {
|
|
2475
|
+
return unavailable(reason(e));
|
|
2476
|
+
}
|
|
2477
|
+
const tags = groups.filter((g) => g.tag).length;
|
|
2478
|
+
return [
|
|
2479
|
+
`Elves ${was} → ${now}${tags ? `, new releases: ${tags}` : ""} — tell the user what changed:`,
|
|
2480
|
+
// commits after the last release (an installer from a clone) are named only next to releases
|
|
2481
|
+
...groups.flatMap((g) => [...(g.tag ? [`${g.tag} — ${RELEASES}/tag/${g.tag}`] : tags ? [`${short(to)} — not in a release`] : []), ...g.items.map((i) => ` ${i}`)]),
|
|
2482
|
+
];
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
/**
|
|
2486
|
+
* The releases between two SHAs of Elves, newest first, each with its list of changes; commits after the last of them
|
|
2487
|
+
* (no releases yet, an installer from a clone) — a group without a tag. The history comes from where the releases do,
|
|
2488
|
+
* fetched without file contents into a temporary repository: the bunx package has no `.git`. A release's list starts
|
|
2489
|
+
* from the previous release, the first one's — from `from`: with an install from a release that is exactly its GitHub
|
|
2490
|
+
* Release's list.
|
|
2491
|
+
*/
|
|
2492
|
+
function releaseGroups(from: string, to: string): { tag?: string; items: string[] }[] {
|
|
2493
|
+
const remote = repoUrl();
|
|
2494
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), "elves-history-"));
|
|
2495
|
+
try {
|
|
2496
|
+
git(tmp, "init", "--quiet", "--bare");
|
|
2497
|
+
git(tmp, "fetch", "--quiet", "--no-tags", "--filter=blob:none", remote, to);
|
|
2498
|
+
const range = new Set(git(tmp, "rev-list", `${from}..${to}`).split("\n"));
|
|
2499
|
+
const tags = releases(remote).filter((r) => range.has(r.sha));
|
|
2500
|
+
const groups: { tag?: string; items: string[] }[] = [];
|
|
2501
|
+
let prev = from;
|
|
2502
|
+
for (const r of tags) groups.push({ tag: r.tag, items: changeItems(tmp, `${prev}..${(prev = r.sha)}`) });
|
|
2503
|
+
if (prev !== to) groups.push({ items: changeItems(tmp, `${prev}..${to}`) });
|
|
2504
|
+
return groups.reverse();
|
|
2505
|
+
} finally {
|
|
2506
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
/**
|
|
2511
|
+
* The flow changes in a range of first-parent commits, a `- …` line each — the list of a GitHub Release and of
|
|
2512
|
+
* `update`: commits that touched the flow or the installer; a PR merged with a merge commit — by its title and number,
|
|
2513
|
+
* one merged by rebase — by its own commits with the task number from `Refs #N`.
|
|
2514
|
+
*/
|
|
2515
|
+
function changeItems(repo: string, range: string): string[] {
|
|
2516
|
+
return git(repo, "log", "--first-parent", "--format=%s%x1f%b%x1e", range, "--", ...RELEASED)
|
|
2517
|
+
.split("\x1e")
|
|
2518
|
+
.map((entry) => entry.trim())
|
|
2519
|
+
.filter(Boolean)
|
|
2520
|
+
.map((entry) => {
|
|
2521
|
+
const [subject = "", body = ""] = entry.split("\x1f");
|
|
2522
|
+
const pr = /^Merge pull request #(\d+) /.exec(subject);
|
|
2523
|
+
const title = body.trim().split("\n")[0];
|
|
2524
|
+
if (pr && title) return `- ${title} (#${pr[1]})`;
|
|
2525
|
+
const refs = [...new Set([...body.matchAll(/^Refs #(\d+)/gm)].map((m) => `#${m[1]}`))];
|
|
2526
|
+
return refs.length ? `- ${subject} (${refs.join(", ")})` : `- ${subject}`;
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/**
|
|
2531
|
+
* A `--link` clone: only main and without changes in tracked files — otherwise a refusal (code 1), the clone is left
|
|
2532
|
+
* alone: that's the user's work. Then `git pull --ff-only` and `install -g --link` with the code of the updated clone:
|
|
2533
|
+
* this process may have loaded its old version, and new skills need links too.
|
|
2534
|
+
*/
|
|
2535
|
+
function updateLink(clone: string) {
|
|
2536
|
+
let branch = "";
|
|
2537
|
+
try {
|
|
2538
|
+
branch = git(clone, "symbolic-ref", "--quiet", "--short", "HEAD");
|
|
2539
|
+
} catch {
|
|
2540
|
+
/* HEAD is detached */
|
|
2541
|
+
}
|
|
2542
|
+
if (branch !== "main") return warn(`update: clone ${clone} is not on main (${branch ? `branch ${branch}` : "HEAD detached"}) — switch it yourself and retry`), 1;
|
|
2543
|
+
const dirty = execFileSync("git", ["-C", clone, "status", "--porcelain", "--untracked-files=no"], { encoding: "utf8" }).trimEnd();
|
|
2544
|
+
if (dirty) return warn(`update: clone ${clone} has changes in tracked files — commit or remove them yourself and retry:\n${dirty}`), 1;
|
|
2545
|
+
const before = git(clone, "rev-parse", "HEAD");
|
|
2546
|
+
try {
|
|
2547
|
+
git(clone, "pull", "--ff-only", "--quiet", "origin", "main");
|
|
2548
|
+
} catch (e) {
|
|
2549
|
+
return warn(`update: git pull --ff-only in clone ${clone} — ${reason(e)}`), 2;
|
|
2550
|
+
}
|
|
2551
|
+
const after = git(clone, "rev-parse", "HEAD");
|
|
2552
|
+
const items = changeItems(clone, `${before}..${after}`);
|
|
2553
|
+
const head = `clone ${clone}: ${short(before)} → ${short(after)}`;
|
|
2554
|
+
note((items.length ? [`${head} — tell the user what changed:`, ...items.map((i) => ` ${i}`)] : [`${head}, the flow hasn't changed`]).join("\n"));
|
|
2555
|
+
return spawnSync(process.execPath, ["--no-env-file", path.join(clone, "bin/elves.ts"), "install", "-g", "--link"], { stdio: "inherit" }).status ?? 2;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
/**
|
|
2559
|
+
* A release is the owner's step, from a clone: the tag vYYYY.MM.DD (the date in UTC; a second one the same day — .1,
|
|
2560
|
+
* .2…) on origin/main and a GitHub Release with the list of changes since the previous release (`changeItems` — `update`
|
|
2561
|
+
* prints the same list). Nothing to release — a refusal (code 1): projects mustn't update for nothing.
|
|
2562
|
+
* @param preview `--dry-run`: show the tag and the list, create nothing
|
|
2563
|
+
*/
|
|
2564
|
+
function releaseCmd(preview: boolean) {
|
|
2565
|
+
if (!existsSync(path.join(SRC, ".git"))) return warn("release — only from an Elves clone: bun <clone>/bin/elves.ts release"), 2;
|
|
2566
|
+
let head, tags, items;
|
|
2567
|
+
try {
|
|
2568
|
+
git(SRC, "fetch", "--quiet", "origin", "main");
|
|
2569
|
+
head = git(SRC, "rev-parse", "origin/main");
|
|
2570
|
+
tags = releases("origin", SRC);
|
|
2571
|
+
const last = tags.at(-1);
|
|
2572
|
+
items = last ? changeItems(SRC, `${last.sha}..${head}`) : [];
|
|
2573
|
+
} catch (e) {
|
|
2574
|
+
return warn(`release: ${reason(e)}`), 2;
|
|
2575
|
+
}
|
|
2576
|
+
const last = tags.at(-1);
|
|
2577
|
+
if (last?.sha === head) return warn(`release: origin/main ${short(head)} is already in release ${last.tag}`), 1;
|
|
2578
|
+
if (last && !items.length) return warn(`release: the flow hasn't changed since ${last.tag} — no release needed`), 1;
|
|
2579
|
+
const notes = last ? [`Flow changes since ${last.tag}:`, "", ...items].join("\n") : "First release: projects install and update the flow by Elves releases, not by main.";
|
|
2580
|
+
const day = `v${new Date().toISOString().slice(0, 10).replaceAll("-", ".")}`;
|
|
2581
|
+
const names = new Set(tags.map((t) => t.tag));
|
|
2582
|
+
let tag = day;
|
|
2583
|
+
for (let n = 1; names.has(tag); n++) tag = `${day}.${n}`;
|
|
2584
|
+
note(`${tag} → ${short(head)}\n\n${notes}`);
|
|
2585
|
+
if (preview) return 0;
|
|
2586
|
+
try {
|
|
2587
|
+
execFileSync("gh", ["release", "create", tag, "--repo", SOURCE, "--target", head, "--title", tag, "--notes", notes], { stdio: ["ignore", "pipe", "pipe"] });
|
|
2588
|
+
} catch (e) {
|
|
2589
|
+
return warn(`release: gh release create — ${reason(e)}`), 2;
|
|
2590
|
+
}
|
|
2591
|
+
note(`\nRelease ${tag}: ${RELEASES}/tag/${tag}`);
|
|
2592
|
+
return 0;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
/**
|
|
2596
|
+
* The npm version of a release tag: vYYYY.MM.DD.N → YYYY.MMDD.N, without a patch — .0. Semver has no leading zeros, and
|
|
2597
|
+
* a same-day patch needs a number of its own: a prerelease (`-1`) sorts below the release itself, build metadata (`+1`)
|
|
2598
|
+
* npm doesn't tell apart. MMDD as a number grows with the date — versions sort as tags. Not a release tag — null.
|
|
2599
|
+
*/
|
|
2600
|
+
function npmVersion(tag: string): string | null {
|
|
2601
|
+
const m = RELEASE_TAG.exec(tag);
|
|
2602
|
+
return m ? `${Number(m[1])}.${Number(m[2]) * 100 + Number(m[3])}.${Number(m[4] ?? 0)}` : null;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
/**
|
|
2606
|
+
* A CommonJS module that names its own file (`__filename`: the knip reporter's path for `deadCode`) — the bundler would
|
|
2607
|
+
* bake the build machine's path into it, and the package would lead into nowhere. It stays a file of its own
|
|
2608
|
+
* (`--external`), copied next to the chunks that import it: the chunks go to CHUNKS beside it, the import keeps its
|
|
2609
|
+
* `./` path.
|
|
2610
|
+
*/
|
|
2611
|
+
const OWN_FILE = ["skills/spec/scripts/knip-hints.cjs"];
|
|
2612
|
+
/** Where the build's chunks go, from dist/: next to OWN_FILE. */
|
|
2613
|
+
const CHUNKS = "spec/scripts";
|
|
2614
|
+
|
|
2615
|
+
/** The modules built for Node: each export of package.json whose `default` is the build of its `bun` source. */
|
|
2616
|
+
function builtExports(pkg: { exports?: unknown }): { src: string; out: string }[] {
|
|
2617
|
+
const entries = pkg.exports && typeof pkg.exports === "object" ? Object.values(pkg.exports) : [];
|
|
2618
|
+
return entries.flatMap((e) => (e && typeof e === "object" && "bun" in e && "default" in e ? [{ src: String(e.bun), out: String(e.default) }] : []));
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
/**
|
|
2622
|
+
* `pack`, CI on a release tag: the npm package of the release — the version from the tag into package.json, the release
|
|
2623
|
+
* commit into its `gitHead` (the package knows its SHA without the network: the manifest of an install from it and
|
|
2624
|
+
* what `update` lists — `sourceSha`) and the modules a project imports (`builtExports`) built into dist/. Node doesn't
|
|
2625
|
+
* strip types in node_modules
|
|
2626
|
+
* (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING): a project's `eslint.config.js`, `node --test`, Playwright and Jest load
|
|
2627
|
+
* the JS (`default`), Bun and the type checker — the TypeScript source (`bun`, `types`). One build with shared chunks:
|
|
2628
|
+
* the modules share the language module and the TypeScript parser. A built module is a project's import, never the entry
|
|
2629
|
+
* point: `import.meta.main` is false — Bun's build for Node turns it into `require.main == require.module`, true under
|
|
2630
|
+
* Node's ESM, and `github` would run its command line on import. The build runs on CI and works at a project: a path of
|
|
2631
|
+
* the build machine in it (OWN_FILE) is a refusal. `npm publish` is the workflow's next step. The build fails —
|
|
2632
|
+
* package.json stays as it was.
|
|
2633
|
+
*/
|
|
2634
|
+
function pack(tag: string | undefined): number {
|
|
2635
|
+
const version = tag === undefined ? null : npmVersion(tag);
|
|
2636
|
+
if (!version) return warn(`pack: a release tag is needed — vYYYY.MM.DD or vYYYY.MM.DD.N, not «${tag ?? ""}»`), 2;
|
|
2637
|
+
if (!existsSync(path.join(SRC, ".git"))) return warn("pack — only from an Elves checkout: bun <checkout>/bin/elves.ts pack <tag>"), 2;
|
|
2638
|
+
const file = path.join(SRC, "package.json");
|
|
2639
|
+
const pkg = JSON.parse(readFileSync(file, "utf8"));
|
|
2640
|
+
const entries = builtExports(pkg);
|
|
2641
|
+
const skills = path.join(SRC, "skills");
|
|
2642
|
+
const dist = path.join(SRC, "dist");
|
|
2643
|
+
rmSync(dist, { recursive: true, force: true });
|
|
2644
|
+
const own = OWN_FILE.map((rel) => ({ from: path.join(SRC, rel), to: path.join(dist, path.relative(skills, path.join(SRC, rel))) }));
|
|
2645
|
+
const args = ["--target=node", "--format=esm", "--splitting", "--define", "import.meta.main=false", `--root=${skills}`, `--outdir=${dist}`, `--chunk-naming=${CHUNKS}/[name]-[hash].[ext]`, ...own.flatMap((o) => ["--external", `*${path.basename(o.from)}`])];
|
|
2646
|
+
const r = spawnSync(process.execPath, ["--no-env-file", "build", ...args, ...entries.map((e) => path.join(SRC, e.src))], { encoding: "utf8" });
|
|
2647
|
+
if (r.error || r.status !== 0) return warn(`pack: bun build — ${r.error?.message ?? (r.stderr || r.stdout).trim().split("\n").slice(-3).join("; ")}`), 2;
|
|
2648
|
+
for (const o of own) cpSync(o.from, o.to);
|
|
2649
|
+
const missing = entries.filter((e) => !existsSync(path.join(SRC, e.out)));
|
|
2650
|
+
if (missing.length) return warn(`pack: the build has no ${missing.map((e) => e.out).join(", ")} — exports of package.json lead past it`), 2;
|
|
2651
|
+
const baked = [...walk(dist).entries()].filter(([, file]) => readFileSync(file, "utf8").includes(SRC)).map(([rel]) => `dist/${rel}`);
|
|
2652
|
+
if (baked.length) return warn(`pack: ${baked.join(", ")} — the build machine's path ${SRC} is baked in: the package would lead into nowhere`), 2;
|
|
2653
|
+
writeFileSync(file, JSON.stringify({ ...pkg, version, gitHead: git(SRC, "rev-parse", "HEAD") }, null, 2) + "\n");
|
|
2654
|
+
note(`${PACKAGE}@${version} — release ${tag}: dist/ ← ${entries.map((e) => e.src).join(", ")}; next: npm publish`);
|
|
2655
|
+
return 0;
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
/**
|
|
2659
|
+
* The `install` question in a project about the `auto` setting: "yes" allows the agent everything listed without a
|
|
2660
|
+
* "yes" for each action.
|
|
2661
|
+
*/
|
|
2662
|
+
const AUTO_QUESTION = "Let the agent do tasks entirely by itself — answers by recommendation, commit, push, fixing CI, merge on green checks?";
|
|
2663
|
+
|
|
2664
|
+
/**
|
|
2665
|
+
* The `install` question in a project about the language: in it the agent talks to the human and writes tasks,
|
|
2666
|
+
* prompts and documents.
|
|
2667
|
+
*/
|
|
2668
|
+
const LANGUAGE_QUESTION = "Project language the agent works in — dialog, tasks, prompts, documents (code: ru, en, …)?";
|
|
2669
|
+
|
|
2670
|
+
/**
|
|
2671
|
+
* The answer to a question in the terminal: the line trimmed of spaces; input closed or unreadable — null.
|
|
2672
|
+
* Synchronous and without dependencies — `readSync(0)`: `main` is synchronous.
|
|
2673
|
+
*/
|
|
2674
|
+
function readAnswer(prompt: string) {
|
|
2675
|
+
const buf = Buffer.alloc(1024);
|
|
2676
|
+
process.stdout.write(prompt);
|
|
2677
|
+
try {
|
|
2678
|
+
const n = readSync(0, buf, 0, buf.length, null);
|
|
2679
|
+
return n ? buf.toString("utf8", 0, n).trim() : null;
|
|
2680
|
+
} catch {
|
|
2681
|
+
return null;
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
/**
|
|
2686
|
+
* A yes/no question in the terminal: Enter — the default value (shown in upper case), an unclear answer — the
|
|
2687
|
+
* question again. Input closed or unreadable — the default value.
|
|
2688
|
+
*/
|
|
2689
|
+
function ask(question: string, def: boolean) {
|
|
2690
|
+
for (;;) {
|
|
2691
|
+
const answer = readAnswer(`${question} [${def ? "Y/n" : "y/N"}] `)?.toLowerCase();
|
|
2692
|
+
if (!answer) return def;
|
|
2693
|
+
if (["y", "yes"].includes(answer)) return true;
|
|
2694
|
+
if (["n", "no"].includes(answer)) return false;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
/**
|
|
2699
|
+
* A language question in the terminal: Enter — the default value, not a language code — the question again; input
|
|
2700
|
+
* closed — the default.
|
|
2701
|
+
*/
|
|
2702
|
+
function askLanguage(def: string) {
|
|
2703
|
+
for (;;) {
|
|
2704
|
+
const answer = readAnswer(`${LANGUAGE_QUESTION} [${def}] `);
|
|
2705
|
+
if (!answer) return def;
|
|
2706
|
+
if (LANGUAGE_CODE.test(answer)) return answer;
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
/**
|
|
2711
|
+
* `--lang <code>` or `--lang=<code>` among the flags: the code and the other flags; a flag without a value — code "",
|
|
2712
|
+
* the code check will reject it.
|
|
2713
|
+
*/
|
|
2714
|
+
function takeLang(flags: string[]): { lang: string | undefined; rest: string[] } {
|
|
2715
|
+
let lang: string | undefined;
|
|
2716
|
+
const rest: string[] = [];
|
|
2717
|
+
for (let i = 0; i < flags.length; i++) {
|
|
2718
|
+
const f = flags[i] ?? "";
|
|
2719
|
+
if (f === "--lang") lang = flags[++i] ?? "";
|
|
2720
|
+
else if (f.startsWith("--lang=")) lang = f.slice("--lang=".length);
|
|
2721
|
+
else rest.push(f);
|
|
2722
|
+
}
|
|
2723
|
+
return { lang, rest };
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
/**
|
|
2727
|
+
* The `install` hint with auto. The Claude Code app rejects a merge without human review even in auto mode: the
|
|
2728
|
+
* machine's permission rule decides it, and that rule applies to all the machine's repositories — wider than the
|
|
2729
|
+
* project. So `install` names the rule instead of setting it: otherwise the owner learns about it from a refusal at
|
|
2730
|
+
* the end of the first task.
|
|
2731
|
+
*/
|
|
2732
|
+
const MERGE_HINT = (language: string) =>
|
|
2733
|
+
[
|
|
2734
|
+
"Claude Code: for the session to merge its own PR, the machine needs a permission rule — the owner adds it, install doesn't change the settings:",
|
|
2735
|
+
' ~/.claude/settings.json → permissions.allow: "Bash(gh pr merge *)"',
|
|
2736
|
+
` without the rule the app rejects a merge without human review — the session ends with «${locale.names(language).lastLine.remaining} …», the merge is left to the owner`,
|
|
2737
|
+
].join("\n");
|
|
2738
|
+
|
|
2739
|
+
/**
|
|
2740
|
+
* No permission to write — usually an agent's sandbox: Claude Code's refuses writes to `.claude/skills` of a project
|
|
2741
|
+
* and to `~/.agents`. Instead of a stack — what was refused and the same command for the owner's terminal: the install
|
|
2742
|
+
* order (installSkills) lets a repeat run finish it. Not a permission error — false, the caller handles it.
|
|
2743
|
+
* @returns the error was reported
|
|
2744
|
+
*/
|
|
2745
|
+
function denied(e: unknown, argv: string[], global: boolean) {
|
|
2746
|
+
const err = e as { code?: unknown; path?: unknown; dest?: unknown };
|
|
2747
|
+
if (err?.code !== "EPERM" && err?.code !== "EACCES") return false;
|
|
2748
|
+
const file = String(err.dest ?? err.path ?? "");
|
|
2749
|
+
const home = os.homedir();
|
|
2750
|
+
const shown = global && file.startsWith(home + path.sep) ? `~${file.slice(home.length)}` : path.relative(projectRoot(), file);
|
|
2751
|
+
warn(`${argv[0]}: no permission to write ${shown} (${err.code}) — an agent's sandbox? Run the same command in your own terminal, a repeat run finishes it: ${RUN} ${argv.join(" ")}`);
|
|
2752
|
+
return true;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
const FLAGS: Record<string, string[]> = { install: ["-g", "--global", "--link", "--auto", "--no-auto"], check: ["-g", "--global", "--hook"], update: ["-g", "--global"], release: ["--dry-run"], pack: [] };
|
|
2756
|
+
|
|
2757
|
+
/** `pack <tag>`: the tag — the first argument that isn't a flag; the other arguments. */
|
|
2758
|
+
function takeTag(args: string[]): { tag: string | undefined; rest: string[] } {
|
|
2759
|
+
const i = args.findIndex((a) => !a.startsWith("-"));
|
|
2760
|
+
return i < 0 ? { tag: undefined, rest: args } : { tag: args[i], rest: args.filter((_, j) => j !== i) };
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
async function main(argv: string[]): Promise<number> {
|
|
2764
|
+
const [cmd = "", ...args] = argv;
|
|
2765
|
+
// a skill's script — at this package's version, the project's: no launcher, the help is the script's
|
|
2766
|
+
if (Object.hasOwn(SCRIPTS, cmd)) return runScript(cmd, args);
|
|
2767
|
+
const help = (await helpOf(argv)) ?? (cmd === "help" ? await overview() : undefined);
|
|
2768
|
+
if (help) return note(help), 0;
|
|
2769
|
+
const global = args.includes("-g") || args.includes("--global");
|
|
2770
|
+
const hookMode = cmd === "check" && args.includes("--hook");
|
|
2771
|
+
// the launcher restarts before it checks the command and flags (`toRelease`): bunx keeps it as old as its first run,
|
|
2772
|
+
// and it mustn't reject what the release knows. "Not installed" needs no release: the hook in someone else's project
|
|
2773
|
+
// doesn't go to the network for nothing; release — only from a clone, pack — from a checkout
|
|
2774
|
+
if (cmd !== "release" && cmd !== "pack" && (cmd !== "check" || (hookMode ? [true, false].some((g) => target(g).installed) : target(global).installed))) {
|
|
2775
|
+
try {
|
|
2776
|
+
const code = toRelease(argv);
|
|
2777
|
+
if (code !== null) return code;
|
|
2778
|
+
} catch (e) {
|
|
2779
|
+
if (hookMode) return hook(e);
|
|
2780
|
+
if (cmd === "check") return process.stderr.write(unavailable(global, e).text + "\n"), 2;
|
|
2781
|
+
return warn(`${cmd}: ${reason(e)}`), 2;
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
// --lang with a value — only for install, the tag — only for pack: for the other commands it's an unknown flag
|
|
2785
|
+
const { lang, rest } = cmd === "install" ? takeLang(args) : { lang: undefined, rest: args };
|
|
2786
|
+
const { tag, rest: flags } = cmd === "pack" ? takeTag(rest) : { tag: undefined, rest };
|
|
2787
|
+
const allowed = Object.hasOwn(HELP, cmd) ? FLAGS[cmd] : undefined;
|
|
2788
|
+
const unknown = allowed ? flags.filter((f) => !allowed.includes(f)) : [];
|
|
2789
|
+
if (!allowed || unknown.length) {
|
|
2790
|
+
process.stderr.write((!allowed && cmd ? `unknown command: ${cmd}\n\n` : unknown.length ? `unknown flag: ${unknown[0]}\n\n` : "") + (await overview()) + "\n");
|
|
2791
|
+
return 2;
|
|
2792
|
+
}
|
|
2793
|
+
if (lang !== undefined && (global || !LANGUAGE_CODE.test(lang))) {
|
|
2794
|
+
warn(`--lang <code> — the project language the agent works in: without -g, a BCP 47 code — ru, en, pt-BR${global ? "" : `; «${lang}» is not a language code`}`);
|
|
2795
|
+
return 2;
|
|
2796
|
+
}
|
|
2797
|
+
if (cmd === "release") return releaseCmd(flags.includes("--dry-run"));
|
|
2798
|
+
if (cmd === "pack") return pack(tag);
|
|
2799
|
+
if (cmd === "check") {
|
|
2800
|
+
if (hookMode) return hook();
|
|
2801
|
+
let r: Result;
|
|
2802
|
+
try {
|
|
2803
|
+
r = check(global);
|
|
2804
|
+
} catch (e) {
|
|
2805
|
+
r = unavailable(global, e);
|
|
2806
|
+
}
|
|
2807
|
+
r = withMachine(global, r);
|
|
2808
|
+
(r.code === 2 ? process.stderr : process.stdout).write(r.text + "\n");
|
|
2809
|
+
return r.code;
|
|
2810
|
+
}
|
|
2811
|
+
if (cmd === "update") {
|
|
2812
|
+
try {
|
|
2813
|
+
return update(global);
|
|
2814
|
+
} catch (e) {
|
|
2815
|
+
return denied(e, argv, global) || warn(`update: ${reason(e)}`), 2;
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
const linkMode = flags.includes("--link");
|
|
2819
|
+
if (linkMode && (!global || !existsSync(path.join(SRC, ".git")))) {
|
|
2820
|
+
warn("--link — only with -g and only from an Elves clone: a project keeps the package version, not a link to a local clone");
|
|
2821
|
+
return 2;
|
|
2822
|
+
}
|
|
2823
|
+
const [on, off] = [flags.includes("--auto"), flags.includes("--no-auto")];
|
|
2824
|
+
if ((on || off) && (global || (on && off))) {
|
|
2825
|
+
warn("--auto and --no-auto — the project setting «a task entirely without asking»: without -g and only one of the two");
|
|
2826
|
+
return 2;
|
|
2827
|
+
}
|
|
2828
|
+
try {
|
|
2829
|
+
if (global) return installGlobal(SRC, linkMode), 0;
|
|
2830
|
+
const root = projectRoot();
|
|
2831
|
+
// questions — only here and only in a terminal: `update` and `check` (the hook at session start) go through
|
|
2832
|
+
// installProject without them
|
|
2833
|
+
const tty = isatty(0) && isatty(1);
|
|
2834
|
+
const language = lang ?? (tty ? askLanguage(languageOf(root)) : languageOf(root));
|
|
2835
|
+
const before = autoOf(root);
|
|
2836
|
+
const auto = on ? true : off ? false : tty ? ask(AUTO_QUESTION, before) : before;
|
|
2837
|
+
if (isElvesClone(root)) installClone(root, auto, language);
|
|
2838
|
+
else installProject(root, auto, language);
|
|
2839
|
+
if (auto) note(MERGE_HINT(language));
|
|
2840
|
+
return 0;
|
|
2841
|
+
} catch (e) {
|
|
2842
|
+
if (denied(e, argv, global)) return 2;
|
|
2843
|
+
throw e;
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
process.exitCode = await main(process.argv.slice(2));
|