diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad24491..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: @@ -67,7 +66,7 @@ jobs: assert catalog["skills"] == sorted(catalog["skills"]) PY - for host in codex claude cursor; do + 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 @@ -93,3 +92,77 @@ jobs: 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: | + # 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 + + 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 + + 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 f8a933f..8ae0dd0 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,14 @@ for these hosts: | 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 three automatic placements; a custom agent path remains +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 @@ -108,9 +111,9 @@ fledge skills update --all --dry-run fledge skills update --all ``` -Use `--host codex`, `--host claude`, or `--host cursor` 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. +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. ## Spec Sync placement diff --git a/bin/corvid-skills b/bin/corvid-skills index 2bdfb7f..c13b6d5 100755 --- a/bin/corvid-skills +++ b/bin/corvid-skills @@ -11,10 +11,10 @@ Manage shared CorvidLabs agent skills in one repository. Usage: fledge skills list [--json] - fledge skills install [--repo ] [--host ] [--link] [--dry-run] + 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] + 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. @@ -38,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 } @@ -82,13 +85,19 @@ ensure_safe_managed_paths() { [[ ! -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() { @@ -102,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 @@ -110,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 @@ -235,6 +247,9 @@ expected_destination() { 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 } @@ -265,16 +280,24 @@ entry_state() { 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 "$skills_dir/$skill")" + actual_digest="$(directory_digest "$destination")" [[ "$actual_digest" == "$expected_digest" ]] && printf 'current' || printf 'modified' else printf 'modified' fi - elif [[ "$install_mode" == "copy" && -d "$destination" && ! -L "$destination" ]]; then - local actual_digest - actual_digest="$(directory_digest "$destination")" - [[ "$actual_digest" == "$expected_digest" ]] && printf 'current' || printf 'modified' elif [[ -e "$destination" || -L "$destination" ]]; then printf 'modified' else diff --git a/tests/installer.sh b/tests/installer.sh index 8e0b862..3a2067a 100755 --- a/tests/installer.sh +++ b/tests/installer.sh @@ -124,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 @@ -134,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 @@ -256,4 +288,273 @@ 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"