Procházet zdrojové kódy

feat(cli): migrate packages/cli to Bun-first with full test suite

- Replace fs-extra with Bun.file/Bun.write/node:fs/promises across all 9 source files
- Fix --help fast-path so all 8 subcommands are visible
- Replace tsup/tsx/vitest with bun build/bun run/bun:test
- Replace __dirname with import.meta.dir; use JSON import assertion for version
- Fix hardcoded OAC_VERSION in add.ts; add hashesMatch import to status.ts
- Replace computeFileHash with Bun.file().bytes() in sha256.ts
- Rename checkNodeVersion → checkBunVersion; parallelise doctor checks
- Fix TypeScript void-inference errors in update.ts, list.ts, status.ts
  by replacing .catch() with let/try-catch where result is used downstream
- Add ManifestError named error class; remove unsafe type casts
- Add 43 bun:test unit tests (sha256, manifest, installer, version) — 0 failures
darrenhinde před 5 měsíci
rodič
revize
89245d5bef

+ 37 - 0
packages/cli/package.json

@@ -0,0 +1,37 @@
+{
+  "name": "@nextsystems/oac-cli",
+  "version": "1.0.0",
+  "description": "OAC CLI — install, manage, and update AI agents and context files",
+  "type": "module",
+  "bin": {
+    "oac": "./dist/index.js"
+  },
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "files": ["dist"],
+  "scripts": {
+    "build": "bun build src/index.ts --outdir dist --target bun --splitting --banner '#!/usr/bin/env bun'",
+    "build:watch": "bun build src/index.ts --outdir dist --target bun --splitting --watch",
+    "dev": "bun run src/index.ts",
+    "test": "bun test",
+    "test:watch": "bun test --watch",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@openagents-control/compatibility-layer": "*",
+    "commander": "^12.0.0",
+    "chalk": "^5.3.0",
+    "ora": "^8.0.0",
+    "semver": "^7.6.0",
+    "zod": "^3.23.0"
+  },
+  "devDependencies": {
+    "@types/bun": "latest",
+    "@types/node": "^20.0.0",
+    "@types/semver": "^7.5.0",
+    "typescript": "^5.4.0"
+  },
+  "engines": {
+    "bun": ">=1.0.0"
+  }
+}

+ 305 - 0
packages/cli/src/commands/add.ts

@@ -0,0 +1,305 @@
+import path from 'node:path';
+import { rm } from 'node:fs/promises';
+import { type Command } from 'commander';
+import { loadRegistry, resolveComponent, listComponents } from '../lib/registry.js';
+import { getPackageRoot, getBundledFilePath } from '../lib/bundled.js';
+import { installFile } from '../lib/installer.js';
+import {
+  readManifest,
+  writeManifest,
+  addFileToManifest,
+  removeFileFromManifest,
+  createEmptyManifest,
+  type ManifestFile,
+  type FileEntry,
+} from '../lib/manifest.js';
+import { log, info, warn, error, success, verbose } from '../ui/logger.js';
+import { createSpinner } from '../ui/spinner.js';
+import { computeFileHash } from '../lib/sha256.js';
+import { readCliVersion } from '../lib/version.js';
+import type { RegistryComponent } from '../lib/registry.js';
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export type AddOptions = {
+  yolo: boolean;
+  dryRun: boolean;
+  verbose: boolean;
+  force: boolean;
+};
+
+export type RemoveOptions = {
+  yolo: boolean;
+  dryRun: boolean;
+  verbose: boolean;
+};
+
+// ── Pure helpers ──────────────────────────────────────────────────────────────
+
+/** Returns the destination path (relative to project root) for a component.
+ *  Uses component.path directly if it already starts with .opencode/,
+ *  otherwise prefixes with the correct subdirectory. */
+const getDestRelativePath = (component: RegistryComponent): string => {
+  if (component.path.startsWith('.opencode/')) return component.path;
+  const base = component.type === 'skill' ? '.opencode/skills' :
+               component.type === 'agent' ? '.opencode/agent' :
+               '.opencode/context';
+  return path.join(base, component.path);
+};
+
+/** Builds a FileEntry for a newly installed component. */
+const buildFileEntry = (
+  sha256: string,
+  component: RegistryComponent,
+): FileEntry => ({
+  sha256,
+  type: component.type,
+  source: 'registry',
+  installedAt: new Date().toISOString(),
+});
+
+/** Returns true if the file at destPath is already tracked in the manifest. */
+const isAlreadyInstalled = (
+  manifest: ManifestFile | null,
+  destRelativePath: string,
+): boolean => manifest?.files[destRelativePath] !== undefined;
+
+// ── List display ──────────────────────────────────────────────────────────────
+
+/** Prints all available components grouped by type. */
+const printAvailableComponents = async (_projectRoot: string): Promise<void> => {
+  const packageRoot = getPackageRoot();
+  const registry = await loadRegistry(packageRoot);
+  const all = listComponents(registry);
+
+  const byType = {
+    agent: all.filter((c) => c.type === 'agent'),
+    context: all.filter((c) => c.type === 'context'),
+    skill: all.filter((c) => c.type === 'skill'),
+  };
+
+  log('');
+  log('Available components:');
+  log('');
+
+  for (const [type, components] of Object.entries(byType)) {
+    if (components.length === 0) continue;
+    log(`  ${type.toUpperCase()}S`);
+    for (const c of components) {
+      log(`    oac add ${type}:${c.id}  — ${c.description}`);
+    }
+    log('');
+  }
+
+  log(`Run 'oac add <type>:<name>' to install a component.`);
+  log(`Example: oac add context:react-patterns`);
+};
+
+// ── Core install logic ────────────────────────────────────────────────────────
+
+/** Resolves the component from the registry or exits with a clear error. */
+const resolveOrFail = async (
+  ref: string,
+): Promise<{ component: RegistryComponent; packageRoot: string }> => {
+  const packageRoot = getPackageRoot();
+  const registry = await loadRegistry(packageRoot);
+  const component = resolveComponent(registry, ref);
+
+  if (component === null) {
+    error(`Component '${ref}' not found. Run 'oac add' to see available components.`);
+    process.exit(1);
+  }
+
+  return { component, packageRoot };
+};
+
+/** Checks if the component is already installed and handles --force / warning. */
+const checkAlreadyInstalled = (
+  manifest: ManifestFile | null,
+  destRelativePath: string,
+  force: boolean,
+): boolean => {
+  if (!isAlreadyInstalled(manifest, destRelativePath)) return false;
+
+  if (!force) {
+    warn(`Already installed. Use --force to reinstall.`);
+    return true; // signal: abort
+  }
+
+  info('Reinstalling (--force).');
+  return false; // signal: proceed
+};
+
+/** Performs the actual file copy and manifest update. */
+const performInstall = async (
+  component: RegistryComponent,
+  packageRoot: string,
+  projectRoot: string,
+  destRelativePath: string,
+  manifest: ManifestFile,
+  opts: AddOptions,
+): Promise<void> => {
+  const sourcePath = getBundledFilePath(packageRoot, component.path);
+  const destPath = path.join(projectRoot, destRelativePath);
+  const destDir = path.dirname(destRelativePath);
+
+  info(`Installing ${component.type}:${component.id} → ${destDir}/`);
+
+  if (opts.verbose) {
+    verbose(`Source: ${sourcePath}`);
+    verbose(`Destination: ${destPath}`);
+  }
+
+  const installOpts = {
+    projectRoot,
+    packageRoot,
+    dryRun: opts.dryRun,
+    yolo: opts.yolo,
+    verbose: opts.verbose,
+  };
+
+  await installFile(sourcePath, destPath, installOpts);
+
+  if (opts.dryRun) {
+    info(`[dry-run] Would install ${component.type}:${component.id} to ${destDir}/`);
+    return;
+  }
+
+  const sha256 = await computeFileHash(destPath);
+  const entry = buildFileEntry(sha256, component);
+  const updatedManifest = addFileToManifest(manifest, destRelativePath, entry);
+  await writeManifest(projectRoot, updatedManifest);
+
+  success(`Added ${component.id} to ${destDir}/`);
+};
+
+// ── Public command functions ──────────────────────────────────────────────────
+
+/**
+ * Implements `oac add [ref]`.
+ * With no ref: lists available components grouped by type.
+ * With ref (e.g. `context:react-patterns`): installs the component.
+ */
+export async function addCommand(
+  ref: string | undefined,
+  options: AddOptions,
+): Promise<void> {
+  const projectRoot = process.cwd();
+
+  if (ref === undefined) {
+    await printAvailableComponents(projectRoot);
+    return;
+  }
+
+  const spinner = createSpinner(`Resolving ${ref}…`, { dryRun: options.dryRun });
+  spinner.start();
+
+  try {
+    const { component, packageRoot } = await resolveOrFail(ref);
+    spinner.stop();
+
+    const manifest = (await readManifest(projectRoot)) ?? createEmptyManifest(readCliVersion());
+    const destRelativePath = getDestRelativePath(component);
+
+    const shouldAbort = checkAlreadyInstalled(manifest, destRelativePath, options.force);
+    if (shouldAbort) return;
+
+    await performInstall(component, packageRoot, projectRoot, destRelativePath, manifest, options);
+  } catch (err: unknown) {
+    spinner.fail();
+    const msg = err instanceof Error ? err.message : String(err);
+    error(`Failed to add '${ref}': ${msg}`);
+    process.exit(1);
+  }
+}
+
+/**
+ * Implements `oac remove [ref]`.
+ * Removes the component file from disk and updates the manifest.
+ */
+export async function removeCommand(
+  ref: string | undefined,
+  options: RemoveOptions,
+): Promise<void> {
+  const projectRoot = process.cwd();
+
+  if (ref === undefined) {
+    error('Please specify a component to remove. Example: oac remove context:react-patterns');
+    process.exit(1);
+  }
+
+  const spinner = createSpinner(`Resolving ${ref}…`, { dryRun: options.dryRun });
+  spinner.start();
+
+  try {
+    const { component } = await resolveOrFail(ref);
+    spinner.stop();
+
+    const manifest = await readManifest(projectRoot);
+    const destRelativePath = getDestRelativePath(component);
+
+    if (!isAlreadyInstalled(manifest, destRelativePath)) {
+      warn(`'${ref}' is not installed — nothing to remove.`);
+      return;
+    }
+
+    const destPath = path.join(projectRoot, destRelativePath);
+
+    if (options.verbose) {
+      verbose(`Removing: ${destPath}`);
+    }
+
+    info(`Removing ${component.type}:${component.id} from ${path.dirname(destRelativePath)}/`);
+
+    if (!options.dryRun) {
+      await rm(destPath, { recursive: true, force: true });
+      const updatedManifest = removeFileFromManifest(manifest!, destRelativePath);
+      await writeManifest(projectRoot, updatedManifest);
+      success(`Removed ${component.id}`);
+    } else {
+      info(`[dry-run] Would remove ${destPath}`);
+    }
+  } catch (err: unknown) {
+    spinner.fail();
+    const msg = err instanceof Error ? err.message : String(err);
+    error(`Failed to remove '${ref}': ${msg}`);
+    process.exit(1);
+  }
+}
+
+// ── Commander registration ────────────────────────────────────────────────────
+
+/**
+ * Registers the `add` and `remove` subcommands on the given Commander program.
+ */
+export function registerAddCommand(program: Command): void {
+  program
+    .command('add [ref]')
+    .description('Add a component (agent, context, or skill). Example: oac add context:react-patterns')
+    .option('--force', 'Reinstall even if already installed', false)
+    .option('--dry-run', 'Show what would happen without making changes', false)
+    .option('--yolo', 'Skip safety checks and overwrite user-modified files', false)
+    .option('--verbose', 'Show source and destination paths', false)
+    .action(async (ref: string | undefined, opts: { force?: boolean; dryRun?: boolean; yolo?: boolean; verbose?: boolean }) => {
+      await addCommand(ref, {
+        force: opts.force ?? false,
+        dryRun: opts.dryRun ?? false,
+        yolo: opts.yolo ?? false,
+        verbose: opts.verbose ?? false,
+      });
+    });
+
+  program
+    .command('remove [ref]')
+    .description('Remove an installed component. Example: oac remove context:react-patterns')
+    .option('--dry-run', 'Show what would happen without making changes', false)
+    .option('--yolo', 'Skip safety checks', false)
+    .option('--verbose', 'Show file paths', false)
+    .action(async (ref: string | undefined, opts: { dryRun?: boolean; yolo?: boolean; verbose?: boolean }) => {
+      await removeCommand(ref, {
+        dryRun: opts.dryRun ?? false,
+        yolo: opts.yolo ?? false,
+        verbose: opts.verbose ?? false,
+      });
+    });
+}

+ 335 - 0
packages/cli/src/commands/apply.ts

@@ -0,0 +1,335 @@
+/**
+ * oac apply — Generate IDE-specific config files from .opencode/agent/ definitions.
+ *
+ * Usage:
+ *   oac apply cursor    → writes .cursorrules
+ *   oac apply claude    → writes CLAUDE.md
+ *   oac apply windsurf  → writes .windsurfrules
+ *   oac apply --all     → detects present IDEs and generates for each
+ */
+
+import type { Command } from 'commander'
+import { join, dirname } from 'node:path'
+import { mkdir, stat } from 'node:fs/promises'
+import {
+  loadAgents,
+  CursorAdapter,
+  ClaudeAdapter,
+  WindsurfAdapter,
+} from '@openagents-control/compatibility-layer'
+import type { OpenAgent, ConversionResult } from '@openagents-control/compatibility-layer'
+import {
+  detectIdes,
+  getIdeOutputFile,
+  getIdeDisplayName,
+} from '../lib/ide-detect.js'
+import type { IdeType } from '../lib/ide-detect.js'
+import { log, info, warn, error, success, dim, verbose, setVerbose } from '../ui/logger.js'
+import { createSpinner } from '../ui/spinner.js'
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+/** File size thresholds in bytes. */
+const SIZE_LIMITS: Partial<Record<IdeType, { warn: number; limit: number }>> = {
+  cursor: { warn: 80 * 1024, limit: 100 * 1024 },
+}
+
+/** Supported apply targets (opencode is read-only source, not a write target). */
+const APPLY_TARGETS: IdeType[] = ['cursor', 'claude', 'windsurf']
+
+// ─── Adapter factory ──────────────────────────────────────────────────────────
+
+/** Returns the correct adapter instance for a given IDE type. */
+function getAdapter(ide: IdeType): CursorAdapter | ClaudeAdapter | WindsurfAdapter | null {
+  if (ide === 'cursor') return new CursorAdapter()
+  if (ide === 'claude') return new ClaudeAdapter()
+  if (ide === 'windsurf') return new WindsurfAdapter()
+  return null
+}
+
+// ─── Size helpers ─────────────────────────────────────────────────────────────
+
+/** Format bytes as a human-readable KB string. */
+function formatKb(bytes: number): string {
+  return `${(bytes / 1024).toFixed(0)}KB`
+}
+
+/** Print size info and warn if thresholds are exceeded. */
+function reportFileSize(ide: IdeType, outputPath: string, sizeBytes: number): void {
+  const displayName = getIdeDisplayName(ide)
+  const outputRelPath = getIdeOutputFile(ide)
+  const limits = SIZE_LIMITS[ide]
+
+  dim(`  ${displayName}: ${outputPath} is ${formatKb(sizeBytes)}`)
+
+  if (limits && sizeBytes >= limits.limit) {
+    warn(`${displayName}: ${outputRelPath} is ${formatKb(sizeBytes)} (limit: ${formatKb(limits.limit)}) — consider removing agents`)
+  } else if (limits && sizeBytes >= limits.warn) {
+    warn(`${displayName}: ${outputRelPath} is ${formatKb(sizeBytes)} (limit: ${formatKb(limits.limit)}) — consider removing agents`)
+  }
+}
+
+// ─── Backup helper ────────────────────────────────────────────────────────────
+
+/** Backs up an existing file to `{file}.bak` before overwriting. */
+async function backupIfExists(filePath: string): Promise<void> {
+  if (await Bun.file(filePath).exists()) {
+    const backupPath = `${filePath}.bak`
+    await Bun.write(backupPath, Bun.file(filePath))
+    dim(`  Backed up existing file → ${backupPath}`)
+  }
+}
+
+// ─── Dry-run preview ──────────────────────────────────────────────────────────
+
+/** Prints a dry-run preview of what would be written. */
+function printDryRunPreview(outputPath: string, content: string): void {
+  info(`[dry-run] Would write: ${outputPath} (${formatKb(Buffer.byteLength(content, 'utf-8'))})`)
+  dim('─'.repeat(60))
+  // Show first 10 lines as a preview
+  const preview = content.split('\n').slice(0, 10).join('\n')
+  dim(preview)
+  if (content.split('\n').length > 10) {
+    dim(`  … (${content.split('\n').length - 10} more lines)`)
+  }
+  dim('─'.repeat(60))
+}
+
+// ─── Conversion warnings ──────────────────────────────────────────────────────
+
+/** Prints adapter warnings to the user. */
+function reportWarnings(result: ConversionResult, isVerbose: boolean): void {
+  if (!result.warnings || result.warnings.length === 0) return
+
+  if (isVerbose) {
+    result.warnings.forEach((w: string) => warn(w))
+  } else {
+    warn(`${result.warnings.length} conversion warning(s) — use --verbose to see details`)
+  }
+}
+
+// ─── Single IDE apply ─────────────────────────────────────────────────────────
+
+/** Applies agents to a single IDE target. Returns true on success. */
+async function applyToIde(
+  ide: IdeType,
+  agents: OpenAgent[],
+  projectRoot: string,
+  options: { dryRun: boolean; verbose: boolean }
+): Promise<boolean> {
+  const displayName = getIdeDisplayName(ide)
+  const outputRelPath = getIdeOutputFile(ide)
+  const outputPath = join(projectRoot, outputRelPath)
+  const adapter = getAdapter(ide)
+
+  if (!adapter) {
+    error(`No adapter available for IDE: ${ide}`)
+    return false
+  }
+
+  if (agents.length === 0) {
+    warn(`No agents found in .opencode/agent/ — nothing to apply for ${displayName}`)
+    return false
+  }
+
+  const spinner = createSpinner(`Generating ${outputRelPath} for ${displayName}…`, {
+    dryRun: options.dryRun,
+  })
+  spinner.start()
+
+  try {
+    // Cursor merges all agents into one; others process the first/primary agent
+    const result: ConversionResult = ide === 'cursor'
+      ? await (adapter as CursorAdapter).fromOAC((adapter as CursorAdapter).mergeAgents(agents))
+      // For Claude and Windsurf, use the first (primary) agent — multiple agents are not merged
+      : await adapter.fromOAC(agents[0]!)
+
+    if (!result.success || result.configs.length === 0) {
+      spinner.fail(`Failed to generate ${outputRelPath}`)
+      const errs = result.errors ?? ['Unknown conversion error']
+      errs.forEach((e: string) => error(e))
+      return false
+    }
+
+    // Concatenate all config content (most adapters return one config)
+    const content = result.configs.map((c: { content: string }) => c.content).join('\n')
+    const sizeBytes = Buffer.byteLength(content, 'utf-8')
+
+    spinner.stop()
+
+    if (options.dryRun) {
+      printDryRunPreview(outputPath, content)
+    } else {
+      await backupIfExists(outputPath)
+      await mkdir(dirname(outputPath), { recursive: true })
+      await Bun.write(outputPath, content)
+    }
+
+    reportWarnings(result, options.verbose)
+
+    const label = options.dryRun ? '[dry-run] Would write' : 'Wrote'
+    success(`${label}: ${outputRelPath} (${formatKb(sizeBytes)})`)
+
+    if (!options.dryRun) {
+      const fileStat = await stat(outputPath)
+      reportFileSize(ide, outputPath, fileStat.size)
+    } else {
+      reportFileSize(ide, outputPath, sizeBytes)
+    }
+
+    return true
+  } catch (err) {
+    spinner.fail(`Error generating ${outputRelPath}`)
+    error(`${displayName} adapter failed: ${err instanceof Error ? err.message : String(err)}`)
+    return false
+  }
+}
+
+// ─── Resolve target IDEs ──────────────────────────────────────────────────────
+
+/** Resolves which IDE targets to apply based on CLI args and --all flag. */
+async function resolveTargets(
+  ide: string | undefined,
+  all: boolean,
+  projectRoot: string
+): Promise<IdeType[]> {
+  if (all) {
+    const detected = await detectIdes(projectRoot)
+    const present = detected
+      .filter((d) => d.detected && APPLY_TARGETS.includes(d.type))
+      .map((d) => d.type)
+
+    if (present.length === 0) {
+      warn('No supported IDEs detected. Install Cursor, Claude, or Windsurf first.')
+      info('Tip: Run `oac apply cursor` to generate .cursorrules regardless.')
+    } else {
+      info(`Detected IDEs: ${present.map(getIdeDisplayName).join(', ')}`)
+    }
+
+    return present
+  }
+
+  if (!ide) {
+    error('Specify an IDE target: cursor | claude | windsurf, or use --all')
+    return []
+  }
+
+  if (!APPLY_TARGETS.includes(ide as IdeType)) {
+    error(`Unknown IDE: "${ide}". Valid targets: ${APPLY_TARGETS.join(', ')}`)
+    return []
+  }
+
+  return [ide as IdeType]
+}
+
+// ─── Main command function ────────────────────────────────────────────────────
+
+/**
+ * Core logic for `oac apply`.
+ *
+ * @param ide     - Optional IDE target (cursor | claude | windsurf)
+ * @param options - CLI flags
+ */
+export async function applyCommand(
+  ide: string | undefined,
+  options: { yolo: boolean; dryRun: boolean; verbose: boolean; all: boolean }
+): Promise<void> {
+  const projectRoot = process.cwd()
+  const agentDir = join(projectRoot, '.opencode', 'agent')
+
+  // Sync verbose flag with logger module so verbose() calls work
+  setVerbose(options.verbose)
+
+  if (options.dryRun) {
+    info('Dry-run mode — no files will be written')
+  }
+
+  // Resolve which IDEs to target
+  const targets = await resolveTargets(ide, options.all, projectRoot)
+  if (targets.length === 0) {
+    process.exitCode = 1
+    return
+  }
+
+  // Load agents once — shared across all targets
+  verbose(`Loading agents from ${agentDir}`)
+  const agentDirExists = await stat(agentDir).then((s) => s.isDirectory()).catch(() => false)
+  if (!agentDirExists) {
+    error(`Agent directory not found: ${agentDir}`)
+    error('Run `oac init` first to set up your project.')
+    process.exitCode = 1
+    return
+  }
+
+  let agents: OpenAgent[]
+  try {
+    agents = await loadAgents(agentDir)
+    verbose(`Loaded ${agents.length} agent(s)`)
+  } catch (err) {
+    error(`Failed to load agents from ${agentDir}: ${err instanceof Error ? err.message : String(err)}`)
+    process.exitCode = 1
+    return
+  }
+
+  if (agents.length === 0) {
+    warn(`No agents found in ${agentDir}`)
+    info('Add agent files (*.md) to .opencode/agent/ and try again.')
+    process.exitCode = 1
+    return
+  }
+
+  log('')
+  info(`Applying ${agents.length} agent(s) to: ${targets.map(getIdeDisplayName).join(', ')}`)
+  log('')
+
+  // Apply to each target
+  let allSucceeded = true
+  for (const target of targets) {
+    const ok = await applyToIde(target, agents, projectRoot, {
+      dryRun: options.dryRun,
+      verbose: options.verbose,
+    })
+    if (!ok) allSucceeded = false
+    log('')
+  }
+
+  if (!allSucceeded) {
+    process.exitCode = 1
+  }
+}
+
+// ─── Commander registration ───────────────────────────────────────────────────
+
+/**
+ * Registers the `oac apply [ide]` command with the Commander program.
+ *
+ * @param program - The root Commander instance
+ */
+export function registerApplyCommand(program: Command): void {
+  program
+    .command('apply [ide]')
+    .description('Generate IDE config files from .opencode/agent/ definitions')
+    .option('--all', 'Apply to all detected IDEs', false)
+    .option('--dry-run', 'Show what would be generated without writing', false)
+    .option('--verbose', 'Show adapter warnings and transformation details', false)
+    .option('--yolo', 'Skip confirmation prompts', false)
+    .addHelpText(
+      'after',
+      `
+Examples:
+  oac apply cursor       Generate .cursorrules
+  oac apply claude       Generate CLAUDE.md
+  oac apply windsurf     Generate .windsurfrules
+  oac apply --all        Generate for all detected IDEs
+  oac apply cursor --dry-run   Preview without writing
+`
+    )
+    .action(async (ide: string | undefined, opts: Record<string, unknown>) => {
+      await applyCommand(ide, {
+        yolo: Boolean(opts['yolo']),
+        dryRun: Boolean(opts['dryRun']),
+        verbose: Boolean(opts['verbose']),
+        all: Boolean(opts['all']),
+      })
+    })
+}

+ 403 - 0
packages/cli/src/commands/doctor.ts

@@ -0,0 +1,403 @@
+import { join } from 'node:path';
+import { type Command } from 'commander';
+import semver from 'semver';
+
+import { readCliVersion } from '../lib/version.js';
+import { readManifest } from '../lib/manifest.js';
+import { readConfig } from '../lib/config.js';
+import { computeFileHash, hashesMatch } from '../lib/sha256.js';
+import { detectIdes, getIdeDisplayName, getIdeOutputFile } from '../lib/ide-detect.js';
+import { log, info, warn, error, success, dim, bold, setVerbose } from '../ui/logger.js';
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export type CheckStatus = 'ok' | 'warn' | 'error' | 'info';
+
+export type CheckResult = {
+  name: string;
+  status: CheckStatus;
+  message: string;
+  detail?: string[];
+};
+
+export type DoctorOptions = {
+  verbose: boolean;
+  json: boolean;
+};
+
+type DoctorSummary = {
+  ok: number;
+  warnings: number;
+  errors: number;
+};
+
+// ── Version helpers ───────────────────────────────────────────────────────────
+
+/** Fetches the latest version from the npm registry. Returns null if offline. */
+const fetchLatestNpmVersion = async (packageName: string): Promise<string | null> => {
+  try {
+    const url = `https://registry.npmjs.org/${packageName}/latest`;
+    const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
+    if (!res.ok) return null;
+    const data = (await res.json()) as { version?: string };
+    return data.version ?? null;
+  } catch {
+    // Network unavailable or timeout — non-blocking
+    return null;
+  }
+};
+
+// ── Individual check functions ────────────────────────────────────────────────
+
+/** Check 1: OAC version vs npm registry (non-blocking, skipped if offline). */
+const checkOacVersion = async (): Promise<CheckResult> => {
+  const current = readCliVersion();
+  const latest = await fetchLatestNpmVersion('@nextsystems/oac');
+
+  if (latest === null) {
+    return {
+      name: 'OAC version',
+      status: 'info',
+      message: `${current} (registry check skipped — offline or unreachable)`,
+    };
+  }
+
+  const isOutdated = semver.lt(current, latest);
+  if (isOutdated) {
+    return {
+      name: 'OAC version',
+      status: 'warn',
+      message: `${current} (latest: ${latest}) — run 'npm install -g @nextsystems/oac' to update`,
+    };
+  }
+
+  return {
+    name: 'OAC version',
+    status: 'ok',
+    message: `${current} (latest)`,
+  };
+};
+
+/** Check 2: Bun runtime version >= 1.0.0. */
+const checkBunVersion = (): CheckResult => {
+  const bunVersion = Bun.version; // e.g. "1.1.0" — global provided by @types/bun
+  const MIN_BUN = '1.0.0';
+  const isValid = semver.gte(bunVersion, MIN_BUN);
+
+  return {
+    name: 'Bun runtime',
+    status: isValid ? 'ok' : 'error',
+    message: isValid
+      ? `${bunVersion} (>= ${MIN_BUN} required)`
+      : `${bunVersion} is below minimum required ${MIN_BUN} — upgrade Bun`,
+  };
+};
+
+/** Check 3: .oac/config.json exists and is valid JSON. */
+const checkConfig = async (projectRoot: string): Promise<CheckResult> => {
+  try {
+    const config = await readConfig(projectRoot);
+    if (config === null) {
+      return {
+        name: 'Config',
+        status: 'warn',
+        message: '.oac/config.json not found — run \'oac init\' to create it',
+      };
+    }
+    return {
+      name: 'Config',
+      status: 'ok',
+      message: '.oac/config.json valid',
+    };
+  } catch (err) {
+    const msg = err instanceof Error ? err.message : String(err);
+    return {
+      name: 'Config',
+      status: 'error',
+      message: `.oac/config.json invalid — ${msg}`,
+    };
+  }
+};
+
+/** Check 4: .oac/manifest.json exists and is valid JSON. */
+const checkManifest = async (projectRoot: string): Promise<CheckResult> => {
+  try {
+    const manifest = await readManifest(projectRoot);
+    if (manifest === null) {
+      return {
+        name: 'Manifest',
+        status: 'error',
+        message: '.oac/manifest.json not found — run \'oac init\' to create it',
+      };
+    }
+    const fileCount = Object.keys(manifest.files).length;
+    return {
+      name: 'Manifest',
+      status: 'ok',
+      message: `.oac/manifest.json valid (${fileCount} file${fileCount !== 1 ? 's' : ''} tracked)`,
+    };
+  } catch (err) {
+    const msg = err instanceof Error ? err.message : String(err);
+    return {
+      name: 'Manifest',
+      status: 'error',
+      message: `.oac/manifest.json invalid — ${msg}`,
+    };
+  }
+};
+
+/** Check 5: Every file listed in manifest exists on disk. */
+const checkFilesOnDisk = async (projectRoot: string): Promise<CheckResult> => {
+  const manifest = await readManifest(projectRoot);
+  if (manifest === null) {
+    return {
+      name: 'Files on disk',
+      status: 'error',
+      message: 'Cannot check files — manifest is missing',
+    };
+  }
+
+  const trackedFiles = Object.keys(manifest.files);
+  if (trackedFiles.length === 0) {
+    return {
+      name: 'Files on disk',
+      status: 'ok',
+      message: 'No files tracked in manifest',
+    };
+  }
+
+  const missingFiles: string[] = [];
+  for (const filePath of trackedFiles) {
+    const absPath = join(projectRoot, filePath);
+    const exists = await Bun.file(absPath).exists();
+    if (!exists) missingFiles.push(filePath);
+  }
+
+  if (missingFiles.length > 0) {
+    return {
+      name: 'Files on disk',
+      status: 'error',
+      message: `${missingFiles.length} file${missingFiles.length !== 1 ? 's' : ''} missing from disk`,
+      detail: missingFiles,
+    };
+  }
+
+  return {
+    name: 'Files on disk',
+    status: 'ok',
+    message: `All ${trackedFiles.length} tracked file${trackedFiles.length !== 1 ? 's' : ''} present`,
+  };
+};
+
+/** Check 6: SHA256 mismatch detection — warn for user-modified files. */
+const checkModifiedFiles = async (projectRoot: string): Promise<CheckResult> => {
+  const manifest = await readManifest(projectRoot);
+  if (manifest === null) {
+    return {
+      name: 'Modified files',
+      status: 'error',
+      message: 'Cannot check modifications — manifest is missing',
+    };
+  }
+
+  const trackedFiles = Object.keys(manifest.files);
+  if (trackedFiles.length === 0) {
+    return {
+      name: 'Modified files',
+      status: 'ok',
+      message: 'No files tracked',
+    };
+  }
+
+  const modifiedFiles: string[] = [];
+  for (const filePath of trackedFiles) {
+    const absPath = join(projectRoot, filePath);
+    const exists = await Bun.file(absPath).exists();
+    if (!exists) continue; // Already reported by checkFilesOnDisk
+
+    try {
+      const currentHash = await computeFileHash(absPath);
+      const manifestHash = manifest.files[filePath]!.sha256;
+      if (!hashesMatch(currentHash, manifestHash)) {
+        modifiedFiles.push(filePath);
+      }
+    } catch {
+      // If we can't hash it, skip — checkFilesOnDisk will catch missing files
+    }
+  }
+
+  if (modifiedFiles.length > 0) {
+    return {
+      name: 'Modified files',
+      status: 'warn',
+      message: `${modifiedFiles.length} file${modifiedFiles.length !== 1 ? 's' : ''} modified since install`,
+      detail: modifiedFiles,
+    };
+  }
+
+  return {
+    name: 'Modified files',
+    status: 'ok',
+    message: 'No files modified since install',
+  };
+};
+
+/** Check 7: IDE detection — suggests 'oac apply' for each detected IDE. */
+const checkIdes = async (projectRoot: string): Promise<CheckResult[]> => {
+  const ides = await detectIdes(projectRoot);
+  const detected = ides.filter((ide) => ide.detected);
+
+  if (detected.length === 0) {
+    return [
+      {
+        name: 'IDE detection',
+        status: 'info',
+        message: 'No IDEs detected — run \'oac apply <ide>\' to generate IDE-specific files',
+      },
+    ];
+  }
+
+  return detected.map((ide) => ({
+    name: `IDE: ${getIdeDisplayName(ide.type)}`,
+    status: 'warn' as CheckStatus,
+    message: `${getIdeDisplayName(ide.type)} detected (${ide.indicator}) — run 'oac apply ${ide.type}' to sync ${getIdeOutputFile(ide.type)}`,
+  }));
+};
+
+// ── Result rendering ──────────────────────────────────────────────────────────
+
+/** Prints a single check result with colored status indicator. */
+const printCheckResult = (result: CheckResult): void => {
+  switch (result.status) {
+    case 'ok':
+      success(`${result.name}: ${result.message}`);
+      break;
+    case 'warn':
+      warn(`${result.name}: ${result.message}`);
+      break;
+    case 'error':
+      error(`${result.name}: ${result.message}`);
+      break;
+    case 'info':
+      info(`${result.name}: ${result.message}`);
+      break;
+  }
+
+  if (result.detail && result.detail.length > 0) {
+    for (const line of result.detail) {
+      dim(`      - ${line}`);
+    }
+  }
+};
+
+/** Computes summary counts from check results. Pure function. */
+const summariseResults = (results: CheckResult[]): DoctorSummary => ({
+  ok: results.filter((r) => r.status === 'ok' || r.status === 'info').length,
+  warnings: results.filter((r) => r.status === 'warn').length,
+  errors: results.filter((r) => r.status === 'error').length,
+});
+
+/** Returns the overall status string from a summary. Pure function. */
+const overallStatus = (summary: DoctorSummary): 'healthy' | 'warning' | 'error' => {
+  if (summary.errors > 0) return 'error';
+  if (summary.warnings > 0) return 'warning';
+  return 'healthy';
+};
+
+/** Prints the final result line. Side-effect only. */
+const printFinalResult = (summary: DoctorSummary): void => {
+  log('');
+  const status = overallStatus(summary);
+  const parts: string[] = [];
+  if (summary.warnings > 0) parts.push(`${summary.warnings} warning${summary.warnings !== 1 ? 's' : ''}`);
+  if (summary.errors > 0) parts.push(`${summary.errors} error${summary.errors !== 1 ? 's' : ''}`);
+  const detail = parts.length > 0 ? ` (${parts.join(', ')})` : '';
+
+  if (status === 'healthy') {
+    success(`Result: HEALTHY${detail}`);
+  } else if (status === 'warning') {
+    warn(`Result: WARNING${detail}`);
+  } else {
+    error(`Result: UNHEALTHY${detail}`);
+  }
+  log('');
+};
+
+// ── Main command ──────────────────────────────────────────────────────────────
+
+/**
+ * Implements `oac doctor`:
+ *  Runs all 7 health checks, prints results, and exits with code 0 (healthy/warnings)
+ *  or 1 (errors found). Supports --json for machine-readable output.
+ */
+export async function doctorCommand(options: DoctorOptions): Promise<void> {
+  if (options.verbose) setVerbose(true);
+
+  const projectRoot = process.cwd();
+
+  // Run all independent async checks in parallel for speed
+  const [configResult, manifestResult, filesResult, modifiedResult, ideResults, versionResult] =
+    await Promise.all([
+      checkConfig(projectRoot),
+      checkManifest(projectRoot),
+      checkFilesOnDisk(projectRoot),
+      checkModifiedFiles(projectRoot),
+      checkIdes(projectRoot),
+      checkOacVersion(),
+    ]);
+
+  const allResults: CheckResult[] = [
+    checkBunVersion(), // synchronous — call directly
+    configResult,
+    manifestResult,
+    filesResult,
+    modifiedResult,
+    ...ideResults,    // checkIdes returns CheckResult[]
+    versionResult,
+  ];
+
+  const summary = summariseResults(allResults);
+  const status = overallStatus(summary);
+
+  // JSON output mode (for CI)
+  if (options.json) {
+    const output = {
+      status,
+      checks: allResults,
+      summary,
+    };
+    log(JSON.stringify(output, null, 2));
+    process.exit(summary.errors > 0 ? 1 : 0);
+    return;
+  }
+
+  // Human-readable output
+  log('');
+  bold('OAC Doctor — Checking your setup...');
+  log('');
+
+  for (const result of allResults) {
+    printCheckResult(result);
+  }
+
+  printFinalResult(summary);
+
+  process.exit(summary.errors > 0 ? 1 : 0);
+}
+
+// ── Commander registration ────────────────────────────────────────────────────
+
+/**
+ * Registers the `doctor` subcommand on the given Commander program.
+ * Called by the CLI entry point (index.ts).
+ */
+export function registerDoctorCommand(program: Command): void {
+  program
+    .command('doctor')
+    .description('Check your OAC setup and report any issues')
+    .option('--verbose', 'Show additional diagnostic detail', false)
+    .option('--json', 'Output results as machine-readable JSON (for CI)', false)
+    .action(async (opts: { verbose: boolean; json: boolean }) => {
+      await doctorCommand({ verbose: opts.verbose, json: opts.json });
+    });
+}

+ 263 - 0
packages/cli/src/commands/init.ts

@@ -0,0 +1,263 @@
+import { type Command } from 'commander';
+
+import { readCliVersion } from '../lib/version.js';
+import { isProjectRoot, installFiles } from '../lib/installer.js';
+import { getPackageRoot, listBundledFiles } from '../lib/bundled.js';
+import { writeManifest } from '../lib/manifest.js';
+import { readConfig, writeConfig, createDefaultConfig } from '../lib/config.js';
+import { detectIdes } from '../lib/ide-detect.js';
+import { log, info, warn, error, success, setVerbose, verbose } from '../ui/logger.js';
+import { createSpinner } from '../ui/spinner.js';
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export type InitOptions = {
+  yolo: boolean;
+  dryRun: boolean;
+  verbose: boolean;
+};
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+/** Counts files by type prefix. Pure function. */
+const countByType = (
+  files: string[],
+): { agents: number; context: number; skills: number; other: number } => ({
+  agents: files.filter((f) => f.startsWith('.opencode/agent/')).length,
+  context: files.filter((f) => f.startsWith('.opencode/context/')).length,
+  skills: files.filter((f) => f.startsWith('.opencode/skills/')).length,
+  other: files.filter(
+    (f) =>
+      !f.startsWith('.opencode/agent/') &&
+      !f.startsWith('.opencode/context/') &&
+      !f.startsWith('.opencode/skills/'),
+  ).length,
+});
+
+/** Formats a file-count summary string. Pure function. */
+const formatFileSummary = (counts: ReturnType<typeof countByType>): string => {
+  const parts: string[] = [];
+  if (counts.agents > 0) parts.push(`${counts.agents} agent${counts.agents !== 1 ? 's' : ''}`);
+  if (counts.context > 0) parts.push(`${counts.context} context file${counts.context !== 1 ? 's' : ''}`);
+  if (counts.skills > 0) parts.push(`${counts.skills} skill${counts.skills !== 1 ? 's' : ''}`);
+  if (counts.other > 0) parts.push(`${counts.other} other file${counts.other !== 1 ? 's' : ''}`);
+  return parts.join(', ') || '0 files';
+};
+
+/** Prints the pre-install plan. Side-effect only. */
+const printPlan = (
+  bundledFiles: string[],
+  ides: Awaited<ReturnType<typeof detectIdes>>,
+  dryRun: boolean,
+): void => {
+  const counts = countByType(bundledFiles);
+  const detectedIdes = ides.filter((i) => i.detected).map((i) => i.type);
+
+  log('');
+  log(dryRun ? '  [dry-run] oac init — no files will be written' : '  oac init');
+  log('');
+  info(`Will install: ${formatFileSummary(counts)}`);
+  info(`Destination:  .opencode/ (relative to project root)`);
+
+  if (detectedIdes.length > 0) {
+    info(`IDEs detected: ${detectedIdes.join(', ')} — run \`oac apply\` after init`);
+  } else {
+    info('No IDEs detected — run `oac apply <ide>` to generate IDE-specific files');
+  }
+
+  log('');
+};
+
+/** Prints the post-install summary. Side-effect only. */
+const printSummary = (
+  installed: number,
+  skipped: number,
+  errors: number,
+  dryRun: boolean,
+): void => {
+  log('');
+  if (dryRun) {
+    info(`[dry-run] Would install ${installed} file${installed !== 1 ? 's' : ''}.`);
+    info('No changes were made. Remove --dry-run to apply.');
+    return;
+  }
+  if (errors > 0) {
+    warn(`Completed with ${errors} error${errors !== 1 ? 's' : ''}.`);
+  }
+  if (skipped > 0) {
+    info(`Skipped ${skipped} file${skipped !== 1 ? 's' : ''} (already modified — use --yolo to overwrite).`);
+  }
+  success(
+    `Done! ${installed} file${installed !== 1 ? 's' : ''} installed. Run \`oac doctor\` to verify.`,
+  );
+  log('');
+};
+
+// ── Validation ────────────────────────────────────────────────────────────────
+
+/** Validates we are in a project root. Exits with code 1 if not. */
+const assertProjectRoot = async (cwd: string): Promise<void> => {
+  const isRoot = await isProjectRoot(cwd);
+  if (!isRoot) {
+    error(
+      'Not a project root — no package.json or .git found in the current directory.',
+    );
+    error('Fix: run `oac init` from your project root (where package.json lives).');
+    process.exit(1);
+  }
+};
+
+// ── Config guard ──────────────────────────────────────────────────────────────
+
+/**
+ * Writes the default config only if one does not already exist.
+ * Idempotent — never overwrites an existing config.
+ */
+const ensureConfig = async (projectRoot: string, dryRun: boolean): Promise<void> => {
+  const existing = await readConfig(projectRoot);
+  if (existing !== null) {
+    verbose('Config already exists — skipping config write.');
+    return;
+  }
+  if (dryRun) {
+    info('[dry-run] Would write .oac/config.json with defaults.');
+    return;
+  }
+  await writeConfig(projectRoot, createDefaultConfig());
+  verbose('Wrote .oac/config.json with defaults.');
+};
+
+// ── Main command ──────────────────────────────────────────────────────────────
+
+/**
+ * Implements `oac init`:
+ *  1. Validates we are in a project root
+ *  2. Detects IDEs and prints the install plan
+ *  3. Copies all bundled files via installFiles()
+ *  4. Writes .oac/manifest.json
+ *  5. Writes .oac/config.json (only if absent)
+ *  6. Prints a completion summary
+ */
+export async function initCommand(options: InitOptions): Promise<void> {
+  // Respect CI=true as implicit --yolo
+  const effectiveYolo = options.yolo || process.env['CI'] === 'true';
+  const effectiveOptions = { ...options, yolo: effectiveYolo };
+
+  if (effectiveOptions.verbose) setVerbose(true);
+
+  const projectRoot = process.cwd();
+
+  // Step 1: validate project root
+  await assertProjectRoot(projectRoot);
+
+  // Step 2: locate bundled files
+  let packageRoot: string;
+  let bundledFiles: string[];
+  try {
+    packageRoot = getPackageRoot();
+    bundledFiles = await listBundledFiles(packageRoot);
+  } catch (err) {
+    const msg = err instanceof Error ? err.message : String(err);
+    error(`Could not locate bundled files: ${msg}`);
+    error('Fix: ensure @nextsystems/oac is installed correctly (try reinstalling).');
+    process.exit(1);
+    return;
+  }
+
+  if (bundledFiles.length === 0) {
+    warn('No bundled files found — nothing to install.');
+    warn('Fix: the @nextsystems/oac package may be missing its bundled assets.');
+    process.exit(1);
+  }
+
+  // Step 3: detect IDEs and print plan
+  const ides = await detectIdes(projectRoot);
+  printPlan(bundledFiles, ides, effectiveOptions.dryRun);
+
+  // Step 4: install files
+  const spinner = createSpinner('Installing files…', { dryRun: effectiveOptions.dryRun });
+  spinner.start();
+
+  let installResult: Awaited<ReturnType<typeof installFiles>>;
+  try {
+    installResult = await installFiles(bundledFiles, {
+      projectRoot,
+      packageRoot,
+      dryRun: effectiveOptions.dryRun,
+      yolo: effectiveOptions.yolo,
+      verbose: effectiveOptions.verbose,
+    });
+  } catch (err) {
+    spinner.fail('Installation failed.');
+    const msg = err instanceof Error ? err.message : String(err);
+    error(`Installation failed: ${msg}`);
+    error('Fix: check file permissions in your project directory.');
+    process.exit(1);
+    return;
+  }
+  const { result, updatedManifest } = installResult;
+
+  // Report per-file errors (non-fatal — partial installs are still useful)
+  for (const fileError of result.errors) {
+    warn(`Error: ${fileError}`);
+  }
+
+  spinner.succeed(`Installed ${result.installed.length} file${result.installed.length !== 1 ? 's' : ''}.`);
+
+  // Step 5: write manifest (skip in dry-run)
+  if (effectiveOptions.dryRun) {
+    info('[dry-run] Would write .oac/manifest.json');
+  } else {
+    const cliVersion = readCliVersion();
+    const finalManifest = { ...updatedManifest, oacVersion: cliVersion };
+
+    await writeManifest(projectRoot, finalManifest).catch((err: unknown) => {
+      const msg = err instanceof Error ? err.message : String(err);
+      error(`Failed to write manifest: ${msg}`);
+      error('Fix: check write permissions for the .oac/ directory.');
+      process.exit(1) as never;
+    });
+    verbose('Wrote .oac/manifest.json');
+  }
+
+  // Step 6: write config (only if absent)
+  await ensureConfig(projectRoot, effectiveOptions.dryRun).catch((err: unknown) => {
+    const msg = err instanceof Error ? err.message : String(err);
+    error(`Failed to write config: ${msg}`);
+    error('Fix: check write permissions for the .oac/ directory.');
+    process.exit(1) as never;
+  });
+
+  // Step 7: print summary
+  printSummary(
+    result.installed.length,
+    result.skipped.length,
+    result.errors.length,
+    effectiveOptions.dryRun,
+  );
+
+  // Exit 0 on success (explicit for clarity)
+  process.exit(0);
+}
+
+// ── Commander registration ────────────────────────────────────────────────────
+
+/**
+ * Registers the `init` subcommand on the given Commander program.
+ * Called by the CLI entry point (index.ts).
+ */
+export function registerInitCommand(program: Command): void {
+  program
+    .command('init')
+    .description('Set up OAC agents and context files in the current project')
+    .option('--yolo', 'Skip conflict checks and overwrite user-modified files', false)
+    .option('--dry-run', 'Print what would happen without making any changes', false)
+    .option('--verbose', 'Show each file being copied', false)
+    .action(async (opts: { yolo: boolean; dryRun: boolean; verbose: boolean }) => {
+      await initCommand({
+        yolo: opts.yolo,
+        dryRun: opts.dryRun,
+        verbose: opts.verbose,
+      });
+    });
+}

+ 274 - 0
packages/cli/src/commands/list.ts

@@ -0,0 +1,274 @@
+import { type Command } from 'commander';
+import path from 'node:path';
+
+import { readManifest, type ManifestFile, type ManifestFileType } from '../lib/manifest.js';
+import { computeFileHash, hashesMatch } from '../lib/sha256.js';
+import { log, info, warn, bold, dim, setVerbose } from '../ui/logger.js';
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export type ListOptions = {
+  type?: string;
+  context: boolean;
+  agents: boolean;
+  skills: boolean;
+  verbose: boolean;
+};
+
+/** A single display row derived from a manifest file entry. */
+type FileRow = {
+  filePath: string;
+  displayPath: string;
+  type: ManifestFileType;
+  installedAt: string;
+  sha256: string;
+  userModified: boolean;
+};
+
+// ── Path helpers (pure) ───────────────────────────────────────────────────────
+
+/** Strips the well-known .opencode/ prefix for a cleaner display path. Pure. */
+const toDisplayPath = (filePath: string, type: ManifestFileType): string => {
+  const prefixes: Record<ManifestFileType, string> = {
+    agent:   '.opencode/agent/',
+    context: '.opencode/context/',
+    skill:   '.opencode/skills/',
+    config:  '.oac/',
+    other:   '',
+  };
+  const prefix = prefixes[type];
+  return prefix && filePath.startsWith(prefix)
+    ? filePath.slice(prefix.length)
+    : filePath;
+};
+
+/** Formats an ISO timestamp as a short date string. Pure. */
+const formatDate = (iso: string): string => {
+  try {
+    return new Date(iso).toLocaleDateString('en-US', {
+      year: 'numeric', month: 'short', day: 'numeric',
+    });
+  } catch {
+    return iso;
+  }
+};
+
+// ── Grouping / filtering (pure) ───────────────────────────────────────────────
+
+/** Resolves which types to display based on CLI flags. Pure. */
+const resolveActiveTypes = (options: ListOptions): ManifestFileType[] | null => {
+  // --type flag takes precedence
+  if (options.type) {
+    const t = options.type as ManifestFileType;
+    return ['agent', 'context', 'skill', 'config', 'other'].includes(t) ? [t] : null;
+  }
+  // Individual shorthand flags
+  const selected: ManifestFileType[] = [];
+  if (options.agents)  selected.push('agent');
+  if (options.context) selected.push('context');
+  if (options.skills)  selected.push('skill');
+  // No flags → show all
+  return selected.length > 0 ? selected : null;
+};
+
+/** Groups file rows by their ManifestFileType. Pure. */
+const groupByType = (rows: FileRow[]): Map<ManifestFileType, FileRow[]> => {
+  const groups = new Map<ManifestFileType, FileRow[]>();
+  for (const row of rows) {
+    const existing = groups.get(row.type) ?? [];
+    groups.set(row.type, [...existing, row]);
+  }
+  return groups;
+};
+
+// ── SHA256 check ──────────────────────────────────────────────────────────────
+
+/** Checks whether a file on disk differs from its manifest hash. */
+const isUserModified = async (
+  projectRoot: string,
+  filePath: string,
+  manifestHash: string,
+): Promise<boolean> => {
+  try {
+    const diskHash = await computeFileHash(path.join(projectRoot, filePath));
+    return !hashesMatch(diskHash, manifestHash);
+  } catch {
+    // File missing from disk — treat as modified (doctor will catch this)
+    return false;
+  }
+};
+
+// ── Row builder ───────────────────────────────────────────────────────────────
+
+/** Builds display rows from the manifest, checking SHA256 when verbose. */
+const buildRows = async (
+  manifest: ManifestFile,
+  projectRoot: string,
+  checkHashes: boolean,
+): Promise<FileRow[]> => {
+  const entries = Object.entries(manifest.files);
+  const rows = await Promise.all(
+    entries.map(async ([filePath, entry]) => {
+      const userModified = checkHashes
+        ? await isUserModified(projectRoot, filePath, entry.sha256)
+        : false;
+      return {
+        filePath,
+        displayPath: toDisplayPath(filePath, entry.type),
+        type: entry.type,
+        installedAt: entry.installedAt,
+        sha256: entry.sha256,
+        userModified,
+      } satisfies FileRow;
+    }),
+  );
+  return rows.sort((a, b) => a.displayPath.localeCompare(b.displayPath));
+};
+
+// ── Rendering (side-effects only) ─────────────────────────────────────────────
+
+/** Prints a single group section. */
+const printGroup = (
+  label: string,
+  rows: FileRow[],
+  verbose: boolean,
+): void => {
+  log('');
+  bold(`  ${label} (${rows.length}):`);
+  for (const row of rows) {
+    const modifiedTag = row.userModified ? ' ⚠ modified' : '';
+    const line = `    ${row.displayPath}${modifiedTag}`;
+    if (row.userModified) {
+      warn(line.trimStart());
+    } else {
+      log(line);
+    }
+    if (verbose) {
+      dim(`      sha256:      ${row.sha256}`);
+      dim(`      installedAt: ${formatDate(row.installedAt)}`);
+    }
+  }
+};
+
+/** Prints the full list output. */
+const printList = (
+  groups: Map<ManifestFileType, FileRow[]>,
+  activeTypes: ManifestFileType[] | null,
+  verbose: boolean,
+): void => {
+  const TYPE_LABELS: Record<ManifestFileType, string> = {
+    agent:   'Agents',
+    context: 'Context',
+    skill:   'Skills',
+    config:  'Config',
+    other:   'Other',
+  };
+  // Display order
+  const ORDER: ManifestFileType[] = ['agent', 'context', 'skill', 'config', 'other'];
+  const typesToShow = activeTypes ?? ORDER;
+
+  log('');
+  bold('OAC Installed Components');
+
+  let totalShown = 0;
+  for (const type of typesToShow) {
+    const rows = groups.get(type);
+    if (!rows || rows.length === 0) continue;
+    printGroup(TYPE_LABELS[type], rows, verbose);
+    totalShown += rows.length;
+  }
+
+  log('');
+  if (totalShown === 0) {
+    info('No components match the selected filter.');
+  } else {
+    dim(`  Total: ${totalShown} file${totalShown !== 1 ? 's' : ''}`);
+  }
+  log('');
+};
+
+// ── Main command ──────────────────────────────────────────────────────────────
+
+/**
+ * Implements `oac list`:
+ *  1. Reads manifest from project root
+ *  2. Builds display rows (with optional SHA256 check in verbose mode)
+ *  3. Groups by type and applies any active filter
+ *  4. Prints human-readable output
+ *
+ * Always exits 0 — this is a read-only command.
+ */
+export async function listCommand(options: ListOptions): Promise<void> {
+  if (options.verbose) setVerbose(true);
+
+  const projectRoot = process.cwd();
+
+  // Read manifest — null means not initialised
+  let manifest: ManifestFile | null;
+  try {
+    manifest = await readManifest(projectRoot);
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    warn(`Could not read manifest: ${msg}`);
+    warn('Fix: run `oac doctor` to diagnose, or `oac init` to reset.');
+    process.exit(0);
+    return; // unreachable — satisfies TypeScript
+  }
+
+  if (manifest === null) {
+    log('');
+    info('No components installed. Run `oac init` to get started.');
+    log('');
+    process.exit(0);
+    return;
+  }
+
+  if (Object.keys(manifest.files).length === 0) {
+    log('');
+    info('No components installed.');
+    log('');
+    process.exit(0);
+    return;
+  }
+
+  // Always check hashes so modified-file warnings appear; verbose adds hash/date detail
+  const rows = await buildRows(manifest, projectRoot, true);
+  const groups = groupByType(rows);
+  const activeTypes = resolveActiveTypes(options);
+
+  printList(groups, activeTypes, options.verbose);
+
+  process.exit(0);
+}
+
+// ── Commander registration ────────────────────────────────────────────────────
+
+/**
+ * Registers the `list` subcommand on the given Commander program.
+ * Called by the CLI entry point (index.ts).
+ */
+export function registerListCommand(program: Command): void {
+  program
+    .command('list')
+    .description('Show all installed OAC components')
+    .option('--type <type>', 'Filter by type: agent | context | skill | config | other')
+    .option('--agents',  'Show agents only',  false)
+    .option('--context', 'Show context files only', false)
+    .option('--skills',  'Show skills only',  false)
+    .option('--verbose', 'Show SHA256 hash and install date for each file', false)
+    .action(async (opts: {
+      type?: string;
+      agents: boolean;
+      context: boolean;
+      skills: boolean;
+      verbose: boolean;
+    }) => {
+      await listCommand({
+        type:    opts.type,
+        agents:  opts.agents,
+        context: opts.context,
+        skills:  opts.skills,
+        verbose: opts.verbose,
+      });
+    });
+}

+ 230 - 0
packages/cli/src/commands/status.ts

@@ -0,0 +1,230 @@
+import { type Command } from 'commander';
+import { join } from 'node:path';
+
+import { readCliVersion } from '../lib/version.js';
+import { readManifest } from '../lib/manifest.js';
+import { computeFileHash, hashesMatch } from '../lib/sha256.js';
+import { detectIdes } from '../lib/ide-detect.js';
+import { log, info, warn, bold, dim, success } from '../ui/logger.js';
+import type { ManifestFile, ManifestFileType } from '../lib/manifest.js';
+import type { DetectedIde } from '../lib/ide-detect.js';
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export type StatusOptions = {
+  verbose: boolean;
+};
+
+type ComponentCounts = {
+  agents: number;
+  context: number;
+  skills: number;
+  other: number;
+  total: number;
+};
+
+type ModifiedResult = {
+  count: number;
+  paths: string[];
+};
+
+// ── Pure counters ─────────────────────────────────────────────────────────────
+
+/** Counts manifest entries by their file type. Pure function. */
+const countComponents = (manifest: ManifestFile): ComponentCounts => {
+  const entries = Object.values(manifest.files);
+  const byType = (t: ManifestFileType): number =>
+    entries.filter((e) => e.type === t).length;
+
+  const agents = byType('agent');
+  const context = byType('context');
+  const skills = byType('skill');
+  const other = entries.length - agents - context - skills;
+
+  return { agents, context, skills, other, total: entries.length };
+};
+
+// ── SHA256 diff check ─────────────────────────────────────────────────────────
+
+/**
+ * Compares each manifest file's stored hash against the file on disk.
+ * Returns the count and paths of files that have been locally modified.
+ * Wraps computeFileHash in try/catch — deleted files are treated as modified.
+ */
+const findModifiedFiles = async (
+  projectRoot: string,
+  manifest: ManifestFile,
+): Promise<ModifiedResult> => {
+  const entries = Object.entries(manifest.files);
+
+  const checks = await Promise.all(
+    entries.map(async ([relPath, entry]) => {
+      const absPath = join(projectRoot, relPath);
+      try {
+        const diskHash = await computeFileHash(absPath);
+        return !hashesMatch(diskHash, entry.sha256) ? relPath : null;
+      } catch {
+        // File deleted or unreadable — counts as modified
+        return relPath;
+      }
+    }),
+  );
+
+  const paths = checks.filter((p): p is string => p !== null);
+  return { count: paths.length, paths };
+};
+
+// ── IDE formatter ─────────────────────────────────────────────────────────────
+
+/** Formats the list of detected IDEs into a display string. Pure function. */
+const formatIdeList = (ides: DetectedIde[]): string => {
+  const detected = ides.filter((i) => i.detected);
+  const notDetected = ides.filter((i) => !i.detected);
+
+  if (detected.length === 0) {
+    return 'None detected — run `oac apply <ide>` to set up';
+  }
+
+  const detectedNames = detected.map((i) => i.type).join(', ');
+  if (notDetected.length === 0) return detectedNames;
+
+  const notDetectedNames = notDetected.map((i) => i.type).join(', ');
+  return `${detectedNames} (not detected: ${notDetectedNames})`;
+};
+
+// ── Update check ──────────────────────────────────────────────────────────────
+
+/** Returns a human-readable update status line. Pure function. */
+const formatUpdateStatus = (manifestVersion: string, cliVersion: string): string =>
+  manifestVersion === cliVersion
+    ? `Up to date (v${cliVersion})`
+    : `Available — manifest has v${manifestVersion}, CLI is v${cliVersion} (run 'oac update')`;
+
+// ── Timestamp formatter ───────────────────────────────────────────────────────
+
+/** Formats an ISO timestamp into a readable local date string. Pure function. */
+const formatTimestamp = (iso: string): string => {
+  try {
+    return new Date(iso).toLocaleString();
+  } catch {
+    return iso;
+  }
+};
+
+// ── Display ───────────────────────────────────────────────────────────────────
+
+/** Prints the one-screen status summary. Side-effect only. */
+const printStatus = (
+  cliVersion: string,
+  projectRoot: string,
+  manifest: ManifestFile,
+  counts: ComponentCounts,
+  modified: ModifiedResult,
+  ides: DetectedIde[],
+): void => {
+  const homeDir = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '';
+  const displayPath = projectRoot.startsWith(homeDir)
+    ? `~${projectRoot.slice(homeDir.length)}`
+    : projectRoot;
+
+  log('');
+  bold(`OAC v${cliVersion} — ${displayPath}`);
+  log('');
+
+  info(`Agents:   ${counts.agents} installed`);
+  info(`Context:  ${counts.context} files`);
+  info(`Skills:   ${counts.skills} installed`);
+
+  if (modified.count > 0) {
+    warn(`Modified: ${modified.count} file${modified.count !== 1 ? 's' : ''} have local changes`);
+  } else {
+    success(`Modified: No local changes`);
+  }
+
+  info(`Updates:  ${formatUpdateStatus(manifest.oacVersion, cliVersion)}`);
+  info(`IDEs:     ${formatIdeList(ides)}`);
+  info(`Last updated: ${formatTimestamp(manifest.updatedAt)}`);
+
+  log('');
+  dim(`  Run 'oac doctor' for full health check`);
+  log('');
+};
+
+/** Prints verbose details about modified files. Side-effect only. */
+const printVerboseModified = (modified: ModifiedResult): void => {
+  if (modified.count === 0) return;
+  dim('  Modified files:');
+  for (const p of modified.paths) {
+    dim(`    • ${p}`);
+  }
+  log('');
+};
+
+// ── Command handler ───────────────────────────────────────────────────────────
+
+/**
+ * Implements `oac status`:
+ *  1. Reads manifest — exits early with helpful message if not initialized
+ *  2. Counts components by type
+ *  3. Checks for user-modified files via SHA256 comparison
+ *  4. Detects IDEs
+ *  5. Prints one-screen summary
+ *  Always exits 0 (read-only command).
+ */
+export async function statusCommand(options: StatusOptions): Promise<void> {
+  const projectRoot = process.cwd();
+  const cliVersion = readCliVersion();
+
+  // Step 1: read manifest — not initialized is a valid state, not an error
+  let manifest: ManifestFile | null;
+  try {
+    manifest = await readManifest(projectRoot);
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    log(`  OAC manifest is invalid: ${msg}`);
+    log(`  Run 'oac init' to reset, or fix .oac/manifest.json manually.`);
+    process.exit(0);
+    return; // unreachable — satisfies TypeScript
+  }
+
+  if (manifest === null) {
+    log('');
+    log('  OAC not initialized. Run \'oac init\' to get started.');
+    log('');
+    process.exit(0);
+  }
+
+  // Step 2: count components
+  const counts = countComponents(manifest);
+
+  // Step 3: check for modified files
+  const modified = await findModifiedFiles(projectRoot, manifest);
+
+  // Step 4: detect IDEs
+  const ides = await detectIdes(projectRoot);
+
+  // Step 5: print summary
+  printStatus(cliVersion, projectRoot, manifest, counts, modified, ides);
+
+  if (options.verbose) {
+    printVerboseModified(modified);
+  }
+
+  process.exit(0);
+}
+
+// ── Commander registration ────────────────────────────────────────────────────
+
+/**
+ * Registers the `oac status` command on the given Commander program.
+ * Called by the CLI entry point (index.ts).
+ */
+export function registerStatusCommand(program: Command): void {
+  program
+    .command('status')
+    .description('Show a one-screen summary of your OAC installation')
+    .option('--verbose', 'Show details about modified files', false)
+    .action(async (opts: { verbose?: boolean }) => {
+      await statusCommand({ verbose: opts.verbose ?? false });
+    });
+}

+ 197 - 0
packages/cli/src/commands/update.ts

@@ -0,0 +1,197 @@
+import { type Command } from 'commander';
+import { updateFiles } from '../lib/installer.js';
+import { getPackageRoot } from '../lib/bundled.js';
+import { readManifest, writeManifest } from '../lib/manifest.js';
+import { readConfig } from '../lib/config.js';
+import { log, info, warn, error, success, dim, bold, verbose, setVerbose } from '../ui/logger.js';
+import { createSpinner, setDryRun } from '../ui/spinner.js';
+import type { InstallResult } from '../lib/installer.js';
+import type { ManifestFile } from '../lib/manifest.js';
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export type UpdateOptions = {
+  yolo: boolean;
+  dryRun: boolean;
+  verbose: boolean;
+  /** Alias for dryRun — shows what would change without changing anything. */
+  check: boolean;
+};
+
+// ── Pre-flight checks ─────────────────────────────────────────────────────────
+
+/**
+ * Validates that a manifest exists before running the update.
+ * Returns the project root (cwd) or exits with code 1.
+ */
+async function assertManifestExists(projectRoot: string): Promise<void> {
+  const manifest = await readManifest(projectRoot);
+  if (manifest === null) {
+    error('No manifest found. Run \'oac init\' first.');
+    process.exit(1);
+  }
+}
+
+// ── Plan announcement ─────────────────────────────────────────────────────────
+
+/** Prints what the command is about to do BEFORE making any changes. */
+function printPlan(opts: UpdateOptions): void {
+  const mode = opts.dryRun ? ' (dry-run — no changes will be made)' : '';
+  bold(`\noac update${mode}`);
+  info('Checking installed files against the latest OAC bundle...');
+  if (opts.yolo) {
+    warn('--yolo mode: user-modified files will be backed up and overwritten.');
+  }
+  if (opts.verbose) {
+    dim('  Verbose mode: SHA256 comparison details will be shown per file.');
+  }
+  log('');
+}
+
+// ── Result summary ────────────────────────────────────────────────────────────
+
+/** Prints the per-category file lists from the result. */
+function printFileList(label: string, files: string[], printer: (msg: string) => void): void {
+  if (files.length === 0) return;
+  printer(`${label}:`);
+  for (const f of files) {
+    dim(`    ${f}`);
+  }
+}
+
+/** Prints the full result summary AFTER the update completes. */
+function printSummary(result: InstallResult, isDryRun: boolean): void {
+  const prefix = isDryRun ? '[dry-run] Would have: ' : '';
+  log('');
+  bold('Summary:');
+
+  printFileList(`  ${prefix}Updated`, result.updated, success);
+  printFileList(`  ${prefix}New files installed`, result.installed, success);
+  printFileList(`  ${prefix}Backed up (--yolo)`, result.backed_up, info);
+  printFileList(`  Skipped (user-modified)`, result.skipped, warn);
+  printFileList(`  Removed from manifest (no longer in bundle)`, result.removed_from_manifest, warn);
+  printFileList(`  Errors`, result.errors, error);
+
+  log('');
+  const updatedCount = result.updated.length + result.installed.length;
+  const skippedCount = result.skipped.length;
+  const backedUpCount = result.backed_up.length;
+
+  const parts: string[] = [];
+  if (updatedCount > 0) parts.push(`${updatedCount} file(s) updated`);
+  if (skippedCount > 0) parts.push(`${skippedCount} skipped (user-modified)`);
+  if (backedUpCount > 0) parts.push(`${backedUpCount} backed up`);
+  if (result.errors.length > 0) parts.push(`${result.errors.length} error(s)`);
+
+  if (parts.length === 0) {
+    info('Everything is already up to date.');
+    return;
+  }
+  log(parts.join('. ') + '.');
+}
+
+// ── Core update logic ─────────────────────────────────────────────────────────
+
+/** Resolves effective options: --check is an alias for --dry-run. */
+const resolveOptions = (opts: UpdateOptions): UpdateOptions => ({
+  ...opts,
+  dryRun: opts.dryRun || opts.check,
+});
+
+/** Runs the update and writes the manifest (unless dry-run). */
+async function runUpdate(projectRoot: string, opts: UpdateOptions): Promise<InstallResult> {
+  const packageRoot = getPackageRoot();
+
+  const spinner = createSpinner('Scanning files...', { dryRun: opts.dryRun });
+  spinner.start();
+
+  let result: InstallResult;
+  let updatedManifest: ManifestFile;
+  try {
+    ({ result, updatedManifest } = await updateFiles({
+      projectRoot,
+      packageRoot,
+      dryRun: opts.dryRun,
+      yolo: opts.yolo,
+      verbose: opts.verbose,
+    }));
+  } catch (err: unknown) {
+    spinner.fail('Update failed.');
+    const msg = err instanceof Error ? err.message : String(err);
+    error(`Update failed: ${msg}`);
+    error('Check that @nextsystems/oac is installed correctly and try again.');
+    process.exit(1);
+    return {} as InstallResult; // unreachable — satisfies TypeScript
+  }
+
+  spinner.succeed('Scan complete.');
+
+  // Write updated manifest only when not in dry-run mode
+  if (!opts.dryRun && result.errors.length === 0) {
+    await writeManifest(projectRoot, updatedManifest);
+    verbose('Manifest written.');
+  } else if (!opts.dryRun && result.errors.length > 0) {
+    warn('Manifest not written due to errors above. Fix the issues and re-run.');
+  }
+
+  return result;
+}
+
+// ── Command handler ───────────────────────────────────────────────────────────
+
+/** Main handler for `oac update`. Orchestrates pre-flight, update, and summary. */
+async function handleUpdate(opts: UpdateOptions): Promise<void> {
+  const effective = resolveOptions(opts);
+  const projectRoot = process.cwd();
+
+  // Configure global flags
+  setVerbose(effective.verbose);
+  setDryRun(effective.dryRun);
+
+  // Read config to pick up persisted yolo/autoBackup preferences
+  const config = await readConfig(projectRoot);
+  const yolo = effective.yolo || (config?.preferences.yoloMode ?? false);
+
+  const finalOpts: UpdateOptions = { ...effective, yolo };
+
+  // Pre-flight: manifest must exist
+  await assertManifestExists(projectRoot);
+
+  // Announce plan BEFORE doing anything
+  printPlan(finalOpts);
+
+  // Run the update
+  const result = await runUpdate(projectRoot, finalOpts);
+
+  // Print summary AFTER
+  printSummary(result, finalOpts.dryRun);
+
+  // Exit non-zero only on hard errors (skipped files are not errors)
+  if (result.errors.length > 0) {
+    process.exit(1);
+  }
+}
+
+// ── Commander registration ────────────────────────────────────────────────────
+
+/**
+ * Registers the `oac update` command on the given Commander program.
+ * Supports --dry-run, --check (alias), --yolo, --verbose.
+ */
+export function registerUpdateCommand(program: Command): void {
+  program
+    .command('update')
+    .description('Update installed OAC files, skipping any you have modified')
+    .option('--dry-run', 'Show what would be updated without making changes')
+    .option('--check', 'Alias for --dry-run: show what would change')
+    .option('--yolo', 'Back up user-modified files and overwrite them anyway')
+    .option('--verbose', 'Show SHA256 comparison details per file')
+    .action(async (cmdOpts: { dryRun?: boolean; check?: boolean; yolo?: boolean; verbose?: boolean }) => {
+      await handleUpdate({
+        dryRun: cmdOpts.dryRun ?? false,
+        check: cmdOpts.check ?? false,
+        yolo: cmdOpts.yolo ?? false,
+        verbose: cmdOpts.verbose ?? false,
+      });
+    });
+}

+ 72 - 0
packages/cli/src/index.ts

@@ -0,0 +1,72 @@
+#!/usr/bin/env node
+
+import { Command } from 'commander'
+import { readCliVersion } from './lib/version.js'
+
+const program = new Command()
+
+program
+  .name('oac')
+  .description('OpenAgents Control — install, manage, and update AI agents and context files')
+  .version(readCliVersion(), '-v, --version', 'Print version and exit')
+  .option('--yolo', 'Skip all confirmations (auto-enabled when CI=true)', false)
+  .option('--dry-run', 'Show what would happen without doing it', false)
+  .option('--verbose', 'Show detailed output', false)
+
+// Lazy-load command modules in parallel — keeps startup < 100ms
+async function main(): Promise<void> {
+  // Fast path: --version only — --help needs all commands registered first
+  const args = process.argv.slice(2)
+  const isFastPath =
+    args.includes('--version') || args.includes('-v')
+
+  if (isFastPath) {
+    await program.parseAsync(process.argv)
+    return
+  }
+
+  const [
+    { registerInitCommand },
+    { registerUpdateCommand },
+    { registerAddCommand },
+    { registerApplyCommand },
+    { registerDoctorCommand },
+    { registerListCommand },
+    { registerStatusCommand },
+  ] = await Promise.all([
+    import('./commands/init.js'),
+    import('./commands/update.js'),
+    import('./commands/add.js'),
+    import('./commands/apply.js'),
+    import('./commands/doctor.js'),
+    import('./commands/list.js'),
+    import('./commands/status.js'),
+  ])
+
+  registerInitCommand(program)
+  registerUpdateCommand(program)
+  registerAddCommand(program) // also registers `remove`
+  registerApplyCommand(program)
+  registerDoctorCommand(program)
+  registerListCommand(program)
+  registerStatusCommand(program)
+
+  // Unknown commands: print a helpful error and exit 1
+  program.on('command:*', (operands: string[]) => {
+    console.error(`error: unknown command '${operands[0]}'\n`)
+    console.error(`Run 'oac --help' to see available commands.`)
+    process.exitCode = 1
+  })
+
+  await program.parseAsync(process.argv)
+
+  // Print help when no command is given
+  if (args.length === 0) {
+    program.help()
+  }
+}
+
+main().catch((err: unknown) => {
+  console.error('Fatal error:', err instanceof Error ? err.message : String(err))
+  process.exitCode = 1
+})

+ 145 - 0
packages/cli/src/lib/bundled.ts

@@ -0,0 +1,145 @@
+import { existsSync } from "node:fs";
+import { readdir, stat } from "node:fs/promises";
+import { join, relative } from "node:path";
+
+// --- Types ---
+
+/** The category of a bundled file, inferred from its path prefix. */
+export type BundledFileType = "agent" | "context" | "skill" | "config";
+
+// --- Constants ---
+
+/** Subdirectories under the package root that contain bundled OAC files. */
+const BUNDLED_SUBDIRS = [
+  ".opencode/agent",
+  ".opencode/context",
+  ".opencode/skills",
+] as const;
+
+// --- Package root resolution ---
+
+/**
+ * Walks up the directory tree from `startDir` until it finds a directory
+ * that contains both `.opencode/` and `package.json` — the npm package root.
+ *
+ * Works in both development (monorepo) and when installed via npm.
+ * import.meta.dir is Bun's native equivalent of __dirname.
+ */
+export function getPackageRoot(): string {
+  // import.meta.dir is Bun's native equivalent of __dirname — points to packages/cli/dist/ at runtime
+  return findPackageRoot(import.meta.dir);
+}
+
+/**
+ * Synchronously walks up from `dir` until finding a directory that has
+ * both `.opencode/` and `package.json`. Throws if the filesystem root is
+ * reached without finding a match.
+ *
+ * Pure in intent — no side effects beyond filesystem reads.
+ */
+export function findPackageRoot(dir: string): string {
+  let current = dir;
+
+  while (true) {
+    const hasOpencode = existsSync(join(current, ".opencode"));
+    const hasPackageJson = existsSync(join(current, "package.json"));
+
+    if (hasOpencode && hasPackageJson) {
+      return current;
+    }
+
+    const parent = join(current, "..");
+    // Reached filesystem root — no package root found
+    if (parent === current) {
+      throw new Error(
+        `getPackageRoot: could not find a directory with ".opencode/" and "package.json" ` +
+          `walking up from "${dir}". Is @nextsystems/oac installed correctly?`,
+      );
+    }
+    current = parent;
+  }
+}
+
+// --- Path helpers ---
+
+/**
+ * Returns the absolute path to a bundled file given the package root and a
+ * relative path (e.g. `.opencode/agent/core/openagent.md`).
+ *
+ * Pure function — no I/O.
+ */
+export const getBundledFilePath = (
+  packageRoot: string,
+  relativePath: string,
+): string => join(packageRoot, relativePath);
+
+// --- File enumeration ---
+
+/**
+ * Recursively collects all file paths under `dir`, returning them as
+ * absolute paths. Directories are not included in the result.
+ */
+async function collectFiles(dir: string): Promise<string[]> {
+  const entries = await readdir(dir, { withFileTypes: true });
+
+  const nested = await Promise.all(
+    entries.map((entry) => {
+      const fullPath = join(dir, entry.name);
+      return entry.isDirectory() ? collectFiles(fullPath) : Promise.resolve([fullPath]);
+    }),
+  );
+
+  return nested.flat();
+}
+
+/**
+ * Lists all files under `.opencode/agent/`, `.opencode/context/`, and
+ * `.opencode/skills/` within the given package root.
+ *
+ * Returns relative paths like `.opencode/agent/core/openagent.md`.
+ * Subdirectories that do not exist are silently skipped.
+ */
+export async function listBundledFiles(packageRoot: string): Promise<string[]> {
+  const results = await Promise.all(
+    BUNDLED_SUBDIRS.map(async (subdir) => {
+      const absSubdir = join(packageRoot, subdir);
+      const exists = await stat(absSubdir).then((s) => s.isDirectory()).catch(() => false);
+      if (!exists) return [];
+
+      const absFiles = await collectFiles(absSubdir);
+      return absFiles.map((absFile) => relative(packageRoot, absFile));
+    }),
+  );
+
+  return results.flat();
+}
+
+// --- Existence check ---
+
+/**
+ * Returns true if the bundled file at `relativePath` exists within the
+ * given package root.
+ */
+export const bundledFileExists = async (
+  packageRoot: string,
+  relativePath: string,
+): Promise<boolean> => Bun.file(getBundledFilePath(packageRoot, relativePath)).exists();
+
+// --- Classification ---
+
+/**
+ * Infers the BundledFileType from a relative path prefix.
+ *
+ * - `.opencode/agent/...`   → "agent"
+ * - `.opencode/context/...` → "context"
+ * - `.opencode/skills/...`  → "skill"
+ * - anything else           → "config"
+ *
+ * Pure function — no I/O.
+ */
+export const classifyBundledFile = (relativePath: string): BundledFileType => {
+  if (relativePath.startsWith(".opencode/agent/")) return "agent";
+  if (relativePath.startsWith(".opencode/context/")) return "context";
+  if (relativePath.startsWith(".opencode/skills/")) return "skill";
+  return "config";
+};

+ 50 - 0
packages/cli/src/lib/config.ts

@@ -0,0 +1,50 @@
+import { z } from "zod";
+import { mkdir } from "node:fs/promises";
+import { join, dirname } from "node:path";
+
+export const OacPreferencesSchema = z.object({
+  yoloMode: z.boolean(),
+  autoBackup: z.boolean(),
+});
+export const OacConfigSchema = z.object({
+  version: z.literal("1"),
+  preferences: OacPreferencesSchema,
+});
+
+export type OacPreferences = z.infer<typeof OacPreferencesSchema>;
+export type OacConfig = z.infer<typeof OacConfigSchema>;
+
+export const getConfigPath = (projectRoot: string): string =>
+  join(projectRoot, ".oac", "config.json");
+
+export const createDefaultConfig = (): OacConfig => ({
+  version: "1",
+  preferences: { yoloMode: false, autoBackup: true },
+});
+
+// Pure — returns new object, no mutation
+export const mergeConfig = (base: OacConfig, overrides: Partial<OacPreferences>): OacConfig =>
+  ({ ...base, preferences: { ...base.preferences, ...overrides } });
+
+export const isYoloMode = (config: OacConfig): boolean =>
+  config.preferences.yoloMode || process.env["CI"] === "true";
+
+export const isAutoBackup = (config: OacConfig): boolean =>
+  config.preferences.autoBackup;
+
+export async function readConfig(projectRoot: string): Promise<OacConfig | null> {
+  const configPath = getConfigPath(projectRoot);
+  if (!(await Bun.file(configPath).exists())) return null;
+  const raw = await Bun.file(configPath).json() as unknown;
+  const result = OacConfigSchema.safeParse(raw);
+  if (!result.success) {
+    throw new Error(`Invalid config at "${configPath}": ${result.error.message}`);
+  }
+  return result.data;
+}
+
+export async function writeConfig(projectRoot: string, config: OacConfig): Promise<void> {
+  const configPath = getConfigPath(projectRoot);
+  await mkdir(dirname(configPath), { recursive: true });
+  await Bun.write(configPath, JSON.stringify(config, null, 2));
+}

+ 99 - 0
packages/cli/src/lib/ide-detect.ts

@@ -0,0 +1,99 @@
+import path from "node:path";
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+export type IdeType = "opencode" | "cursor" | "claude" | "windsurf";
+
+export type DetectedIde = {
+  type: IdeType;
+  detected: boolean;
+  /** Human-readable description of what was found (or checked). */
+  indicator: string;
+};
+
+// ─── IDE Definitions ──────────────────────────────────────────────────────────
+
+/** Maps each IDE to its display name and output file for `oac apply`. */
+const IDE_DISPLAY_NAMES: Record<IdeType, string> = {
+  opencode: "OpenCode",
+  cursor: "Cursor",
+  claude: "Claude",
+  windsurf: "Windsurf",
+};
+
+/** Output file/directory written by `oac apply` for each IDE. */
+const IDE_OUTPUT_FILES: Record<IdeType, string> = {
+  opencode: ".opencode/",
+  cursor: ".cursorrules",
+  claude: "CLAUDE.md",
+  windsurf: ".windsurfrules",
+};
+
+// ─── Detection Logic ──────────────────────────────────────────────────────────
+
+/** Returns the indicator string and detected status for a single IDE. */
+async function checkIde(
+  projectRoot: string,
+  ide: IdeType
+): Promise<{ detected: boolean; indicator: string }> {
+  if (ide === "opencode") {
+    const p = path.join(projectRoot, ".opencode");
+    return (await Bun.file(p).exists())
+      ? { detected: true, indicator: ".opencode/ directory" }
+      : { detected: false, indicator: ".opencode/ directory (not found)" };
+  }
+  if (ide === "cursor") {
+    const p = path.join(projectRoot, ".cursor");
+    return (await Bun.file(p).exists())
+      ? { detected: true, indicator: ".cursor/ directory" }
+      : { detected: false, indicator: ".cursor/ directory (not found)" };
+  }
+  if (ide === "claude") {
+    const dir = path.join(projectRoot, ".claude");
+    const file = path.join(projectRoot, "CLAUDE.md");
+    if (await Bun.file(dir).exists()) return { detected: true, indicator: ".claude/ directory" };
+    if (await Bun.file(file).exists()) return { detected: true, indicator: "CLAUDE.md file" };
+    return { detected: false, indicator: ".claude/ directory or CLAUDE.md (not found)" };
+  }
+  // windsurf
+  const p = path.join(projectRoot, ".windsurf");
+  return (await Bun.file(p).exists())
+    ? { detected: true, indicator: ".windsurf/ directory" }
+    : { detected: false, indicator: ".windsurf/ directory (not found)" };
+}
+
+// ─── Public API ───────────────────────────────────────────────────────────────
+
+/** Detects a single IDE in the given project root. */
+export async function detectIde(
+  projectRoot: string,
+  ide: IdeType
+): Promise<DetectedIde> {
+  const { detected, indicator } = await checkIde(projectRoot, ide);
+  return { type: ide, detected, indicator };
+}
+
+/** Detects all supported IDEs in the given project root. */
+export async function detectIdes(projectRoot: string): Promise<DetectedIde[]> {
+  const ides: IdeType[] = ["opencode", "cursor", "claude", "windsurf"];
+  return Promise.all(ides.map((ide) => detectIde(projectRoot, ide)));
+}
+
+/** Returns true if the given IDE is present in the project root. */
+export async function isIdePresent(
+  projectRoot: string,
+  ide: IdeType
+): Promise<boolean> {
+  const result = await detectIde(projectRoot, ide);
+  return result.detected;
+}
+
+/** Returns the output file path (relative) written by `oac apply` for an IDE. */
+export function getIdeOutputFile(ide: IdeType): string {
+  return IDE_OUTPUT_FILES[ide];
+}
+
+/** Returns the human-readable display name for an IDE. */
+export function getIdeDisplayName(ide: IdeType): string {
+  return IDE_DISPLAY_NAMES[ide];
+}

+ 198 - 0
packages/cli/src/lib/installer.test.ts

@@ -0,0 +1,198 @@
+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
+import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { installFile, backupFile, installFiles } from './installer.js';
+import { computeFileHash } from './sha256.js';
+import type { InstallOptions } from './installer.js';
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+const makeOptions = (
+  projectRoot: string,
+  packageRoot: string,
+  overrides: Partial<InstallOptions> = {},
+): InstallOptions => ({
+  projectRoot,
+  packageRoot,
+  dryRun: false,
+  yolo: false,
+  verbose: false,
+  ...overrides,
+});
+
+// ── installFile ───────────────────────────────────────────────────────────────
+
+describe('installFile', () => {
+  let tmpDir: string;
+
+  beforeAll(async () => {
+    tmpDir = await mkdtemp(join(tmpdir(), 'oac-installer-test-'));
+  });
+
+  afterAll(async () => {
+    await rm(tmpDir, { recursive: true, force: true });
+  });
+
+  test('copies source file to dest, creating parent dirs', async () => {
+    const srcPath = join(tmpDir, 'source.txt');
+    const destPath = join(tmpDir, 'nested', 'deep', 'dest.txt');
+    await writeFile(srcPath, 'hello installer', 'utf8');
+
+    const opts = makeOptions(tmpDir, tmpDir);
+    await installFile(srcPath, destPath, opts);
+
+    const destFile = Bun.file(destPath);
+    expect(await destFile.exists()).toBe(true);
+    expect(await destFile.text()).toBe('hello installer');
+  });
+
+  test('in dry-run mode, does NOT create the dest file', async () => {
+    const srcPath = join(tmpDir, 'dry-source.txt');
+    const destPath = join(tmpDir, 'dry-dest', 'file.txt');
+    await writeFile(srcPath, 'dry run content', 'utf8');
+
+    const opts = makeOptions(tmpDir, tmpDir, { dryRun: true });
+    await installFile(srcPath, destPath, opts);
+
+    expect(await Bun.file(destPath).exists()).toBe(false);
+  });
+
+  test('overwrites an existing dest file', async () => {
+    const srcPath = join(tmpDir, 'overwrite-src.txt');
+    const destPath = join(tmpDir, 'overwrite-dest.txt');
+    await writeFile(srcPath, 'new content', 'utf8');
+    await writeFile(destPath, 'old content', 'utf8');
+
+    const opts = makeOptions(tmpDir, tmpDir);
+    await installFile(srcPath, destPath, opts);
+
+    expect(await Bun.file(destPath).text()).toBe('new content');
+  });
+});
+
+// ── backupFile ────────────────────────────────────────────────────────────────
+
+describe('backupFile', () => {
+  let tmpDir: string;
+
+  beforeAll(async () => {
+    tmpDir = await mkdtemp(join(tmpdir(), 'oac-backup-test-'));
+  });
+
+  afterAll(async () => {
+    await rm(tmpDir, { recursive: true, force: true });
+  });
+
+  test('creates a backup copy and returns its path', async () => {
+    const filePath = join(tmpDir, 'original.txt');
+    await writeFile(filePath, 'backup me', 'utf8');
+
+    const backupPath = await backupFile(filePath, tmpDir);
+
+    expect(await Bun.file(backupPath).exists()).toBe(true);
+    expect(await Bun.file(backupPath).text()).toBe('backup me');
+  });
+
+  test('backup path is inside .oac/backups/', async () => {
+    const filePath = join(tmpDir, 'another.txt');
+    await writeFile(filePath, 'content', 'utf8');
+
+    const backupPath = await backupFile(filePath, tmpDir);
+
+    expect(backupPath).toContain('.oac/backups/');
+  });
+
+  test('original file is unchanged after backup', async () => {
+    const filePath = join(tmpDir, 'unchanged.txt');
+    await writeFile(filePath, 'original content', 'utf8');
+
+    await backupFile(filePath, tmpDir);
+
+    expect(await Bun.file(filePath).text()).toBe('original content');
+  });
+});
+
+// ── installFiles (dry-run) ────────────────────────────────────────────────────
+
+describe('installFiles (dry-run)', () => {
+  let projectRoot: string;
+  let packageRoot: string;
+
+  beforeAll(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), 'oac-install-project-'));
+    packageRoot = await mkdtemp(join(tmpdir(), 'oac-install-package-'));
+
+    // Create a fake bundled files directory structure
+    await mkdir(join(packageRoot, '.opencode', 'agent'), { recursive: true });
+    await writeFile(
+      join(packageRoot, '.opencode', 'agent', 'test-agent.md'),
+      '# Test Agent',
+      'utf8',
+    );
+  });
+
+  afterAll(async () => {
+    await rm(projectRoot, { recursive: true, force: true });
+    await rm(packageRoot, { recursive: true, force: true });
+  });
+
+  test('dry-run: returns installed list without writing files', async () => {
+    const opts = makeOptions(projectRoot, packageRoot, { dryRun: true });
+    const files = ['.opencode/agent/test-agent.md'];
+
+    const { result } = await installFiles(files, opts);
+
+    // In dry-run, files are "installed" in the result but not on disk
+    expect(result.installed).toContain('.opencode/agent/test-agent.md');
+    expect(result.errors).toHaveLength(0);
+    expect(await Bun.file(join(projectRoot, '.opencode/agent/test-agent.md')).exists()).toBe(false);
+  });
+});
+
+// ── installFiles (real write) ─────────────────────────────────────────────────
+
+describe('installFiles (real write)', () => {
+  let projectRoot: string;
+  let packageRoot: string;
+
+  beforeAll(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), 'oac-install-real-project-'));
+    packageRoot = await mkdtemp(join(tmpdir(), 'oac-install-real-package-'));
+
+    await mkdir(join(packageRoot, '.opencode', 'context'), { recursive: true });
+    await writeFile(
+      join(packageRoot, '.opencode', 'context', 'standards.md'),
+      '# Standards',
+      'utf8',
+    );
+  });
+
+  afterAll(async () => {
+    await rm(projectRoot, { recursive: true, force: true });
+    await rm(packageRoot, { recursive: true, force: true });
+  });
+
+  test('installs files to project root and records sha256', async () => {
+    const opts = makeOptions(projectRoot, packageRoot);
+    const files = ['.opencode/context/standards.md'];
+
+    const { result, updatedManifest } = await installFiles(files, opts);
+
+    expect(result.installed).toContain('.opencode/context/standards.md');
+    expect(result.errors).toHaveLength(0);
+
+    const destPath = join(projectRoot, '.opencode/context/standards.md');
+    expect(await Bun.file(destPath).exists()).toBe(true);
+    expect(await Bun.file(destPath).text()).toBe('# Standards');
+
+    // Manifest entry should have a valid sha256
+    const entry = updatedManifest.files['.opencode/context/standards.md'];
+    expect(entry).toBeDefined();
+    expect(entry?.sha256).toHaveLength(64);
+
+    // sha256 in manifest should match actual file
+    const actualHash = await computeFileHash(destPath);
+    expect(entry?.sha256).toBe(actualHash);
+  });
+});

+ 390 - 0
packages/cli/src/lib/installer.ts

@@ -0,0 +1,390 @@
+import path from "node:path";
+import { mkdir } from "node:fs/promises";
+import { computeFileHash, hashesMatch } from "./sha256.js";
+import {
+  type ManifestFile,
+  type FileEntry,
+  addFileToManifest,
+  removeFileFromManifest,
+  readManifest,
+  createEmptyManifest,
+} from "./manifest.js";
+import { listBundledFiles, getBundledFilePath, classifyBundledFile } from "./bundled.js";
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export type InstallOptions = {
+  /** Absolute path to the user's project root (where .oac/ lives). */
+  projectRoot: string;
+  /** Absolute path to the OAC npm package root (where bundled files live). */
+  packageRoot: string;
+  /** When true: log what would happen but make no filesystem changes. */
+  dryRun: boolean;
+  /** When true: backup user-modified files and overwrite them. */
+  yolo: boolean;
+  /** When true: emit verbose log lines. */
+  verbose: boolean;
+};
+
+export type InstallResult = {
+  /** Relative paths of files newly installed (not previously in manifest). */
+  installed: string[];
+  /** Relative paths of files updated (were untouched by user). */
+  updated: string[];
+  /** Relative paths of files skipped because user modified them. */
+  skipped: string[];
+  /** Relative paths of files backed up before yolo overwrite. */
+  backed_up: string[];
+  /** Relative paths removed from manifest (no longer in bundle). */
+  removed_from_manifest: string[];
+  /** Human-readable error messages for any failures. */
+  errors: string[];
+};
+
+// ── Constants ─────────────────────────────────────────────────────────────────
+
+const EMPTY_RESULT: InstallResult = {
+  installed: [],
+  updated: [],
+  skipped: [],
+  backed_up: [],
+  removed_from_manifest: [],
+  errors: [],
+};
+
+// ── Pure helpers ──────────────────────────────────────────────────────────────
+
+/** Builds the ISO timestamp string used in backup directory names. */
+const buildTimestamp = (): string =>
+  new Date().toISOString().replace(/[:.]/g, "-");
+
+/** Returns the absolute backup path for a file. */
+const buildBackupPath = (
+  projectRoot: string,
+  timestamp: string,
+  relativePath: string,
+): string => path.join(projectRoot, ".oac", "backups", timestamp, relativePath);
+
+/** Merges a partial result into an existing result (immutable). */
+const mergeResult = (
+  base: InstallResult,
+  patch: Partial<InstallResult>,
+): InstallResult => ({
+  installed: [...base.installed, ...(patch.installed ?? [])],
+  updated: [...base.updated, ...(patch.updated ?? [])],
+  skipped: [...base.skipped, ...(patch.skipped ?? [])],
+  backed_up: [...base.backed_up, ...(patch.backed_up ?? [])],
+  removed_from_manifest: [
+    ...base.removed_from_manifest,
+    ...(patch.removed_from_manifest ?? []),
+  ],
+  errors: [...base.errors, ...(patch.errors ?? [])],
+});
+
+/** Logs a message when verbose mode is on. Pure side-effect wrapper. */
+const log = (options: InstallOptions, message: string): void => {
+  if (options.verbose || options.dryRun) {
+    process.stdout.write(`[oac] ${message}\n`);
+  }
+};
+
+// ── Single-file I/O operations ────────────────────────────────────────────────
+
+/**
+ * Copies a single file from `sourcePath` to `destPath`, creating parent
+ * directories as needed. In dry-run mode, logs the action and skips the copy.
+ */
+export async function installFile(
+  sourcePath: string,
+  destPath: string,
+  options: InstallOptions,
+): Promise<void> {
+  if (options.dryRun) {
+    log(options, `[dry-run] would copy: ${sourcePath} → ${destPath}`);
+    return;
+  }
+  await mkdir(path.dirname(destPath), { recursive: true });
+  await Bun.write(destPath, Bun.file(sourcePath));
+}
+
+/**
+ * Backs up `filePath` to `.oac/backups/{timestamp}/{original-relative-path}`.
+ * Returns the absolute backup path. Creates the backup directory if needed.
+ */
+export async function backupFile(
+  filePath: string,
+  projectRoot: string,
+): Promise<string> {
+  const timestamp = buildTimestamp();
+  const relativePath = path.relative(projectRoot, filePath);
+  const backupPath = buildBackupPath(projectRoot, timestamp, relativePath);
+  await mkdir(path.dirname(backupPath), { recursive: true });
+  await Bun.write(backupPath, Bun.file(filePath));
+  return backupPath;
+}
+
+// ── Decision logic (pure) ─────────────────────────────────────────────────────
+
+type FileDecision =
+  | { action: "install" }
+  | { action: "update" }
+  | { action: "skip"; reason: string }
+  | { action: "yolo-overwrite"; backupNeeded: true };
+
+/**
+ * Determines what action to take for a single bundled file.
+ * Pure — reads from disk but makes no writes.
+ */
+async function decideFileAction(
+  relativePath: string,
+  manifest: ManifestFile | null,
+  destPath: string,
+  options: InstallOptions,
+): Promise<FileDecision> {
+  const manifestEntry = manifest?.files[relativePath];
+
+  // File not in manifest → brand new, always install
+  if (manifestEntry === undefined) {
+    return { action: "install" };
+  }
+
+  // File is in manifest — check if user modified it
+  const diskExists = await Bun.file(destPath).exists();
+  if (!diskExists) {
+    // File was deleted by user — treat as new install
+    return { action: "install" };
+  }
+
+  const currentHash = await computeFileHash(destPath);
+  const isUntouched = hashesMatch(currentHash, manifestEntry.sha256);
+
+  if (isUntouched) {
+    return { action: "update" };
+  }
+
+  // User modified the file
+  if (options.yolo) {
+    return { action: "yolo-overwrite", backupNeeded: true };
+  }
+
+  return {
+    action: "skip",
+    reason: `${relativePath} was modified by user — skipping (use --yolo to overwrite)`,
+  };
+}
+
+// ── Per-file processor ────────────────────────────────────────────────────────
+
+type ProcessFileArgs = {
+  relativePath: string;
+  sourcePath: string;
+  destPath: string;
+  manifest: ManifestFile | null;
+  options: InstallOptions;
+  timestamp: string;
+};
+
+/**
+ * Processes a single bundled file: decides the action, performs I/O,
+ * and returns a partial result + updated manifest entry.
+ */
+async function processOneFile(
+  args: ProcessFileArgs,
+): Promise<{ patch: Partial<InstallResult>; entry: FileEntry | null }> {
+  const { relativePath, sourcePath, destPath, manifest, options, timestamp } =
+    args;
+
+  // TODO: §4.1 — complex restructure needed (catch returns different shape than FileDecision)
+  let decision: FileDecision;
+  try {
+    decision = await decideFileAction(
+      relativePath,
+      manifest,
+      destPath,
+      options,
+    );
+  } catch (err) {
+    const msg = err instanceof Error ? err.message : String(err);
+    return {
+      patch: { errors: [`${relativePath}: decision failed — ${msg}`] },
+      entry: null,
+    };
+  }
+
+  const now = new Date().toISOString();
+  const fileType = classifyBundledFile(relativePath);
+
+  try {
+    if (decision.action === "install") {
+      log(options, `install: ${relativePath}`);
+      await installFile(sourcePath, destPath, options);
+      const sha256 = options.dryRun ? "" : await computeFileHash(destPath);
+      const entry: FileEntry = {
+        sha256,
+        type: fileType,
+        source: "bundled",
+        installedAt: now,
+      };
+      return { patch: { installed: [relativePath] }, entry };
+    }
+
+    if (decision.action === "update") {
+      log(options, `update: ${relativePath}`);
+      await installFile(sourcePath, destPath, options);
+      const sha256 = options.dryRun ? "" : await computeFileHash(destPath);
+      const existingEntry = manifest!.files[relativePath]!;
+      const entry: FileEntry = { ...existingEntry, sha256 };
+      return { patch: { updated: [relativePath] }, entry };
+    }
+
+    if (decision.action === "yolo-overwrite") {
+      log(options, `yolo: backing up and overwriting ${relativePath}`);
+      const backupPath = buildBackupPath(options.projectRoot, timestamp, relativePath);
+      if (!options.dryRun) {
+        await mkdir(path.dirname(backupPath), { recursive: true });
+        await Bun.write(backupPath, Bun.file(destPath));
+      } else {
+        log(options, `[dry-run] would backup: ${destPath} → ${backupPath}`);
+      }
+      await installFile(sourcePath, destPath, options);
+      const sha256 = options.dryRun ? "" : await computeFileHash(destPath);
+      const existingEntry = manifest!.files[relativePath]!;
+      const entry: FileEntry = { ...existingEntry, sha256 };
+      return {
+        patch: { backed_up: [backupPath], updated: [relativePath] },
+        entry,
+      };
+    }
+
+    // action === "skip" — TypeScript narrows decision.reason inside this block
+    if (decision.action === "skip") {
+      log(options, `skip: ${decision.reason}`);
+    }
+    return { patch: { skipped: [relativePath] }, entry: null };
+  } catch (err) {
+    const msg = err instanceof Error ? err.message : String(err);
+    return {
+      patch: { errors: [`${relativePath}: ${msg}`] },
+      entry: null,
+    };
+  }
+}
+
+// ── Public API ────────────────────────────────────────────────────────────────
+
+/**
+ * Installs a list of files from the bundle into the project root.
+ * Does NOT consult the manifest — treats every file as new.
+ * Used by `oac init` for a fresh install.
+ *
+ * Does NOT write the manifest — caller is responsible.
+ */
+export async function installFiles(
+  files: string[],
+  options: InstallOptions,
+): Promise<{ result: InstallResult; updatedManifest: ManifestFile }> {
+  const now = new Date().toISOString();
+  let manifest = createEmptyManifest("0.0.0"); // TODO: §4.1 — complex restructure needed (loop accumulator)
+  let result = { ...EMPTY_RESULT }; // TODO: §4.1 — complex restructure needed (loop accumulator)
+
+  for (const relativePath of files) {
+    const sourcePath = getBundledFilePath(options.packageRoot, relativePath);
+    const destPath = path.join(options.projectRoot, relativePath);
+
+    log(options, `install: ${relativePath}`);
+    try {
+      await installFile(sourcePath, destPath, options);
+      const sha256 = options.dryRun ? "" : await computeFileHash(destPath);
+      const entry: FileEntry = {
+        sha256,
+        type: classifyBundledFile(relativePath),
+        source: "bundled",
+        installedAt: now,
+      };
+      manifest = addFileToManifest(manifest, relativePath, entry);
+      result = mergeResult(result, { installed: [relativePath] });
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err);
+      result = mergeResult(result, {
+        errors: [`${relativePath}: ${msg}`],
+      });
+    }
+  }
+
+  return { result, updatedManifest: manifest };
+}
+
+/**
+ * Implements the full OAC update algorithm:
+ *
+ * FOR each file in new bundle:
+ *   - In manifest + hash matches disk → safe update
+ *   - In manifest + hash differs → skip (or --yolo: backup + overwrite)
+ *   - Not in manifest → install as new
+ *
+ * FOR each file in manifest NOT in new bundle:
+ *   - Leave user's copy, remove from manifest, warn
+ *
+ * Does NOT write the manifest — caller is responsible.
+ */
+export async function updateFiles(
+  options: InstallOptions,
+): Promise<{ result: InstallResult; updatedManifest: ManifestFile }> {
+  const manifest = await readManifest(options.projectRoot);
+  const bundledFiles = await listBundledFiles(options.packageRoot);
+  const timestamp = buildTimestamp();
+
+  let workingManifest: ManifestFile =
+    manifest ?? createEmptyManifest("0.0.0"); // TODO: §4.1 — complex restructure needed (loop accumulator)
+  let result = { ...EMPTY_RESULT }; // TODO: §4.1 — complex restructure needed (loop accumulator)
+
+  // Phase 1: process each file in the new bundle
+  for (const relativePath of bundledFiles) {
+    const sourcePath = getBundledFilePath(options.packageRoot, relativePath);
+    const destPath = path.join(options.projectRoot, relativePath);
+
+    const { patch, entry } = await processOneFile({
+      relativePath,
+      sourcePath,
+      destPath,
+      manifest,
+      options,
+      timestamp,
+    });
+
+    result = mergeResult(result, patch);
+
+    // Update the working manifest if we have a new/updated entry
+    if (entry !== null) {
+      workingManifest = addFileToManifest(workingManifest, relativePath, entry);
+    }
+  }
+
+  // Phase 2: handle files in manifest that are no longer in the bundle
+  const bundledSet = new Set(bundledFiles);
+  const manifestPaths = Object.keys(workingManifest.files);
+
+  for (const trackedPath of manifestPaths) {
+    if (!bundledSet.has(trackedPath)) {
+      process.stdout.write(
+        `[oac] warn: "${trackedPath}" is no longer maintained by OAC — your copy is untouched\n`,
+      );
+      workingManifest = removeFileFromManifest(workingManifest, trackedPath);
+      result = mergeResult(result, { removed_from_manifest: [trackedPath] });
+    }
+  }
+
+  return { result, updatedManifest: workingManifest };
+}
+
+/**
+ * Returns true if `dir` contains a `package.json` or `.git` entry.
+ * Used by `oac init` to validate we're operating in a project root.
+ */
+export async function isProjectRoot(dir: string): Promise<boolean> {
+  const [hasPackageJson, hasGit] = await Promise.all([
+    Bun.file(path.join(dir, "package.json")).exists(),
+    Bun.file(path.join(dir, ".git")).exists(),
+  ]);
+  return hasPackageJson || hasGit;
+}

+ 209 - 0
packages/cli/src/lib/manifest.test.ts

@@ -0,0 +1,209 @@
+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import {
+  createEmptyManifest,
+  addFileToManifest,
+  removeFileFromManifest,
+  updateFileHash,
+  readManifest,
+  writeManifest,
+  ManifestError,
+  type ManifestFile,
+  type FileEntry,
+} from './manifest.js';
+
+// ── Fixtures ──────────────────────────────────────────────────────────────────
+
+const makeEntry = (overrides: Partial<FileEntry> = {}): FileEntry => ({
+  sha256: 'abc123',
+  type: 'agent',
+  source: 'bundled',
+  installedAt: new Date().toISOString(),
+  ...overrides,
+});
+
+// ── createEmptyManifest ───────────────────────────────────────────────────────
+
+describe('createEmptyManifest', () => {
+  test('returns a manifest with version "1"', () => {
+    const m = createEmptyManifest('1.0.0');
+    expect(m.version).toBe('1');
+  });
+
+  test('stores the provided oacVersion', () => {
+    const m = createEmptyManifest('2.3.4');
+    expect(m.oacVersion).toBe('2.3.4');
+  });
+
+  test('starts with an empty files record', () => {
+    const m = createEmptyManifest('1.0.0');
+    expect(Object.keys(m.files)).toHaveLength(0);
+  });
+
+  test('installedAt and updatedAt are valid ISO strings', () => {
+    const m = createEmptyManifest('1.0.0');
+    expect(() => new Date(m.installedAt)).not.toThrow();
+    expect(() => new Date(m.updatedAt)).not.toThrow();
+  });
+});
+
+// ── addFileToManifest ─────────────────────────────────────────────────────────
+
+describe('addFileToManifest', () => {
+  test('adds a new file entry', () => {
+    const m = createEmptyManifest('1.0.0');
+    const entry = makeEntry();
+    const updated = addFileToManifest(m, 'agents/foo.md', entry);
+    expect(updated.files['agents/foo.md']).toEqual(entry);
+  });
+
+  test('does not mutate the original manifest', () => {
+    const m = createEmptyManifest('1.0.0');
+    addFileToManifest(m, 'agents/foo.md', makeEntry());
+    expect(Object.keys(m.files)).toHaveLength(0);
+  });
+
+  test('replaces an existing entry for the same path', () => {
+    const m = createEmptyManifest('1.0.0');
+    const first = makeEntry({ sha256: 'aaa' });
+    const second = makeEntry({ sha256: 'bbb' });
+    const m1 = addFileToManifest(m, 'agents/foo.md', first);
+    const m2 = addFileToManifest(m1, 'agents/foo.md', second);
+    expect(m2.files['agents/foo.md']?.sha256).toBe('bbb');
+    expect(Object.keys(m2.files)).toHaveLength(1);
+  });
+
+  test('updates updatedAt', () => {
+    const m = createEmptyManifest('1.0.0');
+    const before = m.updatedAt;
+    // Ensure at least 1ms passes
+    const updated = addFileToManifest(m, 'agents/foo.md', makeEntry());
+    expect(updated.updatedAt >= before).toBe(true);
+  });
+});
+
+// ── removeFileFromManifest ────────────────────────────────────────────────────
+
+describe('removeFileFromManifest', () => {
+  test('removes an existing file', () => {
+    const m = addFileToManifest(createEmptyManifest('1.0.0'), 'agents/foo.md', makeEntry());
+    const updated = removeFileFromManifest(m, 'agents/foo.md');
+    expect(updated.files['agents/foo.md']).toBeUndefined();
+  });
+
+  test('is a no-op for a path not in the manifest', () => {
+    const m = createEmptyManifest('1.0.0');
+    const updated = removeFileFromManifest(m, 'agents/nonexistent.md');
+    expect(Object.keys(updated.files)).toHaveLength(0);
+  });
+
+  test('does not mutate the original manifest', () => {
+    const m = addFileToManifest(createEmptyManifest('1.0.0'), 'agents/foo.md', makeEntry());
+    removeFileFromManifest(m, 'agents/foo.md');
+    expect(m.files['agents/foo.md']).toBeDefined();
+  });
+
+  test('leaves other entries intact', () => {
+    let m = createEmptyManifest('1.0.0');
+    m = addFileToManifest(m, 'agents/a.md', makeEntry());
+    m = addFileToManifest(m, 'agents/b.md', makeEntry());
+    const updated = removeFileFromManifest(m, 'agents/a.md');
+    expect(updated.files['agents/b.md']).toBeDefined();
+    expect(Object.keys(updated.files)).toHaveLength(1);
+  });
+});
+
+// ── updateFileHash ────────────────────────────────────────────────────────────
+
+describe('updateFileHash', () => {
+  test('updates the sha256 of a tracked file', () => {
+    const m = addFileToManifest(createEmptyManifest('1.0.0'), 'agents/foo.md', makeEntry({ sha256: 'old' }));
+    const updated = updateFileHash(m, 'agents/foo.md', 'new-hash');
+    expect(updated.files['agents/foo.md']?.sha256).toBe('new-hash');
+  });
+
+  test('throws ManifestError for an untracked file', () => {
+    const m = createEmptyManifest('1.0.0');
+    expect(() => updateFileHash(m, 'agents/missing.md', 'hash')).toThrow(ManifestError);
+  });
+
+  test('preserves other fields on the entry', () => {
+    const entry = makeEntry({ type: 'context', source: 'registry' });
+    const m = addFileToManifest(createEmptyManifest('1.0.0'), 'ctx/foo.md', entry);
+    const updated = updateFileHash(m, 'ctx/foo.md', 'new-hash');
+    expect(updated.files['ctx/foo.md']?.type).toBe('context');
+    expect(updated.files['ctx/foo.md']?.source).toBe('registry');
+  });
+});
+
+// ── ManifestError ─────────────────────────────────────────────────────────────
+
+describe('ManifestError', () => {
+  test('has name "ManifestError"', () => {
+    const err = new ManifestError('oops');
+    expect(err.name).toBe('ManifestError');
+  });
+
+  test('is an instance of Error', () => {
+    expect(new ManifestError('oops')).toBeInstanceOf(Error);
+  });
+
+  test('carries the message', () => {
+    expect(new ManifestError('bad manifest').message).toBe('bad manifest');
+  });
+});
+
+// ── readManifest / writeManifest (I/O) ────────────────────────────────────────
+
+describe('readManifest / writeManifest', () => {
+  let tmpDir: string;
+
+  beforeAll(async () => {
+    tmpDir = await mkdtemp(join(tmpdir(), 'oac-manifest-test-'));
+  });
+
+  afterAll(async () => {
+    await rm(tmpDir, { recursive: true, force: true });
+  });
+
+  test('readManifest returns null when no manifest exists', async () => {
+    const result = await readManifest(tmpDir);
+    expect(result).toBeNull();
+  });
+
+  test('writeManifest then readManifest round-trips correctly', async () => {
+    const original = createEmptyManifest('1.2.3');
+    await writeManifest(tmpDir, original);
+    const read = await readManifest(tmpDir);
+    expect(read).not.toBeNull();
+    expect(read?.version).toBe('1');
+    expect(read?.oacVersion).toBe('1.2.3');
+    expect(Object.keys(read?.files ?? {})).toHaveLength(0);
+  });
+
+  test('readManifest throws ManifestError for invalid JSON structure', async () => {
+    // Write a manifest with a bad version field
+    const badDir = await mkdtemp(join(tmpdir(), 'oac-bad-manifest-'));
+    try {
+      await Bun.write(join(badDir, '.oac/manifest.json'), JSON.stringify({ version: '99', files: {} }));
+      await expect(readManifest(badDir)).rejects.toThrow(ManifestError);
+    } finally {
+      await rm(badDir, { recursive: true, force: true });
+    }
+  });
+
+  test('round-trips a manifest with file entries', async () => {
+    const dir = await mkdtemp(join(tmpdir(), 'oac-manifest-entries-'));
+    try {
+      let m = createEmptyManifest('1.0.0');
+      m = addFileToManifest(m, '.opencode/agent/foo.md', makeEntry({ type: 'agent' }));
+      await writeManifest(dir, m);
+      const read = await readManifest(dir);
+      expect(read?.files['.opencode/agent/foo.md']?.type).toBe('agent');
+    } finally {
+      await rm(dir, { recursive: true, force: true });
+    }
+  });
+});

+ 180 - 0
packages/cli/src/lib/manifest.ts

@@ -0,0 +1,180 @@
+import path from 'node:path';
+import { mkdir } from 'node:fs/promises';
+import { z } from 'zod';
+
+// ── Errors ────────────────────────────────────────────────────────────────────
+
+export class ManifestError extends Error {
+  constructor(message: string) {
+    super(message)
+    this.name = 'ManifestError'
+  }
+}
+
+// ── Constants ─────────────────────────────────────────────────────────────────
+
+const MANIFEST_RELATIVE_PATH = '.oac/manifest.json';
+const MANIFEST_VERSION = '1' as const;
+
+// ── Schemas ───────────────────────────────────────────────────────────────────
+
+export const ManifestFileTypeSchema = z.enum([
+  'agent',
+  'context',
+  'skill',
+  'config',
+  'other',
+]);
+
+export const FileEntrySchema = z.object({
+  sha256: z.string(),
+  type: ManifestFileTypeSchema,
+  source: z.enum(['bundled', 'registry', 'custom']),
+  installedAt: z.string(),
+});
+
+export const ManifestFileSchema = z.object({
+  version: z.literal('1'),
+  oacVersion: z.string(),
+  installedAt: z.string(),
+  updatedAt: z.string(),
+  files: z.record(z.string(), FileEntrySchema),
+});
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export type ManifestFileType = z.infer<typeof ManifestFileTypeSchema>;
+export type FileEntry = z.infer<typeof FileEntrySchema>;
+export type ManifestFile = z.infer<typeof ManifestFileSchema>;
+
+// ── Path helpers ──────────────────────────────────────────────────────────────
+
+/** Returns the absolute path to the manifest file for a given project root. */
+export const getManifestPath = (projectRoot: string): string =>
+  path.join(projectRoot, MANIFEST_RELATIVE_PATH);
+
+// ── Pure constructors ─────────────────────────────────────────────────────────
+
+/**
+ * Creates a fresh empty manifest with no installed files.
+ * Pure — no side effects.
+ */
+export const createEmptyManifest = (oacVersion: string): ManifestFile => {
+  const now = new Date().toISOString();
+  return {
+    version: MANIFEST_VERSION,
+    oacVersion,
+    installedAt: now,
+    updatedAt: now,
+    files: {},
+  };
+};
+
+// ── Pure transformers ─────────────────────────────────────────────────────────
+
+/**
+ * Returns a new manifest with the given file entry added or replaced.
+ * Pure — does not mutate the input manifest.
+ */
+export const addFileToManifest = (
+  manifest: ManifestFile,
+  filePath: string,
+  entry: FileEntry,
+): ManifestFile => ({
+  ...manifest,
+  updatedAt: new Date().toISOString(),
+  files: {
+    ...manifest.files,
+    [filePath]: entry,
+  },
+});
+
+/**
+ * Returns a new manifest with the given file entry removed.
+ * Pure — does not mutate the input manifest.
+ * No-op if the file is not present.
+ */
+export const removeFileFromManifest = (
+  manifest: ManifestFile,
+  filePath: string,
+): ManifestFile => {
+  const { [filePath]: _removed, ...remainingFiles } = manifest.files;
+  return {
+    ...manifest,
+    updatedAt: new Date().toISOString(),
+    files: remainingFiles,
+  };
+};
+
+/**
+ * Returns a new manifest with the SHA256 hash updated for an existing file.
+ * Pure — does not mutate the input manifest.
+ * Throws if the file is not tracked in the manifest.
+ */
+export const updateFileHash = (
+  manifest: ManifestFile,
+  filePath: string,
+  sha256: string,
+): ManifestFile => {
+  const existing = manifest.files[filePath];
+  if (existing === undefined) {
+    throw new ManifestError(
+      `Cannot update hash: "${filePath}" is not tracked in the manifest. ` +
+        `Add it first with addFileToManifest.`,
+    );
+  }
+  return {
+    ...manifest,
+    updatedAt: new Date().toISOString(),
+    files: {
+      ...manifest.files,
+      [filePath]: { ...existing, sha256 },
+    },
+  };
+};
+
+// ── I/O ───────────────────────────────────────────────────────────────────────
+
+/**
+ * Reads and validates the manifest from {projectRoot}/.oac/manifest.json.
+ * Returns null if the file does not exist.
+ * Throws a ZodError with a clear message if the JSON is present but invalid.
+ */
+export const readManifest = async (
+  projectRoot: string,
+): Promise<ManifestFile | null> => {
+  const manifestPath = getManifestPath(projectRoot);
+
+  const exists = await Bun.file(manifestPath).exists();
+  if (!exists) {
+    return null;
+  }
+
+  const raw: unknown = await Bun.file(manifestPath).json() as unknown;
+
+  const result = ManifestFileSchema.safeParse(raw);
+  if (!result.success) {
+    const issues = result.error.issues
+      .map((i) => `  • ${i.path.join('.')}: ${i.message}`)
+      .join('\n');
+    throw new ManifestError(
+      `Invalid manifest at ${manifestPath}:\n${issues}\n` +
+        `Run 'oac init' to reset your manifest, or fix the JSON manually.`,
+    );
+  }
+
+  return result.data;
+};
+
+/**
+ * Writes the manifest to {projectRoot}/.oac/manifest.json.
+ * Creates the .oac/ directory if it does not exist.
+ */
+export const writeManifest = async (
+  projectRoot: string,
+  manifest: ManifestFile,
+): Promise<void> => {
+  const manifestPath = getManifestPath(projectRoot);
+  await mkdir(path.dirname(manifestPath), { recursive: true });
+  await Bun.write(manifestPath, JSON.stringify(manifest, null, 2));
+};

+ 209 - 0
packages/cli/src/lib/registry.ts

@@ -0,0 +1,209 @@
+import { z } from "zod";
+import { join } from "node:path";
+
+// ── Constants ──────────────────────────────────────────────────────────────────
+
+const REGISTRY_FILENAME = "registry.json";
+
+/** Install destinations for each component type (relative to project root). */
+const INSTALL_DIRS = {
+  agent: ".opencode/agent/",
+  context: ".opencode/context/",
+  skill: ".opencode/skills/",
+} as const;
+
+/** Source directories inside the npm bundle for each component type. */
+const BUNDLE_DIRS = {
+  agent: ".opencode/agent/",
+  context: ".opencode/context/",
+  skill: ".opencode/skills/",
+} as const;
+
+// ── Schemas ────────────────────────────────────────────────────────────────────
+
+/**
+ * The component types that the CLI can install via `oac add`.
+ * Matches the user-facing ref prefix: `agent:X`, `context:X`, `skill:X`.
+ */
+export const ComponentTypeSchema = z.enum(["agent", "context", "skill"]);
+
+/**
+ * A single installable component entry from registry.json.
+ * The registry also contains subagents, commands, tools, plugins — those are
+ * not user-installable via `oac add` and are excluded from RegistryComponent.
+ */
+export const RegistryComponentSchema = z.object({
+  id: z.string(),
+  name: z.string(),
+  type: ComponentTypeSchema,
+  path: z.string(),
+  description: z.string(),
+  tags: z.array(z.string()).default([]),
+  dependencies: z.array(z.string()).default([]),
+  category: z.string().default("standard"),
+  /** Skills may list multiple files to install. */
+  files: z.array(z.string()).optional(),
+});
+
+/**
+ * Loose schema for non-installable component categories (subagents, commands,
+ * tools, plugins). We only need to parse them without strict validation.
+ */
+const AnyComponentSchema = z.object({
+  id: z.string(),
+  name: z.string(),
+  type: z.string(),
+  path: z.string(),
+  description: z.string(),
+}).passthrough();
+
+export const RegistrySchema = z.object({
+  version: z.string(),
+  schema_version: z.string().optional(),
+  repository: z.string().optional(),
+  categories: z.record(z.string(), z.string()).optional(),
+  components: z.object({
+    agents: z.array(RegistryComponentSchema).default([]),
+    skills: z.array(RegistryComponentSchema).default([]),
+    contexts: z.array(RegistryComponentSchema).default([]),
+    // Non-installable sections — parsed loosely so schema changes don't break us
+    subagents: z.array(AnyComponentSchema).default([]),
+    commands: z.array(AnyComponentSchema).default([]),
+    tools: z.array(AnyComponentSchema).default([]),
+    plugins: z.array(AnyComponentSchema).default([]),
+  }),
+});
+
+// ── Types ──────────────────────────────────────────────────────────────────────
+
+export type ComponentType = z.infer<typeof ComponentTypeSchema>;
+export type RegistryComponent = z.infer<typeof RegistryComponentSchema>;
+export type Registry = z.infer<typeof RegistrySchema>;
+
+// ── Path helpers ───────────────────────────────────────────────────────────────
+
+/** Returns the absolute path to registry.json given the package root. */
+export const getRegistryPath = (packageRoot: string): string =>
+  join(packageRoot, REGISTRY_FILENAME);
+
+// ── Pure query helpers ─────────────────────────────────────────────────────────
+
+/**
+ * Returns all installable components (agents + skills + contexts) from the
+ * registry, optionally filtered to a single type.
+ * Pure — no side effects.
+ */
+export const listComponents = (
+  registry: Registry,
+  type?: ComponentType,
+): RegistryComponent[] => {
+  const all: RegistryComponent[] = [
+    ...registry.components.agents,
+    ...registry.components.skills,
+    ...registry.components.contexts,
+  ];
+  return type === undefined ? all : all.filter((c) => c.type === type);
+};
+
+/**
+ * Alias matching the acceptance criteria name.
+ * Filters installable components by type string.
+ * Pure — no side effects.
+ */
+export const listComponentsByType = (
+  registry: Registry,
+  type: string,
+): RegistryComponent[] => {
+  const parsed = ComponentTypeSchema.safeParse(type);
+  if (!parsed.success) return [];
+  return listComponents(registry, parsed.data);
+};
+
+/**
+ * Resolves a `type:name` ref (e.g. `"context:react-patterns"`) to a component.
+ * Returns `null` — never throws — when the component is not found or the ref
+ * format is invalid.
+ * Pure — no side effects.
+ */
+export const resolveComponent = (
+  registry: Registry,
+  ref: string,
+): RegistryComponent | null => {
+  const colonIndex = ref.indexOf(":");
+  if (colonIndex === -1) return null;
+
+  const rawType = ref.slice(0, colonIndex);
+  const id = ref.slice(colonIndex + 1);
+  if (!rawType || !id) return null;
+
+  const typeResult = ComponentTypeSchema.safeParse(rawType);
+  if (!typeResult.success) return null;
+
+  const candidates = listComponents(registry, typeResult.data);
+  return candidates.find((c) => c.id === id) ?? null;
+};
+
+/**
+ * Returns the directory (relative to project root) where a component should
+ * be installed.
+ * Pure — no side effects.
+ */
+export const getInstallPath = (component: RegistryComponent): string =>
+  INSTALL_DIRS[component.type];
+
+/**
+ * Returns the directory (relative to the npm bundle / package root) where the
+ * component's source files live.
+ * Pure — no side effects.
+ */
+export const getBundledSourcePath = (component: RegistryComponent): string =>
+  BUNDLE_DIRS[component.type];
+
+// ── I/O ────────────────────────────────────────────────────────────────────────
+
+/**
+ * Reads and validates registry.json from `packageRoot`.
+ * Throws with a clear, actionable message when:
+ *   - the file does not exist
+ *   - the JSON is malformed
+ *   - the schema validation fails
+ */
+export const readRegistry = async (packageRoot: string): Promise<Registry> => {
+  const registryPath = getRegistryPath(packageRoot);
+
+  const exists = await Bun.file(registryPath).exists();
+  if (!exists) {
+    throw new Error(
+      `registry.json not found at "${registryPath}".\n` +
+        `This file should be bundled with the @nextsystems/oac package.\n` +
+        `Try reinstalling: npm install -g @nextsystems/oac`,
+    );
+  }
+
+  const raw = await Bun.file(registryPath).json().catch((err: unknown) => {
+    const msg = err instanceof Error ? err.message : String(err);
+    throw new Error(
+      `Failed to parse registry.json at "${registryPath}": ${msg}\n` +
+        `The file may be corrupted. Try reinstalling: npm install -g @nextsystems/oac`,
+    );
+  }) as unknown;
+
+  const result = RegistrySchema.safeParse(raw);
+  if (!result.success) {
+    const issues = result.error.issues
+      .map((i) => `  • ${i.path.join(".")}: ${i.message}`)
+      .join("\n");
+    throw new Error(
+      `Invalid registry.json at "${registryPath}":\n${issues}\n` +
+        `The registry schema may have changed. Try reinstalling: npm install -g @nextsystems/oac`,
+    );
+  }
+
+  return result.data;
+};
+
+/**
+ * Convenience alias: reads registry.json from `packageRoot`.
+ * Identical to `readRegistry` — provided for callers that prefer this name.
+ */
+export const loadRegistry = readRegistry;

+ 86 - 0
packages/cli/src/lib/sha256.test.ts

@@ -0,0 +1,86 @@
+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
+import { mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { computeFileHash, computeStringHash, hashesMatch } from './sha256.js';
+
+// ── computeStringHash ─────────────────────────────────────────────────────────
+
+describe('computeStringHash', () => {
+  test('returns a 64-char hex string', () => {
+    const hash = computeStringHash('hello');
+    expect(hash).toHaveLength(64);
+    expect(hash).toMatch(/^[0-9a-f]{64}$/);
+  });
+
+  test('is deterministic for the same input', () => {
+    expect(computeStringHash('hello')).toBe(computeStringHash('hello'));
+  });
+
+  test('differs for different inputs', () => {
+    expect(computeStringHash('hello')).not.toBe(computeStringHash('world'));
+  });
+
+  test('empty string has a known SHA256', () => {
+    // SHA256('') = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+    expect(computeStringHash('')).toBe(
+      'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
+    );
+  });
+});
+
+// ── hashesMatch ───────────────────────────────────────────────────────────────
+
+describe('hashesMatch', () => {
+  test('returns true for identical hashes', () => {
+    const h = computeStringHash('test');
+    expect(hashesMatch(h, h)).toBe(true);
+  });
+
+  test('returns false for different hashes', () => {
+    expect(hashesMatch(computeStringHash('a'), computeStringHash('b'))).toBe(false);
+  });
+
+  test('is case-insensitive', () => {
+    const lower = 'abc123def456';
+    const upper = 'ABC123DEF456';
+    expect(hashesMatch(lower, upper)).toBe(true);
+  });
+});
+
+// ── computeFileHash ───────────────────────────────────────────────────────────
+
+describe('computeFileHash', () => {
+  let tmpDir: string;
+
+  beforeAll(async () => {
+    tmpDir = await mkdtemp(join(tmpdir(), 'oac-sha256-test-'));
+  });
+
+  afterAll(async () => {
+    await rm(tmpDir, { recursive: true, force: true });
+  });
+
+  test('returns the SHA256 of a file matching computeStringHash', async () => {
+    const content = 'hello world';
+    const filePath = join(tmpDir, 'test.txt');
+    await writeFile(filePath, content, 'utf8');
+
+    const fileHash = await computeFileHash(filePath);
+    const stringHash = computeStringHash(content);
+    expect(fileHash).toBe(stringHash);
+  });
+
+  test('throws a descriptive error for a missing file', async () => {
+    const missing = join(tmpDir, 'does-not-exist.txt');
+    await expect(computeFileHash(missing)).rejects.toThrow('computeFileHash: cannot read');
+  });
+
+  test('is deterministic across two reads of the same file', async () => {
+    const filePath = join(tmpDir, 'stable.txt');
+    await writeFile(filePath, 'stable content', 'utf8');
+    const h1 = await computeFileHash(filePath);
+    const h2 = await computeFileHash(filePath);
+    expect(h1).toBe(h2);
+  });
+});

+ 22 - 0
packages/cli/src/lib/sha256.ts

@@ -0,0 +1,22 @@
+import { createHash } from "node:crypto";
+
+/** Returns the hex SHA256 of a file's contents. Throws if file does not exist. */
+export async function computeFileHash(filePath: string): Promise<string> {
+  return Bun.file(filePath)
+    .bytes()
+    .then((contents) => createHash("sha256").update(contents).digest("hex"))
+    .catch((err: unknown) => {
+      const msg = err instanceof Error ? err.message : String(err);
+      throw new Error(`computeFileHash: cannot read "${filePath}" — ${msg}`);
+    });
+}
+
+/** Returns the hex SHA256 of a string (synchronous). */
+export function computeStringHash(content: string): string {
+  return createHash("sha256").update(content, "utf8").digest("hex");
+}
+
+/** Returns true when two hex SHA256 strings are identical (case-insensitive). */
+export function hashesMatch(hash1: string, hash2: string): boolean {
+  return hash1.toLowerCase() === hash2.toLowerCase();
+}

+ 19 - 0
packages/cli/src/lib/version.test.ts

@@ -0,0 +1,19 @@
+import { describe, test, expect } from 'bun:test';
+import { readCliVersion } from './version.js';
+
+describe('readCliVersion', () => {
+  test('returns a non-empty string', () => {
+    const version = readCliVersion();
+    expect(typeof version).toBe('string');
+    expect(version.length).toBeGreaterThan(0);
+  });
+
+  test('matches semver format (x.y.z)', () => {
+    const version = readCliVersion();
+    expect(version).toMatch(/^\d+\.\d+\.\d+/);
+  });
+
+  test('is deterministic across calls', () => {
+    expect(readCliVersion()).toBe(readCliVersion());
+  });
+});

+ 6 - 0
packages/cli/src/lib/version.ts

@@ -0,0 +1,6 @@
+import pkgJson from '../../package.json' with { type: 'json' }
+
+/** Returns the CLI version from package.json. Synchronous — no I/O. */
+export function readCliVersion(): string {
+  return (pkgJson as { version?: string }).version ?? '0.0.0'
+}

+ 39 - 0
packages/cli/src/ui/logger.ts

@@ -0,0 +1,39 @@
+import chalk from 'chalk';
+
+// ── Types ────────────────────────────────────────────────────────────────────
+
+export interface Logger {
+  log: (msg: string) => void;
+  info: (msg: string) => void;
+  warn: (msg: string) => void;
+  error: (msg: string) => void;
+  success: (msg: string) => void;
+  dim: (msg: string) => void;
+  bold: (msg: string) => void;
+  verbose: (msg: string) => void;
+}
+
+// ── Verbose state (module-local, mutated only via setVerbose) ────────────────
+
+let verboseEnabled = false;
+
+export const setVerbose = (enabled: boolean): void => {
+  verboseEnabled = enabled;
+};
+
+// ── Output functions (pure aside from console.log side effect) ───────────────
+
+export const log     = (msg: string): void => console.log(msg);
+export const info    = (msg: string): void => console.log(chalk.blue(`  ℹ ${msg}`));
+export const warn    = (msg: string): void => console.log(chalk.yellow(`  ⚠ ${msg}`));
+export const error   = (msg: string): void => console.error(chalk.red(`  ✗ ${msg}`));
+export const success = (msg: string): void => console.log(chalk.green(`  ✓ ${msg}`));
+export const dim     = (msg: string): void => console.log(chalk.gray(msg));
+export const bold    = (msg: string): void => console.log(chalk.bold(msg));
+export const verbose = (msg: string): void => { if (verboseEnabled) console.log(chalk.gray(`  … ${msg}`)); };
+
+// ── Logger object (aggregates all methods) ───────────────────────────────────
+// Named `logger` (lowercase) to avoid collision with the `Logger` interface in
+// the same namespace. Import as: import { logger } from './logger.js'
+
+export const logger: Logger = { log, info, warn, error, success, dim, bold, verbose };

+ 50 - 0
packages/cli/src/ui/spinner.ts

@@ -0,0 +1,50 @@
+import ora, { type Ora } from 'ora';
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export interface Spinner {
+  start(text?: string): void;
+  stop(): void;
+  succeed(text?: string): void;
+  fail(text?: string): void;
+  update(text: string): void;
+}
+
+export interface SpinnerOptions {
+  /** When true, all spinner methods are no-ops (dry-run mode). */
+  dryRun?: boolean;
+}
+
+// ── Global dry-run flag (set once at CLI startup) ─────────────────────────────
+
+let globalDryRun = false;
+/** Configure dry-run mode globally. */
+export const setDryRun = (enabled: boolean): void => { globalDryRun = enabled; };
+
+// ── Spinner implementations ───────────────────────────────────────────────────
+
+const noop = (): void => undefined;
+const createNoOpSpinner = (): Spinner =>
+  ({ start: noop, stop: noop, succeed: noop, fail: noop, update: noop });
+
+const createOraSpinner = (text: string): Spinner => {
+  const s: Ora = ora(text);
+  return {
+    start: (t?: string) => { s.start(t); },
+    stop: () => { s.stop(); },
+    succeed: (t?: string) => { s.succeed(t); },
+    fail: (t?: string) => { s.fail(t); },
+    update: (t: string) => { s.text = t; },
+  };
+};
+
+// ── Factory ───────────────────────────────────────────────────────────────────
+
+/**
+ * Create a new independent spinner.
+ * Returns a no-op when dry-run mode is active (per options or global flag).
+ */
+export const createSpinner = (text: string, options: SpinnerOptions = {}): Spinner => {
+  const isDryRun = options.dryRun ?? globalDryRun;
+  return isDryRun ? createNoOpSpinner() : createOraSpinner(text);
+};

+ 27 - 0
packages/cli/tsconfig.json

@@ -0,0 +1,27 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "lib": ["ES2022", "ESNext"],
+    "outDir": "./dist",
+    "rootDir": "./src",
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "declaration": true,
+    "declarationMap": true,
+    "sourceMap": true,
+    "resolveJsonModule": true,
+    "allowImportingTsExtensions": false,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noImplicitReturns": true,
+    "noFallthroughCasesInSwitch": true,
+    "paths": {
+      "@openagents-control/compatibility-layer": ["../compatibility-layer/dist/index.d.ts"]
+    }
+  },
+  "include": ["src/**/*"],
+  "exclude": ["node_modules", "dist", "**/*.test.ts"]
+}