Skip to content
Merged
19 changes: 13 additions & 6 deletions packages/agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Global options: `--harness <claude\|rovodev\|all>` (default `all`), `--link`, `-

## Project declaration

A project opts into shared artifacts through `.agents/codeassembly.yaml`. Run `codeassembly-agents init` to scaffold one, declare the artifacts you want, then run `codeassembly-agents sync` to materialize them.
A project opts into shared artifacts through `.agents/codeassembly.yaml`. Run `codeassembly-agents init` to scaffold one, declare the artifacts you want, then run `codeassembly-agents sync` to materialize them. The same declaration format resolves in two independent domains — the repo (via `sync`) and the user-global home (via `sync --global`); see [Scopes](#scopes).

### Format

Expand All @@ -40,9 +40,9 @@ subagents:

A declared rulebook is materialized into `.agents/rulebooks/<slug>.md` and, depending on its delivery mode, inlined into `.agents/PROJECT.md` and/or delivered as a `consult-<slug>` skill in each detected harness.

A declared skill is deployed verbatim into each detected harness's project-local skills directory (`.claude/skills/<slug>/`), carrying a `<!-- codeassembly-skill:<slug> -->` ownership marker so `sync` can retract it once it is no longer declared. Only a skill whose `SKILL.md` frontmatter sets `deploy: declared` can be deployed this way; a skill without the field installs unconditionally into the user-global harness directories instead (see the [`deploy` field](#the-deploy-field) below). Skill deployment is project-scoped: declared skills land in the project's harness directories, not the user-global ones.
A declared skill is deployed verbatim into each detected harness's project-local skills directory (`.claude/skills/<slug>/`), carrying a `<!-- codeassembly-skill:<slug> -->` ownership marker so `sync` can retract it once it is no longer declared. Only a skill whose `SKILL.md` frontmatter sets `deploy: declared` can be deployed this way; a skill without the field installs unconditionally into the user-global harness directories instead (see the [`deploy` field](#the-deploy-field) below). Bare `sync` deploys into the project's harness directories; `sync --global` resolves the user-global tier and deploys the same way into the home harness directories instead (see [Scopes](#scopes)).

A declared subagent is deployed into each detected harness's project-local subagents directory (`.claude/agents/<slug>.md`), with the harness transform applied (frontmatter `_defaults` merge, `{tool:…}` rewrite, `{harness_home_dir}` rewrite) and a `<!-- codeassembly-subagent:<slug> -->` ownership marker so `sync` can retract it once it is no longer declared. As with skills, only a subagent whose frontmatter sets `deploy: declared` is deployed this way; a subagent without the field installs unconditionally. Subagent deployment is project-scoped: a declared subagent resolves only where it is declared. Global (home) delivery for subagents is tracked by #857; until then, real, globally-dispatched subagents stay on `install` and are not migrated.
A declared subagent is deployed into each detected harness's project-local subagents directory (`.claude/agents/<slug>.md`), with the harness transform applied (frontmatter `_defaults` merge, `{tool:…}` rewrite, `{harness_home_dir}` rewrite) and a `<!-- codeassembly-subagent:<slug> -->` ownership marker so `sync` can retract it once it is no longer declared. As with skills, only a subagent whose frontmatter sets `deploy: declared` is deployed this way; a subagent without the field installs unconditionally. A declared subagent deploys into the repo under `sync` and into the home harness directories under `sync --global`. The default catalog still ships through the unconditional `install` path; migrating it onto `declared` so `sync --global` carries it is the remaining step.

`rulebooks`, `skills`, `subagents`, and `collections` are all deployed.

Expand All @@ -56,7 +56,7 @@ collections:
- recommended
```

Dropping or omitting a collection — or `root: true` — excludes its entire closure. The shipped `recommended` collection bundles the default declared artifacts and is opt-in: a project gets it only by declaring it.
Dropping or omitting a collection — or `root: true` — excludes its entire closure; dropping a single member that a collection contributed is not supported, so opt out of the whole collection or declare members à la carte instead. The shipped `recommended` collection bundles the default declared artifacts. The installed user-global declaration (`~/.agents/codeassembly.yaml`) declares it, so `sync --global` deploys it into the home directories out of the box; a project adds it for repo deployment by declaring it explicitly.

### Dependencies

Expand Down Expand Up @@ -90,12 +90,19 @@ The field defaults to `install` when absent — the fail-safe default that keeps

### Scopes

The declaration resolves across two tiers, lowest to highest precedence:
The declaration resolves in two independent **domains**, each with its own base and local tiers and its own deployment target. The tiers within a domain run lowest to highest precedence.

**Repo domain** — `codeassembly-agents sync`, deploying into the repo:

1. **Project** — `.agents/codeassembly.yaml`, committed and shared with the team.
2. **Project-local** — `.agents/codeassembly.local.yaml`, gitignored, for personal overrides.

A higher tier adds to and overrides the tiers below it: `use` adds a rulebook, `drop` removes one inherited from a broader scope, and `root: true` discards everything declared in broader scopes, starting fresh from that file.
**Home domain** — `codeassembly-agents sync --global`, deploying into the home harness directories (`~/.claude`, `~/.rovodev`) and `~/.agents/`:

1. **User-global** — `~/.agents/codeassembly.yaml`, install-managed (declares `recommended` by default).
2. **User-global-local** — `~/.agents/codeassembly.local.yaml`, for personal overrides that survive reinstalls.

A higher tier adds to and overrides the tiers below it _within the same domain_: `use` adds an entry, `drop` removes one a broader tier in that domain contributed, and `root: true` discards everything from broader tiers in that domain. The domains never cross — a project tier cannot `drop` a user-global entry, and bare `sync` never writes the home directories (it refuses to run when invoked from the home directory, directing you to `sync --global`). Ambient rulebooks inline into `.agents/PROJECT.md` in the repo domain and `~/.agents/GLOBAL.md` in the home domain.

## Preferences

Expand Down
1 change: 1 addition & 0 deletions packages/agents/content/guidance/shared/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Always act as a conscientious and courteous collaborator. Follow best practices

## Project discovery

- Read ~/.agents/GLOBAL.md (if it exists) for user-global guidance
- Read .agents/PROJECT.md (if it exists) for project information
- Read .agents/preferences.yaml (if it exists) for agent settings

Expand Down
6 changes: 6 additions & 0 deletions packages/agents/content/guidance/shared/codeassembly.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Installed by codeassembly-agents into ~/.agents/codeassembly.yaml; edits are overwritten on the next install.
# This is the user-global declaration tier: `sync --global` deploys its closure into the home harness dirs.
# To override (for example, to drop a recommended member), use ~/.agents/codeassembly.local.yaml.
collections:
use:
- recommended
18 changes: 13 additions & 5 deletions packages/agents/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { initCommand } from './commands/init.ts';
import { installCommand } from './commands/install.ts';
import { libraryListCommand, printLibraryUsage } from './commands/library-list.ts';
import { statusCommand } from './commands/status.ts';
import { syncCommand } from './commands/sync.ts';
import { syncCommand, syncGlobalCommand } from './commands/sync.ts';
import { uninstallCommand } from './commands/uninstall.ts';
import type { HarnessId, InstallOptions } from './lib/types.ts';

Expand All @@ -17,7 +17,7 @@ const VALID_HARNESS_IDS = new Set<string>(['claude', 'rovodev', 'all']);
* Main CLI entry point.
*/
async function main(): Promise<void> {
const { command, subcommand, options, help } = parseArgs(process.argv);
const { command, subcommand, options, help, global } = parseArgs(process.argv);

if (help || !command) {
printUsage();
Expand All @@ -33,7 +33,7 @@ async function main(): Promise<void> {
await initCommand(options);
break;
case 'sync':
await syncCommand(options);
await (global ? syncGlobalCommand(options) : syncCommand(options));
break;
case 'uninstall':
await uninstallCommand({ harness: options.harness, force: options.force });
Expand Down Expand Up @@ -86,6 +86,7 @@ function parseArgs(argv: ReadonlyArray<string>): {
subcommand: string;
options: InstallOptions;
help: boolean;
global: boolean;
} {
const args = argv.slice(2);
let command = '';
Expand All @@ -95,6 +96,7 @@ function parseArgs(argv: ReadonlyArray<string>): {
let force = false;
let dryRun = false;
let help = false;
let global = false;

for (let i = 0; i < args.length; i++) {
const arg = args[i];
Expand All @@ -114,6 +116,9 @@ function parseArgs(argv: ReadonlyArray<string>): {
case 'dry-run':
dryRun = true;
break;
case 'global':
global = true;
break;
case 'harness': {
const result = parseHarnessArg(args, i);
harness = result.harness;
Expand All @@ -137,16 +142,18 @@ function parseArgs(argv: ReadonlyArray<string>): {
subcommand,
options: { harness, link, force, dryRun },
help,
global,
};
}

function parseFlag(arg: string): 'help' | 'link' | 'force' | 'dry-run' | 'harness' | null {
const flags: Record<string, 'help' | 'link' | 'force' | 'dry-run' | 'harness'> = {
function parseFlag(arg: string): 'help' | 'link' | 'force' | 'dry-run' | 'global' | 'harness' | null {
const flags: Record<string, 'help' | 'link' | 'force' | 'dry-run' | 'global' | 'harness'> = {
'--help': 'help',
'-h': 'help',
'--link': 'link',
'--force': 'force',
'--dry-run': 'dry-run',
'--global': 'global',
'--harness': 'harness',
};
return flags[arg] ?? null;
Expand Down Expand Up @@ -188,6 +195,7 @@ Options:
--link Use symlinks instead of copies (install only)
--force Overwrite or remove modified files (install/uninstall)
--dry-run Show what would be done without making changes (install, sync, init)
--global Sync the user-global tier (~/.agents/codeassembly.yaml) into the home harness dirs (sync only)
--help, -h Show this help message`);
}

Expand Down
138 changes: 136 additions & 2 deletions packages/agents/src/commands/__tests__/sync.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { existsSync, statSync } from 'node:fs';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import path from 'node:path';

import { unindent } from '@williamthorsen/toolbelt.strings/candidate';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { resolveContentDir } from '../../lib/content-resolver.ts';
import type { InstallOptions } from '../../lib/types.ts';
import { syncCommand } from '../sync.ts';
import { syncCommand, syncGlobalCommand } from '../sync.ts';

describe(syncCommand, () => {
let projectRoot: string;
Expand Down Expand Up @@ -715,3 +715,137 @@ describe(syncCommand, () => {
});
});
});

describe(syncGlobalCommand, () => {
let homeDir: string;
let contentDir: string;

beforeEach(async () => {
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
homeDir = path.join(tmpdir(), `agents-test-sync-home-${stamp}`);
contentDir = path.join(tmpdir(), `agents-test-sync-home-content-${stamp}`);
await mkdir(homeDir, { recursive: true });
await mkdir(path.join(contentDir, 'guidance', 'rulebooks'), { recursive: true });
});

afterEach(async () => {
await rm(homeDir, { recursive: true, force: true });
await rm(contentDir, { recursive: true, force: true });
});

function makeOptions(overrides: Partial<InstallOptions> = {}): InstallOptions {
return { harness: 'claude', link: false, force: false, dryRun: false, ...overrides };
}

/** Writes a fixture rulebook into the temp content library. */
async function writeLibraryRulebook(slug: string, frontmatter: string, body: string): Promise<void> {
const file = path.join(contentDir, 'guidance', 'rulebooks', `${slug}.md`);
await writeFile(file, `---\nslug: ${slug}\n${frontmatter}\n---\n\n${body}\n`, 'utf8');
}

/** Writes a fixture declared skill into the temp content library. */
async function writeLibrarySkill(slug: string): Promise<void> {
const dir = path.join(contentDir, 'skills', slug);
await mkdir(dir, { recursive: true });
await writeFile(
path.join(dir, 'SKILL.md'),
`---\nname: ${slug}\ndeploy: declared\n---\n\n# ${slug}\n\nBody.\n`,
'utf8',
);
}

/** Writes the user-global codeassembly.yaml under the temp home's `.agents/`. */
async function declareRaw(content: string): Promise<void> {
await mkdir(path.join(homeDir, '.agents'), { recursive: true });
await writeFile(path.join(homeDir, '.agents', 'codeassembly.yaml'), content, 'utf8');
}

it('when no ~/.agents/codeassembly.yaml exists, makes no changes', async () => {
await syncGlobalCommand(makeOptions(), homeDir, contentDir);

expect(existsSync(path.join(homeDir, '.agents', 'rulebooks'))).toBe(false);
});

it('deploys a declared skill into the home harness skills dir with the ownership marker', async () => {
await writeLibrarySkill('people-report');
await declareRaw('skills:\n use:\n - people-report\n');

await syncGlobalCommand(makeOptions(), homeDir, contentDir);

const skill = await readFile(path.join(homeDir, '.claude', 'skills', 'people-report', 'SKILL.md'), 'utf8');
expect(skill).toContain('<!-- codeassembly-skill:people-report -->');
});

it('inlines ambient rulebooks into ~/.agents/GLOBAL.md, never PROJECT.md', async () => {
await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.');
await declareRaw('rulebooks:\n use:\n - alpha\n');

await syncGlobalCommand(makeOptions(), homeDir, contentDir);

const globalMd = await readFile(path.join(homeDir, '.agents', 'GLOBAL.md'), 'utf8');
expect(globalMd).toContain('<!-- rulebook:alpha -->');
expect(globalMd).toContain('Alpha rules.');
expect(existsSync(path.join(homeDir, '.agents', 'PROJECT.md'))).toBe(false);
});

it('refuses to overwrite a home skill that lacks the sync ownership marker', async () => {
await writeLibrarySkill('people-report');
await declareRaw('skills:\n use:\n - people-report\n');
const target = path.join(homeDir, '.claude', 'skills', 'people-report');
await mkdir(target, { recursive: true });
await writeFile(path.join(target, 'SKILL.md'), '---\nname: people-report\n---\n\n# Hand-authored\n', 'utf8');

await expect(syncGlobalCommand(makeOptions(), homeDir, contentDir)).rejects.toThrow(/not owned by sync/i);
expect(await readFile(path.join(target, 'SKILL.md'), 'utf8')).toContain('Hand-authored');
});

it('refuses a bare sync run rooted at the home directory, directing to --global', async () => {
await expect(syncCommand(makeOptions(), homedir(), contentDir)).rejects.toThrow(/--global/);
});

it('retracts a home ambient block on undeclare and never writes ~/.agents/AGENTS.md', async () => {
await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.');
await declareRaw('rulebooks:\n use:\n - alpha\n');
await syncGlobalCommand(makeOptions(), homeDir, contentDir);
expect(await readFile(path.join(homeDir, '.agents', 'GLOBAL.md'), 'utf8')).toContain('<!-- rulebook:alpha -->');

await declareRaw('rulebooks:\n use: []\n');
await syncGlobalCommand(makeOptions(), homeDir, contentDir);

const globalMd = await readFile(path.join(homeDir, '.agents', 'GLOBAL.md'), 'utf8');
expect(globalMd).not.toContain('<!-- rulebook:alpha -->');
expect(existsSync(path.join(homeDir, '.agents', 'AGENTS.md'))).toBe(false);
});

it('refreshes ~/.rovodev/prompts.yml with home-deployed Rovo Dev skills', async () => {
await writeLibrarySkill('people-report');
await declareRaw('skills:\n use:\n - people-report\n');

await syncGlobalCommand(makeOptions({ harness: 'rovodev' }), homeDir, contentDir);

const prompts = await readFile(path.join(homeDir, '.rovodev', 'prompts.yml'), 'utf8');
expect(prompts).toContain("name: 'people-report'");
expect(prompts).toContain('content_file: skills/people-report/SKILL.md');
});

it('deploys the real recommended collection to home via the user-global declaration', async () => {
await declareRaw('collections:\n use:\n - recommended\n');

await syncGlobalCommand(makeOptions({ harness: 'claude' }), homeDir, resolveContentDir());

expect(existsSync(path.join(homeDir, '.claude', 'skills', 'people-report', 'SKILL.md'))).toBe(true);
expect(existsSync(path.join(homeDir, '.claude', 'agents', 'canary.md'))).toBe(true);
});

it('reconciles ~/.agents/rulebooks/ as wholesale sync-owned, removing an undeclared neutral file', async () => {
await writeLibraryRulebook('alpha', 'delivery: ambient', 'Alpha rules.');
await declareRaw('rulebooks:\n use:\n - alpha\n');
await syncGlobalCommand(makeOptions(), homeDir, contentDir);
await writeFile(path.join(homeDir, '.agents', 'rulebooks', 'stray.md'), '# Stray\n', 'utf8');

await syncGlobalCommand(makeOptions(), homeDir, contentDir);

expect(existsSync(path.join(homeDir, '.agents', 'rulebooks', 'stray.md'))).toBe(false);
expect(existsSync(path.join(homeDir, '.agents', 'rulebooks', 'alpha.md'))).toBe(true);
});
});
Loading
Loading