| name | cairn-adopt-components |
| description | Backfill the Cairn component store into a project that was adopted before the component store shipped. |
| when_to_use | Use when the operator wants the component store on a repo that already
has `.cairn/` but no `components:` config (adopted before v0.18.0), or
asks to "adopt components", "backfill the component registry", or "add
`@cairn` headers". Drives detect → annotate → emit inline. Skip when
the repo isn't adopted at all (send to cairn-adopt first) or already
carries a built component store.
|
| allowed-tools | Skill(cairn:cairn-attention), Task(component-annotator), Task(component-registrar), AskUserQuestion |
Skill: cairn-adopt-components
Host portability
This skill is shared by Claude Code, Cursor, and Codex. Use the host's
structured question UI when available; otherwise ask the same concise A/B/C
question in chat and pause. Named subagent briefs live under ../../agents/:
dispatch by name where supported, or read the brief and pass it to the host's
native subagent tool. Execute inline only when no subagent tool exists.
You are backfilling Cairn's component store into an already-adopted
project — the one-time work the adoption pipeline does for fresh repos,
applied to a repo that predates the store. The goal: every component
file carries a @cairn registry header, the derived index is built, and
@singleton headers become §INVs. Spec: docs/PLUGIN_ARCHITECTURE.md
§6 (the component trio 9d→9e→9f) and docs/COMPONENT_STORE_PLAN.md.
This skill drives the bundled dist/cli.mjs internally using the plugin-root
variable supplied by the active host (PLUGIN_ROOT, CURSOR_PLUGIN_ROOT, or
CLAUDE_PLUGIN_ROOT). Never
surface a CLI subcommand to the operator (Plugin spec §11) — the chat
shows progress + consent gates, not commands.
Step 0 — classify the repo
Run this single probe to decide whether backfill applies. It is
ghost-aware: a ghost-adopted repo has no in-repo .cairn/ — its state
lives out-of-repo at ~/.cairn/state/<root-commit>/. The probe resolves the
effective state home (in-repo when present, else the out-of-repo ghost dir
keyed on the repo's root-commit) and prints <mode> <verdict> <home>:
node -e '
const fs=require("node:fs");
const os=require("node:os");
const path=require("node:path");
const cp=require("node:child_process");
const root=process.cwd();
let mode="committed";
let home=path.join(root,".cairn");
if(!fs.existsSync(home)){
// No in-repo .cairn — may be ghost-adopted. Ghost state lives at
// ~/.cairn/state/<repo-id>; repo-id is the move-stable root-commit SHA
// (matches registerGhostRepo). Resolve it and probe there instead.
let rc="";
try{rc=cp.execFileSync("git",["-C",root,"rev-list","--max-parents=0","HEAD"],{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim().split(/\s+/)[0]||"";}catch{}
if(rc){const g=path.join(os.homedir(),".cairn","state",rc);if(fs.existsSync(g)){home=g;mode="ghost";}}
}
const cfg=path.join(home,"config.yaml");
const idx=path.join(home,"ground","components");
if(!fs.existsSync(home)||!fs.existsSync(cfg)){console.log(mode+" not-adopted "+home);process.exit(0);}
let hasBlock=false;
try{hasBlock=/^components:/m.test(fs.readFileSync(cfg,"utf8"));}catch{}
const hasIndex=fs.existsSync(idx)&&fs.readdirSync(idx).length>0;
console.log(mode+" "+(hasBlock&&hasIndex?"has-store":"backfill")+" "+home);'
The first token is the mode (committed | ghost); the third is the
resolved state home. In ghost mode, every .cairn/… path in the
steps below resolves under that home (the out-of-repo dir), NOT the repo
root — the node … cli.mjs components … commands already resolve it
automatically, so only the raw cat / in-place-edit snippets need
$CAIRN_HOME substituted for .cairn. Export it: CAIRN_HOME="<home>".
Branch on the verdict (second token):
not-adopted → the repo has no Cairn state. Surface one line:
"This project isn't adopted yet — run /cairn:cairn-adopt first; it
builds the component store as part of adoption." End the turn.
has-store → a components: block and a built index already
exist. This is a refresh, not a first backfill — skip Step 1's
detect (the config is already there), run Step 2.5 (config-gap
check) first, then go to Step 3 (walk for newly-added un-headered
files). Surface: "Component store already present — re-checking for
un-headered components."
backfill → the normal path. Continue to Step 1.
Step 1 — detect + write the components: config
Run detection (LLM-driven + convention-agnostic — the same one adoption
uses; it reasons over the repo's structure rather than probing a fixed
list of conventional dir names, so any layout / monorepo tooling works):
node "${CLAUDE_PLUGIN_ROOT}/dist/cli.mjs" components detect
Read the stdout and branch:
- "No recognizable component layout found" → the model found no
reusable UI components (e.g. a backend-only repo). Surface one line and
end — there is nothing to backfill.
- "already carries a components: block" → fall through to Step 2.
- "Wrote a components: block" → the config now has a
components:
block. If the output also says "Monorepo detected", run Step 1.5.
Otherwise continue to Step 2.
Step 1.5 — monorepo sharing (only when monorepo detected)
Every workspace is isolated by default — components in one workspace
are OFF-LIMITS to the others. A shared UI library workspace (e.g. a
packages/ui design system) should usually be shared: true so the
whole repo may use it. Detection never guesses this (isolation
invariant 3).
Read the workspace names from .cairn/config.yaml (components.workspaces).
Render an AskUserQuestion (multi-select) listing the workspaces:
Which workspaces expose their components repo-wide (a shared UI/design
library)? Leave all unchecked to keep every workspace isolated.
For each workspace the operator checks, add shared: true to that
workspace's block in .cairn/config.yaml (edit the file in place; touch
nothing else). If none are checked, leave the config as written.
Step 2 — domain one-liner
For better @purpose / @aliases, gather a one-line domain summary the
annotators can ride on. Prefer .cairn/ground/brand/ if it exists;
otherwise infer from the README's first paragraph. Keep it to one
sentence. This is optional context, not a gate.
Step 2.5 — config-gap check (refresh path only)
A config written by an older detector — or one that never re-detected —
can miss whole dirs of co-located components (e.g. app/** next to the
declared components/ dir). The audit already finds these; on refresh,
elevate them instead of letting them sink into the emit baseline.
node "${CLAUDE_PLUGIN_ROOT}/dist/cli.mjs" components audit 2>&1
Collect the UNREGISTERED-COMPONENT: lines — each names a component-shaped
file living OUTSIDE the declared componentDirs. Group them by their
parent directory. If there are none, continue to Step 3 silently.
If there are some, render an AskUserQuestion naming the top offending
dir(s):
N component-shaped files live outside your component dirs (e.g.
<dir>/…). Add their dir(s) to the workspace's componentDirs so the
registry can see them?
a add the dir(s) to componentDirs · b re-detect the layout with
the LLM (a stale config self-corrects) · c skip — leave the config
On a, edit .cairn/config.yaml in place: append each chosen dir to the
matching workspace's componentDirs (top-level for single-app). This is
safe now — the missing-header walk gates on isUnitShaped, so adding a
mixed dir surfaces only genuine un-headered units, never route/entry
files (page.tsx, layout.tsx, …). On b, run Step 1's
components detect to rewrite the block, then continue. On c, leave
the config untouched and continue to Step 3.
Step 3 — walk for un-headered components
List the component files missing a @cairn header:
node "${CLAUDE_PLUGIN_ROOT}/dist/cli.mjs" components check 2>&1
Each ERROR missing @cairn header: <file> line names one un-headered
component file (repo-relative). Collect them into the corpus.
For each file, resolve its workspace + category taxonomy by matching the
file path against components.workspaces[*].componentDirs (or the
top-level componentDirs for single-app) in .cairn/config.yaml, then
reading that workspace's categories (falling back to the top-level
categories).
If the corpus is empty (no missing headers — e.g. a refresh with nothing
new), skip to Step 5.
Surface a banner:
---
**Component backfill** — N component files need a `@cairn` registry header.
Dispatching `component-annotator` subagents in rounds of 4 to add them.
Plan-quota, no API billing.
Step 4 — annotate (committed) / register (ghost), operator-gated, batched
Ghost mode — register, do NOT annotate. When Step 0's mode is
ghost, the @cairn header is forbidden in client source (constraint
2). Dispatch the component-registrar subagent instead of
component-annotator: same per-batch consent + rounds-of-4 dispatch, but
each agent classifies the unit and calls cairn_component_register
(out-of-repo, no source edit) rather than editing a header. The
banner says "registering" and notes nothing is written to source; the
brief inlines file / export_name / workspace / categories;
declined units stay as soft unregistered-unit offers in the attention
queue. Skip the committed instructions below and proceed to Step 5
(components emit builds the index from the out-of-repo registry).
Committed mode — the path below. This step mutates source files,
so it is gated on per-batch consent. Group the corpus into batches of ~4.
For each batch, render an AskUserQuestion:
a annotate this batch · b skip this batch · c stop annotating
On a, spawn one component-annotator subagent per file in the
batch (up to 4 Task calls in a single assistant message → they run
in parallel; await all before the next batch). Each brief MUST inline:
file — absolute path to annotate.
export_name — the file's detected export; the @cairn value MUST be
the exact exported name (the agent re-checks and renames nothing).
categories — the workspace's taxonomy; @category MUST be one of these.
project_domain — the one-liner from Step 2 (omit if none).
- The header is the FIRST comment block in the file.
@aliases ≥2
concrete searchable nouns. @purpose one line. Add @singleton ONLY
for app-shell parts the project intends to exist exactly once.
- Do NOT change any code outside the inserted header comment.
The agent definition lives at agents/component-annotator.md
(Task(component-annotator) is pre-approved in this skill's frontmatter).
It carries the full @cairn grammar + write-once-correct rules inline.
Read disk, not the return text, as the source of truth.
On b, skip the batch (those files stay as missing-header debt). On
c, stop dispatching and go to Step 5 — emit still indexes whatever
headers now exist and queues the rest as debt.
Step 5 — build the store
node "${CLAUDE_PLUGIN_ROOT}/dist/cli.mjs" components emit
This builds the derived index under .cairn/ground/components/, promotes
every @singleton header to a §INV ledger entry, and writes any
still-missing headers + advisory audit findings to a baseline file the
attention queue triages. Capture the printed counts (indexed, singletons
drafted, missing, audit findings, baseline path).
Step 6 — verify
node "${CLAUDE_PLUGIN_ROOT}/dist/cli.mjs" components check 2>&1
- Exit 0 → the store is clean; every component is headered.
- Still failing → note the count of remaining un-headered files as debt
the operator can finish later (re-run this skill any time).
Step 7 — summary + hand off to attention
Produce a single assistant turn containing BOTH a summary AND, when
the emit step wrote a baseline (singleton §INVs to triage, audit
findings, or missing-header debt), a Skill(cairn:cairn-attention) call
to drain it. Do not end with text only when a baseline exists — that
orphans the findings.
Summary (tight, using the Step 5 counts):
- Components indexed.
- Singleton §INVs drafted (if any) — note they joined ground state as
enforced invariants.
- Components still missing headers (if any) — re-run this skill to finish.
- Audit items to triage (if any).
If no baseline was written (everything headered, no singletons, no audit
findings), end with the summary alone — there is nothing to triage.