From e34a5f1284f726f218490db918ec071af4c84180 Mon Sep 17 00:00:00 2001 From: Chris <16280532+chrisl10@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:09:59 -0700 Subject: [PATCH 1/2] Add global Codex Bee Army manager --- AGENTS.md | 7 +- README.md | 56 +++++-- package.json | 16 ++ scripts/bee-army.mjs | 327 +++++++++++++++++++++++++++++++++++++++++ test/bee-army.test.mjs | 66 +++++++++ 5 files changed, 461 insertions(+), 11 deletions(-) create mode 100644 package.json create mode 100755 scripts/bee-army.mjs create mode 100644 test/bee-army.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 0a0ff5f..7e2cfef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Agents -> What agents are, how they work, and how they map across Cursor, Claude Code, and Claude Cowork. +> What agents are, how they work, and how they map across Cursor, Claude Code, Codex, and Claude Cowork. ## What an agent is @@ -37,9 +37,12 @@ Identity, responsibilities, hard rules, and the workflow the agent follows. |---|---|---| | **Cursor** | `.cursor/agents/*.md` | Custom agents the Cursor orchestrator routes to. This is the source of truth in this repo. | | **Claude Code** | `.claude/agents/*.md` | Subagents. Same Markdown + frontmatter shape, so the files port directly. Frontmatter may also carry `tools` and `model`. | +| **Codex** | globally generated `~/.codex/agents/*.toml` | `bee-army` translates each canonical Bee into a native Codex agent and installs its paired Stinger globally under `~/.agents/skills/`. | | **Claude Cowork** | runs on the Claude Agent SDK | Cowork is built on the same engine as Claude Code, so it executes subagents through its Agent and Task tooling. Cowork's primary distributable unit is the skill, so most cross-harness sharing happens at the skill layer. | -The short version: agents are a first-class concept in Cursor and Claude Code, and they share a file format, so the same agent definition works in both. Cowork leans on skills as the portable unit, while still running agent-style delegation under the hood. +The short version: agents are a first-class concept in Cursor, Claude Code, and Codex. Cursor and Claude share a Markdown format. Codex uses native TOML definitions generated from the same canonical Bee and pairs them with the same Stingers. Cowork leans on skills as the portable unit, while still running agent-style delegation under the hood. + +Codex installation is global-only. The manager must never write `.codex/`, `.agents/`, or `AGENTS.md` into a user's project. ## The orchestration model in this repo diff --git a/README.md b/README.md index e224695..32df1ba 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ --- -That Git Life is a [Legion Code Inc.](https://www.legioncodeinc.com) project, and right now it is a blueprint, not a binary. There is no package to install yet. What lives in this repo today is two things: the plan for the product, and the AI tooling that will build it. The plan is a `library/` of PRDs and knowledge docs that spec exactly what gets built. The tooling is a cross-harness army of agents and skills (for Cursor, Claude Code, and Claude Cowork) that do the building. +That Git Life is a [Legion Code Inc.](https://www.legioncodeinc.com) project. The product remains a blueprint, while the repository also includes a small `bee-army` CLI for globally installing and updating its AI tooling across Cursor, Claude Code, and Codex. The plan is a `library/` of PRDs and knowledge docs that spec exactly what gets built. The tooling is a cross-harness army of agents and skills that do the building. The product these plans describe will be a globally-installed npm package: a single-command installer for Windows, macOS, and Linux, an always-on local service on `http://localhost:3050`, and a React web UI that standardizes new repos, scans existing ones for drift, manages your GitHub root, and syncs skills and agents for Cursor or Claude Code. That is the destination. This repo is how we get there. @@ -41,7 +41,7 @@ The product these plans describe will be a globally-installed npm package: a sin ## What this repo is -This repo is the **planning and source of truth** for the product. Cursor (or another AI coding agent) reads the docs here and builds the product against them. Nothing here is shipping code yet. This is the blueprint. +This repo is the **planning and source of truth** for the product. Cursor (or another AI coding agent) reads the docs here and builds the product against them. The `bee-army` CLI is intentionally limited to distributing the repository's existing AI assets; it is not the planned product service or web UI. | Where | What's in it | |---|---| @@ -49,6 +49,7 @@ This repo is the **planning and source of truth** for the product. Cursor (or an | [`.cursor/`](.cursor/) | Cursor agents, skills, and rules. The source of truth for the asset system. | | [`.claude/`](.claude/) | The same agents and skills, in the structure Claude Code consumes. | | [`.cowork/`](.cowork/) | Every skill packaged as an installable `.skill` for Claude Cowork. | +| [`scripts/bee-army.mjs`](scripts/bee-army.mjs) | Global installer and pinned cross-harness update manager for Cursor, Claude Code, and Codex. | | `AGENTS.md` · `SKILLS.md` · `HOOKS.md` · `RULES.md` | Deep explainers for each asset type and how they work across harnesses. | | `README.md` | This file. | | `LICENSE.md` | License. | @@ -57,9 +58,46 @@ The work lives in `library/`. The agents read the PRDs there and build the produ --- +## Global Bee Army installer + +The installer treats `.cursor/` as the canonical asset source and installs one pinned upstream commit into all three CLI/IDE harnesses: + +| Harness | Global destinations | +|---|---| +| Cursor | `~/.cursor/agents`, `~/.cursor/skills`, `~/.cursor/commands`, `~/.cursor/rules` | +| Claude Code | `~/.claude/agents`, `~/.claude/skills`, `~/.claude/commands` | +| Codex | `~/.codex/agents`, `~/.codex/skills/bee-army-update`, `~/.agents/skills` | + +From a checkout, install the command and then preview the global changes: + +```bash +npm link +bee-army check +bee-army preview +bee-army validate +bee-army install --apply +bee-army doctor +``` + +Later upstream changes use the same reviewed flow: + +```bash +bee-army check +bee-army preview +bee-army update --apply +``` + +The manager records the exact upstream commit and hashes every managed file. It refuses to overwrite locally modified managed files, backs up every touched path before applying, and supports `bee-army rollback --apply`. It copies or translates declarative assets but never executes scripts from the upstream checkout. + +This is a **global-only installation**. Running any command must not create `.codex/`, `.agents/`, or `AGENTS.md` inside the current project. Codex receives native TOML Bee definitions plus the shared Stingers each Bee must read before working. Start a fresh Codex session after installation or update so it discovers the new global assets. + +Environment overrides are available for non-default layouts and tests: `CODEX_HOME`, `CLAUDE_HOME`, `CURSOR_HOME`, `AGENTS_HOME`, `BEE_ARMY_HOME`, `BEE_ARMY_STATE_ROOT`, `BEE_ARMY_UPSTREAM_URL`, and `BEE_ARMY_UPSTREAM_BRANCH`. + +--- + ## The asset system -This repo ships a full army of AI assets that work across Cursor, Claude Code, and Claude Cowork. There are four kinds. Each has a deep-dive doc. +This repo ships a full army of AI assets that work across Cursor, Claude Code, Codex, and Claude Cowork. There are four kinds. Each has a deep-dive doc. ### Agents @@ -89,12 +127,12 @@ Always-on guidance that constrains every agent at all times: house style, safety ## Cross-harness compatibility -| Asset | Cursor | Claude Code | Claude Cowork | -|---|---|---|---| -| **Agents** | `.cursor/agents/*.md` | `.claude/agents/*.md` | runs on the Agent SDK; skills are the portable unit | -| **Skills** | `.cursor/skills//` | `.claude/skills//` | `.cowork/skills/.skill` (one-click install) | -| **Hooks** | `.cursor/hooks.json` | `.claude/settings.json` | not user-configurable | -| **Rules** | `.cursor/rules/*.mdc` | `CLAUDE.md` | `CLAUDE.md` + project instructions | +| Asset | Cursor | Claude Code | Codex | Claude Cowork | +|---|---|---|---|---| +| **Agents** | `.cursor/agents/*.md` | `.claude/agents/*.md` | globally generated `~/.codex/agents/*.toml` | runs on the Agent SDK; skills are the portable unit | +| **Skills** | `.cursor/skills//` | `.claude/skills//` | globally installed `~/.agents/skills//` | `.cowork/skills/.skill` (one-click install) | +| **Hooks** | `.cursor/hooks.json` | `.claude/settings.json` | not used by the Bee Army | not user-configurable | +| **Rules** | `.cursor/rules/*.mdc` | `CLAUDE.md` | global Codex instructions | `CLAUDE.md` + project instructions | Skills port one-to-one across all three. Agents share a format across Cursor and Claude Code. Hooks and rules use different formats per harness, so they are translated rather than copied. The Cowork skill copies have their angle brackets swapped for curly braces so they survive import (see [SKILLS.md](./SKILLS.md)). diff --git a/package.json b/package.json new file mode 100644 index 0000000..1584df3 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "that-git-life", + "version": "0.1.0", + "private": true, + "description": "Global cross-harness installer for the That Git Life Bee Army", + "type": "module", + "bin": { + "bee-army": "scripts/bee-army.mjs" + }, + "scripts": { + "test": "node --test test/*.test.mjs" + }, + "engines": { + "node": ">=20" + } +} diff --git a/scripts/bee-army.mjs b/scripts/bee-army.mjs new file mode 100755 index 0000000..b38b2c5 --- /dev/null +++ b/scripts/bee-army.mjs @@ -0,0 +1,327 @@ +#!/usr/bin/env node + +import { + closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, + readFileSync, readdirSync, renameSync, rmSync, writeFileSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { homedir } from "node:os"; +import { basename, dirname, join, relative, sep } from "node:path"; + +const HOME = process.env.BEE_ARMY_HOME || homedir(); +const CODEX_HOME = process.env.CODEX_HOME || join(HOME, ".codex"); +const CLAUDE_HOME = process.env.CLAUDE_HOME || join(HOME, ".claude"); +const CURSOR_HOME = process.env.CURSOR_HOME || join(HOME, ".cursor"); +const SHARED_SKILLS = process.env.AGENTS_HOME || join(HOME, ".agents", "skills"); +const STATE_ROOT = process.env.BEE_ARMY_STATE_ROOT || join(HOME, ".local", "share", "that-git-life"); +const SOURCE_ROOT = join(STATE_ROOT, "upstream"); +const MANIFEST_PATH = join(STATE_ROOT, "manifest.json"); +const BACKUPS_ROOT = join(STATE_ROOT, "backups"); +const LOCK_PATH = join(STATE_ROOT, "update.lock"); +const UPSTREAM_URL = process.env.BEE_ARMY_UPSTREAM_URL || "https://github.com/legioncodeinc/that-git-life.git"; +const UPSTREAM_BRANCH = process.env.BEE_ARMY_UPSTREAM_BRANCH || "main"; +const TEXT_EXTENSIONS = new Set([".md", ".mdc", ".txt", ".json", ".yaml", ".yml", ".toml", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".sh", ".py", ".css", ".html"]); + +function fail(message) { throw new Error(message); } +function run(command, args, options = {}) { + return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...options }).trim(); +} +function ensureSource() { + mkdirSync(STATE_ROOT, { recursive: true }); + if (!existsSync(join(SOURCE_ROOT, ".git"))) run("git", ["clone", "--branch", UPSTREAM_BRANCH, UPSTREAM_URL, SOURCE_ROOT]); +} +function fetchUpstream() { + ensureSource(); + run("git", ["-C", SOURCE_ROOT, "fetch", "origin", UPSTREAM_BRANCH, "--prune"]); + return run("git", ["-C", SOURCE_ROOT, "rev-parse", `origin/${UPSTREAM_BRANCH}`]); +} +function checkoutCommit(commit) { + if (run("git", ["-C", SOURCE_ROOT, "status", "--porcelain"])) fail(`Managed upstream checkout is dirty: ${SOURCE_ROOT}`); + run("git", ["-C", SOURCE_ROOT, "switch", "--detach", commit]); +} +function loadManifest() { return existsSync(MANIFEST_PATH) ? JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) : null; } +function sha256File(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); } +function sha256Text(text) { return createHash("sha256").update(text).digest("hex"); } +function walkFiles(root) { + if (!existsSync(root)) return []; + const files = []; + const visit = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) visit(path); + else if (entry.isFile()) files.push(path); + } + }; + visit(root); + return files.sort(); +} +function extension(path) { + const name = basename(path); + const index = name.lastIndexOf("."); + return index === -1 ? "" : name.slice(index); +} +function isText(path) { return TEXT_EXTENSIONS.has(extension(path)) || basename(path) === "SKILL.md"; } +function parseFrontmatter(text, path, { requireName = true } = {}) { + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (!match) fail(`Missing frontmatter: ${path}`); + const fields = {}; + for (const line of match[1].split(/\r?\n/)) { + const field = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); + if (field) fields[field[1]] = field[2].replace(/^['"]|['"]$/g, ""); + } + if ((requireName && !fields.name) || !fields.description) fail(`Frontmatter is missing required fields: ${path}`); + return { fields, body: text.slice(match[0].length) }; +} +function pairedStinger(agentName, body, skillRoot) { + const explicit = body.match(/(?:\.cursor\/|\.claude\/|\.\.\/|\/Users\/[^\s`)]*\/(?:\.cursor|\.claude)\/)skills\/([^/`\s)]+)/)?.[1]; + const inferred = agentName.replace(/-worker-bee$/, "-stinger"); + const name = explicit || inferred; + if (!name || !existsSync(join(skillRoot, name, "SKILL.md"))) fail(`Bee ${agentName} does not resolve to an existing paired Stinger (tried ${name || "none"})`); + return name; +} +function replaceHarnessPaths(text, harness) { + const skillRoot = harness === "claude" ? join(CLAUDE_HOME, "skills") : harness === "codex" ? SHARED_SKILLS : ".cursor/skills"; + const agentRoot = harness === "claude" ? join(CLAUDE_HOME, "agents") : harness === "codex" ? join(CODEX_HOME, "agents") : ".cursor/agents"; + let output = text + .replace(/\/Users\/[^/\s`)]*\/\.cursor\/skills/g, skillRoot) + .replace(/\/Users\/[^/\s`)]*\/\.claude\/skills/g, skillRoot) + .replaceAll(".cursor/skills", skillRoot).replaceAll(".claude/skills", skillRoot) + .replace(/\/Users\/[^/\s`)]*\/\.cursor\/agents/g, agentRoot) + .replace(/\/Users\/[^/\s`)]*\/\.claude\/agents/g, agentRoot) + .replaceAll(".cursor/agents", agentRoot).replaceAll(".claude/agents", agentRoot) + .replaceAll("ai-tools/skills/", `${skillRoot}/`); + if (harness === "codex") { + output = output + .replaceAll("../skills/", `${skillRoot}/`) + .replaceAll("Use the Task tool at the main agent level.", "Use Codex native subagents from the main agent level.") + .replaceAll("Cursor cannot reliably nest-spawn.", "Keep all spawning at the root and do not nest subagents.") + .replaceAll("Cursor-specific", "canonical").replaceAll("Cursor skill", "Codex skill") + .replaceAll("Cursor orchestrator", "Codex orchestrator").replaceAll("invoke the Bee", "spawn the Bee").replaceAll("Invoke the Bee", "Spawn the Bee") + .replace(/> You are ``\. Before doing anything else, read your paired Stinger at `[^`]+` in full and follow it as your operating manual\. Then:/, + `> You are \`\`. Before doing anything else, read your native Bee definition at \`${agentRoot}/.toml\` in full. Then read your paired Stinger at \`${skillRoot}//SKILL.md\` in full and follow both as your operating manual. Then:`) + .replace(/\]\(\.\.\/\.\.\/\.\.\/agents\/([^)]+)\.md\)/g, `](${agentRoot}/$1.toml)`); + } + return output; +} +function addOutput(outputs, stageRoot, harness, target, content, source) { + const stagePath = join(stageRoot, "files", sha256Text(target).slice(0, 20)); + mkdirSync(dirname(stagePath), { recursive: true }); + writeFileSync(stagePath, content); + outputs.push({ harness, target, stagePath, source, hash: sha256File(stagePath) }); +} +function addCopiedTree(outputs, stageRoot, harness, sourceRoot, targetRoot, transformText = false) { + for (const sourcePath of walkFiles(sourceRoot)) { + const target = join(targetRoot, relative(sourceRoot, sourcePath)); + if (transformText && isText(sourcePath)) addOutput(outputs, stageRoot, harness, target, replaceHarnessPaths(readFileSync(sourcePath, "utf8"), harness), relative(SOURCE_ROOT, sourcePath)); + else { + const stagePath = join(stageRoot, "files", sha256Text(target).slice(0, 20)); + mkdirSync(dirname(stagePath), { recursive: true }); + copyFileSync(sourcePath, stagePath); + outputs.push({ harness, target, stagePath, source: relative(SOURCE_ROOT, sourcePath), hash: sha256File(stagePath) }); + } + } +} +function launcherSkill(commandPath, skillName) { + const parsed = parseFrontmatter(readFileSync(commandPath, "utf8"), commandPath, { requireName: false }); + const body = replaceHarnessPaths(parsed.body, "codex").replaceAll("/the-beekeeper", "$the-beekeeper").replaceAll("/the-smoker", "$the-smoker"); + return `---\nname: ${skillName}\ndescription: ${JSON.stringify(parsed.fields.description)}\n---\n\n# ${skillName}\n\n${body.trim()}\n`; +} +function updateInstructions(harness) { + const invocation = harness === "codex" ? "$bee-army-update" : "/bee-army-update"; + const frontmatter = harness === "codex" + ? `name: bee-army-update\ndescription: Manage the global That Git Life Bee Army shared by Codex, Claude Code, and Cursor.` + : `description: Check, preview, update, diagnose, or roll back the global That Git Life Bee Army shared by Codex, Claude, and Cursor.`; + return `---\n${frontmatter}\n---\n\n# ${invocation}\n\nUse the deterministic global \`bee-army\` command. Run \`check\` and \`preview\` before \`update --apply\`. Use \`validate\` to stage translations without installing, \`doctor\` after installation, and \`rollback --apply\` only with explicit authorization. The command updates all three global harnesses together and must never create project-local scaffolding.\n`; +} +function generateStage(commit) { + const stageRoot = mkdtempSync(join(STATE_ROOT, "stage-")); + const outputs = []; + const cursorRoot = join(SOURCE_ROOT, ".cursor"); + const sourceSkills = join(cursorRoot, "skills"); + const sourceAgents = join(cursorRoot, "agents"); + const sourceCommands = join(cursorRoot, "commands"); + const agentFiles = readdirSync(sourceAgents).filter((name) => name.endsWith(".md")).sort(); + const skillDirs = readdirSync(sourceSkills, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort(); + const usableSkills = skillDirs.filter((name) => existsSync(join(sourceSkills, name, "SKILL.md"))); + if (!agentFiles.length || !usableSkills.length) fail("Upstream contains no usable Bee Army assets"); + const beeNames = new Set(); + for (const file of agentFiles) { + const sourcePath = join(sourceAgents, file); + const parsed = parseFrontmatter(readFileSync(sourcePath, "utf8"), sourcePath); + if (beeNames.has(parsed.fields.name)) fail(`Duplicate Bee name: ${parsed.fields.name}`); + beeNames.add(parsed.fields.name); + pairedStinger(parsed.fields.name, parsed.body, sourceSkills); + } + + addCopiedTree(outputs, stageRoot, "cursor", sourceAgents, join(CURSOR_HOME, "agents")); + addCopiedTree(outputs, stageRoot, "cursor", sourceSkills, join(CURSOR_HOME, "skills")); + addCopiedTree(outputs, stageRoot, "cursor", sourceCommands, join(CURSOR_HOME, "commands")); + addOutput(outputs, stageRoot, "cursor", join(CURSOR_HOME, "commands", "bee-army-update.md"), updateInstructions("cursor"), "generated:bee-army-update"); + if (existsSync(join(cursorRoot, "rules"))) addCopiedTree(outputs, stageRoot, "cursor", join(cursorRoot, "rules"), join(CURSOR_HOME, "rules")); + if (existsSync(join(cursorRoot, "model-comparison-matrix.md"))) addOutput(outputs, stageRoot, "cursor", join(CURSOR_HOME, "model-comparison-matrix.md"), readFileSync(join(cursorRoot, "model-comparison-matrix.md")), ".cursor/model-comparison-matrix.md"); + + addCopiedTree(outputs, stageRoot, "claude", sourceSkills, join(CLAUDE_HOME, "skills")); + addCopiedTree(outputs, stageRoot, "claude", sourceAgents, join(CLAUDE_HOME, "agents"), true); + addCopiedTree(outputs, stageRoot, "claude", sourceCommands, join(CLAUDE_HOME, "commands"), true); + addOutput(outputs, stageRoot, "claude", join(CLAUDE_HOME, "commands", "bee-army-update.md"), updateInstructions("claude"), "generated:bee-army-update"); + if (existsSync(join(cursorRoot, "model-comparison-matrix.md"))) addOutput(outputs, stageRoot, "claude", join(CLAUDE_HOME, "model-comparison-matrix.md"), readFileSync(join(cursorRoot, "model-comparison-matrix.md")), ".cursor/model-comparison-matrix.md"); + + addCopiedTree(outputs, stageRoot, "codex", sourceSkills, SHARED_SKILLS, true); + for (const file of agentFiles) { + const sourcePath = join(sourceAgents, file); + const parsed = parseFrontmatter(readFileSync(sourcePath, "utf8"), sourcePath); + const stinger = pairedStinger(parsed.fields.name, parsed.body, sourceSkills); + const instructions = [`You are ${parsed.fields.name}.`, `Before doing anything else, read your paired Stinger at ${join(SHARED_SKILLS, stinger, "SKILL.md")} in full and follow it as your operating manual.`, "Stay within the scope assigned by the parent Beekeeper. Return a concise result and verification evidence to the parent thread.", "", replaceHarnessPaths(parsed.body, "codex").trim(), ""].join("\n"); + const toml = [`name = ${JSON.stringify(parsed.fields.name)}`, `description = ${JSON.stringify(parsed.fields.description)}`, `developer_instructions = ${JSON.stringify(instructions)}`, ""].join("\n"); + addOutput(outputs, stageRoot, "codex", join(CODEX_HOME, "agents", `${parsed.fields.name}.toml`), toml, relative(SOURCE_ROOT, sourcePath)); + } + for (const skillName of ["the-beekeeper", "the-smoker"]) addOutput(outputs, stageRoot, "codex", join(SHARED_SKILLS, skillName, "SKILL.md"), launcherSkill(join(sourceCommands, `${skillName}.md`), skillName), `.cursor/commands/${skillName}.md`); + addOutput(outputs, stageRoot, "codex", join(CODEX_HOME, "skills", "bee-army-update", "SKILL.md"), updateInstructions("codex"), "generated:bee-army-update"); + validateStage(outputs, agentFiles.length); + return { stageRoot, outputs, commit, agentCount: agentFiles.length, skillCount: skillDirs.length, usableSkillCount: usableSkills.length }; +} +function validateStage(outputs, agentCount) { + const targets = new Set(); + for (const output of outputs) { + if (targets.has(output.target)) fail(`Duplicate generated target: ${output.target}`); + targets.add(output.target); + } + const codexAgentRoot = `${join(CODEX_HOME, "agents")}${sep}`; + const codexAgents = outputs.filter((item) => item.harness === "codex" && item.target.startsWith(codexAgentRoot) && item.target.endsWith(".toml")); + if (codexAgents.length !== agentCount) fail(`Expected ${agentCount} Codex agents, generated ${codexAgents.length}`); + for (const agent of codexAgents) { + const content = readFileSync(agent.stagePath, "utf8"); + for (const field of ["name =", "description =", "developer_instructions ="]) if (!content.includes(field)) fail(`Generated agent missing ${field}: ${agent.target}`); + if (content.includes(".cursor/skills") || content.includes(".claude/skills")) fail(`Generated agent retains foreign skill path: ${agent.target}`); + } + for (const launcher of ["the-beekeeper", "the-smoker"]) { + const item = outputs.find((entry) => entry.target === join(SHARED_SKILLS, launcher, "SKILL.md")); + if (!item || !readFileSync(item.stagePath, "utf8").match(/spawn|dispatch/i)) fail(`Missing or invalid Codex launcher: ${launcher}`); + } + const suit = outputs.find((entry) => entry.target === join(SHARED_SKILLS, "beekeeper-suit", "SKILL.md")); + if (!suit || !readFileSync(suit.stagePath, "utf8").includes("read your native Bee definition")) fail("Codex Beekeeper arming contract does not load the native Bee definition"); +} +function assertManagedFilesUnchanged(manifest) { + if (!manifest) return; + const changed = manifest.files.filter((entry) => !existsSync(entry.target) || sha256File(entry.target) !== entry.hash).map((entry) => entry.target); + if (changed.length) fail(`Managed files changed outside the updater. Refusing to overwrite:\n${changed.slice(0, 20).join("\n")}${changed.length > 20 ? `\n...and ${changed.length - 20} more` : ""}`); +} +function safeBackupName(path) { return relative(HOME, path).split(sep).join("__"); } +function applyStage(stage, previousManifest) { + assertManagedFilesUnchanged(previousManifest); + mkdirSync(BACKUPS_ROOT, { recursive: true }); + const backupId = new Date().toISOString().replace(/[:.]/g, "-"); + const backupRoot = join(BACKUPS_ROOT, backupId); + mkdirSync(backupRoot, { recursive: true }); + const priorTargets = new Map((previousManifest?.files || []).map((entry) => [entry.target, entry])); + const nextTargets = new Set(stage.outputs.map((entry) => entry.target)); + const records = []; + for (const target of new Set([...priorTargets.keys(), ...nextTargets])) { + const existed = existsSync(target); + const backupPath = existed ? join(backupRoot, "files", safeBackupName(target)) : null; + if (existed) { mkdirSync(dirname(backupPath), { recursive: true }); copyFileSync(target, backupPath); } + records.push({ target, existed, backupPath }); + } + writeFileSync(join(backupRoot, "backup.json"), JSON.stringify({ previousManifest, records }, null, 2) + "\n"); + try { + for (const target of priorTargets.keys()) if (!nextTargets.has(target) && existsSync(target)) rmSync(target, { force: true }); + for (const output of stage.outputs) { + mkdirSync(dirname(output.target), { recursive: true }); + const temporary = `${output.target}.bee-army-new`; + copyFileSync(output.stagePath, temporary); + renameSync(temporary, output.target); + } + const manifest = { schemaVersion: 1, upstream: UPSTREAM_URL, branch: UPSTREAM_BRANCH, commit: stage.commit, installedAt: new Date().toISOString(), agentCount: stage.agentCount, skillCount: stage.skillCount, usableSkillCount: stage.usableSkillCount, backupId, files: stage.outputs.map(({ harness, target, source, hash }) => ({ harness, target, source, hash })) }; + writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + "\n"); + return manifest; + } catch (error) { restoreBackup(backupRoot); throw error; } +} +function restoreBackup(backupRoot) { + const recordPath = join(backupRoot, "backup.json"); + if (!existsSync(recordPath)) fail(`Backup record not found: ${recordPath}`); + const backup = JSON.parse(readFileSync(recordPath, "utf8")); + for (const record of backup.records) { + if (record.existed) { mkdirSync(dirname(record.target), { recursive: true }); copyFileSync(record.backupPath, record.target); } + else if (existsSync(record.target)) rmSync(record.target, { force: true }); + } + if (backup.previousManifest) writeFileSync(MANIFEST_PATH, JSON.stringify(backup.previousManifest, null, 2) + "\n"); + else rmSync(MANIFEST_PATH, { force: true }); +} +function doctor(manifest = loadManifest()) { + if (!manifest) fail("Bee Army is not installed by this manager"); + const problems = []; + const counts = { claude: 0, cursor: 0, codex: 0 }; + for (const entry of manifest.files) { + counts[entry.harness] = (counts[entry.harness] || 0) + 1; + if (!existsSync(entry.target)) problems.push(`missing ${entry.target}`); + else if (sha256File(entry.target) !== entry.hash) problems.push(`modified ${entry.target}`); + } + const codexAgents = manifest.files.filter((entry) => entry.harness === "codex" && entry.target.startsWith(`${join(CODEX_HOME, "agents")}${sep}`) && entry.target.endsWith(".toml")).length; + if (codexAgents !== manifest.agentCount) problems.push(`expected ${manifest.agentCount} Codex agents, found ${codexAgents}`); + if (problems.length) fail(`Bee Army doctor failed:\n${problems.slice(0, 30).join("\n")}`); + console.log(JSON.stringify({ ok: true, commit: manifest.commit, agents: manifest.agentCount, skillDirectories: manifest.skillCount, usableSkills: manifest.usableSkillCount, managedFiles: manifest.files.length, filesByHarness: counts }, null, 2)); +} +function status() { + const manifest = loadManifest(); + console.log(JSON.stringify(manifest ? { installed: true, commit: manifest.commit, installedAt: manifest.installedAt, agents: manifest.agentCount, skillDirectories: manifest.skillCount, usableSkills: manifest.usableSkillCount, managedFiles: manifest.files.length, backupId: manifest.backupId } : { installed: false, upstream: UPSTREAM_URL }, null, 2)); +} +function check() { + const manifest = loadManifest(); + const latest = fetchUpstream(); + console.log(JSON.stringify({ installed: manifest?.commit || null, latest, updateAvailable: manifest?.commit !== latest }, null, 2)); +} +function preview() { + const manifest = loadManifest(); + const latest = fetchUpstream(); + if (!manifest) return void console.log(`Bee Army is not installed. Latest upstream commit: ${latest}`); + if (manifest.commit === latest) return void console.log(`Already current at ${latest}`); + let diff; + try { diff = run("git", ["-C", SOURCE_ROOT, "diff", "--name-status", manifest.commit, latest, "--", ".cursor"]); } + catch { diff = "Unable to calculate the file diff; the installed commit may no longer be present locally."; } + console.log(`Installed: ${manifest.commit}\nLatest: ${latest}\n\n${diff || "No .cursor asset changes"}`); +} +function update(apply) { + if (!apply) fail("Refusing to apply without --apply. Run preview first."); + const latest = fetchUpstream(); + checkoutCommit(latest); + const stage = generateStage(latest); + try { doctor(applyStage(stage, loadManifest())); } + finally { rmSync(stage.stageRoot, { recursive: true, force: true }); } +} +function validateUpdate() { + const latest = fetchUpstream(); + checkoutCommit(latest); + const stage = generateStage(latest); + try { + const counts = { claude: 0, cursor: 0, codex: 0 }; + for (const entry of stage.outputs) counts[entry.harness] += 1; + console.log(JSON.stringify({ ok: true, commit: latest, agents: stage.agentCount, skillDirectories: stage.skillCount, usableSkills: stage.usableSkillCount, generatedFiles: stage.outputs.length, filesByHarness: counts }, null, 2)); + } finally { rmSync(stage.stageRoot, { recursive: true, force: true }); } +} +function rollback(apply) { + if (!apply) fail("Refusing to roll back without --apply"); + const manifest = loadManifest(); + if (!manifest?.backupId) fail("No rollback backup is recorded"); + restoreBackup(join(BACKUPS_ROOT, manifest.backupId)); + console.log(JSON.stringify({ ok: true, restoredBackup: manifest.backupId, currentCommit: loadManifest()?.commit || null }, null, 2)); +} +function withLock(fn) { + mkdirSync(STATE_ROOT, { recursive: true }); + let fd; + try { fd = openSync(LOCK_PATH, "wx"); } catch { fail(`Another Bee Army operation appears active: ${LOCK_PATH}`); } + try { return fn(); } finally { closeSync(fd); rmSync(LOCK_PATH, { force: true }); } +} +function main() { + const [command = "status", ...args] = process.argv.slice(2); + const apply = args.includes("--apply"); + if (command === "status") status(); + else if (command === "check") check(); + else if (command === "preview") preview(); + else if (command === "validate") withLock(validateUpdate); + else if (command === "doctor") doctor(); + else if (command === "update" || command === "install") withLock(() => update(apply)); + else if (command === "rollback") withLock(() => rollback(apply)); + else fail(`Unknown command: ${command}`); +} +try { main(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; } diff --git a/test/bee-army.test.mjs b/test/bee-army.test.mjs new file mode 100644 index 0000000..d94b3ae --- /dev/null +++ b/test/bee-army.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const cli = join(repo, "scripts", "bee-army.mjs"); + +function run(home, ...args) { + return execFileSync(process.execPath, [cli, ...args], { + cwd: repo, + encoding: "utf8", + env: { + ...process.env, + BEE_ARMY_HOME: home, + BEE_ARMY_UPSTREAM_URL: repo, + BEE_ARMY_UPSTREAM_BRANCH: "main", + }, + }); +} + +test("installs the full hive globally without project scaffolding", { timeout: 120_000 }, () => { + const home = mkdtempSync(join(tmpdir(), "bee-army-home-")); + const project = mkdtempSync(join(tmpdir(), "bee-army-project-")); + try { + const validation = JSON.parse(run(home, "validate")); + assert.equal(validation.ok, true); + assert.equal(validation.agents, 85); + assert.equal(validation.skillDirectories, 91); + assert.equal(validation.usableSkills, 90); + + run(home, "install", "--apply"); + const diagnosis = JSON.parse(run(home, "doctor")); + assert.equal(diagnosis.ok, true); + assert.equal(diagnosis.agents, 85); + const terminalBee = join(home, ".codex", "agents", "terminal-bash-worker-bee.toml"); + assert.ok(existsSync(terminalBee)); + assert.ok(existsSync(join(home, ".agents", "skills", "terminal-bash-stinger", "SKILL.md"))); + assert.ok(existsSync(join(home, ".agents", "skills", "the-beekeeper", "SKILL.md"))); + assert.ok(existsSync(join(home, ".codex", "skills", "bee-army-update", "SKILL.md"))); + assert.ok(existsSync(join(home, ".claude", "commands", "bee-army-update.md"))); + assert.ok(existsSync(join(home, ".cursor", "commands", "bee-army-update.md"))); + assert.equal(existsSync(join(project, ".codex")), false); + assert.equal(existsSync(join(project, ".agents")), false); + assert.equal(existsSync(join(project, "AGENTS.md")), false); + + const manifest = JSON.parse(readFileSync(join(home, ".local", "share", "that-git-life", "manifest.json"), "utf8")); + assert.equal(manifest.agentCount, 85); + assert.ok(manifest.files.every((entry) => entry.target.startsWith(home))); + + const originalBee = readFileSync(terminalBee, "utf8"); + writeFileSync(terminalBee, `${originalBee}\n# local edit\n`); + assert.throws(() => run(home, "update", "--apply"), /Managed files changed outside the updater/); + writeFileSync(terminalBee, originalBee); + + run(home, "rollback", "--apply"); + assert.equal(JSON.parse(run(home, "status")).installed, false); + assert.equal(existsSync(terminalBee), false); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } +}); From e174399a820629d54a54b7e84902b201e029b315 Mon Sep 17 00:00:00 2001 From: Chris <16280532+chrisl10@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:39:48 -0700 Subject: [PATCH 2/2] Harden Bee Army update recovery --- README.md | 8 +-- scripts/bee-army.mjs | 130 +++++++++++++++++++++++++++++++--------- scripts/frontmatter.mjs | 18 ++++++ test/bee-army.test.mjs | 58 ++++++++++++++++-- 4 files changed, 177 insertions(+), 37 deletions(-) create mode 100644 scripts/frontmatter.mjs diff --git a/README.md b/README.md index 32df1ba..e3a24fb 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,11 @@ bee-army preview bee-army update --apply ``` -The manager records the exact upstream commit and hashes every managed file. It refuses to overwrite locally modified managed files, backs up every touched path before applying, and supports `bee-army rollback --apply`. It copies or translates declarative assets but never executes scripts from the upstream checkout. +The manager records the exact upstream commit and hashes every managed file. It refuses to overwrite locally modified managed files, backs up every touched path before applying, retains the three newest backups by default, and supports `bee-army rollback --apply`. Interrupted updates are recovered from their pending backup before another update begins. It copies or translates declarative assets but never executes scripts from the upstream checkout. This is a **global-only installation**. Running any command must not create `.codex/`, `.agents/`, or `AGENTS.md` inside the current project. Codex receives native TOML Bee definitions plus the shared Stingers each Bee must read before working. Start a fresh Codex session after installation or update so it discovers the new global assets. -Environment overrides are available for non-default layouts and tests: `CODEX_HOME`, `CLAUDE_HOME`, `CURSOR_HOME`, `AGENTS_HOME`, `BEE_ARMY_HOME`, `BEE_ARMY_STATE_ROOT`, `BEE_ARMY_UPSTREAM_URL`, and `BEE_ARMY_UPSTREAM_BRANCH`. +Environment overrides are available for non-default layouts and tests: `CODEX_HOME`, `CLAUDE_HOME`, `CURSOR_HOME`, `AGENTS_HOME`, `BEE_ARMY_HOME`, `BEE_ARMY_STATE_ROOT`, `BEE_ARMY_UPSTREAM_URL`, `BEE_ARMY_UPSTREAM_BRANCH`, `BEE_ARMY_BACKUP_RETENTION`, `BEE_ARMY_GIT_TIMEOUT_MS`, and `BEE_ARMY_LOCK_STALE_MS`. --- @@ -132,9 +132,9 @@ Always-on guidance that constrains every agent at all times: house style, safety | **Agents** | `.cursor/agents/*.md` | `.claude/agents/*.md` | globally generated `~/.codex/agents/*.toml` | runs on the Agent SDK; skills are the portable unit | | **Skills** | `.cursor/skills//` | `.claude/skills//` | globally installed `~/.agents/skills//` | `.cowork/skills/.skill` (one-click install) | | **Hooks** | `.cursor/hooks.json` | `.claude/settings.json` | not used by the Bee Army | not user-configurable | -| **Rules** | `.cursor/rules/*.mdc` | `CLAUDE.md` | global Codex instructions | `CLAUDE.md` + project instructions | +| **Rules** | `.cursor/rules/*.mdc` (installed globally by `bee-army`) | `CLAUDE.md` (reference only) | global Codex instructions (reference only) | `CLAUDE.md` + project instructions | -Skills port one-to-one across all three. Agents share a format across Cursor and Claude Code. Hooks and rules use different formats per harness, so they are translated rather than copied. The Cowork skill copies have their angle brackets swapped for curly braces so they survive import (see [SKILLS.md](./SKILLS.md)). +Skills port one-to-one across all three. Agents share a format across Cursor and Claude Code. The current `bee-army` installer installs rules only for Cursor; it does not generate `CLAUDE.md` or global Codex instructions. The Cowork skill copies have their angle brackets swapped for curly braces so they survive import (see [SKILLS.md](./SKILLS.md)). --- diff --git a/scripts/bee-army.mjs b/scripts/bee-army.mjs index b38b2c5..79d1f37 100755 --- a/scripts/bee-army.mjs +++ b/scripts/bee-army.mjs @@ -2,12 +2,13 @@ import { closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, - readFileSync, readdirSync, renameSync, rmSync, writeFileSync, + readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { execFileSync } from "node:child_process"; import { homedir } from "node:os"; import { basename, dirname, join, relative, sep } from "node:path"; +import { parseFrontmatter } from "./frontmatter.mjs"; const HOME = process.env.BEE_ARMY_HOME || homedir(); const CODEX_HOME = process.env.CODEX_HOME || join(HOME, ".codex"); @@ -19,13 +20,21 @@ const SOURCE_ROOT = join(STATE_ROOT, "upstream"); const MANIFEST_PATH = join(STATE_ROOT, "manifest.json"); const BACKUPS_ROOT = join(STATE_ROOT, "backups"); const LOCK_PATH = join(STATE_ROOT, "update.lock"); +const PENDING_PATH = join(STATE_ROOT, "pending-update.json"); const UPSTREAM_URL = process.env.BEE_ARMY_UPSTREAM_URL || "https://github.com/legioncodeinc/that-git-life.git"; const UPSTREAM_BRANCH = process.env.BEE_ARMY_UPSTREAM_BRANCH || "main"; +const GIT_TIMEOUT_MS = positiveInteger(process.env.BEE_ARMY_GIT_TIMEOUT_MS, 180_000); +const LOCK_STALE_MS = positiveInteger(process.env.BEE_ARMY_LOCK_STALE_MS, 900_000); +const BACKUP_RETENTION = positiveInteger(process.env.BEE_ARMY_BACKUP_RETENTION, 3); const TEXT_EXTENSIONS = new Set([".md", ".mdc", ".txt", ".json", ".yaml", ".yml", ".toml", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".sh", ".py", ".css", ".html"]); +function positiveInteger(value, fallback) { + const parsed = Number.parseInt(value || "", 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} function fail(message) { throw new Error(message); } function run(command, args, options = {}) { - return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...options }).trim(); + return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: GIT_TIMEOUT_MS, ...options }).trim(); } function ensureSource() { mkdirSync(STATE_ROOT, { recursive: true }); @@ -41,6 +50,12 @@ function checkoutCommit(commit) { run("git", ["-C", SOURCE_ROOT, "switch", "--detach", commit]); } function loadManifest() { return existsSync(MANIFEST_PATH) ? JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) : null; } +function writeJsonAtomic(path, value) { + const temporary = `${path}.bee-army-new`; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(temporary, JSON.stringify(value, null, 2) + "\n"); + renameSync(temporary, path); +} function sha256File(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); } function sha256Text(text) { return createHash("sha256").update(text).digest("hex"); } function walkFiles(root) { @@ -62,17 +77,6 @@ function extension(path) { return index === -1 ? "" : name.slice(index); } function isText(path) { return TEXT_EXTENSIONS.has(extension(path)) || basename(path) === "SKILL.md"; } -function parseFrontmatter(text, path, { requireName = true } = {}) { - const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); - if (!match) fail(`Missing frontmatter: ${path}`); - const fields = {}; - for (const line of match[1].split(/\r?\n/)) { - const field = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); - if (field) fields[field[1]] = field[2].replace(/^['"]|['"]$/g, ""); - } - if ((requireName && !fields.name) || !fields.description) fail(`Frontmatter is missing required fields: ${path}`); - return { fields, body: text.slice(match[0].length) }; -} function pairedStinger(agentName, body, skillRoot) { const explicit = body.match(/(?:\.cursor\/|\.claude\/|\.\.\/|\/Users\/[^\s`)]*\/(?:\.cursor|\.claude)\/)skills\/([^/`\s)]+)/)?.[1]; const inferred = agentName.replace(/-worker-bee$/, "-stinger"); @@ -208,11 +212,46 @@ function assertManagedFilesUnchanged(manifest) { if (changed.length) fail(`Managed files changed outside the updater. Refusing to overwrite:\n${changed.slice(0, 20).join("\n")}${changed.length > 20 ? `\n...and ${changed.length - 20} more` : ""}`); } function safeBackupName(path) { return relative(HOME, path).split(sep).join("__"); } +function backupRootFor(backupId) { + if (!backupId || basename(backupId) !== backupId) fail(`Invalid backup ID: ${backupId || "missing"}`); + return join(BACKUPS_ROOT, backupId); +} +function loadPending() { + if (!existsSync(PENDING_PATH)) return null; + try { return JSON.parse(readFileSync(PENDING_PATH, "utf8")); } + catch { fail(`Pending update record is invalid: ${PENDING_PATH}`); } +} +function clearPending(backupId) { + const pending = loadPending(); + if (!pending || pending.backupId === backupId) rmSync(PENDING_PATH, { force: true }); +} +function recoverPendingBackup() { + const pending = loadPending(); + if (!pending) return null; + if (loadManifest()?.backupId === pending.backupId) { + clearPending(pending.backupId); + return null; + } + const backupRoot = backupRootFor(pending.backupId); + restoreBackup(backupRoot); + return pending.backupId; +} +function pruneBackups() { + if (!existsSync(BACKUPS_ROOT)) return; + const protectedIds = new Set([loadManifest()?.backupId, loadPending()?.backupId].filter(Boolean)); + const backups = readdirSync(BACKUPS_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && existsSync(join(BACKUPS_ROOT, entry.name, "backup.json"))) + .map((entry) => entry.name) + .sort() + .reverse(); + const keep = new Set([...backups.slice(0, BACKUP_RETENTION), ...protectedIds]); + for (const backupId of backups) if (!keep.has(backupId)) rmSync(join(BACKUPS_ROOT, backupId), { recursive: true, force: true }); +} function applyStage(stage, previousManifest) { assertManagedFilesUnchanged(previousManifest); mkdirSync(BACKUPS_ROOT, { recursive: true }); const backupId = new Date().toISOString().replace(/[:.]/g, "-"); - const backupRoot = join(BACKUPS_ROOT, backupId); + const backupRoot = backupRootFor(backupId); mkdirSync(backupRoot, { recursive: true }); const priorTargets = new Map((previousManifest?.files || []).map((entry) => [entry.target, entry])); const nextTargets = new Set(stage.outputs.map((entry) => entry.target)); @@ -223,7 +262,8 @@ function applyStage(stage, previousManifest) { if (existed) { mkdirSync(dirname(backupPath), { recursive: true }); copyFileSync(target, backupPath); } records.push({ target, existed, backupPath }); } - writeFileSync(join(backupRoot, "backup.json"), JSON.stringify({ previousManifest, records }, null, 2) + "\n"); + writeJsonAtomic(join(backupRoot, "backup.json"), { createdAt: new Date().toISOString(), previousManifest, records }); + writeJsonAtomic(PENDING_PATH, { backupId, createdAt: new Date().toISOString() }); try { for (const target of priorTargets.keys()) if (!nextTargets.has(target) && existsSync(target)) rmSync(target, { force: true }); for (const output of stage.outputs) { @@ -233,7 +273,9 @@ function applyStage(stage, previousManifest) { renameSync(temporary, output.target); } const manifest = { schemaVersion: 1, upstream: UPSTREAM_URL, branch: UPSTREAM_BRANCH, commit: stage.commit, installedAt: new Date().toISOString(), agentCount: stage.agentCount, skillCount: stage.skillCount, usableSkillCount: stage.usableSkillCount, backupId, files: stage.outputs.map(({ harness, target, source, hash }) => ({ harness, target, source, hash })) }; - writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + "\n"); + writeJsonAtomic(MANIFEST_PATH, manifest); + clearPending(backupId); + pruneBackups(); return manifest; } catch (error) { restoreBackup(backupRoot); throw error; } } @@ -245,8 +287,9 @@ function restoreBackup(backupRoot) { if (record.existed) { mkdirSync(dirname(record.target), { recursive: true }); copyFileSync(record.backupPath, record.target); } else if (existsSync(record.target)) rmSync(record.target, { force: true }); } - if (backup.previousManifest) writeFileSync(MANIFEST_PATH, JSON.stringify(backup.previousManifest, null, 2) + "\n"); + if (backup.previousManifest) writeJsonAtomic(MANIFEST_PATH, backup.previousManifest); else rmSync(MANIFEST_PATH, { force: true }); + clearPending(basename(backupRoot)); } function doctor(manifest = loadManifest()) { if (!manifest) fail("Bee Army is not installed by this manager"); @@ -302,26 +345,59 @@ function validateUpdate() { function rollback(apply) { if (!apply) fail("Refusing to roll back without --apply"); const manifest = loadManifest(); - if (!manifest?.backupId) fail("No rollback backup is recorded"); - restoreBackup(join(BACKUPS_ROOT, manifest.backupId)); - console.log(JSON.stringify({ ok: true, restoredBackup: manifest.backupId, currentCommit: loadManifest()?.commit || null }, null, 2)); + const backupId = loadPending()?.backupId || manifest?.backupId; + if (!backupId) fail("No rollback backup is recorded"); + restoreBackup(backupRootFor(backupId)); + console.log(JSON.stringify({ ok: true, restoredBackup: backupId, currentCommit: loadManifest()?.commit || null }, null, 2)); +} +function processIsActive(pid) { + if (!Number.isInteger(pid) || pid < 1) return false; + try { process.kill(pid, 0); return true; } + catch (error) { return error?.code === "EPERM"; } +} +function removeStaleLock() { + let metadata = null; + try { metadata = JSON.parse(readFileSync(LOCK_PATH, "utf8")); } catch {} + const age = Date.now() - statSync(LOCK_PATH).mtimeMs; + if (metadata && processIsActive(metadata.pid)) return false; + if (!metadata && age < LOCK_STALE_MS) return false; + rmSync(LOCK_PATH, { force: true }); + return true; } -function withLock(fn) { +function withLock(fn, { recover = true } = {}) { mkdirSync(STATE_ROOT, { recursive: true }); let fd; - try { fd = openSync(LOCK_PATH, "wx"); } catch { fail(`Another Bee Army operation appears active: ${LOCK_PATH}`); } - try { return fn(); } finally { closeSync(fd); rmSync(LOCK_PATH, { force: true }); } + const token = randomUUID(); + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + fd = openSync(LOCK_PATH, "wx"); + writeFileSync(fd, JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }) + "\n"); + break; + } catch (error) { + if (error?.code !== "EEXIST" || attempt > 0 || !removeStaleLock()) fail(`Another Bee Army operation appears active: ${LOCK_PATH}`); + } + } + try { + if (recover) recoverPendingBackup(); + return fn(); + } finally { + closeSync(fd); + try { + const current = JSON.parse(readFileSync(LOCK_PATH, "utf8")); + if (current.token === token) rmSync(LOCK_PATH, { force: true }); + } catch {} + } } function main() { const [command = "status", ...args] = process.argv.slice(2); const apply = args.includes("--apply"); if (command === "status") status(); - else if (command === "check") check(); - else if (command === "preview") preview(); + else if (command === "check") withLock(check); + else if (command === "preview") withLock(preview); else if (command === "validate") withLock(validateUpdate); else if (command === "doctor") doctor(); else if (command === "update" || command === "install") withLock(() => update(apply)); - else if (command === "rollback") withLock(() => rollback(apply)); + else if (command === "rollback") withLock(() => rollback(apply), { recover: false }); else fail(`Unknown command: ${command}`); } try { main(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; } diff --git a/scripts/frontmatter.mjs b/scripts/frontmatter.mjs new file mode 100644 index 0000000..6ab6be1 --- /dev/null +++ b/scripts/frontmatter.mjs @@ -0,0 +1,18 @@ +export function parseFrontmatter(text, path, { requireName = true } = {}) { + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (!match) throw new Error(`Missing frontmatter: ${path}`); + const fields = {}; + let currentField = null; + for (const line of match[1].split(/\r?\n/)) { + const field = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); + if (field) { + currentField = field[1]; + const value = field[2].replace(/^['"]|['"]$/g, ""); + fields[currentField] = /^[>|][+-]?$/.test(value) ? "" : value; + } else if (currentField && /^\s+\S/.test(line)) { + fields[currentField] = `${fields[currentField]} ${line.trim()}`.trim(); + } + } + if ((requireName && !fields.name) || !fields.description) throw new Error(`Frontmatter is missing required fields: ${path}`); + return { fields, body: text.slice(match[0].length) }; +} diff --git a/test/bee-army.test.mjs b/test/bee-army.test.mjs index d94b3ae..c822b76 100644 --- a/test/bee-army.test.mjs +++ b/test/bee-army.test.mjs @@ -1,27 +1,48 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { parseFrontmatter } from "../scripts/frontmatter.mjs"; const repo = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const cli = join(repo, "scripts", "bee-army.mjs"); +const upstreamBranch = "main"; // The CLI and official upstream both define main as the supported default. +const { + CODEX_HOME: _codexHome, + CLAUDE_HOME: _claudeHome, + CURSOR_HOME: _cursorHome, + AGENTS_HOME: _agentsHome, + BEE_ARMY_STATE_ROOT: _stateRoot, + ...isolatedEnv +} = process.env; function run(home, ...args) { + return runIn(home, repo, ...args); +} + +function runIn(home, cwd, ...args) { return execFileSync(process.execPath, [cli, ...args], { - cwd: repo, + cwd, encoding: "utf8", env: { - ...process.env, + ...isolatedEnv, BEE_ARMY_HOME: home, BEE_ARMY_UPSTREAM_URL: repo, - BEE_ARMY_UPSTREAM_BRANCH: "main", + BEE_ARMY_UPSTREAM_BRANCH: upstreamBranch, + BEE_ARMY_BACKUP_RETENTION: "3", }, }); } +test("folds multiline frontmatter descriptions", () => { + const parsed = parseFrontmatter("---\nname: example-worker-bee\ndescription: >\n First line\n second line\n---\nBody\n", "example.md"); + assert.equal(parsed.fields.description, "First line second line"); + assert.equal(parsed.body, "Body\n"); +}); + test("installs the full hive globally without project scaffolding", { timeout: 120_000 }, () => { const home = mkdtempSync(join(tmpdir(), "bee-army-home-")); const project = mkdtempSync(join(tmpdir(), "bee-army-project-")); @@ -32,7 +53,7 @@ test("installs the full hive globally without project scaffolding", { timeout: 1 assert.equal(validation.skillDirectories, 91); assert.equal(validation.usableSkills, 90); - run(home, "install", "--apply"); + runIn(home, project, "install", "--apply"); const diagnosis = JSON.parse(run(home, "doctor")); assert.equal(diagnosis.ok, true); assert.equal(diagnosis.agents, 85); @@ -47,15 +68,40 @@ test("installs the full hive globally without project scaffolding", { timeout: 1 assert.equal(existsSync(join(project, ".agents")), false); assert.equal(existsSync(join(project, "AGENTS.md")), false); - const manifest = JSON.parse(readFileSync(join(home, ".local", "share", "that-git-life", "manifest.json"), "utf8")); + const stateRoot = join(home, ".local", "share", "that-git-life"); + const manifestPath = join(stateRoot, "manifest.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); assert.equal(manifest.agentCount, 85); assert.ok(manifest.files.every((entry) => entry.target.startsWith(home))); + const lockPath = join(stateRoot, "update.lock"); + writeFileSync(lockPath, JSON.stringify({ pid: process.pid, token: "active", createdAt: new Date().toISOString() })); + assert.throws(() => run(home, "check"), /Another Bee Army operation appears active/); + rmSync(lockPath); + writeFileSync(lockPath, JSON.stringify({ pid: 99999999, token: "stale", createdAt: new Date(0).toISOString() })); + run(home, "check"); + assert.equal(existsSync(lockPath), false); + + const crashBackupId = "9999-12-31T23-59-59-999Z"; + const crashBackupRoot = join(stateRoot, "backups", crashBackupId); + const crashBackupFile = join(crashBackupRoot, "files", "terminal-bash-worker-bee.toml"); + mkdirSync(join(crashBackupRoot, "files"), { recursive: true }); + copyFileSync(terminalBee, crashBackupFile); + writeFileSync(join(crashBackupRoot, "backup.json"), JSON.stringify({ previousManifest: manifest, records: [{ target: terminalBee, existed: true, backupPath: crashBackupFile }] })); + writeFileSync(join(stateRoot, "pending-update.json"), JSON.stringify({ backupId: crashBackupId, createdAt: new Date().toISOString() })); + writeFileSync(terminalBee, "partial interrupted update"); + run(home, "update", "--apply"); + assert.equal(existsSync(join(stateRoot, "pending-update.json")), false); + assert.match(readFileSync(terminalBee, "utf8"), /terminal-bash-worker-bee/); + assert.ok(readdirSync(join(stateRoot, "backups")).length <= 3); + const originalBee = readFileSync(terminalBee, "utf8"); writeFileSync(terminalBee, `${originalBee}\n# local edit\n`); assert.throws(() => run(home, "update", "--apply"), /Managed files changed outside the updater/); writeFileSync(terminalBee, originalBee); + run(home, "rollback", "--apply"); + assert.equal(JSON.parse(run(home, "status")).installed, true); run(home, "rollback", "--apply"); assert.equal(JSON.parse(run(home, "status")).installed, false); assert.equal(existsSync(terminalBee), false);