Procházet zdrojové kódy

feat(cli): tiered install, safe re-init, complete adds and an honest doctor

The build half of the canonical refactor worked; the user half did not.
`oac init` copied all 351 bundled files regardless of profile, overwrote
local edits on re-run, `oac add skill:` dropped a skill's router and
scripts, and `oac doctor` warned on a correct install and pointed at a
target that does not exist. This closes the install and discovery cluster
from the issue tracker (#321, #310, #350, #277, #120, #349, #304, #237).

init:   --profile <id> (default essential, 26 files), --context-only,
        --list-profiles. Profiles resolve through registry.json, whose
        profile map is generated from content/profiles, with trailing-*
        wildcards expanded and files[] honoured. `full` keeps the old
        copy-everything path. An existing manifest routes through the
        hash-checked sync, so edits are skipped and named; --yolo backs
        up then overwrites. Paths outside .opencode/ are refused.
add:    copies every files[] entry and records it; remove deletes them all.
doctor: OpenCode present is a pass, corrupt manifests are failed checks
        instead of a crash, --json shape unchanged.
docs:   README Quick Start leads with the npm CLI and a real tier table;
        new openagents-repo guide for the CLI, registered and linked from
        navigation.

Also commits the regenerated .opencode/agent tree, registry.json and build
manifest that the sidecar retirement left stale, so the drift gate is green.

CLI tests 153 -> 190. Compatibility layer 550 pass, build --check clean.
darrenhinde před 2 týdny
rodič
revize
73400e256f
31 změnil soubory, kde provedl 1673 přidání a 737 odebrání
  1. 20 20
      .oac/build-manifest.json
  2. 0 10
      .opencode/agent/subagents/code/build-agent.md
  3. 0 12
      .opencode/agent/subagents/code/coder-agent.md
  4. 0 10
      .opencode/agent/subagents/code/reviewer.md
  5. 0 8
      .opencode/agent/subagents/code/test-engineer.md
  6. 0 20
      .opencode/agent/subagents/core/context-manager.md
  7. 0 32
      .opencode/agent/subagents/core/context-retriever.md
  8. 0 8
      .opencode/agent/subagents/core/documentation.md
  9. 0 20
      .opencode/agent/subagents/core/externalscout.md
  10. 0 8
      .opencode/agent/subagents/development/devops-specialist.md
  11. 0 8
      .opencode/agent/subagents/development/frontend-specialist.md
  12. 0 4
      .opencode/agent/subagents/planning/adr-manager.md
  13. 0 4
      .opencode/agent/subagents/planning/prioritization-engine.md
  14. 0 8
      .opencode/agent/subagents/system-builder/context-organizer.md
  15. 0 8
      .opencode/agent/subagents/system-builder/workflow-designer.md
  16. 63 0
      .opencode/context/openagents-repo/guides/oac-cli-install.md
  17. 3 2
      .opencode/context/openagents-repo/navigation.md
  18. 35 10
      README.md
  19. 70 60
      packages/cli/src/commands/add.ts
  20. 47 101
      packages/cli/src/commands/doctor.ts
  21. 93 0
      packages/cli/src/commands/init.test.ts
  22. 235 96
      packages/cli/src/commands/init.ts
  23. 34 0
      packages/cli/src/lib/bundled.test.ts
  24. 40 1
      packages/cli/src/lib/bundled.ts
  25. 149 0
      packages/cli/src/lib/doctor-checks.test.ts
  26. 113 0
      packages/cli/src/lib/doctor-checks.ts
  27. 86 0
      packages/cli/src/lib/installer-update.test.ts
  28. 42 23
      packages/cli/src/lib/installer.ts
  29. 243 0
      packages/cli/src/lib/registry.test.ts
  30. 209 68
      packages/cli/src/lib/registry.ts
  31. 191 196
      registry.json

+ 20 - 20
.oac/build-manifest.json

@@ -37,22 +37,22 @@
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/code/build-agent.md": {
-      "sha256": "cd0f60cc1da2713986bdecd0486b9443a8cf9ee4a2fa1e3b62c0e873c0beac3c",
+      "sha256": "1f61e83d854c57d422a52f89c2e680bb6d601cbcce3828abf3f551b8e507b1e9",
       "target": "opencode",
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/code/coder-agent.md": {
-      "sha256": "6c5d0e5c55e0d0f08692102825d5e90f9ce062bcc09ef03b4db682b0f5350820",
+      "sha256": "87bb97e02c7a6276c36785dfceb7b280af212fec26ca4fdb07e89e0439d818b2",
       "target": "opencode",
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/code/reviewer.md": {
-      "sha256": "9c1cf8a94d905149fd4e8fc1355232138277e79310bdc580fae845f575953b02",
+      "sha256": "65bd1f8c1c7804cea7580eedfb47adac39a0da04a447b924b0979eb6d48a144b",
       "target": "opencode",
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/code/test-engineer.md": {
-      "sha256": "0fdd7971cf6deae6f4278df6f65d319c53b2e0304fa7dd15d33255342223978e",
+      "sha256": "a2f5a6904f7d35a7f347d47490d02320fe1dfffe2d911855faca1945328f6202",
       "target": "opencode",
       "root": ".opencode/agent"
     },
@@ -62,12 +62,12 @@
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/core/context-manager.md": {
-      "sha256": "718bac8e8383a28c566fd68e9dcf797aa9ca273112bffc08755e955b7403aa50",
+      "sha256": "2211bf59259c2e6a8ef3542e6a2fb826520a49f0c35e65dbdffadde60cc394ef",
       "target": "opencode",
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/core/context-retriever.md": {
-      "sha256": "98992bc55a901237d23425667fbea61c0621bbbd3cf647ade01c639e5454ca46",
+      "sha256": "19cef5fc00434cf14061915a957480ffbaf4e3c898919ad4fac350cdf5f04527",
       "target": "opencode",
       "root": ".opencode/agent"
     },
@@ -77,12 +77,12 @@
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/core/documentation.md": {
-      "sha256": "b21eb4bf84e13eaae14627adf8790c25d2dce9204b256c749c2154b4933a8e70",
+      "sha256": "2dcfb7e65c1a24ddcd9f32a6f0568b53cf7223026eb967c578586c28cca46301",
       "target": "opencode",
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/core/externalscout.md": {
-      "sha256": "400043e08b543cf5f7c3db6a1fab6fcd3222835f1a3dd1a153b08d71faddc944",
+      "sha256": "53c8108b7e02bbf67fd8eeeb7cc31f9315d6b3a2ff8c0c4466c52dd775d499b2",
       "target": "opencode",
       "root": ".opencode/agent"
     },
@@ -97,17 +97,17 @@
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/development/devops-specialist.md": {
-      "sha256": "f8d739ccabcd2ca5628b88c9e4e38d16e1487d6a56274bfc4e1b05c73af0a9c3",
+      "sha256": "f6dc9bbccb154e78e9efe62f1576dbd353d968f6512b7043c170661fa56e8dde",
       "target": "opencode",
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/development/frontend-specialist.md": {
-      "sha256": "00637285291b6ebf280a847ab2536007b08f5fa7d58556bedc6e2e517eb0ecfb",
+      "sha256": "b404eafe11b46cdab04e75fad9a99ee58ee864e60a82e1a655f4644146194deb",
       "target": "opencode",
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/planning/adr-manager.md": {
-      "sha256": "a936cae7f5becd35c8f7399b73d335a2d451e90af56c7b2a95afe81498abb61d",
+      "sha256": "dce68ca574f628e885df344f44b9b68a98dcf605a155918fbc0b1986c23653e2",
       "target": "opencode",
       "root": ".opencode/agent"
     },
@@ -122,7 +122,7 @@
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/planning/prioritization-engine.md": {
-      "sha256": "72ccb101c0e94a99eb5960e142b1604baae2123ef151d09dccb37a85aa469717",
+      "sha256": "0622e2fbce7b0def55b34ccb6296c2c12fca68794a2801a870ca36fd49033ebf",
       "target": "opencode",
       "root": ".opencode/agent"
     },
@@ -142,7 +142,7 @@
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/system-builder/context-organizer.md": {
-      "sha256": "067032b5ad9a18dbe2e3d17479352a73e57b7d77e95ec8d09cff0a675481eed3",
+      "sha256": "fbcf3dd6d5a6ea26a48be0d9820a59b801661be06456d67a0423579a24eb0047",
       "target": "opencode",
       "root": ".opencode/agent"
     },
@@ -152,7 +152,7 @@
       "root": ".opencode/agent"
     },
     ".opencode/agent/subagents/system-builder/workflow-designer.md": {
-      "sha256": "21a17801bdee71f64e3f98bdceb4649c0f2ace630c09a8bcdfffc3524276f00c",
+      "sha256": "2e11d4d8165a273ae33fa1ea344737a5ea18ee35f1aa7e52ea4bc5990a522c3f",
       "target": "opencode",
       "root": ".opencode/agent"
     },
@@ -167,17 +167,17 @@
       "root": ".opencode/agent"
     },
     ".tmp/oac-build/plugins/claude-code/agents/code-reviewer.md": {
-      "sha256": "985896d8123330aa28ccb45fa23583de44c416a39f1cee09b282eb15b56b04f1",
+      "sha256": "5a6f85a83ff22b90e154c3b53f601850a9f6bb4a2d6c828eabfc007c374eeea5",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     ".tmp/oac-build/plugins/claude-code/agents/coder-agent.md": {
-      "sha256": "92d0db161d9682ae2188c2a532c4b3b0928dcde816532a77dff1e52c74bb2d00",
+      "sha256": "f9a95528bac2a77fea6dcd12a4dac88f076c236acafd45ece4ec34651428baf0",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     ".tmp/oac-build/plugins/claude-code/agents/context-manager.md": {
-      "sha256": "61f07dd83ca55afb6d72cc58094f6753aa71dfb84505fe628343badfd4310b98",
+      "sha256": "8d0837575c3875dba08fe76276080b89abd7447eaafa2ef923da3984d343179c",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
@@ -187,7 +187,7 @@
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     ".tmp/oac-build/plugins/claude-code/agents/external-scout.md": {
-      "sha256": "9656405d4fa8e81397f9b0228770abe8bae63f44cc9a5ff2d66999534d428b7a",
+      "sha256": "de921f643ffaa8a11148da2a8d70120e1ab694d054a6b499b033f0552f1d0181",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
@@ -197,12 +197,12 @@
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     ".tmp/oac-build/plugins/claude-code/agents/test-engineer.md": {
-      "sha256": "01332fdd48dcf47008d150c42ffe794366b14ce5f05babf9fd94c0001d43d811",
+      "sha256": "8e0cc825358255957173783972774d353313e883f89d10699dba3042b600ff31",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     "registry.json": {
-      "sha256": "d149d8a5d30641136919c88775935fff68bb2cadbb02e2ea8dcc1d03b94b1346",
+      "sha256": "eb030beea4d06f3d46617b06959a2cbfa7189d2ea71fd3650fed9ff8fc6ed435",
       "target": "registry",
       "root": "registry.json"
     }

+ 0 - 10
.opencode/agent/subagents/code/build-agent.md

@@ -90,12 +90,6 @@ task(subagent_type="ContextScout", description="Find build standards", prompt="F
 3. **Apply** any custom build configurations or strictness requirements
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
----
-
 ## What NOT to Do
 
 - ❌ **Don't skip ContextScout** — build validation without project standards = running wrong commands
@@ -106,10 +100,6 @@ task(subagent_type="ContextScout", description="Find build standards", prompt="F
 - ❌ **Don't give vague error reports** — include file paths, line numbers, and what's expected
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
   <context_first>ContextScout before any validation — understand project conventions first</context_first>
   <detect_first>Language detection before any commands — never assume</detect_first>
   <read_only>Report errors, never fix them — clear separation of concerns</read_only>

+ 0 - 12
.opencode/agent/subagents/code/coder-agent.md

@@ -92,12 +92,6 @@ task(subagent_type="ContextScout", description="Find coding standards for [featu
 3. If ContextScout flags a framework/library → call **ExternalScout** for live docs (see below)
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
----
-
 ## Workflow
 
 ### Step 1: Read Subtask JSON
@@ -245,12 +239,6 @@ Summary: Implemented JWT authentication with refresh tokens and error handling
 - Status marking is the signal that enables parallel workflow progression
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
----
-
 ## Principles
 
 - Context first, code second. Always.

+ 0 - 10
.opencode/agent/subagents/code/reviewer.md

@@ -81,12 +81,6 @@ task(subagent_type="ContextScout", description="Find code review standards", pro
 3. Flag deviations from team standards as findings
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
----
-
 ## What NOT to Do
 
 - ❌ **Don't skip ContextScout** — reviewing without project standards = generic feedback that misses project-specific issues
@@ -97,10 +91,6 @@ task(subagent_type="ContextScout", description="Find code review standards", pro
 - ❌ **Don't skip error handling checks** — missing error handling is a correctness issue
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
   <context_first>ContextScout before any review — standards-blind reviews are useless</context_first>
   <security_first>Security findings always surface first — they have the highest impact</security_first>
   <read_only>Suggest, never apply — the developer owns the fix</read_only>

+ 0 - 8
.opencode/agent/subagents/code/test-engineer.md

@@ -94,10 +94,6 @@ task(subagent_type="ContextScout", description="Find testing standards", prompt=
 3. Structure your test plan to match project conventions
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
    - ✅ Positive: [expected success outcome]
    - ❌ Negative: [expected failure/edge case handling]
    - ✅ Positive: [expected success outcome]
@@ -115,10 +111,6 @@ task(subagent_type="ContextScout", description="Find testing standards", prompt=
 - ❌ **Don't skip the test plan** — propose before implementing, get approval
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
   <context_first>ContextScout before any test writing — conventions matter</context_first>
   <tdd_mindset>Think about testability before implementation — tests define behavior</tdd_mindset>
   <deterministic>Tests must be reliable — no flakiness, no external dependencies</deterministic>

+ 0 - 20
.opencode/agent/subagents/core/context-manager.md

@@ -96,10 +96,6 @@ permission:
 <task>Discover context structure via navigation → catalog existing context → validate integrity → propose improvements → maintain lifecycle</task>
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ## 📋 Process Flow
@@ -230,10 +226,6 @@ permission:
 </process_flow>
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
   <parameter name="request_type" type="enum">
     Type of context management request:
     - "discover": Discover and map context structure
@@ -360,10 +352,6 @@ permission:
 </output_specification>
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
   <pre_execution>
     - Verify request_type is valid
     - Verify scope exists or is "all"
@@ -422,10 +410,6 @@ permission:
 </context_management_principles>
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ## 📝 Common Operations
@@ -466,10 +450,6 @@ Details: Overall context health and maintenance recommendations
 ```
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 **ContextManager** - Organize, validate, and maintain your project context!

+ 0 - 32
.opencode/agent/subagents/core/context-retriever.md

@@ -282,10 +282,6 @@ Always structure your response in this format:
 **Files Searched**: {number of files examined}
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ### 🎯 Primary Results (Must Read)
@@ -308,10 +304,6 @@ Always structure your response in this format:
 - {what to do with this information}
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ### 📚 Secondary Results (Should Read)
@@ -327,10 +319,6 @@ Always structure your response in this format:
 **Why Read This**: {brief explanation of value}
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ## 📋 Summary
@@ -390,10 +378,6 @@ read(filePath="{discovered-standards-file}")
 **Files Searched**: 12
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
     └── best-practices.md ⭐ FOUND
 ---
 
@@ -422,10 +406,6 @@ read(filePath="{discovered-standards-file}")
 - Follow naming conventions for consistency
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ### 📍 Context Structure Discovered
@@ -446,10 +426,6 @@ docs/contributing/
 ```
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ### 📋 Summary
@@ -597,20 +573,12 @@ Repository lacks structured context
 **Files Searched**: 0
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ### 📍 Context Structure Discovered
 {show structure}
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ### 📍 Many Relevant Files Found ({count})

+ 0 - 8
.opencode/agent/subagents/core/documentation.md

@@ -83,10 +83,6 @@ task(subagent_type="ContextScout", description="Find documentation standards", p
 3. **Apply** formatting, structure, and tone standards to your writing
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ## What NOT to Do
@@ -99,10 +95,6 @@ task(subagent_type="ContextScout", description="Find documentation standards", p
 - ❌ **Don't ignore existing style** — match what's already there
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
   <context_first>ContextScout before any writing — consistency requires knowing the standards</context_first>
   <propose_first>Always propose before writing — documentation changes need sign-off</propose_first>
   <concise>Scannable in <30 seconds — if not, it's too long</concise>

+ 0 - 20
.opencode/agent/subagents/core/externalscout.md

@@ -74,10 +74,6 @@ permission:
 </critical_rules>
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
   <tier level="1" desc="Critical Operations">
     - @check_cache_first: Check .tmp/external-context/ before fetching
     - @tool_usage: Use ONLY allowed tools
@@ -236,10 +232,6 @@ permission:
 </workflow_execution>
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ## Quick Reference
@@ -249,10 +241,6 @@ permission:
 **Supported Libraries**: Drizzle | Prisma | Better Auth | NextAuth.js | Clerk | Next.js | React | TanStack Query/Router | Cloudflare Workers | AWS Lambda | Vercel | Shadcn/ui | Radix UI | Tailwind CSS | Zustand | Jotai | Zod | React Hook Form | Vitest | Playwright
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
     ├── cloudflare-deployment.md
     ├── server-functions.md
     └── file-routing.md
@@ -291,10 +279,6 @@ If Context7 API fails:
 3. Suggest checking `.opencode/context/` for cached docs
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ## Success Criteria
@@ -314,7 +298,3 @@ You succeed when ALL of these are complete:
 - Return summary without file locations
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-

+ 0 - 8
.opencode/agent/subagents/development/devops-specialist.md

@@ -92,10 +92,6 @@ task(subagent_type="ContextScout", description="Find DevOps standards", prompt="
 3. If ContextScout flags a cloud service or tool → verify current docs before implementing
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ## What NOT to Do
@@ -109,10 +105,6 @@ task(subagent_type="ContextScout", description="Find DevOps standards", prompt="
 - ❌ **Don't ignore peer dependencies** — verify version compatibility before deploying
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
   <pre_flight>
     - ContextScout called and standards loaded
     - Parent agent requirements clear

+ 0 - 8
.opencode/agent/subagents/development/frontend-specialist.md

@@ -86,10 +86,6 @@ task(subagent_type="ContextScout", description="Find frontend design standards",
 3. If ContextScout flags a UI library (Tailwind, Shadcn, etc.) → call **ExternalScout** (see below)
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ## Workflow
@@ -142,10 +138,6 @@ task(subagent_type="ContextScout", description="Find frontend design standards",
 4. Present: "Updated design saved. Previous version preserved."
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 <heuristics>

+ 0 - 4
.opencode/agent/subagents/planning/adr-manager.md

@@ -86,10 +86,6 @@ task(subagent_type="ContextScout", description="Find ADR standards", prompt="Fin
 3. **Apply** formatting, structure, and linking standards to your ADRs
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ## Workflow

+ 0 - 4
.opencode/agent/subagents/planning/prioritization-engine.md

@@ -86,10 +86,6 @@ task(subagent_type="ContextScout", description="Find prioritization context for
 3. **Apply** those criteria to RICE and WSJF scoring
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ## Workflow

+ 0 - 8
.opencode/agent/subagents/system-builder/context-organizer.md

@@ -80,10 +80,6 @@ task(subagent_type="ContextScout", description="Find context system standards",
 3. **Apply** MVI format, frontmatter, and structure standards to all generated files
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ## What NOT to Do
@@ -97,10 +93,6 @@ task(subagent_type="ContextScout", description="Find context system standards",
 - ❌ **Don't skip navigation.md** — every category needs one
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
   <!-- Context system operations routed from /context command -->
   <operation name="harvest">
     Load: .opencode/context/core/context-system/operations/harvest.md

+ 0 - 8
.opencode/agent/subagents/system-builder/workflow-designer.md

@@ -80,10 +80,6 @@ task(subagent_type="ContextScout", description="Find workflow design standards",
 3. **Apply** validation gate, context dependency, and success criteria standards
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 
 ## What NOT to Do
@@ -96,10 +92,6 @@ task(subagent_type="ContextScout", description="Find workflow design standards",
 - ❌ **Don't ignore complexity patterns** — match the pattern to the use case complexity
 
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
   <simple_pattern>
     Linear execution with validation:
     1. Validate inputs → 2. Execute main task → 3. Validate outputs → 4. Deliver results

+ 63 - 0
.opencode/context/openagents-repo/guides/oac-cli-install.md

@@ -0,0 +1,63 @@
+<!-- Context: openagents-repo/guides | Priority: high | Version: 1.0 | Updated: 2026-09-02 -->
+
+# Guide: The `oac` CLI (install, profiles, update)
+
+**Purpose**: How end users install and manage OAC with the npm CLI, and how that connects to the canonical `content/` build maintainers run.
+
+---
+
+## Commands (packages/cli/src/commands)
+
+| Command | What it does | Key flags |
+|---------|--------------|-----------|
+| `oac init` | Install a profile into `./.opencode/`, write `.oac/manifest.json` and `.oac/config.json`. Requires a `package.json` or `.git` in cwd. | `--profile <id>` (default `essential`), `--context-only`, `--dry-run`, `--yolo`, `--verbose` |
+| `oac add <type:id>` | Copy one registry component (and every file in its `files[]`) from the bundle and record it in the manifest. No argument lists what is available. | `--force`, `--dry-run` |
+| `oac remove <type:id>` | Delete an installed component and drop it from the manifest. | `--dry-run` |
+| `oac update` | Re-copy every OAC-owned file whose on-disk sha256 still matches the manifest; skip files the user changed. Files no longer shipped are left on disk and dropped from the manifest with a warning. | `--check`, `--dry-run`, `--yolo` (overwrite after backing up to `.oac/backups/<timestamp>/`) |
+| `oac apply claude` | Generate a `CLAUDE.md` from `.opencode/agent/` (existing file backed up to `.bak`). Claude Code is the only apply target. | `--dry-run` |
+| `oac doctor` | Health checks: Node ≥ 20, config, manifest, tracked files present, sha drift, detected tools, npm latest. Exit 0 with OK on a correct fresh install. | `--verbose`, `--json` |
+| `oac list` / `oac status` | Manifest contents (flagging locally modified files) / one-screen summary. | `--type`, `--verbose` |
+| `oac build` | **Maintainer only.** Regenerate the tool trees from `content/`. Needs the repo, not the npm package. | `--check`, `--dry-run`, `--strict`, `--target` |
+
+All commands are offline except `doctor`'s npm-latest check. Runtime is plain Node; Bun is only used to bundle the CLI.
+
+## Profiles
+
+Two layers, both canonical under `content/profiles/`:
+
+- **Context profiles** (`content/profiles/context/<id>.json`): `{ id, contexts[] }`. Each entry is a registry context id or a directory glob such as `core/*`. Examples: `core`, `essentials`, `engineering`, `business`, `meta-authoring`.
+- **System profiles** (`content/profiles/system/<id>.json`): `{ id, agents[], contextProfiles[], commands[], skills[], tools[], plugins[], config[] }`. These are the installable tiers: `essential`, `developer`, `business`, `full`, `advanced`. They name context profiles, never raw context ids.
+
+`oac build` resolves system → context profiles → registry components and emits the result as `registry.json → profiles.<id>.components`, a flat list of `type:id` references. **The CLI reads that generated map**; it never parses `content/` at install time (the npm package does not ship `content/`).
+
+`oac init --profile <id>` resolves each `type:id` in the profile to its registry entry and copies `path` plus `files[]`. `--context-only` keeps only the `context:` references. `--profile full` is the previous "install everything" behaviour.
+
+Adding an agent to a tier means editing the system profile JSON and rebuilding. Every new agent must land in at least one profile or users cannot install it (see `guides/profile-validation.md` for the coverage principle; ignore its registry-editing steps).
+
+## Manifest: how local edits are protected
+
+`.oac/manifest.json` records, per installed file, its sha256 at install time, its kind (agent / context / skill / command / tool / config), and source. `oac update` compares disk to manifest:
+
+| Disk vs manifest | Action |
+|------------------|--------|
+| Same hash | Overwrite with the new bundled version |
+| Different hash | Skip and report; `--yolo` backs up then overwrites |
+| Not in bundle any more | Leave on disk, remove from manifest, warn |
+
+`oac init` on a project that already has a manifest takes the same path, so re-running it never clobbers edited files. `oac add` and `oac remove` keep the manifest in sync; `oac list` shows which files differ from install.
+
+## Maintainer loop (repo only)
+
+1. Edit the canonical source: `content/agents/**` (agent markdown with the `oac:` block) or `content/profiles/**`.
+2. `make build-canonical` (runs `oac build`). It rewrites `.opencode/agent/**`, the agent/subagent/profile sections of `registry.json`, and the ledger `.oac/build-manifest.json`. The Claude Code agents are staged to `.tmp/oac-build/` and compared, not written in place.
+3. Commit the regenerated output together with the source change.
+4. CI runs `scripts/validation/check-build-drift.sh`; a hand edit to any generated file turns the build red with instructions pointing back at `content/`.
+
+Still hand-authored (no canonical source yet): `.opencode/context/**`, `.opencode/command/**`, `.opencode/skills/**`, `.opencode/tool/**`, and `.opencode/agent/eval-runner.md`. Register those categories by hand or with the manually dispatched `update-registry` workflow.
+
+## Related
+
+- `standards/agent-frontmatter.md` — what goes in the `oac:` block
+- `core-concepts/agent-metadata.md` — build flow from `content/` to registry
+- `guides/profile-validation.md` — profile coverage principle
+- `docs/architecture/canonical-refactor/04-cli-build-distribution.md` — CLI and build spec

+ 3 - 2
.opencode/context/openagents-repo/navigation.md

@@ -1,4 +1,4 @@
-<!-- Context: openagents-repo/navigation | Priority: critical | Version: 1.0 | Updated: 2026-02-15 -->
+<!-- Context: openagents-repo/navigation | Priority: critical | Version: 1.1 | Updated: 2026-09-02 -->
 
 # OpenAgents Control Repository Context
 
@@ -15,7 +15,7 @@
 | **Standards** | 2 files | Agent creation standards |
 | **Concepts** | 6 files | Core ideas and principles |
 | **Examples** | 9 files | Working code samples |
-| **Guides** | 14 files | Step-by-step workflows |
+| **Guides** | 16 files | Step-by-step workflows |
 | **Lookup** | 11 files | Quick reference tables |
 | **Errors** | 2 files | Common issues + solutions |
 | **Features** | 3 files | Feature documentation and refactoring |
@@ -64,6 +64,7 @@
 
 | File | Topic | Priority |
 |------|-------|----------|
+| `guides/oac-cli-install.md` | The `oac` CLI: install, profiles, update, maintainer build loop | ⭐⭐⭐⭐⭐ |
 | `guides/compatibility-layer-workflow.md` | Developing compatibility layer for AI tools | ⭐⭐⭐⭐⭐ |
 | `guides/testing-subagents.md` | How to test subagents standalone | ⭐⭐⭐⭐⭐ |
 | `guides/adding-agent-basics.md` | How to add new agents (basics) | ⭐⭐⭐⭐ |

+ 35 - 10
README.md

@@ -66,7 +66,7 @@ export async function POST(request: Request) {
 Agents load YOUR patterns before generating code. Code matches your project from the start. No refactoring needed.
 
 **📝 Editable Agents (Not Baked-In Plugins)**  
-Full control over agent behavior. Edit markdown files directly—no compilation, no vendor lock-in. Change workflows, add constraints, customize for your team.
+Full control over agent behavior. Everything installed into your project is plain markdown you edit in place, with no vendor lock-in. Change workflows, add constraints, customize for your team. (Repo maintainers author agents once under `content/` and run `oac build` to generate every tool's copy; you never need that step.)
 
 **✋ Approval Gates (Human-Guided AI)**  
 Agents ALWAYS request approval before execution. Propose → Approve → Execute. You stay in control. No "oh no, what did the AI just do?" moments.
@@ -115,31 +115,56 @@ Use any AI model (Claude, GPT, Gemini, local). No vendor lock-in.
 
 ## 🚀 Quick Start
 
-**Prerequisites:** [OpenCode CLI](https://opencode.ai/docs) (free, open-source) • Bash 3.2+ • Git
+**Prerequisites:** [OpenCode CLI](https://opencode.ai/docs) (free, open-source) • Node 20+ (for the `oac` CLI) • Git
 
 ### Step 1: Install
 
-**One command:**
+**With the `oac` CLI (recommended):**
 
 ```bash
-curl -fsSL https://raw.githubusercontent.com/darrenhinde/OpenAgentsControl/main/install.sh | bash -s developer
+npm i -g @controlstack/oac
+cd your-project
+oac init                      # essential tier: one agent, core subagents, core standards
+oac init --profile developer  # or: business, advanced, full
+oac init --context-only       # just the context/standards files, bring your own agents
+oac apply claude              # project into Claude Code
+oac doctor                    # verify
+oac update                    # later: refresh, keeps your edits
 ```
 
-<sub>The installer will set up OpenCode CLI if you don't have it yet.</sub>
+`oac init` writes everything into `.opencode/` in your project and records what it installed in `.oac/manifest.json`, so `oac update` can refresh OAC-owned files and leave anything you edited alone. Pull one more thing at any time with `oac add context:react-patterns` or `oac add skill:task-management`.
+
+**What the tiers contain:**
+
+| Profile | Agents | Context | Also |
+|---------|--------|---------|------|
+| `essential` (default) | OpenAgent + ContextScout, Documentation, TaskManager | Core (quick start, project context) + core standards (code, tests, docs, patterns, analysis, review workflow, session management) | `add-context`, `clean`, `context` commands; task-management skill |
+| `developer` | + OpenCoder, CoderAgent, Reviewer, Tester, BuildAgent, DevOps and Frontend specialists | Core + engineering (development, UI, API design, clean code, React patterns, repo standards) | + `commit`, `test`, `optimize`, `analyze-patterns`, `validate-repo` |
+| `business` | OpenAgent + Copywriter, DataAnalyst, TechnicalWriter, ImageSpecialist, ContextScout, Documentation, TaskManager | Core + business (project intelligence) | + `prompt-enhancer`; gemini tool |
+| `full` | Every developer and business agent | Core + engineering | Every command; gemini tool |
+| `advanced` | Full plus the SystemBuilder, RepoManager and generator subagents | Core + engineering + meta-authoring (templates, frontmatter, context-system internals) | + `build-context-system`, `worktrees` |
+
+`--context-only` installs only the context files of the chosen tier: the standards, patterns and navigation indexes, with no agents, commands or skills. Use it when you already have agents you like and just want them to read your standards.
+
+**Alternative: shell installer**
 
-**Or interactive:**
 ```bash
-curl -fsSL https://raw.githubusercontent.com/darrenhinde/OpenAgentsControl/main/install.sh -o install.sh
-bash install.sh
+curl -fsSL https://raw.githubusercontent.com/darrenhinde/OpenAgentsControl/main/install.sh | bash -s developer
 ```
 
+<sub>The installer will set up OpenCode CLI if you don't have it yet. Interactive mode: download `install.sh` and run `bash install.sh`.</sub>
+
 ### Keep Updated
 
 ```bash
-curl -fsSL https://raw.githubusercontent.com/darrenhinde/OpenAgentsControl/main/update.sh | bash
+oac update            # CLI installs (skips files you have modified; --yolo to overwrite with a backup)
+```
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/darrenhinde/OpenAgentsControl/main/update.sh | bash   # shell installs
 ```
 
-> Use `--install-dir PATH` if you installed to a custom location (e.g. `~/.config/opencode`).
+> Use `--install-dir PATH` with the shell updater if you installed to a custom location (e.g. `~/.config/opencode`).
 
 ### Step 2: Start Building
 

+ 70 - 60
packages/cli/src/commands/add.ts

@@ -2,8 +2,16 @@ import path from 'node:path';
 // node:fs/promises rm is used intentionally — Bun has no built-in recursive directory removal
 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 {
+  loadRegistry,
+  resolveComponent,
+  listComponents,
+  filesForComponent,
+  isInstallablePath,
+  ComponentTypeSchema,
+  type ComponentType,
+} from '../lib/registry.js';
+import { getPackageRoot, getBundledFilePath, toManifestType } from '../lib/bundled.js';
 import { installFile } from '../lib/installer.js';
 import {
   readManifest,
@@ -37,53 +45,42 @@ export type RemoveOptions = {
 
 // ── 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. */
+/** Builds a FileEntry for a newly installed component file. */
 const buildFileEntry = (
   sha256: string,
   component: RegistryComponent,
 ): FileEntry => ({
   sha256,
-  type: component.type,
+  type: toManifestType(component.type),
   source: 'registry',
   installedAt: new Date().toISOString(),
 });
 
-/** Returns true if the file at destPath is already tracked in the manifest. */
+/** Returns true if the file is already tracked in the manifest. */
 const isAlreadyInstalled = (
   manifest: ManifestFile | null,
   destRelativePath: string,
 ): boolean => manifest?.files[destRelativePath] !== undefined;
 
+/** The directory a component's primary file lands in, for messages. */
+const destDirOf = (component: RegistryComponent): string => path.dirname(component.path);
+
 // ── List display ──────────────────────────────────────────────────────────────
 
+/** Component kinds shown by a bare `oac add`, in display order. */
+const LISTED_TYPES: ComponentType[] = ['agent', 'subagent', 'context', 'skill', 'command', 'tool'];
+
 /** Prints all available components grouped by type. */
-const printAvailableComponents = async (_projectRoot: string): Promise<void> => {
+const printAvailableComponents = async (): 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)) {
+  for (const type of LISTED_TYPES) {
+    const components = listComponents(registry, type);
     if (components.length === 0) continue;
     log(`  ${type.toUpperCase()}S`);
     for (const c of components) {
@@ -107,7 +104,8 @@ const resolveOrFail = async (
   const component = resolveComponent(registry, ref);
 
   if (component === null) {
-    error(`Component '${ref}' not found. Run 'oac add' to see available components.`);
+    const kinds = ComponentTypeSchema.options.join(', ');
+    error(`Component '${ref}' not found. Run 'oac add' to see available components (kinds: ${kinds}).`);
     process.exit(1);
   }
 
@@ -131,25 +129,18 @@ const checkAlreadyInstalled = (
   return false; // signal: proceed
 };
 
-/** Performs the actual file copy and manifest update. */
+/** Copies every file of a component and records each one in the manifest. */
 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}/`);
+  const files = filesForComponent(component);
+  const destDir = destDirOf(component);
 
-  if (opts.verbose) {
-    verbose(`Source: ${sourcePath}`);
-    verbose(`Destination: ${destPath}`);
-  }
+  info(`Installing ${component.type}:${component.id} → ${destDir}/ (${files.length} file${files.length !== 1 ? 's' : ''})`);
 
   const installOpts = {
     projectRoot,
@@ -159,18 +150,30 @@ const performInstall = async (
     verbose: opts.verbose,
   };
 
-  await installFile(sourcePath, destPath, installOpts);
+  let updatedManifest = manifest;
+  for (const relativePath of files) {
+    const sourcePath = getBundledFilePath(packageRoot, relativePath);
+    const destPath = path.join(projectRoot, relativePath);
+
+    if (opts.verbose) {
+      verbose(`Source: ${sourcePath}`);
+      verbose(`Destination: ${destPath}`);
+    }
+
+    await installFile(sourcePath, destPath, installOpts);
+
+    if (!opts.dryRun) {
+      const sha256 = await computeFileHash(destPath);
+      updatedManifest = addFileToManifest(updatedManifest, relativePath, buildFileEntry(sha256, component));
+    }
+  }
 
   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}/`);
 };
 
@@ -179,7 +182,7 @@ const performInstall = async (
 /**
  * Implements `oac add [ref]`.
  * With no ref: lists available components grouped by type.
- * With ref (e.g. `context:react-patterns`): installs the component.
+ * With ref (e.g. `context:react-patterns`): installs the component and every file it lists.
  */
 export async function addCommand(
   ref: string | undefined,
@@ -188,7 +191,7 @@ export async function addCommand(
   const projectRoot = process.cwd();
 
   if (ref === undefined) {
-    await printAvailableComponents(projectRoot);
+    await printAvailableComponents();
     return;
   }
 
@@ -199,13 +202,17 @@ export async function addCommand(
     const { component, packageRoot } = await resolveOrFail(ref);
     spinner.stop();
 
+    if (!filesForComponent(component).every(isInstallablePath)) {
+      error(`'${ref}' would write outside .opencode/ (${component.path}) — refusing to overwrite a project file.`);
+      process.exit(1);
+    }
+
     const manifest = (await readManifest(projectRoot)) ?? createEmptyManifest(readCliVersion());
-    const destRelativePath = getDestRelativePath(component);
 
-    const shouldAbort = checkAlreadyInstalled(manifest, destRelativePath, options.force);
+    const shouldAbort = checkAlreadyInstalled(manifest, component.path, options.force);
     if (shouldAbort) return;
 
-    await performInstall(component, packageRoot, projectRoot, destRelativePath, manifest, options);
+    await performInstall(component, packageRoot, projectRoot, manifest, options);
   } catch (err: unknown) {
     spinner.fail();
     const msg = err instanceof Error ? err.message : String(err);
@@ -216,7 +223,7 @@ export async function addCommand(
 
 /**
  * Implements `oac remove [ref]`.
- * Removes the component file from disk and updates the manifest.
+ * Removes every file the component installed and updates the manifest.
  */
 export async function removeCommand(
   ref: string | undefined,
@@ -237,28 +244,31 @@ export async function removeCommand(
     spinner.stop();
 
     const manifest = await readManifest(projectRoot);
-    const destRelativePath = getDestRelativePath(component);
 
-    if (!isAlreadyInstalled(manifest, destRelativePath)) {
+    if (!isAlreadyInstalled(manifest, component.path)) {
       warn(`'${ref}' is not installed — nothing to remove.`);
       return;
     }
 
-    const destPath = path.join(projectRoot, destRelativePath);
+    const files = filesForComponent(component);
+    info(`Removing ${component.type}:${component.id} from ${destDirOf(component)}/ (${files.length} file${files.length !== 1 ? 's' : ''})`);
 
-    if (options.verbose) {
-      verbose(`Removing: ${destPath}`);
-    }
+    let updatedManifest = manifest!;
+    for (const relativePath of files) {
+      const destPath = path.join(projectRoot, relativePath);
+      if (options.verbose) verbose(`Removing: ${destPath}`);
 
-    info(`Removing ${component.type}:${component.id} from ${path.dirname(destRelativePath)}/`);
+      if (options.dryRun) {
+        info(`[dry-run] Would remove ${destPath}`);
+        continue;
+      }
+      await rm(destPath, { recursive: true, force: true });
+      updatedManifest = removeFileFromManifest(updatedManifest, relativePath);
+    }
 
     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();
@@ -276,7 +286,7 @@ export async function removeCommand(
 export function registerAddCommand(program: Command): void {
   program
     .command('add [ref]')
-    .description('Add a component (agent, context, or skill). Example: oac add context:react-patterns')
+    .description('Add a component (agent, context, skill, command, tool). 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)

+ 47 - 101
packages/cli/src/commands/doctor.ts

@@ -4,34 +4,29 @@ 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 { detectIdes } from '../lib/ide-detect.js';
+import {
+  ideCheckResults,
+  manifestUnavailable,
+  overallStatus,
+  safeReadManifest,
+  summariseResults,
+  type CheckResult,
+  type DoctorSummary,
+} from '../lib/doctor-checks.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, CheckStatus } from '../lib/doctor-checks.js';
 
-export type CheckResult = {
-  name: string;
-  status: CheckStatus;
-  message: string;
-  detail?: string[];
-};
+// ── Types ─────────────────────────────────────────────────────────────────────
 
 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. */
@@ -122,49 +117,40 @@ const checkConfig = async (projectRoot: string): Promise<CheckResult> => {
 
 /** 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;
+  const read = await safeReadManifest(projectRoot);
+  if (!read.ok) {
     return {
       name: 'Manifest',
-      status: 'ok',
-      message: `.oac/manifest.json valid (${fileCount} file${fileCount !== 1 ? 's' : ''} tracked)`,
+      status: 'error',
+      message: `.oac/manifest.json invalid — ${read.error}`,
     };
-  } catch (err) {
-    const msg = err instanceof Error ? err.message : String(err);
+  }
+  if (read.manifest === null) {
     return {
       name: 'Manifest',
       status: 'error',
-      message: `.oac/manifest.json invalid — ${msg}`,
+      message: '.oac/manifest.json not found — run \'oac init\' to create it',
     };
   }
+  const fileCount = Object.keys(read.manifest.files).length;
+  return {
+    name: 'Manifest',
+    status: 'ok',
+    message: `.oac/manifest.json valid (${fileCount} file${fileCount !== 1 ? 's' : ''} tracked)`,
+  };
 };
 
 /** 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 name = 'Files on disk';
+  const read = await safeReadManifest(projectRoot);
+  if (!read.ok || read.manifest === null) {
+    return manifestUnavailable(name, read) ?? { name, status: 'error', message: 'Cannot check — manifest is missing' };
   }
 
-  const trackedFiles = Object.keys(manifest.files);
+  const trackedFiles = Object.keys(read.manifest.files);
   if (trackedFiles.length === 0) {
-    return {
-      name: 'Files on disk',
-      status: 'ok',
-      message: 'No files tracked in manifest',
-    };
+    return { name, status: 'ok', message: 'No files tracked in manifest' };
   }
 
   const missingFiles: string[] = [];
@@ -176,7 +162,7 @@ const checkFilesOnDisk = async (projectRoot: string): Promise<CheckResult> => {
 
   if (missingFiles.length > 0) {
     return {
-      name: 'Files on disk',
+      name,
       status: 'error',
       message: `${missingFiles.length} file${missingFiles.length !== 1 ? 's' : ''} missing from disk`,
       detail: missingFiles,
@@ -184,7 +170,7 @@ const checkFilesOnDisk = async (projectRoot: string): Promise<CheckResult> => {
   }
 
   return {
-    name: 'Files on disk',
+    name,
     status: 'ok',
     message: `All ${trackedFiles.length} tracked file${trackedFiles.length !== 1 ? 's' : ''} present`,
   };
@@ -192,22 +178,16 @@ const checkFilesOnDisk = async (projectRoot: string): Promise<CheckResult> => {
 
 /** 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 name = 'Modified files';
+  const read = await safeReadManifest(projectRoot);
+  if (!read.ok || read.manifest === null) {
+    return manifestUnavailable(name, read) ?? { name, status: 'error', message: 'Cannot check — manifest is missing' };
   }
 
+  const manifest = read.manifest;
   const trackedFiles = Object.keys(manifest.files);
   if (trackedFiles.length === 0) {
-    return {
-      name: 'Modified files',
-      status: 'ok',
-      message: 'No files tracked',
-    };
+    return { name, status: 'ok', message: 'No files tracked' };
   }
 
   const modifiedFiles: string[] = [];
@@ -229,41 +209,19 @@ const checkModifiedFiles = async (projectRoot: string): Promise<CheckResult> =>
 
   if (modifiedFiles.length > 0) {
     return {
-      name: 'Modified files',
+      name,
       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',
-  };
+  return { name, 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)}`,
-  }));
-};
+/** Check 7: which tools are present. OpenCode present is the healthy state; Claude is informational. */
+const checkIdes = async (projectRoot: string): Promise<CheckResult[]> =>
+  ideCheckResults(await detectIdes(projectRoot));
 
 // ── Result rendering ──────────────────────────────────────────────────────────
 
@@ -291,20 +249,6 @@ const printCheckResult = (result: CheckResult): void => {
   }
 };
 
-/** 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('');
@@ -330,6 +274,9 @@ const printFinalResult = (summary: DoctorSummary): void => {
  * 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.
+ *
+ *  Every check returns a value; a corrupt manifest is reported as a failed check rather
+ *  than crashing the one tool whose job is to report it.
  */
 export async function doctorCommand(options: DoctorOptions): Promise<void> {
   if (options.verbose) setVerbose(true);
@@ -369,7 +316,6 @@ export async function doctorCommand(options: DoctorOptions): Promise<void> {
     };
     log(JSON.stringify(output, null, 2));
     process.exit(summary.errors > 0 ? 1 : 0);
-    return;
   }
 
   // Human-readable output

+ 93 - 0
packages/cli/src/commands/init.test.ts

@@ -0,0 +1,93 @@
+/**
+ * Tests for the pure planning half of `oac init`: which files a profile installs.
+ */
+import { describe, test, expect } from 'bun:test';
+import { planInstall, countByType, formatFileSummary } from './init.js';
+import { RegistrySchema } from '../lib/registry.js';
+
+const REGISTRY = RegistrySchema.parse({
+  version: '1.0.0',
+  components: {
+    agents: [{ id: 'openagent', name: 'OpenAgent', type: 'agent', path: '.opencode/agent/core/openagent.md', description: '' }],
+    contexts: [{ id: 'quick-start', name: 'Quick Start', type: 'context', path: '.opencode/context/core/quick-start.md', description: '' }],
+    skills: [
+      {
+        id: 'task-management',
+        name: 'Tasks',
+        type: 'skill',
+        path: '.opencode/skills/task-management/SKILL.md',
+        description: '',
+        files: ['.opencode/skills/task-management/SKILL.md', '.opencode/skills/task-management/router.sh'],
+      },
+    ],
+  },
+  profiles: {
+    essential: { name: 'Essential', components: ['agent:openagent', 'context:quick-start', 'skill:task-management', 'context:missing'] },
+    full: { name: 'Full', components: [] },
+  },
+});
+
+const BUNDLE = [
+  '.opencode/agent/core/openagent.md',
+  '.opencode/agent/content/copywriter.md',
+  '.opencode/context/core/quick-start.md',
+  '.opencode/context/ui/web.md',
+  '.opencode/skills/task-management/SKILL.md',
+  '.opencode/skills/task-management/router.sh',
+];
+
+describe('planInstall', () => {
+  test('essential resolves through the registry, not the whole bundle', () => {
+    const plan = planInstall(REGISTRY, BUNDLE, { profile: 'essential', contextOnly: false })!;
+    expect(plan.profileName).toBe('Essential');
+    expect(plan.files).toEqual([
+      '.opencode/agent/core/openagent.md',
+      '.opencode/context/core/quick-start.md',
+      '.opencode/skills/task-management/SKILL.md',
+      '.opencode/skills/task-management/router.sh',
+    ]);
+    expect(plan.unresolved).toEqual(['context:missing']);
+  });
+
+  test('full installs every bundled file', () => {
+    const plan = planInstall(REGISTRY, BUNDLE, { profile: 'full', contextOnly: false })!;
+    expect(plan.files).toEqual([...BUNDLE].sort());
+    expect(plan.unresolved).toEqual([]);
+  });
+
+  test('full --context-only keeps only bundled context files', () => {
+    const plan = planInstall(REGISTRY, BUNDLE, { profile: 'full', contextOnly: true })!;
+    expect(plan.files).toEqual(['.opencode/context/core/quick-start.md', '.opencode/context/ui/web.md']);
+  });
+
+  test('essential --context-only keeps only the profile context refs', () => {
+    const plan = planInstall(REGISTRY, BUNDLE, { profile: 'essential', contextOnly: true })!;
+    expect(plan.files).toEqual(['.opencode/context/core/quick-start.md']);
+  });
+
+  test('unknown profile yields null', () => {
+    expect(planInstall(REGISTRY, BUNDLE, { profile: 'nope', contextOnly: false })).toBeNull();
+  });
+});
+
+describe('countByType / formatFileSummary', () => {
+  test('counts every bundled kind', () => {
+    const counts = countByType([
+      '.opencode/agent/a.md',
+      '.opencode/context/c.md',
+      '.opencode/context/d.md',
+      '.opencode/skills/s/SKILL.md',
+      '.opencode/command/x.md',
+      '.opencode/tool/env/index.ts',
+      'env.example',
+    ]);
+    expect(counts).toEqual({ agent: 1, context: 2, skill: 1, command: 1, tool: 1, config: 1 });
+    expect(formatFileSummary(counts)).toBe(
+      '1 agent, 2 context files, 1 skill file, 1 command, 1 tool file, 1 config file',
+    );
+  });
+
+  test('empty list formats as 0 files', () => {
+    expect(formatFileSummary(countByType([]))).toBe('0 files');
+  });
+});

+ 235 - 96
packages/cli/src/commands/init.ts

@@ -1,12 +1,21 @@
 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 { isProjectRoot, installFiles, syncFiles, type InstallResult } from '../lib/installer.js';
+import { getPackageRoot, listBundledFiles, classifyBundledFile } from '../lib/bundled.js';
+import { readManifest, writeManifest, type ManifestFile } 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 {
+  loadRegistry,
+  listProfiles,
+  getProfile,
+  resolveProfile,
+  DEFAULT_PROFILE_ID,
+  FULL_PROFILE_ID,
+  type Registry,
+} from '../lib/registry.js';
+import { log, info, warn, error, success, setVerbose, verbose, dim } from '../ui/logger.js';
 import { createSpinner } from '../ui/spinner.js';
 
 // ── Types ─────────────────────────────────────────────────────────────────────
@@ -15,81 +24,154 @@ export type InitOptions = {
   yolo: boolean;
   dryRun: boolean;
   verbose: boolean;
+  /** Install tier from registry.json `profiles`. Defaults to `essential`. */
+  profile: string;
+  /** Keep only the profile's context files. */
+  contextOnly: boolean;
+  /** Print the available profiles and exit. */
+  listProfiles: boolean;
 };
 
-// ── Helpers ───────────────────────────────────────────────────────────────────
+/** The files `oac init` will copy, plus anything worth telling the user about the choice. */
+export type InstallPlan = {
+  profileId: string;
+  profileName: string;
+  files: string[];
+  unresolved: string[];
+  refused: string[];
+};
 
-/** 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,
-});
+// ── Pure helpers ──────────────────────────────────────────────────────────────
+
+const CONTEXT_PREFIX = '.opencode/context/';
+
+/** Counts files by bundled kind. Pure function. */
+export const countByType = (files: string[]): Record<string, number> =>
+  files.reduce<Record<string, number>>((acc, file) => {
+    const kind = classifyBundledFile(file);
+    return { ...acc, [kind]: (acc[kind] ?? 0) + 1 };
+  }, {});
+
+const LABELS: Record<string, [string, string]> = {
+  agent: ['agent', 'agents'],
+  context: ['context file', 'context files'],
+  skill: ['skill file', 'skill files'],
+  command: ['command', 'commands'],
+  tool: ['tool file', 'tool files'],
+  plugin: ['plugin', 'plugins'],
+  config: ['config file', 'config files'],
+};
 
 /** 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' : ''}`);
+export const formatFileSummary = (counts: Record<string, number>): string => {
+  const parts = Object.keys(LABELS)
+    .filter((kind) => (counts[kind] ?? 0) > 0)
+    .map((kind) => {
+      const n = counts[kind]!;
+      const [one, many] = LABELS[kind]!;
+      return `${n} ${n === 1 ? one : many}`;
+    });
   return parts.join(', ') || '0 files';
 };
 
+/**
+ * Decides which bundled files a profile installs.
+ *
+ * `full` is the bundle itself (every agent, context and skill file), so it needs no registry
+ * resolution. Every other profile is the closure of its `type:id` refs.
+ *
+ * Pure apart from reading the bundle listing, which the caller supplies.
+ */
+export const planInstall = (
+  registry: Registry,
+  bundledFiles: string[],
+  options: Pick<InitOptions, 'profile' | 'contextOnly'>,
+): InstallPlan | null => {
+  const profile = getProfile(registry, options.profile);
+  if (profile === null) return null;
+
+  if (options.profile === FULL_PROFILE_ID) {
+    const files = options.contextOnly
+      ? bundledFiles.filter((f) => f.startsWith(CONTEXT_PREFIX))
+      : bundledFiles;
+    return { profileId: options.profile, profileName: profile.name, files: [...files].sort(), unresolved: [], refused: [] };
+  }
+
+  const resolved = resolveProfile(registry, profile, { contextOnly: options.contextOnly });
+  return {
+    profileId: options.profile,
+    profileName: profile.name,
+    files: resolved.files,
+    unresolved: resolved.unresolved,
+    refused: resolved.refused,
+  };
+};
+
+// ── Output ────────────────────────────────────────────────────────────────────
+
+/** Prints every profile the bundle offers. Side-effect only. */
+const printProfiles = (registry: Registry): void => {
+  log('');
+  log('Available profiles (oac init --profile <id>):');
+  log('');
+  for (const p of listProfiles(registry)) {
+    const marker = p.id === DEFAULT_PROFILE_ID ? ' (default)' : '';
+    log(`  ${p.id}${marker} — ${p.name}, ${p.components.length} components`);
+    if (p.description) dim(`      ${p.description}`);
+  }
+  log('');
+};
+
 /** Prints the pre-install plan. Side-effect only. */
 const printPlan = (
-  bundledFiles: string[],
+  plan: InstallPlan,
   ides: Awaited<ReturnType<typeof detectIdes>>,
-  dryRun: boolean,
+  options: InitOptions,
 ): 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(options.dryRun ? '  [dry-run] oac init — no files will be written' : '  oac init');
   log('');
-  info(`Will install: ${formatFileSummary(counts)}`);
+  info(`Profile:      ${plan.profileId} (${plan.profileName})${options.contextOnly ? ', context files only' : ''}`);
+  info(`Will install: ${formatFileSummary(countByType(plan.files))}`);
   info(`Destination:  .opencode/ (relative to project root)`);
 
+  for (const ref of plan.unresolved) warn(`Profile lists '${ref}', which this bundle does not have — skipping.`);
+  for (const ref of plan.refused) warn(`Profile lists '${ref}', which lives outside .opencode/ — skipping.`);
+
   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');
+    info('No IDEs detected — run `oac apply claude` to generate Claude Code files');
   }
 
   log('');
 };
 
 /** Prints the post-install summary. Side-effect only. */
-const printSummary = (
-  installed: number,
-  skipped: number,
-  errors: number,
-  dryRun: boolean,
-): void => {
+const printSummary = (result: InstallResult, reinstall: boolean, dryRun: boolean): void => {
+  const written = result.installed.length + result.updated.length;
   log('');
   if (dryRun) {
-    info(`[dry-run] Would install ${installed} file${installed !== 1 ? 's' : ''}.`);
+    info(`[dry-run] Would install ${written} file${written !== 1 ? 's' : ''}.`);
+    if (result.skipped.length > 0) {
+      info(`[dry-run] Would skip ${result.skipped.length} (modified since install — use --yolo to overwrite).`);
+    }
     info('No changes were made. Remove --dry-run to apply.');
     return;
   }
-  if (errors > 0) {
-    warn(`Completed with ${errors} error${errors !== 1 ? 's' : ''}.`);
+  if (result.errors.length > 0) {
+    warn(`Completed with ${result.errors.length} error${result.errors.length !== 1 ? 's' : ''}.`);
+  }
+  if (reinstall && result.skipped.length > 0) {
+    warn(`Skipped ${result.skipped.length} file${result.skipped.length !== 1 ? 's' : ''} you have modified (use --yolo to back up and overwrite):`);
+    for (const f of result.skipped) dim(`    ${f}`);
   }
-  if (skipped > 0) {
-    info(`Skipped ${skipped} file${skipped !== 1 ? 's' : ''} (already modified — use --yolo to overwrite).`);
+  if (result.backed_up.length > 0) {
+    info(`Backed up ${result.backed_up.length} file${result.backed_up.length !== 1 ? 's' : ''} to .oac/backups/.`);
   }
-  success(
-    `Done! ${installed} file${installed !== 1 ? 's' : ''} installed. Run \`oac doctor\` to verify.`,
-  );
+  success(`Done! ${written} file${written !== 1 ? 's' : ''} installed. Run \`oac doctor\` to verify.`);
   log('');
 };
 
@@ -99,9 +181,7 @@ const printSummary = (
 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('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);
   }
@@ -127,89 +207,138 @@ const ensureConfig = async (projectRoot: string, dryRun: boolean): Promise<void>
   verbose('Wrote .oac/config.json with defaults.');
 };
 
+// ── Install step ──────────────────────────────────────────────────────────────
+
+type InstallOutcome = { result: InstallResult; updatedManifest: ManifestFile; reinstall: boolean };
+
+/**
+ * Fresh project: copy everything. Existing manifest: reconcile against it so a file the
+ * user edited is skipped, not clobbered, and `oac add` entries survive.
+ */
+const runInstall = async (
+  files: string[],
+  existing: ManifestFile | null,
+  projectRoot: string,
+  packageRoot: string,
+  options: InitOptions,
+): Promise<InstallOutcome> => {
+  const installOpts = {
+    projectRoot,
+    packageRoot,
+    dryRun: options.dryRun,
+    yolo: options.yolo,
+    verbose: options.verbose,
+  };
+
+  if (existing === null) {
+    const { result, updatedManifest } = await installFiles(files, installOpts);
+    return { result, updatedManifest, reinstall: false };
+  }
+
+  const { result, updatedManifest } = await syncFiles(files, existing, installOpts);
+  return { result, updatedManifest, reinstall: true };
+};
+
 // ── 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
+ *  2. Loads the bundled registry and resolves the chosen profile
+ *  3. Detects IDEs and prints the install plan
+ *  4. Copies the profile's files (reconciling against an existing manifest)
+ *  5. Writes .oac/manifest.json
+ *  6. Writes .oac/config.json (only if absent)
+ *  7. 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 };
+  const effectiveOptions: InitOptions = { ...options, yolo: options.yolo || process.env['CI'] === 'true' };
 
   if (effectiveOptions.verbose) setVerbose(true);
 
   const projectRoot = process.cwd();
 
-  // Step 1: validate project root
-  await assertProjectRoot(projectRoot);
-
-  // Step 2: locate bundled files
+  // Step 1: locate the bundle and its registry
   let packageRoot: string;
+  let registry: Registry;
   let bundledFiles: string[];
   try {
     packageRoot = getPackageRoot();
+    registry = await loadRegistry(packageRoot);
     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 @controlstack/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 @controlstack/oac package may be missing its bundled assets.');
+  if (effectiveOptions.listProfiles) {
+    printProfiles(registry);
+    process.exit(0);
+  }
+
+  // Step 2: validate project root
+  await assertProjectRoot(projectRoot);
+
+  // Step 3: resolve the profile
+  const plan = planInstall(registry, bundledFiles, effectiveOptions);
+  if (plan === null) {
+    const known = listProfiles(registry).map((p) => p.id).join(', ');
+    error(`Unknown profile '${effectiveOptions.profile}'. Available: ${known}.`);
+    error('Fix: run `oac init --list-profiles` to see what each one installs.');
     process.exit(1);
   }
 
-  // Step 3: detect IDEs and print plan
+  if (plan.files.length === 0) {
+    warn(`Profile '${plan.profileId}' resolves to no files — nothing to install.`);
+    process.exit(1);
+  }
+
+  // Step 4: detect IDEs and print plan
   const ides = await detectIdes(projectRoot);
-  printPlan(bundledFiles, ides, effectiveOptions.dryRun);
+  printPlan(plan, ides, effectiveOptions);
+
+  // Step 5: install files
+  const existing = await readManifest(projectRoot).catch((err: unknown): never => {
+    const msg = err instanceof Error ? err.message : String(err);
+    error(`Could not read .oac/manifest.json: ${msg}`);
+    error('Fix: repair the JSON, or delete .oac/manifest.json to start fresh.');
+    return process.exit(1);
+  });
+  if (existing !== null) {
+    info('Existing install found — files you have modified will be kept.');
+  }
 
-  // Step 4: install files
   const spinner = createSpinner('Installing files…', { dryRun: effectiveOptions.dryRun });
   spinner.start();
 
-  let installResult: Awaited<ReturnType<typeof installFiles>>;
+  let outcome: InstallOutcome;
   try {
-    installResult = await installFiles(bundledFiles, {
-      projectRoot,
-      packageRoot,
-      dryRun: effectiveOptions.dryRun,
-      yolo: effectiveOptions.yolo,
-      verbose: effectiveOptions.verbose,
-    });
+    outcome = await runInstall(plan.files, existing, projectRoot, packageRoot, effectiveOptions);
   } 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;
+  const { result, updatedManifest, reinstall } = outcome;
 
   // 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' : ''}.`);
+  const written = result.installed.length + result.updated.length;
+  spinner.succeed(`Installed ${written} file${written !== 1 ? 's' : ''}.`);
 
-  // Step 5: write manifest (skip in dry-run)
+  // Step 6: 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 };
+    const finalManifest = { ...updatedManifest, oacVersion: readCliVersion() };
 
     await writeManifest(projectRoot, finalManifest).catch((err: unknown) => {
       const msg = err instanceof Error ? err.message : String(err);
@@ -220,7 +349,7 @@ export async function initCommand(options: InitOptions): Promise<void> {
     verbose('Wrote .oac/manifest.json');
   }
 
-  // Step 6: write config (only if absent)
+  // Step 7: 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}`);
@@ -228,13 +357,8 @@ export async function initCommand(options: InitOptions): Promise<void> {
     process.exit(1) as never;
   });
 
-  // Step 7: print summary
-  printSummary(
-    result.installed.length,
-    result.skipped.length,
-    result.errors.length,
-    effectiveOptions.dryRun,
-  );
+  // Step 8: print summary
+  printSummary(result, reinstall, effectiveOptions.dryRun);
 
   // Exit 0 on success (explicit for clarity)
   process.exit(0);
@@ -250,14 +374,29 @@ 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('-p, --profile <id>', `Install tier: essential, developer, business, advanced or full`, DEFAULT_PROFILE_ID)
+    .option('--context-only', "Install only the profile's context files (no agents, skills or commands)", false)
+    .option('--list-profiles', 'Show the available profiles and exit', false)
+    .option('--yolo', 'Back up and overwrite files you have modified (re-runs only)', 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,
-      });
-    });
+    .action(
+      async (opts: {
+        profile: string;
+        contextOnly: boolean;
+        listProfiles: boolean;
+        yolo: boolean;
+        dryRun: boolean;
+        verbose: boolean;
+      }) => {
+        await initCommand({
+          profile: opts.profile,
+          contextOnly: opts.contextOnly,
+          listProfiles: opts.listProfiles,
+          yolo: opts.yolo,
+          dryRun: opts.dryRun,
+          verbose: opts.verbose,
+        });
+      },
+    );
 }

+ 34 - 0
packages/cli/src/lib/bundled.test.ts

@@ -9,6 +9,7 @@ import {
   listBundledFiles,
   bundledFileExists,
   type BundledFileType,
+  toManifestType,
 } from './bundled.js';
 
 // ── classifyBundledFile ───────────────────────────────────────────────────────
@@ -391,3 +392,36 @@ describe('bundledFileExists', () => {
     expect(exists).toBe(false);
   });
 });
+
+// ── classifyBundledFile — commands, tools, plugins ────────────────────────────
+
+describe('classifyBundledFile — profile kinds', () => {
+  test('returns "command" for .opencode/command/ paths', () => {
+    expect(classifyBundledFile('.opencode/command/clean.md')).toBe('command');
+  });
+
+  test('returns "tool" for .opencode/tool/ paths', () => {
+    expect(classifyBundledFile('.opencode/tool/env/index.ts')).toBe('tool');
+  });
+
+  test('returns "plugin" for .opencode/plugin/ paths', () => {
+    expect(classifyBundledFile('.opencode/plugin/notify.ts')).toBe('plugin');
+  });
+
+  test('returns "config" for the env template', () => {
+    expect(classifyBundledFile('env.example')).toBe('config');
+  });
+});
+
+describe('toManifestType', () => {
+  test('folds registry kinds into the manifest vocabulary', () => {
+    expect(toManifestType('agent')).toBe('agent');
+    expect(toManifestType('subagent')).toBe('agent');
+    expect(toManifestType('context')).toBe('context');
+    expect(toManifestType('skill')).toBe('skill');
+    expect(toManifestType('config')).toBe('config');
+    expect(toManifestType('command')).toBe('other');
+    expect(toManifestType('tool')).toBe('other');
+    expect(toManifestType('plugin')).toBe('other');
+  });
+});

+ 40 - 1
packages/cli/src/lib/bundled.ts

@@ -6,7 +6,20 @@ import { fileURLToPath } from "node:url";
 // --- Types ---
 
 /** The category of a bundled file, inferred from its path prefix. */
-export type BundledFileType = "agent" | "context" | "skill" | "config";
+export type BundledFileType =
+  | "agent"
+  | "context"
+  | "skill"
+  | "command"
+  | "tool"
+  | "plugin"
+  | "config";
+
+/**
+ * What the manifest records for a bundled file. The manifest's vocabulary is narrower than
+ * the bundle's (it predates commands, tools and plugins), so those fold into "other".
+ */
+export type ManifestType = "agent" | "context" | "skill" | "config" | "other";
 
 // --- Constants ---
 
@@ -171,6 +184,9 @@ export const bundledFileExists = async (
  * - `.opencode/agent/...`   → "agent"
  * - `.opencode/context/...` → "context"
  * - `.opencode/skills/...`  → "skill"
+ * - `.opencode/command/...` → "command"
+ * - `.opencode/tool/...`    → "tool"
+ * - `.opencode/plugin/...`  → "plugin"
  * - anything else           → "config"
  *
  * Pure function — no I/O.
@@ -179,5 +195,28 @@ 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";
+  if (relativePath.startsWith(".opencode/command/")) return "command";
+  if (relativePath.startsWith(".opencode/tool/")) return "tool";
+  if (relativePath.startsWith(".opencode/plugin/")) return "plugin";
   return "config";
 };
+
+/**
+ * Maps a bundled-file kind (or a registry component type) onto the manifest's vocabulary.
+ * Pure function — no I/O.
+ */
+export const toManifestType = (kind: string): ManifestType => {
+  switch (kind) {
+    case "agent":
+    case "subagent":
+      return "agent";
+    case "context":
+      return "context";
+    case "skill":
+      return "skill";
+    case "config":
+      return "config";
+    default:
+      return "other";
+  }
+};

+ 149 - 0
packages/cli/src/lib/doctor-checks.test.ts

@@ -0,0 +1,149 @@
+import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
+import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import {
+  ideCheckResult,
+  ideCheckResults,
+  manifestUnavailable,
+  overallStatus,
+  safeReadManifest,
+  summariseResults,
+  type CheckResult,
+} from './doctor-checks.js';
+import { createEmptyManifest, writeManifest } from './manifest.js';
+
+// ── safeReadManifest ──────────────────────────────────────────────────────────
+
+describe('safeReadManifest', () => {
+  let missingRoot: string;
+  let corruptRoot: string;
+  let invalidRoot: string;
+  let validRoot: string;
+
+  beforeAll(async () => {
+    missingRoot = await mkdtemp(join(tmpdir(), 'oac-doctor-missing-'));
+
+    corruptRoot = await mkdtemp(join(tmpdir(), 'oac-doctor-corrupt-'));
+    await mkdir(join(corruptRoot, '.oac'), { recursive: true });
+    await writeFile(join(corruptRoot, '.oac', 'manifest.json'), '{ not json', 'utf8');
+
+    invalidRoot = await mkdtemp(join(tmpdir(), 'oac-doctor-invalid-'));
+    await mkdir(join(invalidRoot, '.oac'), { recursive: true });
+    await writeFile(join(invalidRoot, '.oac', 'manifest.json'), '{"files": "nope"}', 'utf8');
+
+    validRoot = await mkdtemp(join(tmpdir(), 'oac-doctor-valid-'));
+    await writeManifest(validRoot, createEmptyManifest('1.1.0'));
+  });
+
+  afterAll(async () => {
+    await Promise.all(
+      [missingRoot, corruptRoot, invalidRoot, validRoot].map((d) =>
+        rm(d, { recursive: true, force: true }),
+      ),
+    );
+  });
+
+  test('absent manifest is ok with null', async () => {
+    expect(await safeReadManifest(missingRoot)).toEqual({ ok: true, manifest: null });
+  });
+
+  test('unparseable JSON is a value, not an exception', async () => {
+    const read = await safeReadManifest(corruptRoot);
+    expect(read.ok).toBe(false);
+    if (!read.ok) expect(read.error.length).toBeGreaterThan(0);
+  });
+
+  test('schema-invalid manifest is a value, not an exception', async () => {
+    const read = await safeReadManifest(invalidRoot);
+    expect(read.ok).toBe(false);
+    if (!read.ok) expect(read.error).toContain('Invalid manifest');
+  });
+
+  test('valid manifest is returned', async () => {
+    const read = await safeReadManifest(validRoot);
+    expect(read.ok).toBe(true);
+    if (read.ok) expect(read.manifest?.files).toEqual({});
+  });
+});
+
+// ── manifestUnavailable ───────────────────────────────────────────────────────
+
+describe('manifestUnavailable', () => {
+  test('invalid manifest becomes an error check', () => {
+    const result = manifestUnavailable('Files on disk', { ok: false, error: 'boom' });
+    expect(result?.status).toBe('error');
+    expect(result?.message).toContain('invalid');
+  });
+
+  test('missing manifest becomes an error check', () => {
+    const result = manifestUnavailable('Files on disk', { ok: true, manifest: null });
+    expect(result?.status).toBe('error');
+    expect(result?.message).toContain('missing');
+  });
+
+  test('valid manifest lets the check proceed', () => {
+    const result = manifestUnavailable('Files on disk', {
+      ok: true,
+      manifest: createEmptyManifest('1.1.0'),
+    });
+    expect(result).toBeNull();
+  });
+});
+
+// ── IDE lines ─────────────────────────────────────────────────────────────────
+
+describe('ideCheckResult', () => {
+  test('OpenCode present is ok and never tells the user to apply', () => {
+    const result = ideCheckResult({
+      type: 'opencode',
+      detected: true,
+      indicator: '.opencode/ directory',
+    });
+    expect(result.status).toBe('ok');
+    expect(result.message).not.toContain('oac apply opencode');
+  });
+
+  test('Claude present is informational and points at the valid target', () => {
+    const result = ideCheckResult({ type: 'claude', detected: true, indicator: 'CLAUDE.md file' });
+    expect(result.status).toBe('info');
+    expect(result.message).toContain("oac apply claude");
+  });
+
+  test('a fresh install with only .opencode/ yields no warnings', () => {
+    const lines = ideCheckResults([
+      { type: 'opencode', detected: true, indicator: '.opencode/ directory' },
+      { type: 'claude', detected: false, indicator: '.claude/ directory or CLAUDE.md (not found)' },
+    ]);
+    expect(lines).toHaveLength(1);
+    expect(summariseResults(lines).warnings).toBe(0);
+  });
+
+  test('nothing detected yields a single hint', () => {
+    const lines = ideCheckResults([
+      { type: 'opencode', detected: false, indicator: '' },
+      { type: 'claude', detected: false, indicator: '' },
+    ]);
+    expect(lines).toHaveLength(1);
+    expect(lines[0]?.status).toBe('info');
+  });
+});
+
+// ── Summary ───────────────────────────────────────────────────────────────────
+
+describe('summariseResults / overallStatus', () => {
+  const mk = (status: CheckResult['status']): CheckResult => ({ name: 'x', status, message: '' });
+
+  test('info counts as ok', () => {
+    expect(summariseResults([mk('ok'), mk('info')])).toEqual({ ok: 2, warnings: 0, errors: 0 });
+    expect(overallStatus(summariseResults([mk('ok'), mk('info')]))).toBe('healthy');
+  });
+
+  test('a warning downgrades to warning', () => {
+    expect(overallStatus(summariseResults([mk('ok'), mk('warn')]))).toBe('warning');
+  });
+
+  test('an error wins over warnings', () => {
+    expect(overallStatus(summariseResults([mk('warn'), mk('error')]))).toBe('error');
+  });
+});

+ 113 - 0
packages/cli/src/lib/doctor-checks.ts

@@ -0,0 +1,113 @@
+import { readManifest, type ManifestFile } from './manifest.js';
+import { getIdeDisplayName, type DetectedIde } from './ide-detect.js';
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export type CheckStatus = 'ok' | 'warn' | 'error' | 'info';
+
+export type CheckResult = {
+  name: string;
+  status: CheckStatus;
+  message: string;
+  detail?: string[];
+};
+
+export type DoctorSummary = {
+  ok: number;
+  warnings: number;
+  errors: number;
+};
+
+export type OverallStatus = 'healthy' | 'warning' | 'error';
+
+/** Outcome of reading the manifest without letting a corrupt file escape as an exception. */
+export type SafeManifest =
+  | { ok: true; manifest: ManifestFile | null }
+  | { ok: false; error: string };
+
+// ── Manifest ──────────────────────────────────────────────────────────────────
+
+/**
+ * Reads the manifest and turns every failure into a value.
+ *
+ * `readManifest` throws on a corrupt file, which is the one condition `oac doctor` exists to
+ * report — so the diagnostic must never die on it. `manifest: null` means the file is absent.
+ */
+export const safeReadManifest = async (projectRoot: string): Promise<SafeManifest> => {
+  try {
+    return { ok: true, manifest: await readManifest(projectRoot) };
+  } catch (err) {
+    return { ok: false, error: err instanceof Error ? err.message : String(err) };
+  }
+};
+
+/**
+ * The check result for a manifest-dependent check when the manifest could not be used.
+ * Returns null when the manifest is present and valid, i.e. the check should proceed.
+ */
+export const manifestUnavailable = (name: string, read: SafeManifest): CheckResult | null => {
+  if (!read.ok) {
+    return { name, status: 'error', message: `Cannot check — manifest is invalid` };
+  }
+  if (read.manifest === null) {
+    return { name, status: 'error', message: `Cannot check — manifest is missing` };
+  }
+  return null;
+};
+
+// ── IDE lines ─────────────────────────────────────────────────────────────────
+
+/**
+ * Maps one detected tool to its doctor line. Pure.
+ *
+ * OpenCode reads `.opencode/` directly, so its presence is the healthy state and there is
+ * nothing to apply — a correct fresh install must not produce a warning here. Claude Code
+ * files are reported as information: `oac apply claude` writes CLAUDE.md, but nothing marks
+ * a hand-written one, so the tool cannot honestly say whether it is stale.
+ */
+export const ideCheckResult = (ide: DetectedIde): CheckResult => {
+  const label = getIdeDisplayName(ide.type);
+  if (ide.type === 'opencode') {
+    return {
+      name: `IDE: ${label}`,
+      status: 'ok',
+      message: `.opencode/ present — nothing to apply`,
+    };
+  }
+  return {
+    name: `IDE: ${label}`,
+    status: 'info',
+    message: `${label} detected (${ide.indicator}) — run 'oac apply claude' to regenerate CLAUDE.md from your agents`,
+  };
+};
+
+/** Doctor lines for every detected tool, or a single hint when none is present. Pure. */
+export const ideCheckResults = (ides: DetectedIde[]): CheckResult[] => {
+  const detected = ides.filter((ide) => ide.detected);
+  if (detected.length === 0) {
+    return [
+      {
+        name: 'IDE detection',
+        status: 'info',
+        message: "No tools detected — run 'oac init' to install, then 'oac apply claude' for Claude Code",
+      },
+    ];
+  }
+  return detected.map(ideCheckResult);
+};
+
+// ── Summary ───────────────────────────────────────────────────────────────────
+
+/** Computes summary counts from check results. Pure. */
+export 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 from a summary. Pure. */
+export const overallStatus = (summary: DoctorSummary): OverallStatus => {
+  if (summary.errors > 0) return 'error';
+  if (summary.warnings > 0) return 'warning';
+  return 'healthy';
+};

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

@@ -556,3 +556,89 @@ describe('isProjectRoot', () => {
     expect(result).toBe(false);
   });
 });
+
+// ── syncFiles — the merge step `oac init` re-runs use ────────────────────────
+
+import { syncFiles } from './installer.js';
+import { readManifest as readManifestFile } from './manifest.js';
+
+describe('syncFiles — re-running init against an existing manifest', () => {
+  let projectRoot: string;
+  let packageRoot: string;
+  const bundledPath = '.opencode/context/core/quick-start.md';
+  const editedPath = '.opencode/context/core/standards/code-quality.md';
+  const addedPath = '.opencode/context/extra/react-patterns.md';
+
+  beforeAll(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), 'oac-sync-'));
+    packageRoot = await mkdtemp(join(tmpdir(), 'oac-sync-pkg-'));
+
+    await setupPackageRoot(packageRoot, bundledPath, '# Quick start v2');
+    await setupPackageRoot(packageRoot, editedPath, '# Code quality v2');
+
+    // First install: both files land untouched, plus one `oac add` entry not in this profile.
+    await setupPackageRoot(projectRoot, bundledPath, '# Quick start v1');
+    await setupPackageRoot(projectRoot, editedPath, '# Code quality v1');
+    await setupPackageRoot(projectRoot, addedPath, '# React');
+
+    let manifest = createEmptyManifest('1.0.0');
+    manifest = addFileToManifest(manifest, bundledPath, makeEntry(await computeFileHash(join(projectRoot, bundledPath)), { type: 'context' }));
+    manifest = addFileToManifest(manifest, editedPath, makeEntry(await computeFileHash(join(projectRoot, editedPath)), { type: 'context' }));
+    manifest = addFileToManifest(manifest, addedPath, makeEntry(await computeFileHash(join(projectRoot, addedPath)), { type: 'context', source: 'registry' }));
+    await writeManifest(projectRoot, manifest);
+
+    // The user edits one file after install.
+    await writeFile(join(projectRoot, editedPath), '# Code quality v1 — my edits', 'utf8');
+  });
+
+  afterAll(async () => {
+    await rm(projectRoot, { recursive: true, force: true });
+    await rm(packageRoot, { recursive: true, force: true });
+  });
+
+  test('refreshes untouched files, skips edited ones, keeps foreign manifest entries', async () => {
+    const manifest = await readManifestFile(projectRoot);
+    const { result, updatedManifest } = await syncFiles(
+      [bundledPath, editedPath],
+      manifest,
+      makeOptions(projectRoot, packageRoot),
+    );
+
+    expect(result.updated).toEqual([bundledPath]);
+    expect(result.skipped).toEqual([editedPath]);
+    expect(result.installed).toEqual([]);
+    expect(result.removed_from_manifest).toEqual([]);
+
+    expect(await Bun.file(join(projectRoot, bundledPath)).text()).toBe('# Quick start v2');
+    expect(await Bun.file(join(projectRoot, editedPath)).text()).toBe('# Code quality v1 — my edits');
+
+    // The `oac add` entry survives the re-init.
+    expect(updatedManifest.files[addedPath]).toBeDefined();
+    expect(updatedManifest.files[addedPath]!.source).toBe('registry');
+    // The skipped file keeps its old hash entry (still tracked, still "modified").
+    expect(updatedManifest.files[editedPath]!.sha256).toBe(manifest!.files[editedPath]!.sha256);
+  });
+
+  test('with yolo, backs up the edited file and overwrites it', async () => {
+    const manifest = await readManifestFile(projectRoot);
+    const { result } = await syncFiles([editedPath], manifest, makeOptions(projectRoot, packageRoot, { yolo: true }));
+
+    expect(result.updated).toEqual([editedPath]);
+    expect(result.backed_up).toHaveLength(1);
+    expect(result.backed_up[0]).toContain(join('.oac', 'backups'));
+    expect(await Bun.file(join(projectRoot, editedPath)).text()).toBe('# Code quality v2');
+    expect(await Bun.file(result.backed_up[0]!).text()).toBe('# Code quality v1 — my edits');
+  });
+
+  test('installs a file the manifest has never seen', async () => {
+    const newPath = '.opencode/command/clean.md';
+    await setupPackageRoot(packageRoot, newPath, '# clean');
+    const manifest = await readManifestFile(projectRoot);
+
+    const { result, updatedManifest } = await syncFiles([newPath], manifest, makeOptions(projectRoot, packageRoot));
+
+    expect(result.installed).toEqual([newPath]);
+    expect(updatedManifest.files[newPath]!.type).toBe('other');
+    expect(await Bun.file(join(projectRoot, newPath)).exists()).toBe(true);
+  });
+});

+ 42 - 23
packages/cli/src/lib/installer.ts

@@ -9,7 +9,7 @@ import {
   readManifest,
   createEmptyManifest,
 } from "./manifest.js";
-import { listBundledFiles, getBundledFilePath, classifyBundledFile } from "./bundled.js";
+import { listBundledFiles, getBundledFilePath, classifyBundledFile, toManifestType } from "./bundled.js";
 
 // ── Types ─────────────────────────────────────────────────────────────────────
 
@@ -219,7 +219,7 @@ async function processOneFile(
   const decision = decisionResult.value;
 
   const now = new Date().toISOString();
-  const fileType = classifyBundledFile(relativePath);
+  const fileType = toManifestType(classifyBundledFile(relativePath));
 
   try {
     if (decision.action === "install") {
@@ -305,7 +305,7 @@ export async function installFiles(
         const sha256 = options.dryRun ? "" : await computeFileHash(destPath);
         const entry: FileEntry = {
           sha256,
-          type: classifyBundledFile(relativePath),
+          type: toManifestType(classifyBundledFile(relativePath)),
           source: "bundled",
           installedAt: now,
         };
@@ -337,28 +337,22 @@ export async function installFiles(
 }
 
 /**
- * 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
+ * Reconciles an explicit list of bundled files against the manifest: new files are
+ * installed, untouched files are refreshed, user-modified files are skipped (or backed up
+ * and overwritten with --yolo). Entries already in the manifest but not in `files` are left
+ * alone — this is the merge step `oac init` re-runs and `oac update` share.
  *
  * Does NOT write the manifest — caller is responsible.
  */
-export async function updateFiles(
+export async function syncFiles(
+  files: string[],
+  manifest: ManifestFile | null,
   options: InstallOptions,
 ): Promise<{ result: InstallResult; updatedManifest: ManifestFile }> {
-  const manifest = await readManifest(options.projectRoot);
-  const bundledFiles = await listBundledFiles(options.packageRoot);
   const timestamp = buildTimestamp();
 
-  // Phase 1: process each file in the new bundle (parallel)
-  const phase1Results = await Promise.all(
-    bundledFiles.map(async (relativePath) => {
+  const outcomes = await Promise.all(
+    files.map(async (relativePath) => {
       const sourcePath = getBundledFilePath(options.packageRoot, relativePath);
       const destPath = path.join(options.projectRoot, relativePath);
       const { patch, entry } = await processOneFile({
@@ -373,19 +367,44 @@ export async function updateFiles(
     }),
   );
 
-  const phase1 = phase1Results.reduce(
+  return outcomes.reduce(
     (acc, { relativePath, patch, entry }) => ({
       result: mergeResult(acc.result, patch),
-      workingManifest:
+      updatedManifest:
         entry !== null
-          ? addFileToManifest(acc.workingManifest, relativePath, entry)
-          : acc.workingManifest,
+          ? addFileToManifest(acc.updatedManifest, relativePath, entry)
+          : acc.updatedManifest,
     }),
     {
       result: { ...EMPTY_RESULT } as InstallResult,
-      workingManifest: manifest ?? createEmptyManifest("0.0.0"),
+      updatedManifest: manifest ?? createEmptyManifest("0.0.0"),
     },
   );
+}
+
+/**
+ * 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);
+
+  // Phase 1: reconcile every file in the new bundle
+  const phase1 = await syncFiles(bundledFiles, manifest, options).then(
+    ({ result, updatedManifest }) => ({ result, workingManifest: updatedManifest }),
+  );
 
   // Phase 2: handle files in manifest that are no longer in the bundle
   const bundledSet = new Set(bundledFiles);

+ 243 - 0
packages/cli/src/lib/registry.test.ts

@@ -0,0 +1,243 @@
+/**
+ * Tests for the pure registry helpers: ref resolution, profile resolution and the
+ * "never write outside .opencode/" guard. No I/O.
+ */
+import { describe, test, expect } from 'bun:test';
+import {
+  RegistrySchema,
+  resolveComponent,
+  resolveProfile,
+  listProfiles,
+  getProfile,
+  filesForComponent,
+  isInstallablePath,
+  listComponents,
+  expandRef,
+  type Registry,
+} from './registry.js';
+
+// ── Fixture ───────────────────────────────────────────────────────────────────
+
+const component = (type: string, id: string, path: string, extra: Record<string, unknown> = {}) => ({
+  id,
+  name: id,
+  type,
+  path,
+  description: `${id} description`,
+  ...extra,
+});
+
+const FIXTURE: Registry = RegistrySchema.parse({
+  version: '1.0.0',
+  components: {
+    agents: [component('agent', 'openagent', '.opencode/agent/core/openagent.md')],
+    subagents: [component('subagent', 'contextscout', '.opencode/agent/subagents/core/contextscout.md')],
+    contexts: [
+      component('context', 'standards-code', '.opencode/context/core/standards/code-quality.md', { aliases: ['code-standards'] }),
+      component('context', 'quick-start', '.opencode/context/core/quick-start.md'),
+      component('context', 'paths', '.opencode/context/core/config/paths.json'),
+      component('context', 'web-ui', '.opencode/context/ui/web.md'),
+    ],
+    skills: [
+      component('skill', 'task-management', '.opencode/skills/task-management/SKILL.md', {
+        files: [
+          '.opencode/skills/task-management/SKILL.md',
+          '.opencode/skills/task-management/router.sh',
+          '.opencode/skills/task-management/scripts/task-cli.ts',
+        ],
+      }),
+    ],
+    commands: [component('command', 'clean', '.opencode/command/clean.md')],
+    tools: [component('tool', 'env', '.opencode/tool/env/index.ts')],
+    plugins: [],
+    config: [
+      component('config', 'env-example', 'env.example'),
+      component('config', 'readme', 'README.md'),
+    ],
+  },
+  profiles: {
+    essential: {
+      name: 'Essential',
+      description: 'Minimal',
+      components: [
+        'agent:openagent',
+        'subagent:contextscout',
+        'context:standards-code',
+        'context:quick-start',
+        'skill:task-management',
+        'command:clean',
+        'tool:env',
+        'config:env-example',
+        'config:readme',
+        'context:does-not-exist',
+        'agent:openagent',
+      ],
+    },
+    full: { name: 'Full', components: [] },
+  },
+});
+
+// ── Schema ────────────────────────────────────────────────────────────────────
+
+describe('RegistrySchema', () => {
+  test('accepts every component kind and the profiles map', () => {
+    expect(listComponents(FIXTURE)).toHaveLength(11);
+    expect(listProfiles(FIXTURE).map((p) => p.id)).toEqual(['essential', 'full']);
+  });
+
+  test('defaults profiles to an empty map when absent', () => {
+    const parsed = RegistrySchema.parse({ version: '1', components: {} });
+    expect(parsed.profiles).toEqual({});
+    expect(listComponents(parsed)).toEqual([]);
+  });
+});
+
+// ── resolveComponent ──────────────────────────────────────────────────────────
+
+describe('resolveComponent', () => {
+  test('resolves every kind by type:id', () => {
+    expect(resolveComponent(FIXTURE, 'agent:openagent')?.path).toBe('.opencode/agent/core/openagent.md');
+    expect(resolveComponent(FIXTURE, 'subagent:contextscout')?.type).toBe('subagent');
+    expect(resolveComponent(FIXTURE, 'command:clean')?.type).toBe('command');
+    expect(resolveComponent(FIXTURE, 'tool:env')?.type).toBe('tool');
+    expect(resolveComponent(FIXTURE, 'config:env-example')?.path).toBe('env.example');
+  });
+
+  test('returns null for unknown ids, unknown types and malformed refs', () => {
+    expect(resolveComponent(FIXTURE, 'context:nope')).toBeNull();
+    expect(resolveComponent(FIXTURE, 'widget:clean')).toBeNull();
+    expect(resolveComponent(FIXTURE, 'clean')).toBeNull();
+    expect(resolveComponent(FIXTURE, ':clean')).toBeNull();
+    expect(resolveComponent(FIXTURE, 'agent:')).toBeNull();
+  });
+
+  test('does not find an id under the wrong type', () => {
+    expect(resolveComponent(FIXTURE, 'agent:contextscout')).toBeNull();
+  });
+});
+
+// ── filesForComponent / isInstallablePath ─────────────────────────────────────
+
+describe('filesForComponent', () => {
+  test('returns files[] when present', () => {
+    const skill = resolveComponent(FIXTURE, 'skill:task-management')!;
+    expect(filesForComponent(skill)).toHaveLength(3);
+    expect(filesForComponent(skill)).toContain('.opencode/skills/task-management/router.sh');
+  });
+
+  test('falls back to path when files[] is absent', () => {
+    const ctx = resolveComponent(FIXTURE, 'context:quick-start')!;
+    expect(filesForComponent(ctx)).toEqual(['.opencode/context/core/quick-start.md']);
+  });
+});
+
+describe('isInstallablePath', () => {
+  test('allows anything under .opencode/ and the env template', () => {
+    expect(isInstallablePath('.opencode/agent/x.md')).toBe(true);
+    expect(isInstallablePath('.opencode/tool/env/index.ts')).toBe(true);
+    expect(isInstallablePath('env.example')).toBe(true);
+  });
+
+  test('refuses project-level files', () => {
+    expect(isInstallablePath('README.md')).toBe(false);
+    expect(isInstallablePath('package.json')).toBe(false);
+    expect(isInstallablePath('.opencode')).toBe(false);
+  });
+});
+
+// ── resolveProfile ────────────────────────────────────────────────────────────
+
+describe('resolveProfile', () => {
+  const essential = getProfile(FIXTURE, 'essential')!;
+
+  test('expands refs to files, including every file of a multi-file skill', () => {
+    const { files } = resolveProfile(FIXTURE, essential);
+    expect(files).toContain('.opencode/agent/core/openagent.md');
+    expect(files).toContain('.opencode/agent/subagents/core/contextscout.md');
+    expect(files).toContain('.opencode/skills/task-management/router.sh');
+    expect(files).toContain('.opencode/skills/task-management/scripts/task-cli.ts');
+    expect(files).toContain('.opencode/command/clean.md');
+    expect(files).toContain('.opencode/tool/env/index.ts');
+    expect(files).toContain('env.example');
+  });
+
+  test('dedupes repeated refs and returns a sorted list', () => {
+    const { files } = resolveProfile(FIXTURE, essential);
+    expect(files.filter((f) => f.endsWith('openagent.md'))).toHaveLength(1);
+    expect(files).toEqual([...files].sort());
+  });
+
+  test('reports unknown refs instead of throwing', () => {
+    const { unresolved } = resolveProfile(FIXTURE, essential);
+    expect(unresolved).toEqual(['context:does-not-exist']);
+  });
+
+  test('refuses refs that would write outside .opencode/', () => {
+    const { refused, files } = resolveProfile(FIXTURE, essential);
+    expect(refused).toEqual(['config:readme']);
+    expect(files).not.toContain('README.md');
+  });
+
+  test('contextOnly keeps only context refs', () => {
+    const { files, unresolved } = resolveProfile(FIXTURE, essential, { contextOnly: true });
+    expect(files).toEqual([
+      '.opencode/context/core/quick-start.md',
+      '.opencode/context/core/standards/code-quality.md',
+    ]);
+    expect(unresolved).toEqual(['context:does-not-exist']);
+  });
+
+  test('an empty profile resolves to nothing', () => {
+    const { files, unresolved, refused } = resolveProfile(FIXTURE, getProfile(FIXTURE, 'full')!);
+    expect(files).toEqual([]);
+    expect(unresolved).toEqual([]);
+    expect(refused).toEqual([]);
+  });
+});
+
+// ── path-shaped refs, aliases and wildcards ───────────────────────────────────
+
+describe('resolveComponent — path-shaped context refs and aliases', () => {
+  test('resolves a context by its path under .opencode/context/, with or without .md', () => {
+    expect(resolveComponent(FIXTURE, 'context:core/standards/code-quality')?.id).toBe('standards-code');
+    expect(resolveComponent(FIXTURE, 'context:core/config/paths.json')?.id).toBe('paths');
+  });
+
+  test('resolves an alias', () => {
+    expect(resolveComponent(FIXTURE, 'context:code-standards')?.id).toBe('standards-code');
+  });
+
+  test('a path that is not in the registry is null', () => {
+    expect(resolveComponent(FIXTURE, 'context:core/nope')).toBeNull();
+  });
+});
+
+describe('expandRef', () => {
+  test('a plain ref expands to one component', () => {
+    expect(expandRef(FIXTURE, 'agent:openagent').map((c) => c.id)).toEqual(['openagent']);
+    expect(expandRef(FIXTURE, 'agent:nope')).toEqual([]);
+  });
+
+  test('context:core/* expands to every context under that directory, recursively', () => {
+    const ids = expandRef(FIXTURE, 'context:core/*').map((c) => c.id).sort();
+    expect(ids).toEqual(['paths', 'quick-start', 'standards-code']);
+  });
+
+  test('a narrower glob only matches its subtree', () => {
+    expect(expandRef(FIXTURE, 'context:core/standards/*').map((c) => c.id)).toEqual(['standards-code']);
+    expect(expandRef(FIXTURE, 'context:nothing/*')).toEqual([]);
+  });
+});
+
+describe('resolveProfile — wildcards', () => {
+  test('a wildcard ref contributes every matching file and is not reported as unresolved', () => {
+    const profile = { name: 'W', description: '', components: ['context:core/*', 'context:missing/*'] };
+    const { files, unresolved } = resolveProfile(FIXTURE, profile);
+    expect(files).toEqual([
+      '.opencode/context/core/config/paths.json',
+      '.opencode/context/core/quick-start.md',
+      '.opencode/context/core/standards/code-quality.md',
+    ]);
+    expect(unresolved).toEqual(['context:missing/*']);
+  });
+});

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

@@ -6,57 +6,64 @@ import { join } from "node:path";
 
 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;
+/** The profile `oac init` installs when none is named. */
+export const DEFAULT_PROFILE_ID = "essential";
 
-/** 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 one profile that is NOT resolved through the registry: it means "everything the
+ * bundle ships", so `oac init --profile full` keeps copying the whole tree.
+ */
+export const FULL_PROFILE_ID = "full";
 
 /**
- * The component types that the CLI can install via `oac add`.
- * Matches the user-facing ref prefix: `agent:X`, `context:X`, `skill:X`.
+ * Component paths must live under `.opencode/` to be installed. The only exception is the
+ * environment template, which is harmless to drop next to the user's project. Anything else
+ * at the project root (`README.md`, for example) would overwrite a file the user owns.
  */
-export const ComponentTypeSchema = z.enum(["agent", "context", "skill"]);
+const PROJECT_LEVEL_ALLOWLIST: ReadonlySet<string> = new Set(["env.example"]);
+const INSTALL_PREFIX = ".opencode/";
+
+// ── Schemas ────────────────────────────────────────────────────────────────────
 
 /**
- * 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.
+ * Every component kind the registry knows and the CLI can install.
+ * Matches the user-facing ref prefix: `agent:X`, `context:X`, `skill:X`, `command:X`, ...
  */
+export const ComponentTypeSchema = z.enum([
+  "agent",
+  "subagent",
+  "context",
+  "skill",
+  "command",
+  "tool",
+  "plugin",
+  "config",
+]);
+
+/** A single component entry from registry.json. */
 export const RegistryComponentSchema = z.object({
   id: z.string(),
   name: z.string(),
   type: ComponentTypeSchema,
   path: z.string(),
-  description: z.string(),
+  description: z.string().default(""),
   tags: z.array(z.string()).default([]),
   dependencies: z.array(z.string()).default([]),
   category: z.string().default("standard"),
-  /** Skills may list multiple files to install. */
+  /** Multi-file components (skills) list every file to install; `path` is the primary one. */
   files: z.array(z.string()).optional(),
+  /** Alternative ids a ref may use (legacy names kept for old profiles). */
+  aliases: 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(),
+/** An install tier: a name, a blurb, and the `type:id` refs it pulls in. */
+export const RegistryProfileSchema = z.object({
   name: z.string(),
-  type: z.string(),
-  path: z.string(),
-  description: z.string(),
-}).passthrough();
+  description: z.string().default(""),
+  components: z.array(z.string()).default([]),
+});
+
+const componentList = z.array(RegistryComponentSchema).default([]);
 
 export const RegistrySchema = z.object({
   version: z.string(),
@@ -64,51 +71,83 @@ export const RegistrySchema = z.object({
   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([]),
+    agents: componentList,
+    subagents: componentList,
+    contexts: componentList,
+    skills: componentList,
+    commands: componentList,
+    tools: componentList,
+    plugins: componentList,
+    config: componentList,
   }),
+  profiles: z.record(z.string(), RegistryProfileSchema).default({}),
 });
 
 // ── Types ──────────────────────────────────────────────────────────────────────
 
 export type ComponentType = z.infer<typeof ComponentTypeSchema>;
 export type RegistryComponent = z.infer<typeof RegistryComponentSchema>;
+export type RegistryProfile = z.infer<typeof RegistryProfileSchema>;
 export type Registry = z.infer<typeof RegistrySchema>;
 
+/** A profile with its id, for listing. */
+export type ProfileSummary = RegistryProfile & { id: string };
+
+/** What a profile resolves to on disk. */
+export type ProfileResolution = {
+  /** Relative paths to install, deduplicated and sorted. */
+  files: string[];
+  /** The components those files came from. */
+  components: RegistryComponent[];
+  /** `type:id` refs the registry does not know. Reported, never fatal. */
+  unresolved: string[];
+  /** Refs skipped because their path would land outside `.opencode/`. */
+  refused: string[];
+};
+
 // ── Path helpers ───────────────────────────────────────────────────────────────
 
 /** Returns the absolute path to registry.json given the package root. */
 export const getRegistryPath = (packageRoot: string): string =>
   join(packageRoot, REGISTRY_FILENAME);
 
+/** True when a component path is somewhere `oac` is allowed to write. Pure. */
+export const isInstallablePath = (relativePath: string): boolean =>
+  relativePath.startsWith(INSTALL_PREFIX) || PROJECT_LEVEL_ALLOWLIST.has(relativePath);
+
+/** Every file a component installs: its `files[]` when listed, else its `path`. Pure. */
+export const filesForComponent = (component: RegistryComponent): string[] =>
+  component.files !== undefined && component.files.length > 0
+    ? component.files
+    : [component.path];
+
 // ── Pure query helpers ─────────────────────────────────────────────────────────
 
+const SECTION_FOR_TYPE: Record<ComponentType, keyof Registry["components"]> = {
+  agent: "agents",
+  subagent: "subagents",
+  context: "contexts",
+  skill: "skills",
+  command: "commands",
+  tool: "tools",
+  plugin: "plugins",
+  config: "config",
+};
+
 /**
- * Returns all installable components (agents + skills + contexts) from the
- * registry, optionally filtered to a single type.
+ * Returns every component in the registry, optionally filtered to one 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);
+  if (type !== undefined) return registry.components[SECTION_FOR_TYPE[type]];
+  return ComponentTypeSchema.options.flatMap((t) => registry.components[SECTION_FOR_TYPE[t]]);
 };
 
 /**
- * Alias matching the acceptance criteria name.
- * Filters installable components by type string.
+ * Filters components by a type string; unknown types yield an empty list.
  * Pure — no side effects.
  */
 export const listComponentsByType = (
@@ -120,8 +159,44 @@ export const listComponentsByType = (
   return listComponents(registry, parsed.data);
 };
 
+/** Splits `type:id` into its parts, or null when the shape is wrong. Pure. */
+const parseRef = (ref: string): { type: ComponentType; id: string } | 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;
+
+  return { type: typeResult.data, id };
+};
+
+/** Where each component kind's files live, for path-shaped and wildcard refs. */
+const BASE_DIR: Record<ComponentType, string> = {
+  agent: ".opencode/agent/",
+  subagent: ".opencode/agent/",
+  context: ".opencode/context/",
+  skill: ".opencode/skills/",
+  command: ".opencode/command/",
+  tool: ".opencode/tool/",
+  plugin: ".opencode/plugin/",
+  config: "",
+};
+
+/** True when the component answers to `id`, by id or alias. Pure. */
+const hasId = (component: RegistryComponent, id: string): boolean =>
+  component.id === id || (component.aliases?.includes(id) ?? false);
+
 /**
  * Resolves a `type:name` ref (e.g. `"context:react-patterns"`) to a component.
+ *
+ * A context ref may also be path-shaped (`context:core/standards/code-quality`), the form
+ * older profiles and `install.sh` use: it matches the component whose path is that file
+ * under `.opencode/context/`, with or without a `.md` extension.
+ *
  * Returns `null` — never throws — when the component is not found or the ref
  * format is invalid.
  * Pure — no side effects.
@@ -130,35 +205,101 @@ export const resolveComponent = (
   registry: Registry,
   ref: string,
 ): RegistryComponent | null => {
-  const colonIndex = ref.indexOf(":");
-  if (colonIndex === -1) return null;
+  const parsed = parseRef(ref);
+  if (parsed === null) return null;
 
-  const rawType = ref.slice(0, colonIndex);
-  const id = ref.slice(colonIndex + 1);
-  if (!rawType || !id) return null;
+  const candidates = listComponents(registry, parsed.type);
+  const byId = candidates.find((c) => hasId(c, parsed.id));
+  if (byId !== undefined) return byId;
 
-  const typeResult = ComponentTypeSchema.safeParse(rawType);
-  if (!typeResult.success) return null;
-
-  const candidates = listComponents(registry, typeResult.data);
-  return candidates.find((c) => c.id === id) ?? null;
+  if (!parsed.id.includes("/")) return null;
+  const asPath = `${BASE_DIR[parsed.type]}${parsed.id}`;
+  return candidates.find((c) => c.path === `${asPath}.md` || c.path === asPath) ?? null;
 };
 
 /**
- * Returns the directory (relative to project root) where a component should
- * be installed.
+ * Expands one profile ref to the components it names.
+ *
+ * `context:core/*` means every context whose path sits under `.opencode/context/core/`.
+ * Only a trailing `*` is understood; that is the only shape the shipped profiles use.
+ * A ref without a wildcard expands to at most one component.
+ *
  * Pure — no side effects.
  */
-export const getInstallPath = (component: RegistryComponent): string =>
-  INSTALL_DIRS[component.type];
+export const expandRef = (registry: Registry, ref: string): RegistryComponent[] => {
+  const parsed = parseRef(ref);
+  if (parsed === null) return [];
+
+  if (!parsed.id.endsWith("*")) {
+    const one = resolveComponent(registry, ref);
+    return one === null ? [] : [one];
+  }
+
+  const prefix = `${BASE_DIR[parsed.type]}${parsed.id.slice(0, -1)}`;
+  return listComponents(registry, parsed.type).filter((c) => c.path.startsWith(prefix));
+};
+
+// ── Profiles ───────────────────────────────────────────────────────────────────
+
+/** Every profile the registry declares, sorted by id. Pure. */
+export const listProfiles = (registry: Registry): ProfileSummary[] =>
+  Object.entries(registry.profiles)
+    .map(([id, profile]) => ({ id, ...profile }))
+    .sort((a, b) => a.id.localeCompare(b.id));
+
+/** A profile by id, or null. Pure. */
+export const getProfile = (registry: Registry, id: string): RegistryProfile | null =>
+  registry.profiles[id] ?? null;
 
 /**
- * Returns the directory (relative to the npm bundle / package root) where the
- * component's source files live.
+ * Turns a profile into the list of files to install.
+ *
+ * Refs may be ids, path-shaped context refs, or trailing-wildcard globs (see {@link expandRef}).
+ * Unknown refs are collected, not thrown: a stale profile entry should cost the user one
+ * warning line, not the whole install. Refs whose path would land outside `.opencode/` are
+ * refused for the same reason `oac` never writes a project's README.
+ *
  * Pure — no side effects.
  */
-export const getBundledSourcePath = (component: RegistryComponent): string =>
-  BUNDLE_DIRS[component.type];
+export const resolveProfile = (
+  registry: Registry,
+  profile: RegistryProfile,
+  options: { contextOnly?: boolean } = {},
+): ProfileResolution => {
+  const wanted = options.contextOnly
+    ? profile.components.filter((ref) => ref.startsWith("context:"))
+    : profile.components;
+
+  const seen = new Set<string>();
+  const resolution: ProfileResolution = { files: [], components: [], unresolved: [], refused: [] };
+
+  for (const ref of wanted) {
+    const components = expandRef(registry, ref);
+    if (components.length === 0) {
+      resolution.unresolved.push(ref);
+      continue;
+    }
+
+    for (const component of components) {
+      const files = filesForComponent(component);
+      if (!files.every(isInstallablePath)) {
+        resolution.refused.push(ref);
+        continue;
+      }
+
+      resolution.components.push(component);
+      for (const file of files) {
+        if (!seen.has(file)) {
+          seen.add(file);
+          resolution.files.push(file);
+        }
+      }
+    }
+  }
+
+  resolution.files.sort();
+  return resolution;
+};
 
 // ── I/O ────────────────────────────────────────────────────────────────────────
 

+ 191 - 196
registry.json

@@ -3287,6 +3287,20 @@
         ],
         "dependencies": [],
         "category": "essential"
+      },
+      {
+        "id": "oac-cli-install",
+        "name": "OAC CLI Install Guide",
+        "type": "context",
+        "path": ".opencode/context/openagents-repo/guides/oac-cli-install.md",
+        "description": "How users install and manage OAC with the oac CLI: init, profiles, add, update, doctor, and the maintainer build loop",
+        "tags": [
+          "cli",
+          "install",
+          "profiles"
+        ],
+        "dependencies": [],
+        "category": "standard"
       }
     ],
     "config": [
@@ -3304,20 +3318,6 @@
         "dependencies": [],
         "category": "essential"
       },
-      {
-        "id": "agent-metadata",
-        "name": "Agent Metadata",
-        "type": "config",
-        "path": ".opencode/config/agent-metadata.json",
-        "description": "Centralized agent metadata used for runtime resolution, registry management, and dependency discovery",
-        "tags": [
-          "config",
-          "agents",
-          "metadata"
-        ],
-        "dependencies": [],
-        "category": "essential"
-      },
       {
         "id": "readme",
         "name": "README",
@@ -3335,35 +3335,111 @@
     ]
   },
   "profiles": {
-    "essential": {
-      "name": "Essential (Minimal)",
-      "description": "Minimal starter kit - universal agent with core subagents. Great for learning the system or lightweight tasks. Upgrade to Developer or Business for full features.",
+    "advanced": {
+      "name": "Advanced (Meta-Level)",
+      "description": "Full installation plus System Builder for creating custom AI architectures. Includes comprehensive context system, system builder templates, and repository management tools. For power users and contributors.",
       "components": [
+        "agent:copywriter",
+        "agent:data-analyst",
+        "agent:eval-runner",
         "agent:openagent",
-        "subagent:task-manager",
+        "agent:opencoder",
+        "agent:repo-manager",
+        "agent:system-builder",
+        "agent:technical-writer",
+        "command:add-context",
+        "command:analyze-patterns",
+        "command:build-context-system",
+        "command:clean",
+        "command:commit",
+        "command:context",
+        "command:optimize",
+        "command:prompt-enhancer",
+        "command:test",
+        "command:validate-repo",
+        "command:worktrees",
+        "config:env-example",
+        "config:readme",
+        "context:adding-skill-basics",
+        "context:animation-advanced",
+        "context:animation-basics",
+        "context:animation-components",
+        "context:api-design",
+        "context:clean-code",
+        "context:codebase-references",
+        "context:context-system",
+        "context:core/*",
+        "context:core/context-system/*",
+        "context:design-systems",
+        "context:development/*",
+        "context:essential-patterns",
+        "context:frontmatter",
+        "context:openagents-repo/*",
+        "context:orchestrator-template",
+        "context:project-context",
+        "context:project-intelligence/*",
+        "context:quick-start",
+        "context:react-patterns",
+        "context:registry-dependencies",
+        "context:subagent-template",
+        "context:system-builder-guide",
+        "context:templates",
+        "context:ui-styling-standards",
+        "context:ui/*",
+        "plugin:notify",
+        "skill:task-management",
+        "subagent:agent-generator",
+        "subagent:build-agent",
+        "subagent:coder-agent",
+        "subagent:command-creator",
+        "subagent:context-organizer",
+        "subagent:context-retriever",
         "subagent:contextscout",
+        "subagent:devops-specialist",
         "subagent:documentation",
-        "skill:task-management",
-        "command:context",
+        "subagent:domain-analyzer",
+        "subagent:frontend-specialist",
+        "subagent:image-specialist",
+        "subagent:reviewer",
+        "subagent:task-manager",
+        "subagent:tester",
+        "subagent:workflow-designer",
+        "tool:env",
+        "tool:gemini"
+      ],
+      "additionalPaths": [
+        ".Building/",
+        ".github/workflows/"
+      ]
+    },
+    "business": {
+      "name": "Business",
+      "description": "Business process automation, content creation, and visual workflows. Includes image generation, notifications, and documentation tools.",
+      "components": [
+        "agent:copywriter",
+        "agent:data-analyst",
+        "agent:openagent",
+        "agent:technical-writer",
         "command:add-context",
         "command:clean",
-        "tool:env",
+        "command:context",
+        "command:prompt-enhancer",
+        "config:env-example",
+        "config:readme",
+        "context:adding-skill-basics",
+        "context:core/*",
         "context:essential-patterns",
         "context:project-context",
+        "context:project-intelligence/*",
         "context:quick-start",
-        "context:standards-code",
-        "context:standards-patterns",
-        "context:standards-tests",
-        "context:standards-docs",
-        "context:standards-analysis",
-        "context:task-delegation-basics",
-        "context:session-management",
-        "context:feature-breakdown",
-        "context:workflows-review",
-        "context:system-context-guide",
-        "context:adding-skill-basics",
-        "config:env-example",
-        "config:agent-metadata"
+        "plugin:notify",
+        "skill:task-management",
+        "subagent:contextscout",
+        "subagent:documentation",
+        "subagent:image-specialist",
+        "subagent:task-manager",
+        "tool:env",
+        "tool:gemini"
       ]
     },
     "developer": {
@@ -3373,210 +3449,129 @@
       "components": [
         "agent:openagent",
         "agent:opencoder",
-        "subagent:task-manager",
-        "subagent:frontend-specialist",
-        "subagent:devops-specialist",
-        "subagent:documentation",
-        "subagent:coder-agent",
-        "subagent:reviewer",
-        "subagent:tester",
-        "subagent:build-agent",
-        "subagent:contextscout",
-        "skill:task-management",
-        "command:commit",
-        "command:test",
-        "command:context",
         "command:add-context",
+        "command:analyze-patterns",
         "command:clean",
+        "command:commit",
+        "command:context",
         "command:optimize",
+        "command:test",
         "command:validate-repo",
-        "command:analyze-patterns",
-        "tool:env",
+        "config:env-example",
+        "config:readme",
+        "context:adding-skill-basics",
+        "context:animation-advanced",
+        "context:animation-basics",
+        "context:animation-components",
+        "context:api-design",
+        "context:clean-code",
+        "context:core/*",
+        "context:design-systems",
+        "context:development/*",
         "context:essential-patterns",
+        "context:openagents-repo/*",
         "context:project-context",
-        "context:quick-start",
-        "context:core/*",
         "context:project-intelligence/*",
-        "context:adding-skill-basics",
-        "context:ui/*",
-        "context:development/*",
-        "context:design-systems",
+        "context:quick-start",
         "context:react-patterns",
-        "context:animation-basics",
-        "context:animation-components",
-        "context:animation-advanced",
         "context:ui-styling-standards",
-        "context:clean-code",
-        "context:api-design",
-        "config:env-example",
-        "config:agent-metadata",
-        "config:readme",
-        "context:openagents-repo/*"
+        "context:ui/*",
+        "skill:task-management",
+        "subagent:build-agent",
+        "subagent:coder-agent",
+        "subagent:contextscout",
+        "subagent:devops-specialist",
+        "subagent:documentation",
+        "subagent:frontend-specialist",
+        "subagent:reviewer",
+        "subagent:task-manager",
+        "subagent:tester",
+        "tool:env"
       ]
     },
-    "business": {
-      "name": "Business",
-      "description": "Business process automation, content creation, and visual workflows. Includes image generation, notifications, and documentation tools.",
+    "essential": {
+      "name": "Essential (Minimal)",
+      "description": "Minimal starter kit - universal agent with core subagents. Great for learning the system or lightweight tasks. Upgrade to Developer or Business for full features.",
       "components": [
         "agent:openagent",
-        "agent:copywriter",
-        "agent:technical-writer",
-        "agent:data-analyst",
-        "subagent:task-manager",
-        "subagent:contextscout",
-        "subagent:documentation",
-        "subagent:image-specialist",
-        "skill:task-management",
-        "command:context",
         "command:add-context",
         "command:clean",
-        "command:prompt-enhancer",
-        "tool:env",
-        "tool:gemini",
-        "plugin:notify",
+        "command:context",
+        "config:env-example",
+        "context:adding-skill-basics",
         "context:essential-patterns",
+        "context:feature-breakdown",
         "context:project-context",
         "context:quick-start",
-        "context:core/*",
-        "context:project-intelligence/*",
-        "context:adding-skill-basics",
-        "config:env-example",
-        "config:agent-metadata",
-        "config:readme"
+        "context:session-management",
+        "context:standards-analysis",
+        "context:standards-code",
+        "context:standards-docs",
+        "context:standards-patterns",
+        "context:standards-tests",
+        "context:system-context-guide",
+        "context:task-delegation-basics",
+        "context:workflows-review",
+        "skill:task-management",
+        "subagent:contextscout",
+        "subagent:documentation",
+        "subagent:task-manager",
+        "tool:env"
       ]
     },
     "full": {
       "name": "Full",
       "description": "Everything included - all agents, subagents, tools, and plugins for maximum functionality. Includes UI/UX patterns, development principles, and design systems.",
       "components": [
+        "agent:copywriter",
+        "agent:data-analyst",
+        "agent:eval-runner",
         "agent:openagent",
         "agent:opencoder",
-        "agent:eval-runner",
-        "agent:copywriter",
         "agent:technical-writer",
-        "agent:data-analyst",
-        "subagent:task-manager",
-        "subagent:frontend-specialist",
-        "subagent:devops-specialist",
-        "subagent:documentation",
-        "subagent:coder-agent",
-        "subagent:reviewer",
-        "subagent:tester",
-        "subagent:build-agent",
-        "subagent:contextscout",
-        "skill:task-management",
-        "subagent:image-specialist",
-        "command:test",
-        "command:commit",
-        "command:context",
         "command:add-context",
+        "command:analyze-patterns",
         "command:clean",
+        "command:commit",
+        "command:context",
         "command:optimize",
         "command:prompt-enhancer",
-        "command:worktrees",
+        "command:test",
         "command:validate-repo",
-        "command:analyze-patterns",
-        "tool:env",
-        "tool:gemini",
-        "plugin:notify",
+        "command:worktrees",
+        "config:env-example",
+        "config:readme",
+        "context:adding-skill-basics",
+        "context:animation-advanced",
+        "context:animation-basics",
+        "context:animation-components",
+        "context:api-design",
+        "context:clean-code",
+        "context:core/*",
+        "context:design-systems",
+        "context:development/*",
         "context:essential-patterns",
+        "context:openagents-repo/*",
         "context:project-context",
-        "context:quick-start",
-        "context:core/*",
         "context:project-intelligence/*",
-        "context:adding-skill-basics",
-        "context:ui/*",
-        "context:development/*",
-        "context:design-systems",
+        "context:quick-start",
         "context:react-patterns",
-        "context:animation-basics",
-        "context:animation-components",
-        "context:animation-advanced",
         "context:ui-styling-standards",
-        "context:clean-code",
-        "context:api-design",
-        "config:env-example",
-        "config:agent-metadata",
-        "config:readme",
-        "context:openagents-repo/*"
-      ]
-    },
-    "advanced": {
-      "name": "Advanced (Meta-Level)",
-      "description": "Full installation plus System Builder for creating custom AI architectures. Includes comprehensive context system, system builder templates, and repository management tools. For power users and contributors.",
-      "components": [
-        "agent:openagent",
-        "agent:opencoder",
-        "agent:system-builder",
-        "agent:repo-manager",
-        "agent:eval-runner",
-        "agent:copywriter",
-        "agent:technical-writer",
-        "agent:data-analyst",
-        "subagent:task-manager",
-        "subagent:frontend-specialist",
+        "context:ui/*",
+        "plugin:notify",
+        "skill:task-management",
+        "subagent:build-agent",
+        "subagent:coder-agent",
+        "subagent:contextscout",
         "subagent:devops-specialist",
         "subagent:documentation",
-        "subagent:coder-agent",
+        "subagent:frontend-specialist",
+        "subagent:image-specialist",
         "subagent:reviewer",
+        "subagent:task-manager",
         "subagent:tester",
-        "subagent:build-agent",
-        "subagent:image-specialist",
-        "subagent:context-retriever",
-        "subagent:contextscout",
-        "skill:task-management",
-        "subagent:domain-analyzer",
-        "subagent:agent-generator",
-        "subagent:context-organizer",
-        "subagent:workflow-designer",
-        "subagent:command-creator",
-        "command:test",
-        "command:commit",
-        "command:context",
-        "command:add-context",
-        "command:clean",
-        "command:optimize",
-        "command:prompt-enhancer",
-        "command:worktrees",
-        "command:build-context-system",
-        "command:validate-repo",
-        "command:analyze-patterns",
         "tool:env",
-        "tool:gemini",
-        "plugin:notify",
-        "context:essential-patterns",
-        "context:project-context",
-        "context:quick-start",
-        "context:core/*",
-        "context:project-intelligence/*",
-        "context:adding-skill-basics",
-        "context:ui/*",
-        "context:development/*",
-        "context:design-systems",
-        "context:react-patterns",
-        "context:animation-basics",
-        "context:animation-components",
-        "context:animation-advanced",
-        "context:ui-styling-standards",
-        "context:clean-code",
-        "context:api-design",
-        "context:system-builder-guide",
-        "context:orchestrator-template",
-        "context:subagent-template",
-        "context:openagents-repo/*",
-        "context:context-system",
-        "context:core/context-system/*",
-        "context:registry-dependencies",
-        "context:templates",
-        "context:frontmatter",
-        "context:codebase-references",
-        "config:env-example",
-        "config:agent-metadata",
-        "config:readme"
-      ],
-      "additionalPaths": [
-        ".Building/",
-        ".github/workflows/"
+        "tool:gemini"
       ]
     }
   },