Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .agents/PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,12 @@ The agents package is a CLI tool (`codeassembly-agents`) that installs reusable

**CLI commands:**

| Command | Description |
| ----------- | ----------------------------------------------------------------- |
| `install` | Copies or symlinks skills and subagents into platform directories |
| `uninstall` | Removes previously installed items (respects drift detection) |
| `status` | Shows current vs modified vs missing installed items |
| Command | Description |
| ------------------- | ----------------------------------------------------------------- |
| `generate <target>` | Scaffolds project files (`label-map`) |
| `install` | Copies or symlinks skills and subagents into platform directories |
| `status` | Shows current vs modified vs missing installed items |
| `uninstall` | Removes previously installed items (respects drift detection) |

Key flags: `--platform <claude|rovodev|all>`, `--link` (symlink instead of copy), `--force` (overwrite modified), `--dry-run`.

Expand Down
8 changes: 8 additions & 0 deletions .readyup/kits/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ export default defineRdyKit({
},
fix: 'Add `@.agents/PROJECT.md` to .claude/CLAUDE.md so Claude reads project context',
},
{
name: '.meta/label-map.json exists',
check: () => {
const content = readFile('.meta/label-map.json');
return content !== undefined;
},
fix: 'Run `codeassembly-agents generate label-map` to create a starter label map',
},
],
},
],
Expand Down
29 changes: 29 additions & 0 deletions packages/agents/schemas/label-map.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/williamthorsen/codeassembly/raw/agents-v0.1.0/packages/agents/schemas/label-map.json",
"title": "Label map",
"description": "Maps commit types and package scopes to human-readable label values for use in issue trackers and CI systems.",
"type": "object",
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference URI."
},
"types": {
"type": "object",
"description": "Maps commit-type keys to label values.",
"additionalProperties": {
"type": "string"
}
},
"scopes": {
"type": "object",
"description": "Maps scope keys (package directory names) to label values.",
"additionalProperties": {
"type": "string"
}
}
},
"required": ["types", "scopes"],
"additionalProperties": false
}
54 changes: 54 additions & 0 deletions packages/agents/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

import { describe, expect, it } from 'vitest';

const execFileAsync = promisify(execFile);

const CLI_PATH = new URL('../cli.ts', import.meta.url).pathname;

interface ExecError {
readonly stdout: string;
readonly stderr: string;
readonly code: number;
}

/** Type guard for child_process exec errors. */
function isExecError(error: unknown): error is ExecError {
return typeof error === 'object' && error !== null && 'stdout' in error && 'stderr' in error && 'code' in error;
}

interface CliResult {
readonly stdout: string;
readonly stderr: string;
readonly exitCode: number;
}

/** Runs the CLI via tsx and captures stdout, stderr, and exit code. */
async function runCli(...args: Array<string>): Promise<CliResult> {
try {
const { stdout, stderr } = await execFileAsync('tsx', [CLI_PATH, ...args]);
return { stdout, stderr, exitCode: 0 };
} catch (error: unknown) {
if (isExecError(error)) {
return { stdout: error.stdout, stderr: error.stderr, exitCode: error.code };
}
throw error;
}
}

describe('CLI generate routing', () => {
it('exits 1 and prints generate usage when no subcommand is given', async () => {
const result = await runCli('generate');

expect(result.exitCode).toBe(1);
expect(result.stdout).toContain('label-map');
});

it('exits 1 and prints error for unknown generate target', async () => {
const result = await runCli('generate', 'nonexistent');

expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Unknown generate target "nonexistent"');
});
});
24 changes: 20 additions & 4 deletions packages/agents/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/* eslint unicorn/no-process-exit: off */
import process from 'node:process';

import { generateLabelMap, printGenerateUsage } from './commands/generate-label-map.js';
import { installCommand } from './commands/install.js';
import { statusCommand } from './commands/status.js';
import { uninstallCommand } from './commands/uninstall.js';
Expand Down Expand Up @@ -46,11 +47,13 @@ function parseFlag(arg: string): 'help' | 'link' | 'force' | 'dry-run' | 'platfo
*/
function parseArgs(argv: ReadonlyArray<string>): {
command: string;
subcommand: string;
options: InstallOptions;
help: boolean;
} {
const args = argv.slice(2);
let command = '';
let subcommand = '';
let platform: InstallOptions['platform'] = 'all';
let link = false;
let force = false;
Expand Down Expand Up @@ -87,12 +90,15 @@ function parseArgs(argv: ReadonlyArray<string>): {
process.exit(1);
} else if (!command) {
command = arg;
} else if (!subcommand) {
subcommand = arg;
}
}
}

return {
command,
subcommand,
options: { platform, link, force, dryRun },
help,
};
Expand All @@ -105,9 +111,10 @@ function printUsage(): void {
console.info(`Usage: codeassembly-agents <command> [options]

Commands:
install Install guidance, skills, and subagents into platform directories
uninstall Remove installed guidance, skills, and subagents
status Show the current state of installed items
install Install guidance, skills, and subagents into platform directories
uninstall Remove installed guidance, skills, and subagents
status Show the current state of installed items
generate <target> Generate a configuration file (e.g., label-map)

Options:
--platform <name> Target platform: claude, rovodev, or all (default: all)
Expand All @@ -121,7 +128,7 @@ Options:
* Main CLI entry point.
*/
async function main(): Promise<void> {
const { command, options, help } = parseArgs(process.argv);
const { command, subcommand, options, help } = parseArgs(process.argv);

if (help || !command) {
printUsage();
Expand All @@ -139,6 +146,15 @@ async function main(): Promise<void> {
case 'status':
await statusCommand({ platform: options.platform });
break;
case 'generate':
if (subcommand === 'label-map') {
await generateLabelMap({ force: options.force });
} else {
if (subcommand) console.error(`Error: Unknown generate target "${subcommand}"`);
printGenerateUsage();
process.exit(1);
}
break;
default:
console.error(`Error: Unknown command "${command}"`);
printUsage();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { mkdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { mockedReaddir, mockedStat } = vi.hoisted(() => {
return { mockedReaddir: vi.fn(), mockedStat: vi.fn() };
});

vi.mock('node:fs/promises', async (importOriginal) => {
const original = await importOriginal<typeof import('node:fs/promises')>();
return {
...original,
readdir: mockedReaddir.mockImplementation(original.readdir),
stat: mockedStat.mockImplementation(original.stat),
};
});

describe('generateLabelMap error paths', () => {
let tempDir: string;

beforeEach(async () => {
tempDir = path.join(tmpdir(), `agents-test-errors-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(tempDir, { recursive: true });
});

afterEach(async () => {
mockedReaddir.mockRestore();
mockedStat.mockRestore();
await rm(tempDir, { recursive: true, force: true });
});

it('propagates non-ENOENT errors from readdir in scope derivation', async () => {
const eaccesError = Object.assign(new Error('permission denied'), { code: 'EACCES' });

// Create packages/ so readdir is called on it.
await mkdir(path.join(tempDir, 'packages'), { recursive: true });

mockedReaddir.mockRejectedValueOnce(eaccesError);

const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});

const { generateLabelMap } = await import('../generate-label-map.js');

await expect(generateLabelMap({ force: false }, tempDir)).rejects.toThrow('permission denied');

infoSpy.mockRestore();
});

it('propagates non-ENOENT errors from stat in overwrite guard', async () => {
const eaccesError = Object.assign(new Error('permission denied'), { code: 'EACCES' });

mockedStat.mockRejectedValueOnce(eaccesError);

const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});

const { generateLabelMap } = await import('../generate-label-map.js');

await expect(generateLabelMap({ force: false }, tempDir)).rejects.toThrow('permission denied');

infoSpy.mockRestore();
});
});
Loading
Loading