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..e3a24fb 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, 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`, `BEE_ARMY_UPSTREAM_BRANCH`, `BEE_ARMY_BACKUP_RETENTION`, `BEE_ARMY_GIT_TIMEOUT_MS`, and `BEE_ARMY_LOCK_STALE_MS`. + +--- + ## 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,14 +127,14 @@ 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` (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/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..79d1f37 --- /dev/null +++ b/scripts/bee-army.mjs @@ -0,0 +1,403 @@ +#!/usr/bin/env node + +import { + closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, + readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, +} from "node:fs"; +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"); +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 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"], timeout: GIT_TIMEOUT_MS, ...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 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) { + 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 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 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 = 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)); + 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 }); + } + 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) { + 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 })) }; + writeJsonAtomic(MANIFEST_PATH, manifest); + clearPending(backupId); + pruneBackups(); + 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) 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"); + 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(); + 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, { recover = true } = {}) { + mkdirSync(STATE_ROOT, { recursive: true }); + let fd; + 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") 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), { 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 new file mode 100644 index 0000000..c822b76 --- /dev/null +++ b/test/bee-army.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +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, + encoding: "utf8", + env: { + ...isolatedEnv, + BEE_ARMY_HOME: home, + BEE_ARMY_UPSTREAM_URL: repo, + 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-")); + 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); + + runIn(home, project, "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 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); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(project, { recursive: true, force: true }); + } +});