Prechádzať 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 2 týždňov pred
rodič
commit
73400e256f
31 zmenil súbory, kde vykonal 1673 pridanie a 737 odobranie
  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"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/code/build-agent.md": {
     ".opencode/agent/subagents/code/build-agent.md": {
-      "sha256": "cd0f60cc1da2713986bdecd0486b9443a8cf9ee4a2fa1e3b62c0e873c0beac3c",
+      "sha256": "1f61e83d854c57d422a52f89c2e680bb6d601cbcce3828abf3f551b8e507b1e9",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/code/coder-agent.md": {
     ".opencode/agent/subagents/code/coder-agent.md": {
-      "sha256": "6c5d0e5c55e0d0f08692102825d5e90f9ce062bcc09ef03b4db682b0f5350820",
+      "sha256": "87bb97e02c7a6276c36785dfceb7b280af212fec26ca4fdb07e89e0439d818b2",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/code/reviewer.md": {
     ".opencode/agent/subagents/code/reviewer.md": {
-      "sha256": "9c1cf8a94d905149fd4e8fc1355232138277e79310bdc580fae845f575953b02",
+      "sha256": "65bd1f8c1c7804cea7580eedfb47adac39a0da04a447b924b0979eb6d48a144b",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/code/test-engineer.md": {
     ".opencode/agent/subagents/code/test-engineer.md": {
-      "sha256": "0fdd7971cf6deae6f4278df6f65d319c53b2e0304fa7dd15d33255342223978e",
+      "sha256": "a2f5a6904f7d35a7f347d47490d02320fe1dfffe2d911855faca1945328f6202",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
@@ -62,12 +62,12 @@
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/core/context-manager.md": {
     ".opencode/agent/subagents/core/context-manager.md": {
-      "sha256": "718bac8e8383a28c566fd68e9dcf797aa9ca273112bffc08755e955b7403aa50",
+      "sha256": "2211bf59259c2e6a8ef3542e6a2fb826520a49f0c35e65dbdffadde60cc394ef",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/core/context-retriever.md": {
     ".opencode/agent/subagents/core/context-retriever.md": {
-      "sha256": "98992bc55a901237d23425667fbea61c0621bbbd3cf647ade01c639e5454ca46",
+      "sha256": "19cef5fc00434cf14061915a957480ffbaf4e3c898919ad4fac350cdf5f04527",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
@@ -77,12 +77,12 @@
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/core/documentation.md": {
     ".opencode/agent/subagents/core/documentation.md": {
-      "sha256": "b21eb4bf84e13eaae14627adf8790c25d2dce9204b256c749c2154b4933a8e70",
+      "sha256": "2dcfb7e65c1a24ddcd9f32a6f0568b53cf7223026eb967c578586c28cca46301",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/core/externalscout.md": {
     ".opencode/agent/subagents/core/externalscout.md": {
-      "sha256": "400043e08b543cf5f7c3db6a1fab6fcd3222835f1a3dd1a153b08d71faddc944",
+      "sha256": "53c8108b7e02bbf67fd8eeeb7cc31f9315d6b3a2ff8c0c4466c52dd775d499b2",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
@@ -97,17 +97,17 @@
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/development/devops-specialist.md": {
     ".opencode/agent/subagents/development/devops-specialist.md": {
-      "sha256": "f8d739ccabcd2ca5628b88c9e4e38d16e1487d6a56274bfc4e1b05c73af0a9c3",
+      "sha256": "f6dc9bbccb154e78e9efe62f1576dbd353d968f6512b7043c170661fa56e8dde",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/development/frontend-specialist.md": {
     ".opencode/agent/subagents/development/frontend-specialist.md": {
-      "sha256": "00637285291b6ebf280a847ab2536007b08f5fa7d58556bedc6e2e517eb0ecfb",
+      "sha256": "b404eafe11b46cdab04e75fad9a99ee58ee864e60a82e1a655f4644146194deb",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/planning/adr-manager.md": {
     ".opencode/agent/subagents/planning/adr-manager.md": {
-      "sha256": "a936cae7f5becd35c8f7399b73d335a2d451e90af56c7b2a95afe81498abb61d",
+      "sha256": "dce68ca574f628e885df344f44b9b68a98dcf605a155918fbc0b1986c23653e2",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
@@ -122,7 +122,7 @@
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/planning/prioritization-engine.md": {
     ".opencode/agent/subagents/planning/prioritization-engine.md": {
-      "sha256": "72ccb101c0e94a99eb5960e142b1604baae2123ef151d09dccb37a85aa469717",
+      "sha256": "0622e2fbce7b0def55b34ccb6296c2c12fca68794a2801a870ca36fd49033ebf",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
@@ -142,7 +142,7 @@
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/system-builder/context-organizer.md": {
     ".opencode/agent/subagents/system-builder/context-organizer.md": {
-      "sha256": "067032b5ad9a18dbe2e3d17479352a73e57b7d77e95ec8d09cff0a675481eed3",
+      "sha256": "fbcf3dd6d5a6ea26a48be0d9820a59b801661be06456d67a0423579a24eb0047",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
@@ -152,7 +152,7 @@
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".opencode/agent/subagents/system-builder/workflow-designer.md": {
     ".opencode/agent/subagents/system-builder/workflow-designer.md": {
-      "sha256": "21a17801bdee71f64e3f98bdceb4649c0f2ace630c09a8bcdfffc3524276f00c",
+      "sha256": "2e11d4d8165a273ae33fa1ea344737a5ea18ee35f1aa7e52ea4bc5990a522c3f",
       "target": "opencode",
       "target": "opencode",
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
@@ -167,17 +167,17 @@
       "root": ".opencode/agent"
       "root": ".opencode/agent"
     },
     },
     ".tmp/oac-build/plugins/claude-code/agents/code-reviewer.md": {
     ".tmp/oac-build/plugins/claude-code/agents/code-reviewer.md": {
-      "sha256": "985896d8123330aa28ccb45fa23583de44c416a39f1cee09b282eb15b56b04f1",
+      "sha256": "5a6f85a83ff22b90e154c3b53f601850a9f6bb4a2d6c828eabfc007c374eeea5",
       "target": "claude-code",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     },
     ".tmp/oac-build/plugins/claude-code/agents/coder-agent.md": {
     ".tmp/oac-build/plugins/claude-code/agents/coder-agent.md": {
-      "sha256": "92d0db161d9682ae2188c2a532c4b3b0928dcde816532a77dff1e52c74bb2d00",
+      "sha256": "f9a95528bac2a77fea6dcd12a4dac88f076c236acafd45ece4ec34651428baf0",
       "target": "claude-code",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     },
     ".tmp/oac-build/plugins/claude-code/agents/context-manager.md": {
     ".tmp/oac-build/plugins/claude-code/agents/context-manager.md": {
-      "sha256": "61f07dd83ca55afb6d72cc58094f6753aa71dfb84505fe628343badfd4310b98",
+      "sha256": "8d0837575c3875dba08fe76276080b89abd7447eaafa2ef923da3984d343179c",
       "target": "claude-code",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     },
@@ -187,7 +187,7 @@
       "root": ".tmp/oac-build/plugins/claude-code/agents"
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     },
     ".tmp/oac-build/plugins/claude-code/agents/external-scout.md": {
     ".tmp/oac-build/plugins/claude-code/agents/external-scout.md": {
-      "sha256": "9656405d4fa8e81397f9b0228770abe8bae63f44cc9a5ff2d66999534d428b7a",
+      "sha256": "de921f643ffaa8a11148da2a8d70120e1ab694d054a6b499b033f0552f1d0181",
       "target": "claude-code",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     },
@@ -197,12 +197,12 @@
       "root": ".tmp/oac-build/plugins/claude-code/agents"
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     },
     ".tmp/oac-build/plugins/claude-code/agents/test-engineer.md": {
     ".tmp/oac-build/plugins/claude-code/agents/test-engineer.md": {
-      "sha256": "01332fdd48dcf47008d150c42ffe794366b14ce5f05babf9fd94c0001d43d811",
+      "sha256": "8e0cc825358255957173783972774d353313e883f89d10699dba3042b600ff31",
       "target": "claude-code",
       "target": "claude-code",
       "root": ".tmp/oac-build/plugins/claude-code/agents"
       "root": ".tmp/oac-build/plugins/claude-code/agents"
     },
     },
     "registry.json": {
     "registry.json": {
-      "sha256": "d149d8a5d30641136919c88775935fff68bb2cadbb02e2ea8dcc1d03b94b1346",
+      "sha256": "eb030beea4d06f3d46617b06959a2cbfa7189d2ea71fd3650fed9ff8fc6ed435",
       "target": "registry",
       "target": "registry",
       "root": "registry.json"
       "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
 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
 ## What NOT to Do
 
 
 - ❌ **Don't skip ContextScout** — build validation without project standards = running wrong commands
 - ❌ **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
 - ❌ **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>
   <context_first>ContextScout before any validation — understand project conventions first</context_first>
   <detect_first>Language detection before any commands — never assume</detect_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>
   <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)
 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
 ## Workflow
 
 
 ### Step 1: Read Subtask JSON
 ### 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
 - 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
 ## Principles
 
 
 - Context first, code second. Always.
 - 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
 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
 ## What NOT to Do
 
 
 - ❌ **Don't skip ContextScout** — reviewing without project standards = generic feedback that misses project-specific issues
 - ❌ **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
 - ❌ **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>
   <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>
   <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>
   <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
 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]
    - ✅ Positive: [expected success outcome]
    - ❌ Negative: [expected failure/edge case handling]
    - ❌ Negative: [expected failure/edge case handling]
    - ✅ Positive: [expected success outcome]
    - ✅ 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
 - ❌ **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>
   <context_first>ContextScout before any test writing — conventions matter</context_first>
   <tdd_mindset>Think about testability before implementation — tests define behavior</tdd_mindset>
   <tdd_mindset>Think about testability before implementation — tests define behavior</tdd_mindset>
   <deterministic>Tests must be reliable — no flakiness, no external dependencies</deterministic>
   <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>
 <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
 ## 📋 Process Flow
@@ -230,10 +226,6 @@ permission:
 </process_flow>
 </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">
   <parameter name="request_type" type="enum">
     Type of context management request:
     Type of context management request:
     - "discover": Discover and map context structure
     - "discover": Discover and map context structure
@@ -360,10 +352,6 @@ permission:
 </output_specification>
 </output_specification>
 
 
 ---
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
   <pre_execution>
   <pre_execution>
     - Verify request_type is valid
     - Verify request_type is valid
     - Verify scope exists or is "all"
     - Verify scope exists or is "all"
@@ -422,10 +410,6 @@ permission:
 </context_management_principles>
 </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
 ## 📝 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!
 **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}
 **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)
 ### 🎯 Primary Results (Must Read)
@@ -308,10 +304,6 @@ Always structure your response in this format:
 - {what to do with this information}
 - {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)
 ### 📚 Secondary Results (Should Read)
@@ -327,10 +319,6 @@ Always structure your response in this format:
 **Why Read This**: {brief explanation of value}
 **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
 ## 📋 Summary
@@ -390,10 +378,6 @@ read(filePath="{discovered-standards-file}")
 **Files Searched**: 12
 **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
     └── best-practices.md ⭐ FOUND
 ---
 ---
 
 
@@ -422,10 +406,6 @@ read(filePath="{discovered-standards-file}")
 - Follow naming conventions for consistency
 - 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
 ### 📍 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
 ### 📋 Summary
@@ -597,20 +573,12 @@ Repository lacks structured context
 **Files Searched**: 0
 **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
 ### 📍 Context Structure Discovered
 {show structure}
 {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})
 ### 📍 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
 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
 ## 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
 - ❌ **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>
   <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>
   <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>
   <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>
 </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">
   <tier level="1" desc="Critical Operations">
     - @check_cache_first: Check .tmp/external-context/ before fetching
     - @check_cache_first: Check .tmp/external-context/ before fetching
     - @tool_usage: Use ONLY allowed tools
     - @tool_usage: Use ONLY allowed tools
@@ -236,10 +232,6 @@ permission:
 </workflow_execution>
 </workflow_execution>
 
 
 ---
 ---
-# OpenCode Agent Configuration
-# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:
-# .opencode/config/agent-metadata.json
-
 ---
 ---
 
 
 ## Quick Reference
 ## 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
 **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
     ├── cloudflare-deployment.md
     ├── server-functions.md
     ├── server-functions.md
     └── file-routing.md
     └── file-routing.md
@@ -291,10 +279,6 @@ If Context7 API fails:
 3. Suggest checking `.opencode/context/` for cached docs
 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
 ## Success Criteria
@@ -314,7 +298,3 @@ You succeed when ALL of these are complete:
 - Return summary without file locations
 - 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
 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
 ## 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
 - ❌ **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>
   <pre_flight>
     - ContextScout called and standards loaded
     - ContextScout called and standards loaded
     - Parent agent requirements clear
     - 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)
 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
 ## Workflow
@@ -142,10 +138,6 @@ task(subagent_type="ContextScout", description="Find frontend design standards",
 4. Present: "Updated design saved. Previous version preserved."
 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>
 <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
 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
 ## 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
 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
 ## 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
 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
 ## 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
 - ❌ **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 -->
   <!-- Context system operations routed from /context command -->
   <operation name="harvest">
   <operation name="harvest">
     Load: .opencode/context/core/context-system/operations/harvest.md
     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
 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
 ## 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
 - ❌ **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>
   <simple_pattern>
     Linear execution with validation:
     Linear execution with validation:
     1. Validate inputs → 2. Execute main task → 3. Validate outputs → 4. Deliver results
     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
 # OpenAgents Control Repository Context
 
 
@@ -15,7 +15,7 @@
 | **Standards** | 2 files | Agent creation standards |
 | **Standards** | 2 files | Agent creation standards |
 | **Concepts** | 6 files | Core ideas and principles |
 | **Concepts** | 6 files | Core ideas and principles |
 | **Examples** | 9 files | Working code samples |
 | **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 |
 | **Lookup** | 11 files | Quick reference tables |
 | **Errors** | 2 files | Common issues + solutions |
 | **Errors** | 2 files | Common issues + solutions |
 | **Features** | 3 files | Feature documentation and refactoring |
 | **Features** | 3 files | Feature documentation and refactoring |
@@ -64,6 +64,7 @@
 
 
 | File | Topic | Priority |
 | 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/compatibility-layer-workflow.md` | Developing compatibility layer for AI tools | ⭐⭐⭐⭐⭐ |
 | `guides/testing-subagents.md` | How to test subagents standalone | ⭐⭐⭐⭐⭐ |
 | `guides/testing-subagents.md` | How to test subagents standalone | ⭐⭐⭐⭐⭐ |
 | `guides/adding-agent-basics.md` | How to add new agents (basics) | ⭐⭐⭐⭐ |
 | `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.
 Agents load YOUR patterns before generating code. Code matches your project from the start. No refactoring needed.
 
 
 **📝 Editable Agents (Not Baked-In Plugins)**  
 **📝 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)**  
 **✋ 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.
 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
 ## 🚀 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
 ### Step 1: Install
 
 
-**One command:**
+**With the `oac` CLI (recommended):**
 
 
 ```bash
 ```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
 ```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
 ### Keep Updated
 
 
 ```bash
 ```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
 ### 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
 // node:fs/promises rm is used intentionally — Bun has no built-in recursive directory removal
 import { rm } from 'node:fs/promises';
 import { rm } from 'node:fs/promises';
 import { type Command } from 'commander';
 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 { installFile } from '../lib/installer.js';
 import {
 import {
   readManifest,
   readManifest,
@@ -37,53 +45,42 @@ export type RemoveOptions = {
 
 
 // ── Pure helpers ──────────────────────────────────────────────────────────────
 // ── 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 = (
 const buildFileEntry = (
   sha256: string,
   sha256: string,
   component: RegistryComponent,
   component: RegistryComponent,
 ): FileEntry => ({
 ): FileEntry => ({
   sha256,
   sha256,
-  type: component.type,
+  type: toManifestType(component.type),
   source: 'registry',
   source: 'registry',
   installedAt: new Date().toISOString(),
   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 = (
 const isAlreadyInstalled = (
   manifest: ManifestFile | null,
   manifest: ManifestFile | null,
   destRelativePath: string,
   destRelativePath: string,
 ): boolean => manifest?.files[destRelativePath] !== undefined;
 ): 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 ──────────────────────────────────────────────────────────────
 // ── 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. */
 /** Prints all available components grouped by type. */
-const printAvailableComponents = async (_projectRoot: string): Promise<void> => {
+const printAvailableComponents = async (): Promise<void> => {
   const packageRoot = getPackageRoot();
   const packageRoot = getPackageRoot();
   const registry = await loadRegistry(packageRoot);
   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('');
   log('Available components:');
   log('Available components:');
   log('');
   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;
     if (components.length === 0) continue;
     log(`  ${type.toUpperCase()}S`);
     log(`  ${type.toUpperCase()}S`);
     for (const c of components) {
     for (const c of components) {
@@ -107,7 +104,8 @@ const resolveOrFail = async (
   const component = resolveComponent(registry, ref);
   const component = resolveComponent(registry, ref);
 
 
   if (component === null) {
   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);
     process.exit(1);
   }
   }
 
 
@@ -131,25 +129,18 @@ const checkAlreadyInstalled = (
   return false; // signal: proceed
   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 (
 const performInstall = async (
   component: RegistryComponent,
   component: RegistryComponent,
   packageRoot: string,
   packageRoot: string,
   projectRoot: string,
   projectRoot: string,
-  destRelativePath: string,
   manifest: ManifestFile,
   manifest: ManifestFile,
   opts: AddOptions,
   opts: AddOptions,
 ): Promise<void> => {
 ): 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 = {
   const installOpts = {
     projectRoot,
     projectRoot,
@@ -159,18 +150,30 @@ const performInstall = async (
     verbose: opts.verbose,
     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) {
   if (opts.dryRun) {
     info(`[dry-run] Would install ${component.type}:${component.id} to ${destDir}/`);
     info(`[dry-run] Would install ${component.type}:${component.id} to ${destDir}/`);
     return;
     return;
   }
   }
 
 
-  const sha256 = await computeFileHash(destPath);
-  const entry = buildFileEntry(sha256, component);
-  const updatedManifest = addFileToManifest(manifest, destRelativePath, entry);
   await writeManifest(projectRoot, updatedManifest);
   await writeManifest(projectRoot, updatedManifest);
-
   success(`Added ${component.id} to ${destDir}/`);
   success(`Added ${component.id} to ${destDir}/`);
 };
 };
 
 
@@ -179,7 +182,7 @@ const performInstall = async (
 /**
 /**
  * Implements `oac add [ref]`.
  * Implements `oac add [ref]`.
  * With no ref: lists available components grouped by type.
  * 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(
 export async function addCommand(
   ref: string | undefined,
   ref: string | undefined,
@@ -188,7 +191,7 @@ export async function addCommand(
   const projectRoot = process.cwd();
   const projectRoot = process.cwd();
 
 
   if (ref === undefined) {
   if (ref === undefined) {
-    await printAvailableComponents(projectRoot);
+    await printAvailableComponents();
     return;
     return;
   }
   }
 
 
@@ -199,13 +202,17 @@ export async function addCommand(
     const { component, packageRoot } = await resolveOrFail(ref);
     const { component, packageRoot } = await resolveOrFail(ref);
     spinner.stop();
     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 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;
     if (shouldAbort) return;
 
 
-    await performInstall(component, packageRoot, projectRoot, destRelativePath, manifest, options);
+    await performInstall(component, packageRoot, projectRoot, manifest, options);
   } catch (err: unknown) {
   } catch (err: unknown) {
     spinner.fail();
     spinner.fail();
     const msg = err instanceof Error ? err.message : String(err);
     const msg = err instanceof Error ? err.message : String(err);
@@ -216,7 +223,7 @@ export async function addCommand(
 
 
 /**
 /**
  * Implements `oac remove [ref]`.
  * 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(
 export async function removeCommand(
   ref: string | undefined,
   ref: string | undefined,
@@ -237,28 +244,31 @@ export async function removeCommand(
     spinner.stop();
     spinner.stop();
 
 
     const manifest = await readManifest(projectRoot);
     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.`);
       warn(`'${ref}' is not installed — nothing to remove.`);
       return;
       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) {
     if (!options.dryRun) {
-      await rm(destPath, { recursive: true, force: true });
-      const updatedManifest = removeFileFromManifest(manifest!, destRelativePath);
       await writeManifest(projectRoot, updatedManifest);
       await writeManifest(projectRoot, updatedManifest);
       success(`Removed ${component.id}`);
       success(`Removed ${component.id}`);
-    } else {
-      info(`[dry-run] Would remove ${destPath}`);
     }
     }
   } catch (err: unknown) {
   } catch (err: unknown) {
     spinner.fail();
     spinner.fail();
@@ -276,7 +286,7 @@ export async function removeCommand(
 export function registerAddCommand(program: Command): void {
 export function registerAddCommand(program: Command): void {
   program
   program
     .command('add [ref]')
     .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('--force', 'Reinstall even if already installed', false)
     .option('--dry-run', 'Show what would happen without making changes', false)
     .option('--dry-run', 'Show what would happen without making changes', false)
     .option('--yolo', 'Skip safety checks and overwrite user-modified files', 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 semver from 'semver';
 
 
 import { readCliVersion } from '../lib/version.js';
 import { readCliVersion } from '../lib/version.js';
-import { readManifest } from '../lib/manifest.js';
 import { readConfig } from '../lib/config.js';
 import { readConfig } from '../lib/config.js';
 import { computeFileHash, hashesMatch } from '../lib/sha256.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';
 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 = {
 export type DoctorOptions = {
   verbose: boolean;
   verbose: boolean;
   json: boolean;
   json: boolean;
 };
 };
 
 
-type DoctorSummary = {
-  ok: number;
-  warnings: number;
-  errors: number;
-};
-
 // ── Version helpers ───────────────────────────────────────────────────────────
 // ── Version helpers ───────────────────────────────────────────────────────────
 
 
 /** Fetches the latest version from the npm registry. Returns null if offline. */
 /** 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. */
 /** Check 4: .oac/manifest.json exists and is valid JSON. */
 const checkManifest = async (projectRoot: string): Promise<CheckResult> => {
 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 {
     return {
       name: 'Manifest',
       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 {
     return {
       name: 'Manifest',
       name: 'Manifest',
       status: 'error',
       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. */
 /** Check 5: Every file listed in manifest exists on disk. */
 const checkFilesOnDisk = async (projectRoot: string): Promise<CheckResult> => {
 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) {
   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[] = [];
   const missingFiles: string[] = [];
@@ -176,7 +162,7 @@ const checkFilesOnDisk = async (projectRoot: string): Promise<CheckResult> => {
 
 
   if (missingFiles.length > 0) {
   if (missingFiles.length > 0) {
     return {
     return {
-      name: 'Files on disk',
+      name,
       status: 'error',
       status: 'error',
       message: `${missingFiles.length} file${missingFiles.length !== 1 ? 's' : ''} missing from disk`,
       message: `${missingFiles.length} file${missingFiles.length !== 1 ? 's' : ''} missing from disk`,
       detail: missingFiles,
       detail: missingFiles,
@@ -184,7 +170,7 @@ const checkFilesOnDisk = async (projectRoot: string): Promise<CheckResult> => {
   }
   }
 
 
   return {
   return {
-    name: 'Files on disk',
+    name,
     status: 'ok',
     status: 'ok',
     message: `All ${trackedFiles.length} tracked file${trackedFiles.length !== 1 ? 's' : ''} present`,
     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. */
 /** Check 6: SHA256 mismatch detection — warn for user-modified files. */
 const checkModifiedFiles = async (projectRoot: string): Promise<CheckResult> => {
 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);
   const trackedFiles = Object.keys(manifest.files);
   if (trackedFiles.length === 0) {
   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[] = [];
   const modifiedFiles: string[] = [];
@@ -229,41 +209,19 @@ const checkModifiedFiles = async (projectRoot: string): Promise<CheckResult> =>
 
 
   if (modifiedFiles.length > 0) {
   if (modifiedFiles.length > 0) {
     return {
     return {
-      name: 'Modified files',
+      name,
       status: 'warn',
       status: 'warn',
       message: `${modifiedFiles.length} file${modifiedFiles.length !== 1 ? 's' : ''} modified since install`,
       message: `${modifiedFiles.length} file${modifiedFiles.length !== 1 ? 's' : ''} modified since install`,
       detail: modifiedFiles,
       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 ──────────────────────────────────────────────────────────
 // ── 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. */
 /** Prints the final result line. Side-effect only. */
 const printFinalResult = (summary: DoctorSummary): void => {
 const printFinalResult = (summary: DoctorSummary): void => {
   log('');
   log('');
@@ -330,6 +274,9 @@ const printFinalResult = (summary: DoctorSummary): void => {
  * Implements `oac doctor`:
  * Implements `oac doctor`:
  *  Runs all 7 health checks, prints results, and exits with code 0 (healthy/warnings)
  *  Runs all 7 health checks, prints results, and exits with code 0 (healthy/warnings)
  *  or 1 (errors found). Supports --json for machine-readable output.
  *  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> {
 export async function doctorCommand(options: DoctorOptions): Promise<void> {
   if (options.verbose) setVerbose(true);
   if (options.verbose) setVerbose(true);
@@ -369,7 +316,6 @@ export async function doctorCommand(options: DoctorOptions): Promise<void> {
     };
     };
     log(JSON.stringify(output, null, 2));
     log(JSON.stringify(output, null, 2));
     process.exit(summary.errors > 0 ? 1 : 0);
     process.exit(summary.errors > 0 ? 1 : 0);
-    return;
   }
   }
 
 
   // Human-readable output
   // 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 { type Command } from 'commander';
 
 
 import { readCliVersion } from '../lib/version.js';
 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 { readConfig, writeConfig, createDefaultConfig } from '../lib/config.js';
 import { detectIdes } from '../lib/ide-detect.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';
 import { createSpinner } from '../ui/spinner.js';
 
 
 // ── Types ─────────────────────────────────────────────────────────────────────
 // ── Types ─────────────────────────────────────────────────────────────────────
@@ -15,81 +24,154 @@ export type InitOptions = {
   yolo: boolean;
   yolo: boolean;
   dryRun: boolean;
   dryRun: boolean;
   verbose: 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. */
 /** 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';
   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. */
 /** Prints the pre-install plan. Side-effect only. */
 const printPlan = (
 const printPlan = (
-  bundledFiles: string[],
+  plan: InstallPlan,
   ides: Awaited<ReturnType<typeof detectIdes>>,
   ides: Awaited<ReturnType<typeof detectIdes>>,
-  dryRun: boolean,
+  options: InitOptions,
 ): void => {
 ): void => {
-  const counts = countByType(bundledFiles);
   const detectedIdes = ides.filter((i) => i.detected).map((i) => i.type);
   const detectedIdes = ides.filter((i) => i.detected).map((i) => i.type);
 
 
   log('');
   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('');
   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)`);
   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) {
   if (detectedIdes.length > 0) {
     info(`IDEs detected: ${detectedIdes.join(', ')} — run \`oac apply\` after init`);
     info(`IDEs detected: ${detectedIdes.join(', ')} — run \`oac apply\` after init`);
   } else {
   } 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('');
   log('');
 };
 };
 
 
 /** Prints the post-install summary. Side-effect only. */
 /** 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('');
   log('');
   if (dryRun) {
   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.');
     info('No changes were made. Remove --dry-run to apply.');
     return;
     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('');
   log('');
 };
 };
 
 
@@ -99,9 +181,7 @@ const printSummary = (
 const assertProjectRoot = async (cwd: string): Promise<void> => {
 const assertProjectRoot = async (cwd: string): Promise<void> => {
   const isRoot = await isProjectRoot(cwd);
   const isRoot = await isProjectRoot(cwd);
   if (!isRoot) {
   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).');
     error('Fix: run `oac init` from your project root (where package.json lives).');
     process.exit(1);
     process.exit(1);
   }
   }
@@ -127,89 +207,138 @@ const ensureConfig = async (projectRoot: string, dryRun: boolean): Promise<void>
   verbose('Wrote .oac/config.json with defaults.');
   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 ──────────────────────────────────────────────────────────────
 // ── Main command ──────────────────────────────────────────────────────────────
 
 
 /**
 /**
  * Implements `oac init`:
  * Implements `oac init`:
  *  1. Validates we are in a project root
  *  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> {
 export async function initCommand(options: InitOptions): Promise<void> {
   // Respect CI=true as implicit --yolo
   // 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);
   if (effectiveOptions.verbose) setVerbose(true);
 
 
   const projectRoot = process.cwd();
   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 packageRoot: string;
+  let registry: Registry;
   let bundledFiles: string[];
   let bundledFiles: string[];
   try {
   try {
     packageRoot = getPackageRoot();
     packageRoot = getPackageRoot();
+    registry = await loadRegistry(packageRoot);
     bundledFiles = await listBundledFiles(packageRoot);
     bundledFiles = await listBundledFiles(packageRoot);
   } catch (err) {
   } catch (err) {
     const msg = err instanceof Error ? err.message : String(err);
     const msg = err instanceof Error ? err.message : String(err);
     error(`Could not locate bundled files: ${msg}`);
     error(`Could not locate bundled files: ${msg}`);
     error('Fix: ensure @controlstack/oac is installed correctly (try reinstalling).');
     error('Fix: ensure @controlstack/oac is installed correctly (try reinstalling).');
     process.exit(1);
     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);
     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);
   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 });
   const spinner = createSpinner('Installing files…', { dryRun: effectiveOptions.dryRun });
   spinner.start();
   spinner.start();
 
 
-  let installResult: Awaited<ReturnType<typeof installFiles>>;
+  let outcome: InstallOutcome;
   try {
   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) {
   } catch (err) {
     spinner.fail('Installation failed.');
     spinner.fail('Installation failed.');
     const msg = err instanceof Error ? err.message : String(err);
     const msg = err instanceof Error ? err.message : String(err);
     error(`Installation failed: ${msg}`);
     error(`Installation failed: ${msg}`);
     error('Fix: check file permissions in your project directory.');
     error('Fix: check file permissions in your project directory.');
     process.exit(1);
     process.exit(1);
-    return;
   }
   }
-  const { result, updatedManifest } = installResult;
+  const { result, updatedManifest, reinstall } = outcome;
 
 
   // Report per-file errors (non-fatal — partial installs are still useful)
   // Report per-file errors (non-fatal — partial installs are still useful)
   for (const fileError of result.errors) {
   for (const fileError of result.errors) {
     warn(`Error: ${fileError}`);
     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) {
   if (effectiveOptions.dryRun) {
     info('[dry-run] Would write .oac/manifest.json');
     info('[dry-run] Would write .oac/manifest.json');
   } else {
   } else {
-    const cliVersion = readCliVersion();
-    const finalManifest = { ...updatedManifest, oacVersion: cliVersion };
+    const finalManifest = { ...updatedManifest, oacVersion: readCliVersion() };
 
 
     await writeManifest(projectRoot, finalManifest).catch((err: unknown) => {
     await writeManifest(projectRoot, finalManifest).catch((err: unknown) => {
       const msg = err instanceof Error ? err.message : String(err);
       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');
     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) => {
   await ensureConfig(projectRoot, effectiveOptions.dryRun).catch((err: unknown) => {
     const msg = err instanceof Error ? err.message : String(err);
     const msg = err instanceof Error ? err.message : String(err);
     error(`Failed to write config: ${msg}`);
     error(`Failed to write config: ${msg}`);
@@ -228,13 +357,8 @@ export async function initCommand(options: InitOptions): Promise<void> {
     process.exit(1) as never;
     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)
   // Exit 0 on success (explicit for clarity)
   process.exit(0);
   process.exit(0);
@@ -250,14 +374,29 @@ export function registerInitCommand(program: Command): void {
   program
   program
     .command('init')
     .command('init')
     .description('Set up OAC agents and context files in the current project')
     .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('--dry-run', 'Print what would happen without making any changes', false)
     .option('--verbose', 'Show each file being copied', 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,
   listBundledFiles,
   bundledFileExists,
   bundledFileExists,
   type BundledFileType,
   type BundledFileType,
+  toManifestType,
 } from './bundled.js';
 } from './bundled.js';
 
 
 // ── classifyBundledFile ───────────────────────────────────────────────────────
 // ── classifyBundledFile ───────────────────────────────────────────────────────
@@ -391,3 +392,36 @@ describe('bundledFileExists', () => {
     expect(exists).toBe(false);
     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 ---
 // --- Types ---
 
 
 /** The category of a bundled file, inferred from its path prefix. */
 /** 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 ---
 // --- Constants ---
 
 
@@ -171,6 +184,9 @@ export const bundledFileExists = async (
  * - `.opencode/agent/...`   → "agent"
  * - `.opencode/agent/...`   → "agent"
  * - `.opencode/context/...` → "context"
  * - `.opencode/context/...` → "context"
  * - `.opencode/skills/...`  → "skill"
  * - `.opencode/skills/...`  → "skill"
+ * - `.opencode/command/...` → "command"
+ * - `.opencode/tool/...`    → "tool"
+ * - `.opencode/plugin/...`  → "plugin"
  * - anything else           → "config"
  * - anything else           → "config"
  *
  *
  * Pure function — no I/O.
  * 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/agent/")) return "agent";
   if (relativePath.startsWith(".opencode/context/")) return "context";
   if (relativePath.startsWith(".opencode/context/")) return "context";
   if (relativePath.startsWith(".opencode/skills/")) return "skill";
   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";
   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);
     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,
   readManifest,
   createEmptyManifest,
   createEmptyManifest,
 } from "./manifest.js";
 } from "./manifest.js";
-import { listBundledFiles, getBundledFilePath, classifyBundledFile } from "./bundled.js";
+import { listBundledFiles, getBundledFilePath, classifyBundledFile, toManifestType } from "./bundled.js";
 
 
 // ── Types ─────────────────────────────────────────────────────────────────────
 // ── Types ─────────────────────────────────────────────────────────────────────
 
 
@@ -219,7 +219,7 @@ async function processOneFile(
   const decision = decisionResult.value;
   const decision = decisionResult.value;
 
 
   const now = new Date().toISOString();
   const now = new Date().toISOString();
-  const fileType = classifyBundledFile(relativePath);
+  const fileType = toManifestType(classifyBundledFile(relativePath));
 
 
   try {
   try {
     if (decision.action === "install") {
     if (decision.action === "install") {
@@ -305,7 +305,7 @@ export async function installFiles(
         const sha256 = options.dryRun ? "" : await computeFileHash(destPath);
         const sha256 = options.dryRun ? "" : await computeFileHash(destPath);
         const entry: FileEntry = {
         const entry: FileEntry = {
           sha256,
           sha256,
-          type: classifyBundledFile(relativePath),
+          type: toManifestType(classifyBundledFile(relativePath)),
           source: "bundled",
           source: "bundled",
           installedAt: now,
           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.
  * Does NOT write the manifest — caller is responsible.
  */
  */
-export async function updateFiles(
+export async function syncFiles(
+  files: string[],
+  manifest: ManifestFile | null,
   options: InstallOptions,
   options: InstallOptions,
 ): Promise<{ result: InstallResult; updatedManifest: ManifestFile }> {
 ): Promise<{ result: InstallResult; updatedManifest: ManifestFile }> {
-  const manifest = await readManifest(options.projectRoot);
-  const bundledFiles = await listBundledFiles(options.packageRoot);
   const timestamp = buildTimestamp();
   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 sourcePath = getBundledFilePath(options.packageRoot, relativePath);
       const destPath = path.join(options.projectRoot, relativePath);
       const destPath = path.join(options.projectRoot, relativePath);
       const { patch, entry } = await processOneFile({
       const { patch, entry } = await processOneFile({
@@ -373,19 +367,44 @@ export async function updateFiles(
     }),
     }),
   );
   );
 
 
-  const phase1 = phase1Results.reduce(
+  return outcomes.reduce(
     (acc, { relativePath, patch, entry }) => ({
     (acc, { relativePath, patch, entry }) => ({
       result: mergeResult(acc.result, patch),
       result: mergeResult(acc.result, patch),
-      workingManifest:
+      updatedManifest:
         entry !== null
         entry !== null
-          ? addFileToManifest(acc.workingManifest, relativePath, entry)
-          : acc.workingManifest,
+          ? addFileToManifest(acc.updatedManifest, relativePath, entry)
+          : acc.updatedManifest,
     }),
     }),
     {
     {
       result: { ...EMPTY_RESULT } as InstallResult,
       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
   // Phase 2: handle files in manifest that are no longer in the bundle
   const bundledSet = new Set(bundledFiles);
   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";
 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({
 export const RegistryComponentSchema = z.object({
   id: z.string(),
   id: z.string(),
   name: z.string(),
   name: z.string(),
   type: ComponentTypeSchema,
   type: ComponentTypeSchema,
   path: z.string(),
   path: z.string(),
-  description: z.string(),
+  description: z.string().default(""),
   tags: z.array(z.string()).default([]),
   tags: z.array(z.string()).default([]),
   dependencies: z.array(z.string()).default([]),
   dependencies: z.array(z.string()).default([]),
   category: z.string().default("standard"),
   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(),
   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(),
   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({
 export const RegistrySchema = z.object({
   version: z.string(),
   version: z.string(),
@@ -64,51 +71,83 @@ export const RegistrySchema = z.object({
   repository: z.string().optional(),
   repository: z.string().optional(),
   categories: z.record(z.string(), z.string()).optional(),
   categories: z.record(z.string(), z.string()).optional(),
   components: z.object({
   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 ──────────────────────────────────────────────────────────────────────
 // ── Types ──────────────────────────────────────────────────────────────────────
 
 
 export type ComponentType = z.infer<typeof ComponentTypeSchema>;
 export type ComponentType = z.infer<typeof ComponentTypeSchema>;
 export type RegistryComponent = z.infer<typeof RegistryComponentSchema>;
 export type RegistryComponent = z.infer<typeof RegistryComponentSchema>;
+export type RegistryProfile = z.infer<typeof RegistryProfileSchema>;
 export type Registry = z.infer<typeof RegistrySchema>;
 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 ───────────────────────────────────────────────────────────────
 // ── Path helpers ───────────────────────────────────────────────────────────────
 
 
 /** Returns the absolute path to registry.json given the package root. */
 /** Returns the absolute path to registry.json given the package root. */
 export const getRegistryPath = (packageRoot: string): string =>
 export const getRegistryPath = (packageRoot: string): string =>
   join(packageRoot, REGISTRY_FILENAME);
   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 ─────────────────────────────────────────────────────────
 // ── 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.
  * Pure — no side effects.
  */
  */
 export const listComponents = (
 export const listComponents = (
   registry: Registry,
   registry: Registry,
   type?: ComponentType,
   type?: ComponentType,
 ): RegistryComponent[] => {
 ): 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.
  * Pure — no side effects.
  */
  */
 export const listComponentsByType = (
 export const listComponentsByType = (
@@ -120,8 +159,44 @@ export const listComponentsByType = (
   return listComponents(registry, parsed.data);
   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.
  * 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
  * Returns `null` — never throws — when the component is not found or the ref
  * format is invalid.
  * format is invalid.
  * Pure — no side effects.
  * Pure — no side effects.
@@ -130,35 +205,101 @@ export const resolveComponent = (
   registry: Registry,
   registry: Registry,
   ref: string,
   ref: string,
 ): RegistryComponent | null => {
 ): 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.
  * 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.
  * 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 ────────────────────────────────────────────────────────────────────────
 // ── I/O ────────────────────────────────────────────────────────────────────────
 
 

+ 191 - 196
registry.json

@@ -3287,6 +3287,20 @@
         ],
         ],
         "dependencies": [],
         "dependencies": [],
         "category": "essential"
         "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": [
     "config": [
@@ -3304,20 +3318,6 @@
         "dependencies": [],
         "dependencies": [],
         "category": "essential"
         "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",
         "id": "readme",
         "name": "README",
         "name": "README",
@@ -3335,35 +3335,111 @@
     ]
     ]
   },
   },
   "profiles": {
   "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": [
       "components": [
+        "agent:copywriter",
+        "agent:data-analyst",
+        "agent:eval-runner",
         "agent:openagent",
         "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:contextscout",
+        "subagent:devops-specialist",
         "subagent:documentation",
         "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:add-context",
         "command:clean",
         "command:clean",
-        "tool:env",
+        "command:context",
+        "command:prompt-enhancer",
+        "config:env-example",
+        "config:readme",
+        "context:adding-skill-basics",
+        "context:core/*",
         "context:essential-patterns",
         "context:essential-patterns",
         "context:project-context",
         "context:project-context",
+        "context:project-intelligence/*",
         "context:quick-start",
         "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": {
     "developer": {
@@ -3373,210 +3449,129 @@
       "components": [
       "components": [
         "agent:openagent",
         "agent:openagent",
         "agent:opencoder",
         "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:add-context",
+        "command:analyze-patterns",
         "command:clean",
         "command:clean",
+        "command:commit",
+        "command:context",
         "command:optimize",
         "command:optimize",
+        "command:test",
         "command:validate-repo",
         "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:essential-patterns",
+        "context:openagents-repo/*",
         "context:project-context",
         "context:project-context",
-        "context:quick-start",
-        "context:core/*",
         "context:project-intelligence/*",
         "context:project-intelligence/*",
-        "context:adding-skill-basics",
-        "context:ui/*",
-        "context:development/*",
-        "context:design-systems",
+        "context:quick-start",
         "context:react-patterns",
         "context:react-patterns",
-        "context:animation-basics",
-        "context:animation-components",
-        "context:animation-advanced",
         "context:ui-styling-standards",
         "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": [
       "components": [
         "agent:openagent",
         "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:add-context",
         "command:clean",
         "command:clean",
-        "command:prompt-enhancer",
-        "tool:env",
-        "tool:gemini",
-        "plugin:notify",
+        "command:context",
+        "config:env-example",
+        "context:adding-skill-basics",
         "context:essential-patterns",
         "context:essential-patterns",
+        "context:feature-breakdown",
         "context:project-context",
         "context:project-context",
         "context:quick-start",
         "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": {
     "full": {
       "name": "Full",
       "name": "Full",
       "description": "Everything included - all agents, subagents, tools, and plugins for maximum functionality. Includes UI/UX patterns, development principles, and design systems.",
       "description": "Everything included - all agents, subagents, tools, and plugins for maximum functionality. Includes UI/UX patterns, development principles, and design systems.",
       "components": [
       "components": [
+        "agent:copywriter",
+        "agent:data-analyst",
+        "agent:eval-runner",
         "agent:openagent",
         "agent:openagent",
         "agent:opencoder",
         "agent:opencoder",
-        "agent:eval-runner",
-        "agent:copywriter",
         "agent:technical-writer",
         "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:add-context",
+        "command:analyze-patterns",
         "command:clean",
         "command:clean",
+        "command:commit",
+        "command:context",
         "command:optimize",
         "command:optimize",
         "command:prompt-enhancer",
         "command:prompt-enhancer",
-        "command:worktrees",
+        "command:test",
         "command:validate-repo",
         "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:essential-patterns",
+        "context:openagents-repo/*",
         "context:project-context",
         "context:project-context",
-        "context:quick-start",
-        "context:core/*",
         "context:project-intelligence/*",
         "context:project-intelligence/*",
-        "context:adding-skill-basics",
-        "context:ui/*",
-        "context:development/*",
-        "context:design-systems",
+        "context:quick-start",
         "context:react-patterns",
         "context:react-patterns",
-        "context:animation-basics",
-        "context:animation-components",
-        "context:animation-advanced",
         "context:ui-styling-standards",
         "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:devops-specialist",
         "subagent:documentation",
         "subagent:documentation",
-        "subagent:coder-agent",
+        "subagent:frontend-specialist",
+        "subagent:image-specialist",
         "subagent:reviewer",
         "subagent:reviewer",
+        "subagent:task-manager",
         "subagent:tester",
         "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: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"
       ]
       ]
     }
     }
   },
   },