From aec402f9847e386a5158feca940829d4de0568f7 Mon Sep 17 00:00:00 2001 From: bitnom <14287229+bitnom@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:08:12 -0400 Subject: [PATCH 1/2] feat(hosts): wire Droid (Factory CLI) and the Pi coding agent into graft init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Droid: AGENTS.md section (it reads AGENTS.md per Factory's docs) plus a repo-level .factory/mcp.json MCP registration — the committed, team-shared level of droid's user/folder/project trio, matching how cursor/gemini/kiro are wired. Detected via ~/.factory or a repo .factory dir; selecting it alongside the generic agents row is two identical AGENTS.md upserts, so the fenced section is still written once. Pi: a graft-owned skill at .pi/skills/graft/SKILL.md — pi discovers .pi/skills/ from the cwd upward, and its README's "No MCP" philosophy means the wiring is deliberately skill + AGENTS.md only, with no MCP registration. Detected via ~/.pi or a repo .pi dir. Retraction needs no new code: both targets derive from the same registries init writes through, so graft uninstall removes the skill file and the .factory/mcp.json key for free. --- CHANGELOG.md | 6 ++++++ src/hosts/mcp-config.ts | 9 +++++++- src/hosts/registry.ts | 23 ++++++++++++++++++++ test/hosts-init.test.ts | 40 +++++++++++++++++++++++++++++++++-- test/hosts-mcp-config.test.ts | 10 +++++++++ test/hosts-registry.test.ts | 2 +- 6 files changed, 86 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd845ee8..6ac69c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ - **A brain verifies the checkout before it mines** (#344): graft checks the working copy against the repo a brain expects, so rules are never mined from the wrong tree. +- **Droid and Pi wiring** via `graft init`: Droid (Factory CLI) gets an + `AGENTS.md` section plus a repo-level `.factory/mcp.json` MCP registration + (its documented project level); Pi gets a graft-owned skill at + `.pi/skills/graft/SKILL.md` and deliberately no MCP registration (pi's own + no-MCP stance — the CLI on PATH is the integration surface). Both detect + from their config dirs (`~/.factory`, `~/.pi`, or repo-local equivalents). ### Fixed diff --git a/src/hosts/mcp-config.ts b/src/hosts/mcp-config.ts index 111da50f..e34b77e3 100644 --- a/src/hosts/mcp-config.ts +++ b/src/hosts/mcp-config.ts @@ -212,8 +212,15 @@ export function mcpTargets( out.push(jsonTarget(id, 'opencode', join(repo, 'opencode.json'), 'mcp', opencodeEntry())); } break; + case 'droid': + // Droid reads MCP from `.factory/mcp.json` at the project root — the + // committed, team-shared level of its user/folder/project trio + // (docs.factory.ai/harness/mcp.md). Standard `{command,args}` under + // `mcpServers`, repo scope like cursor/gemini/kiro. + out.push(jsonTarget(id, 'droid', join(repo, '.factory', 'mcp.json'), 'mcpServers', entry)); + break; default: - break; // copilot / windsurf / adal: no MCP target in this phase + break; // copilot / windsurf / adal / pi: no MCP target (pi's own no-MCP stance) } } return out; diff --git a/src/hosts/registry.ts b/src/hosts/registry.ts index 9bc4e4a7..89956aba 100644 --- a/src/hosts/registry.ts +++ b/src/hosts/registry.ts @@ -121,6 +121,29 @@ export const HOSTS: HostTarget[] = [ content: windsurfRule, detect: (p) => p.dirExists(join(p.home, '.codeium', 'windsurf')) || p.dirExists(join(p.repo, '.windsurf')), }, + { + // Droid reads AGENTS.md (docs.factory.ai/harness/agents-md.md) — the same + // file as the 'agents' row, so selecting both runs two identical upserts + // and the second reports 'unchanged'. Its own row exists for detection + // (the ~/.factory install probe) and the MCP target (.factory/mcp.json). + id: 'droid', + name: 'Droid (Factory CLI)', + kind: 'section', + relPath: 'AGENTS.md', + content: instructionBody, + detect: (p) => p.dirExists(join(p.home, '.factory')) || p.dirExists(join(p.repo, '.factory')), + }, + { + // Pi has no MCP support by design (its README: "No MCP" — CLI tools and + // skills are the extension surface), so wiring is the skill file alone: + // pi discovers `.pi/skills/` from the cwd upward, alongside AGENTS.md. + id: 'pi', + name: 'Pi Coding Agent', + kind: 'owned', + relPath: join('.pi', 'skills', 'graft', 'SKILL.md'), + content: skillTemplate, + detect: (p) => p.dirExists(join(p.home, '.pi')) || p.dirExists(join(p.repo, '.pi')), + }, ]; export function hostIds(): string[] { diff --git a/test/hosts-init.test.ts b/test/hosts-init.test.ts index cbbd3c37..23b8ff7e 100644 --- a/test/hosts-init.test.ts +++ b/test/hosts-init.test.ts @@ -34,14 +34,50 @@ test('explicit agents list overrides detection and flags unknown ids', () => { test('all writes every host and re-run converges (idempotent)', () => { const home = fresh(); const repo = fresh(); const first = runHostsInit(repo, { home, all: true }); - assert.equal(first.written.length, 10); + assert.equal(first.written.length, 12); const second = runHostsInit(repo, { home, all: true }); assert.ok(second.written.every((w) => w.action === 'unchanged')); - // `agents` and `antigravity` share AGENTS.md, but the fenced section is written once + // `agents`, `droid`, and `antigravity` share AGENTS.md, but the fenced section is written once const agents = readFileSync(join(repo, 'AGENTS.md'), 'utf8'); assert.equal(agents.match(/graft:start/g)!.length, 1); }); +test('droid: detected via ~/.factory or repo .factory, wired as an AGENTS.md section', () => { + const home = fresh(); const repo = fresh(); + mkdirSync(join(home, '.factory')); + const r = runHostsInit(repo, { home, agents: ['droid'] }); + assert.deepEqual(r.written.map((w) => w.id), ['droid']); + const agents = readFileSync(join(repo, 'AGENTS.md'), 'utf8'); + assert.ok(agents.includes('graft ask')); + assert.equal(agents.match(/graft:start/g)!.length, 1, 'one fenced section despite the shared file'); + // selecting the generic agents row on top is an identical upsert, not a duplicate block + const both = runHostsInit(repo, { home, agents: ['agents', 'droid'] }); + assert.deepEqual(both.written.filter((w) => w.id === 'agents' || w.id === 'droid').map((w) => w.action), + ['unchanged', 'unchanged']); + assert.equal(readFileSync(join(repo, 'AGENTS.md'), 'utf8').match(/graft:start/g)!.length, 1); +}); + +test('droid: also detected from a repo-local .factory dir', () => { + const home = fresh(); const repo = fresh(); + mkdirSync(join(repo, '.factory')); + const r = runHostsInit(repo, { home }); + assert.ok(r.written.some((w) => w.id === 'droid')); +}); + +test('pi: detected via ~/.pi or repo .pi, wired as a graft-owned skill file', () => { + const home = fresh(); const repo = fresh(); + mkdirSync(join(home, '.pi')); + const r = runHostsInit(repo, { home, agents: ['pi'] }); + assert.deepEqual(r.written.map((w) => w.id), ['pi']); + const skill = readFileSync(join(repo, '.pi', 'skills', 'graft', 'SKILL.md'), 'utf8'); + assert.match(skill, /^---\nname: graft\n/); + assert.ok(skill.includes('graft ask')); + assert.deepEqual(r.mcp, [], 'pi has no MCP target (its own no-MCP stance)'); + const repoDotPi = fresh(); + mkdirSync(join(repoDotPi, '.pi')); + assert.ok(runHostsInit(repoDotPi, { home: fresh() }).written.some((w) => w.id === 'pi')); +}); + test('preserves user content around the fenced section', () => { const home = fresh(); const repo = fresh(); const target = join(repo, '.github', 'copilot-instructions.md'); diff --git a/test/hosts-mcp-config.test.ts b/test/hosts-mcp-config.test.ts index f6596670..85767365 100644 --- a/test/hosts-mcp-config.test.ts +++ b/test/hosts-mcp-config.test.ts @@ -80,6 +80,16 @@ test('grok gets a repo-local TOML MCP section', () => { assert.deepEqual(again.map((x) => x.action), ['unchanged']); }); +test('droid gets a repo-local .factory/mcp.json; pi gets nothing', () => { + const repo = fresh(); const home = fresh(); + const w = registerMcpConfigs(repo, ['droid', 'pi'], { home }); + assert.deepEqual(w.map((x) => x.id).sort(), ['droid'], 'pi registers no MCP config at all'); + const droid = JSON.parse(readFileSync(join(repo, '.factory', 'mcp.json'), 'utf8')); + assert.deepEqual(droid.mcpServers.graft, { command: 'npx', args: ['-y', '@nanonets/graft', 'mcp'] }); + const again = registerMcpConfigs(repo, ['droid'], { home }); + assert.deepEqual(again.map((x) => x.action), ['unchanged']); +}); + test('JSON with non-object mcpServers value is skipped', () => { const repo = fresh(); const home = fresh(); mkdirSync(join(repo, '.cursor'), { recursive: true }); diff --git a/test/hosts-registry.test.ts b/test/hosts-registry.test.ts index 76153ad4..4d193180 100644 --- a/test/hosts-registry.test.ts +++ b/test/hosts-registry.test.ts @@ -15,7 +15,7 @@ function probeFor(home: string, repo: string): DetectProbe { function fresh(): string { return mkdtempSync(join(tmpdir(), 'graft-registry-')); } test('registry exposes the known hosts', () => { - assert.deepEqual(hostIds().sort(), ['adal', 'agents', 'antigravity', 'copilot', 'cursor', 'gemini', 'grok', 'hermes', 'kiro', 'windsurf']); + assert.deepEqual(hostIds().sort(), ['adal', 'agents', 'antigravity', 'copilot', 'cursor', 'droid', 'gemini', 'grok', 'hermes', 'kiro', 'pi', 'windsurf']); for (const h of HOSTS) { assert.ok(h.relPath.length > 0); assert.ok(h.content().length > 0); From 0613b88bfb036e4b14ba12bd2e0ba85138a60678 Mon Sep 17 00:00:00 2001 From: bitnom <14287229+bitnom@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:17:00 -0400 Subject: [PATCH 2/2] feat(hosts): project-agents init row for the vendor-neutral standard files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One picker row wires every agent that reads project-level STANDARD files: the AGENTS.md instruction section plus graft's skill at .agents/skills/graft/SKILL.md — the Agent-Skills-standard location droid documents as its compatibility scope and pi documents as a skill dir, so one write covers both (and the next standard-reading tool needs no new registry entry). Strictly project-scoped: no machine-wide config, unlike the agents row's ~/.codex writes. Vendor rows keep their vendor-specific halves; the overlaps are deliberate and idempotent (identical AGENTS.md content upserts to 'unchanged', and pi resolving the same skill name from .pi/skills/ and .agents/skills/ resolves by precedence). Retraction derives the skill target like every other write, so graft uninstall removes it for free. Also: the shim-resolution fakes claim versions above any real release — the shim's candidate list includes the test runner's own node install, so a machine with a real global graft joined the version race and the 0.x fakes stopped being hermetic. --- CHANGELOG.md | 6 ++++ src/hosts/init.ts | 8 +++++- src/hosts/mcp-config.ts | 2 +- src/hosts/plan.ts | 2 ++ src/hosts/project-agents.ts | 48 ++++++++++++++++++++++++++++++++ src/hosts/registry.ts | 22 +++++++++++++++ src/hosts/retract.ts | 12 ++++++++ test/claude-shim-resolve.test.ts | 31 ++++++++++++++------- test/hosts-init.test.ts | 23 +++++++++++++-- test/hosts-mcp-config.test.ts | 4 +-- test/hosts-registry.test.ts | 19 +++++++++++-- 11 files changed, 159 insertions(+), 18 deletions(-) create mode 100644 src/hosts/project-agents.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac69c88..75b810b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ `.pi/skills/graft/SKILL.md` and deliberately no MCP registration (pi's own no-MCP stance — the CLI on PATH is the integration surface). Both detect from their config dirs (`~/.factory`, `~/.pi`, or repo-local equivalents). +- **A `project-agents` init row** for the vendor-neutral conventions: one + selection writes the `AGENTS.md` section plus graft's skill at + `.agents/skills/graft/SKILL.md` — the Agent-Skills-standard location droid, + pi, and other standard-reading tools discover natively — with strictly + project-scoped writes (no machine-wide config), and no new registry row + needed for the next standard-reading agent. ### Fixed diff --git a/src/hosts/init.ts b/src/hosts/init.ts index 12b73d5a..16076f5f 100644 --- a/src/hosts/init.ts +++ b/src/hosts/init.ts @@ -13,6 +13,7 @@ import { installCodexHooks } from './codex-hooks.js'; import { installCursorHooks } from './cursor-hooks.js'; import type { ConfigWrite } from './config-write.js'; import { installAntigravitySkill } from './antigravity.js'; +import { installProjectAgentSkill } from './project-agents.js'; export interface HostsInitResult { written: { id: string; path: string; action: string }[]; @@ -94,5 +95,10 @@ export function runHostsInit( opts.global === false || !selected.some((h) => h.id === 'antigravity') ? [] : installAntigravitySkill(home); - return { written, skipped, unknown, mcp, hooks: [...hooks, ...cursorHooks, ...antigravitySkill] }; + // The project-agents skill is repo-local (.agents/skills/) — --no-global does + // NOT suppress it; there is no out-of-repo write in the row at all. + const projectAgentSkill = !selected.some((h) => h.id === 'project-agents') + ? [] + : installProjectAgentSkill(repo); + return { written, skipped, unknown, mcp, hooks: [...hooks, ...cursorHooks, ...antigravitySkill, ...projectAgentSkill] }; } diff --git a/src/hosts/mcp-config.ts b/src/hosts/mcp-config.ts index e34b77e3..8cbf82f1 100644 --- a/src/hosts/mcp-config.ts +++ b/src/hosts/mcp-config.ts @@ -220,7 +220,7 @@ export function mcpTargets( out.push(jsonTarget(id, 'droid', join(repo, '.factory', 'mcp.json'), 'mcpServers', entry)); break; default: - break; // copilot / windsurf / adal / pi: no MCP target (pi's own no-MCP stance) + break; // copilot / windsurf / adal / pi / project-agents: no MCP target (pi's own no-MCP stance) } } return out; diff --git a/src/hosts/plan.ts b/src/hosts/plan.ts index 38b225b3..d53084b7 100644 --- a/src/hosts/plan.ts +++ b/src/hosts/plan.ts @@ -15,6 +15,7 @@ import { mcpTargets } from './mcp-config.js'; import { hookTargets } from './codex-hooks.js'; import { cursorHookTargets } from './cursor-hooks.js'; import { antigravitySkillTargets } from './antigravity.js'; +import { projectAgentSkillTargets } from './project-agents.js'; import { claudeTargets } from '../claude/init.js'; import { claudeGlobalTargets } from './claude-global.js'; @@ -85,6 +86,7 @@ export function planInit(repo: string, opts: { home?: string; ids?: string[] } = ...(host.id === 'agents' ? hookTargets(home) : []), ...(host.id === 'cursor' ? cursorHookTargets(repo) : []), ...(host.id === 'antigravity' ? antigravitySkillTargets(home) : []), + ...(host.id === 'project-agents' ? projectAgentSkillTargets(repo) : []), ], })), ]; diff --git a/src/hosts/project-agents.ts b/src/hosts/project-agents.ts new file mode 100644 index 00000000..5c44c33f --- /dev/null +++ b/src/hosts/project-agents.ts @@ -0,0 +1,48 @@ +/** + * The vendor-neutral, project-scoped Agent-Skills-standard wiring. + * + * `.agents/skills//SKILL.md` is the one location multiple agents read + * natively (pi documents it; droid's skills doc lists it as its compatibility + * scope) — so one write covers every standard-reading tool in the repo, and + * any future one, without a registry row per vendor. The always-on half + * (AGENTS.md) comes from the `project-agents` host's own registry entry; this + * module is the skill half. + * + * `projectAgentSkillTargets()` is the pure "which files would this touch" half + * (for `graft init --dry-run` / the picker); `installProjectAgentSkill()` does + * the write. + */ +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { skillTemplate } from '../claude/skill-template.js'; +import type { PlannedWrite } from './plan.js'; +import type { ConfigWrite } from './config-write.js'; + +/** The skill file path, under the repo's shared Agent-Skills dir. */ +function skillPath(repo: string): string { + return join(repo, '.agents', 'skills', 'graft', 'SKILL.md'); +} + +/** The files installing the project-standard skill would touch — pure, no writes. */ +export function projectAgentSkillTargets(repo: string): PlannedWrite[] { + return [ + { + hostId: 'project-agents', id: 'project-agents-skill', + path: skillPath(repo), + scope: 'repo', kind: 'skill', what: 'graft skill (.agents/skills standard)', + }, + ]; +} + +/** Write graft's skill into `/.agents/skills/graft/SKILL.md`, idempotently. */ +export function installProjectAgentSkill(repo: string): ConfigWrite[] { + const path = skillPath(repo); + const content = skillTemplate(); + const existed = existsSync(path); + if (existed && readFileSync(path, 'utf8') === content) { + return [{ id: 'project-agents-skill', path, action: 'unchanged' }]; + } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + return [{ id: 'project-agents-skill', path, action: existed ? 'updated' : 'created' }]; +} diff --git a/src/hosts/registry.ts b/src/hosts/registry.ts index 89956aba..f42842d1 100644 --- a/src/hosts/registry.ts +++ b/src/hosts/registry.ts @@ -144,6 +144,28 @@ export const HOSTS: HostTarget[] = [ content: skillTemplate, detect: (p) => p.dirExists(join(p.home, '.pi')) || p.dirExists(join(p.repo, '.pi')), }, + { + // The vendor-neutral convention row: every agent that reads project-level + // STANDARD files — AGENTS.md plus the Agent-Skills `.agents/skills/` + // location (droid's documented compatibility scope, pi's documented skill + // dir, Codex-style CLIs' instruction file) — wires from this one row, and + // a future standard-reading tool needs no registry entry. Strictly + // project-scoped: machine-wide and vendor-specific config stays with each + // vendor's own row (the agents row's ~/.codex writes, droid's MCP). + // Detection: any installed agent in the standard-reading family, or a repo + // that already carries the `.agents/` convention. + id: 'project-agents', + name: 'Project-standard agents (AGENTS.md + .agents/skills)', + kind: 'section', + relPath: 'AGENTS.md', + content: instructionBody, + detect: (p) => + p.dirExists(join(p.home, '.codex')) || + p.dirExists(join(p.home, '.config', 'opencode')) || + p.dirExists(join(p.home, '.factory')) || + p.dirExists(join(p.home, '.pi')) || + p.dirExists(join(p.repo, '.agents')), + }, ]; export function hostIds(): string[] { diff --git a/src/hosts/retract.ts b/src/hosts/retract.ts index 801c5aa9..5829055c 100644 --- a/src/hosts/retract.ts +++ b/src/hosts/retract.ts @@ -31,6 +31,7 @@ import { ALL_MARKERS, type Markers } from './sections.js'; import { mcpTargets, stripTomlSection } from './mcp-config.js'; import { hookTargets } from './codex-hooks.js'; import { antigravitySkillTargets } from './antigravity.js'; +import { projectAgentSkillTargets } from './project-agents.js'; import { claudeGlobalTargets } from './claude-global.js'; import { claudeTargets } from '../claude/init.js'; import { isGraftAllowEntry, isGraftFooterRegex } from '../claude/settings-merge.js'; @@ -420,6 +421,17 @@ function targets(repo: string, opts: RetractOpts): Target[] { }); } + // 2b. The project-agents skill — a repo-local write outside the host's own + // relPath (AGENTS.md), so it needs its own target here. Removing it is + // safe while AGENTS.md stays: the standard-reading agents lose the + // on-demand playbook but keep the always-on section, matching what a + // repo wired by an older graft had. + if (!exclude.has('project-agents')) { + for (const t of projectAgentSkillTargets(repo)) { + add({ hostId: t.hostId, path: t.path, what: t.what, scope: t.scope, run: (a) => removeFile(t.path, a) }); + } + } + // 3. Claude Code: settings fragments, both shims, the skill, and the .mcp.json key. if (!exclude.has('claude')) { const [settings, statusline, hooks, skill, mcp] = claudeTargets(repo).map((t) => t.path); diff --git a/test/claude-shim-resolve.test.ts b/test/claude-shim-resolve.test.ts index fd8b571d..162d6bf3 100644 --- a/test/claude-shim-resolve.test.ts +++ b/test/claude-shim-resolve.test.ts @@ -45,35 +45,46 @@ function runShim(root: string, bakedDir: string, projectDir: string): string | n return existsSync(marker) ? readFileSync(marker, 'utf8') : null; } +// The fakes' versions sit far above any real release ON PURPOSE: the shim's +// candidate list includes the node install the TEST RUNNER itself lives under +// (`process.execPath`/../lib), so on a machine with a real `npm i -g` graft +// (0.18.0 as of this comment) that real install joins the race and outranks +// 0.11/0.9 fakes — the shim correctly loads it, the fake marker never lands, +// and the test reports a resolution the fixture never contained. The fakes +// only model relative order against each other, so they claim versions no +// real install will carry and stay hermetic either way. +const WINNER = '99.0.0'; +const LOSER = '0.9.1'; + test('an upgraded global install wins over the stale baked path', () => { const root = tmpRepo('shim-upgrade'); - const stale = fakeInstall(root, 'old-node-install', '0.9.1'); - fakeInstall(join(root, 'project', 'node_modules', '@nanonets'), 'graft', '0.11.0'); + const stale = fakeInstall(root, 'old-node-install', LOSER); + fakeInstall(join(root, 'project', 'node_modules', '@nanonets'), 'graft', WINNER); // BAKED points at the install that `graft init` ran from — still on disk (an // nvm switch leaves it there), still first in the candidate list, now stale. - assert.equal(runShim(root, stale, join(root, 'project')), '0.11.0'); + assert.equal(runShim(root, stale, join(root, 'project')), WINNER); }); test('the baked path still wins when it is the newest', () => { const root = tmpRepo('shim-baked-newest'); - const baked = fakeInstall(root, 'current', '0.11.0'); - fakeInstall(join(root, 'project', 'node_modules', '@nanonets'), 'graft', '0.9.1'); - assert.equal(runShim(root, baked, join(root, 'project')), '0.11.0'); + const baked = fakeInstall(root, 'current', WINNER); + fakeInstall(join(root, 'project', 'node_modules', '@nanonets'), 'graft', LOSER); + assert.equal(runShim(root, baked, join(root, 'project')), WINNER); }); test('a single candidate is used whatever its version', () => { const root = tmpRepo('shim-single'); - const only = fakeInstall(root, 'only', '0.9.1'); + const only = fakeInstall(root, 'only', WINNER); mkdirSync(join(root, 'project'), { recursive: true }); - assert.equal(runShim(root, only, join(root, 'project')), '0.9.1'); + assert.equal(runShim(root, only, join(root, 'project')), WINNER); }); test('an install with an unreadable version loses to a known one', () => { const root = tmpRepo('shim-noversion'); const broken = fakeInstall(root, 'broken', '0.0.0'); writeFileSync(join(root, 'broken', 'package.json'), 'not json'); - fakeInstall(join(root, 'project', 'node_modules', '@nanonets'), 'graft', '0.9.1'); - assert.equal(runShim(root, broken, join(root, 'project')), '0.9.1'); + fakeInstall(join(root, 'project', 'node_modules', '@nanonets'), 'graft', WINNER); + assert.equal(runShim(root, broken, join(root, 'project')), WINNER); }); test('no candidate at all exits quietly — a hook must never fail the session', () => { diff --git a/test/hosts-init.test.ts b/test/hosts-init.test.ts index 23b8ff7e..79de53cd 100644 --- a/test/hosts-init.test.ts +++ b/test/hosts-init.test.ts @@ -34,14 +34,33 @@ test('explicit agents list overrides detection and flags unknown ids', () => { test('all writes every host and re-run converges (idempotent)', () => { const home = fresh(); const repo = fresh(); const first = runHostsInit(repo, { home, all: true }); - assert.equal(first.written.length, 12); + assert.equal(first.written.length, 13); const second = runHostsInit(repo, { home, all: true }); assert.ok(second.written.every((w) => w.action === 'unchanged')); - // `agents`, `droid`, and `antigravity` share AGENTS.md, but the fenced section is written once + assert.ok(second.hooks.every((w) => w.action === 'unchanged'), 'skill/hook writes converge too'); + // `agents`, `droid`, `project-agents`, and `antigravity` share AGENTS.md, but the + // fenced section is written once const agents = readFileSync(join(repo, 'AGENTS.md'), 'utf8'); assert.equal(agents.match(/graft:start/g)!.length, 1); }); +test('project-agents: AGENTS.md section plus the .agents/skills standard skill, nothing machine-wide', () => { + const home = fresh(); const repo = fresh(); + mkdirSync(join(home, '.factory')); + const r = runHostsInit(repo, { home, agents: ['project-agents'] }); + assert.deepEqual(r.written.map((w) => w.id), ['project-agents']); + assert.ok(readFileSync(join(repo, 'AGENTS.md'), 'utf8').includes('graft ask')); + const skill = readFileSync(join(repo, '.agents', 'skills', 'graft', 'SKILL.md'), 'utf8'); + assert.match(skill, /^---\nname: graft\n/); + assert.ok(skill.includes('graft callers'), 'the skill carries the full playbook, not just a pointer'); + assert.deepEqual(r.mcp, [], 'the row is instructions + skill only'); + // re-run converges, and the vendor rows on top never duplicate the shared section + const second = runHostsInit(repo, { home, agents: ['project-agents'] }); + assert.ok(second.written.every((w) => w.action === 'unchanged')); + const all = runHostsInit(repo, { home, agents: ['project-agents', 'agents', 'droid', 'pi'] }); + assert.equal(readFileSync(join(repo, 'AGENTS.md'), 'utf8').match(/graft:start/g)!.length, 1); +}); + test('droid: detected via ~/.factory or repo .factory, wired as an AGENTS.md section', () => { const home = fresh(); const repo = fresh(); mkdirSync(join(home, '.factory')); diff --git a/test/hosts-mcp-config.test.ts b/test/hosts-mcp-config.test.ts index 85767365..d142e49b 100644 --- a/test/hosts-mcp-config.test.ts +++ b/test/hosts-mcp-config.test.ts @@ -80,9 +80,9 @@ test('grok gets a repo-local TOML MCP section', () => { assert.deepEqual(again.map((x) => x.action), ['unchanged']); }); -test('droid gets a repo-local .factory/mcp.json; pi gets nothing', () => { +test('droid gets a repo-local .factory/mcp.json; pi and project-agents get nothing', () => { const repo = fresh(); const home = fresh(); - const w = registerMcpConfigs(repo, ['droid', 'pi'], { home }); + const w = registerMcpConfigs(repo, ['droid', 'pi', 'project-agents'], { home }); assert.deepEqual(w.map((x) => x.id).sort(), ['droid'], 'pi registers no MCP config at all'); const droid = JSON.parse(readFileSync(join(repo, '.factory', 'mcp.json'), 'utf8')); assert.deepEqual(droid.mcpServers.graft, { command: 'npx', args: ['-y', '@nanonets/graft', 'mcp'] }); diff --git a/test/hosts-registry.test.ts b/test/hosts-registry.test.ts index 4d193180..9a19690e 100644 --- a/test/hosts-registry.test.ts +++ b/test/hosts-registry.test.ts @@ -15,7 +15,7 @@ function probeFor(home: string, repo: string): DetectProbe { function fresh(): string { return mkdtempSync(join(tmpdir(), 'graft-registry-')); } test('registry exposes the known hosts', () => { - assert.deepEqual(hostIds().sort(), ['adal', 'agents', 'antigravity', 'copilot', 'cursor', 'droid', 'gemini', 'grok', 'hermes', 'kiro', 'pi', 'windsurf']); + assert.deepEqual(hostIds().sort(), ['adal', 'agents', 'antigravity', 'copilot', 'cursor', 'droid', 'gemini', 'grok', 'hermes', 'kiro', 'pi', 'project-agents', 'windsurf']); for (const h of HOSTS) { assert.ok(h.relPath.length > 0); assert.ok(h.content().length > 0); @@ -32,7 +32,22 @@ test('home config dirs light up their hosts', () => { mkdirSync(join(home, '.gemini')); mkdirSync(join(home, '.codex')); const ids = detectHosts(probeFor(home, repo)).map((h) => h.id).sort(); - assert.deepEqual(ids, ['agents', 'cursor', 'gemini']); + assert.deepEqual(ids, ['agents', 'cursor', 'gemini', 'project-agents']); +}); + +test('project-agents lights up from any standard-reader home dir or a repo .agents dir', () => { + const repo = fresh(); + for (const dir of [['.codex'], ['.config', 'opencode'], ['.factory'], ['.pi']] as const) { + const home = fresh(); + mkdirSync(join(home, ...dir), { recursive: true }); + assert.ok( + detectHosts(probeFor(home, repo)).some((h) => h.id === 'project-agents'), + `~/${join(...dir)} should detect project-agents`, + ); + } + const home = fresh(); + mkdirSync(join(repo, '.agents')); + assert.ok(detectHosts(probeFor(home, repo)).some((h) => h.id === 'project-agents'), 'repo .agents/ detects'); }); test('repo-local markers also light up hosts', () => {