Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,8 @@ fclt deploy plan \
--json
```

Use [`fclt capability`](./docs/capability-registry.md) for a versioned skills/plugin/MCP registry, host overlays, drift inspection, and one explicitly owned copy deployment. Native plugin files remain independently owned.

`deploy plan` reads canonical, target, and ownership state directly and never writes them. The
content-addressed JSON plan fails closed on stale hashes, unsupported adapters, unresolved
variables, lossy translation, corrupt ownership state, and path escape. Ownership is keyed by the
Expand Down Expand Up @@ -626,6 +628,7 @@ fclt project render --root <repo>/.ai --project-root <repo> --check --json
fclt project render --root <repo>/.ai --project-root <repo> --json
fclt project render --root <repo>/.ai --project-root <repo> --rollback --json
fclt deploy plan --help
fclt capability --help
fclt setup codex-plugin [--dry-run] [--json] [--no-codex-install]
fclt manage <tool> --dry-run
fclt sync [tool] --dry-run
Expand Down
117 changes: 117 additions & 0 deletions docs/capability-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Versioned capability ownership

`fclt capability` records where a skill, custom plugin, or MCP configuration artifact came from and deploys one explicitly owned copy at a time. A pinned digest detects source drift. An ownership receipt detects live edits before an update. An installer that replaces a skill symlink with a directory therefore produces a conflict instead of silently losing either version.

The commands are separate from the read-only `deploy plan` text-file contract. Broad legacy `sync` and `manage` apply remain deprecated.

## Registry and source snapshots

Keep a registry in version control alongside reviewed source snapshots. This example uses `capabilities.json` under a canonical source root:

```json
{
"schemaVersion": 1,
"entries": [
{
"id": "example-skill",
"kind": "skill",
"owner": "fclt",
"enabled": true,
"source": {
"path": "skills/example",
"origin": "https://example.org/skills",
"revision": "upstream-commit-or-version",
"sha256": "sha256:REPLACE_WITH_DIGEST"
},
"target": ".agents/skills/example",
"platforms": ["darwin", "linux"],
"requires": []
},
{
"id": "example-native-plugin",
"kind": "plugin",
"owner": "native",
"source": {
"origin": "marketplace:example-plugin",
"revision": "1.0.0"
}
}
]
}
```

`kind` is `skill`, `plugin`, or `mcp`. `owner` is `fclt`, `native`, or `external`. A native/external record describes registered intent; it does **not** prove installation, observed version, authentication, or readiness. Use its provider to verify those states. Its target is optional, and several independent entries may identify one shared provider config file. Any target overlap involving an fclt-owned entry is rejected.

An fclt-owned source requires `path`, `origin`, `revision`, and `sha256`; it also requires a target. Source and target paths are relative to the explicit roots. A source can be a directory or one regular file. A skill requires a directory containing `SKILL.md`. An MCP entry deploys a complete standalone configuration artifact; it does not merge servers into shared provider settings. Native plugins retain ownership of their installed files. To distribute a custom plugin's authored package, register a separate fclt-owned staging target outside the native manager's directories, then install or update through that manager.

The digest includes file bytes, paths, empty directories, and executable flags. Ownership, timestamps, and other permission bits are excluded. Copies use private permissions (directories/executables `0700`, other files `0600`). Nested symlinks, hardlinked files, special files, case-colliding paths, and snapshots over 32 MiB or 4096 nodes are rejected. Directories named `.git`, `node_modules`, `__pycache__`, `.local`, and files named `.env` are rejected rather than silently omitted. Prepare a clean snapshot; do not copy machine caches or secrets into canonical source. If a live target gains runtime caches, that entry reports a conflict and inventory continues for other entries. Caches are never silently excluded or deleted. Configure runtime-writing skills to store caches outside their managed directories, or review and archive the divergent target before an explicit redeployment. Compiled assets such as `dist` may be legitimate plugin inputs and require review before pinning.

## Add, import, or update a capability

1. Acquire the upstream version in an isolated staging directory. Keep download credentials in the acquisition tool's authorized runtime; never put values in the registry or source tree.
2. Inspect its files, license, source URL, and immutable revision. For an existing live installation, compare it with canonical source and explicitly choose the intended version. The CLI does not infer which divergent copy is newer or correct.
3. Place the reviewed snapshot under the canonical source root. Run `fclt capability digest --path /path/to/canonical/skills/example` and record the returned `sha256` with its upstream revision in the registry. Commit the source and registry together.
4. Inspect `inventory` and the selected `plan`, then apply its exact `planId`. Verify provider discovery or runtime behavior separately.

Run upstream installers into staging, not into fclt-owned live directories. Replacing a live link or writing through a canonical link is detectable, but detection cannot reconstruct an unrecorded upstream version. Version control and acquisition provenance provide that history. The registry never downloads or executes upstream content.

## Host, platform, and private overlays

An entry may set `hosts`, `platforms`, and `overrides`. A matching platform override is applied first, then a matching host override. Each override has exactly one selector (`host` or `platform`) and may replace `enabled`, `source`, or `target`. `source` replacement must supply the complete pinned source object. Duplicate selectors are rejected. Host/platform allowlists are applied after these embedded overrides.

```json
{
"overrides": [
{ "platform": "linux", "enabled": false },
{ "host": "build-station", "enabled": true }
]
}
```

For machine-owned or private choices, pass repeated `--overlay FILE` arguments. Each overlay has this shape:

```json
{
"schemaVersion": 1,
"entries": [
{ "id": "example-skill", "enabled": false }
]
}
```

Overlay files apply in CLI order after embedded overrides and selection. They may explicitly enable an excluded entry. Patches may contain only `id`, `enabled`, `source`, `target`, and `requires`; unknown IDs, duplicate IDs, unknown fields, and ownership changes are rejected. Overlay paths stay relative to the same roots. Select which platform/machine/private files to pass in the machine configuration; the library implements their common merge and validation rules. Keep private files out of shared source control. Plan IDs include all manifest and overlay bytes.

`requires` lists explicit **runtime** requirements, for example `env:API_TOKEN` or `op://vault/item/field`. Ordinary downloaded skill documents normally have none. Credentials used only to download or install a package belong to acquisition, not `requires`. An environment reference is available when that variable is nonempty; the command does not output or inject its value. A 1Password reference remains unresolved and blocks owned deployment in v1. Resolution belongs to an authorized provider integration; there is no override flag or secret store in this command.

## Inspect and apply

```sh
fclt capability inventory \
--manifest /path/to/canonical/capabilities.json \
--source-root /path/to/canonical \
--target-root /path/to/tool-home-parent \
--state-root /path/to/runtime/capabilities \
--host build-station --platform linux \
--overlay /path/to/machine/platform.json \
--overlay /path/to/private/capabilities.json
```

Use the same arguments with `plan --id example-skill`, then `apply --id example-skill --expected-plan sha256:...`. All commands emit JSON; `--json` is accepted. The source root defaults to the manifest's directory; host and platform default to the current machine. Source, target, and runtime state must be separate at the actual asset paths. Runtime state may be elsewhere under the target home. No live home is inferred.

| Status | Meaning and apply behavior |
| --- | --- |
| `create` | Target absent; copy the pinned source and record ownership. |
| `restore` | Previously owned target absent; explicitly restore it. |
| `adopt` | Target has identical bytes, or is an exact link to canonical source; record ownership and convert that link into a copy. |
| `update` | Target still matches its receipt; copy the new pinned source. |
| `current` | Source, target, provenance, and receipt match; apply is a no-op. |
| `conflict` | Source pin or live content diverged, binding changed, or a foreign/broken link exists; preserve it and refuse apply. |
| `blocked` | Source or a required runtime reference is unavailable; refuse apply. |
| `disabled` | Intentional exclusion; retain existing content and refuse apply. |
| `independent` | Native/external ownership; no reads of installed contents and no mutation. |

A missing target never implies intentional disable; use `enabled: false` to express that choice. The `targetPresence` field reports `present`, `absent`, or `uninspected`; disabled fclt-owned entries inspect presence without reading content. Disabling stops management and retains existing files. It does not uninstall a capability or disable a native plugin in its provider.

`plan` and `inventory` do not write state. `apply` rebuilds and compares the plan before writing. Stale plans fail. A per-target lock serializes cooperating applies; unexpected locks are not automatically removed. Updates stage and verify a copy in the runtime state directory, retain the previous target under `recovery-*/previous` there, publish, verify, and write the receipt. Runtime state and the target must share a filesystem; otherwise apply refuses before changing the target. Recovery content stays outside provider discovery. The apply result returns `recoveryPath`; save it with deployment evidence. The old content is never automatically deleted. If publication fails after moving the old target and the destination remains absent, the command restores it. A process kill may leave a transaction directory, lock, or missing target: inspect them, preserve recovery content, and plan recovery before clearing the stale lock.

These checks protect against ordinary drift and cooperating deployments. Stop other installers while applying; the command does not provide an OS-level transaction against unrelated processes racing filesystem changes. A successful apply proves file content and provenance match. It does not prove provider discovery, plugin installation, MCP authentication, or tool execution.
2 changes: 2 additions & 0 deletions plugins/fclt/skills/fclt-capability-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,5 @@ Drafting or recording does not authorize canonical apply, cross-scope promotion,
tracker changes, publishing, or scheduler activation. Preserve those gates and native authorization.
Do not infer effectiveness from apply: verify the producing workflow before resolving source writebacks.
Keep unchanged, non-actionable scheduled results quiet unless periodic reports were requested.

For a versioned capability registry, use `fclt capability inventory` with explicit source/target/state roots and host/platform overlays. Review source pins, ownership, drift, credential-reference availability, and retained disabled targets. Independent native records describe intent, not observed version or readiness. A successful file deployment does not prove provider discovery or execution. Preserve both sides of a conflict; do not repair it with an upstream installer in a managed output directory.
6 changes: 6 additions & 0 deletions plugins/fclt/skills/fclt-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,9 @@ fclt templates init operating-model --global --update
- assumptions and fresh-session state
- exact undo or rollback path
- what still needs approval

## Versioned skill and plugin sources

For registered skill, custom plugin, or MCP artifact updates, use `fclt capability inventory` and a per-entry `plan` before `apply --id ... --expected-plan ...`. Use the registry's explicit source, target, and runtime state roots; pass machine/private choices with ordered `--overlay` files. Acquire upstream updates into isolated staging, review the snapshot, and record its revision and `capability digest` before deploying. Do not run embedded installer or auto-update instructions against fclt-owned outputs, and do not use broad legacy sync to repair drift. Preserve divergent files for source selection.

Native plugin managers own their installed files. Registered native versions are intended versions until verified with the provider. `enabled: false` stops fclt management but retains existing files; it is not a provider disable or uninstall operation. See the package's `docs/capability-registry.md` for the schema, conflict states, and recovery procedure.
2 changes: 1 addition & 1 deletion src/builtin-assets.ts

Large diffs are not rendered by default.

153 changes: 153 additions & 0 deletions src/capability-apply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { randomUUID } from "node:crypto";
import {
lstat,
mkdir,
mkdtemp,
rename,
rm,
rmdir,
writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import {
capabilityCollisionPath,
optionalStat,
safeCapabilityPath,
snapshotCapability,
stageCapability,
} from "./capability-files";
import {
buildCapabilityPlan,
type CapabilityContext,
type CapabilityPlan,
type CapabilityReceipt,
} from "./capability-plan";
import { hashCapability } from "./capability-registry";

const APPLY_STATUSES = new Set([
"create",
"restore",
"adopt",
"update",
"current",
]);
export async function applyCapability(
options: CapabilityContext,
id: string,
expectedPlan: string
) {
const initial = await buildCapabilityPlan(options, id);
assertApplicable(initial, expectedPlan);
if (initial.status === "current") {
return { ...initial, applied: false, recoveryPath: null };
}
const parent = dirname(initial.targetPath!);
await safeCapabilityPath(parent);
await mkdir(parent, { recursive: true, mode: 0o700 });
await safeCapabilityPath(parent);
const lock = join(
parent,
`.fclt-${hashCapability(capabilityCollisionPath(initial.targetPath!)).slice(7)}.lock`
);
await mkdir(lock, { mode: 0o700 });
let transaction: string | null = null;
let recoveryPath: string | null = null;
let installed = false;
try {
const plan = await buildCapabilityPlan(options, id);
assertApplicable(plan, expectedPlan);
const snapshot = await snapshotCapability(plan.sourcePath!);
if (snapshot?.hash !== plan.hashes.source) {
throw new Error("Capability source changed after planning");
}
const stateParent = dirname(plan.statePath);
await safeCapabilityPath(stateParent);
await mkdir(stateParent, { recursive: true, mode: 0o700 });
await safeCapabilityPath(stateParent);
const stateDirectory = await lstat(stateParent);
// biome-ignore lint/suspicious/noBitwiseOperators: ownership state must not be writable by other users
if ((stateDirectory.mode & 0o022) !== 0) {
throw new Error(
"Capability runtime state must not be writable by other users"
);
}
if (stateDirectory.dev !== (await lstat(parent)).dev) {
throw new Error(
"Capability runtime state and target must share a filesystem for recoverable publication"
);
}
transaction = await mkdtemp(join(stateParent, "recovery-"));
const staged = join(transaction, "next");
await stageCapability(staged, snapshot);
if ((await snapshotCapability(staged))?.hash !== plan.hashes.source) {
throw new Error("Staged capability verification failed");
}
assertApplicable(await buildCapabilityPlan(options, id), expectedPlan);
if (plan.hashes.current !== plan.hashes.source) {
if (plan.hashes.current !== null) {
recoveryPath = join(transaction, "previous");
await rename(plan.targetPath!, recoveryPath);
}
await rename(staged, plan.targetPath!);
installed = true;
}
if ((await snapshotCapability(plan.targetPath!))?.hash !== snapshot.hash) {
throw new Error(
"Deployed capability verification failed; recovery content retained"
);
}
const receipt: CapabilityReceipt = {
schemaVersion: 1,
id,
sourcePath: plan.sourcePath!,
targetPath: plan.targetPath!,
sourceHash: snapshot.hash,
targetHash: snapshot.hash,
revision: plan.source.revision,
origin: plan.source.origin,
};
const temporaryReceipt = `${plan.statePath}.${randomUUID()}.tmp`;
await writeFile(temporaryReceipt, `${JSON.stringify(receipt, null, 2)}\n`, {
flag: "wx",
mode: 0o600,
});
await safeCapabilityPath(plan.statePath);
await rename(temporaryReceipt, plan.statePath);
const verified = await buildCapabilityPlan(options, id);
if (verified.status !== "current") {
throw new Error(
"Capability changed during verification; recovery content retained"
);
}
return { ...verified, applied: true, recoveryPath };
} catch (error) {
// If publication fails after moving the old target, restore it only into an
// absent destination. A competing writer's content must remain untouched.
if (
recoveryPath &&
!installed &&
!(await optionalStat(initial.targetPath!))
) {
await safeCapabilityPath(dirname(initial.targetPath!));
await rename(recoveryPath, initial.targetPath!);
recoveryPath = null;
}
throw error;
} finally {
// Never delete the previous target, including after interruption or failed publication.
if (transaction && !recoveryPath) {
await rm(transaction, { recursive: true, force: true });
}
await rmdir(lock);
}
}
function assertApplicable(plan: CapabilityPlan, expectedPlan: string): void {
if (plan.planId !== expectedPlan) {
throw new Error(
"Stale capability plan; inspect a fresh plan before applying"
);
}
if (!APPLY_STATUSES.has(plan.status)) {
throw new Error(`Capability apply refused: ${plan.status}: ${plan.reason}`);
}
}
Loading
Loading