diff --git a/.agents/PROJECT.md b/.agents/PROJECT.md index 4c998f3c..c1cfad24 100644 --- a/.agents/PROJECT.md +++ b/.agents/PROJECT.md @@ -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 ` | 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 `, `--link` (symlink instead of copy), `--force` (overwrite modified), `--dry-run`. diff --git a/.readyup/kits/default.ts b/.readyup/kits/default.ts index a7d5b46a..23aad9b3 100644 --- a/.readyup/kits/default.ts +++ b/.readyup/kits/default.ts @@ -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', + }, ], }, ], diff --git a/packages/agents/schemas/label-map.json b/packages/agents/schemas/label-map.json new file mode 100644 index 00000000..06b1420e --- /dev/null +++ b/packages/agents/schemas/label-map.json @@ -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 +} diff --git a/packages/agents/src/__tests__/cli.test.ts b/packages/agents/src/__tests__/cli.test.ts new file mode 100644 index 00000000..55e2faa1 --- /dev/null +++ b/packages/agents/src/__tests__/cli.test.ts @@ -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): Promise { + 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"'); + }); +}); diff --git a/packages/agents/src/cli.ts b/packages/agents/src/cli.ts index 53e3fc1d..6a04a798 100644 --- a/packages/agents/src/cli.ts +++ b/packages/agents/src/cli.ts @@ -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'; @@ -46,11 +47,13 @@ function parseFlag(arg: string): 'help' | 'link' | 'force' | 'dry-run' | 'platfo */ function parseArgs(argv: ReadonlyArray): { 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; @@ -87,12 +90,15 @@ function parseArgs(argv: ReadonlyArray): { process.exit(1); } else if (!command) { command = arg; + } else if (!subcommand) { + subcommand = arg; } } } return { command, + subcommand, options: { platform, link, force, dryRun }, help, }; @@ -105,9 +111,10 @@ function printUsage(): void { console.info(`Usage: codeassembly-agents [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 Generate a configuration file (e.g., label-map) Options: --platform Target platform: claude, rovodev, or all (default: all) @@ -121,7 +128,7 @@ Options: * Main CLI entry point. */ async function main(): Promise { - const { command, options, help } = parseArgs(process.argv); + const { command, subcommand, options, help } = parseArgs(process.argv); if (help || !command) { printUsage(); @@ -139,6 +146,15 @@ async function main(): Promise { 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(); diff --git a/packages/agents/src/commands/__tests__/generate-label-map-errors.test.ts b/packages/agents/src/commands/__tests__/generate-label-map-errors.test.ts new file mode 100644 index 00000000..e578085a --- /dev/null +++ b/packages/agents/src/commands/__tests__/generate-label-map-errors.test.ts @@ -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(); + 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(); + }); +}); diff --git a/packages/agents/src/commands/__tests__/generate-label-map.test.ts b/packages/agents/src/commands/__tests__/generate-label-map.test.ts new file mode 100644 index 00000000..c2c22fa6 --- /dev/null +++ b/packages/agents/src/commands/__tests__/generate-label-map.test.ts @@ -0,0 +1,172 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { generateLabelMap, printGenerateUsage } from '../generate-label-map.js'; + +interface LabelMap { + readonly $schema: string; + readonly types: Record; + readonly scopes: Record; +} + +/** Parses the generated JSON file content into a typed `LabelMap`. */ +function parseLabelMap(raw: string): LabelMap { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- JSON.parse returns `any`; validated by test assertions + return JSON.parse(raw); +} + +describe(generateLabelMap, () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = path.join(tmpdir(), `agents-test-generate-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(tempDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('creates .meta/label-map.json with correct structure', async () => { + const result = await readGeneratedFile({ force: false }, tempDir); + const parsed = parseLabelMap(result); + + expect(parsed.$schema).toMatch( + /^https:\/\/github\.com\/williamthorsen\/codeassembly\/raw\/agents-v[\d.]+\/packages\/agents\/schemas\/label-map\.json$/, + ); + expect(parsed.types).toBeDefined(); + expect(parsed.scopes).toEqual({}); + }); + + it('embeds the actual package version in the $schema URL', async () => { + const thisDir = path.dirname(fileURLToPath(import.meta.url)); + const packageJsonPath = path.resolve(thisDir, '../../../package.json'); + const packageJsonRaw = await readFile(packageJsonPath, 'utf8'); + + const packageJsonParsed: { version: string } = JSON.parse(packageJsonRaw); + + const result = await readGeneratedFile({ force: false }, tempDir); + const parsed = parseLabelMap(result); + + expect(parsed.$schema).toContain(`agents-v${packageJsonParsed.version}`); + }); + + it('includes all canonical type mappings', async () => { + const result = await readGeneratedFile({ force: false }, tempDir); + const parsed = parseLabelMap(result); + + expect(parsed.types).toEqual({ + ai: 'ai', + ci: 'ci', + deprecate: 'deprecate', + deps: 'dependencies', + docs: 'documentation', + feat: 'feature', + fix: 'fix', + fmt: 'formatting', + internal: 'utility', + perf: 'performance', + refactor: 'refactoring', + sec: 'security', + tests: 'tests', + tooling: 'tooling', + }); + }); + + it('derives scopes from packages/ subdirectories', async () => { + await mkdir(path.join(tempDir, 'packages', 'alpha'), { recursive: true }); + await mkdir(path.join(tempDir, 'packages', 'beta'), { recursive: true }); + + const result = await readGeneratedFile({ force: false }, tempDir); + const parsed = parseLabelMap(result); + + expect(parsed.scopes).toEqual({ + alpha: 'scope:alpha', + beta: 'scope:beta', + root: 'scope:root', + }); + }); + + it('returns empty scopes when packages/ does not exist', async () => { + const result = await readGeneratedFile({ force: false }, tempDir); + const parsed = parseLabelMap(result); + + expect(parsed.scopes).toEqual({}); + }); + + it('returns empty scopes when packages/ has no subdirectories', async () => { + await mkdir(path.join(tempDir, 'packages'), { recursive: true }); + await writeFile(path.join(tempDir, 'packages', 'README.md'), 'hello', 'utf8'); + + const result = await readGeneratedFile({ force: false }, tempDir); + const parsed = parseLabelMap(result); + + expect(parsed.scopes).toEqual({}); + }); + + it('exits with error when file exists and --force is not set', async () => { + const metaDir = path.join(tempDir, '.meta'); + await mkdir(metaDir, { recursive: true }); + await writeFile(path.join(metaDir, 'label-map.json'), '{}', 'utf8'); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(generateLabelMap({ force: false }, tempDir)).rejects.toThrow('process.exit called'); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('already exists')); + + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('overwrites existing file when --force is set', async () => { + const metaDir = path.join(tempDir, '.meta'); + await mkdir(metaDir, { recursive: true }); + await writeFile(path.join(metaDir, 'label-map.json'), '{"old": true}', 'utf8'); + + const result = await readGeneratedFile({ force: true }, tempDir); + const parsed = parseLabelMap(result); + + expect(parsed).toHaveProperty('types'); + expect(parsed).not.toHaveProperty('old'); + }); + + it('prints the output path on success', async () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + await generateLabelMap({ force: false }, tempDir); + + const expectedPath = path.join(tempDir, '.meta', 'label-map.json'); + expect(infoSpy).toHaveBeenCalledWith(expectedPath); + + infoSpy.mockRestore(); + }); +}); + +describe(printGenerateUsage, () => { + it('outputs available targets and options', () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + printGenerateUsage(); + + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('label-map')); + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('--force')); + + infoSpy.mockRestore(); + }); +}); + +/** Runs the generator and returns the file contents. */ +async function readGeneratedFile(options: { force: boolean }, workingDir: string): Promise { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + await generateLabelMap(options, workingDir); + infoSpy.mockRestore(); + return readFile(path.join(workingDir, '.meta', 'label-map.json'), 'utf8'); +} diff --git a/packages/agents/src/commands/generate-label-map.ts b/packages/agents/src/commands/generate-label-map.ts new file mode 100644 index 00000000..fb7b0f66 --- /dev/null +++ b/packages/agents/src/commands/generate-label-map.ts @@ -0,0 +1,173 @@ +import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Canonical mapping from commit type keys to human-readable label values. */ +const TYPE_MAP: Readonly> = { + ai: 'ai', + ci: 'ci', + deprecate: 'deprecate', + deps: 'dependencies', + docs: 'documentation', + feat: 'feature', + fix: 'fix', + fmt: 'formatting', + internal: 'utility', + perf: 'performance', + refactor: 'refactoring', + sec: 'security', + tests: 'tests', + tooling: 'tooling', +}; + +interface GenerateLabelMapOptions { + readonly force: boolean; +} + +/** + * Builds the `$schema` URL for the label-map JSON file using the agents package version. + */ +function buildSchemaUrl(version: string): string { + return `https://github.com/williamthorsen/codeassembly/raw/agents-v${version}/packages/agents/schemas/label-map.json`; +} + +/** + * Derives scope entries from `packages/` subdirectories in the given working directory. + * Returns an empty record if `packages/` does not exist. + */ +async function deriveScopes(workingDir: string): Promise> { + const packagesDir = path.join(workingDir, 'packages'); + + let entries: ReadonlyArray; + try { + entries = await readdir(packagesDir); + } catch (error: unknown) { + if (isEnoent(error)) { + return {}; + } + throw error; + } + + const scopes: Record = {}; + + for (const entry of entries) { + const entryPath = path.join(packagesDir, entry); + const entryStat = await stat(entryPath); + if (entryStat.isDirectory()) { + scopes[entry] = `scope:${entry}`; + } + } + + // Include root scope only when at least one package subdirectory exists (monorepo). + if (Object.keys(scopes).length > 0) { + scopes.root = 'scope:root'; + } + + return scopes; +} + +/** + * Resolves the `package.json` path relative to this module. + * + * In dev (`src/commands/`), two levels up reaches the package root. + * In built output (`dist/esm/commands/`), three levels up reaches the package root. + */ +async function resolvePackageJsonPath(): Promise { + const thisDir = path.dirname(fileURLToPath(import.meta.url)); + + const primaryPath = path.resolve(thisDir, '../../package.json'); + if (await pathExists(primaryPath)) { + return primaryPath; + } + + const fallbackPath = path.resolve(thisDir, '../../../package.json'); + if (await pathExists(fallbackPath)) { + return fallbackPath; + } + + throw new Error(`Could not locate package.json. Searched:\n ${primaryPath}\n ${fallbackPath}`); +} + +/** Checks whether a path exists on disk. */ +async function pathExists(filePath: string): Promise { + return stat(filePath) + .then(() => true) + .catch(() => false); +} + +/** + * Reads the agents package version from `package.json`. + */ +async function readPackageVersion(): Promise { + const packageJsonPath = await resolvePackageJsonPath(); + const raw = await readFile(packageJsonPath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || !('version' in parsed)) { + throw new Error('Unable to read version from package.json'); + } + const { version } = parsed; + if (typeof version !== 'string') { + throw new TypeError('Invalid version field in package.json'); + } + return version; +} + +/** + * Generates `.meta/label-map.json` in the given working directory. + * Refuses to overwrite an existing file unless `force` is true. + */ +export async function generateLabelMap(options: GenerateLabelMapOptions, workingDir?: string): Promise { + const cwd = workingDir ?? process.cwd(); + const outputDir = path.join(cwd, '.meta'); + const outputPath = path.join(outputDir, 'label-map.json'); + + // Check for existing file. + if (!options.force) { + try { + await stat(outputPath); + console.error(`Error: ${outputPath} already exists. Use --force to overwrite.`); + process.exit(1); + } catch (error: unknown) { + if (!isEnoent(error)) { + throw error; + } + // File does not exist — proceed. + } + } + + const version = await readPackageVersion(); + const scopes = await deriveScopes(cwd); + + const labelMap = { + $schema: buildSchemaUrl(version), + types: { ...TYPE_MAP }, + scopes, + }; + + const content = JSON.stringify(labelMap, undefined, 2) + '\n'; + + await mkdir(outputDir, { recursive: true }); + await writeFile(outputPath, content, 'utf8'); + + console.info(outputPath); +} + +/** + * Prints usage information for the `generate` command. + */ +export function printGenerateUsage(): void { + console.info(`Usage: codeassembly-agents generate [options] + +Targets: + label-map Generate .meta/label-map.json with type and scope mappings + +Options: + --force Overwrite an existing file`); +} + +/** + * Type guard that checks whether an error is a Node.js ENOENT error. + */ +function isEnoent(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'; +}