diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aeceaa9..d276df5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] workflow_dispatch: permissions: @@ -50,19 +49,120 @@ jobs: - name: Install the catalog plugin from this checkout run: fledge plugins install . --yes --non-interactive - - name: Dogfood a repository-local skill install + - name: Dogfood the complete catalog lifecycle + shell: bash + run: | + test_root="$(mktemp -d)" + trap 'rm -rf "$test_root"' EXIT + fledge skills list --json > "$test_root/catalog.json" + + python3 - "$test_root/catalog.json" <<'PY' + import json + import sys + + with open(sys.argv[1], encoding="utf-8") as source: + catalog = json.load(source) + assert len(catalog["skills"]) == 15 + assert catalog["skills"] == sorted(catalog["skills"]) + PY + + for host in codex claude cursor gemini grok openai; do + test_repo="$test_root/$host" + mkdir -p "$test_repo" + git -C "$test_repo" init -q + + while IFS= read -r skill; do + fledge skills install "$skill" --repo "$test_repo" --host "$host" + done < <(fledge skills list) + + fledge skills status --repo "$test_repo" --json > "$test_root/$host-status.json" + python3 - "$test_root/$host-status.json" "$host" <<'PY' + import json + import sys + + with open(sys.argv[1], encoding="utf-8") as source: + status = json.load(source) + assert len(status["installs"]) == 15 + assert {item["host"] for item in status["installs"]} == {sys.argv[2]} + assert {item["state"] for item in status["installs"]} == {"current"} + PY + + fledge skills update --all --repo "$test_repo" --host "$host" --dry-run + fledge skills uninstall agent-coordination --repo "$test_repo" --host "$host" --dry-run + fledge skills uninstall agent-coordination --repo "$test_repo" --host "$host" + test ! -e "$test_repo/.$host/skills/agent-coordination" + done + + - name: Prove Grok placement with Let find skills shell: bash run: | - test_repo="$(mktemp -d)" - trap 'rm -rf "$test_repo"' EXIT + # Let’s fledge plugin build hook requires Bun. + curl -fsSL https://bun.sh/install | bash + export BUN_INSTALL="${HOME}/.bun" + export PATH="${BUN_INSTALL}/bin:${PATH}" + command -v bun + bun --version + fledge plugins install CorvidLabs/let@v0.2.0 --yes --non-interactive + test_repo="$(mktemp -d)/grok-let" + mkdir -p "$test_repo" git -C "$test_repo" init -q + fledge skills install agent-coordination --repo "$test_repo" --host grok + fledge skills install fledge-workflows --repo "$test_repo" --host grok + fledge let find skills --scope project --host grok \ + --repo "$test_repo" --cwd "$test_repo" --json \ + > /tmp/let-find-skills-grok.json + python3 - "$test_repo" <<'PY' + import json + import sys + from pathlib import Path - fledge skills list | grep -qx 'agent-coordination' - fledge skills list | grep -qx 'spec-sync' - fledge skills install agent-coordination --repo "$test_repo" --host codex - fledge skills install spec-sync --repo "$test_repo" --host codex - fledge skills status --repo "$test_repo" | grep -q '^agent-coordination' - fledge skills status --repo "$test_repo" | grep -q '^spec-sync' + repo = Path(sys.argv[1]).resolve() + payload = json.loads(Path("/tmp/let-find-skills-grok.json").read_text(encoding="utf-8")) + assert payload.get("ok") is True, payload + project = [] + for item in payload.get("data", {}).get("items", []): + if item.get("scope") != "project" or item.get("host") != "grok": + continue + path = Path(item.get("path", "")).resolve() + try: + path.relative_to(repo) + except ValueError: + continue + project.append(item) + names = {item["name"] for item in project} + assert names >= {"agent-coordination", "fledge-workflows"}, names + for item in project: + expected = repo / ".grok" / "skills" / item["name"] / "SKILL.md" + assert Path(item["path"]).resolve() == expected.resolve() + print("let find skills proof ok:", sorted(names)) + PY + fledge skills uninstall agent-coordination --repo "$test_repo" --host grok + test ! -e "$test_repo/.grok/skills/agent-coordination" + fledge let find skills --scope project --host grok \ + --repo "$test_repo" --cwd "$test_repo" \ + --query agent-coordination --json \ + > /tmp/let-find-after-uninstall.json + python3 - "$test_repo" <<'PY' + import json + import sys + from pathlib import Path - test -f "$test_repo/.codex/skills/agent-coordination/SKILL.md" - test -f "$test_repo/.codex/skills/spec-sync/SKILL.md" + repo = Path(sys.argv[1]).resolve() + payload = json.loads(Path("/tmp/let-find-after-uninstall.json").read_text(encoding="utf-8")) + assert payload.get("ok") is True, payload + project = [] + for item in payload.get("data", {}).get("items", []): + if ( + item.get("scope") != "project" + or item.get("host") != "grok" + or item.get("name") != "agent-coordination" + ): + continue + try: + Path(item.get("path", "")).resolve().relative_to(repo) + except ValueError: + continue + project.append(item) + assert not project, project + print("let post-uninstall proof ok") + PY diff --git a/README.md b/README.md index e1e2b30..8ae0dd0 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,130 @@ # CorvidLabs Skills -Versioned, shared skills for CorvidLabs agents. This repository holds cross-project -operating knowledge; each product repository keeps its own architecture and generated -Spec Sync material. - -## Initial skills - -- `agent-coordination` — use `fledge let` to discover the correct context, then use - `fledge rune` only to observe or send a scoped message to that confirmed agent session. -- `let` — locate the authoritative repository context, worktrees, sessions, instructions, - skills, and recent activity before acting. -- `rune` — safely observe or drive a confirmed CLI-agent session through a bounded PTY. -- `augur` — inspect deterministic Git change risk and enforce explicit review or block gates. -- `attest` — verify or record provenance evidence for exact reviewed commits. -- `atlas` — map specifications to code ownership, drift, review queues, and coverage gaps. -- `three-md` — author and validate general layered `.3md` documents. -- `agent-3md` — validate, route, preview, and explicitly execute `agent.3md` tool templates. -- `spec-sync` — the shared baseline for bidirectional Spec Sync work. A project may - generate a richer local version from its own configuration. -- `corvid-swift-package` — the shared operating baseline for CorvidLabs Swift packages: - Fledge-first discovery, Swift 6 concurrency, cross-platform support, and release hygiene. -- `corvid-web-bun` — build and validate CorvidLabs Bun web projects while deferring - framework, architecture, and release policy to the repository-local guides. -- `fledge-workflows` — discover and use repository-defined Fledge tasks and lanes. -- `spec-sync-routing` — route shared guidance to the repo-generated Spec Sync truth. -- `ci-release-hygiene` — keep CI and release evidence tied to the current commit. -- `public-release-audit` — audit a private repository before an explicit public-release decision. - -## Install with Fledge - -Bootstrap the Skills plugin once: +Public, versioned operating knowledge for software agents working with CorvidLabs tools. +Each skill is a portable `SKILL.md`; Fledge provides safe repository-local installation, +status, update, and uninstall commands. + +Product repositories remain authoritative for their own architecture, commands, and +generated Spec Sync material. + +## Quick start + +Install the catalog plugin, inspect it, and add only the skills a repository needs: ```sh fledge plugins install CorvidLabs/skills +fledge skills list +fledge skills install agent-coordination --host codex +fledge skills status ``` -Pin the source to a released tag when a catalog release is available. +To pin a published catalog release, add its tag to the source, for example: -Then install a selected skill into the current Git repository: +```sh +fledge plugins install CorvidLabs/skills@v0.2.0 +``` + +## Catalog + +### Coordination and discovery + +| Skill | Purpose | +| --- | --- | +| `agent-coordination` | Coordinate scoped discovery, Rune, Fledge, and Spec Sync verification. | +| `let` | Discover local agent context when its federated metadata scope is explicitly permitted. | +| `rune` | Observe or control a confirmed CLI-agent session through a bounded PTY. | +| `fledge-workflows` | Discover and use repository-defined Fledge tasks, lanes, plugins, and work commands. | + +### Product tools + +| Skill | Purpose | +| --- | --- | +| `augur` | Inspect deterministic Git change risk and apply explicit review or block gates. | +| `attest` | Verify or record provenance evidence for exact reviewed commits. | +| `atlas` | Map specifications to ownership, drift, review queues, and coverage gaps. | +| `three-md` | Author and validate layered `.3md` documents. | +| `agent-3md` | Validate, route, preview, and explicitly execute `agent.3md` tool templates. | + +### Project engineering + +| Skill | Purpose | +| --- | --- | +| `spec-sync` | Apply the shared, version-neutral baseline for bidirectional Spec Sync work. | +| `spec-sync-routing` | Route shared guidance to authoritative repository-generated Spec Sync instructions. | +| `corvid-swift-package` | Build CorvidLabs Swift packages with Fledge-first, Swift 6, and cross-platform practices. | +| `corvid-web-bun` | Build and verify local Bun/TypeScript web tools. | +| `ci-release-hygiene` | Tie CI and release evidence to the exact current commit. | +| `public-release-audit` | Audit a repository before an explicit public-release decision. | + +## Agent compatibility + +The skill content is agent-neutral Markdown. Any agent that can load a `SKILL.md` from +repository context can use it. Fledge currently provides automatic, collision-safe placement +for these hosts: + +| Host | Repository-local destination | Automatic placement | +| --- | --- | --- | +| Codex | `.codex/skills/` | Yes | +| Claude | `.claude/skills/` | Yes | +| Cursor | `.cursor/skills/` | Yes | +| Gemini | `.gemini/skills/` | Yes | +| Grok | `.grok/skills/` | Yes | +| OpenAI | `.openai/skills/` | Yes | +| Other agents | Agent-defined | No; use the agent's documented skill path. | + +Host placement does not translate private context or product-specific assumptions into a skill. +All catalog content and examples must remain usable from public CorvidLabs sources. +Fledge lifecycle tracking applies to the six automatic placements; a custom agent path remains +managed by that agent or by the user. + +## Manage installed skills + +Installs copy by default. Use `--link` only while developing this catalog locally. +Every placement is recorded in `.corvid-skills.json` with its source revision, mode, +destination, and content digest. ```sh -fledge skills list -fledge skills install agent-coordination --host codex +# Inspect human-readable or machine-readable state. +fledge skills status +fledge skills status --json + +# Preview, then update one skill or every managed skill. +fledge skills update augur --dry-run +fledge skills update --all --dry-run +fledge skills update --all + +# Preview, then remove one manifest-owned skill. +fledge skills uninstall augur --dry-run +fledge skills uninstall augur +``` + +`status` reports `current`, `modified`, or `missing`. Update and uninstall refuse modified +or missing copies, so local work is never overwritten or removed. For a manifest-owned link, +Fledge verifies the exact link target before refreshing metadata or removing the link. + +Update the catalog plugin separately when you want newer source content: + +```sh +fledge plugins update fledge-plugin-skills fledge skills status +fledge skills update --all --dry-run +fledge skills update --all ``` -`--host auto` is supported only when exactly one of the supported repository-local -host directories already exists. Use `--host` when setting up a new project or -when more than one host is present. Installs copy by default; `--link` is for -local skill development only. +Use `--host codex`, `--host claude`, `--host cursor`, `--host gemini`, `--host grok`, or +`--host openai` to select one placement when a skill is installed for multiple hosts. `--host auto` +is available for install only when exactly one supported host directory already exists. -`status` reports each managed install as `current`, `modified`, or `missing` by comparing -the recorded digest with safe repository-local placement. A generated Spec Sync skill owns -`.codex/skills/spec-sync`; do not install the shared skill over it. Install -`spec-sync-routing` beside generated guidance when shared routing is useful. +## Spec Sync placement -The initial plugin deliberately supports `list`, `install`, and `status` only. -Safe managed `update` and `uninstall` will follow after their ownership and -local-modification rules are tested. +A project-generated Spec Sync skill owns its repository-local `spec-sync` destination. +The installer never overwrites an existing directory. Keep generated guidance in place and +install `spec-sync-routing` beside it when shared routing is useful. This policy does not +depend on a particular Spec Sync generator version or generated directory layout. ## Verify the catalog -Use the repository-defined Fledge lanes: +Run the same Fledge-native checks used by CI: ```sh fledge run --list @@ -70,30 +133,23 @@ fledge lanes run verify fledge lanes run audit ``` -The verification lane requires Bash, Python 3, and ShellCheck. The audit lane also -requires Gitleaks and redacts any finding output. - -## Direct installer +The verification lane requires Bash, Python 3, and ShellCheck. The audit lane also requires +Gitleaks and redacts any finding output. -Install a named skill into a repository-local agent directory: +The direct installer is available for debugging or environments without plugin dispatch: ```sh +bin/corvid-skills list --json bin/corvid-skills install agent-coordination --repo /path/to/project --host codex -bin/corvid-skills install spec-sync --repo /path/to/project --host claude -``` - -Supported hosts are `codex`, `claude`, and `cursor`. Installations copy the selected -skill and record its source revision, destination, install mode, and content digest -in `.corvid-skills.json`. Existing skill directories are never overwritten. - -```sh -bin/corvid-skills list -bin/corvid-skills install agent-coordination --repo . --host codex --link +bin/corvid-skills status --repo /path/to/project --json +bin/corvid-skills update agent-coordination --repo /path/to/project --dry-run +bin/corvid-skills uninstall agent-coordination --repo /path/to/project --dry-run ``` ## Design rules -- Shared skills teach reusable operating practices. -- Repo-local skills are authoritative for that repo's commands, architecture, and policy. -- A generated Spec Sync skill is authoritative and must not be overwritten by the shared baseline. -- Installer changes are explicit and repository-local by default. +- Keep one concise skill per tool or workflow; avoid aliases and overlapping boilerplate. +- Verify real public commands before documenting them. +- Treat repository-local instructions as authoritative for that repository. +- Never include private paths, session content, secrets, user metadata, or private-repository assumptions. +- Make installation and lifecycle mutations explicit, manifest-owned, and repository-local. diff --git a/bin/corvid-skills b/bin/corvid-skills index 5ebcaa1..c13b6d5 100755 --- a/bin/corvid-skills +++ b/bin/corvid-skills @@ -10,13 +10,17 @@ usage() { Manage shared CorvidLabs agent skills in one repository. Usage: - fledge skills list - fledge skills install [--repo ] [--host ] [--link] [--dry-run] - fledge skills status [--repo ] + fledge skills list [--json] + fledge skills install [--repo ] [--host ] [--link] [--dry-run] + fledge skills status [--repo ] [--json] + fledge skills update |--all [--repo ] [--host ] [--dry-run] + fledge skills uninstall [--repo ] [--host ] [--dry-run] Install copies a selected skill into a repository-local agent directory and records the exact source revision and content digest in .corvid-skills.json. It never overwrites an existing directory. + +Update and uninstall operate only on unmodified managed copies or exact manifest-owned links. EOF } @@ -34,6 +38,9 @@ host_directory() { codex) printf '.codex/skills' ;; claude) printf '.claude/skills' ;; cursor) printf '.cursor/skills' ;; + gemini) printf '.gemini/skills' ;; + grok) printf '.grok/skills' ;; + openai) printf '.openai/skills' ;; *) fail "unsupported host: $1" ;; esac } @@ -59,13 +66,38 @@ ensure_safe_install_paths() { [[ ! -L "$repo/$manifest_name" ]] || fail "refusing symlinked manifest: $repo/$manifest_name" } +ensure_safe_managed_paths() { + local repo="$1" + local destination_rel="$2" + local install_mode="$3" + local current="$repo" + local components=() + local index + + IFS='/' read -r -a components <<< "$destination_rel" + for ((index = 0; index < ${#components[@]} - 1; index++)); do + current="$current/${components[$index]}" + [[ ! -L "$current" ]] || fail "refusing symlinked managed path: $current" + done + if [[ "$install_mode" == "copy" ]]; then + [[ ! -L "$repo/$destination_rel" ]] || fail "refusing symlinked managed copy: $repo/$destination_rel" + fi + [[ ! -L "$repo/$manifest_name" ]] || fail "refusing symlinked manifest: $repo/$manifest_name" +} + +has_only_regular_entries() { + local directory="$1" + [[ -d "$directory" && ! -L "$directory" ]] || return 1 + [[ -z "$(find "$directory" -mindepth 1 ! -type d ! -type f -print -quit)" ]] +} + validate_skill_source() { local skill="$1" [[ "$skill" =~ ^[a-z0-9][a-z0-9-]*$ ]] || fail "invalid skill name: $skill" [[ -d "$skills_dir/$skill" && ! -L "$skills_dir/$skill" && -f "$skills_dir/$skill/SKILL.md" ]] || fail "unknown skill: $skill" - [[ -z "$(find "$skills_dir/$skill" -type l -print -quit)" ]] || - fail "skill contains a symlink: $skill" + has_only_regular_entries "$skills_dir/$skill" || + fail "skill contains unsupported filesystem entries: $skill" } resolve_host() { @@ -79,7 +111,7 @@ resolve_host() { local candidates=() local host - for host in codex claude cursor; do + for host in codex claude cursor gemini grok openai; do [[ -d "$repo/$(host_directory "$host")" ]] && candidates+=("$host") done if [[ ${#candidates[@]} -eq 1 ]]; then @@ -87,20 +119,23 @@ resolve_host() { return fi if [[ ${#candidates[@]} -eq 0 ]]; then - fail "cannot infer an agent host; pass --host codex, --host claude, or --host cursor" + fail "cannot infer an agent host; pass --host codex, --host claude, --host cursor, --host gemini, --host grok, or --host openai" fi fail "multiple agent hosts are present (${candidates[*]}); pass --host explicitly" } directory_digest() { local directory="$1" + has_only_regular_entries "$directory" || return 1 ( cd "$directory" - find . -type f -print0 | LC_ALL=C sort -z | while IFS= read -r -d '' file; do - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$file" + find . -mindepth 1 -print0 | LC_ALL=C sort -z | while IFS= read -r -d '' entry; do + if [[ -d "$entry" ]]; then + printf 'directory\0%s\0' "$entry" + elif command -v shasum >/dev/null 2>&1; then + printf 'file\0%s\0%s\0' "$entry" "$(shasum -a 256 "$entry" | awk '{print $1}')" else - sha256sum "$file" + printf 'file\0%s\0%s\0' "$entry" "$(sha256sum "$entry" | awk '{print $1}')" fi done ) | if command -v shasum >/dev/null 2>&1; then @@ -111,7 +146,21 @@ directory_digest() { } list_skills() { - find "$skills_dir" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | LC_ALL=C sort + local json="false" + while [[ $# -gt 0 ]]; do + case "$1" in + --json) json="true"; shift ;; + *) fail "unknown option: $1" ;; + esac + done + if [[ "$json" == "true" ]]; then + require_python + find "$skills_dir" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | + LC_ALL=C sort | + python3 -c 'import json, sys; print(json.dumps({"skills": [line.rstrip("\n") for line in sys.stdin]}))' + else + find "$skills_dir" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | LC_ALL=C sort + fi } write_manifest_entry() { @@ -165,6 +214,158 @@ finally: PY } +manifest_rows() { + local manifest="$1" + require_python + python3 - "$manifest" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + data = json.load(source) +if data.get("schema_version") != 1 or not isinstance(data.get("installs"), list): + raise SystemExit(f"unsupported managed-skills manifest: {sys.argv[1]}") +for item in data["installs"]: + fields = ( + item["skill"], + item["host"], + item["destination"], + item["source_revision"], + item["install_mode"], + item.get("content_digest", ""), + ) + if any("\t" in str(field) or "\n" in str(field) for field in fields): + raise SystemExit("invalid control character in managed-skills manifest") + print("\t".join(str(field) for field in fields)) +PY +} + +expected_destination() { + local skill="$1" + local host="$2" + case "$host" in + codex) printf '.codex/skills/%s' "$skill" ;; + claude) printf '.claude/skills/%s' "$skill" ;; + cursor) printf '.cursor/skills/%s' "$skill" ;; + gemini) printf '.gemini/skills/%s' "$skill" ;; + grok) printf '.grok/skills/%s' "$skill" ;; + openai) printf '.openai/skills/%s' "$skill" ;; + *) return 1 ;; + esac +} + +entry_state() { + local repo="$1" + local skill="$2" + local host="$3" + local destination_rel="$4" + local install_mode="$5" + local expected_digest="$6" + local expected_rel="" + local destination="$repo/$destination_rel" + local current="$repo" + local components=() + local index + + [[ "$skill" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { printf 'modified'; return; } + expected_rel="$(expected_destination "$skill" "$host" 2>/dev/null || true)" + [[ -n "$expected_rel" && "$destination_rel" == "$expected_rel" ]] || { printf 'modified'; return; } + [[ "$install_mode" == "copy" || "$install_mode" == "link" ]] || { printf 'modified'; return; } + + IFS='/' read -r -a components <<< "$destination_rel" + for ((index = 0; index < ${#components[@]} - 1; index++)); do + current="$current/${components[$index]}" + [[ ! -L "$current" ]] || { printf 'modified'; return; } + done + + if [[ "$install_mode" == "link" && -L "$destination" ]]; then + if [[ "$(readlink "$destination")" == "$skills_dir/$skill" ]]; then + if has_only_regular_entries "$skills_dir/$skill"; then + local actual_digest + actual_digest="$(directory_digest "$skills_dir/$skill")" + [[ "$actual_digest" == "$expected_digest" ]] && printf 'current' || printf 'modified' + else + printf 'modified' + fi + else + printf 'modified' + fi + elif [[ "$install_mode" == "copy" && -d "$destination" && ! -L "$destination" ]]; then + if has_only_regular_entries "$destination"; then + local actual_digest + actual_digest="$(directory_digest "$destination")" + [[ "$actual_digest" == "$expected_digest" ]] && printf 'current' || printf 'modified' + else + printf 'modified' + fi + elif [[ -e "$destination" || -L "$destination" ]]; then + printf 'modified' + else + printf 'missing' + fi +} + +managed_link_is_owned() { + local repo="$1" + local skill="$2" + local destination_rel="$3" + local destination="$repo/$destination_rel" + + [[ -L "$destination" ]] || return 1 + [[ "$(readlink "$destination")" == "$skills_dir/$skill" ]] +} + +mutate_manifest_entry() { + local action="$1" + local manifest="$2" + local skill="$3" + local host="$4" + local revision="${5:-}" + local digest="${6:-}" + require_python + python3 - "$action" "$manifest" "$skill" "$host" "$revision" "$digest" <<'PY' +import datetime +import json +import os +import sys +import tempfile + +action, manifest, skill, host, revision, digest = sys.argv[1:] +with open(manifest, encoding="utf-8") as source: + data = json.load(source) +if data.get("schema_version") != 1 or not isinstance(data.get("installs"), list): + raise SystemExit(f"unsupported managed-skills manifest: {manifest}") +matches = [item for item in data["installs"] if item.get("skill") == skill and item.get("host") == host] +if len(matches) != 1: + raise SystemExit(f"expected one managed install for {skill} on {host}, found {len(matches)}") +if action == "update": + matches[0]["source_revision"] = revision + matches[0]["content_digest"] = digest + matches[0]["updated_at"] = ( + datetime.datetime.now(datetime.timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) +elif action == "remove": + data["installs"] = [ + item for item in data["installs"] + if not (item.get("skill") == skill and item.get("host") == host) + ] +else: + raise SystemExit(f"unsupported manifest mutation: {action}") +fd, temporary = tempfile.mkstemp(prefix=".corvid-skills.", dir=os.path.dirname(manifest) or ".") +try: + with os.fdopen(fd, "w", encoding="utf-8") as output: + json.dump(data, output, indent=2, sort_keys=True) + output.write("\n") + os.replace(temporary, manifest) +finally: + if os.path.exists(temporary): + os.unlink(temporary) +PY +} + install_skill() { local skill="$1" shift @@ -193,7 +394,7 @@ install_skill() { ensure_safe_install_paths "$repo" "$destination_rel" [[ ! -e "$destination" && ! -L "$destination" ]] || fail "destination already exists: $destination" - revision="$(git -C "$root_dir" rev-parse HEAD 2>/dev/null || printf 'working-tree')" + revision="$(git -C "$root_dir" rev-parse --verify HEAD 2>/dev/null || printf 'working-tree')" digest="$(directory_digest "$skills_dir/$skill")" if [[ "$dry_run" == "true" ]]; then local requested_mode="copy" @@ -206,7 +407,10 @@ install_skill() { mkdir -p "$(dirname "$destination")" if [[ "$link" == "true" ]]; then ln -s "$skills_dir/$skill" "$destination" - write_manifest_entry "$manifest" "$skill" "$host" "$destination_rel" "link" "$revision" "$digest" + if ! write_manifest_entry "$manifest" "$skill" "$host" "$destination_rel" "link" "$revision" "$digest"; then + rm -f -- "$destination" + exit 1 + fi else local temporary="${destination}.tmp.$$" cp -R "$skills_dir/$skill" "$temporary" @@ -219,68 +423,185 @@ install_skill() { printf 'Installed %s for %s at %s (revision %s)\n' "$skill" "$host" "$destination" "$revision" } +update_skills() { + [[ $# -ge 1 ]] || fail "update needs a skill name or --all" + local requested="$1" + shift + local repo_arg="." + local host_filter="" + local dry_run="false" + while [[ $# -gt 0 ]]; do + case "$1" in + --repo) [[ $# -ge 2 ]] || fail "--repo needs a path"; repo_arg="$2"; shift 2 ;; + --host) [[ $# -ge 2 ]] || fail "--host needs a value"; host_filter="$2"; shift 2 ;; + --dry-run) dry_run="true"; shift ;; + *) fail "unknown option: $1" ;; + esac + done + [[ "$requested" == "--all" || "$requested" =~ ^[a-z0-9][a-z0-9-]*$ ]] || + fail "invalid skill name: $requested" + [[ -z "$host_filter" ]] || host_directory "$host_filter" >/dev/null + + local repo manifest revision + repo="$(repository_root "$repo_arg")" + manifest="$repo/$manifest_name" + [[ -f "$manifest" && ! -L "$manifest" ]] || fail "managed-skills manifest not found: $manifest" + revision="$(git -C "$root_dir" rev-parse --verify HEAD 2>/dev/null || printf 'working-tree')" + local records=() + local row + while IFS=$'\t' read -r skill host destination_rel old_revision install_mode expected_digest; do + [[ "$requested" == "--all" || "$skill" == "$requested" ]] || continue + [[ -z "$host_filter" || "$host" == "$host_filter" ]] || continue + printf -v row '%s\t%s\t%s\t%s\t%s\t%s' \ + "$skill" "$host" "$destination_rel" "$old_revision" "$install_mode" "$expected_digest" + records+=("$row") + done < <(manifest_rows "$manifest") + [[ ${#records[@]} -gt 0 ]] || fail "no matching managed skills are installed" + + local record skill host destination_rel old_revision install_mode expected_digest state + for record in "${records[@]}"; do + IFS=$'\t' read -r skill host destination_rel old_revision install_mode expected_digest <<< "$record" + validate_skill_source "$skill" + [[ "$destination_rel" == "$(expected_destination "$skill" "$host" 2>/dev/null || true)" ]] || + fail "refusing unsafe manifest destination: $destination_rel" + ensure_safe_managed_paths "$repo" "$destination_rel" "$install_mode" + state="$(entry_state "$repo" "$skill" "$host" "$destination_rel" "$install_mode" "$expected_digest")" + if [[ "$install_mode" == "link" ]]; then + managed_link_is_owned "$repo" "$skill" "$destination_rel" || + fail "refusing to update $skill on $host: managed link was changed or removed" + else + [[ "$state" == "current" ]] || fail "refusing to update $skill on $host: state is $state" + fi + done + + local destination new_digest temporary backup + for record in "${records[@]}"; do + IFS=$'\t' read -r skill host destination_rel old_revision install_mode expected_digest <<< "$record" + destination="$repo/$destination_rel" + new_digest="$(directory_digest "$skills_dir/$skill")" + if [[ "$dry_run" == "true" ]]; then + printf 'Would update %s for %s at %s to revision %s\n' "$skill" "$host" "$destination" "$revision" + continue + fi + if [[ "$install_mode" == "link" ]]; then + mutate_manifest_entry update "$manifest" "$skill" "$host" "$revision" "$new_digest" + else + temporary="${destination}.update.$$" + backup="${destination}.backup.$$" + [[ ! -e "$temporary" && ! -L "$temporary" && ! -e "$backup" && ! -L "$backup" ]] || + fail "temporary update path already exists for $skill on $host" + cp -R "$skills_dir/$skill" "$temporary" + mv "$destination" "$backup" + mv "$temporary" "$destination" + if ! mutate_manifest_entry update "$manifest" "$skill" "$host" "$revision" "$new_digest"; then + rm -rf -- "$destination" + mv "$backup" "$destination" + exit 1 + fi + rm -rf -- "$backup" + fi + printf 'Updated %s for %s at %s to revision %s\n' "$skill" "$host" "$destination" "$revision" + done +} + +uninstall_skill() { + [[ $# -ge 1 ]] || fail "uninstall needs a skill name" + local requested="$1" + shift + [[ "$requested" =~ ^[a-z0-9][a-z0-9-]*$ ]] || fail "invalid skill name: $requested" + local repo_arg="." + local host_filter="" + local dry_run="false" + while [[ $# -gt 0 ]]; do + case "$1" in + --repo) [[ $# -ge 2 ]] || fail "--repo needs a path"; repo_arg="$2"; shift 2 ;; + --host) [[ $# -ge 2 ]] || fail "--host needs a value"; host_filter="$2"; shift 2 ;; + --dry-run) dry_run="true"; shift ;; + *) fail "unknown option: $1" ;; + esac + done + [[ -z "$host_filter" ]] || host_directory "$host_filter" >/dev/null + + local repo manifest + repo="$(repository_root "$repo_arg")" + manifest="$repo/$manifest_name" + [[ -f "$manifest" && ! -L "$manifest" ]] || fail "managed-skills manifest not found: $manifest" + local records=() + local row + while IFS=$'\t' read -r skill host destination_rel revision install_mode expected_digest; do + [[ "$skill" == "$requested" ]] || continue + [[ -z "$host_filter" || "$host" == "$host_filter" ]] || continue + printf -v row '%s\t%s\t%s\t%s\t%s\t%s' \ + "$skill" "$host" "$destination_rel" "$revision" "$install_mode" "$expected_digest" + records+=("$row") + done < <(manifest_rows "$manifest") + [[ ${#records[@]} -gt 0 ]] || fail "no matching managed skill is installed: $requested" + + local record skill host destination_rel revision install_mode expected_digest state + for record in "${records[@]}"; do + IFS=$'\t' read -r skill host destination_rel revision install_mode expected_digest <<< "$record" + [[ "$destination_rel" == "$(expected_destination "$skill" "$host" 2>/dev/null || true)" ]] || + fail "refusing unsafe manifest destination: $destination_rel" + ensure_safe_managed_paths "$repo" "$destination_rel" "$install_mode" + state="$(entry_state "$repo" "$skill" "$host" "$destination_rel" "$install_mode" "$expected_digest")" + if [[ "$install_mode" == "link" ]]; then + managed_link_is_owned "$repo" "$skill" "$destination_rel" || + fail "refusing to uninstall $skill on $host: managed link was changed or removed" + else + [[ "$state" == "current" ]] || fail "refusing to uninstall $skill on $host: state is $state" + fi + done + + local destination temporary + for record in "${records[@]}"; do + IFS=$'\t' read -r skill host destination_rel revision install_mode expected_digest <<< "$record" + destination="$repo/$destination_rel" + if [[ "$dry_run" == "true" ]]; then + printf 'Would uninstall %s for %s from %s\n' "$skill" "$host" "$destination" + continue + fi + temporary="${destination}.remove.$$" + [[ ! -e "$temporary" && ! -L "$temporary" ]] || fail "temporary removal path already exists for $skill on $host" + mv "$destination" "$temporary" + if ! mutate_manifest_entry remove "$manifest" "$skill" "$host"; then + mv "$temporary" "$destination" + exit 1 + fi + if [[ -L "$temporary" || -f "$temporary" ]]; then + rm -f -- "$temporary" + else + rm -rf -- "$temporary" + fi + printf 'Uninstalled %s for %s from %s\n' "$skill" "$host" "$destination" + done +} + status_skills() { local repo_arg="." + local json="false" while [[ $# -gt 0 ]]; do case "$1" in --repo) [[ $# -ge 2 ]] || fail "--repo needs a path"; repo_arg="$2"; shift 2 ;; + --json) json="true"; shift ;; *) fail "unknown option: $1" ;; esac done local repo manifest repo="$(repository_root "$repo_arg")" manifest="$repo/$manifest_name" + [[ ! -L "$manifest" ]] || fail "refusing symlinked manifest: $manifest" if [[ ! -f "$manifest" ]]; then - echo "No managed CorvidLabs skills are installed in $repo" + if [[ "$json" == "true" ]]; then + printf '{"schema_version":1,"installs":[]}\n' + else + echo "No managed CorvidLabs skills are installed in $repo" + fi return fi - require_python local status_output="" while IFS=$'\t' read -r skill host destination_rel revision install_mode expected_digest; do - local destination="$repo/$destination_rel" - local state="missing" - local safe_manifest="true" - local expected_destination_rel="" - local unsafe_parent="false" - local current="$repo" - local components=() - local index - case "$host" in - codex) expected_destination_rel=".codex/skills/$skill" ;; - claude) expected_destination_rel=".claude/skills/$skill" ;; - cursor) expected_destination_rel=".cursor/skills/$skill" ;; - *) safe_manifest="false" ;; - esac - [[ "$skill" =~ ^[a-z0-9][a-z0-9-]*$ ]] || safe_manifest="false" - [[ "$destination_rel" == "$expected_destination_rel" ]] || safe_manifest="false" - if [[ "$safe_manifest" == "true" ]]; then - IFS='/' read -r -a components <<< "$destination_rel" - for ((index = 0; index < ${#components[@]} - 1; index++)); do - current="$current/${components[$index]}" - if [[ -L "$current" ]]; then - unsafe_parent="true" - break - fi - done - fi - - if [[ "$safe_manifest" != "true" || "$unsafe_parent" == "true" ]]; then - state="modified" - elif [[ "$install_mode" == "link" && -L "$destination" ]]; then - if [[ "$(readlink "$destination")" == "$skills_dir/$skill" ]]; then - local actual_digest - actual_digest="$(directory_digest "$skills_dir/$skill")" - [[ "$actual_digest" == "$expected_digest" ]] && state="current" || state="modified" - else - state="modified" - fi - elif [[ -d "$destination" && ! -L "$destination" ]]; then - local actual_digest - actual_digest="$(directory_digest "$destination")" - [[ "$actual_digest" == "$expected_digest" ]] && state="current" || state="modified" - elif [[ -e "$destination" || -L "$destination" ]]; then - state="modified" - fi + local state + state="$(entry_state "$repo" "$skill" "$host" "$destination_rel" "$install_mode" "$expected_digest")" local status_line printf -v status_line '%s\t%s\t%s\t%s\t%s\t%s' \ "$skill" "$host" "$destination_rel" "$revision" "$install_mode" "$state" @@ -288,32 +609,36 @@ status_skills() { status_output+=$'\n' fi status_output+="$status_line" - done < <(python3 - "$manifest" <<'PY' + done < <(manifest_rows "$manifest") + if [[ "$json" == "true" ]]; then + { [[ -z "$status_output" ]] || printf '%s\n' "$status_output"; } | python3 -c ' import json import sys -with open(sys.argv[1], encoding="utf-8") as source: - data = json.load(source) -for item in data.get("installs", []): - fields = ( - item["skill"], - item["host"], - item["destination"], - item["source_revision"], - item["install_mode"], - item.get("content_digest", ""), - ) - if any("\t" in str(field) or "\n" in str(field) for field in fields): - raise SystemExit("invalid control character in managed-skills manifest") - print("\t".join(str(field) for field in fields)) -PY - ) - [[ -z "$status_output" ]] || printf '%s\n' "$status_output" + +installs = [] +for line in sys.stdin: + skill, host, destination, revision, mode, state = line.rstrip("\n").split("\t") + installs.append({ + "skill": skill, + "host": host, + "destination": destination, + "source_revision": revision, + "install_mode": mode, + "state": state, + }) +print(json.dumps({"schema_version": 1, "installs": installs}, sort_keys=True)) +' + else + [[ -z "$status_output" ]] || printf '%s\n' "$status_output" + fi } case "${1:-}" in - list) [[ $# -eq 1 ]] || fail "list accepts no options"; list_skills ;; + list) list_skills "${@:2}" ;; install) [[ $# -ge 2 ]] || { usage; exit 2; }; install_skill "$2" "${@:3}" ;; status) status_skills "${@:2}" ;; + update) [[ $# -ge 2 ]] || { usage; exit 2; }; update_skills "$2" "${@:3}" ;; + uninstall) [[ $# -ge 2 ]] || { usage; exit 2; }; uninstall_skill "$2" "${@:3}" ;; -h|--help|help|'') usage ;; *) fail "unknown command: $1" ;; esac diff --git a/plugin.toml b/plugin.toml index efe4dad..bf9a70c 100644 --- a/plugin.toml +++ b/plugin.toml @@ -1,6 +1,6 @@ [plugin] name = "fledge-plugin-skills" -version = "0.1.0" +version = "0.2.0" description = "Install and track shared CorvidLabs agent skills in a repository" author = "CorvidLabs" license = "MIT" diff --git a/skills/agent-coordination/SKILL.md b/skills/agent-coordination/SKILL.md index 7b3ebc3..87062d6 100644 --- a/skills/agent-coordination/SKILL.md +++ b/skills/agent-coordination/SKILL.md @@ -10,21 +10,27 @@ worktree for an agent task. ## Discover first -Use `fledge let` to establish the facts before acting: +For public-only work, establish facts from the confirmed repository without invoking Let's +federated discovery: ```sh -fledge let find worktrees --scope project --repo --json -fledge let find instructions --scope project --cwd --json -fledge let history --scope project --cwd --json +fledge work status +fledge run --list +git status --short --branch +git ls-files ``` -Confirm the repository, worktree, branch, sibling worktrees, applicable instructions, and -latest agent session. Do not infer ownership from a branch name alone. Session metadata is an -activity hint, not proof that work is delivered: compare it with the worktree, commits, pull -request, CI, and sandbox. +Read only tracked instructions and declared workflows. Do not infer ownership from a branch +name alone. Session metadata is an activity hint, not proof that work is delivered: compare it +with the worktree, commits, pull request, CI, and sandbox. + +Let 0.2 can return user-global or sibling-worktree metadata even for project-scoped queries. +Do not invoke it in a public-only workflow until the installed version is independently verified +to isolate the requested repository. Mark the Let step blocked and continue with public +repository evidence; do not filter private results after they have already been read. -For public-only work, avoid broad Let context queries that can include user-scoped path -metadata. Keep discovery project-scoped and rooted at a confirmed public repository. +When local agent metadata is explicitly authorized, use `fledge let --help` to select the +narrowest supported query and treat every returned path or session identifier as private. ## Observe by default @@ -69,7 +75,7 @@ Spec Sync failure must not be hidden by approving or rewriting scope without the After a prompt is accepted, verify the worktree, declared Fledge workflow, Spec Sync evidence, and CI again; delivery is not completion. If -the target session or worktree is wrong, stop and rediscover it with Let. If a provider or +the target session or worktree is wrong, stop and rediscover it within the task's privacy scope. If a provider or network connection fails, Rune cannot bypass it—report the boundary and rely on repository evidence. Assign one worktree and responsibility per agent, and use sibling-worktree context to avoid overlapping edits. For repeated CI failures, reproduce narrowly and add or confirm a diff --git a/skills/agent-coordination/agents/openai.yaml b/skills/agent-coordination/agents/openai.yaml new file mode 100644 index 0000000..5da3488 --- /dev/null +++ b/skills/agent-coordination/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Agent Coordination" + short_description: "Coordinate agents from discovery to verification" + default_prompt: "Use -coordination to safely coordinate this agent workflow from discovery through verification." diff --git a/skills/ci-release-hygiene/agents/openai.yaml b/skills/ci-release-hygiene/agents/openai.yaml new file mode 100644 index 0000000..dddf4aa --- /dev/null +++ b/skills/ci-release-hygiene/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "CI Release Hygiene" + short_description: "Tie CI and release evidence to exact commits" + default_prompt: "Use -release-hygiene to verify that CI and release evidence matches the current commit." diff --git a/skills/corvid-swift-package/agents/openai.yaml b/skills/corvid-swift-package/agents/openai.yaml new file mode 100644 index 0000000..d7ccdcc --- /dev/null +++ b/skills/corvid-swift-package/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Corvid Swift Package" + short_description: "Build and verify CorvidLabs Swift packages" + default_prompt: "Use -swift-package to implement and verify this CorvidLabs Swift package change." diff --git a/skills/corvid-web-bun/SKILL.md b/skills/corvid-web-bun/SKILL.md index 63aa138..c59a5e0 100644 --- a/skills/corvid-web-bun/SKILL.md +++ b/skills/corvid-web-bun/SKILL.md @@ -10,16 +10,17 @@ replacement for a repository's `AGENTS.md`, framework conventions, or product sp ## Start with the project -1. Locate the actual worktree and project-scoped instructions before editing: +1. Confirm the current public repository and its tracked instructions before editing: ```sh - fledge let find worktrees --scope project --repo --json - fledge let find instructions --scope project --cwd --json + fledge work status + fledge run --list + git status --short --branch + git ls-files ``` - For public-only work, do not use broad context queries that can include user-scoped paths - or session metadata. Fall back to public Git and Fledge repository facts when project-only - isolation is unavailable. + Do not use federated Let discovery in public-only work: Let 0.2 can enumerate user-global + or sibling-worktree metadata even for project-scoped queries. 2. Read the project instructions, `package.json`, and the affected spec or component. 3. Use the Fledge task named by the project. Inspect available tasks if it is unclear: diff --git a/skills/fledge-workflows/SKILL.md b/skills/fledge-workflows/SKILL.md index aa091a3..72928f9 100644 --- a/skills/fledge-workflows/SKILL.md +++ b/skills/fledge-workflows/SKILL.md @@ -18,7 +18,6 @@ fledge --help fledge plugins list fledge run --help fledge lanes --help -fledge let context --pack brief --cwd . --json ``` Use the documented task or lane that matches the change. If no appropriate Fledge task diff --git a/skills/fledge-workflows/agents/openai.yaml b/skills/fledge-workflows/agents/openai.yaml new file mode 100644 index 0000000..1ef8dbf --- /dev/null +++ b/skills/fledge-workflows/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Fledge Workflows" + short_description: "Discover and run repository Fledge workflows" + default_prompt: "Use -workflows to discover and run the repository's declared workflow." diff --git a/skills/let/SKILL.md b/skills/let/SKILL.md index 1cd6010..532aaf1 100644 --- a/skills/let/SKILL.md +++ b/skills/let/SKILL.md @@ -5,45 +5,48 @@ description: Discover authoritative CorvidLabs agent, session, worktree, instruc # Let Discovery -Use `fledge let` before selecting a worktree, resuming an agent, reporting status, or -installing a skill. Let is a locator and read-only context source; it does not expose an -agent's private live reasoning and it does not deliver prompts. +Let is a locator and read-only context source; it does not expose an agent's private live +reasoning and it does not deliver prompts. Use it only when the task permits discovery of +local agent metadata. ## Public-only boundary -Read-only does not mean public-safe. Broad `where` or context queries can report user-scoped -instruction paths or other local metadata. For public-only work, use explicit `--scope project` -queries rooted at a confirmed public repository, ignore or redact user-scope records, and never -publish local paths or session metadata. If the installed Let version cannot isolate project -scope for a query, use public Git and Fledge repository facts instead. +Read-only does not mean public-safe. Let 0.2 discovery is federated: even a `--scope project` +query can enumerate user-global instructions or sibling-worktree paths. `doctor`, `where`, +`context`, `find`, `history`, and skill routing can therefore touch local metadata outside the +public repository. + +Do not invoke Let in a public-only workflow unless the installed version has been independently +verified to isolate the requested repository. Never rely on filtering or redaction after a broad +query, because the private metadata has already been read. Use repository-local Git and Fledge +facts instead, and report the Let discovery step as blocked by its current isolation boundary. ## Start with the local facts -Run the health check and resolve the target from a concrete path or repository: +For public-only work, stay within the confirmed repository: ```sh -fledge let doctor --json -fledge let where --json -fledge let context --pack brief --cwd --json +fledge work status +fledge run --list +git status --short --branch +git ls-files ``` -Confirm the repository root, current worktree, branch, sibling worktrees, and applicable -instructions. Do not infer ownership from a branch name or a stale session title. +Read only tracked repository instructions and declared workflows. Do not inspect sibling +worktrees, user-level agent directories, sessions, or global instruction roots. Do not infer +ownership from a branch name or a stale session title. ## Find the right asset -Use structured output when another agent or tool will consume the result: +When local metadata discovery is explicitly in scope, inspect the installed command surface +before selecting the narrowest query: ```sh -fledge let find sessions --scope project --repo --json -fledge let find worktrees --scope project --repo --json -fledge let find instructions --scope project --cwd --json -fledge let history --scope project --cwd --json -fledge let skill route "" --json +fledge let --help ``` -Use `show` to inspect a specific returned identifier. Prefer a brief context pack unless a -task genuinely needs all instructions; excessive context is a coordination failure. +Prefer an exact identifier over a broad context pack. Treat returned paths and session metadata +as private unless their public provenance is independently established. ## Freshness and limits diff --git a/skills/let/agents/openai.yaml b/skills/let/agents/openai.yaml new file mode 100644 index 0000000..6edeef3 --- /dev/null +++ b/skills/let/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Let Discovery" + short_description: "Discover authorized local agent context safely" + default_prompt: "Use $let to discover only the local agent context permitted by this task." diff --git a/skills/public-release-audit/agents/openai.yaml b/skills/public-release-audit/agents/openai.yaml new file mode 100644 index 0000000..ab67fa0 --- /dev/null +++ b/skills/public-release-audit/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Public Release Audit" + short_description: "Audit repositories before public release" + default_prompt: "Use -release-audit to assess this repository for a safe public release." diff --git a/skills/rune/agents/openai.yaml b/skills/rune/agents/openai.yaml new file mode 100644 index 0000000..698430c --- /dev/null +++ b/skills/rune/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Rune Session Control" + short_description: "Observe and control confirmed agent sessions" + default_prompt: "Use to safely observe or control this confirmed CLI-agent session." diff --git a/skills/spec-sync-routing/agents/openai.yaml b/skills/spec-sync-routing/agents/openai.yaml new file mode 100644 index 0000000..234af74 --- /dev/null +++ b/skills/spec-sync-routing/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Spec Sync Routing" + short_description: "Route work to repository-local Spec Sync truth" + default_prompt: "Use -sync-routing to locate and follow this repository's generated Spec Sync guidance." diff --git a/skills/spec-sync/agents/openai.yaml b/skills/spec-sync/agents/openai.yaml new file mode 100644 index 0000000..0751a6e --- /dev/null +++ b/skills/spec-sync/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Spec Sync" + short_description: "Keep project specifications and code synchronized" + default_prompt: "Use -sync to keep this change and its project specifications synchronized." diff --git a/tests/installer.sh b/tests/installer.sh index 9c44fd7..3a2067a 100755 --- a/tests/installer.sh +++ b/tests/installer.sh @@ -14,6 +14,14 @@ mkdir -p "$test_dir/project/subdirectory" "$installer" list | grep -qx 'spec-sync' "$installer" list | grep -qx 'let' "$installer" list | grep -qx 'rune' +"$installer" list --json | python3 -c ' +import json +import sys + +data = json.load(sys.stdin) +assert len(data["skills"]) == 15 +assert data["skills"] == sorted(data["skills"]) +' for skill in \ agent-3md \ atlas \ @@ -87,8 +95,23 @@ if "$installer" install agent-coordination --repo "$manifest_repo" --host codex; echo "expected a symlinked manifest to be rejected" >&2 exit 1 fi +if "$installer" status --repo "$manifest_repo"; then + echo "expected status to reject a symlinked manifest" >&2 + exit 1 +fi test -L "$manifest_repo/.corvid-skills.json" +invalid_manifest_repo="$test_dir/invalid-manifest-repo" +mkdir -p "$invalid_manifest_repo" +git -C "$invalid_manifest_repo" init -q +printf '{"schema_version":999,"installs":[]}\n' > "$invalid_manifest_repo/.corvid-skills.json" +if "$installer" install agent-coordination --repo "$invalid_manifest_repo" --host codex --link; then + echo "expected an unsupported manifest to reject a link install" >&2 + exit 1 +fi +test ! -e "$invalid_manifest_repo/.codex/skills/agent-coordination" +test ! -L "$invalid_manifest_repo/.codex/skills/agent-coordination" + if "$installer" install ../skills/agent-coordination --repo "$test_dir" --host codex; then echo "expected a path-like skill name to be rejected" >&2 exit 1 @@ -101,6 +124,22 @@ git -C "$link_repo" init -q test -L "$link_repo/.claude/skills/spec-sync" "$installer" status --repo "$link_repo" | grep -q $'^spec-sync\tclaude\t.claude/skills/spec-sync\t.*\tlink\tcurrent$' +gemini_repo="$test_dir/gemini-repo" +mkdir -p "$gemini_repo/.gemini/skills" +git -C "$gemini_repo" init -q +"$installer" install agent-coordination --repo "$gemini_repo" --host auto +test -f "$gemini_repo/.gemini/skills/agent-coordination/SKILL.md" +"$installer" status --repo "$gemini_repo" | + grep -q $'^agent-coordination\tgemini\t.gemini/skills/agent-coordination\t.*\tcopy\tcurrent$' + +gemini_link_repo="$test_dir/gemini-link-repo" +mkdir -p "$gemini_link_repo" +git -C "$gemini_link_repo" init -q +"$installer" install spec-sync --repo "$gemini_link_repo" --host gemini --link +test -L "$gemini_link_repo/.gemini/skills/spec-sync" +"$installer" status --repo "$gemini_link_repo" | + grep -q $'^spec-sync\tgemini\t.gemini/skills/spec-sync\t.*\tlink\tcurrent$' + status_repo="$test_dir/status-repo" mkdir -p "$status_repo" git -C "$status_repo" init -q @@ -111,6 +150,22 @@ printf '\nlocal edit\n' >> "$status_repo/.codex/skills/augur/SKILL.md" mv "$status_repo/.codex/skills/augur" "$status_repo/.codex/skills/augur.moved" "$installer" status --repo "$status_repo" | grep -q $'^augur\t.*\tmissing$' +extra_entry_repo="$test_dir/extra-entry-repo" +mkdir -p "$extra_entry_repo" +git -C "$extra_entry_repo" init -q +"$installer" install augur --repo "$extra_entry_repo" --host codex +ln -s "$root_dir/README.md" "$extra_entry_repo/.codex/skills/augur/user-added-link" +"$installer" status --repo "$extra_entry_repo" | grep -q $'^augur\t.*\tmodified$' +if "$installer" update augur --repo "$extra_entry_repo"; then + echo "expected update to reject a copied skill with an added symlink" >&2 + exit 1 +fi +if "$installer" uninstall augur --repo "$extra_entry_repo"; then + echo "expected uninstall to reject a copied skill with an added symlink" >&2 + exit 1 +fi +test -L "$extra_entry_repo/.codex/skills/augur/user-added-link" + unsafe_status_repo="$test_dir/unsafe-status-repo" mkdir -p "$unsafe_status_repo" git -C "$unsafe_status_repo" init -q @@ -126,6 +181,14 @@ with open(sys.argv[1], "w", encoding="utf-8") as output: json.dump(manifest, output) PY "$installer" status --repo "$unsafe_status_repo" | grep -q $'^atlas\t.*\tmodified$' +if "$installer" update atlas --repo "$unsafe_status_repo"; then + echo "expected update to reject an unsafe manifest destination" >&2 + exit 1 +fi +if "$installer" uninstall atlas --repo "$unsafe_status_repo"; then + echo "expected uninstall to reject an unsafe manifest destination" >&2 + exit 1 +fi dry_run_repo="$test_dir/dry-run-repo" mkdir -p "$dry_run_repo" @@ -146,4 +209,352 @@ grep -q $'^spec-sync\t.*\tcurrent$' <<< "$root_status" nested_status="$("$installer" status --repo "$test_dir/project/subdirectory")" grep -q $'^spec-sync\t.*\tcurrent$' <<< "$nested_status" "$installer" status --repo "$test_dir" | grep -q '^agent-coordination' + +status_json="$test_dir/status.json" +"$installer" status --repo "$test_dir" --json > "$status_json" +python3 - "$status_json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + data = json.load(source) +assert data["schema_version"] == 1 +assert len(data["installs"]) == 4 +assert {item["state"] for item in data["installs"]} == {"current"} +PY + +lifecycle_source="$test_dir/lifecycle-source" +lifecycle_repo="$test_dir/lifecycle-repo" +mkdir -p "$lifecycle_source/bin" "$lifecycle_source/skills" "$lifecycle_repo" +cp "$installer" "$lifecycle_source/bin/corvid-skills" +cp -R "$root_dir/skills/augur" "$lifecycle_source/skills/augur" +cp -R "$root_dir/skills/attest" "$lifecycle_source/skills/attest" +git -C "$lifecycle_source" init -q +git -C "$lifecycle_repo" init -q +lifecycle_installer="$lifecycle_source/bin/corvid-skills" +"$lifecycle_installer" install augur --repo "$lifecycle_repo" --host codex +"$lifecycle_installer" install attest --repo "$lifecycle_repo" --host codex +printf '\nupdated catalog marker\n' >> "$lifecycle_source/skills/augur/SKILL.md" +"$lifecycle_installer" update augur --repo "$lifecycle_repo" --dry-run +if grep -q 'updated catalog marker' "$lifecycle_repo/.codex/skills/augur/SKILL.md"; then + echo "expected dry-run update not to change installed content" >&2 + exit 1 +fi +"$lifecycle_installer" update --all --repo "$lifecycle_repo" --host codex +grep -q 'updated catalog marker' "$lifecycle_repo/.codex/skills/augur/SKILL.md" +"$lifecycle_installer" status --repo "$lifecycle_repo" | grep -q $'^augur\t.*\tcurrent$' +python3 - "$lifecycle_repo/.corvid-skills.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + manifest = json.load(source) +augur = next(item for item in manifest["installs"] if item["skill"] == "augur") +assert augur["updated_at"] +PY + +printf '\nconsumer edit\n' >> "$lifecycle_repo/.codex/skills/augur/SKILL.md" +if "$lifecycle_installer" update augur --repo "$lifecycle_repo"; then + echo "expected update to reject a modified install" >&2 + exit 1 +fi +if "$lifecycle_installer" uninstall augur --repo "$lifecycle_repo"; then + echo "expected uninstall to reject a modified install" >&2 + exit 1 +fi +grep -q 'consumer edit' "$lifecycle_repo/.codex/skills/augur/SKILL.md" + +"$lifecycle_installer" uninstall attest --repo "$lifecycle_repo" --dry-run +test -d "$lifecycle_repo/.codex/skills/attest" +"$lifecycle_installer" uninstall attest --repo "$lifecycle_repo" +test ! -e "$lifecycle_repo/.codex/skills/attest" +python3 - "$lifecycle_repo/.corvid-skills.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + manifest = json.load(source) +assert all(item["skill"] != "attest" for item in manifest["installs"]) +PY + +link_update_repo="$test_dir/link-update-repo" +mkdir -p "$link_update_repo" +git -C "$link_update_repo" init -q +"$lifecycle_installer" install augur --repo "$link_update_repo" --host claude --link +printf '\nsecond catalog marker\n' >> "$lifecycle_source/skills/augur/SKILL.md" +"$lifecycle_installer" status --repo "$link_update_repo" | grep -q $'^augur\t.*\tmodified$' +"$lifecycle_installer" update augur --repo "$link_update_repo" +test -L "$link_update_repo/.claude/skills/augur" +"$lifecycle_installer" status --repo "$link_update_repo" | grep -q $'^augur\t.*\tcurrent$' +"$lifecycle_installer" uninstall augur --repo "$link_update_repo" +test ! -e "$link_update_repo/.claude/skills/augur" + +grok_repo="$test_dir/grok-repo" +mkdir -p "$grok_repo" +git -C "$grok_repo" init -q +"$installer" install agent-coordination --repo "$grok_repo" --host grok +test -f "$grok_repo/.grok/skills/agent-coordination/SKILL.md" +"$installer" status --repo "$grok_repo" | grep -q $'^agent-coordination\tgrok\t.grok/skills/agent-coordination\t.*\tcopy\tcurrent$' +python3 - "$grok_repo/.corvid-skills.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + manifest = json.load(source) +assert len(manifest["installs"]) == 1 +assert manifest["installs"][0]["host"] == "grok" +assert manifest["installs"][0]["destination"] == ".grok/skills/agent-coordination" +PY +"$installer" uninstall agent-coordination --repo "$grok_repo" --dry-run +test -d "$grok_repo/.grok/skills/agent-coordination" +"$installer" uninstall agent-coordination --repo "$grok_repo" +test ! -e "$grok_repo/.grok/skills/agent-coordination" +python3 - "$grok_repo/.corvid-skills.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + manifest = json.load(source) +assert manifest["installs"] == [] +PY + +grok_auto_repo="$test_dir/grok-auto-repo" +mkdir -p "$grok_auto_repo/.grok/skills" +git -C "$grok_auto_repo" init -q +"$installer" install let --repo "$grok_auto_repo" --host auto +test -f "$grok_auto_repo/.grok/skills/let/SKILL.md" +"$installer" status --repo "$grok_auto_repo" | grep -q $'^let\tgrok\t.grok/skills/let\t.*\tcurrent$' + +# Prove Grok project placement is visible to Let find skills when Let is available. +# Let 0.2 may still federate user-global skills; only project-scope rows are asserted. +if command -v fledge >/dev/null 2>&1 && fledge let version >/dev/null 2>&1; then + grok_let_repo="$test_dir/grok-let-repo" + mkdir -p "$grok_let_repo" + git -C "$grok_let_repo" init -q + "$installer" install agent-coordination --repo "$grok_let_repo" --host grok + "$installer" install fledge-workflows --repo "$grok_let_repo" --host grok + fledge let find skills --scope project --host grok \ + --repo "$grok_let_repo" --cwd "$grok_let_repo" --json \ + > "$test_dir/let-find-skills-grok.json" + python3 - "$test_dir/let-find-skills-grok.json" "$grok_let_repo" <<'PY' +import json +import sys +from pathlib import Path + +payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +repo = Path(sys.argv[2]).resolve() +assert payload.get("ok") is True, payload +items = payload.get("data", {}).get("items", []) +project = [] +for item in items: + if item.get("scope") != "project" or item.get("host") != "grok": + continue + path = Path(item.get("path", "")).resolve() + try: + path.relative_to(repo) + except ValueError: + continue + project.append(item) +names = {item["name"] for item in project} +assert names >= {"agent-coordination", "fledge-workflows"}, names +for item in project: + expected = repo / ".grok" / "skills" / item["name"] / "SKILL.md" + assert Path(item["path"]).resolve() == expected.resolve(), (item["path"], expected) +PY + fledge let find skills --scope project --host grok \ + --repo "$grok_let_repo" --cwd "$grok_let_repo" \ + --query agent-coordination --json \ + > "$test_dir/let-find-query-agent-coordination.json" + python3 - "$test_dir/let-find-query-agent-coordination.json" "$grok_let_repo" <<'PY' +import json +import sys +from pathlib import Path + +payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +repo = Path(sys.argv[2]).resolve() +assert payload.get("ok") is True, payload +project = [] +for item in payload.get("data", {}).get("items", []): + if ( + item.get("scope") != "project" + or item.get("host") != "grok" + or item.get("name") != "agent-coordination" + ): + continue + path = Path(item.get("path", "")).resolve() + try: + path.relative_to(repo) + except ValueError: + continue + project.append(item) +assert len(project) == 1, project +assert project[0]["path"].endswith(".grok/skills/agent-coordination/SKILL.md") +PY + "$installer" uninstall agent-coordination --repo "$grok_let_repo" --host grok + fledge let find skills --scope project --host grok \ + --repo "$grok_let_repo" --cwd "$grok_let_repo" \ + --query agent-coordination --json \ + > "$test_dir/let-find-after-uninstall.json" + python3 - "$test_dir/let-find-after-uninstall.json" "$grok_let_repo" <<'PY' +import json +import sys +from pathlib import Path + +payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +repo = Path(sys.argv[2]).resolve() +assert payload.get("ok") is True, payload +project = [] +for item in payload.get("data", {}).get("items", []): + if ( + item.get("scope") != "project" + or item.get("host") != "grok" + or item.get("name") != "agent-coordination" + ): + continue + path = Path(item.get("path", "")).resolve() + try: + path.relative_to(repo) + except ValueError: + continue + project.append(item) +assert project == [], project +PY +fi + +openai_repo="$test_dir/openai-repo" +mkdir -p "$openai_repo" +git -C "$openai_repo" init -q +"$installer" install agent-coordination --repo "$openai_repo" --host openai +test -f "$openai_repo/.openai/skills/agent-coordination/SKILL.md" +"$installer" status --repo "$openai_repo" | grep -q $'^agent-coordination\topenai\t.openai/skills/agent-coordination\t.*\tcopy\tcurrent$' +python3 - "$openai_repo/.corvid-skills.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + manifest = json.load(source) +assert len(manifest["installs"]) == 1 +assert manifest["installs"][0]["host"] == "openai" +assert manifest["installs"][0]["destination"] == ".openai/skills/agent-coordination" +PY +"$installer" uninstall agent-coordination --repo "$openai_repo" --dry-run +test -d "$openai_repo/.openai/skills/agent-coordination" +"$installer" uninstall agent-coordination --repo "$openai_repo" +test ! -e "$openai_repo/.openai/skills/agent-coordination" +python3 - "$openai_repo/.corvid-skills.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + manifest = json.load(source) +assert manifest["installs"] == [] +PY + +openai_auto_repo="$test_dir/openai-auto-repo" +mkdir -p "$openai_auto_repo/.openai/skills" +git -C "$openai_auto_repo" init -q +"$installer" install let --repo "$openai_auto_repo" --host auto +test -f "$openai_auto_repo/.openai/skills/let/SKILL.md" +"$installer" status --repo "$openai_auto_repo" | grep -q $'^let\topenai\t.openai/skills/let\t.*\tcurrent$' + +# Prove OpenAI project placement is visible to Let find skills when Let supports it. +# This requires a Let version that recognizes .openai/skills roots (CorvidLabs/let#6). +if command -v fledge >/dev/null 2>&1 && fledge let version >/dev/null 2>&1; then + if fledge let find skills --scope project --host openai \ + --repo "$openai_repo" --cwd "$openai_repo" --json >/dev/null 2>&1; then + openai_let_repo="$test_dir/openai-let-repo" + mkdir -p "$openai_let_repo" + git -C "$openai_let_repo" init -q + "$installer" install agent-coordination --repo "$openai_let_repo" --host openai + "$installer" install fledge-workflows --repo "$openai_let_repo" --host openai + fledge let find skills --scope project --host openai \ + --repo "$openai_let_repo" --cwd "$openai_let_repo" --json \ + > "$test_dir/let-find-skills-openai.json" + python3 - "$test_dir/let-find-skills-openai.json" "$openai_let_repo" <<'PY' +import json +import sys +from pathlib import Path + +payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +repo = Path(sys.argv[2]).resolve() +assert payload.get("ok") is True, payload +project = [] +for item in payload.get("data", {}).get("items", []): + if item.get("scope") != "project" or item.get("host") != "openai": + continue + path = Path(item.get("path", "")).resolve() + try: + path.relative_to(repo) + except ValueError: + continue + project.append(item) +names = {item["name"] for item in project} +assert names >= {"agent-coordination", "fledge-workflows"}, names +for item in project: + expected = repo / ".openai" / "skills" / item["name"] / "SKILL.md" + assert Path(item["path"]).resolve() == expected.resolve(), (item["path"], expected) +PY + fledge let find skills --scope project --host openai \ + --repo "$openai_let_repo" --cwd "$openai_let_repo" \ + --query agent-coordination --json \ + > "$test_dir/let-find-query-agent-coordination-openai.json" + python3 - "$test_dir/let-find-query-agent-coordination-openai.json" "$openai_let_repo" <<'PY' +import json +import sys +from pathlib import Path + +payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +repo = Path(sys.argv[2]).resolve() +assert payload.get("ok") is True, payload +project = [] +for item in payload.get("data", {}).get("items", []): + if ( + item.get("scope") != "project" + or item.get("host") != "openai" + or item.get("name") != "agent-coordination" + ): + continue + path = Path(item.get("path", "")).resolve() + try: + path.relative_to(repo) + except ValueError: + continue + project.append(item) +assert len(project) == 1, project +assert project[0]["path"].endswith(".openai/skills/agent-coordination/SKILL.md") +PY + "$installer" uninstall agent-coordination --repo "$openai_let_repo" --host openai + fledge let find skills --scope project --host openai \ + --repo "$openai_let_repo" --cwd "$openai_let_repo" \ + --query agent-coordination --json \ + > "$test_dir/let-find-after-uninstall-openai.json" + python3 - "$test_dir/let-find-after-uninstall-openai.json" "$openai_let_repo" <<'PY' +import json +import sys +from pathlib import Path + +payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +repo = Path(sys.argv[2]).resolve() +assert payload.get("ok") is True, payload +project = [] +for item in payload.get("data", {}).get("items", []): + if ( + item.get("scope") != "project" + or item.get("host") != "openai" + or item.get("name") != "agent-coordination" + ): + continue + path = Path(item.get("path", "")).resolve() + try: + path.relative_to(repo) + except ValueError: + continue + project.append(item) +assert project == [], project +PY + else + echo "skipping OpenAI Let proof: installed Let does not recognize .openai/skills" + fi +fi + echo "installer tests passed"