diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index edc06f4..af08b6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,6 +31,7 @@ make check spec/hosts.json host adapter database spec/hosts.schema.json host spec schema testdata/ skill fixtures and golden cases +tests/go-golden/ repository-level Go parity harness docs/ product and contract docs examples/ minimal SDK consumer CLIs Makefile root developer commands @@ -58,7 +59,7 @@ Run the full parity gate before opening a pull request: make check ``` -This validates the shared spec, fixtures, generated host constants, TypeScript, Go, Rust, and examples. +This validates the shared spec, fixtures, generated host constants, standalone Go modules, TypeScript, Go, Rust, and examples. ## Host Adapter Changes diff --git a/Makefile b/Makefile index 59a40c6..8081d6c 100644 --- a/Makefile +++ b/Makefile @@ -10,13 +10,14 @@ RESET := \033[0m TS_DIR := ts GO_DIR := go GO_COBRA_DIR := go-cobra +GO_GOLDEN_DIR := tests/go-golden RUST_DIR := rust PYTHON_DIR := python EXAMPLE_TS_DIR := examples/ts EXAMPLE_GO_DIR := examples/go EXAMPLE_RUST_DIR := examples/rust EXAMPLE_PYTHON_DIR := examples/python -GO_FILES := $(shell find $(GO_DIR) $(GO_COBRA_DIR) $(EXAMPLE_GO_DIR) -name '*.go' -type f) +GO_FILES := $(shell find $(GO_DIR) $(GO_COBRA_DIR) $(GO_GOLDEN_DIR) $(EXAMPLE_GO_DIR) -name '*.go' -type f) # ── Quality ────────────────────────────────────────────────────────────────── @@ -31,7 +32,7 @@ test-ts: ## Run TypeScript tests pnpm --dir $(TS_DIR) test test-go: ## Run Go SDK tests - cd $(GO_DIR) && go test ./... + node scripts/check-go-modules.mjs test-go-cobra: ## Run Go Cobra adapter tests cd $(GO_COBRA_DIR) && go test ./... @@ -45,7 +46,7 @@ test-python: ## Run Python SDK tests fmt: fmt-ts fmt-go fmt-rust fmt-python ## Format all SDK code fmt-ts: ## Format TypeScript code - cd $(TS_DIR) && pnpm exec prettier --write src test ../examples/ts/cli.ts ../scripts/check.mjs ../scripts/prepare-release.mjs + cd $(TS_DIR) && pnpm exec prettier --write src test ../examples/ts/cli.ts ../scripts/check.mjs ../scripts/check-go-modules.mjs ../scripts/prepare-release.mjs fmt-go: ## Format Go code gofmt -w $(GO_FILES) diff --git a/docs/API.md b/docs/API.md index 2727443..7e4dce6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -415,12 +415,13 @@ In TTY mode, zero detected hosts prompts from all supported hosts. One detected Selector semantics: -- `scope: "user"` installs into the first `userSkillsDirs` path for each host. -- `scope: "project"` installs into the first `projectSkillsDirs` path for each host. -- `agents: "auto"` uses host detection. +- `scope: "user"` keeps a valid kitup-owned target for the same skill on its configured path; normal ownership checks still decide whether the requesting app may mutate it. Otherwise, it reuses the first `userSkillsDirs` path that exists as a directory or falls back to the first configured path. +- `scope: "project"` keeps a valid kitup-owned target for the same skill on its configured path; normal ownership checks still decide whether the requesting app may mutate it. Otherwise, it reuses the first `projectSkillsDirs` path that exists as a directory or falls back to the first configured path. +- `agents: "auto"` detects a host when any of its non-generic detection paths exists; shared roots such as `.agents` do not identify a host by themselves. - `agents: "*"` selects every host adapter. - explicit agents select canonical host ids or aliases. - hosts without a path for the selected scope return an `unsupported-scope` error. +- uninstall removes every valid kitup-owned copy for the requested `appId` and skill across the selected hosts' configured paths; unmanaged and other-owner directories are never removed. ## Reports diff --git a/docs/architecture.mmd b/docs/architecture.mmd index 13bc913..7ec066c 100644 --- a/docs/architecture.mmd +++ b/docs/architecture.mmd @@ -14,8 +14,9 @@ flowchart TB HOSTSPEC["Host Spec\nspec/hosts.json"]:::contract SCHEMAS["Schemas\nspec/hosts.schema.json\ntestdata/cases.schema.json"]:::contract CASES["Golden Cases\ntestdata/cases/*.json"]:::contract + GOGOLDEN["Go Golden Harness\ntests/go-golden"]:::artifact GENHOSTS["Generated Host Constants\nts / go / rust / python"]:::artifact - VERIFY["Verification\ncheck.mjs · sync-hosts.mjs"]:::control + VERIFY["Verification\ncheck.mjs · sync-hosts.mjs · standalone Go modules"]:::control GITHUB["GitHub API"]:::external TARGETS["Agent Host\nDirectory State"]:::state METADATA[".kitup.json"]:::state @@ -38,6 +39,8 @@ flowchart TB HOSTSPEC -.->|"generates"| GENHOSTS VERIFY -.-> HOSTSPEC VERIFY -.->|"checks parity"| CASES + VERIFY -.-> GOGOLDEN + CASES -.-> GOGOLDEN VERIFY -.-> GENHOSTS classDef actor fill:#1f2937,stroke:#94a3b8,color:#f8fafc,stroke-width:1px; diff --git a/docs/host-adapter-contract.md b/docs/host-adapter-contract.md index 99d50dc..05025de 100644 --- a/docs/host-adapter-contract.md +++ b/docs/host-adapter-contract.md @@ -8,13 +8,15 @@ Each host entry describes where a local Agent Skill can be installed and how the `projectSkillsDirs` and `userSkillsDirs` are ordered. -The first path is the canonical install target for that host. Later paths are compatible discovery roots that the host also scans. SDKs should install to the first path unless a caller explicitly requests another supported path. +The first path is the canonical install target for that host. Later paths are compatible discovery roots that the host also scans. SDKs keep using a configured path that already contains a valid kitup-owned target for the same skill; normal ownership checks still decide whether the requesting app may update or remove it. Otherwise, SDKs reuse the first configured path that exists as a directory or install to the first path when none exist. Regular files do not count as compatible install directories. Project paths must be relative paths. User paths must be home-relative paths beginning with `~/`. -All adapter paths use `/` separators and non-empty segments; `..`, backslashes, colons, and NUL bytes are invalid. +All adapter paths use `/` separators and non-empty segments; `.`, `..`, backslashes, colons, and NUL bytes are invalid. If multiple selected hosts resolve to the same target directory, SDKs must copy once and associate that installed target with every matching host. Shared roots such as `.agents/skills` are common and should not produce duplicate writes. +Uninstall removes every valid kitup-owned copy for the requested `appId` and skill across the selected hosts' configured paths. This cleans up duplicate owned copies left by earlier target selection while preserving unmanaged and other-owner directories. + ## Aliases `aliases` are accepted input names for one host adapter. @@ -25,7 +27,15 @@ Aliases are for ecosystem compatibility only. SDK result objects should return t `detect` is only a default selector for `agents: "auto"`. -Detection should check path existence. Entries may be home-relative paths such as `~/.codex` or project-relative paths such as `.replit`. +Detection checks path existence across every `detect` entry: a host is detected when any of its non-generic entries exists. Entries may be home-relative paths such as `~/.codex` or project-relative paths such as `.replit`. + +Every `detect` entry must be evidence that this specific host is present: + +- Generic shared roots (`~/.agents`, `~/.agents/skills`, `~/.config/agents`, `.agents`, `.agents/skills`, `package.json`) never count as evidence and are ignored by detection. +- An entry must not be one of the host's own install directories — a kitup install would create that directory itself and turn into next run's false detection evidence. +- An entry must not point at another host's namespace; a host that scans other tools' skill directories expresses that through `projectSkillsDirs`/`userSkillsDirs` compatibility paths, not through `detect`. Hosts that share their entire install surface (one product namespace) may share detection evidence. + +SDK host-spec loaders reject non-generic detection paths that duplicate the same host's install directories, including in custom `hostsFile` overrides. `scripts/check.mjs` also enforces cross-host ownership for the canonical adapter table. Detection must not run host binaries, start editors, mutate configuration, or require network access. diff --git a/examples/go/go.mod b/examples/go/go.mod index 4402882..a0e6288 100644 --- a/examples/go/go.mod +++ b/examples/go/go.mod @@ -2,6 +2,4 @@ module kitup-example-go go 1.23 -require github.com/lathe-cli/kitup/go v0.1.0 - -replace github.com/lathe-cli/kitup/go => ../../go +require github.com/lathe-cli/kitup/go v0.1.3 diff --git a/go-cobra/go.mod b/go-cobra/go.mod index e5e8257..32c7a5c 100644 --- a/go-cobra/go.mod +++ b/go-cobra/go.mod @@ -11,5 +11,3 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.6 // indirect ) - -replace github.com/lathe-cli/kitup/go => ../go diff --git a/go-cobra/go.sum b/go-cobra/go.sum index ffae55e..abfc725 100644 --- a/go-cobra/go.sum +++ b/go-cobra/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lathe-cli/kitup/go v0.1.3 h1:7eEW8mDr5MbXFaTwr2dlnH8ebg+3Kmhj2/j4d9YfQNQ= +github.com/lathe-cli/kitup/go v0.1.3/go.mod h1:dZgJDmFRKjaFBZyaP1qlzOB9IEafnf1/ai4KX4hMA4c= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= diff --git a/go-cobra/skill_test.go b/go-cobra/skill_test.go index 15a1639..ae072da 100644 --- a/go-cobra/skill_test.go +++ b/go-cobra/skill_test.go @@ -15,7 +15,7 @@ func TestSkillCommandInstallsWithCoreFlags(t *testing.T) { var out bytes.Buffer cmd := NewSkillCommand(Options{ AppID: "example-cli", - Bundle: kitup.DirectoryBundle(filepath.Join("..", "testdata", "skills", "basic")), + Bundle: basicBundle(), Home: home, Out: &out, }) @@ -36,7 +36,7 @@ func TestInstallCommandPromptsForScopeBeforeInstall(t *testing.T) { var out bytes.Buffer cmd := NewSkillCommand(Options{ AppID: "example-cli", - Bundle: kitup.DirectoryBundle(filepath.Join("..", "testdata", "skills", "basic")), + Bundle: basicBundle(), Home: home, CWD: workspace, StdinTTY: true, @@ -71,7 +71,7 @@ func TestInstallCommandForceOverwritesUnmanaged(t *testing.T) { var out bytes.Buffer cmd := NewSkillCommand(Options{ AppID: "example-cli", - Bundle: kitup.DirectoryBundle(filepath.Join("..", "testdata", "skills", "basic")), + Bundle: basicBundle(), Home: home, Out: &out, }) @@ -89,7 +89,7 @@ func TestInstallCommandForceOverwritesUnmanaged(t *testing.T) { func TestInstallCommandReturnsCoreFlagError(t *testing.T) { cmd := NewInstallCommand(Options{ AppID: "example-cli", - Bundle: kitup.DirectoryBundle(filepath.Join("..", "testdata", "skills", "basic")), + Bundle: basicBundle(), Home: t.TempDir(), }) cmd.SetArgs([]string{"--scope", "bad"}) @@ -99,3 +99,10 @@ func TestInstallCommandReturnsCoreFlagError(t *testing.T) { t.Fatalf("got %v, want %q", err, kitup.InstallUX.InvalidFlags) } } + +func basicBundle() kitup.SkillBundle { + return kitup.FilesBundle([]kitup.SkillFile{{ + Path: "SKILL.md", + Contents: []byte("---\nname: basic\ndescription: Basic fixture.\n---\n"), + }}) +} diff --git a/go.work b/go.work new file mode 100644 index 0000000..194ada4 --- /dev/null +++ b/go.work @@ -0,0 +1,7 @@ +go 1.23 + +use ( + ./examples/go + ./go + ./go-cobra +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..9fc6cf3 --- /dev/null +++ b/go.work.sum @@ -0,0 +1 @@ +github.com/lathe-cli/kitup/go v0.1.3/go.mod h1:dZgJDmFRKjaFBZyaP1qlzOB9IEafnf1/ai4KX4hMA4c= diff --git a/go/hosts_gen.go b/go/hosts_gen.go index 521b4e6..8117900 100644 --- a/go/hosts_gen.go +++ b/go/hosts_gen.go @@ -2,4 +2,4 @@ package kitup -const defaultHostsSpecJSON = "{\"$schema\":\"./hosts.schema.json\",\"schemaVersion\":1,\"hosts\":[{\"id\":\"adal\",\"displayName\":\"AdaL\",\"projectSkillsDirs\":[\".adal/skills\"],\"userSkillsDirs\":[\"~/.adal/skills\"],\"detect\":[\"~/.adal\"],\"status\":\"community\"},{\"id\":\"aider-desk\",\"displayName\":\"AiderDesk\",\"projectSkillsDirs\":[\".aider-desk/skills\"],\"userSkillsDirs\":[\"~/.aider-desk/skills\"],\"detect\":[\"~/.aider-desk\"],\"status\":\"community\"},{\"id\":\"amp\",\"displayName\":\"Amp\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\"~/.config/amp\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"antigravity\",\"displayName\":\"Antigravity\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity/skills\"],\"detect\":[\"~/.gemini/antigravity\"],\"status\":\"community\"},{\"id\":\"antigravity-cli\",\"displayName\":\"Antigravity CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity-cli/skills\"],\"detect\":[\"~/.gemini/antigravity-cli\"],\"status\":\"community\"},{\"id\":\"astrbot\",\"displayName\":\"AstrBot\",\"projectSkillsDirs\":[\"data/skills\"],\"userSkillsDirs\":[\"~/.astrbot/data/skills\"],\"detect\":[\"~/.astrbot\",\"data/skills\",\"~/.astrbot/data\"],\"status\":\"community\"},{\"id\":\"augment\",\"displayName\":\"Augment\",\"projectSkillsDirs\":[\".augment/skills\"],\"userSkillsDirs\":[\"~/.augment/skills\"],\"detect\":[\"~/.augment\"],\"status\":\"community\"},{\"id\":\"autohand-code\",\"displayName\":\"Autohand Code CLI\",\"projectSkillsDirs\":[\".autohand/skills\"],\"userSkillsDirs\":[\"~/.autohand/skills\"],\"detect\":[\"~/.autohand\"],\"status\":\"community\"},{\"id\":\"bob\",\"displayName\":\"IBM Bob\",\"projectSkillsDirs\":[\".bob/skills\"],\"userSkillsDirs\":[\"~/.bob/skills\"],\"detect\":[\"~/.bob\"],\"status\":\"community\"},{\"id\":\"claude-code\",\"displayName\":\"Claude Code\",\"projectSkillsDirs\":[\".claude/skills\"],\"userSkillsDirs\":[\"~/.claude/skills\"],\"detect\":[\"~/.claude\"],\"status\":\"verified\"},{\"id\":\"cline\",\"displayName\":\"Cline\",\"projectSkillsDirs\":[\".agents/skills\",\".cline/skills\",\".clinerules/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.cline/skills\"],\"detect\":[\"~/.cline\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"codearts-agent\",\"displayName\":\"CodeArts Agent\",\"projectSkillsDirs\":[\".codeartsdoer/skills\"],\"userSkillsDirs\":[\"~/.codeartsdoer/skills\"],\"detect\":[\"~/.codeartsdoer\"],\"status\":\"community\"},{\"id\":\"codebuddy\",\"displayName\":\"CodeBuddy\",\"projectSkillsDirs\":[\".codebuddy/skills\"],\"userSkillsDirs\":[\"~/.codebuddy/skills\"],\"detect\":[\"~/.codebuddy\",\".codebuddy\"],\"status\":\"community\"},{\"id\":\"codemaker\",\"displayName\":\"Codemaker\",\"projectSkillsDirs\":[\".codemaker/skills\"],\"userSkillsDirs\":[\"~/.codemaker/skills\"],\"detect\":[\"~/.codemaker\"],\"status\":\"community\"},{\"id\":\"codestudio\",\"displayName\":\"Code Studio\",\"projectSkillsDirs\":[\".codestudio/skills\"],\"userSkillsDirs\":[\"~/.codestudio/skills\"],\"detect\":[\"~/.codestudio\"],\"status\":\"community\"},{\"id\":\"codex\",\"displayName\":\"Codex\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.codex/skills\"],\"detect\":[\"~/.codex\",\"~/.agents/skills\",\"~/.agents\"],\"status\":\"verified\",\"notes\":[\"Keep both ~/.agents/skills and ~/.codex/skills for compatibility.\"]},{\"id\":\"command-code\",\"displayName\":\"Command Code\",\"projectSkillsDirs\":[\".commandcode/skills\"],\"userSkillsDirs\":[\"~/.commandcode/skills\"],\"detect\":[\"~/.commandcode\"],\"status\":\"community\"},{\"id\":\"continue\",\"displayName\":\"Continue\",\"projectSkillsDirs\":[\".continue/skills\"],\"userSkillsDirs\":[\"~/.continue/skills\"],\"detect\":[\"~/.continue\",\".continue\"],\"status\":\"community\"},{\"id\":\"cortex\",\"displayName\":\"Cortex Code\",\"projectSkillsDirs\":[\".cortex/skills\"],\"userSkillsDirs\":[\"~/.snowflake/cortex/skills\"],\"detect\":[\"~/.snowflake/cortex\"],\"status\":\"community\"},{\"id\":\"crush\",\"displayName\":\"Crush\",\"projectSkillsDirs\":[\".crush/skills\"],\"userSkillsDirs\":[\"~/.config/crush/skills\"],\"detect\":[\"~/.config/crush\"],\"status\":\"community\"},{\"id\":\"cursor\",\"displayName\":\"Cursor\",\"projectSkillsDirs\":[\".agents/skills\",\".cursor/skills\"],\"userSkillsDirs\":[\"~/.cursor/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.cursor\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"deepagents\",\"displayName\":\"Deep Agents\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.deepagents/agent/skills\"],\"detect\":[\"~/.deepagents\",\"~/.deepagents/agent\"],\"status\":\"community\"},{\"id\":\"devin\",\"displayName\":\"Devin for Terminal\",\"projectSkillsDirs\":[\".devin/skills\"],\"userSkillsDirs\":[\"~/.config/devin/skills\"],\"detect\":[\"~/.config/devin\"],\"status\":\"community\"},{\"id\":\"dexto\",\"displayName\":\"Dexto\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.dexto\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"droid\",\"displayName\":\"Droid\",\"projectSkillsDirs\":[\".factory/skills\"],\"userSkillsDirs\":[\"~/.factory/skills\"],\"detect\":[\"~/.factory\"],\"status\":\"community\"},{\"id\":\"eve\",\"displayName\":\"Eve\",\"projectSkillsDirs\":[\"agent/skills\"],\"userSkillsDirs\":[],\"detect\":[\"agent\",\"package.json\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\",\"Detect from Eve project shape; no global skill directory.\"]},{\"id\":\"firebender\",\"displayName\":\"Firebender\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.firebender/skills\"],\"detect\":[\"~/.firebender\"],\"status\":\"community\"},{\"id\":\"forgecode\",\"displayName\":\"ForgeCode\",\"projectSkillsDirs\":[\".forge/skills\"],\"userSkillsDirs\":[\"~/.forge/skills\"],\"detect\":[\"~/.forge\"],\"status\":\"community\"},{\"id\":\"gemini-cli\",\"displayName\":\"Gemini CLI\",\"projectSkillsDirs\":[\".agents/skills\",\".gemini/skills\"],\"userSkillsDirs\":[\"~/.gemini/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.gemini\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"github-copilot\",\"displayName\":\"GitHub Copilot\",\"projectSkillsDirs\":[\".agents/skills\",\".github/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.copilot/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.copilot\",\"~/.agents\",\"~/.claude\"],\"status\":\"documented\"},{\"id\":\"goose\",\"displayName\":\"Goose\",\"projectSkillsDirs\":[\".goose/skills\"],\"userSkillsDirs\":[\"~/.config/goose/skills\"],\"detect\":[\"~/.config/goose\"],\"status\":\"community\"},{\"id\":\"hermes-agent\",\"displayName\":\"Hermes Agent\",\"projectSkillsDirs\":[\".hermes/skills\"],\"userSkillsDirs\":[\"~/.hermes/skills\"],\"detect\":[\"~/.hermes\"],\"status\":\"community\"},{\"id\":\"iflow-cli\",\"displayName\":\"iFlow CLI\",\"projectSkillsDirs\":[\".iflow/skills\"],\"userSkillsDirs\":[\"~/.iflow/skills\"],\"detect\":[\"~/.iflow\"],\"status\":\"community\"},{\"id\":\"inference-sh\",\"displayName\":\"inference.sh\",\"projectSkillsDirs\":[\".inferencesh/skills\"],\"userSkillsDirs\":[\"~/.inferencesh/skills\"],\"detect\":[\"~/.inferencesh\"],\"status\":\"community\"},{\"id\":\"jazz\",\"displayName\":\"Jazz\",\"projectSkillsDirs\":[\".jazz/skills\"],\"userSkillsDirs\":[\"~/.jazz/skills\"],\"detect\":[\"~/.jazz\",\".jazz\"],\"status\":\"community\"},{\"id\":\"junie\",\"displayName\":\"Junie\",\"projectSkillsDirs\":[\".junie/skills\"],\"userSkillsDirs\":[\"~/.junie/skills\"],\"detect\":[\"~/.junie\"],\"status\":\"community\"},{\"id\":\"kilo\",\"displayName\":\"Kilo Code\",\"projectSkillsDirs\":[\".kilocode/skills\"],\"userSkillsDirs\":[\"~/.kilocode/skills\"],\"detect\":[\"~/.kilocode\"],\"status\":\"community\"},{\"id\":\"kimi-cli\",\"displayName\":\"Kimi Code CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.config/agents\",\"~/.kimi-code\",\"~/.kimi\",\"~/.agents\"],\"status\":\"community\",\"notes\":[\"kimi-code-cli is an alias for the same Kimi Code CLI path family.\"],\"aliases\":[\"kimi-code-cli\"]},{\"id\":\"kiro-cli\",\"displayName\":\"Kiro CLI\",\"projectSkillsDirs\":[\".kiro/skills\"],\"userSkillsDirs\":[\"~/.kiro/skills\"],\"detect\":[\"~/.kiro\"],\"status\":\"community\"},{\"id\":\"kode\",\"displayName\":\"Kode\",\"projectSkillsDirs\":[\".kode/skills\"],\"userSkillsDirs\":[\"~/.kode/skills\"],\"detect\":[\"~/.kode\"],\"status\":\"community\"},{\"id\":\"lingma\",\"displayName\":\"Lingma\",\"projectSkillsDirs\":[\".lingma/skills\"],\"userSkillsDirs\":[\"~/.lingma/skills\"],\"detect\":[\"~/.lingma\"],\"status\":\"community\"},{\"id\":\"loaf\",\"displayName\":\"Loaf\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.loaf\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"mcpjam\",\"displayName\":\"MCPJam\",\"projectSkillsDirs\":[\".mcpjam/skills\"],\"userSkillsDirs\":[\"~/.mcpjam/skills\"],\"detect\":[\"~/.mcpjam\"],\"status\":\"community\"},{\"id\":\"mistral-vibe\",\"displayName\":\"Mistral Vibe\",\"projectSkillsDirs\":[\".vibe/skills\"],\"userSkillsDirs\":[\"~/.vibe/skills\"],\"detect\":[\"~/.vibe\"],\"status\":\"community\"},{\"id\":\"moxby\",\"displayName\":\"Moxby\",\"projectSkillsDirs\":[\".moxby/skills\"],\"userSkillsDirs\":[\"~/.moxby/skills\"],\"detect\":[\"~/.moxby\"],\"status\":\"community\"},{\"id\":\"mux\",\"displayName\":\"Mux\",\"projectSkillsDirs\":[\".mux/skills\"],\"userSkillsDirs\":[\"~/.mux/skills\"],\"detect\":[\"~/.mux\"],\"status\":\"community\"},{\"id\":\"neovate\",\"displayName\":\"Neovate\",\"projectSkillsDirs\":[\".neovate/skills\"],\"userSkillsDirs\":[\"~/.neovate/skills\"],\"detect\":[\"~/.neovate\"],\"status\":\"community\"},{\"id\":\"ona\",\"displayName\":\"Ona\",\"projectSkillsDirs\":[\".ona/skills\"],\"userSkillsDirs\":[\"~/.ona/skills\"],\"detect\":[\"~/.ona\"],\"status\":\"community\"},{\"id\":\"openclaw\",\"displayName\":\"OpenClaw\",\"projectSkillsDirs\":[\"skills\"],\"userSkillsDirs\":[\"~/.openclaw/skills\"],\"detect\":[\"~/.openclaw\",\"~/.clawdbot\",\"~/.moltbot\"],\"status\":\"community\"},{\"id\":\"opencode\",\"displayName\":\"OpenCode\",\"projectSkillsDirs\":[\".agents/skills\",\".opencode/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.config/opencode/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.config/opencode\",\"~/.agents\",\"~/.claude\"],\"status\":\"verified\"},{\"id\":\"openhands\",\"displayName\":\"OpenHands\",\"projectSkillsDirs\":[\".openhands/skills\"],\"userSkillsDirs\":[\"~/.openhands/skills\"],\"detect\":[\"~/.openhands\"],\"status\":\"community\"},{\"id\":\"pi\",\"displayName\":\"Pi\",\"projectSkillsDirs\":[\".pi/skills\"],\"userSkillsDirs\":[\"~/.pi/agent/skills\"],\"detect\":[\"~/.pi/agent\"],\"status\":\"community\"},{\"id\":\"pochi\",\"displayName\":\"Pochi\",\"projectSkillsDirs\":[\".pochi/skills\"],\"userSkillsDirs\":[\"~/.pochi/skills\"],\"detect\":[\"~/.pochi\"],\"status\":\"community\"},{\"id\":\"promptscript\",\"displayName\":\"PromptScript\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[],\"detect\":[\".promptscript\",\"promptscript.yaml\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\"]},{\"id\":\"qoder\",\"displayName\":\"Qoder\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder/skills\"],\"detect\":[\"~/.qoder\"],\"status\":\"community\"},{\"id\":\"qoder-cn\",\"displayName\":\"Qoder CN\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder-cn/skills\"],\"detect\":[\"~/.qoder-cn\"],\"status\":\"community\"},{\"id\":\"qwen-code\",\"displayName\":\"Qwen Code\",\"projectSkillsDirs\":[\".qwen/skills\"],\"userSkillsDirs\":[\"~/.qwen/skills\"],\"detect\":[\"~/.qwen\"],\"status\":\"community\"},{\"id\":\"reasonix\",\"displayName\":\"Reasonix\",\"projectSkillsDirs\":[\".reasonix/skills\"],\"userSkillsDirs\":[\"~/.reasonix/skills\"],\"detect\":[\"~/.reasonix\"],\"status\":\"community\"},{\"id\":\"replit\",\"displayName\":\"Replit\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\".replit\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"roo\",\"displayName\":\"Roo Code\",\"aliases\":[\"roo-code\"],\"projectSkillsDirs\":[\".roo/skills\",\".agents/skills\"],\"userSkillsDirs\":[\"~/.roo/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.roo\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"rovodev\",\"displayName\":\"Rovo Dev\",\"projectSkillsDirs\":[\".rovodev/skills\"],\"userSkillsDirs\":[\"~/.rovodev/skills\"],\"detect\":[\"~/.rovodev\"],\"status\":\"community\"},{\"id\":\"tabnine-cli\",\"displayName\":\"Tabnine CLI\",\"projectSkillsDirs\":[\".tabnine/agent/skills\"],\"userSkillsDirs\":[\"~/.tabnine/agent/skills\"],\"detect\":[\"~/.tabnine\",\"~/.tabnine/agent\"],\"status\":\"community\"},{\"id\":\"terramind\",\"displayName\":\"Terramind\",\"projectSkillsDirs\":[\".terramind/skills\"],\"userSkillsDirs\":[\"~/.terramind/skills\"],\"detect\":[\"~/.terramind\"],\"status\":\"community\"},{\"id\":\"tinycloud\",\"displayName\":\"Tinycloud\",\"projectSkillsDirs\":[\".tinycloud/skills\"],\"userSkillsDirs\":[\"~/.tinycloud/skills\"],\"detect\":[\"~/.tinycloud\"],\"status\":\"community\"},{\"id\":\"trae\",\"displayName\":\"Trae\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae/skills\"],\"detect\":[\"~/.trae\"],\"status\":\"community\"},{\"id\":\"trae-cn\",\"displayName\":\"Trae CN\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae-cn/skills\"],\"detect\":[\"~/.trae-cn\"],\"status\":\"community\"},{\"id\":\"universal\",\"displayName\":\"Universal\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.config/agents/skills\"],\"detect\":[\"~/.agents\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"warp\",\"displayName\":\"Warp\",\"projectSkillsDirs\":[\".agents/skills\",\".warp/skills\",\".claude/skills\",\".codex/skills\",\".cursor/skills\",\".gemini/skills\",\".copilot/skills\",\".factory/skills\",\".github/skills\",\".opencode/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.warp/skills\",\"~/.claude/skills\",\"~/.codex/skills\",\"~/.cursor/skills\",\"~/.gemini/skills\",\"~/.copilot/skills\",\"~/.factory/skills\",\"~/.github/skills\",\"~/.opencode/skills\"],\"detect\":[\"~/.warp\",\"~/.agents\",\"~/.claude\",\"~/.codex\",\"~/.cursor\",\"~/.gemini\",\"~/.copilot\",\"~/.factory\",\"~/.github\",\"~/.opencode\"],\"status\":\"documented\"},{\"id\":\"windsurf\",\"displayName\":\"Windsurf\",\"projectSkillsDirs\":[\".windsurf/skills\"],\"userSkillsDirs\":[\"~/.codeium/windsurf/skills\"],\"detect\":[\"~/.codeium/windsurf\"],\"status\":\"community\"},{\"id\":\"zed\",\"displayName\":\"Zed\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.config/zed\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"zencoder\",\"displayName\":\"Zencoder\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"},{\"id\":\"zenflow\",\"displayName\":\"Zenflow\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"}]}" +const defaultHostsSpecJSON = "{\"$schema\":\"./hosts.schema.json\",\"schemaVersion\":1,\"hosts\":[{\"id\":\"adal\",\"displayName\":\"AdaL\",\"projectSkillsDirs\":[\".adal/skills\"],\"userSkillsDirs\":[\"~/.adal/skills\"],\"detect\":[\"~/.adal\"],\"status\":\"community\"},{\"id\":\"aider-desk\",\"displayName\":\"AiderDesk\",\"projectSkillsDirs\":[\".aider-desk/skills\"],\"userSkillsDirs\":[\"~/.aider-desk/skills\"],\"detect\":[\"~/.aider-desk\"],\"status\":\"community\"},{\"id\":\"amp\",\"displayName\":\"Amp\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\"~/.config/amp\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"antigravity\",\"displayName\":\"Antigravity\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity/skills\"],\"detect\":[\"~/.gemini/antigravity\"],\"status\":\"community\"},{\"id\":\"antigravity-cli\",\"displayName\":\"Antigravity CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity-cli/skills\"],\"detect\":[\"~/.gemini/antigravity-cli\"],\"status\":\"community\"},{\"id\":\"astrbot\",\"displayName\":\"AstrBot\",\"projectSkillsDirs\":[\"data/skills\"],\"userSkillsDirs\":[\"~/.astrbot/data/skills\"],\"detect\":[\"~/.astrbot\",\"~/.astrbot/data\"],\"status\":\"community\"},{\"id\":\"augment\",\"displayName\":\"Augment\",\"projectSkillsDirs\":[\".augment/skills\"],\"userSkillsDirs\":[\"~/.augment/skills\"],\"detect\":[\"~/.augment\"],\"status\":\"community\"},{\"id\":\"autohand-code\",\"displayName\":\"Autohand Code CLI\",\"projectSkillsDirs\":[\".autohand/skills\"],\"userSkillsDirs\":[\"~/.autohand/skills\"],\"detect\":[\"~/.autohand\"],\"status\":\"community\"},{\"id\":\"bob\",\"displayName\":\"IBM Bob\",\"projectSkillsDirs\":[\".bob/skills\"],\"userSkillsDirs\":[\"~/.bob/skills\"],\"detect\":[\"~/.bob\"],\"status\":\"community\"},{\"id\":\"claude-code\",\"displayName\":\"Claude Code\",\"projectSkillsDirs\":[\".claude/skills\"],\"userSkillsDirs\":[\"~/.claude/skills\"],\"detect\":[\"~/.claude\"],\"status\":\"verified\"},{\"id\":\"cline\",\"displayName\":\"Cline\",\"projectSkillsDirs\":[\".agents/skills\",\".cline/skills\",\".clinerules/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.cline/skills\"],\"detect\":[\"~/.cline\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"codearts-agent\",\"displayName\":\"CodeArts Agent\",\"projectSkillsDirs\":[\".codeartsdoer/skills\"],\"userSkillsDirs\":[\"~/.codeartsdoer/skills\"],\"detect\":[\"~/.codeartsdoer\"],\"status\":\"community\"},{\"id\":\"codebuddy\",\"displayName\":\"CodeBuddy\",\"projectSkillsDirs\":[\".codebuddy/skills\"],\"userSkillsDirs\":[\"~/.codebuddy/skills\"],\"detect\":[\"~/.codebuddy\",\".codebuddy\"],\"status\":\"community\"},{\"id\":\"codemaker\",\"displayName\":\"Codemaker\",\"projectSkillsDirs\":[\".codemaker/skills\"],\"userSkillsDirs\":[\"~/.codemaker/skills\"],\"detect\":[\"~/.codemaker\"],\"status\":\"community\"},{\"id\":\"codestudio\",\"displayName\":\"Code Studio\",\"projectSkillsDirs\":[\".codestudio/skills\"],\"userSkillsDirs\":[\"~/.codestudio/skills\"],\"detect\":[\"~/.codestudio\"],\"status\":\"community\"},{\"id\":\"codex\",\"displayName\":\"Codex\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.codex/skills\"],\"detect\":[\"~/.codex\",\"~/.agents/skills\",\"~/.agents\"],\"status\":\"verified\",\"notes\":[\"Keep both ~/.agents/skills and ~/.codex/skills for compatibility.\"]},{\"id\":\"command-code\",\"displayName\":\"Command Code\",\"projectSkillsDirs\":[\".commandcode/skills\"],\"userSkillsDirs\":[\"~/.commandcode/skills\"],\"detect\":[\"~/.commandcode\"],\"status\":\"community\"},{\"id\":\"continue\",\"displayName\":\"Continue\",\"projectSkillsDirs\":[\".continue/skills\"],\"userSkillsDirs\":[\"~/.continue/skills\"],\"detect\":[\"~/.continue\",\".continue\"],\"status\":\"community\"},{\"id\":\"cortex\",\"displayName\":\"Cortex Code\",\"projectSkillsDirs\":[\".cortex/skills\"],\"userSkillsDirs\":[\"~/.snowflake/cortex/skills\"],\"detect\":[\"~/.snowflake/cortex\"],\"status\":\"community\"},{\"id\":\"crush\",\"displayName\":\"Crush\",\"projectSkillsDirs\":[\".crush/skills\"],\"userSkillsDirs\":[\"~/.config/crush/skills\"],\"detect\":[\"~/.config/crush\"],\"status\":\"community\"},{\"id\":\"cursor\",\"displayName\":\"Cursor\",\"projectSkillsDirs\":[\".agents/skills\",\".cursor/skills\"],\"userSkillsDirs\":[\"~/.cursor/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.cursor\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"deepagents\",\"displayName\":\"Deep Agents\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.deepagents/agent/skills\"],\"detect\":[\"~/.deepagents\",\"~/.deepagents/agent\"],\"status\":\"community\"},{\"id\":\"devin\",\"displayName\":\"Devin for Terminal\",\"projectSkillsDirs\":[\".devin/skills\"],\"userSkillsDirs\":[\"~/.config/devin/skills\"],\"detect\":[\"~/.config/devin\"],\"status\":\"community\"},{\"id\":\"dexto\",\"displayName\":\"Dexto\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.dexto\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"droid\",\"displayName\":\"Droid\",\"projectSkillsDirs\":[\".factory/skills\"],\"userSkillsDirs\":[\"~/.factory/skills\"],\"detect\":[\"~/.factory\"],\"status\":\"community\"},{\"id\":\"eve\",\"displayName\":\"Eve\",\"projectSkillsDirs\":[\"agent/skills\"],\"userSkillsDirs\":[],\"detect\":[\"package.json\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\",\"Explicit selection only; generic project paths are not safe detection evidence.\"]},{\"id\":\"firebender\",\"displayName\":\"Firebender\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.firebender/skills\"],\"detect\":[\"~/.firebender\"],\"status\":\"community\"},{\"id\":\"forgecode\",\"displayName\":\"ForgeCode\",\"projectSkillsDirs\":[\".forge/skills\"],\"userSkillsDirs\":[\"~/.forge/skills\"],\"detect\":[\"~/.forge\"],\"status\":\"community\"},{\"id\":\"gemini-cli\",\"displayName\":\"Gemini CLI\",\"projectSkillsDirs\":[\".agents/skills\",\".gemini/skills\"],\"userSkillsDirs\":[\"~/.gemini/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.gemini\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"github-copilot\",\"displayName\":\"GitHub Copilot\",\"projectSkillsDirs\":[\".agents/skills\",\".github/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.copilot/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.copilot\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"goose\",\"displayName\":\"Goose\",\"projectSkillsDirs\":[\".goose/skills\"],\"userSkillsDirs\":[\"~/.config/goose/skills\"],\"detect\":[\"~/.config/goose\"],\"status\":\"community\"},{\"id\":\"hermes-agent\",\"displayName\":\"Hermes Agent\",\"projectSkillsDirs\":[\".hermes/skills\"],\"userSkillsDirs\":[\"~/.hermes/skills\"],\"detect\":[\"~/.hermes\"],\"status\":\"community\"},{\"id\":\"iflow-cli\",\"displayName\":\"iFlow CLI\",\"projectSkillsDirs\":[\".iflow/skills\"],\"userSkillsDirs\":[\"~/.iflow/skills\"],\"detect\":[\"~/.iflow\"],\"status\":\"community\"},{\"id\":\"inference-sh\",\"displayName\":\"inference.sh\",\"projectSkillsDirs\":[\".inferencesh/skills\"],\"userSkillsDirs\":[\"~/.inferencesh/skills\"],\"detect\":[\"~/.inferencesh\"],\"status\":\"community\"},{\"id\":\"jazz\",\"displayName\":\"Jazz\",\"projectSkillsDirs\":[\".jazz/skills\"],\"userSkillsDirs\":[\"~/.jazz/skills\"],\"detect\":[\"~/.jazz\",\".jazz\"],\"status\":\"community\"},{\"id\":\"junie\",\"displayName\":\"Junie\",\"projectSkillsDirs\":[\".junie/skills\"],\"userSkillsDirs\":[\"~/.junie/skills\"],\"detect\":[\"~/.junie\"],\"status\":\"community\"},{\"id\":\"kilo\",\"displayName\":\"Kilo Code\",\"projectSkillsDirs\":[\".kilocode/skills\"],\"userSkillsDirs\":[\"~/.kilocode/skills\"],\"detect\":[\"~/.kilocode\"],\"status\":\"community\"},{\"id\":\"kimi-cli\",\"displayName\":\"Kimi Code CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.config/agents\",\"~/.kimi-code\",\"~/.kimi\",\"~/.agents\"],\"status\":\"community\",\"notes\":[\"kimi-code-cli is an alias for the same Kimi Code CLI path family.\"],\"aliases\":[\"kimi-code-cli\"]},{\"id\":\"kiro-cli\",\"displayName\":\"Kiro CLI\",\"projectSkillsDirs\":[\".kiro/skills\"],\"userSkillsDirs\":[\"~/.kiro/skills\"],\"detect\":[\"~/.kiro\"],\"status\":\"community\"},{\"id\":\"kode\",\"displayName\":\"Kode\",\"projectSkillsDirs\":[\".kode/skills\"],\"userSkillsDirs\":[\"~/.kode/skills\"],\"detect\":[\"~/.kode\"],\"status\":\"community\"},{\"id\":\"lingma\",\"displayName\":\"Lingma\",\"projectSkillsDirs\":[\".lingma/skills\"],\"userSkillsDirs\":[\"~/.lingma/skills\"],\"detect\":[\"~/.lingma\"],\"status\":\"community\"},{\"id\":\"loaf\",\"displayName\":\"Loaf\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.loaf\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"mcpjam\",\"displayName\":\"MCPJam\",\"projectSkillsDirs\":[\".mcpjam/skills\"],\"userSkillsDirs\":[\"~/.mcpjam/skills\"],\"detect\":[\"~/.mcpjam\"],\"status\":\"community\"},{\"id\":\"mistral-vibe\",\"displayName\":\"Mistral Vibe\",\"projectSkillsDirs\":[\".vibe/skills\"],\"userSkillsDirs\":[\"~/.vibe/skills\"],\"detect\":[\"~/.vibe\"],\"status\":\"community\"},{\"id\":\"moxby\",\"displayName\":\"Moxby\",\"projectSkillsDirs\":[\".moxby/skills\"],\"userSkillsDirs\":[\"~/.moxby/skills\"],\"detect\":[\"~/.moxby\"],\"status\":\"community\"},{\"id\":\"mux\",\"displayName\":\"Mux\",\"projectSkillsDirs\":[\".mux/skills\"],\"userSkillsDirs\":[\"~/.mux/skills\"],\"detect\":[\"~/.mux\"],\"status\":\"community\"},{\"id\":\"neovate\",\"displayName\":\"Neovate\",\"projectSkillsDirs\":[\".neovate/skills\"],\"userSkillsDirs\":[\"~/.neovate/skills\"],\"detect\":[\"~/.neovate\"],\"status\":\"community\"},{\"id\":\"ona\",\"displayName\":\"Ona\",\"projectSkillsDirs\":[\".ona/skills\"],\"userSkillsDirs\":[\"~/.ona/skills\"],\"detect\":[\"~/.ona\"],\"status\":\"community\"},{\"id\":\"openclaw\",\"displayName\":\"OpenClaw\",\"projectSkillsDirs\":[\"skills\"],\"userSkillsDirs\":[\"~/.openclaw/skills\"],\"detect\":[\"~/.openclaw\",\"~/.clawdbot\",\"~/.moltbot\"],\"status\":\"community\"},{\"id\":\"opencode\",\"displayName\":\"OpenCode\",\"projectSkillsDirs\":[\".agents/skills\",\".opencode/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.config/opencode/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.config/opencode\",\"~/.agents\"],\"status\":\"verified\"},{\"id\":\"openhands\",\"displayName\":\"OpenHands\",\"projectSkillsDirs\":[\".openhands/skills\"],\"userSkillsDirs\":[\"~/.openhands/skills\"],\"detect\":[\"~/.openhands\"],\"status\":\"community\"},{\"id\":\"pi\",\"displayName\":\"Pi\",\"projectSkillsDirs\":[\".pi/skills\"],\"userSkillsDirs\":[\"~/.pi/agent/skills\"],\"detect\":[\"~/.pi/agent\"],\"status\":\"community\"},{\"id\":\"pochi\",\"displayName\":\"Pochi\",\"projectSkillsDirs\":[\".pochi/skills\"],\"userSkillsDirs\":[\"~/.pochi/skills\"],\"detect\":[\"~/.pochi\"],\"status\":\"community\"},{\"id\":\"promptscript\",\"displayName\":\"PromptScript\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[],\"detect\":[\".promptscript\",\"promptscript.yaml\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\"]},{\"id\":\"qoder\",\"displayName\":\"Qoder\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder/skills\"],\"detect\":[\"~/.qoder\"],\"status\":\"community\"},{\"id\":\"qoder-cn\",\"displayName\":\"Qoder CN\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder-cn/skills\"],\"detect\":[\"~/.qoder-cn\"],\"status\":\"community\"},{\"id\":\"qwen-code\",\"displayName\":\"Qwen Code\",\"projectSkillsDirs\":[\".qwen/skills\"],\"userSkillsDirs\":[\"~/.qwen/skills\"],\"detect\":[\"~/.qwen\"],\"status\":\"community\"},{\"id\":\"reasonix\",\"displayName\":\"Reasonix\",\"projectSkillsDirs\":[\".reasonix/skills\"],\"userSkillsDirs\":[\"~/.reasonix/skills\"],\"detect\":[\"~/.reasonix\"],\"status\":\"community\"},{\"id\":\"replit\",\"displayName\":\"Replit\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\".replit\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"roo\",\"displayName\":\"Roo Code\",\"aliases\":[\"roo-code\"],\"projectSkillsDirs\":[\".roo/skills\",\".agents/skills\"],\"userSkillsDirs\":[\"~/.roo/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.roo\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"rovodev\",\"displayName\":\"Rovo Dev\",\"projectSkillsDirs\":[\".rovodev/skills\"],\"userSkillsDirs\":[\"~/.rovodev/skills\"],\"detect\":[\"~/.rovodev\"],\"status\":\"community\"},{\"id\":\"tabnine-cli\",\"displayName\":\"Tabnine CLI\",\"projectSkillsDirs\":[\".tabnine/agent/skills\"],\"userSkillsDirs\":[\"~/.tabnine/agent/skills\"],\"detect\":[\"~/.tabnine\",\"~/.tabnine/agent\"],\"status\":\"community\"},{\"id\":\"terramind\",\"displayName\":\"Terramind\",\"projectSkillsDirs\":[\".terramind/skills\"],\"userSkillsDirs\":[\"~/.terramind/skills\"],\"detect\":[\"~/.terramind\"],\"status\":\"community\"},{\"id\":\"tinycloud\",\"displayName\":\"Tinycloud\",\"projectSkillsDirs\":[\".tinycloud/skills\"],\"userSkillsDirs\":[\"~/.tinycloud/skills\"],\"detect\":[\"~/.tinycloud\"],\"status\":\"community\"},{\"id\":\"trae\",\"displayName\":\"Trae\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae/skills\"],\"detect\":[\"~/.trae\"],\"status\":\"community\"},{\"id\":\"trae-cn\",\"displayName\":\"Trae CN\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae-cn/skills\"],\"detect\":[\"~/.trae-cn\"],\"status\":\"community\"},{\"id\":\"universal\",\"displayName\":\"Universal\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.config/agents/skills\"],\"detect\":[\"~/.agents\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"warp\",\"displayName\":\"Warp\",\"projectSkillsDirs\":[\".agents/skills\",\".warp/skills\",\".claude/skills\",\".codex/skills\",\".cursor/skills\",\".gemini/skills\",\".copilot/skills\",\".factory/skills\",\".github/skills\",\".opencode/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.warp/skills\",\"~/.claude/skills\",\"~/.codex/skills\",\"~/.cursor/skills\",\"~/.gemini/skills\",\"~/.copilot/skills\",\"~/.factory/skills\",\"~/.github/skills\",\"~/.opencode/skills\"],\"detect\":[\"~/.warp\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"windsurf\",\"displayName\":\"Windsurf\",\"projectSkillsDirs\":[\".windsurf/skills\"],\"userSkillsDirs\":[\"~/.codeium/windsurf/skills\"],\"detect\":[\"~/.codeium/windsurf\"],\"status\":\"community\"},{\"id\":\"zed\",\"displayName\":\"Zed\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.config/zed\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"zencoder\",\"displayName\":\"Zencoder\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"},{\"id\":\"zenflow\",\"displayName\":\"Zenflow\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"}]}" diff --git a/go/kitup.go b/go/kitup.go index 89ccd9e..0773406 100644 --- a/go/kitup.go +++ b/go/kitup.go @@ -15,6 +15,7 @@ import ( pathpkg "path" "path/filepath" "regexp" + "slices" "sort" "strconv" "strings" @@ -426,20 +427,26 @@ func LoadHostSpec(hostsFile string) ([]Host, error) { func validateHostSpec(hosts []Host) error { for _, host := range hosts { + installDirs := map[string]bool{} for _, path := range host.ProjectSkillsDir { if !isProjectHostPath(path) { return fmt.Errorf("invalid project path %q for host %q", path, host.ID) } + installDirs[path] = true } for _, path := range host.UserSkillsDir { if !isHomeHostPath(path) { return fmt.Errorf("invalid user path %q for host %q", path, host.ID) } + installDirs[path] = true } for _, path := range host.Detect { if !isHomeHostPath(path) && !isProjectHostPath(path) { return fmt.Errorf("invalid detect path %q for host %q", path, host.ID) } + if !isGenericDetectPath(path) && installDirs[path] { + return fmt.Errorf("detect path is an install target for host %q: %q", host.ID, path) + } } } return nil @@ -458,7 +465,7 @@ func isSafeHostPath(path string) bool { return false } for _, segment := range strings.Split(path, "/") { - if segment == ".." || segment == "" { + if segment == "." || segment == ".." || segment == "" { return false } } @@ -504,11 +511,14 @@ func DetectHosts(opts BaseOptions, scope Scope) ([]Host, error) { home, cwd := defaults(opts) detected := []Host{} for _, host := range hosts { - if len(host.Detect) == 0 || isGenericDetectPath(host.Detect[0]) { - continue - } - if exists(expandHostPath(host.Detect[0], home, cwd)) { - detected = append(detected, host) + for _, path := range host.Detect { + if isGenericDetectPath(path) { + continue + } + if detectionPathExists(expandHostPath(path, home, cwd)) { + detected = append(detected, host) + break + } } } if scope == "" { @@ -570,6 +580,10 @@ func ResolveInstallSelection(opts InstallSelectionOptions) (InstallSelection, er } func ResolveInstallTargets(opts BaseOptions, agents AgentSelector, scope Scope, skillName string) ([]TargetGroup, []map[string]any, []string, error) { + return resolveInstallTargets(opts, agents, scope, skillName, "") +} + +func resolveInstallTargets(opts BaseOptions, agents AgentSelector, scope Scope, skillName, uninstallAppID string) ([]TargetGroup, []map[string]any, []string, error) { if !isValidSkillName(skillName) { return nil, []map[string]any{{ "skillName": skillName, @@ -596,20 +610,29 @@ func ResolveInstallTargets(opts BaseOptions, agents AgentSelector, scope Scope, } byTarget := map[string]*TargetGroup{} for _, host := range selected { - root := chooseScopePath(host, scope, home, cwd) - if root == "" { + roots := []string{} + if uninstallAppID != "" { + roots = uninstallScopePaths(host, scope, home, cwd, skillName, uninstallAppID) + } else if root := chooseScopePath(host, scope, home, cwd, skillName); root != "" { + roots = append(roots, root) + } + if len(roots) == 0 { errs = append(errs, map[string]any{ "hostId": host.ID, "skillName": skillName, "scope": string(scope), "reason": "unsupported-scope", }) continue } - targetDir := filepath.Join(root, skillName) - group := byTarget[targetDir] - if group == nil { - group = &TargetGroup{SkillName: skillName, TargetDir: targetDir} - byTarget[targetDir] = group + for _, root := range roots { + targetDir := filepath.Join(root, skillName) + group := byTarget[targetDir] + if group == nil { + group = &TargetGroup{SkillName: skillName, TargetDir: targetDir} + byTarget[targetDir] = group + } + if !slices.Contains(group.HostIDs, host.ID) { + group.HostIDs = append(group.HostIDs, host.ID) + } } - group.HostIDs = append(group.HostIDs, host.ID) } targets := []TargetGroup{} for _, target := range byTarget { @@ -840,7 +863,7 @@ func UninstallBundledSkill(opts UninstallOptions) (UninstallReport, error) { if opts.AppID == "" { return emptyUninstallReport([]map[string]any{{"reason": "invalid-app-id"}}), nil } - targets, errs, _, err := ResolveInstallTargets(opts.BaseOptions, opts.Agents, opts.Scope, opts.SkillName) + targets, errs, _, err := resolveInstallTargets(opts.BaseOptions, opts.Agents, opts.Scope, opts.SkillName, opts.AppID) if err != nil { return UninstallReport{}, err } @@ -1327,20 +1350,48 @@ func canonicalScopePath(host Host, scope Scope, home, cwd string) string { return expandHostPath(paths[0], home, cwd) } -func chooseScopePath(host Host, scope Scope, home, cwd string) string { +func chooseScopePath(host Host, scope Scope, home, cwd, skillName string) string { paths := scopePaths(host, scope) + existing := []string{} for _, path := range paths { expanded := expandHostPath(path, home, cwd) - if exists(expanded) { - return expanded + if isDirectory(expanded) { + existing = append(existing, expanded) + } + } + for _, root := range existing { + meta, _, managed := readMetadata(filepath.Join(root, skillName)) + if managed && meta.SkillName == skillName { + return root } } + if len(existing) > 0 { + return existing[0] + } if len(paths) == 0 { return "" } return expandHostPath(paths[0], home, cwd) } +func uninstallScopePaths(host Host, scope Scope, home, cwd, skillName, appID string) []string { + owned := []string{} + for _, path := range scopePaths(host, scope) { + root := expandHostPath(path, home, cwd) + meta, _, managed := readMetadata(filepath.Join(root, skillName)) + if managed && meta.SkillName == skillName && meta.AppID == appID { + owned = append(owned, root) + } + } + if len(owned) > 0 { + return owned + } + if fallback := chooseScopePath(host, scope, home, cwd, skillName); fallback != "" { + return []string{fallback} + } + return nil +} + func scopePaths(host Host, scope Scope) []string { if scope == UserScope { return host.UserSkillsDir @@ -1687,10 +1738,25 @@ func skipName(name string) bool { } func isGenericDetectPath(path string) bool { - return path == "~/.agents" || path == "~/.agents/skills" || path == "~/.config/agents" + switch path { + case "~/.agents", "~/.agents/skills", "~/.config/agents", + ".agents", ".agents/skills", "package.json": + return true + } + return false } func exists(path string) bool { _, err := os.Stat(path) return err == nil || !errors.Is(err, os.ErrNotExist) } + +func detectionPathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func isDirectory(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} diff --git a/go/kitup_test.go b/go/kitup_test.go index 23bb6c3..c20a1f3 100644 --- a/go/kitup_test.go +++ b/go/kitup_test.go @@ -1,44 +1,29 @@ package kitup import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" + "errors" "os" "path/filepath" - "reflect" - "strconv" - "strings" "testing" "testing/fstest" ) -type goldenFile struct { - Cases []goldenCase `json:"cases"` -} - -type goldenCase struct { - ID string `json:"id"` - Operation string `json:"operation"` - Options map[string]any `json:"options"` - Given map[string]any `json:"given"` - Expected map[string]any `json:"expected"` -} +func TestDetectionPathExistsRequiresSuccessfulStat(t *testing.T) { + locked := filepath.Join(t.TempDir(), "locked") + if err := os.Mkdir(locked, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(locked, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(locked, 0o700) }) -func TestGoldenCases(t *testing.T) { - var file goldenFile - readJSON(t, "../testdata/cases/bundled-skill-install.json", &file) - for _, tc := range file.Cases { - t.Run(tc.ID, func(t *testing.T) { - root := t.TempDir() - home := filepath.Join(root, "home") - workspace := filepath.Join(root, "workspace") - must(t, os.MkdirAll(home, 0o755)) - must(t, os.MkdirAll(workspace, 0o755)) - setupGiven(t, tc, home, workspace) - runCase(t, tc, home, workspace) - }) + target := filepath.Join(locked, "marker") + if _, err := os.Stat(target); err == nil || errors.Is(err, os.ErrNotExist) { + t.Skip("filesystem does not expose an inaccessible path as a stat error") + } + if detectionPathExists(target) { + t.Fatal("inaccessible detection path must not count as existing") } } @@ -53,558 +38,17 @@ func TestFSBundleTreatsEmbedReadOnlyModesAsDefaults(t *testing.T) { Mode: 0o444, }, }, "skills/basic") - must(t, err) + if err != nil { + t.Fatal(err) + } modes := map[string]os.FileMode{} for _, file := range files { modes[file.Path] = file.Mode.Perm() } - equal(t, modes["SKILL.md"], os.FileMode(0o644)) - equal(t, modes["scripts/helper.sh"], os.FileMode(0o755)) -} - -func runCase(t *testing.T, tc goldenCase, home, workspace string) { - opts := expandValue(tc.Options, home, workspace).(map[string]any) - switch tc.Operation { - case "resolve-hosts": - hostsFile := repoPathFromCase(tc.Given["hostsFile"].(string)) - hosts, err := LoadHostSpec(hostsFile) - must(t, err) - resolved, errs := ResolveHosts(agentSelector(opts["agents"]), hosts) - if expected, ok := tc.Expected["count"]; ok && int(expected.(float64)) != len(resolved) { - t.Fatalf("count: got %d want %v", len(resolved), expected) - } - if expected, ok := tc.Expected["hostIds"]; ok { - equal(t, hostIDs(resolved), expected) - } - if expected, ok := tc.Expected["resolvedHostIds"]; ok { - equal(t, hostIDs(resolved), expected) - } - if expected, ok := tc.Expected["errors"]; ok { - equal(t, errs, expected) - } - case "validate": - result := ValidateSkillBundle(skillBundleFromOptions(opts)) - equal(t, result.Valid, tc.Expected["valid"]) - equal(t, result.ErrorCode, tc.Expected["errorCode"]) - case "parse-install-flags": - assertParsedFlags(t, ParseInstallFlags(InstallFlagValues{ - Scope: stringValue(opts["scope"]), - ScopeSet: boolValue(opts["scopeSet"]) || opts["scope"] != nil, - Agents: stringSlice(opts["agents"]), - Yes: boolValue(opts["yes"]), - DryRun: boolValue(opts["dryRun"]), - Force: boolValue(opts["force"]), - }), tc.Expected["parsed"].(map[string]any)) - case "resolve-install-selection": - selection, err := ResolveInstallSelection(InstallSelectionOptions{ - BaseOptions: caseBaseOptions(tc, home, workspace), - Scope: Scope(opts["scope"].(string)), - Agents: agentSelector(opts["agents"]), - Yes: boolValue(opts["yes"]), - StdinTTY: boolValue(opts["stdinTTY"]), - }) - must(t, err) - assertSelection(t, normalize(selection).(map[string]any), tc.Expected["selection"].(map[string]any)) - case "run-install-workflow": - var out bytes.Buffer - report, err := RunBundledSkillInstall(InstallWorkflowOptions{ - InstallOptions: InstallOptions{ - BaseOptions: caseBaseOptions(tc, home, workspace), - AppID: opts["appId"].(string), - SkillBundle: skillBundleFromOptions(opts), - Scope: Scope(stringValue(opts["scope"])), - Agents: agentSelector(opts["agents"]), - Force: boolValue(opts["force"]), - }, - Yes: boolValue(opts["yes"]), - DryRun: boolValue(opts["dryRun"]), - StdinTTY: boolValue(opts["stdinTTY"]), - DefaultScope: Scope(stringValue(opts["defaultScope"])), - ScopeSet: boolValue(opts["scopeSet"]) || opts["scope"] != nil, - PromptScope: boolValue(opts["promptScope"]), - In: strings.NewReader(stringValue(opts["input"])), - Out: &out, - }) - must(t, err) - assertWorkflow(t, normalize(report).(map[string]any), tc.Expected["workflow"]) - if expected, ok := tc.Expected["exit"]; ok { - equal(t, ClassifyInstallWorkflowExit(report), expected) - } - assertOutput(t, out.String(), tc.Expected["output"]) - assertOutputContains(t, out.String(), tc.Expected["outputContains"]) - if expected, ok := tc.Expected["report"]; ok { - equal(t, report.Report, expandValue(expected, home, workspace)) - } - assertExpectedFiles(t, tc, home, workspace) - assertExpectedFileModes(t, tc, home, workspace) - assertExpectedMetadata(t, tc, home, workspace) - default: - base := caseBaseOptions(tc, home, workspace) - if expected, ok := tc.Expected["detectedHosts"]; ok { - hosts, err := DetectHosts(base, Scope(opts["scope"].(string))) - must(t, err) - equal(t, hostIDs(hosts), expected) - } - report, err := runReportCase(t, tc, opts, base) - if throws, ok := tc.Expected["throws"].(bool); ok && throws { - if err == nil { - t.Fatal("expected operation to throw") - } - assertExpectedFiles(t, tc, home, workspace) - return - } - must(t, err) - if expected, ok := tc.Expected["report"]; ok { - equal(t, report, expandValue(expected, home, workspace)) - } - assertExpectedWriteCounts(t, tc, report, home, workspace) - assertExpectedFiles(t, tc, home, workspace) - assertExpectedFileModes(t, tc, home, workspace) - assertExpectedMetadata(t, tc, home, workspace) - } -} - -func runReportCase(t *testing.T, tc goldenCase, opts map[string]any, base BaseOptions) (any, error) { - switch tc.Operation { - case "uninstall": - return UninstallBundledSkill(UninstallOptions{ - BaseOptions: base, - AppID: opts["appId"].(string), - SkillName: opts["skillName"].(string), - Scope: Scope(opts["scope"].(string)), - Agents: agentSelector(opts["agents"]), - }) - case "install", "update", "plan": - fn := InstallBundledSkill - if tc.Operation == "update" { - fn = UpdateBundledSkill - } - if tc.Operation == "plan" { - fn = PlanBundledSkill - } - return fn(InstallOptions{ - BaseOptions: base, - AppID: opts["appId"].(string), - SkillBundle: skillBundleFromOptions(opts), - Scope: Scope(opts["scope"].(string)), - Agents: agentSelector(opts["agents"]), - Force: boolValue(opts["force"]), - }) - default: - t.Fatalf("unsupported operation: %s", tc.Operation) - return nil, nil - } -} - -func baseOptions(home, workspace string) BaseOptions { - return BaseOptions{Home: home, CWD: workspace, HostsFile: repoPathFromCase("spec/hosts.json")} -} - -func caseBaseOptions(tc goldenCase, home, workspace string) BaseOptions { - base := baseOptions(home, workspace) - if hostsFile, ok := tc.Options["hostsFile"].(string); ok && hostsFile != "" { - expanded := expandString(hostsFile, home, workspace) - if filepath.IsAbs(expanded) { - base.HostsFile = expanded - } else { - base.HostsFile = repoPathFromCase(expanded) - } - } - return base -} - -func setupGiven(t *testing.T, tc goldenCase, home, workspace string) { - for _, dir := range stringSlice(tc.Given["dirs"]) { - must(t, os.MkdirAll(expandString(dir, home, workspace), 0o755)) - } - if files, ok := tc.Given["files"].(map[string]any); ok { - for path, value := range files { - writeFixtureFile(t, expandString(path, home, workspace), value) - } - } - if target, ok := tc.Given["copySkillBundleTo"].(string); ok { - must(t, os.RemoveAll(expandString(target, home, workspace))) - must(t, copySkillBundleDir(caseSkillBundleDir(tc), expandString(target, home, workspace))) - } - if modes, ok := tc.Given["fileModes"].(map[string]any); ok { - for path, mode := range modes { - value, err := strconv.ParseUint(mode.(string), 8, 32) - must(t, err) - must(t, os.Chmod(expandString(path, home, workspace), os.FileMode(value))) - } - } - if meta, ok := tc.Given["metadata"].(map[string]any); ok { - writeMetadataFixture(t, tc, home, workspace, meta) - } - if github, ok := tc.Given["github"].(map[string]any); ok { - startGitHubFixture(t, github) - } -} - -func assertExpectedFiles(t *testing.T, tc goldenCase, home, workspace string) { - for _, path := range stringSlice(tc.Expected["filesPresent"]) { - if !exists(expandString(path, home, workspace)) { - t.Fatalf("expected file to exist: %s", expandString(path, home, workspace)) - } + if got, want := modes["SKILL.md"], os.FileMode(0o644); got != want { + t.Fatalf("SKILL.md mode: got %o want %o", got, want) } - for _, path := range stringSlice(tc.Expected["filesAbsent"]) { - if exists(expandString(path, home, workspace)) { - t.Fatalf("expected file to be absent: %s", expandString(path, home, workspace)) - } - } -} - -func assertExpectedFileModes(t *testing.T, tc goldenCase, home, workspace string) { - modes, ok := tc.Expected["fileModes"].(map[string]any) - if !ok { - return - } - for path, expected := range modes { - info, err := os.Stat(expandString(path, home, workspace)) - must(t, err) - equal(t, modeString(info.Mode().Perm()), expected) - } -} - -func assertExpectedMetadata(t *testing.T, tc goldenCase, home, workspace string) { - meta, ok := tc.Expected["metadata"].(map[string]any) - if !ok { - return - } - path := expandString(meta["path"].(string), home, workspace) - var actual map[string]any - readJSON(t, path, &actual) - for key, value := range meta["fields"].(map[string]any) { - equal(t, actual[key], value) - } - hash := expectedBundleHash(t, tc, meta["hash"].(string)) - equal(t, actual["hash"], hash) -} - -func modeString(mode os.FileMode) string { - return strings.TrimLeft("000"+strconv.FormatUint(uint64(mode), 8), "0") -} - -func assertExpectedWriteCounts(t *testing.T, tc goldenCase, report any, home, workspace string) { - expected, ok := tc.Expected["writeCountByTargetDir"] - if !ok { - return - } - actual := map[string]any{} - reportMap := normalize(report).(map[string]any) - for _, key := range []string{"installed", "updated"} { - for _, item := range reportMap[key].([]any) { - targetDir := item.(map[string]any)["targetDir"].(string) - if actual[targetDir] == nil { - actual[targetDir] = float64(0) - } - actual[targetDir] = actual[targetDir].(float64) + 1 - } - } - equal(t, actual, expandValue(expected, home, workspace)) -} - -func writeMetadataFixture(t *testing.T, tc goldenCase, home, workspace string, meta map[string]any) { - hash := expectedBundleHash(t, tc, meta["hash"].(string)) - fields := map[string]any{} - for key, value := range meta["fields"].(map[string]any) { - fields[key] = value - } - fields["hash"] = hash - writeFixtureFile(t, expandString(meta["path"].(string), home, workspace), fields) -} - -func expectedBundleHash(t *testing.T, tc goldenCase, marker string) string { - switch marker { - case "from-skill-bundle-dir": - hash, err := ComputeBundleContentHash(DirectoryBundle(caseSkillBundleDir(tc))) - must(t, err) - return hash - case "from-skill-files": - hash, err := ComputeBundleContentHash(FilesBundle(skillFiles(tc.Options["skillFiles"].([]any)))) - must(t, err) - return hash - case "from-github-bundle": - hash, err := ComputeBundleContentHash(FilesBundle(githubSkillFiles(tc))) - must(t, err) - return hash - default: - return marker - } -} - -func assertSelection(t *testing.T, actual, expected map[string]any) { - if expectedCount, ok := expected["selectedCount"]; ok { - equal(t, float64(len(actual["selectedHostIds"].([]any))), expectedCount) - delete(actual, "selectedHostIds") - } - if expectedCount, ok := expected["candidateCount"]; ok { - equal(t, float64(len(actual["candidateHostIds"].([]any))), expectedCount) - delete(actual, "candidateHostIds") - } - delete(expected, "selectedCount") - delete(expected, "candidateCount") - equal(t, actual, expected) -} - -func assertWorkflow(t *testing.T, actual map[string]any, expected any) { - expectedMap, ok := expected.(map[string]any) - if !ok { - return - } - for key, value := range expectedMap { - equal(t, actual[key], value) - } -} - -func assertParsedFlags(t *testing.T, actual ParsedInstallFlags, expected map[string]any) { - agentKind := actual.Agents.Kind - agentIDs := actual.Agents.IDs - if agentIDs == nil { - agentIDs = []string{} - } - equal(t, map[string]any{ - "scope": string(actual.Scope), - "scopeSet": actual.ScopeSet, - "agentKind": agentKind, - "agentIds": agentIDs, - "yes": actual.Yes, - "dryRun": actual.DryRun, - "force": actual.Force, - "errors": actual.Errors, - }, expected) -} - -func assertOutputContains(t *testing.T, actual string, expected any) { - for _, value := range stringSlice(expected) { - if !strings.Contains(actual, value) { - t.Fatalf("expected output to contain %q, got:\n%s", value, actual) - } - } -} - -func assertOutput(t *testing.T, actual string, expected any) { - if expected == nil { - return - } - equal(t, actual, expected) -} - -func writeFixtureFile(t *testing.T, path string, value any) { - must(t, os.MkdirAll(filepath.Dir(path), 0o755)) - switch value := value.(type) { - case string: - must(t, os.WriteFile(path, []byte(value), 0o644)) - default: - data, err := json.MarshalIndent(value, "", " ") - must(t, err) - must(t, os.WriteFile(path, append(data, '\n'), 0o644)) - } -} - -func agentSelector(value any) AgentSelector { - if value == nil { - return AutoAgents() - } - if text, ok := value.(string); ok { - if text == "*" { - return AllAgents() - } - return AutoAgents() - } - return ExplicitAgents(stringSlice(value)...) -} - -func skillBundleFromOptions(opts map[string]any) SkillBundle { - if files, ok := opts["skillFiles"].([]any); ok { - return FilesBundle(skillFiles(files)) - } - if dir, ok := opts["skillBundleDir"].(string); ok { - return DirectoryBundle(repoPathFromCase(dir)) - } - if bundle, ok := opts["githubBundle"].(map[string]any); ok { - return GitHubBundle(GitHubBundleOptions{ - Owner: bundle["owner"].(string), - Repo: bundle["repo"].(string), - Path: bundle["path"].(string), - Ref: bundle["ref"].(string), - }) - } - return SkillBundle{} -} - -func skillFiles(values []any) []SkillFile { - files := make([]SkillFile, 0, len(values)) - for _, value := range values { - item := value.(map[string]any) - mode := os.FileMode(0) - if raw, ok := item["mode"].(float64); ok { - mode = os.FileMode(raw) - } - files = append(files, SkillFile{ - Path: item["path"].(string), - Contents: []byte(item["contents"].(string)), - Mode: mode, - }) - } - return files -} - -func githubSkillFiles(tc goldenCase) []SkillFile { - bundle := tc.Options["githubBundle"].(map[string]any) - root := strings.Trim(bundle["path"].(string), "/") + "/" - github := tc.Given["github"].(map[string]any) - rawFiles := github["files"].(map[string]any) - files := []SkillFile{} - for path, contents := range rawFiles { - if strings.HasPrefix(path, root) { - files = append(files, SkillFile{Path: strings.TrimPrefix(path, root), Contents: []byte(contents.(string))}) - } - } - return files -} - -func startGitHubFixture(t *testing.T, github map[string]any) { - owner := github["owner"].(string) - repo := github["repo"].(string) - ref := github["ref"].(string) - commit := github["commit"].(string) - treeSha := github["treeSha"].(string) - files := github["files"].(map[string]any) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/repos/" + owner + "/" + repo + "/commits/" + ref: - writeResponseJSON(t, w, map[string]any{"sha": commit, "commit": map[string]any{"tree": map[string]any{"sha": treeSha}}}) - case "/repos/" + owner + "/" + repo + "/git/trees/" + treeSha: - tree := []map[string]string{} - for path := range files { - mode := "100644" - if strings.HasSuffix(path, ".sh") { - mode = "100755" - } - tree = append(tree, map[string]string{"path": path, "type": "blob", "mode": mode}) - } - writeResponseJSON(t, w, map[string]any{"tree": tree}) - default: - prefix := "/" + owner + "/" + repo + "/" + commit + "/" - if strings.HasPrefix(r.URL.Path, prefix) { - path := strings.TrimPrefix(r.URL.Path, prefix) - if contents, ok := files[path]; ok { - _, _ = w.Write([]byte(contents.(string))) - return - } - } - http.NotFound(w, r) - } - })) - t.Setenv("KITUP_GITHUB_API_BASE_URL", server.URL) - t.Setenv("KITUP_GITHUB_RAW_BASE_URL", server.URL) - t.Cleanup(server.Close) -} - -func writeResponseJSON(t *testing.T, w http.ResponseWriter, value any) { - w.Header().Set("content-type", "application/json") - data, err := json.Marshal(value) - must(t, err) - _, _ = w.Write(data) -} - -func boolValue(value any) bool { - boolean, _ := value.(bool) - return boolean -} - -func stringValue(value any) string { - text, _ := value.(string) - return text -} - -func caseSkillBundleDir(tc goldenCase) string { - if dir, ok := tc.Options["skillBundleDir"].(string); ok { - return repoPathFromCase(dir) - } - return repoPathFromCase("testdata/skills/" + tc.Options["skillName"].(string)) -} - -func repoPathFromCase(path string) string { - if filepath.IsAbs(path) { - return path - } - return filepath.Join("..", path) -} - -func hostIDs(hosts []Host) []string { - ids := []string{} - for _, host := range hosts { - ids = append(ids, host.ID) - } - return ids -} - -func stringSlice(value any) []string { - if value == nil { - return nil - } - var out []string - for _, item := range value.([]any) { - out = append(out, item.(string)) - } - return out -} - -func expandValue(value any, home, workspace string) any { - switch value := value.(type) { - case string: - return expandString(value, home, workspace) - case []any: - out := make([]any, len(value)) - for i, item := range value { - out[i] = expandValue(item, home, workspace) - } - return out - case map[string]any: - out := map[string]any{} - for key, item := range value { - out[expandString(key, home, workspace)] = expandValue(item, home, workspace) - } - return out - default: - return value - } -} - -func expandString(value, home, workspace string) string { - value = strings.ReplaceAll(value, "$HOME", home) - return strings.ReplaceAll(value, "$WORKSPACE", workspace) -} - -func equal(t *testing.T, got, want any) { - t.Helper() - got = normalize(got) - want = normalize(want) - if !reflect.DeepEqual(got, want) { - g, _ := json.MarshalIndent(got, "", " ") - w, _ := json.MarshalIndent(want, "", " ") - t.Fatalf("got:\n%s\nwant:\n%s", g, w) - } -} - -func normalize(value any) any { - data, _ := json.Marshal(value) - var out any - _ = json.Unmarshal(data, &out) - return out -} - -func readJSON(t *testing.T, path string, out any) { - data, err := os.ReadFile(path) - must(t, err) - must(t, json.Unmarshal(data, out)) -} - -func must(t *testing.T, err error) { - t.Helper() - if err != nil { - t.Fatal(err) + if got, want := modes["scripts/helper.sh"], os.FileMode(0o755); got != want { + t.Fatalf("helper.sh mode: got %o want %o", got, want) } } diff --git a/python/src/kitup/_hosts_generated.py b/python/src/kitup/_hosts_generated.py index 71b68c1..42bd256 100644 --- a/python/src/kitup/_hosts_generated.py +++ b/python/src/kitup/_hosts_generated.py @@ -1,3 +1,3 @@ # Code generated from spec/hosts.json. DO NOT EDIT. -DEFAULT_HOSTS_SPEC_JSON = "{\"$schema\":\"./hosts.schema.json\",\"schemaVersion\":1,\"hosts\":[{\"id\":\"adal\",\"displayName\":\"AdaL\",\"projectSkillsDirs\":[\".adal/skills\"],\"userSkillsDirs\":[\"~/.adal/skills\"],\"detect\":[\"~/.adal\"],\"status\":\"community\"},{\"id\":\"aider-desk\",\"displayName\":\"AiderDesk\",\"projectSkillsDirs\":[\".aider-desk/skills\"],\"userSkillsDirs\":[\"~/.aider-desk/skills\"],\"detect\":[\"~/.aider-desk\"],\"status\":\"community\"},{\"id\":\"amp\",\"displayName\":\"Amp\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\"~/.config/amp\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"antigravity\",\"displayName\":\"Antigravity\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity/skills\"],\"detect\":[\"~/.gemini/antigravity\"],\"status\":\"community\"},{\"id\":\"antigravity-cli\",\"displayName\":\"Antigravity CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity-cli/skills\"],\"detect\":[\"~/.gemini/antigravity-cli\"],\"status\":\"community\"},{\"id\":\"astrbot\",\"displayName\":\"AstrBot\",\"projectSkillsDirs\":[\"data/skills\"],\"userSkillsDirs\":[\"~/.astrbot/data/skills\"],\"detect\":[\"~/.astrbot\",\"data/skills\",\"~/.astrbot/data\"],\"status\":\"community\"},{\"id\":\"augment\",\"displayName\":\"Augment\",\"projectSkillsDirs\":[\".augment/skills\"],\"userSkillsDirs\":[\"~/.augment/skills\"],\"detect\":[\"~/.augment\"],\"status\":\"community\"},{\"id\":\"autohand-code\",\"displayName\":\"Autohand Code CLI\",\"projectSkillsDirs\":[\".autohand/skills\"],\"userSkillsDirs\":[\"~/.autohand/skills\"],\"detect\":[\"~/.autohand\"],\"status\":\"community\"},{\"id\":\"bob\",\"displayName\":\"IBM Bob\",\"projectSkillsDirs\":[\".bob/skills\"],\"userSkillsDirs\":[\"~/.bob/skills\"],\"detect\":[\"~/.bob\"],\"status\":\"community\"},{\"id\":\"claude-code\",\"displayName\":\"Claude Code\",\"projectSkillsDirs\":[\".claude/skills\"],\"userSkillsDirs\":[\"~/.claude/skills\"],\"detect\":[\"~/.claude\"],\"status\":\"verified\"},{\"id\":\"cline\",\"displayName\":\"Cline\",\"projectSkillsDirs\":[\".agents/skills\",\".cline/skills\",\".clinerules/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.cline/skills\"],\"detect\":[\"~/.cline\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"codearts-agent\",\"displayName\":\"CodeArts Agent\",\"projectSkillsDirs\":[\".codeartsdoer/skills\"],\"userSkillsDirs\":[\"~/.codeartsdoer/skills\"],\"detect\":[\"~/.codeartsdoer\"],\"status\":\"community\"},{\"id\":\"codebuddy\",\"displayName\":\"CodeBuddy\",\"projectSkillsDirs\":[\".codebuddy/skills\"],\"userSkillsDirs\":[\"~/.codebuddy/skills\"],\"detect\":[\"~/.codebuddy\",\".codebuddy\"],\"status\":\"community\"},{\"id\":\"codemaker\",\"displayName\":\"Codemaker\",\"projectSkillsDirs\":[\".codemaker/skills\"],\"userSkillsDirs\":[\"~/.codemaker/skills\"],\"detect\":[\"~/.codemaker\"],\"status\":\"community\"},{\"id\":\"codestudio\",\"displayName\":\"Code Studio\",\"projectSkillsDirs\":[\".codestudio/skills\"],\"userSkillsDirs\":[\"~/.codestudio/skills\"],\"detect\":[\"~/.codestudio\"],\"status\":\"community\"},{\"id\":\"codex\",\"displayName\":\"Codex\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.codex/skills\"],\"detect\":[\"~/.codex\",\"~/.agents/skills\",\"~/.agents\"],\"status\":\"verified\",\"notes\":[\"Keep both ~/.agents/skills and ~/.codex/skills for compatibility.\"]},{\"id\":\"command-code\",\"displayName\":\"Command Code\",\"projectSkillsDirs\":[\".commandcode/skills\"],\"userSkillsDirs\":[\"~/.commandcode/skills\"],\"detect\":[\"~/.commandcode\"],\"status\":\"community\"},{\"id\":\"continue\",\"displayName\":\"Continue\",\"projectSkillsDirs\":[\".continue/skills\"],\"userSkillsDirs\":[\"~/.continue/skills\"],\"detect\":[\"~/.continue\",\".continue\"],\"status\":\"community\"},{\"id\":\"cortex\",\"displayName\":\"Cortex Code\",\"projectSkillsDirs\":[\".cortex/skills\"],\"userSkillsDirs\":[\"~/.snowflake/cortex/skills\"],\"detect\":[\"~/.snowflake/cortex\"],\"status\":\"community\"},{\"id\":\"crush\",\"displayName\":\"Crush\",\"projectSkillsDirs\":[\".crush/skills\"],\"userSkillsDirs\":[\"~/.config/crush/skills\"],\"detect\":[\"~/.config/crush\"],\"status\":\"community\"},{\"id\":\"cursor\",\"displayName\":\"Cursor\",\"projectSkillsDirs\":[\".agents/skills\",\".cursor/skills\"],\"userSkillsDirs\":[\"~/.cursor/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.cursor\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"deepagents\",\"displayName\":\"Deep Agents\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.deepagents/agent/skills\"],\"detect\":[\"~/.deepagents\",\"~/.deepagents/agent\"],\"status\":\"community\"},{\"id\":\"devin\",\"displayName\":\"Devin for Terminal\",\"projectSkillsDirs\":[\".devin/skills\"],\"userSkillsDirs\":[\"~/.config/devin/skills\"],\"detect\":[\"~/.config/devin\"],\"status\":\"community\"},{\"id\":\"dexto\",\"displayName\":\"Dexto\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.dexto\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"droid\",\"displayName\":\"Droid\",\"projectSkillsDirs\":[\".factory/skills\"],\"userSkillsDirs\":[\"~/.factory/skills\"],\"detect\":[\"~/.factory\"],\"status\":\"community\"},{\"id\":\"eve\",\"displayName\":\"Eve\",\"projectSkillsDirs\":[\"agent/skills\"],\"userSkillsDirs\":[],\"detect\":[\"agent\",\"package.json\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\",\"Detect from Eve project shape; no global skill directory.\"]},{\"id\":\"firebender\",\"displayName\":\"Firebender\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.firebender/skills\"],\"detect\":[\"~/.firebender\"],\"status\":\"community\"},{\"id\":\"forgecode\",\"displayName\":\"ForgeCode\",\"projectSkillsDirs\":[\".forge/skills\"],\"userSkillsDirs\":[\"~/.forge/skills\"],\"detect\":[\"~/.forge\"],\"status\":\"community\"},{\"id\":\"gemini-cli\",\"displayName\":\"Gemini CLI\",\"projectSkillsDirs\":[\".agents/skills\",\".gemini/skills\"],\"userSkillsDirs\":[\"~/.gemini/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.gemini\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"github-copilot\",\"displayName\":\"GitHub Copilot\",\"projectSkillsDirs\":[\".agents/skills\",\".github/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.copilot/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.copilot\",\"~/.agents\",\"~/.claude\"],\"status\":\"documented\"},{\"id\":\"goose\",\"displayName\":\"Goose\",\"projectSkillsDirs\":[\".goose/skills\"],\"userSkillsDirs\":[\"~/.config/goose/skills\"],\"detect\":[\"~/.config/goose\"],\"status\":\"community\"},{\"id\":\"hermes-agent\",\"displayName\":\"Hermes Agent\",\"projectSkillsDirs\":[\".hermes/skills\"],\"userSkillsDirs\":[\"~/.hermes/skills\"],\"detect\":[\"~/.hermes\"],\"status\":\"community\"},{\"id\":\"iflow-cli\",\"displayName\":\"iFlow CLI\",\"projectSkillsDirs\":[\".iflow/skills\"],\"userSkillsDirs\":[\"~/.iflow/skills\"],\"detect\":[\"~/.iflow\"],\"status\":\"community\"},{\"id\":\"inference-sh\",\"displayName\":\"inference.sh\",\"projectSkillsDirs\":[\".inferencesh/skills\"],\"userSkillsDirs\":[\"~/.inferencesh/skills\"],\"detect\":[\"~/.inferencesh\"],\"status\":\"community\"},{\"id\":\"jazz\",\"displayName\":\"Jazz\",\"projectSkillsDirs\":[\".jazz/skills\"],\"userSkillsDirs\":[\"~/.jazz/skills\"],\"detect\":[\"~/.jazz\",\".jazz\"],\"status\":\"community\"},{\"id\":\"junie\",\"displayName\":\"Junie\",\"projectSkillsDirs\":[\".junie/skills\"],\"userSkillsDirs\":[\"~/.junie/skills\"],\"detect\":[\"~/.junie\"],\"status\":\"community\"},{\"id\":\"kilo\",\"displayName\":\"Kilo Code\",\"projectSkillsDirs\":[\".kilocode/skills\"],\"userSkillsDirs\":[\"~/.kilocode/skills\"],\"detect\":[\"~/.kilocode\"],\"status\":\"community\"},{\"id\":\"kimi-cli\",\"displayName\":\"Kimi Code CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.config/agents\",\"~/.kimi-code\",\"~/.kimi\",\"~/.agents\"],\"status\":\"community\",\"notes\":[\"kimi-code-cli is an alias for the same Kimi Code CLI path family.\"],\"aliases\":[\"kimi-code-cli\"]},{\"id\":\"kiro-cli\",\"displayName\":\"Kiro CLI\",\"projectSkillsDirs\":[\".kiro/skills\"],\"userSkillsDirs\":[\"~/.kiro/skills\"],\"detect\":[\"~/.kiro\"],\"status\":\"community\"},{\"id\":\"kode\",\"displayName\":\"Kode\",\"projectSkillsDirs\":[\".kode/skills\"],\"userSkillsDirs\":[\"~/.kode/skills\"],\"detect\":[\"~/.kode\"],\"status\":\"community\"},{\"id\":\"lingma\",\"displayName\":\"Lingma\",\"projectSkillsDirs\":[\".lingma/skills\"],\"userSkillsDirs\":[\"~/.lingma/skills\"],\"detect\":[\"~/.lingma\"],\"status\":\"community\"},{\"id\":\"loaf\",\"displayName\":\"Loaf\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.loaf\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"mcpjam\",\"displayName\":\"MCPJam\",\"projectSkillsDirs\":[\".mcpjam/skills\"],\"userSkillsDirs\":[\"~/.mcpjam/skills\"],\"detect\":[\"~/.mcpjam\"],\"status\":\"community\"},{\"id\":\"mistral-vibe\",\"displayName\":\"Mistral Vibe\",\"projectSkillsDirs\":[\".vibe/skills\"],\"userSkillsDirs\":[\"~/.vibe/skills\"],\"detect\":[\"~/.vibe\"],\"status\":\"community\"},{\"id\":\"moxby\",\"displayName\":\"Moxby\",\"projectSkillsDirs\":[\".moxby/skills\"],\"userSkillsDirs\":[\"~/.moxby/skills\"],\"detect\":[\"~/.moxby\"],\"status\":\"community\"},{\"id\":\"mux\",\"displayName\":\"Mux\",\"projectSkillsDirs\":[\".mux/skills\"],\"userSkillsDirs\":[\"~/.mux/skills\"],\"detect\":[\"~/.mux\"],\"status\":\"community\"},{\"id\":\"neovate\",\"displayName\":\"Neovate\",\"projectSkillsDirs\":[\".neovate/skills\"],\"userSkillsDirs\":[\"~/.neovate/skills\"],\"detect\":[\"~/.neovate\"],\"status\":\"community\"},{\"id\":\"ona\",\"displayName\":\"Ona\",\"projectSkillsDirs\":[\".ona/skills\"],\"userSkillsDirs\":[\"~/.ona/skills\"],\"detect\":[\"~/.ona\"],\"status\":\"community\"},{\"id\":\"openclaw\",\"displayName\":\"OpenClaw\",\"projectSkillsDirs\":[\"skills\"],\"userSkillsDirs\":[\"~/.openclaw/skills\"],\"detect\":[\"~/.openclaw\",\"~/.clawdbot\",\"~/.moltbot\"],\"status\":\"community\"},{\"id\":\"opencode\",\"displayName\":\"OpenCode\",\"projectSkillsDirs\":[\".agents/skills\",\".opencode/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.config/opencode/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.config/opencode\",\"~/.agents\",\"~/.claude\"],\"status\":\"verified\"},{\"id\":\"openhands\",\"displayName\":\"OpenHands\",\"projectSkillsDirs\":[\".openhands/skills\"],\"userSkillsDirs\":[\"~/.openhands/skills\"],\"detect\":[\"~/.openhands\"],\"status\":\"community\"},{\"id\":\"pi\",\"displayName\":\"Pi\",\"projectSkillsDirs\":[\".pi/skills\"],\"userSkillsDirs\":[\"~/.pi/agent/skills\"],\"detect\":[\"~/.pi/agent\"],\"status\":\"community\"},{\"id\":\"pochi\",\"displayName\":\"Pochi\",\"projectSkillsDirs\":[\".pochi/skills\"],\"userSkillsDirs\":[\"~/.pochi/skills\"],\"detect\":[\"~/.pochi\"],\"status\":\"community\"},{\"id\":\"promptscript\",\"displayName\":\"PromptScript\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[],\"detect\":[\".promptscript\",\"promptscript.yaml\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\"]},{\"id\":\"qoder\",\"displayName\":\"Qoder\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder/skills\"],\"detect\":[\"~/.qoder\"],\"status\":\"community\"},{\"id\":\"qoder-cn\",\"displayName\":\"Qoder CN\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder-cn/skills\"],\"detect\":[\"~/.qoder-cn\"],\"status\":\"community\"},{\"id\":\"qwen-code\",\"displayName\":\"Qwen Code\",\"projectSkillsDirs\":[\".qwen/skills\"],\"userSkillsDirs\":[\"~/.qwen/skills\"],\"detect\":[\"~/.qwen\"],\"status\":\"community\"},{\"id\":\"reasonix\",\"displayName\":\"Reasonix\",\"projectSkillsDirs\":[\".reasonix/skills\"],\"userSkillsDirs\":[\"~/.reasonix/skills\"],\"detect\":[\"~/.reasonix\"],\"status\":\"community\"},{\"id\":\"replit\",\"displayName\":\"Replit\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\".replit\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"roo\",\"displayName\":\"Roo Code\",\"aliases\":[\"roo-code\"],\"projectSkillsDirs\":[\".roo/skills\",\".agents/skills\"],\"userSkillsDirs\":[\"~/.roo/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.roo\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"rovodev\",\"displayName\":\"Rovo Dev\",\"projectSkillsDirs\":[\".rovodev/skills\"],\"userSkillsDirs\":[\"~/.rovodev/skills\"],\"detect\":[\"~/.rovodev\"],\"status\":\"community\"},{\"id\":\"tabnine-cli\",\"displayName\":\"Tabnine CLI\",\"projectSkillsDirs\":[\".tabnine/agent/skills\"],\"userSkillsDirs\":[\"~/.tabnine/agent/skills\"],\"detect\":[\"~/.tabnine\",\"~/.tabnine/agent\"],\"status\":\"community\"},{\"id\":\"terramind\",\"displayName\":\"Terramind\",\"projectSkillsDirs\":[\".terramind/skills\"],\"userSkillsDirs\":[\"~/.terramind/skills\"],\"detect\":[\"~/.terramind\"],\"status\":\"community\"},{\"id\":\"tinycloud\",\"displayName\":\"Tinycloud\",\"projectSkillsDirs\":[\".tinycloud/skills\"],\"userSkillsDirs\":[\"~/.tinycloud/skills\"],\"detect\":[\"~/.tinycloud\"],\"status\":\"community\"},{\"id\":\"trae\",\"displayName\":\"Trae\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae/skills\"],\"detect\":[\"~/.trae\"],\"status\":\"community\"},{\"id\":\"trae-cn\",\"displayName\":\"Trae CN\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae-cn/skills\"],\"detect\":[\"~/.trae-cn\"],\"status\":\"community\"},{\"id\":\"universal\",\"displayName\":\"Universal\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.config/agents/skills\"],\"detect\":[\"~/.agents\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"warp\",\"displayName\":\"Warp\",\"projectSkillsDirs\":[\".agents/skills\",\".warp/skills\",\".claude/skills\",\".codex/skills\",\".cursor/skills\",\".gemini/skills\",\".copilot/skills\",\".factory/skills\",\".github/skills\",\".opencode/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.warp/skills\",\"~/.claude/skills\",\"~/.codex/skills\",\"~/.cursor/skills\",\"~/.gemini/skills\",\"~/.copilot/skills\",\"~/.factory/skills\",\"~/.github/skills\",\"~/.opencode/skills\"],\"detect\":[\"~/.warp\",\"~/.agents\",\"~/.claude\",\"~/.codex\",\"~/.cursor\",\"~/.gemini\",\"~/.copilot\",\"~/.factory\",\"~/.github\",\"~/.opencode\"],\"status\":\"documented\"},{\"id\":\"windsurf\",\"displayName\":\"Windsurf\",\"projectSkillsDirs\":[\".windsurf/skills\"],\"userSkillsDirs\":[\"~/.codeium/windsurf/skills\"],\"detect\":[\"~/.codeium/windsurf\"],\"status\":\"community\"},{\"id\":\"zed\",\"displayName\":\"Zed\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.config/zed\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"zencoder\",\"displayName\":\"Zencoder\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"},{\"id\":\"zenflow\",\"displayName\":\"Zenflow\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"}]}" +DEFAULT_HOSTS_SPEC_JSON = "{\"$schema\":\"./hosts.schema.json\",\"schemaVersion\":1,\"hosts\":[{\"id\":\"adal\",\"displayName\":\"AdaL\",\"projectSkillsDirs\":[\".adal/skills\"],\"userSkillsDirs\":[\"~/.adal/skills\"],\"detect\":[\"~/.adal\"],\"status\":\"community\"},{\"id\":\"aider-desk\",\"displayName\":\"AiderDesk\",\"projectSkillsDirs\":[\".aider-desk/skills\"],\"userSkillsDirs\":[\"~/.aider-desk/skills\"],\"detect\":[\"~/.aider-desk\"],\"status\":\"community\"},{\"id\":\"amp\",\"displayName\":\"Amp\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\"~/.config/amp\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"antigravity\",\"displayName\":\"Antigravity\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity/skills\"],\"detect\":[\"~/.gemini/antigravity\"],\"status\":\"community\"},{\"id\":\"antigravity-cli\",\"displayName\":\"Antigravity CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity-cli/skills\"],\"detect\":[\"~/.gemini/antigravity-cli\"],\"status\":\"community\"},{\"id\":\"astrbot\",\"displayName\":\"AstrBot\",\"projectSkillsDirs\":[\"data/skills\"],\"userSkillsDirs\":[\"~/.astrbot/data/skills\"],\"detect\":[\"~/.astrbot\",\"~/.astrbot/data\"],\"status\":\"community\"},{\"id\":\"augment\",\"displayName\":\"Augment\",\"projectSkillsDirs\":[\".augment/skills\"],\"userSkillsDirs\":[\"~/.augment/skills\"],\"detect\":[\"~/.augment\"],\"status\":\"community\"},{\"id\":\"autohand-code\",\"displayName\":\"Autohand Code CLI\",\"projectSkillsDirs\":[\".autohand/skills\"],\"userSkillsDirs\":[\"~/.autohand/skills\"],\"detect\":[\"~/.autohand\"],\"status\":\"community\"},{\"id\":\"bob\",\"displayName\":\"IBM Bob\",\"projectSkillsDirs\":[\".bob/skills\"],\"userSkillsDirs\":[\"~/.bob/skills\"],\"detect\":[\"~/.bob\"],\"status\":\"community\"},{\"id\":\"claude-code\",\"displayName\":\"Claude Code\",\"projectSkillsDirs\":[\".claude/skills\"],\"userSkillsDirs\":[\"~/.claude/skills\"],\"detect\":[\"~/.claude\"],\"status\":\"verified\"},{\"id\":\"cline\",\"displayName\":\"Cline\",\"projectSkillsDirs\":[\".agents/skills\",\".cline/skills\",\".clinerules/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.cline/skills\"],\"detect\":[\"~/.cline\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"codearts-agent\",\"displayName\":\"CodeArts Agent\",\"projectSkillsDirs\":[\".codeartsdoer/skills\"],\"userSkillsDirs\":[\"~/.codeartsdoer/skills\"],\"detect\":[\"~/.codeartsdoer\"],\"status\":\"community\"},{\"id\":\"codebuddy\",\"displayName\":\"CodeBuddy\",\"projectSkillsDirs\":[\".codebuddy/skills\"],\"userSkillsDirs\":[\"~/.codebuddy/skills\"],\"detect\":[\"~/.codebuddy\",\".codebuddy\"],\"status\":\"community\"},{\"id\":\"codemaker\",\"displayName\":\"Codemaker\",\"projectSkillsDirs\":[\".codemaker/skills\"],\"userSkillsDirs\":[\"~/.codemaker/skills\"],\"detect\":[\"~/.codemaker\"],\"status\":\"community\"},{\"id\":\"codestudio\",\"displayName\":\"Code Studio\",\"projectSkillsDirs\":[\".codestudio/skills\"],\"userSkillsDirs\":[\"~/.codestudio/skills\"],\"detect\":[\"~/.codestudio\"],\"status\":\"community\"},{\"id\":\"codex\",\"displayName\":\"Codex\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.codex/skills\"],\"detect\":[\"~/.codex\",\"~/.agents/skills\",\"~/.agents\"],\"status\":\"verified\",\"notes\":[\"Keep both ~/.agents/skills and ~/.codex/skills for compatibility.\"]},{\"id\":\"command-code\",\"displayName\":\"Command Code\",\"projectSkillsDirs\":[\".commandcode/skills\"],\"userSkillsDirs\":[\"~/.commandcode/skills\"],\"detect\":[\"~/.commandcode\"],\"status\":\"community\"},{\"id\":\"continue\",\"displayName\":\"Continue\",\"projectSkillsDirs\":[\".continue/skills\"],\"userSkillsDirs\":[\"~/.continue/skills\"],\"detect\":[\"~/.continue\",\".continue\"],\"status\":\"community\"},{\"id\":\"cortex\",\"displayName\":\"Cortex Code\",\"projectSkillsDirs\":[\".cortex/skills\"],\"userSkillsDirs\":[\"~/.snowflake/cortex/skills\"],\"detect\":[\"~/.snowflake/cortex\"],\"status\":\"community\"},{\"id\":\"crush\",\"displayName\":\"Crush\",\"projectSkillsDirs\":[\".crush/skills\"],\"userSkillsDirs\":[\"~/.config/crush/skills\"],\"detect\":[\"~/.config/crush\"],\"status\":\"community\"},{\"id\":\"cursor\",\"displayName\":\"Cursor\",\"projectSkillsDirs\":[\".agents/skills\",\".cursor/skills\"],\"userSkillsDirs\":[\"~/.cursor/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.cursor\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"deepagents\",\"displayName\":\"Deep Agents\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.deepagents/agent/skills\"],\"detect\":[\"~/.deepagents\",\"~/.deepagents/agent\"],\"status\":\"community\"},{\"id\":\"devin\",\"displayName\":\"Devin for Terminal\",\"projectSkillsDirs\":[\".devin/skills\"],\"userSkillsDirs\":[\"~/.config/devin/skills\"],\"detect\":[\"~/.config/devin\"],\"status\":\"community\"},{\"id\":\"dexto\",\"displayName\":\"Dexto\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.dexto\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"droid\",\"displayName\":\"Droid\",\"projectSkillsDirs\":[\".factory/skills\"],\"userSkillsDirs\":[\"~/.factory/skills\"],\"detect\":[\"~/.factory\"],\"status\":\"community\"},{\"id\":\"eve\",\"displayName\":\"Eve\",\"projectSkillsDirs\":[\"agent/skills\"],\"userSkillsDirs\":[],\"detect\":[\"package.json\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\",\"Explicit selection only; generic project paths are not safe detection evidence.\"]},{\"id\":\"firebender\",\"displayName\":\"Firebender\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.firebender/skills\"],\"detect\":[\"~/.firebender\"],\"status\":\"community\"},{\"id\":\"forgecode\",\"displayName\":\"ForgeCode\",\"projectSkillsDirs\":[\".forge/skills\"],\"userSkillsDirs\":[\"~/.forge/skills\"],\"detect\":[\"~/.forge\"],\"status\":\"community\"},{\"id\":\"gemini-cli\",\"displayName\":\"Gemini CLI\",\"projectSkillsDirs\":[\".agents/skills\",\".gemini/skills\"],\"userSkillsDirs\":[\"~/.gemini/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.gemini\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"github-copilot\",\"displayName\":\"GitHub Copilot\",\"projectSkillsDirs\":[\".agents/skills\",\".github/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.copilot/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.copilot\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"goose\",\"displayName\":\"Goose\",\"projectSkillsDirs\":[\".goose/skills\"],\"userSkillsDirs\":[\"~/.config/goose/skills\"],\"detect\":[\"~/.config/goose\"],\"status\":\"community\"},{\"id\":\"hermes-agent\",\"displayName\":\"Hermes Agent\",\"projectSkillsDirs\":[\".hermes/skills\"],\"userSkillsDirs\":[\"~/.hermes/skills\"],\"detect\":[\"~/.hermes\"],\"status\":\"community\"},{\"id\":\"iflow-cli\",\"displayName\":\"iFlow CLI\",\"projectSkillsDirs\":[\".iflow/skills\"],\"userSkillsDirs\":[\"~/.iflow/skills\"],\"detect\":[\"~/.iflow\"],\"status\":\"community\"},{\"id\":\"inference-sh\",\"displayName\":\"inference.sh\",\"projectSkillsDirs\":[\".inferencesh/skills\"],\"userSkillsDirs\":[\"~/.inferencesh/skills\"],\"detect\":[\"~/.inferencesh\"],\"status\":\"community\"},{\"id\":\"jazz\",\"displayName\":\"Jazz\",\"projectSkillsDirs\":[\".jazz/skills\"],\"userSkillsDirs\":[\"~/.jazz/skills\"],\"detect\":[\"~/.jazz\",\".jazz\"],\"status\":\"community\"},{\"id\":\"junie\",\"displayName\":\"Junie\",\"projectSkillsDirs\":[\".junie/skills\"],\"userSkillsDirs\":[\"~/.junie/skills\"],\"detect\":[\"~/.junie\"],\"status\":\"community\"},{\"id\":\"kilo\",\"displayName\":\"Kilo Code\",\"projectSkillsDirs\":[\".kilocode/skills\"],\"userSkillsDirs\":[\"~/.kilocode/skills\"],\"detect\":[\"~/.kilocode\"],\"status\":\"community\"},{\"id\":\"kimi-cli\",\"displayName\":\"Kimi Code CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.config/agents\",\"~/.kimi-code\",\"~/.kimi\",\"~/.agents\"],\"status\":\"community\",\"notes\":[\"kimi-code-cli is an alias for the same Kimi Code CLI path family.\"],\"aliases\":[\"kimi-code-cli\"]},{\"id\":\"kiro-cli\",\"displayName\":\"Kiro CLI\",\"projectSkillsDirs\":[\".kiro/skills\"],\"userSkillsDirs\":[\"~/.kiro/skills\"],\"detect\":[\"~/.kiro\"],\"status\":\"community\"},{\"id\":\"kode\",\"displayName\":\"Kode\",\"projectSkillsDirs\":[\".kode/skills\"],\"userSkillsDirs\":[\"~/.kode/skills\"],\"detect\":[\"~/.kode\"],\"status\":\"community\"},{\"id\":\"lingma\",\"displayName\":\"Lingma\",\"projectSkillsDirs\":[\".lingma/skills\"],\"userSkillsDirs\":[\"~/.lingma/skills\"],\"detect\":[\"~/.lingma\"],\"status\":\"community\"},{\"id\":\"loaf\",\"displayName\":\"Loaf\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.loaf\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"mcpjam\",\"displayName\":\"MCPJam\",\"projectSkillsDirs\":[\".mcpjam/skills\"],\"userSkillsDirs\":[\"~/.mcpjam/skills\"],\"detect\":[\"~/.mcpjam\"],\"status\":\"community\"},{\"id\":\"mistral-vibe\",\"displayName\":\"Mistral Vibe\",\"projectSkillsDirs\":[\".vibe/skills\"],\"userSkillsDirs\":[\"~/.vibe/skills\"],\"detect\":[\"~/.vibe\"],\"status\":\"community\"},{\"id\":\"moxby\",\"displayName\":\"Moxby\",\"projectSkillsDirs\":[\".moxby/skills\"],\"userSkillsDirs\":[\"~/.moxby/skills\"],\"detect\":[\"~/.moxby\"],\"status\":\"community\"},{\"id\":\"mux\",\"displayName\":\"Mux\",\"projectSkillsDirs\":[\".mux/skills\"],\"userSkillsDirs\":[\"~/.mux/skills\"],\"detect\":[\"~/.mux\"],\"status\":\"community\"},{\"id\":\"neovate\",\"displayName\":\"Neovate\",\"projectSkillsDirs\":[\".neovate/skills\"],\"userSkillsDirs\":[\"~/.neovate/skills\"],\"detect\":[\"~/.neovate\"],\"status\":\"community\"},{\"id\":\"ona\",\"displayName\":\"Ona\",\"projectSkillsDirs\":[\".ona/skills\"],\"userSkillsDirs\":[\"~/.ona/skills\"],\"detect\":[\"~/.ona\"],\"status\":\"community\"},{\"id\":\"openclaw\",\"displayName\":\"OpenClaw\",\"projectSkillsDirs\":[\"skills\"],\"userSkillsDirs\":[\"~/.openclaw/skills\"],\"detect\":[\"~/.openclaw\",\"~/.clawdbot\",\"~/.moltbot\"],\"status\":\"community\"},{\"id\":\"opencode\",\"displayName\":\"OpenCode\",\"projectSkillsDirs\":[\".agents/skills\",\".opencode/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.config/opencode/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.config/opencode\",\"~/.agents\"],\"status\":\"verified\"},{\"id\":\"openhands\",\"displayName\":\"OpenHands\",\"projectSkillsDirs\":[\".openhands/skills\"],\"userSkillsDirs\":[\"~/.openhands/skills\"],\"detect\":[\"~/.openhands\"],\"status\":\"community\"},{\"id\":\"pi\",\"displayName\":\"Pi\",\"projectSkillsDirs\":[\".pi/skills\"],\"userSkillsDirs\":[\"~/.pi/agent/skills\"],\"detect\":[\"~/.pi/agent\"],\"status\":\"community\"},{\"id\":\"pochi\",\"displayName\":\"Pochi\",\"projectSkillsDirs\":[\".pochi/skills\"],\"userSkillsDirs\":[\"~/.pochi/skills\"],\"detect\":[\"~/.pochi\"],\"status\":\"community\"},{\"id\":\"promptscript\",\"displayName\":\"PromptScript\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[],\"detect\":[\".promptscript\",\"promptscript.yaml\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\"]},{\"id\":\"qoder\",\"displayName\":\"Qoder\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder/skills\"],\"detect\":[\"~/.qoder\"],\"status\":\"community\"},{\"id\":\"qoder-cn\",\"displayName\":\"Qoder CN\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder-cn/skills\"],\"detect\":[\"~/.qoder-cn\"],\"status\":\"community\"},{\"id\":\"qwen-code\",\"displayName\":\"Qwen Code\",\"projectSkillsDirs\":[\".qwen/skills\"],\"userSkillsDirs\":[\"~/.qwen/skills\"],\"detect\":[\"~/.qwen\"],\"status\":\"community\"},{\"id\":\"reasonix\",\"displayName\":\"Reasonix\",\"projectSkillsDirs\":[\".reasonix/skills\"],\"userSkillsDirs\":[\"~/.reasonix/skills\"],\"detect\":[\"~/.reasonix\"],\"status\":\"community\"},{\"id\":\"replit\",\"displayName\":\"Replit\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\".replit\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"roo\",\"displayName\":\"Roo Code\",\"aliases\":[\"roo-code\"],\"projectSkillsDirs\":[\".roo/skills\",\".agents/skills\"],\"userSkillsDirs\":[\"~/.roo/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.roo\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"rovodev\",\"displayName\":\"Rovo Dev\",\"projectSkillsDirs\":[\".rovodev/skills\"],\"userSkillsDirs\":[\"~/.rovodev/skills\"],\"detect\":[\"~/.rovodev\"],\"status\":\"community\"},{\"id\":\"tabnine-cli\",\"displayName\":\"Tabnine CLI\",\"projectSkillsDirs\":[\".tabnine/agent/skills\"],\"userSkillsDirs\":[\"~/.tabnine/agent/skills\"],\"detect\":[\"~/.tabnine\",\"~/.tabnine/agent\"],\"status\":\"community\"},{\"id\":\"terramind\",\"displayName\":\"Terramind\",\"projectSkillsDirs\":[\".terramind/skills\"],\"userSkillsDirs\":[\"~/.terramind/skills\"],\"detect\":[\"~/.terramind\"],\"status\":\"community\"},{\"id\":\"tinycloud\",\"displayName\":\"Tinycloud\",\"projectSkillsDirs\":[\".tinycloud/skills\"],\"userSkillsDirs\":[\"~/.tinycloud/skills\"],\"detect\":[\"~/.tinycloud\"],\"status\":\"community\"},{\"id\":\"trae\",\"displayName\":\"Trae\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae/skills\"],\"detect\":[\"~/.trae\"],\"status\":\"community\"},{\"id\":\"trae-cn\",\"displayName\":\"Trae CN\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae-cn/skills\"],\"detect\":[\"~/.trae-cn\"],\"status\":\"community\"},{\"id\":\"universal\",\"displayName\":\"Universal\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.config/agents/skills\"],\"detect\":[\"~/.agents\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"warp\",\"displayName\":\"Warp\",\"projectSkillsDirs\":[\".agents/skills\",\".warp/skills\",\".claude/skills\",\".codex/skills\",\".cursor/skills\",\".gemini/skills\",\".copilot/skills\",\".factory/skills\",\".github/skills\",\".opencode/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.warp/skills\",\"~/.claude/skills\",\"~/.codex/skills\",\"~/.cursor/skills\",\"~/.gemini/skills\",\"~/.copilot/skills\",\"~/.factory/skills\",\"~/.github/skills\",\"~/.opencode/skills\"],\"detect\":[\"~/.warp\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"windsurf\",\"displayName\":\"Windsurf\",\"projectSkillsDirs\":[\".windsurf/skills\"],\"userSkillsDirs\":[\"~/.codeium/windsurf/skills\"],\"detect\":[\"~/.codeium/windsurf\"],\"status\":\"community\"},{\"id\":\"zed\",\"displayName\":\"Zed\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.config/zed\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"zencoder\",\"displayName\":\"Zencoder\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"},{\"id\":\"zenflow\",\"displayName\":\"Zenflow\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"}]}" diff --git a/python/src/kitup/_metadata.py b/python/src/kitup/_metadata.py index d9e6436..31dfbb2 100644 --- a/python/src/kitup/_metadata.py +++ b/python/src/kitup/_metadata.py @@ -56,7 +56,8 @@ def read_install_metadata(target_dir: Path) -> dict[str, object] | None: def is_owned_metadata(payload: dict[str, object]) -> bool: - if payload.get("schemaVersion") != 1: + schema_version = payload.get("schemaVersion") + if type(schema_version) is not int or schema_version != 1: return False app_id = payload.get("appId") skill_name = payload.get("skillName") diff --git a/python/src/kitup/hosts.py b/python/src/kitup/hosts.py index 046b697..329c0a4 100644 --- a/python/src/kitup/hosts.py +++ b/python/src/kitup/hosts.py @@ -4,7 +4,14 @@ from ._hosts_generated import DEFAULT_HOSTS_SPEC_JSON from .types import BaseOptions, Host, HostSpec, KitupError, Scope -_GENERIC_DETECT_PATHS = {"~/.agents", "~/.agents/skills", "~/.config/agents"} +_GENERIC_DETECT_PATHS = { + "~/.agents", + "~/.agents/skills", + "~/.config/agents", + ".agents", + ".agents/skills", + "package.json", +} def load_host_spec(hosts_file: str | None = None) -> HostSpec: @@ -33,6 +40,7 @@ def load_host_spec(hosts_file: str | None = None) -> HostSpec: def _validate_host_spec(hosts: list[Host]) -> None: for host in hosts: + install_dirs = set(host.project_skills_dirs + host.user_skills_dirs) for path in host.project_skills_dirs: if not _is_project_host_path(path): raise KitupError(f"invalid project path {path!r} for host {host.id}") @@ -42,6 +50,10 @@ def _validate_host_spec(hosts: list[Host]) -> None: for path in host.detect: if not _is_home_host_path(path) and not _is_project_host_path(path): raise KitupError(f"invalid detect path {path!r} for host {host.id}") + if path not in _GENERIC_DETECT_PATHS and path in install_dirs: + raise KitupError( + f"detect path is an install target for host {host.id}: {path!r}" + ) def _is_project_host_path(path: str) -> bool: @@ -59,7 +71,7 @@ def _is_home_host_path(path: str) -> bool: def _is_safe_host_path(path: str) -> bool: return not any(character in path for character in "\0\\:") and not any( - segment in ("..", "") for segment in path.split("/") + segment in (".", "..", "") for segment in path.split("/") ) @@ -99,7 +111,7 @@ def detect_hosts(options: BaseOptions, scope: Scope | None = None) -> list[Host] cwd = Path(options.cwd) if options.cwd else Path.cwd() detected: list[Host] = [] for host in spec.hosts: - if _primary_detect_path_exists(host, home=home, cwd=cwd): + if _detect_path_exists(host, home=home, cwd=cwd): detected.append(host) if scope is not None: @@ -121,13 +133,12 @@ def _canonical_scope_path( return _expand_host_path(paths[0], home=home, cwd=cwd) -def _primary_detect_path_exists(host: Host, *, home: Path, cwd: Path) -> bool: - if not host.detect: - return False - path = host.detect[0] - if path in _GENERIC_DETECT_PATHS: - return False - return _expand_host_path(path, home=home, cwd=cwd).exists() +def _detect_path_exists(host: Host, *, home: Path, cwd: Path) -> bool: + return any( + _expand_host_path(path, home=home, cwd=cwd).exists() + for path in host.detect + if path not in _GENERIC_DETECT_PATHS + ) def _expand_host_path(path: str, *, home: Path, cwd: Path) -> Path: diff --git a/python/src/kitup/install.py b/python/src/kitup/install.py index 6267181..3c754e4 100644 --- a/python/src/kitup/install.py +++ b/python/src/kitup/install.py @@ -45,18 +45,62 @@ def expand_host_path(path: str, *, home: Path, cwd: Path) -> Path: def choose_scope_path( - host: Host, *, scope: Scope, home: Path, cwd: Path + host: Host, + *, + scope: Scope, + home: Path, + cwd: Path, + skill_name: str, ) -> Path | None: paths = host.user_skills_dirs if scope == "user" else host.project_skills_dirs - for path in paths: - expanded = expand_host_path(path, home=home, cwd=cwd) - if expanded.exists(): - return expanded + existing = [ + expanded + for path in paths + if (expanded := expand_host_path(path, home=home, cwd=cwd)).is_dir() + ] + for root in existing: + metadata = read_install_metadata(root / skill_name) + if metadata and metadata.get("skillName") == skill_name: + return root + if existing: + return existing[0] if not paths: return None return expand_host_path(paths[0], home=home, cwd=cwd) +def _uninstall_scope_paths( + host: Host, + *, + scope: Scope, + home: Path, + cwd: Path, + skill_name: str, + app_id: str, +) -> list[Path]: + paths = host.user_skills_dirs if scope == "user" else host.project_skills_dirs + owned = [] + for path in paths: + root = expand_host_path(path, home=home, cwd=cwd) + metadata = read_install_metadata(root / skill_name) + if ( + metadata + and metadata.get("skillName") == skill_name + and metadata.get("appId") == app_id + ): + owned.append(root) + if owned: + return owned + fallback = choose_scope_path( + host, + scope=scope, + home=home, + cwd=cwd, + skill_name=skill_name, + ) + return [fallback] if fallback is not None else [] + + def resolve_install_targets( options: BaseOptions, agents: str | list[str] | None, @@ -319,6 +363,7 @@ def uninstall_bundled_skill(options: UninstallOptions) -> UninstallReport: options.agents, options.scope, options.skill_name, + uninstall_app_id=options.app_id, ) report = empty_uninstall_report(errors) for target in targets: @@ -362,6 +407,8 @@ def _resolve_install_targets_with_errors( agents: str | list[str] | None, scope: Scope, skill_name: str, + *, + uninstall_app_id: str | None = None, ) -> tuple[list[TargetGroup], list[TargetError]]: if not is_valid_skill_name(skill_name): return [], [TargetError(reason="invalid-skill-name", skill_name=skill_name)] @@ -381,8 +428,25 @@ def _resolve_install_targets_with_errors( by_target: dict[str, TargetGroup] = {} for host in selected: - root = choose_scope_path(host, scope=scope, home=home, cwd=cwd) - if root is None: + if uninstall_app_id is not None: + roots = _uninstall_scope_paths( + host, + scope=scope, + home=home, + cwd=cwd, + skill_name=skill_name, + app_id=uninstall_app_id, + ) + else: + root = choose_scope_path( + host, + scope=scope, + home=home, + cwd=cwd, + skill_name=skill_name, + ) + roots = [root] if root is not None else [] + if not roots: errors.append( TargetError( reason="unsupported-scope", @@ -392,12 +456,14 @@ def _resolve_install_targets_with_errors( ) ) continue - target_dir = str(root / skill_name) - group = by_target.get(target_dir) - if group is None: - group = TargetGroup(skill_name=skill_name, target_dir=target_dir) - by_target[target_dir] = group - group.host_ids.append(host.id) + for root in roots: + target_dir = str(root / skill_name) + group = by_target.get(target_dir) + if group is None: + group = TargetGroup(skill_name=skill_name, target_dir=target_dir) + by_target[target_dir] = group + if host.id not in group.host_ids: + group.host_ids.append(host.id) return [by_target[path] for path in sorted(by_target)], errors diff --git a/python/tests/golden_test.py b/python/tests/golden_test.py index 13460ef..a72b85f 100644 --- a/python/tests/golden_test.py +++ b/python/tests/golden_test.py @@ -29,6 +29,7 @@ plan_bundled_skill, resolve_hosts, resolve_install_selection, + resolve_install_targets, run_bundled_skill_install_with_io, uninstall_bundled_skill, update_bundled_skill, @@ -98,6 +99,28 @@ def run_case(case, home: Path, workspace: Path) -> None: ) return + if operation == "resolve-install-targets": + targets = resolve_install_targets( + BaseOptions( + home=str(home), + cwd=str(workspace), + hosts_file=case_hosts_file(case, home, workspace), + ), + case["options"]["agents"], + case["options"]["scope"], + case["options"]["skillName"], + ) + actual = [ + { + "hostIds": target.host_ids, + "skillName": target.skill_name, + "targetDir": target.target_dir, + } + for target in targets + ] + assert actual == expand_value(case["expected"]["targets"], home, workspace) + return + if operation == "run-install-workflow": input_stream = io.StringIO(case["options"].get("input", "")) output_stream = io.StringIO() diff --git a/python/tests/test_hosts.py b/python/tests/test_hosts.py index 3323ec4..7b29041 100644 --- a/python/tests/test_hosts.py +++ b/python/tests/test_hosts.py @@ -85,7 +85,7 @@ def test_detect_hosts_skips_generic_detect_paths_and_sorts_by_scope_path(tmp_pat assert [host.id for host in hosts] == ["codex", "claude-code"] -def test_detect_hosts_does_not_scan_past_primary_generic_detect_path(tmp_path): +def test_detect_hosts_scans_all_non_generic_detect_paths(tmp_path): home = tmp_path / "home" workspace = tmp_path / "workspace" home.mkdir() @@ -100,4 +100,26 @@ def test_detect_hosts_does_not_scan_past_primary_generic_detect_path(tmp_path): scope="user", ) - assert "kimi-cli" not in [host.id for host in hosts] + assert "kimi-cli" in [host.id for host in hosts] + + +def test_detect_hosts_never_counts_generic_detect_paths(tmp_path): + home = tmp_path / "home" + workspace = tmp_path / "workspace" + home.mkdir() + workspace.mkdir() + (home / ".agents").mkdir() + (home / ".agents" / "skills").mkdir() + (home / ".config").mkdir() + (home / ".config" / "agents").mkdir() + (workspace / "package.json").write_text("{}") + + hosts = detect_hosts( + BaseOptions( + home=str(home), + cwd=str(workspace), + ), + scope="user", + ) + + assert hosts == [] diff --git a/rust/src/hosts_generated.rs b/rust/src/hosts_generated.rs index a75dd27..2f2e580 100644 --- a/rust/src/hosts_generated.rs +++ b/rust/src/hosts_generated.rs @@ -1,3 +1,3 @@ // Code generated from spec/hosts.json. DO NOT EDIT. -pub(crate) const DEFAULT_HOSTS_SPEC_JSON: &str = r#"{"$schema":"./hosts.schema.json","schemaVersion":1,"hosts":[{"id":"adal","displayName":"AdaL","projectSkillsDirs":[".adal/skills"],"userSkillsDirs":["~/.adal/skills"],"detect":["~/.adal"],"status":"community"},{"id":"aider-desk","displayName":"AiderDesk","projectSkillsDirs":[".aider-desk/skills"],"userSkillsDirs":["~/.aider-desk/skills"],"detect":["~/.aider-desk"],"status":"community"},{"id":"amp","displayName":"Amp","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.config/agents/skills"],"detect":["~/.config/amp","~/.config/agents"],"status":"community"},{"id":"antigravity","displayName":"Antigravity","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.gemini/antigravity/skills"],"detect":["~/.gemini/antigravity"],"status":"community"},{"id":"antigravity-cli","displayName":"Antigravity CLI","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.gemini/antigravity-cli/skills"],"detect":["~/.gemini/antigravity-cli"],"status":"community"},{"id":"astrbot","displayName":"AstrBot","projectSkillsDirs":["data/skills"],"userSkillsDirs":["~/.astrbot/data/skills"],"detect":["~/.astrbot","data/skills","~/.astrbot/data"],"status":"community"},{"id":"augment","displayName":"Augment","projectSkillsDirs":[".augment/skills"],"userSkillsDirs":["~/.augment/skills"],"detect":["~/.augment"],"status":"community"},{"id":"autohand-code","displayName":"Autohand Code CLI","projectSkillsDirs":[".autohand/skills"],"userSkillsDirs":["~/.autohand/skills"],"detect":["~/.autohand"],"status":"community"},{"id":"bob","displayName":"IBM Bob","projectSkillsDirs":[".bob/skills"],"userSkillsDirs":["~/.bob/skills"],"detect":["~/.bob"],"status":"community"},{"id":"claude-code","displayName":"Claude Code","projectSkillsDirs":[".claude/skills"],"userSkillsDirs":["~/.claude/skills"],"detect":["~/.claude"],"status":"verified"},{"id":"cline","displayName":"Cline","projectSkillsDirs":[".agents/skills",".cline/skills",".clinerules/skills",".claude/skills"],"userSkillsDirs":["~/.agents/skills","~/.cline/skills"],"detect":["~/.cline","~/.agents"],"status":"documented"},{"id":"codearts-agent","displayName":"CodeArts Agent","projectSkillsDirs":[".codeartsdoer/skills"],"userSkillsDirs":["~/.codeartsdoer/skills"],"detect":["~/.codeartsdoer"],"status":"community"},{"id":"codebuddy","displayName":"CodeBuddy","projectSkillsDirs":[".codebuddy/skills"],"userSkillsDirs":["~/.codebuddy/skills"],"detect":["~/.codebuddy",".codebuddy"],"status":"community"},{"id":"codemaker","displayName":"Codemaker","projectSkillsDirs":[".codemaker/skills"],"userSkillsDirs":["~/.codemaker/skills"],"detect":["~/.codemaker"],"status":"community"},{"id":"codestudio","displayName":"Code Studio","projectSkillsDirs":[".codestudio/skills"],"userSkillsDirs":["~/.codestudio/skills"],"detect":["~/.codestudio"],"status":"community"},{"id":"codex","displayName":"Codex","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.agents/skills","~/.codex/skills"],"detect":["~/.codex","~/.agents/skills","~/.agents"],"status":"verified","notes":["Keep both ~/.agents/skills and ~/.codex/skills for compatibility."]},{"id":"command-code","displayName":"Command Code","projectSkillsDirs":[".commandcode/skills"],"userSkillsDirs":["~/.commandcode/skills"],"detect":["~/.commandcode"],"status":"community"},{"id":"continue","displayName":"Continue","projectSkillsDirs":[".continue/skills"],"userSkillsDirs":["~/.continue/skills"],"detect":["~/.continue",".continue"],"status":"community"},{"id":"cortex","displayName":"Cortex Code","projectSkillsDirs":[".cortex/skills"],"userSkillsDirs":["~/.snowflake/cortex/skills"],"detect":["~/.snowflake/cortex"],"status":"community"},{"id":"crush","displayName":"Crush","projectSkillsDirs":[".crush/skills"],"userSkillsDirs":["~/.config/crush/skills"],"detect":["~/.config/crush"],"status":"community"},{"id":"cursor","displayName":"Cursor","projectSkillsDirs":[".agents/skills",".cursor/skills"],"userSkillsDirs":["~/.cursor/skills","~/.agents/skills"],"detect":["~/.cursor","~/.agents"],"status":"documented"},{"id":"deepagents","displayName":"Deep Agents","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.deepagents/agent/skills"],"detect":["~/.deepagents","~/.deepagents/agent"],"status":"community"},{"id":"devin","displayName":"Devin for Terminal","projectSkillsDirs":[".devin/skills"],"userSkillsDirs":["~/.config/devin/skills"],"detect":["~/.config/devin"],"status":"community"},{"id":"dexto","displayName":"Dexto","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.agents/skills"],"detect":["~/.dexto","~/.agents"],"status":"community"},{"id":"droid","displayName":"Droid","projectSkillsDirs":[".factory/skills"],"userSkillsDirs":["~/.factory/skills"],"detect":["~/.factory"],"status":"community"},{"id":"eve","displayName":"Eve","projectSkillsDirs":["agent/skills"],"userSkillsDirs":[],"detect":["agent","package.json"],"status":"community","notes":["Project-only host; userSkillsDirs is intentionally empty.","Detect from Eve project shape; no global skill directory."]},{"id":"firebender","displayName":"Firebender","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.firebender/skills"],"detect":["~/.firebender"],"status":"community"},{"id":"forgecode","displayName":"ForgeCode","projectSkillsDirs":[".forge/skills"],"userSkillsDirs":["~/.forge/skills"],"detect":["~/.forge"],"status":"community"},{"id":"gemini-cli","displayName":"Gemini CLI","projectSkillsDirs":[".agents/skills",".gemini/skills"],"userSkillsDirs":["~/.gemini/skills","~/.agents/skills"],"detect":["~/.gemini","~/.agents"],"status":"documented"},{"id":"github-copilot","displayName":"GitHub Copilot","projectSkillsDirs":[".agents/skills",".github/skills",".claude/skills"],"userSkillsDirs":["~/.copilot/skills","~/.agents/skills","~/.claude/skills"],"detect":["~/.copilot","~/.agents","~/.claude"],"status":"documented"},{"id":"goose","displayName":"Goose","projectSkillsDirs":[".goose/skills"],"userSkillsDirs":["~/.config/goose/skills"],"detect":["~/.config/goose"],"status":"community"},{"id":"hermes-agent","displayName":"Hermes Agent","projectSkillsDirs":[".hermes/skills"],"userSkillsDirs":["~/.hermes/skills"],"detect":["~/.hermes"],"status":"community"},{"id":"iflow-cli","displayName":"iFlow CLI","projectSkillsDirs":[".iflow/skills"],"userSkillsDirs":["~/.iflow/skills"],"detect":["~/.iflow"],"status":"community"},{"id":"inference-sh","displayName":"inference.sh","projectSkillsDirs":[".inferencesh/skills"],"userSkillsDirs":["~/.inferencesh/skills"],"detect":["~/.inferencesh"],"status":"community"},{"id":"jazz","displayName":"Jazz","projectSkillsDirs":[".jazz/skills"],"userSkillsDirs":["~/.jazz/skills"],"detect":["~/.jazz",".jazz"],"status":"community"},{"id":"junie","displayName":"Junie","projectSkillsDirs":[".junie/skills"],"userSkillsDirs":["~/.junie/skills"],"detect":["~/.junie"],"status":"community"},{"id":"kilo","displayName":"Kilo Code","projectSkillsDirs":[".kilocode/skills"],"userSkillsDirs":["~/.kilocode/skills"],"detect":["~/.kilocode"],"status":"community"},{"id":"kimi-cli","displayName":"Kimi Code CLI","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.config/agents/skills","~/.agents/skills"],"detect":["~/.config/agents","~/.kimi-code","~/.kimi","~/.agents"],"status":"community","notes":["kimi-code-cli is an alias for the same Kimi Code CLI path family."],"aliases":["kimi-code-cli"]},{"id":"kiro-cli","displayName":"Kiro CLI","projectSkillsDirs":[".kiro/skills"],"userSkillsDirs":["~/.kiro/skills"],"detect":["~/.kiro"],"status":"community"},{"id":"kode","displayName":"Kode","projectSkillsDirs":[".kode/skills"],"userSkillsDirs":["~/.kode/skills"],"detect":["~/.kode"],"status":"community"},{"id":"lingma","displayName":"Lingma","projectSkillsDirs":[".lingma/skills"],"userSkillsDirs":["~/.lingma/skills"],"detect":["~/.lingma"],"status":"community"},{"id":"loaf","displayName":"Loaf","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.agents/skills"],"detect":["~/.loaf","~/.agents"],"status":"community"},{"id":"mcpjam","displayName":"MCPJam","projectSkillsDirs":[".mcpjam/skills"],"userSkillsDirs":["~/.mcpjam/skills"],"detect":["~/.mcpjam"],"status":"community"},{"id":"mistral-vibe","displayName":"Mistral Vibe","projectSkillsDirs":[".vibe/skills"],"userSkillsDirs":["~/.vibe/skills"],"detect":["~/.vibe"],"status":"community"},{"id":"moxby","displayName":"Moxby","projectSkillsDirs":[".moxby/skills"],"userSkillsDirs":["~/.moxby/skills"],"detect":["~/.moxby"],"status":"community"},{"id":"mux","displayName":"Mux","projectSkillsDirs":[".mux/skills"],"userSkillsDirs":["~/.mux/skills"],"detect":["~/.mux"],"status":"community"},{"id":"neovate","displayName":"Neovate","projectSkillsDirs":[".neovate/skills"],"userSkillsDirs":["~/.neovate/skills"],"detect":["~/.neovate"],"status":"community"},{"id":"ona","displayName":"Ona","projectSkillsDirs":[".ona/skills"],"userSkillsDirs":["~/.ona/skills"],"detect":["~/.ona"],"status":"community"},{"id":"openclaw","displayName":"OpenClaw","projectSkillsDirs":["skills"],"userSkillsDirs":["~/.openclaw/skills"],"detect":["~/.openclaw","~/.clawdbot","~/.moltbot"],"status":"community"},{"id":"opencode","displayName":"OpenCode","projectSkillsDirs":[".agents/skills",".opencode/skills",".claude/skills"],"userSkillsDirs":["~/.config/opencode/skills","~/.agents/skills","~/.claude/skills"],"detect":["~/.config/opencode","~/.agents","~/.claude"],"status":"verified"},{"id":"openhands","displayName":"OpenHands","projectSkillsDirs":[".openhands/skills"],"userSkillsDirs":["~/.openhands/skills"],"detect":["~/.openhands"],"status":"community"},{"id":"pi","displayName":"Pi","projectSkillsDirs":[".pi/skills"],"userSkillsDirs":["~/.pi/agent/skills"],"detect":["~/.pi/agent"],"status":"community"},{"id":"pochi","displayName":"Pochi","projectSkillsDirs":[".pochi/skills"],"userSkillsDirs":["~/.pochi/skills"],"detect":["~/.pochi"],"status":"community"},{"id":"promptscript","displayName":"PromptScript","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":[],"detect":[".promptscript","promptscript.yaml"],"status":"community","notes":["Project-only host; userSkillsDirs is intentionally empty."]},{"id":"qoder","displayName":"Qoder","projectSkillsDirs":[".qoder/skills"],"userSkillsDirs":["~/.qoder/skills"],"detect":["~/.qoder"],"status":"community"},{"id":"qoder-cn","displayName":"Qoder CN","projectSkillsDirs":[".qoder/skills"],"userSkillsDirs":["~/.qoder-cn/skills"],"detect":["~/.qoder-cn"],"status":"community"},{"id":"qwen-code","displayName":"Qwen Code","projectSkillsDirs":[".qwen/skills"],"userSkillsDirs":["~/.qwen/skills"],"detect":["~/.qwen"],"status":"community"},{"id":"reasonix","displayName":"Reasonix","projectSkillsDirs":[".reasonix/skills"],"userSkillsDirs":["~/.reasonix/skills"],"detect":["~/.reasonix"],"status":"community"},{"id":"replit","displayName":"Replit","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.config/agents/skills"],"detect":[".replit","~/.config/agents"],"status":"community"},{"id":"roo","displayName":"Roo Code","aliases":["roo-code"],"projectSkillsDirs":[".roo/skills",".agents/skills"],"userSkillsDirs":["~/.roo/skills","~/.agents/skills"],"detect":["~/.roo","~/.agents"],"status":"documented"},{"id":"rovodev","displayName":"Rovo Dev","projectSkillsDirs":[".rovodev/skills"],"userSkillsDirs":["~/.rovodev/skills"],"detect":["~/.rovodev"],"status":"community"},{"id":"tabnine-cli","displayName":"Tabnine CLI","projectSkillsDirs":[".tabnine/agent/skills"],"userSkillsDirs":["~/.tabnine/agent/skills"],"detect":["~/.tabnine","~/.tabnine/agent"],"status":"community"},{"id":"terramind","displayName":"Terramind","projectSkillsDirs":[".terramind/skills"],"userSkillsDirs":["~/.terramind/skills"],"detect":["~/.terramind"],"status":"community"},{"id":"tinycloud","displayName":"Tinycloud","projectSkillsDirs":[".tinycloud/skills"],"userSkillsDirs":["~/.tinycloud/skills"],"detect":["~/.tinycloud"],"status":"community"},{"id":"trae","displayName":"Trae","projectSkillsDirs":[".trae/skills"],"userSkillsDirs":["~/.trae/skills"],"detect":["~/.trae"],"status":"community"},{"id":"trae-cn","displayName":"Trae CN","projectSkillsDirs":[".trae/skills"],"userSkillsDirs":["~/.trae-cn/skills"],"detect":["~/.trae-cn"],"status":"community"},{"id":"universal","displayName":"Universal","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.agents/skills","~/.config/agents/skills"],"detect":["~/.agents","~/.config/agents"],"status":"community"},{"id":"warp","displayName":"Warp","projectSkillsDirs":[".agents/skills",".warp/skills",".claude/skills",".codex/skills",".cursor/skills",".gemini/skills",".copilot/skills",".factory/skills",".github/skills",".opencode/skills"],"userSkillsDirs":["~/.agents/skills","~/.warp/skills","~/.claude/skills","~/.codex/skills","~/.cursor/skills","~/.gemini/skills","~/.copilot/skills","~/.factory/skills","~/.github/skills","~/.opencode/skills"],"detect":["~/.warp","~/.agents","~/.claude","~/.codex","~/.cursor","~/.gemini","~/.copilot","~/.factory","~/.github","~/.opencode"],"status":"documented"},{"id":"windsurf","displayName":"Windsurf","projectSkillsDirs":[".windsurf/skills"],"userSkillsDirs":["~/.codeium/windsurf/skills"],"detect":["~/.codeium/windsurf"],"status":"community"},{"id":"zed","displayName":"Zed","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.agents/skills"],"detect":["~/.config/zed","~/.agents"],"status":"community"},{"id":"zencoder","displayName":"Zencoder","projectSkillsDirs":[".zencoder/skills"],"userSkillsDirs":["~/.zencoder/skills"],"detect":["~/.zencoder"],"status":"community"},{"id":"zenflow","displayName":"Zenflow","projectSkillsDirs":[".zencoder/skills"],"userSkillsDirs":["~/.zencoder/skills"],"detect":["~/.zencoder"],"status":"community"}]}"#; +pub(crate) const DEFAULT_HOSTS_SPEC_JSON: &str = r#"{"$schema":"./hosts.schema.json","schemaVersion":1,"hosts":[{"id":"adal","displayName":"AdaL","projectSkillsDirs":[".adal/skills"],"userSkillsDirs":["~/.adal/skills"],"detect":["~/.adal"],"status":"community"},{"id":"aider-desk","displayName":"AiderDesk","projectSkillsDirs":[".aider-desk/skills"],"userSkillsDirs":["~/.aider-desk/skills"],"detect":["~/.aider-desk"],"status":"community"},{"id":"amp","displayName":"Amp","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.config/agents/skills"],"detect":["~/.config/amp","~/.config/agents"],"status":"community"},{"id":"antigravity","displayName":"Antigravity","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.gemini/antigravity/skills"],"detect":["~/.gemini/antigravity"],"status":"community"},{"id":"antigravity-cli","displayName":"Antigravity CLI","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.gemini/antigravity-cli/skills"],"detect":["~/.gemini/antigravity-cli"],"status":"community"},{"id":"astrbot","displayName":"AstrBot","projectSkillsDirs":["data/skills"],"userSkillsDirs":["~/.astrbot/data/skills"],"detect":["~/.astrbot","~/.astrbot/data"],"status":"community"},{"id":"augment","displayName":"Augment","projectSkillsDirs":[".augment/skills"],"userSkillsDirs":["~/.augment/skills"],"detect":["~/.augment"],"status":"community"},{"id":"autohand-code","displayName":"Autohand Code CLI","projectSkillsDirs":[".autohand/skills"],"userSkillsDirs":["~/.autohand/skills"],"detect":["~/.autohand"],"status":"community"},{"id":"bob","displayName":"IBM Bob","projectSkillsDirs":[".bob/skills"],"userSkillsDirs":["~/.bob/skills"],"detect":["~/.bob"],"status":"community"},{"id":"claude-code","displayName":"Claude Code","projectSkillsDirs":[".claude/skills"],"userSkillsDirs":["~/.claude/skills"],"detect":["~/.claude"],"status":"verified"},{"id":"cline","displayName":"Cline","projectSkillsDirs":[".agents/skills",".cline/skills",".clinerules/skills",".claude/skills"],"userSkillsDirs":["~/.agents/skills","~/.cline/skills"],"detect":["~/.cline","~/.agents"],"status":"documented"},{"id":"codearts-agent","displayName":"CodeArts Agent","projectSkillsDirs":[".codeartsdoer/skills"],"userSkillsDirs":["~/.codeartsdoer/skills"],"detect":["~/.codeartsdoer"],"status":"community"},{"id":"codebuddy","displayName":"CodeBuddy","projectSkillsDirs":[".codebuddy/skills"],"userSkillsDirs":["~/.codebuddy/skills"],"detect":["~/.codebuddy",".codebuddy"],"status":"community"},{"id":"codemaker","displayName":"Codemaker","projectSkillsDirs":[".codemaker/skills"],"userSkillsDirs":["~/.codemaker/skills"],"detect":["~/.codemaker"],"status":"community"},{"id":"codestudio","displayName":"Code Studio","projectSkillsDirs":[".codestudio/skills"],"userSkillsDirs":["~/.codestudio/skills"],"detect":["~/.codestudio"],"status":"community"},{"id":"codex","displayName":"Codex","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.agents/skills","~/.codex/skills"],"detect":["~/.codex","~/.agents/skills","~/.agents"],"status":"verified","notes":["Keep both ~/.agents/skills and ~/.codex/skills for compatibility."]},{"id":"command-code","displayName":"Command Code","projectSkillsDirs":[".commandcode/skills"],"userSkillsDirs":["~/.commandcode/skills"],"detect":["~/.commandcode"],"status":"community"},{"id":"continue","displayName":"Continue","projectSkillsDirs":[".continue/skills"],"userSkillsDirs":["~/.continue/skills"],"detect":["~/.continue",".continue"],"status":"community"},{"id":"cortex","displayName":"Cortex Code","projectSkillsDirs":[".cortex/skills"],"userSkillsDirs":["~/.snowflake/cortex/skills"],"detect":["~/.snowflake/cortex"],"status":"community"},{"id":"crush","displayName":"Crush","projectSkillsDirs":[".crush/skills"],"userSkillsDirs":["~/.config/crush/skills"],"detect":["~/.config/crush"],"status":"community"},{"id":"cursor","displayName":"Cursor","projectSkillsDirs":[".agents/skills",".cursor/skills"],"userSkillsDirs":["~/.cursor/skills","~/.agents/skills"],"detect":["~/.cursor","~/.agents"],"status":"documented"},{"id":"deepagents","displayName":"Deep Agents","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.deepagents/agent/skills"],"detect":["~/.deepagents","~/.deepagents/agent"],"status":"community"},{"id":"devin","displayName":"Devin for Terminal","projectSkillsDirs":[".devin/skills"],"userSkillsDirs":["~/.config/devin/skills"],"detect":["~/.config/devin"],"status":"community"},{"id":"dexto","displayName":"Dexto","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.agents/skills"],"detect":["~/.dexto","~/.agents"],"status":"community"},{"id":"droid","displayName":"Droid","projectSkillsDirs":[".factory/skills"],"userSkillsDirs":["~/.factory/skills"],"detect":["~/.factory"],"status":"community"},{"id":"eve","displayName":"Eve","projectSkillsDirs":["agent/skills"],"userSkillsDirs":[],"detect":["package.json"],"status":"community","notes":["Project-only host; userSkillsDirs is intentionally empty.","Explicit selection only; generic project paths are not safe detection evidence."]},{"id":"firebender","displayName":"Firebender","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.firebender/skills"],"detect":["~/.firebender"],"status":"community"},{"id":"forgecode","displayName":"ForgeCode","projectSkillsDirs":[".forge/skills"],"userSkillsDirs":["~/.forge/skills"],"detect":["~/.forge"],"status":"community"},{"id":"gemini-cli","displayName":"Gemini CLI","projectSkillsDirs":[".agents/skills",".gemini/skills"],"userSkillsDirs":["~/.gemini/skills","~/.agents/skills"],"detect":["~/.gemini","~/.agents"],"status":"documented"},{"id":"github-copilot","displayName":"GitHub Copilot","projectSkillsDirs":[".agents/skills",".github/skills",".claude/skills"],"userSkillsDirs":["~/.copilot/skills","~/.agents/skills","~/.claude/skills"],"detect":["~/.copilot","~/.agents"],"status":"documented"},{"id":"goose","displayName":"Goose","projectSkillsDirs":[".goose/skills"],"userSkillsDirs":["~/.config/goose/skills"],"detect":["~/.config/goose"],"status":"community"},{"id":"hermes-agent","displayName":"Hermes Agent","projectSkillsDirs":[".hermes/skills"],"userSkillsDirs":["~/.hermes/skills"],"detect":["~/.hermes"],"status":"community"},{"id":"iflow-cli","displayName":"iFlow CLI","projectSkillsDirs":[".iflow/skills"],"userSkillsDirs":["~/.iflow/skills"],"detect":["~/.iflow"],"status":"community"},{"id":"inference-sh","displayName":"inference.sh","projectSkillsDirs":[".inferencesh/skills"],"userSkillsDirs":["~/.inferencesh/skills"],"detect":["~/.inferencesh"],"status":"community"},{"id":"jazz","displayName":"Jazz","projectSkillsDirs":[".jazz/skills"],"userSkillsDirs":["~/.jazz/skills"],"detect":["~/.jazz",".jazz"],"status":"community"},{"id":"junie","displayName":"Junie","projectSkillsDirs":[".junie/skills"],"userSkillsDirs":["~/.junie/skills"],"detect":["~/.junie"],"status":"community"},{"id":"kilo","displayName":"Kilo Code","projectSkillsDirs":[".kilocode/skills"],"userSkillsDirs":["~/.kilocode/skills"],"detect":["~/.kilocode"],"status":"community"},{"id":"kimi-cli","displayName":"Kimi Code CLI","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.config/agents/skills","~/.agents/skills"],"detect":["~/.config/agents","~/.kimi-code","~/.kimi","~/.agents"],"status":"community","notes":["kimi-code-cli is an alias for the same Kimi Code CLI path family."],"aliases":["kimi-code-cli"]},{"id":"kiro-cli","displayName":"Kiro CLI","projectSkillsDirs":[".kiro/skills"],"userSkillsDirs":["~/.kiro/skills"],"detect":["~/.kiro"],"status":"community"},{"id":"kode","displayName":"Kode","projectSkillsDirs":[".kode/skills"],"userSkillsDirs":["~/.kode/skills"],"detect":["~/.kode"],"status":"community"},{"id":"lingma","displayName":"Lingma","projectSkillsDirs":[".lingma/skills"],"userSkillsDirs":["~/.lingma/skills"],"detect":["~/.lingma"],"status":"community"},{"id":"loaf","displayName":"Loaf","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.agents/skills"],"detect":["~/.loaf","~/.agents"],"status":"community"},{"id":"mcpjam","displayName":"MCPJam","projectSkillsDirs":[".mcpjam/skills"],"userSkillsDirs":["~/.mcpjam/skills"],"detect":["~/.mcpjam"],"status":"community"},{"id":"mistral-vibe","displayName":"Mistral Vibe","projectSkillsDirs":[".vibe/skills"],"userSkillsDirs":["~/.vibe/skills"],"detect":["~/.vibe"],"status":"community"},{"id":"moxby","displayName":"Moxby","projectSkillsDirs":[".moxby/skills"],"userSkillsDirs":["~/.moxby/skills"],"detect":["~/.moxby"],"status":"community"},{"id":"mux","displayName":"Mux","projectSkillsDirs":[".mux/skills"],"userSkillsDirs":["~/.mux/skills"],"detect":["~/.mux"],"status":"community"},{"id":"neovate","displayName":"Neovate","projectSkillsDirs":[".neovate/skills"],"userSkillsDirs":["~/.neovate/skills"],"detect":["~/.neovate"],"status":"community"},{"id":"ona","displayName":"Ona","projectSkillsDirs":[".ona/skills"],"userSkillsDirs":["~/.ona/skills"],"detect":["~/.ona"],"status":"community"},{"id":"openclaw","displayName":"OpenClaw","projectSkillsDirs":["skills"],"userSkillsDirs":["~/.openclaw/skills"],"detect":["~/.openclaw","~/.clawdbot","~/.moltbot"],"status":"community"},{"id":"opencode","displayName":"OpenCode","projectSkillsDirs":[".agents/skills",".opencode/skills",".claude/skills"],"userSkillsDirs":["~/.config/opencode/skills","~/.agents/skills","~/.claude/skills"],"detect":["~/.config/opencode","~/.agents"],"status":"verified"},{"id":"openhands","displayName":"OpenHands","projectSkillsDirs":[".openhands/skills"],"userSkillsDirs":["~/.openhands/skills"],"detect":["~/.openhands"],"status":"community"},{"id":"pi","displayName":"Pi","projectSkillsDirs":[".pi/skills"],"userSkillsDirs":["~/.pi/agent/skills"],"detect":["~/.pi/agent"],"status":"community"},{"id":"pochi","displayName":"Pochi","projectSkillsDirs":[".pochi/skills"],"userSkillsDirs":["~/.pochi/skills"],"detect":["~/.pochi"],"status":"community"},{"id":"promptscript","displayName":"PromptScript","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":[],"detect":[".promptscript","promptscript.yaml"],"status":"community","notes":["Project-only host; userSkillsDirs is intentionally empty."]},{"id":"qoder","displayName":"Qoder","projectSkillsDirs":[".qoder/skills"],"userSkillsDirs":["~/.qoder/skills"],"detect":["~/.qoder"],"status":"community"},{"id":"qoder-cn","displayName":"Qoder CN","projectSkillsDirs":[".qoder/skills"],"userSkillsDirs":["~/.qoder-cn/skills"],"detect":["~/.qoder-cn"],"status":"community"},{"id":"qwen-code","displayName":"Qwen Code","projectSkillsDirs":[".qwen/skills"],"userSkillsDirs":["~/.qwen/skills"],"detect":["~/.qwen"],"status":"community"},{"id":"reasonix","displayName":"Reasonix","projectSkillsDirs":[".reasonix/skills"],"userSkillsDirs":["~/.reasonix/skills"],"detect":["~/.reasonix"],"status":"community"},{"id":"replit","displayName":"Replit","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.config/agents/skills"],"detect":[".replit","~/.config/agents"],"status":"community"},{"id":"roo","displayName":"Roo Code","aliases":["roo-code"],"projectSkillsDirs":[".roo/skills",".agents/skills"],"userSkillsDirs":["~/.roo/skills","~/.agents/skills"],"detect":["~/.roo","~/.agents"],"status":"documented"},{"id":"rovodev","displayName":"Rovo Dev","projectSkillsDirs":[".rovodev/skills"],"userSkillsDirs":["~/.rovodev/skills"],"detect":["~/.rovodev"],"status":"community"},{"id":"tabnine-cli","displayName":"Tabnine CLI","projectSkillsDirs":[".tabnine/agent/skills"],"userSkillsDirs":["~/.tabnine/agent/skills"],"detect":["~/.tabnine","~/.tabnine/agent"],"status":"community"},{"id":"terramind","displayName":"Terramind","projectSkillsDirs":[".terramind/skills"],"userSkillsDirs":["~/.terramind/skills"],"detect":["~/.terramind"],"status":"community"},{"id":"tinycloud","displayName":"Tinycloud","projectSkillsDirs":[".tinycloud/skills"],"userSkillsDirs":["~/.tinycloud/skills"],"detect":["~/.tinycloud"],"status":"community"},{"id":"trae","displayName":"Trae","projectSkillsDirs":[".trae/skills"],"userSkillsDirs":["~/.trae/skills"],"detect":["~/.trae"],"status":"community"},{"id":"trae-cn","displayName":"Trae CN","projectSkillsDirs":[".trae/skills"],"userSkillsDirs":["~/.trae-cn/skills"],"detect":["~/.trae-cn"],"status":"community"},{"id":"universal","displayName":"Universal","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.agents/skills","~/.config/agents/skills"],"detect":["~/.agents","~/.config/agents"],"status":"community"},{"id":"warp","displayName":"Warp","projectSkillsDirs":[".agents/skills",".warp/skills",".claude/skills",".codex/skills",".cursor/skills",".gemini/skills",".copilot/skills",".factory/skills",".github/skills",".opencode/skills"],"userSkillsDirs":["~/.agents/skills","~/.warp/skills","~/.claude/skills","~/.codex/skills","~/.cursor/skills","~/.gemini/skills","~/.copilot/skills","~/.factory/skills","~/.github/skills","~/.opencode/skills"],"detect":["~/.warp","~/.agents"],"status":"documented"},{"id":"windsurf","displayName":"Windsurf","projectSkillsDirs":[".windsurf/skills"],"userSkillsDirs":["~/.codeium/windsurf/skills"],"detect":["~/.codeium/windsurf"],"status":"community"},{"id":"zed","displayName":"Zed","projectSkillsDirs":[".agents/skills"],"userSkillsDirs":["~/.agents/skills"],"detect":["~/.config/zed","~/.agents"],"status":"community"},{"id":"zencoder","displayName":"Zencoder","projectSkillsDirs":[".zencoder/skills"],"userSkillsDirs":["~/.zencoder/skills"],"detect":["~/.zencoder"],"status":"community"},{"id":"zenflow","displayName":"Zenflow","projectSkillsDirs":[".zencoder/skills"],"userSkillsDirs":["~/.zencoder/skills"],"detect":["~/.zencoder"],"status":"community"}]}"#; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 7465e7c..f391ad0 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -472,6 +472,12 @@ pub fn load_host_spec(hosts_file: Option<&Path>) -> io::Result> { fn validate_host_spec(hosts: &[Host]) -> io::Result<()> { for host in hosts { + let install_dirs: HashSet<&str> = host + .project_skills_dirs + .iter() + .chain(&host.user_skills_dirs) + .map(String::as_str) + .collect(); for path in &host.project_skills_dirs { if !is_project_host_path(path) { return Err(io::Error::new( @@ -495,6 +501,15 @@ fn validate_host_spec(hosts: &[Host]) -> io::Result<()> { format!("invalid detect path {path:?} for host {}", host.id), )); } + if !is_generic_detect_path(path) && install_dirs.contains(path.as_str()) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "detect path is an install target for host {}: {path:?}", + host.id + ), + )); + } } } Ok(()) @@ -514,7 +529,7 @@ fn is_safe_host_path(path: &str) -> bool { .any(|character| matches!(character, '\0' | '\\' | ':')) && !path .split('/') - .any(|segment| segment == ".." || segment.is_empty()) + .any(|segment| segment == "." || segment == ".." || segment.is_empty()) } pub fn resolve_hosts(agents: &AgentSelector, hosts: &[Host]) -> (Vec, Vec) { @@ -551,10 +566,10 @@ pub fn detect_hosts(options: &BaseOptions, scope: Option) -> io::Result io::Result<(Vec, Vec, Vec)> { + resolve_install_targets_for_lifecycle(options, agents, scope, skill_name, None) +} + +fn resolve_install_targets_for_lifecycle( + options: &BaseOptions, + agents: &AgentSelector, + scope: Scope, + skill_name: &str, + uninstall_app_id: Option<&str>, ) -> io::Result<(Vec, Vec, Vec)> { if !valid_skill_name(skill_name) { return Ok(( @@ -680,24 +705,33 @@ pub fn resolve_install_targets( }; let mut by_target: BTreeMap = BTreeMap::new(); for host in selected { - if let Some(root) = choose_scope_path(&host, scope, &home, &cwd) { - let target_dir = root.join(skill_name); - by_target - .entry(target_dir.clone()) - .or_insert_with(|| TargetGroup { - host_ids: Vec::new(), - skill_name: skill_name.to_string(), - target_dir, - }) - .host_ids - .push(host.id); - } else { + let roots = match uninstall_app_id { + Some(app_id) => uninstall_scope_paths(&host, scope, &home, &cwd, skill_name, app_id), + None => choose_scope_path(&host, scope, &home, &cwd, skill_name) + .into_iter() + .collect(), + }; + if roots.is_empty() { errors.push(json!({ "hostId": host.id, "skillName": skill_name, "scope": scope_text(scope), "reason": "unsupported-scope" })); + } else { + for root in roots { + let target_dir = root.join(skill_name); + let group = by_target + .entry(target_dir.clone()) + .or_insert_with(|| TargetGroup { + host_ids: Vec::new(), + skill_name: skill_name.to_string(), + target_dir, + }); + if !group.host_ids.contains(&host.id) { + group.host_ids.push(host.id.clone()); + } + } } } let targets: Vec<_> = by_target.into_values().collect(); @@ -989,11 +1023,12 @@ pub fn uninstall_bundled_skill(options: &UninstallOptions) -> io::Result O paths.first().map(|path| expand_host_path(path, home, cwd)) } -fn choose_scope_path(host: &Host, scope: Scope, home: &Path, cwd: &Path) -> Option { +fn choose_scope_path( + host: &Host, + scope: Scope, + home: &Path, + cwd: &Path, + skill_name: &str, +) -> Option { let paths = scope_paths(host, scope); - for path in paths { - let expanded = expand_host_path(path, home, cwd); - if expanded.exists() { - return Some(expanded); + let existing: Vec<_> = paths + .iter() + .map(|path| expand_host_path(path, home, cwd)) + .filter(|path| path.is_dir()) + .collect(); + for root in &existing { + if let MetadataState::Managed(metadata) = read_metadata(&root.join(skill_name)) { + if metadata.skill_name == skill_name { + return Some(root.clone()); + } } } + if let Some(root) = existing.into_iter().next() { + return Some(root); + } paths.first().map(|path| expand_host_path(path, home, cwd)) } +fn uninstall_scope_paths( + host: &Host, + scope: Scope, + home: &Path, + cwd: &Path, + skill_name: &str, + app_id: &str, +) -> Vec { + let owned: Vec<_> = scope_paths(host, scope) + .iter() + .map(|path| expand_host_path(path, home, cwd)) + .filter(|root| match read_metadata(&root.join(skill_name)) { + MetadataState::Managed(metadata) => { + metadata.skill_name == skill_name && metadata.app_id == app_id + } + _ => false, + }) + .collect(); + if !owned.is_empty() { + return owned; + } + choose_scope_path(host, scope, home, cwd, skill_name) + .into_iter() + .collect() +} + fn scope_paths(host: &Host, scope: Scope) -> &[String] { match scope { Scope::User => &host.user_skills_dirs, @@ -1958,7 +2034,15 @@ fn skip_name(name: &str) -> bool { } fn is_generic_detect_path(path: &str) -> bool { - path == "~/.agents" || path == "~/.agents/skills" || path == "~/.config/agents" + matches!( + path, + "~/.agents" + | "~/.agents/skills" + | "~/.config/agents" + | ".agents" + | ".agents/skills" + | "package.json" + ) } fn scope_text(scope: Scope) -> &'static str { diff --git a/rust/tests/golden.rs b/rust/tests/golden.rs index 6783ce8..6ad17a3 100644 --- a/rust/tests/golden.rs +++ b/rust/tests/golden.rs @@ -1,7 +1,7 @@ use kitup::{ classify_install_workflow_exit, compute_bundle_content_hash, detect_hosts, directory_bundle, files_bundle, github_bundle, install_bundled_skill, load_host_spec, parse_install_flags, - plan_bundled_skill, resolve_hosts, resolve_install_selection, + plan_bundled_skill, resolve_hosts, resolve_install_selection, resolve_install_targets, run_bundled_skill_install_with_io, uninstall_bundled_skill, update_bundled_skill, validate_skill_bundle, AgentSelector, BaseOptions, GitHubBundleOptions, InstallFlagValues, InstallOptions, InstallSelectionOptions, InstallWorkflowOptions, ParsedInstallFlags, Scope, @@ -174,6 +174,32 @@ fn run_case(case: &GoldenCase, home: &Path, workspace: &Path) { case.expected["selection"].clone(), ); } + "resolve-install-targets" => { + let base = case_base_options(options, home, workspace); + let (targets, _, _) = resolve_install_targets( + &base, + &agent_selector(&options["agents"]), + scope(options["scope"].as_str().unwrap()), + options["skillName"].as_str().unwrap(), + ) + .unwrap(); + let actual = Value::Array( + targets + .into_iter() + .map(|target| { + json!({ + "hostIds": target.host_ids, + "skillName": target.skill_name, + "targetDir": target.target_dir, + }) + }) + .collect(), + ); + assert_json_eq( + &actual, + expand_value(&case.expected["targets"], home, workspace), + ); + } "run-install-workflow" => { let mut input = Cursor::new( options diff --git a/scripts/check-go-modules.mjs b/scripts/check-go-modules.mjs new file mode 100644 index 0000000..caf6a7d --- /dev/null +++ b/scripts/check-go-modules.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = new URL("../", import.meta.url); +const scratch = mkdtempSync(join(tmpdir(), "kitup-go-modules-")); +const core = join(scratch, "go"); +const cobra = join(scratch, "go-cobra"); +const consumer = join(scratch, "consumer"); +const env = { ...process.env, GOWORK: "off" }; +delete env.KITUP_TEST_REPO_ROOT; + +try { + cpSync(new URL("go/", root), core, { recursive: true }); + cpSync(new URL("go-cobra/", root), cobra, { recursive: true }); + + if (existsSync(join(core, "testdata"))) { + throw new Error( + "standalone Go module must not contain copied repository testdata", + ); + } + + run("go", ["test", "-count=1", "./..."], core); + + cpSync( + new URL("tests/go-golden/golden_test.go", root), + join(core, "golden_test.go"), + ); + run("go", ["test", "-count=1", "./..."], core, { + ...env, + KITUP_TEST_REPO_ROOT: fileURLToPath(root), + }); + console.log("ok: repository Go golden parity"); + + run("go", ["test", "-mod=readonly", "-count=1", "./..."], cobra); + + run( + "go", + ["mod", "edit", "-replace=github.com/lathe-cli/kitup/go=../go"], + cobra, + ); + run("go", ["test", "-count=1", "./..."], cobra); + + mkdirSync(consumer); + writeFileSync( + join(consumer, "go.mod"), + `module kitup-module-smoke + +go 1.23 + +require github.com/lathe-cli/kitup/go-cobra v0.0.0 + +replace github.com/lathe-cli/kitup/go-cobra => ../go-cobra + +replace github.com/lathe-cli/kitup/go => ../go +`, + ); + writeFileSync( + join(consumer, "main.go"), + `package main + +import ( + "io" + + kitup "github.com/lathe-cli/kitup/go" + kitupcobra "github.com/lathe-cli/kitup/go-cobra" +) + +func main() { + _ = kitupcobra.NewSkillCommand(kitupcobra.Options{ + AppID: "module-smoke", + Bundle: kitup.FilesBundle([]kitup.SkillFile{{ + Path: "SKILL.md", + Contents: []byte("---\\nname: module-smoke\\ndescription: Standalone module smoke fixture.\\n---\\n"), + }}), + Out: io.Discard, + }) +} +`, + ); + run("go", ["mod", "tidy"], consumer); + const modules = output( + "go", + [ + "list", + "-m", + "-f", + "{{if .Replace}}{{.Path}}=>{{.Replace.Path}}{{end}}", + "all", + ], + consumer, + ); + for (const expected of [ + "github.com/lathe-cli/kitup/go=>../go", + "github.com/lathe-cli/kitup/go-cobra=>../go-cobra", + ]) { + if (!modules.split("\n").includes(expected)) { + throw new Error(`go list did not resolve ${expected}`); + } + } + run("go", ["test", "./..."], consumer); + run("go", ["build", "."], consumer); +} finally { + rmSync(scratch, { recursive: true, force: true }); +} + +console.log("ok: standalone Go modules"); + +function run(command, args, cwd, commandEnv = env) { + const result = spawnSync(command, args, { + cwd, + env: commandEnv, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} exited ${result.status}`); + } +} + +function output(command, args, cwd) { + const result = spawnSync(command, args, { cwd, env, encoding: "utf8" }); + if (result.error) throw result.error; + if (result.status !== 0) { + process.stderr.write(result.stderr); + throw new Error(`${command} ${args.join(" ")} exited ${result.status}`); + } + return result.stdout.trim(); +} diff --git a/scripts/check.mjs b/scripts/check.mjs index c67bbff..7eef39f 100755 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -33,6 +33,15 @@ function shouldRun(group) { return selectedGroups.size === 0 || selectedGroups.has(group); } +const genericDetectPaths = new Set([ + "~/.agents", + "~/.agents/skills", + "~/.config/agents", + ".agents", + ".agents/skills", + "package.json", +]); + function validateHosts(spec, schema) { assert(spec.schemaVersion === 1, "hosts schemaVersion must be 1"); assert(Array.isArray(spec.hosts), "hosts must be an array"); @@ -44,6 +53,7 @@ function validateHosts(spec, schema) { "../outside", "..\\outside", "C:/outside", + "a/./b", "a//b", "a/", ]) { @@ -53,6 +63,7 @@ function validateHosts(spec, schema) { "~/../outside", "~/..\\outside", "~/C:/outside", + "~/a/./b", "~//a", "~/a/", ]) { @@ -100,11 +111,19 @@ function validateHosts(spec, schema) { Array.isArray(host.detect) && host.detect.length > 0, `missing detect paths: ${host.id}`, ); + const installDirs = new Set([ + ...host.projectSkillsDirs, + ...host.userSkillsDirs, + ]); for (const path of host.detect) { assert( homePattern.test(path) || projectPattern.test(path), `bad detect path for ${host.id}: ${path}`, ); + assert( + genericDetectPaths.has(path) || !installDirs.has(path), + `detect path is an install target for ${host.id}: ${path}`, + ); } assert( @@ -120,6 +139,28 @@ function validateHosts(spec, schema) { } } + const installSurface = (host) => + JSON.stringify([...host.projectSkillsDirs, ...host.userSkillsDirs].sort()); + const primaryDetect = new Map(); + for (const host of spec.hosts) { + const primary = host.detect[0]; + if (!primary || genericDetectPaths.has(primary)) continue; + if (!primaryDetect.has(primary)) primaryDetect.set(primary, []); + primaryDetect.get(primary).push(host); + } + for (const host of spec.hosts) { + for (const path of host.detect) { + if (genericDetectPaths.has(path)) continue; + for (const owner of primaryDetect.get(path) || []) { + assert( + owner.id === host.id || + installSurface(owner) === installSurface(host), + `detect path for ${host.id} belongs to ${owner.id}: ${path}`, + ); + } + } + } + return { ids, aliases }; } @@ -159,11 +200,19 @@ function validateCases(cases, hosts) { "user-scope-install", "codex-user-scope-prefers-first-user-dir", "project-scope-install", + "codex-user-scope-reuses-existing-compatible-dir", + "codex-user-scope-ignores-compatible-file", + "update-prefers-owned-compatible-target", + "resolve-targets-prefers-owned-compatible-target", + "uninstall-prefers-owned-compatible-target", "project-scope-plan", "project-only-host-project-scope-install", "project-only-host-user-scope-error", "auto-host-detection", "auto-host-detection-empty", + "auto-host-detection-secondary-path", + "auto-host-detection-ignores-generic-paths", + "auto-host-detection-ignores-generic-agent-dir", "unchanged-noop", "unchanged-repairs-script-mode", "workflow-unchanged-silent", @@ -173,6 +222,10 @@ function validateCases(cases, hosts) { "different-owner-conflict", "uninstall-owned-skill", "uninstall-owner-mismatch", + "uninstall-removes-all-owned-compatible-targets", + "uninstall-rejects-boolean-schema-version", + "hosts-file-rejects-self-detect-install-path", + "hosts-file-rejects-dot-segment-alias", "missing-skill-md", "invalid-frontmatter", "nested-resources-copied", @@ -267,6 +320,19 @@ function validateVersions() { ); } +function validateGoModules() { + for (const path of ["go/go.mod", "go-cobra/go.mod"]) { + assert( + !/^replace\s/m.test(readText(path)), + `${path} must not contain a replace directive`, + ); + } + assert( + /^use \(/m.test(readText("go.work")), + "go.work must list the workspace modules", + ); +} + function validateReleaseWorkflow() { const workflow = readText(".github/workflows/release.yml"); assert( @@ -305,6 +371,7 @@ const { ids, aliases } = validateHosts(hostsSpec, hostsSchema); validateCases(cases, hostsSpec.hosts); validateFixtures(); validateVersions(); +validateGoModules(); validateReleaseWorkflow(); assert(ids.has("kimi-cli"), "kimi-cli must be canonical"); @@ -345,25 +412,13 @@ for (const [group, name, command, args, cwd, env] of [ "test", "../examples/ts/cli.ts", "../scripts/check.mjs", + "../scripts/check-go-modules.mjs", "../scripts/prepare-release.mjs", ], rootPath, ], ["typescript", "typescript", "pnpm", ["--dir", "ts", "test"], rootPath], - [ - "go", - "go", - "go", - ["test", "-count=1", "./..."], - new URL("../go/", import.meta.url), - ], - [ - "go", - "go-cobra", - "go", - ["test", "./..."], - new URL("../go-cobra/", import.meta.url), - ], + ["go", "go-modules", "node", ["scripts/check-go-modules.mjs"], rootPath], ["rust", "rust", "cargo", ["test"], new URL("../rust/", import.meta.url)], [ "rust", diff --git a/spec/hosts.json b/spec/hosts.json index d1af3c3..08b8978 100644 --- a/spec/hosts.json +++ b/spec/hosts.json @@ -84,7 +84,6 @@ ], "detect": [ "~/.astrbot", - "data/skills", "~/.astrbot/data" ], "status": "community" @@ -381,13 +380,12 @@ ], "userSkillsDirs": [], "detect": [ - "agent", "package.json" ], "status": "community", "notes": [ "Project-only host; userSkillsDirs is intentionally empty.", - "Detect from Eve project shape; no global skill directory." + "Explicit selection only; generic project paths are not safe detection evidence." ] }, { @@ -450,8 +448,7 @@ ], "detect": [ "~/.copilot", - "~/.agents", - "~/.claude" + "~/.agents" ], "status": "documented" }, @@ -750,8 +747,7 @@ ], "detect": [ "~/.config/opencode", - "~/.agents", - "~/.claude" + "~/.agents" ], "status": "verified" }, @@ -1034,15 +1030,7 @@ ], "detect": [ "~/.warp", - "~/.agents", - "~/.claude", - "~/.codex", - "~/.cursor", - "~/.gemini", - "~/.copilot", - "~/.factory", - "~/.github", - "~/.opencode" + "~/.agents" ], "status": "documented" }, diff --git a/spec/hosts.schema.json b/spec/hosts.schema.json index 67ed375..fa68d85 100644 --- a/spec/hosts.schema.json +++ b/spec/hosts.schema.json @@ -115,12 +115,12 @@ "projectPath": { "type": "string", "minLength": 1, - "pattern": "^(?!/)(?!~)(?!.*(^|/)\\.\\.(/|$))[^/\\u0000:\\\\]+(?:/[^/\\u0000:\\\\]+)*$" + "pattern": "^(?!/)(?!~)(?!.*(^|/)\\.\\.?(/|$))[^/\\u0000:\\\\]+(?:/[^/\\u0000:\\\\]+)*$" }, "homePath": { "type": "string", "minLength": 3, - "pattern": "^(?!.*(^|/)\\.\\.(/|$))~/[^/\\u0000:\\\\]+(?:/[^/\\u0000:\\\\]+)*$" + "pattern": "^(?!.*(^|/)\\.\\.?(/|$))~/[^/\\u0000:\\\\]+(?:/[^/\\u0000:\\\\]+)*$" }, "detectPath": { "anyOf": [ diff --git a/testdata/cases.schema.json b/testdata/cases.schema.json index e64fd4f..69f1610 100644 --- a/testdata/cases.schema.json +++ b/testdata/cases.schema.json @@ -48,6 +48,7 @@ "plan", "run-install-workflow", "resolve-install-selection", + "resolve-install-targets", "resolve-hosts", "update", "uninstall", diff --git a/testdata/cases/bundled-skill-install.json b/testdata/cases/bundled-skill-install.json index fcc5407..0b5747b 100644 --- a/testdata/cases/bundled-skill-install.json +++ b/testdata/cases/bundled-skill-install.json @@ -441,6 +441,296 @@ ] } }, + { + "id": "codex-user-scope-reuses-existing-compatible-dir", + "operation": "install", + "description": "Reuses the existing compatible Codex user directory when the canonical directory is absent.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": "auto", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex/skills" + ], + "files": {} + }, + "expected": { + "detectedHosts": [ + "codex" + ], + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.codex/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.codex/skills/basic/SKILL.md", + "$HOME/.codex/skills/basic/.kitup.json" + ], + "filesAbsent": [ + "$HOME/.agents/skills/basic" + ] + } + }, + { + "id": "codex-user-scope-ignores-compatible-file", + "operation": "install", + "description": "Ignores a regular file at a compatible Codex user path and installs into the canonical directory.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": { + "$HOME/.codex/skills": "existing file\n" + } + }, + "expected": { + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.codex/skills", + "$HOME/.agents/skills/basic/SKILL.md", + "$HOME/.agents/skills/basic/.kitup.json" + ] + } + }, + { + "id": "update-prefers-owned-compatible-target", + "operation": "update", + "description": "Keeps updating a kitup-owned compatible target after the canonical root appears.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills" + ], + "files": {}, + "copySkillBundleTo": "$HOME/.codex/skills/basic", + "metadata": { + "path": "$HOME/.codex/skills/basic/.kitup.json", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled" + }, + "hash": "from-skill-bundle-dir" + } + }, + "expected": { + "report": { + "installed": [], + "updated": [], + "skipped": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.codex/skills/basic", + "reason": "unchanged" + } + ], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.codex/skills/basic/.kitup.json" + ], + "filesAbsent": [ + "$HOME/.agents/skills/basic" + ] + } + }, + { + "id": "resolve-targets-prefers-owned-compatible-target", + "operation": "resolve-install-targets", + "description": "Resolves the same kitup-owned compatible target used by lifecycle operations.", + "options": { + "skillName": "basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills" + ], + "files": {}, + "copySkillBundleTo": "$HOME/.codex/skills/basic", + "metadata": { + "path": "$HOME/.codex/skills/basic/.kitup.json", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled" + }, + "hash": "from-skill-bundle-dir" + } + }, + "expected": { + "targets": [ + { + "hostIds": [ + "codex" + ], + "skillName": "basic", + "targetDir": "$HOME/.codex/skills/basic" + } + ] + } + }, + { + "id": "uninstall-prefers-owned-compatible-target", + "operation": "uninstall", + "description": "Removes a kitup-owned compatible target after the canonical root appears.", + "options": { + "appId": "example-cli", + "skillName": "basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills" + ], + "files": {}, + "copySkillBundleTo": "$HOME/.codex/skills/basic", + "metadata": { + "path": "$HOME/.codex/skills/basic/.kitup.json", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled" + }, + "hash": "from-skill-bundle-dir" + } + }, + "expected": { + "report": { + "removed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.codex/skills/basic" + } + ], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesAbsent": [ + "$HOME/.codex/skills/basic", + "$HOME/.agents/skills/basic" + ] + } + }, + { + "id": "uninstall-removes-all-owned-compatible-targets", + "operation": "uninstall", + "description": "Removes every owned copy of a skill from configured compatible roots.", + "options": { + "appId": "example-cli", + "skillName": "basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": { + "$HOME/.agents/skills/basic/SKILL.md": "---\nname: basic\ndescription: Canonical copy.\n---\n", + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "canonical-copy" + }, + "$HOME/.codex/skills/basic/SKILL.md": "---\nname: basic\ndescription: Compatible copy.\n---\n", + "$HOME/.codex/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "compatible-copy" + } + } + }, + "expected": { + "report": { + "removed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + }, + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.codex/skills/basic" + } + ], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesAbsent": [ + "$HOME/.agents/skills/basic", + "$HOME/.codex/skills/basic" + ] + } + }, { "id": "project-scope-install", "operation": "install", @@ -708,6 +998,107 @@ } } }, + { + "id": "auto-host-detection-secondary-path", + "operation": "install", + "description": "Detects a host through a non-first detect entry when earlier entries are generic or absent.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": "auto", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.kimi" + ], + "files": {} + }, + "expected": { + "detectedHosts": [ + "kimi-cli" + ], + "report": { + "installed": [ + { + "hostId": "kimi-cli", + "skillName": "basic", + "targetDir": "$HOME/.config/agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + } + } + }, + { + "id": "auto-host-detection-ignores-generic-paths", + "operation": "install", + "description": "Never treats shared generic roots or install-target shapes as host presence evidence.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": "auto", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents", + "$HOME/.agents/skills", + "$HOME/.config/agents", + "$WORKSPACE/data/skills" + ], + "files": {} + }, + "expected": { + "detectedHosts": [], + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + } + } + }, + { + "id": "auto-host-detection-ignores-generic-agent-dir", + "operation": "install", + "description": "Does not treat an ordinary project agent directory as evidence that Eve is installed.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "project", + "agents": "auto", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$WORKSPACE/agent" + ], + "files": {} + }, + "expected": { + "detectedHosts": [], + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesAbsent": [ + "$WORKSPACE/agent/skills/basic" + ] + } + }, { "id": "shared-target-deduplication", "operation": "install", @@ -2948,6 +3339,146 @@ ] } }, + { + "id": "uninstall-rejects-boolean-schema-version", + "operation": "uninstall", + "description": "Rejects a boolean schema version before resolving a lifecycle target as kitup-owned.", + "options": { + "appId": "example-cli", + "skillName": "basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills" + ], + "files": { + "$HOME/.codex/skills/basic/.kitup.json": { + "schemaVersion": true, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "not-owned" + }, + "$HOME/.codex/skills/basic/user-data.txt": "preserve me\n" + } + }, + "expected": { + "report": { + "removed": [], + "skipped": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "missing" + } + ], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.codex/skills/basic/.kitup.json", + "$HOME/.codex/skills/basic/user-data.txt" + ], + "filesAbsent": [ + "$HOME/.agents/skills/basic" + ] + } + }, + { + "id": "hosts-file-rejects-self-detect-install-path", + "operation": "install", + "description": "Rejects a custom host whose non-generic detection path is also an install directory.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "probe" + ], + "hostsFile": "$WORKSPACE/hosts.json", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": { + "$WORKSPACE/hosts.json": { + "schemaVersion": 1, + "hosts": [ + { + "id": "probe", + "displayName": "Probe", + "projectSkillsDirs": [], + "userSkillsDirs": [ + "~/.probe/skills" + ], + "detect": [ + "~/.probe/skills" + ], + "status": "community" + } + ] + } + } + }, + "expected": { + "throws": true, + "filesAbsent": [ + "$HOME/.probe/skills/basic" + ] + } + }, + { + "id": "hosts-file-rejects-dot-segment-alias", + "operation": "install", + "description": "Rejects dot segments that can alias an install directory in a custom host definition.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "probe" + ], + "hostsFile": "$WORKSPACE/hosts.json", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": { + "$WORKSPACE/hosts.json": { + "schemaVersion": 1, + "hosts": [ + { + "id": "probe", + "displayName": "Probe", + "projectSkillsDirs": [], + "userSkillsDirs": [ + "~/.probe/skills" + ], + "detect": [ + "~/.probe/skills/." + ], + "status": "community" + } + ] + } + } + }, + "expected": { + "throws": true, + "filesAbsent": [ + "$HOME/.probe/skills/basic" + ] + } + }, { "id": "install-rejects-empty-app-id", "operation": "install", diff --git a/tests/go-golden/golden_test.go b/tests/go-golden/golden_test.go new file mode 100644 index 0000000..a2c2f83 --- /dev/null +++ b/tests/go-golden/golden_test.go @@ -0,0 +1,609 @@ +package kitup + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" +) + +type goldenFile struct { + Cases []goldenCase `json:"cases"` +} + +type goldenCase struct { + ID string `json:"id"` + Operation string `json:"operation"` + Options map[string]any `json:"options"` + Given map[string]any `json:"given"` + Expected map[string]any `json:"expected"` +} + +const testRepoRootEnv = "KITUP_TEST_REPO_ROOT" + +func TestGoldenCases(t *testing.T) { + if os.Getenv(testRepoRootEnv) == "" { + t.Skip("shared golden cases require KITUP_TEST_REPO_ROOT") + } + var file goldenFile + readJSON(t, repoPathFromCase("testdata/cases/bundled-skill-install.json"), &file) + for _, tc := range file.Cases { + t.Run(tc.ID, func(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + workspace := filepath.Join(root, "workspace") + must(t, os.MkdirAll(home, 0o755)) + must(t, os.MkdirAll(workspace, 0o755)) + setupGiven(t, tc, home, workspace) + runCase(t, tc, home, workspace) + }) + } +} + +func runCase(t *testing.T, tc goldenCase, home, workspace string) { + opts := expandValue(tc.Options, home, workspace).(map[string]any) + switch tc.Operation { + case "resolve-hosts": + hostsFile := repoPathFromCase(tc.Given["hostsFile"].(string)) + hosts, err := LoadHostSpec(hostsFile) + must(t, err) + resolved, errs := ResolveHosts(agentSelector(opts["agents"]), hosts) + if expected, ok := tc.Expected["count"]; ok && int(expected.(float64)) != len(resolved) { + t.Fatalf("count: got %d want %v", len(resolved), expected) + } + if expected, ok := tc.Expected["hostIds"]; ok { + equal(t, hostIDs(resolved), expected) + } + if expected, ok := tc.Expected["resolvedHostIds"]; ok { + equal(t, hostIDs(resolved), expected) + } + if expected, ok := tc.Expected["errors"]; ok { + equal(t, errs, expected) + } + case "validate": + result := ValidateSkillBundle(skillBundleFromOptions(opts)) + equal(t, result.Valid, tc.Expected["valid"]) + equal(t, result.ErrorCode, tc.Expected["errorCode"]) + case "parse-install-flags": + assertParsedFlags(t, ParseInstallFlags(InstallFlagValues{ + Scope: stringValue(opts["scope"]), + ScopeSet: boolValue(opts["scopeSet"]) || opts["scope"] != nil, + Agents: stringSlice(opts["agents"]), + Yes: boolValue(opts["yes"]), + DryRun: boolValue(opts["dryRun"]), + Force: boolValue(opts["force"]), + }), tc.Expected["parsed"].(map[string]any)) + case "resolve-install-selection": + selection, err := ResolveInstallSelection(InstallSelectionOptions{ + BaseOptions: caseBaseOptions(tc, home, workspace), + Scope: Scope(opts["scope"].(string)), + Agents: agentSelector(opts["agents"]), + Yes: boolValue(opts["yes"]), + StdinTTY: boolValue(opts["stdinTTY"]), + }) + must(t, err) + assertSelection(t, normalize(selection).(map[string]any), tc.Expected["selection"].(map[string]any)) + case "resolve-install-targets": + targets, _, _, err := ResolveInstallTargets( + caseBaseOptions(tc, home, workspace), + agentSelector(opts["agents"]), + Scope(opts["scope"].(string)), + opts["skillName"].(string), + ) + must(t, err) + actual := []map[string]any{} + for _, target := range targets { + actual = append(actual, map[string]any{ + "hostIds": target.HostIDs, "skillName": target.SkillName, "targetDir": target.TargetDir, + }) + } + equal(t, actual, expandValue(tc.Expected["targets"], home, workspace)) + case "run-install-workflow": + var out bytes.Buffer + report, err := RunBundledSkillInstall(InstallWorkflowOptions{ + InstallOptions: InstallOptions{ + BaseOptions: caseBaseOptions(tc, home, workspace), + AppID: opts["appId"].(string), + SkillBundle: skillBundleFromOptions(opts), + Scope: Scope(stringValue(opts["scope"])), + Agents: agentSelector(opts["agents"]), + Force: boolValue(opts["force"]), + }, + Yes: boolValue(opts["yes"]), + DryRun: boolValue(opts["dryRun"]), + StdinTTY: boolValue(opts["stdinTTY"]), + DefaultScope: Scope(stringValue(opts["defaultScope"])), + ScopeSet: boolValue(opts["scopeSet"]) || opts["scope"] != nil, + PromptScope: boolValue(opts["promptScope"]), + In: strings.NewReader(stringValue(opts["input"])), + Out: &out, + }) + must(t, err) + assertWorkflow(t, normalize(report).(map[string]any), tc.Expected["workflow"]) + if expected, ok := tc.Expected["exit"]; ok { + equal(t, ClassifyInstallWorkflowExit(report), expected) + } + assertOutput(t, out.String(), tc.Expected["output"]) + assertOutputContains(t, out.String(), tc.Expected["outputContains"]) + if expected, ok := tc.Expected["report"]; ok { + equal(t, report.Report, expandValue(expected, home, workspace)) + } + assertExpectedFiles(t, tc, home, workspace) + assertExpectedFileModes(t, tc, home, workspace) + assertExpectedMetadata(t, tc, home, workspace) + default: + base := caseBaseOptions(tc, home, workspace) + if expected, ok := tc.Expected["detectedHosts"]; ok { + hosts, err := DetectHosts(base, Scope(opts["scope"].(string))) + must(t, err) + equal(t, hostIDs(hosts), expected) + } + report, err := runReportCase(t, tc, opts, base) + if throws, ok := tc.Expected["throws"].(bool); ok && throws { + if err == nil { + t.Fatal("expected operation to throw") + } + assertExpectedFiles(t, tc, home, workspace) + return + } + must(t, err) + if expected, ok := tc.Expected["report"]; ok { + equal(t, report, expandValue(expected, home, workspace)) + } + assertExpectedWriteCounts(t, tc, report, home, workspace) + assertExpectedFiles(t, tc, home, workspace) + assertExpectedFileModes(t, tc, home, workspace) + assertExpectedMetadata(t, tc, home, workspace) + } +} + +func runReportCase(t *testing.T, tc goldenCase, opts map[string]any, base BaseOptions) (any, error) { + switch tc.Operation { + case "uninstall": + return UninstallBundledSkill(UninstallOptions{ + BaseOptions: base, + AppID: opts["appId"].(string), + SkillName: opts["skillName"].(string), + Scope: Scope(opts["scope"].(string)), + Agents: agentSelector(opts["agents"]), + }) + case "install", "update", "plan": + fn := InstallBundledSkill + if tc.Operation == "update" { + fn = UpdateBundledSkill + } + if tc.Operation == "plan" { + fn = PlanBundledSkill + } + return fn(InstallOptions{ + BaseOptions: base, + AppID: opts["appId"].(string), + SkillBundle: skillBundleFromOptions(opts), + Scope: Scope(opts["scope"].(string)), + Agents: agentSelector(opts["agents"]), + Force: boolValue(opts["force"]), + }) + default: + t.Fatalf("unsupported operation: %s", tc.Operation) + return nil, nil + } +} + +func baseOptions(home, workspace string) BaseOptions { + return BaseOptions{Home: home, CWD: workspace, HostsFile: repoPathFromCase("spec/hosts.json")} +} + +func caseBaseOptions(tc goldenCase, home, workspace string) BaseOptions { + base := baseOptions(home, workspace) + if hostsFile, ok := tc.Options["hostsFile"].(string); ok && hostsFile != "" { + expanded := expandString(hostsFile, home, workspace) + if filepath.IsAbs(expanded) { + base.HostsFile = expanded + } else { + base.HostsFile = repoPathFromCase(expanded) + } + } + return base +} + +func setupGiven(t *testing.T, tc goldenCase, home, workspace string) { + for _, dir := range stringSlice(tc.Given["dirs"]) { + must(t, os.MkdirAll(expandString(dir, home, workspace), 0o755)) + } + if files, ok := tc.Given["files"].(map[string]any); ok { + for path, value := range files { + writeFixtureFile(t, expandString(path, home, workspace), value) + } + } + if target, ok := tc.Given["copySkillBundleTo"].(string); ok { + must(t, os.RemoveAll(expandString(target, home, workspace))) + must(t, copySkillBundleDir(caseSkillBundleDir(tc), expandString(target, home, workspace))) + } + if modes, ok := tc.Given["fileModes"].(map[string]any); ok { + for path, mode := range modes { + value, err := strconv.ParseUint(mode.(string), 8, 32) + must(t, err) + must(t, os.Chmod(expandString(path, home, workspace), os.FileMode(value))) + } + } + if meta, ok := tc.Given["metadata"].(map[string]any); ok { + writeMetadataFixture(t, tc, home, workspace, meta) + } + if github, ok := tc.Given["github"].(map[string]any); ok { + startGitHubFixture(t, github) + } +} + +func assertExpectedFiles(t *testing.T, tc goldenCase, home, workspace string) { + for _, path := range stringSlice(tc.Expected["filesPresent"]) { + if !exists(expandString(path, home, workspace)) { + t.Fatalf("expected file to exist: %s", expandString(path, home, workspace)) + } + } + for _, path := range stringSlice(tc.Expected["filesAbsent"]) { + if exists(expandString(path, home, workspace)) { + t.Fatalf("expected file to be absent: %s", expandString(path, home, workspace)) + } + } +} + +func assertExpectedFileModes(t *testing.T, tc goldenCase, home, workspace string) { + modes, ok := tc.Expected["fileModes"].(map[string]any) + if !ok { + return + } + for path, expected := range modes { + info, err := os.Stat(expandString(path, home, workspace)) + must(t, err) + equal(t, modeString(info.Mode().Perm()), expected) + } +} + +func assertExpectedMetadata(t *testing.T, tc goldenCase, home, workspace string) { + meta, ok := tc.Expected["metadata"].(map[string]any) + if !ok { + return + } + path := expandString(meta["path"].(string), home, workspace) + var actual map[string]any + readJSON(t, path, &actual) + for key, value := range meta["fields"].(map[string]any) { + equal(t, actual[key], value) + } + hash := expectedBundleHash(t, tc, meta["hash"].(string)) + equal(t, actual["hash"], hash) +} + +func modeString(mode os.FileMode) string { + return strings.TrimLeft("000"+strconv.FormatUint(uint64(mode), 8), "0") +} + +func assertExpectedWriteCounts(t *testing.T, tc goldenCase, report any, home, workspace string) { + expected, ok := tc.Expected["writeCountByTargetDir"] + if !ok { + return + } + actual := map[string]any{} + reportMap := normalize(report).(map[string]any) + for _, key := range []string{"installed", "updated"} { + for _, item := range reportMap[key].([]any) { + targetDir := item.(map[string]any)["targetDir"].(string) + if actual[targetDir] == nil { + actual[targetDir] = float64(0) + } + actual[targetDir] = actual[targetDir].(float64) + 1 + } + } + equal(t, actual, expandValue(expected, home, workspace)) +} + +func writeMetadataFixture(t *testing.T, tc goldenCase, home, workspace string, meta map[string]any) { + hash := expectedBundleHash(t, tc, meta["hash"].(string)) + fields := map[string]any{} + for key, value := range meta["fields"].(map[string]any) { + fields[key] = value + } + fields["hash"] = hash + writeFixtureFile(t, expandString(meta["path"].(string), home, workspace), fields) +} + +func expectedBundleHash(t *testing.T, tc goldenCase, marker string) string { + switch marker { + case "from-skill-bundle-dir": + hash, err := ComputeBundleContentHash(DirectoryBundle(caseSkillBundleDir(tc))) + must(t, err) + return hash + case "from-skill-files": + hash, err := ComputeBundleContentHash(FilesBundle(skillFiles(tc.Options["skillFiles"].([]any)))) + must(t, err) + return hash + case "from-github-bundle": + hash, err := ComputeBundleContentHash(FilesBundle(githubSkillFiles(tc))) + must(t, err) + return hash + default: + return marker + } +} + +func assertSelection(t *testing.T, actual, expected map[string]any) { + if expectedCount, ok := expected["selectedCount"]; ok { + equal(t, float64(len(actual["selectedHostIds"].([]any))), expectedCount) + delete(actual, "selectedHostIds") + } + if expectedCount, ok := expected["candidateCount"]; ok { + equal(t, float64(len(actual["candidateHostIds"].([]any))), expectedCount) + delete(actual, "candidateHostIds") + } + delete(expected, "selectedCount") + delete(expected, "candidateCount") + equal(t, actual, expected) +} + +func assertWorkflow(t *testing.T, actual map[string]any, expected any) { + expectedMap, ok := expected.(map[string]any) + if !ok { + return + } + for key, value := range expectedMap { + equal(t, actual[key], value) + } +} + +func assertParsedFlags(t *testing.T, actual ParsedInstallFlags, expected map[string]any) { + agentKind := actual.Agents.Kind + agentIDs := actual.Agents.IDs + if agentIDs == nil { + agentIDs = []string{} + } + equal(t, map[string]any{ + "scope": string(actual.Scope), + "scopeSet": actual.ScopeSet, + "agentKind": agentKind, + "agentIds": agentIDs, + "yes": actual.Yes, + "dryRun": actual.DryRun, + "force": actual.Force, + "errors": actual.Errors, + }, expected) +} + +func assertOutputContains(t *testing.T, actual string, expected any) { + for _, value := range stringSlice(expected) { + if !strings.Contains(actual, value) { + t.Fatalf("expected output to contain %q, got:\n%s", value, actual) + } + } +} + +func assertOutput(t *testing.T, actual string, expected any) { + if expected == nil { + return + } + equal(t, actual, expected) +} + +func writeFixtureFile(t *testing.T, path string, value any) { + must(t, os.MkdirAll(filepath.Dir(path), 0o755)) + switch value := value.(type) { + case string: + must(t, os.WriteFile(path, []byte(value), 0o644)) + default: + data, err := json.MarshalIndent(value, "", " ") + must(t, err) + must(t, os.WriteFile(path, append(data, '\n'), 0o644)) + } +} + +func agentSelector(value any) AgentSelector { + if value == nil { + return AutoAgents() + } + if text, ok := value.(string); ok { + if text == "*" { + return AllAgents() + } + return AutoAgents() + } + return ExplicitAgents(stringSlice(value)...) +} + +func skillBundleFromOptions(opts map[string]any) SkillBundle { + if files, ok := opts["skillFiles"].([]any); ok { + return FilesBundle(skillFiles(files)) + } + if dir, ok := opts["skillBundleDir"].(string); ok { + return DirectoryBundle(repoPathFromCase(dir)) + } + if bundle, ok := opts["githubBundle"].(map[string]any); ok { + return GitHubBundle(GitHubBundleOptions{ + Owner: bundle["owner"].(string), + Repo: bundle["repo"].(string), + Path: bundle["path"].(string), + Ref: bundle["ref"].(string), + }) + } + return SkillBundle{} +} + +func skillFiles(values []any) []SkillFile { + files := make([]SkillFile, 0, len(values)) + for _, value := range values { + item := value.(map[string]any) + mode := os.FileMode(0) + if raw, ok := item["mode"].(float64); ok { + mode = os.FileMode(raw) + } + files = append(files, SkillFile{ + Path: item["path"].(string), + Contents: []byte(item["contents"].(string)), + Mode: mode, + }) + } + return files +} + +func githubSkillFiles(tc goldenCase) []SkillFile { + bundle := tc.Options["githubBundle"].(map[string]any) + root := strings.Trim(bundle["path"].(string), "/") + "/" + github := tc.Given["github"].(map[string]any) + rawFiles := github["files"].(map[string]any) + files := []SkillFile{} + for path, contents := range rawFiles { + if strings.HasPrefix(path, root) { + files = append(files, SkillFile{Path: strings.TrimPrefix(path, root), Contents: []byte(contents.(string))}) + } + } + return files +} + +func startGitHubFixture(t *testing.T, github map[string]any) { + owner := github["owner"].(string) + repo := github["repo"].(string) + ref := github["ref"].(string) + commit := github["commit"].(string) + treeSha := github["treeSha"].(string) + files := github["files"].(map[string]any) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/" + owner + "/" + repo + "/commits/" + ref: + writeResponseJSON(t, w, map[string]any{"sha": commit, "commit": map[string]any{"tree": map[string]any{"sha": treeSha}}}) + case "/repos/" + owner + "/" + repo + "/git/trees/" + treeSha: + tree := []map[string]string{} + for path := range files { + mode := "100644" + if strings.HasSuffix(path, ".sh") { + mode = "100755" + } + tree = append(tree, map[string]string{"path": path, "type": "blob", "mode": mode}) + } + writeResponseJSON(t, w, map[string]any{"tree": tree}) + default: + prefix := "/" + owner + "/" + repo + "/" + commit + "/" + if strings.HasPrefix(r.URL.Path, prefix) { + path := strings.TrimPrefix(r.URL.Path, prefix) + if contents, ok := files[path]; ok { + _, _ = w.Write([]byte(contents.(string))) + return + } + } + http.NotFound(w, r) + } + })) + t.Setenv("KITUP_GITHUB_API_BASE_URL", server.URL) + t.Setenv("KITUP_GITHUB_RAW_BASE_URL", server.URL) + t.Cleanup(server.Close) +} + +func writeResponseJSON(t *testing.T, w http.ResponseWriter, value any) { + w.Header().Set("content-type", "application/json") + data, err := json.Marshal(value) + must(t, err) + _, _ = w.Write(data) +} + +func boolValue(value any) bool { + boolean, _ := value.(bool) + return boolean +} + +func stringValue(value any) string { + text, _ := value.(string) + return text +} + +func caseSkillBundleDir(tc goldenCase) string { + if dir, ok := tc.Options["skillBundleDir"].(string); ok { + return repoPathFromCase(dir) + } + return repoPathFromCase("testdata/skills/" + tc.Options["skillName"].(string)) +} + +func repoPathFromCase(path string) string { + if filepath.IsAbs(path) { + return path + } + return filepath.Join(os.Getenv(testRepoRootEnv), path) +} + +func hostIDs(hosts []Host) []string { + ids := []string{} + for _, host := range hosts { + ids = append(ids, host.ID) + } + return ids +} + +func stringSlice(value any) []string { + if value == nil { + return nil + } + var out []string + for _, item := range value.([]any) { + out = append(out, item.(string)) + } + return out +} + +func expandValue(value any, home, workspace string) any { + switch value := value.(type) { + case string: + return expandString(value, home, workspace) + case []any: + out := make([]any, len(value)) + for i, item := range value { + out[i] = expandValue(item, home, workspace) + } + return out + case map[string]any: + out := map[string]any{} + for key, item := range value { + out[expandString(key, home, workspace)] = expandValue(item, home, workspace) + } + return out + default: + return value + } +} + +func expandString(value, home, workspace string) string { + value = strings.ReplaceAll(value, "$HOME", home) + return strings.ReplaceAll(value, "$WORKSPACE", workspace) +} + +func equal(t *testing.T, got, want any) { + t.Helper() + got = normalize(got) + want = normalize(want) + if !reflect.DeepEqual(got, want) { + g, _ := json.MarshalIndent(got, "", " ") + w, _ := json.MarshalIndent(want, "", " ") + t.Fatalf("got:\n%s\nwant:\n%s", g, w) + } +} + +func normalize(value any) any { + data, _ := json.Marshal(value) + var out any + _ = json.Unmarshal(data, &out) + return out +} + +func readJSON(t *testing.T, path string, out any) { + data, err := os.ReadFile(path) + must(t, err) + must(t, json.Unmarshal(data, out)) +} + +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} diff --git a/ts/src/hosts.generated.ts b/ts/src/hosts.generated.ts index 419abb5..71f295c 100644 --- a/ts/src/hosts.generated.ts +++ b/ts/src/hosts.generated.ts @@ -2,4 +2,4 @@ // prettier-ignore export const defaultHostsSpecJson = - "{\"$schema\":\"./hosts.schema.json\",\"schemaVersion\":1,\"hosts\":[{\"id\":\"adal\",\"displayName\":\"AdaL\",\"projectSkillsDirs\":[\".adal/skills\"],\"userSkillsDirs\":[\"~/.adal/skills\"],\"detect\":[\"~/.adal\"],\"status\":\"community\"},{\"id\":\"aider-desk\",\"displayName\":\"AiderDesk\",\"projectSkillsDirs\":[\".aider-desk/skills\"],\"userSkillsDirs\":[\"~/.aider-desk/skills\"],\"detect\":[\"~/.aider-desk\"],\"status\":\"community\"},{\"id\":\"amp\",\"displayName\":\"Amp\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\"~/.config/amp\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"antigravity\",\"displayName\":\"Antigravity\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity/skills\"],\"detect\":[\"~/.gemini/antigravity\"],\"status\":\"community\"},{\"id\":\"antigravity-cli\",\"displayName\":\"Antigravity CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity-cli/skills\"],\"detect\":[\"~/.gemini/antigravity-cli\"],\"status\":\"community\"},{\"id\":\"astrbot\",\"displayName\":\"AstrBot\",\"projectSkillsDirs\":[\"data/skills\"],\"userSkillsDirs\":[\"~/.astrbot/data/skills\"],\"detect\":[\"~/.astrbot\",\"data/skills\",\"~/.astrbot/data\"],\"status\":\"community\"},{\"id\":\"augment\",\"displayName\":\"Augment\",\"projectSkillsDirs\":[\".augment/skills\"],\"userSkillsDirs\":[\"~/.augment/skills\"],\"detect\":[\"~/.augment\"],\"status\":\"community\"},{\"id\":\"autohand-code\",\"displayName\":\"Autohand Code CLI\",\"projectSkillsDirs\":[\".autohand/skills\"],\"userSkillsDirs\":[\"~/.autohand/skills\"],\"detect\":[\"~/.autohand\"],\"status\":\"community\"},{\"id\":\"bob\",\"displayName\":\"IBM Bob\",\"projectSkillsDirs\":[\".bob/skills\"],\"userSkillsDirs\":[\"~/.bob/skills\"],\"detect\":[\"~/.bob\"],\"status\":\"community\"},{\"id\":\"claude-code\",\"displayName\":\"Claude Code\",\"projectSkillsDirs\":[\".claude/skills\"],\"userSkillsDirs\":[\"~/.claude/skills\"],\"detect\":[\"~/.claude\"],\"status\":\"verified\"},{\"id\":\"cline\",\"displayName\":\"Cline\",\"projectSkillsDirs\":[\".agents/skills\",\".cline/skills\",\".clinerules/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.cline/skills\"],\"detect\":[\"~/.cline\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"codearts-agent\",\"displayName\":\"CodeArts Agent\",\"projectSkillsDirs\":[\".codeartsdoer/skills\"],\"userSkillsDirs\":[\"~/.codeartsdoer/skills\"],\"detect\":[\"~/.codeartsdoer\"],\"status\":\"community\"},{\"id\":\"codebuddy\",\"displayName\":\"CodeBuddy\",\"projectSkillsDirs\":[\".codebuddy/skills\"],\"userSkillsDirs\":[\"~/.codebuddy/skills\"],\"detect\":[\"~/.codebuddy\",\".codebuddy\"],\"status\":\"community\"},{\"id\":\"codemaker\",\"displayName\":\"Codemaker\",\"projectSkillsDirs\":[\".codemaker/skills\"],\"userSkillsDirs\":[\"~/.codemaker/skills\"],\"detect\":[\"~/.codemaker\"],\"status\":\"community\"},{\"id\":\"codestudio\",\"displayName\":\"Code Studio\",\"projectSkillsDirs\":[\".codestudio/skills\"],\"userSkillsDirs\":[\"~/.codestudio/skills\"],\"detect\":[\"~/.codestudio\"],\"status\":\"community\"},{\"id\":\"codex\",\"displayName\":\"Codex\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.codex/skills\"],\"detect\":[\"~/.codex\",\"~/.agents/skills\",\"~/.agents\"],\"status\":\"verified\",\"notes\":[\"Keep both ~/.agents/skills and ~/.codex/skills for compatibility.\"]},{\"id\":\"command-code\",\"displayName\":\"Command Code\",\"projectSkillsDirs\":[\".commandcode/skills\"],\"userSkillsDirs\":[\"~/.commandcode/skills\"],\"detect\":[\"~/.commandcode\"],\"status\":\"community\"},{\"id\":\"continue\",\"displayName\":\"Continue\",\"projectSkillsDirs\":[\".continue/skills\"],\"userSkillsDirs\":[\"~/.continue/skills\"],\"detect\":[\"~/.continue\",\".continue\"],\"status\":\"community\"},{\"id\":\"cortex\",\"displayName\":\"Cortex Code\",\"projectSkillsDirs\":[\".cortex/skills\"],\"userSkillsDirs\":[\"~/.snowflake/cortex/skills\"],\"detect\":[\"~/.snowflake/cortex\"],\"status\":\"community\"},{\"id\":\"crush\",\"displayName\":\"Crush\",\"projectSkillsDirs\":[\".crush/skills\"],\"userSkillsDirs\":[\"~/.config/crush/skills\"],\"detect\":[\"~/.config/crush\"],\"status\":\"community\"},{\"id\":\"cursor\",\"displayName\":\"Cursor\",\"projectSkillsDirs\":[\".agents/skills\",\".cursor/skills\"],\"userSkillsDirs\":[\"~/.cursor/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.cursor\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"deepagents\",\"displayName\":\"Deep Agents\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.deepagents/agent/skills\"],\"detect\":[\"~/.deepagents\",\"~/.deepagents/agent\"],\"status\":\"community\"},{\"id\":\"devin\",\"displayName\":\"Devin for Terminal\",\"projectSkillsDirs\":[\".devin/skills\"],\"userSkillsDirs\":[\"~/.config/devin/skills\"],\"detect\":[\"~/.config/devin\"],\"status\":\"community\"},{\"id\":\"dexto\",\"displayName\":\"Dexto\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.dexto\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"droid\",\"displayName\":\"Droid\",\"projectSkillsDirs\":[\".factory/skills\"],\"userSkillsDirs\":[\"~/.factory/skills\"],\"detect\":[\"~/.factory\"],\"status\":\"community\"},{\"id\":\"eve\",\"displayName\":\"Eve\",\"projectSkillsDirs\":[\"agent/skills\"],\"userSkillsDirs\":[],\"detect\":[\"agent\",\"package.json\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\",\"Detect from Eve project shape; no global skill directory.\"]},{\"id\":\"firebender\",\"displayName\":\"Firebender\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.firebender/skills\"],\"detect\":[\"~/.firebender\"],\"status\":\"community\"},{\"id\":\"forgecode\",\"displayName\":\"ForgeCode\",\"projectSkillsDirs\":[\".forge/skills\"],\"userSkillsDirs\":[\"~/.forge/skills\"],\"detect\":[\"~/.forge\"],\"status\":\"community\"},{\"id\":\"gemini-cli\",\"displayName\":\"Gemini CLI\",\"projectSkillsDirs\":[\".agents/skills\",\".gemini/skills\"],\"userSkillsDirs\":[\"~/.gemini/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.gemini\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"github-copilot\",\"displayName\":\"GitHub Copilot\",\"projectSkillsDirs\":[\".agents/skills\",\".github/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.copilot/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.copilot\",\"~/.agents\",\"~/.claude\"],\"status\":\"documented\"},{\"id\":\"goose\",\"displayName\":\"Goose\",\"projectSkillsDirs\":[\".goose/skills\"],\"userSkillsDirs\":[\"~/.config/goose/skills\"],\"detect\":[\"~/.config/goose\"],\"status\":\"community\"},{\"id\":\"hermes-agent\",\"displayName\":\"Hermes Agent\",\"projectSkillsDirs\":[\".hermes/skills\"],\"userSkillsDirs\":[\"~/.hermes/skills\"],\"detect\":[\"~/.hermes\"],\"status\":\"community\"},{\"id\":\"iflow-cli\",\"displayName\":\"iFlow CLI\",\"projectSkillsDirs\":[\".iflow/skills\"],\"userSkillsDirs\":[\"~/.iflow/skills\"],\"detect\":[\"~/.iflow\"],\"status\":\"community\"},{\"id\":\"inference-sh\",\"displayName\":\"inference.sh\",\"projectSkillsDirs\":[\".inferencesh/skills\"],\"userSkillsDirs\":[\"~/.inferencesh/skills\"],\"detect\":[\"~/.inferencesh\"],\"status\":\"community\"},{\"id\":\"jazz\",\"displayName\":\"Jazz\",\"projectSkillsDirs\":[\".jazz/skills\"],\"userSkillsDirs\":[\"~/.jazz/skills\"],\"detect\":[\"~/.jazz\",\".jazz\"],\"status\":\"community\"},{\"id\":\"junie\",\"displayName\":\"Junie\",\"projectSkillsDirs\":[\".junie/skills\"],\"userSkillsDirs\":[\"~/.junie/skills\"],\"detect\":[\"~/.junie\"],\"status\":\"community\"},{\"id\":\"kilo\",\"displayName\":\"Kilo Code\",\"projectSkillsDirs\":[\".kilocode/skills\"],\"userSkillsDirs\":[\"~/.kilocode/skills\"],\"detect\":[\"~/.kilocode\"],\"status\":\"community\"},{\"id\":\"kimi-cli\",\"displayName\":\"Kimi Code CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.config/agents\",\"~/.kimi-code\",\"~/.kimi\",\"~/.agents\"],\"status\":\"community\",\"notes\":[\"kimi-code-cli is an alias for the same Kimi Code CLI path family.\"],\"aliases\":[\"kimi-code-cli\"]},{\"id\":\"kiro-cli\",\"displayName\":\"Kiro CLI\",\"projectSkillsDirs\":[\".kiro/skills\"],\"userSkillsDirs\":[\"~/.kiro/skills\"],\"detect\":[\"~/.kiro\"],\"status\":\"community\"},{\"id\":\"kode\",\"displayName\":\"Kode\",\"projectSkillsDirs\":[\".kode/skills\"],\"userSkillsDirs\":[\"~/.kode/skills\"],\"detect\":[\"~/.kode\"],\"status\":\"community\"},{\"id\":\"lingma\",\"displayName\":\"Lingma\",\"projectSkillsDirs\":[\".lingma/skills\"],\"userSkillsDirs\":[\"~/.lingma/skills\"],\"detect\":[\"~/.lingma\"],\"status\":\"community\"},{\"id\":\"loaf\",\"displayName\":\"Loaf\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.loaf\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"mcpjam\",\"displayName\":\"MCPJam\",\"projectSkillsDirs\":[\".mcpjam/skills\"],\"userSkillsDirs\":[\"~/.mcpjam/skills\"],\"detect\":[\"~/.mcpjam\"],\"status\":\"community\"},{\"id\":\"mistral-vibe\",\"displayName\":\"Mistral Vibe\",\"projectSkillsDirs\":[\".vibe/skills\"],\"userSkillsDirs\":[\"~/.vibe/skills\"],\"detect\":[\"~/.vibe\"],\"status\":\"community\"},{\"id\":\"moxby\",\"displayName\":\"Moxby\",\"projectSkillsDirs\":[\".moxby/skills\"],\"userSkillsDirs\":[\"~/.moxby/skills\"],\"detect\":[\"~/.moxby\"],\"status\":\"community\"},{\"id\":\"mux\",\"displayName\":\"Mux\",\"projectSkillsDirs\":[\".mux/skills\"],\"userSkillsDirs\":[\"~/.mux/skills\"],\"detect\":[\"~/.mux\"],\"status\":\"community\"},{\"id\":\"neovate\",\"displayName\":\"Neovate\",\"projectSkillsDirs\":[\".neovate/skills\"],\"userSkillsDirs\":[\"~/.neovate/skills\"],\"detect\":[\"~/.neovate\"],\"status\":\"community\"},{\"id\":\"ona\",\"displayName\":\"Ona\",\"projectSkillsDirs\":[\".ona/skills\"],\"userSkillsDirs\":[\"~/.ona/skills\"],\"detect\":[\"~/.ona\"],\"status\":\"community\"},{\"id\":\"openclaw\",\"displayName\":\"OpenClaw\",\"projectSkillsDirs\":[\"skills\"],\"userSkillsDirs\":[\"~/.openclaw/skills\"],\"detect\":[\"~/.openclaw\",\"~/.clawdbot\",\"~/.moltbot\"],\"status\":\"community\"},{\"id\":\"opencode\",\"displayName\":\"OpenCode\",\"projectSkillsDirs\":[\".agents/skills\",\".opencode/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.config/opencode/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.config/opencode\",\"~/.agents\",\"~/.claude\"],\"status\":\"verified\"},{\"id\":\"openhands\",\"displayName\":\"OpenHands\",\"projectSkillsDirs\":[\".openhands/skills\"],\"userSkillsDirs\":[\"~/.openhands/skills\"],\"detect\":[\"~/.openhands\"],\"status\":\"community\"},{\"id\":\"pi\",\"displayName\":\"Pi\",\"projectSkillsDirs\":[\".pi/skills\"],\"userSkillsDirs\":[\"~/.pi/agent/skills\"],\"detect\":[\"~/.pi/agent\"],\"status\":\"community\"},{\"id\":\"pochi\",\"displayName\":\"Pochi\",\"projectSkillsDirs\":[\".pochi/skills\"],\"userSkillsDirs\":[\"~/.pochi/skills\"],\"detect\":[\"~/.pochi\"],\"status\":\"community\"},{\"id\":\"promptscript\",\"displayName\":\"PromptScript\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[],\"detect\":[\".promptscript\",\"promptscript.yaml\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\"]},{\"id\":\"qoder\",\"displayName\":\"Qoder\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder/skills\"],\"detect\":[\"~/.qoder\"],\"status\":\"community\"},{\"id\":\"qoder-cn\",\"displayName\":\"Qoder CN\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder-cn/skills\"],\"detect\":[\"~/.qoder-cn\"],\"status\":\"community\"},{\"id\":\"qwen-code\",\"displayName\":\"Qwen Code\",\"projectSkillsDirs\":[\".qwen/skills\"],\"userSkillsDirs\":[\"~/.qwen/skills\"],\"detect\":[\"~/.qwen\"],\"status\":\"community\"},{\"id\":\"reasonix\",\"displayName\":\"Reasonix\",\"projectSkillsDirs\":[\".reasonix/skills\"],\"userSkillsDirs\":[\"~/.reasonix/skills\"],\"detect\":[\"~/.reasonix\"],\"status\":\"community\"},{\"id\":\"replit\",\"displayName\":\"Replit\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\".replit\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"roo\",\"displayName\":\"Roo Code\",\"aliases\":[\"roo-code\"],\"projectSkillsDirs\":[\".roo/skills\",\".agents/skills\"],\"userSkillsDirs\":[\"~/.roo/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.roo\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"rovodev\",\"displayName\":\"Rovo Dev\",\"projectSkillsDirs\":[\".rovodev/skills\"],\"userSkillsDirs\":[\"~/.rovodev/skills\"],\"detect\":[\"~/.rovodev\"],\"status\":\"community\"},{\"id\":\"tabnine-cli\",\"displayName\":\"Tabnine CLI\",\"projectSkillsDirs\":[\".tabnine/agent/skills\"],\"userSkillsDirs\":[\"~/.tabnine/agent/skills\"],\"detect\":[\"~/.tabnine\",\"~/.tabnine/agent\"],\"status\":\"community\"},{\"id\":\"terramind\",\"displayName\":\"Terramind\",\"projectSkillsDirs\":[\".terramind/skills\"],\"userSkillsDirs\":[\"~/.terramind/skills\"],\"detect\":[\"~/.terramind\"],\"status\":\"community\"},{\"id\":\"tinycloud\",\"displayName\":\"Tinycloud\",\"projectSkillsDirs\":[\".tinycloud/skills\"],\"userSkillsDirs\":[\"~/.tinycloud/skills\"],\"detect\":[\"~/.tinycloud\"],\"status\":\"community\"},{\"id\":\"trae\",\"displayName\":\"Trae\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae/skills\"],\"detect\":[\"~/.trae\"],\"status\":\"community\"},{\"id\":\"trae-cn\",\"displayName\":\"Trae CN\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae-cn/skills\"],\"detect\":[\"~/.trae-cn\"],\"status\":\"community\"},{\"id\":\"universal\",\"displayName\":\"Universal\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.config/agents/skills\"],\"detect\":[\"~/.agents\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"warp\",\"displayName\":\"Warp\",\"projectSkillsDirs\":[\".agents/skills\",\".warp/skills\",\".claude/skills\",\".codex/skills\",\".cursor/skills\",\".gemini/skills\",\".copilot/skills\",\".factory/skills\",\".github/skills\",\".opencode/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.warp/skills\",\"~/.claude/skills\",\"~/.codex/skills\",\"~/.cursor/skills\",\"~/.gemini/skills\",\"~/.copilot/skills\",\"~/.factory/skills\",\"~/.github/skills\",\"~/.opencode/skills\"],\"detect\":[\"~/.warp\",\"~/.agents\",\"~/.claude\",\"~/.codex\",\"~/.cursor\",\"~/.gemini\",\"~/.copilot\",\"~/.factory\",\"~/.github\",\"~/.opencode\"],\"status\":\"documented\"},{\"id\":\"windsurf\",\"displayName\":\"Windsurf\",\"projectSkillsDirs\":[\".windsurf/skills\"],\"userSkillsDirs\":[\"~/.codeium/windsurf/skills\"],\"detect\":[\"~/.codeium/windsurf\"],\"status\":\"community\"},{\"id\":\"zed\",\"displayName\":\"Zed\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.config/zed\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"zencoder\",\"displayName\":\"Zencoder\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"},{\"id\":\"zenflow\",\"displayName\":\"Zenflow\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"}]}"; + "{\"$schema\":\"./hosts.schema.json\",\"schemaVersion\":1,\"hosts\":[{\"id\":\"adal\",\"displayName\":\"AdaL\",\"projectSkillsDirs\":[\".adal/skills\"],\"userSkillsDirs\":[\"~/.adal/skills\"],\"detect\":[\"~/.adal\"],\"status\":\"community\"},{\"id\":\"aider-desk\",\"displayName\":\"AiderDesk\",\"projectSkillsDirs\":[\".aider-desk/skills\"],\"userSkillsDirs\":[\"~/.aider-desk/skills\"],\"detect\":[\"~/.aider-desk\"],\"status\":\"community\"},{\"id\":\"amp\",\"displayName\":\"Amp\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\"~/.config/amp\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"antigravity\",\"displayName\":\"Antigravity\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity/skills\"],\"detect\":[\"~/.gemini/antigravity\"],\"status\":\"community\"},{\"id\":\"antigravity-cli\",\"displayName\":\"Antigravity CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.gemini/antigravity-cli/skills\"],\"detect\":[\"~/.gemini/antigravity-cli\"],\"status\":\"community\"},{\"id\":\"astrbot\",\"displayName\":\"AstrBot\",\"projectSkillsDirs\":[\"data/skills\"],\"userSkillsDirs\":[\"~/.astrbot/data/skills\"],\"detect\":[\"~/.astrbot\",\"~/.astrbot/data\"],\"status\":\"community\"},{\"id\":\"augment\",\"displayName\":\"Augment\",\"projectSkillsDirs\":[\".augment/skills\"],\"userSkillsDirs\":[\"~/.augment/skills\"],\"detect\":[\"~/.augment\"],\"status\":\"community\"},{\"id\":\"autohand-code\",\"displayName\":\"Autohand Code CLI\",\"projectSkillsDirs\":[\".autohand/skills\"],\"userSkillsDirs\":[\"~/.autohand/skills\"],\"detect\":[\"~/.autohand\"],\"status\":\"community\"},{\"id\":\"bob\",\"displayName\":\"IBM Bob\",\"projectSkillsDirs\":[\".bob/skills\"],\"userSkillsDirs\":[\"~/.bob/skills\"],\"detect\":[\"~/.bob\"],\"status\":\"community\"},{\"id\":\"claude-code\",\"displayName\":\"Claude Code\",\"projectSkillsDirs\":[\".claude/skills\"],\"userSkillsDirs\":[\"~/.claude/skills\"],\"detect\":[\"~/.claude\"],\"status\":\"verified\"},{\"id\":\"cline\",\"displayName\":\"Cline\",\"projectSkillsDirs\":[\".agents/skills\",\".cline/skills\",\".clinerules/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.cline/skills\"],\"detect\":[\"~/.cline\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"codearts-agent\",\"displayName\":\"CodeArts Agent\",\"projectSkillsDirs\":[\".codeartsdoer/skills\"],\"userSkillsDirs\":[\"~/.codeartsdoer/skills\"],\"detect\":[\"~/.codeartsdoer\"],\"status\":\"community\"},{\"id\":\"codebuddy\",\"displayName\":\"CodeBuddy\",\"projectSkillsDirs\":[\".codebuddy/skills\"],\"userSkillsDirs\":[\"~/.codebuddy/skills\"],\"detect\":[\"~/.codebuddy\",\".codebuddy\"],\"status\":\"community\"},{\"id\":\"codemaker\",\"displayName\":\"Codemaker\",\"projectSkillsDirs\":[\".codemaker/skills\"],\"userSkillsDirs\":[\"~/.codemaker/skills\"],\"detect\":[\"~/.codemaker\"],\"status\":\"community\"},{\"id\":\"codestudio\",\"displayName\":\"Code Studio\",\"projectSkillsDirs\":[\".codestudio/skills\"],\"userSkillsDirs\":[\"~/.codestudio/skills\"],\"detect\":[\"~/.codestudio\"],\"status\":\"community\"},{\"id\":\"codex\",\"displayName\":\"Codex\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.codex/skills\"],\"detect\":[\"~/.codex\",\"~/.agents/skills\",\"~/.agents\"],\"status\":\"verified\",\"notes\":[\"Keep both ~/.agents/skills and ~/.codex/skills for compatibility.\"]},{\"id\":\"command-code\",\"displayName\":\"Command Code\",\"projectSkillsDirs\":[\".commandcode/skills\"],\"userSkillsDirs\":[\"~/.commandcode/skills\"],\"detect\":[\"~/.commandcode\"],\"status\":\"community\"},{\"id\":\"continue\",\"displayName\":\"Continue\",\"projectSkillsDirs\":[\".continue/skills\"],\"userSkillsDirs\":[\"~/.continue/skills\"],\"detect\":[\"~/.continue\",\".continue\"],\"status\":\"community\"},{\"id\":\"cortex\",\"displayName\":\"Cortex Code\",\"projectSkillsDirs\":[\".cortex/skills\"],\"userSkillsDirs\":[\"~/.snowflake/cortex/skills\"],\"detect\":[\"~/.snowflake/cortex\"],\"status\":\"community\"},{\"id\":\"crush\",\"displayName\":\"Crush\",\"projectSkillsDirs\":[\".crush/skills\"],\"userSkillsDirs\":[\"~/.config/crush/skills\"],\"detect\":[\"~/.config/crush\"],\"status\":\"community\"},{\"id\":\"cursor\",\"displayName\":\"Cursor\",\"projectSkillsDirs\":[\".agents/skills\",\".cursor/skills\"],\"userSkillsDirs\":[\"~/.cursor/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.cursor\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"deepagents\",\"displayName\":\"Deep Agents\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.deepagents/agent/skills\"],\"detect\":[\"~/.deepagents\",\"~/.deepagents/agent\"],\"status\":\"community\"},{\"id\":\"devin\",\"displayName\":\"Devin for Terminal\",\"projectSkillsDirs\":[\".devin/skills\"],\"userSkillsDirs\":[\"~/.config/devin/skills\"],\"detect\":[\"~/.config/devin\"],\"status\":\"community\"},{\"id\":\"dexto\",\"displayName\":\"Dexto\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.dexto\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"droid\",\"displayName\":\"Droid\",\"projectSkillsDirs\":[\".factory/skills\"],\"userSkillsDirs\":[\"~/.factory/skills\"],\"detect\":[\"~/.factory\"],\"status\":\"community\"},{\"id\":\"eve\",\"displayName\":\"Eve\",\"projectSkillsDirs\":[\"agent/skills\"],\"userSkillsDirs\":[],\"detect\":[\"package.json\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\",\"Explicit selection only; generic project paths are not safe detection evidence.\"]},{\"id\":\"firebender\",\"displayName\":\"Firebender\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.firebender/skills\"],\"detect\":[\"~/.firebender\"],\"status\":\"community\"},{\"id\":\"forgecode\",\"displayName\":\"ForgeCode\",\"projectSkillsDirs\":[\".forge/skills\"],\"userSkillsDirs\":[\"~/.forge/skills\"],\"detect\":[\"~/.forge\"],\"status\":\"community\"},{\"id\":\"gemini-cli\",\"displayName\":\"Gemini CLI\",\"projectSkillsDirs\":[\".agents/skills\",\".gemini/skills\"],\"userSkillsDirs\":[\"~/.gemini/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.gemini\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"github-copilot\",\"displayName\":\"GitHub Copilot\",\"projectSkillsDirs\":[\".agents/skills\",\".github/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.copilot/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.copilot\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"goose\",\"displayName\":\"Goose\",\"projectSkillsDirs\":[\".goose/skills\"],\"userSkillsDirs\":[\"~/.config/goose/skills\"],\"detect\":[\"~/.config/goose\"],\"status\":\"community\"},{\"id\":\"hermes-agent\",\"displayName\":\"Hermes Agent\",\"projectSkillsDirs\":[\".hermes/skills\"],\"userSkillsDirs\":[\"~/.hermes/skills\"],\"detect\":[\"~/.hermes\"],\"status\":\"community\"},{\"id\":\"iflow-cli\",\"displayName\":\"iFlow CLI\",\"projectSkillsDirs\":[\".iflow/skills\"],\"userSkillsDirs\":[\"~/.iflow/skills\"],\"detect\":[\"~/.iflow\"],\"status\":\"community\"},{\"id\":\"inference-sh\",\"displayName\":\"inference.sh\",\"projectSkillsDirs\":[\".inferencesh/skills\"],\"userSkillsDirs\":[\"~/.inferencesh/skills\"],\"detect\":[\"~/.inferencesh\"],\"status\":\"community\"},{\"id\":\"jazz\",\"displayName\":\"Jazz\",\"projectSkillsDirs\":[\".jazz/skills\"],\"userSkillsDirs\":[\"~/.jazz/skills\"],\"detect\":[\"~/.jazz\",\".jazz\"],\"status\":\"community\"},{\"id\":\"junie\",\"displayName\":\"Junie\",\"projectSkillsDirs\":[\".junie/skills\"],\"userSkillsDirs\":[\"~/.junie/skills\"],\"detect\":[\"~/.junie\"],\"status\":\"community\"},{\"id\":\"kilo\",\"displayName\":\"Kilo Code\",\"projectSkillsDirs\":[\".kilocode/skills\"],\"userSkillsDirs\":[\"~/.kilocode/skills\"],\"detect\":[\"~/.kilocode\"],\"status\":\"community\"},{\"id\":\"kimi-cli\",\"displayName\":\"Kimi Code CLI\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.config/agents\",\"~/.kimi-code\",\"~/.kimi\",\"~/.agents\"],\"status\":\"community\",\"notes\":[\"kimi-code-cli is an alias for the same Kimi Code CLI path family.\"],\"aliases\":[\"kimi-code-cli\"]},{\"id\":\"kiro-cli\",\"displayName\":\"Kiro CLI\",\"projectSkillsDirs\":[\".kiro/skills\"],\"userSkillsDirs\":[\"~/.kiro/skills\"],\"detect\":[\"~/.kiro\"],\"status\":\"community\"},{\"id\":\"kode\",\"displayName\":\"Kode\",\"projectSkillsDirs\":[\".kode/skills\"],\"userSkillsDirs\":[\"~/.kode/skills\"],\"detect\":[\"~/.kode\"],\"status\":\"community\"},{\"id\":\"lingma\",\"displayName\":\"Lingma\",\"projectSkillsDirs\":[\".lingma/skills\"],\"userSkillsDirs\":[\"~/.lingma/skills\"],\"detect\":[\"~/.lingma\"],\"status\":\"community\"},{\"id\":\"loaf\",\"displayName\":\"Loaf\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.loaf\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"mcpjam\",\"displayName\":\"MCPJam\",\"projectSkillsDirs\":[\".mcpjam/skills\"],\"userSkillsDirs\":[\"~/.mcpjam/skills\"],\"detect\":[\"~/.mcpjam\"],\"status\":\"community\"},{\"id\":\"mistral-vibe\",\"displayName\":\"Mistral Vibe\",\"projectSkillsDirs\":[\".vibe/skills\"],\"userSkillsDirs\":[\"~/.vibe/skills\"],\"detect\":[\"~/.vibe\"],\"status\":\"community\"},{\"id\":\"moxby\",\"displayName\":\"Moxby\",\"projectSkillsDirs\":[\".moxby/skills\"],\"userSkillsDirs\":[\"~/.moxby/skills\"],\"detect\":[\"~/.moxby\"],\"status\":\"community\"},{\"id\":\"mux\",\"displayName\":\"Mux\",\"projectSkillsDirs\":[\".mux/skills\"],\"userSkillsDirs\":[\"~/.mux/skills\"],\"detect\":[\"~/.mux\"],\"status\":\"community\"},{\"id\":\"neovate\",\"displayName\":\"Neovate\",\"projectSkillsDirs\":[\".neovate/skills\"],\"userSkillsDirs\":[\"~/.neovate/skills\"],\"detect\":[\"~/.neovate\"],\"status\":\"community\"},{\"id\":\"ona\",\"displayName\":\"Ona\",\"projectSkillsDirs\":[\".ona/skills\"],\"userSkillsDirs\":[\"~/.ona/skills\"],\"detect\":[\"~/.ona\"],\"status\":\"community\"},{\"id\":\"openclaw\",\"displayName\":\"OpenClaw\",\"projectSkillsDirs\":[\"skills\"],\"userSkillsDirs\":[\"~/.openclaw/skills\"],\"detect\":[\"~/.openclaw\",\"~/.clawdbot\",\"~/.moltbot\"],\"status\":\"community\"},{\"id\":\"opencode\",\"displayName\":\"OpenCode\",\"projectSkillsDirs\":[\".agents/skills\",\".opencode/skills\",\".claude/skills\"],\"userSkillsDirs\":[\"~/.config/opencode/skills\",\"~/.agents/skills\",\"~/.claude/skills\"],\"detect\":[\"~/.config/opencode\",\"~/.agents\"],\"status\":\"verified\"},{\"id\":\"openhands\",\"displayName\":\"OpenHands\",\"projectSkillsDirs\":[\".openhands/skills\"],\"userSkillsDirs\":[\"~/.openhands/skills\"],\"detect\":[\"~/.openhands\"],\"status\":\"community\"},{\"id\":\"pi\",\"displayName\":\"Pi\",\"projectSkillsDirs\":[\".pi/skills\"],\"userSkillsDirs\":[\"~/.pi/agent/skills\"],\"detect\":[\"~/.pi/agent\"],\"status\":\"community\"},{\"id\":\"pochi\",\"displayName\":\"Pochi\",\"projectSkillsDirs\":[\".pochi/skills\"],\"userSkillsDirs\":[\"~/.pochi/skills\"],\"detect\":[\"~/.pochi\"],\"status\":\"community\"},{\"id\":\"promptscript\",\"displayName\":\"PromptScript\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[],\"detect\":[\".promptscript\",\"promptscript.yaml\"],\"status\":\"community\",\"notes\":[\"Project-only host; userSkillsDirs is intentionally empty.\"]},{\"id\":\"qoder\",\"displayName\":\"Qoder\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder/skills\"],\"detect\":[\"~/.qoder\"],\"status\":\"community\"},{\"id\":\"qoder-cn\",\"displayName\":\"Qoder CN\",\"projectSkillsDirs\":[\".qoder/skills\"],\"userSkillsDirs\":[\"~/.qoder-cn/skills\"],\"detect\":[\"~/.qoder-cn\"],\"status\":\"community\"},{\"id\":\"qwen-code\",\"displayName\":\"Qwen Code\",\"projectSkillsDirs\":[\".qwen/skills\"],\"userSkillsDirs\":[\"~/.qwen/skills\"],\"detect\":[\"~/.qwen\"],\"status\":\"community\"},{\"id\":\"reasonix\",\"displayName\":\"Reasonix\",\"projectSkillsDirs\":[\".reasonix/skills\"],\"userSkillsDirs\":[\"~/.reasonix/skills\"],\"detect\":[\"~/.reasonix\"],\"status\":\"community\"},{\"id\":\"replit\",\"displayName\":\"Replit\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.config/agents/skills\"],\"detect\":[\".replit\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"roo\",\"displayName\":\"Roo Code\",\"aliases\":[\"roo-code\"],\"projectSkillsDirs\":[\".roo/skills\",\".agents/skills\"],\"userSkillsDirs\":[\"~/.roo/skills\",\"~/.agents/skills\"],\"detect\":[\"~/.roo\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"rovodev\",\"displayName\":\"Rovo Dev\",\"projectSkillsDirs\":[\".rovodev/skills\"],\"userSkillsDirs\":[\"~/.rovodev/skills\"],\"detect\":[\"~/.rovodev\"],\"status\":\"community\"},{\"id\":\"tabnine-cli\",\"displayName\":\"Tabnine CLI\",\"projectSkillsDirs\":[\".tabnine/agent/skills\"],\"userSkillsDirs\":[\"~/.tabnine/agent/skills\"],\"detect\":[\"~/.tabnine\",\"~/.tabnine/agent\"],\"status\":\"community\"},{\"id\":\"terramind\",\"displayName\":\"Terramind\",\"projectSkillsDirs\":[\".terramind/skills\"],\"userSkillsDirs\":[\"~/.terramind/skills\"],\"detect\":[\"~/.terramind\"],\"status\":\"community\"},{\"id\":\"tinycloud\",\"displayName\":\"Tinycloud\",\"projectSkillsDirs\":[\".tinycloud/skills\"],\"userSkillsDirs\":[\"~/.tinycloud/skills\"],\"detect\":[\"~/.tinycloud\"],\"status\":\"community\"},{\"id\":\"trae\",\"displayName\":\"Trae\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae/skills\"],\"detect\":[\"~/.trae\"],\"status\":\"community\"},{\"id\":\"trae-cn\",\"displayName\":\"Trae CN\",\"projectSkillsDirs\":[\".trae/skills\"],\"userSkillsDirs\":[\"~/.trae-cn/skills\"],\"detect\":[\"~/.trae-cn\"],\"status\":\"community\"},{\"id\":\"universal\",\"displayName\":\"Universal\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.config/agents/skills\"],\"detect\":[\"~/.agents\",\"~/.config/agents\"],\"status\":\"community\"},{\"id\":\"warp\",\"displayName\":\"Warp\",\"projectSkillsDirs\":[\".agents/skills\",\".warp/skills\",\".claude/skills\",\".codex/skills\",\".cursor/skills\",\".gemini/skills\",\".copilot/skills\",\".factory/skills\",\".github/skills\",\".opencode/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\",\"~/.warp/skills\",\"~/.claude/skills\",\"~/.codex/skills\",\"~/.cursor/skills\",\"~/.gemini/skills\",\"~/.copilot/skills\",\"~/.factory/skills\",\"~/.github/skills\",\"~/.opencode/skills\"],\"detect\":[\"~/.warp\",\"~/.agents\"],\"status\":\"documented\"},{\"id\":\"windsurf\",\"displayName\":\"Windsurf\",\"projectSkillsDirs\":[\".windsurf/skills\"],\"userSkillsDirs\":[\"~/.codeium/windsurf/skills\"],\"detect\":[\"~/.codeium/windsurf\"],\"status\":\"community\"},{\"id\":\"zed\",\"displayName\":\"Zed\",\"projectSkillsDirs\":[\".agents/skills\"],\"userSkillsDirs\":[\"~/.agents/skills\"],\"detect\":[\"~/.config/zed\",\"~/.agents\"],\"status\":\"community\"},{\"id\":\"zencoder\",\"displayName\":\"Zencoder\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"},{\"id\":\"zenflow\",\"displayName\":\"Zenflow\",\"projectSkillsDirs\":[\".zencoder/skills\"],\"userSkillsDirs\":[\"~/.zencoder/skills\"],\"detect\":[\"~/.zencoder\"],\"status\":\"community\"}]}"; diff --git a/ts/src/index.ts b/ts/src/index.ts index 0c74fb5..a9fe366 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -399,6 +399,10 @@ export async function loadHostSpec(hostsFile?: string): Promise { function validateHostSpec(spec: HostSpec) { for (const host of spec.hosts ?? []) { + const installDirs = new Set([ + ...(host.projectSkillsDirs ?? []), + ...(host.userSkillsDirs ?? []), + ]); for (const path of host.projectSkillsDirs ?? []) { if (!isProjectHostPath(path)) { throw new Error( @@ -419,6 +423,11 @@ function validateHostSpec(spec: HostSpec) { `invalid detect path ${JSON.stringify(path)} for host ${host.id}`, ); } + if (!isGenericDetectPath(path) && installDirs.has(path)) { + throw new Error( + `detect path is an install target for host ${host.id}: ${JSON.stringify(path)}`, + ); + } } } } @@ -441,7 +450,9 @@ function isSafeHostPath(path: string) { !path.includes("\0") && !path.includes("\\") && !path.includes(":") && - !path.split("/").some((segment) => segment === ".." || segment === "") + !path + .split("/") + .some((segment) => segment === "." || segment === ".." || segment === "") ); } @@ -488,13 +499,12 @@ export async function detectHosts( const detected: Host[] = []; for (const host of spec.hosts) { - const detectPath = host.detect[0]; - if ( - detectPath && - !isGenericDetectPath(detectPath) && - (await exists(expandHostPath(detectPath, home, cwd))) - ) { - detected.push(host); + for (const detectPath of host.detect) { + if (isGenericDetectPath(detectPath)) continue; + if (await exists(expandHostPath(detectPath, home, cwd))) { + detected.push(host); + break; + } } } @@ -744,6 +754,21 @@ export async function resolveInstallTargets( targets: TargetGroup[]; errors: TargetError[]; detectedHostIds: string[]; +}> { + return resolveInstallTargetsForLifecycle(options); +} + +async function resolveInstallTargetsForLifecycle( + options: BaseOptions & { + agents?: AgentSelector; + scope: Scope; + skillName: string; + }, + uninstallAppId?: string, +): Promise<{ + targets: TargetGroup[]; + errors: TargetError[]; + detectedHostIds: string[]; }> { if (!isValidSkillName(options.skillName)) { return { @@ -769,8 +794,25 @@ export async function resolveInstallTargets( const byTarget = new Map(); for (const host of selected) { - const root = await chooseScopePath(host, options.scope, home, cwd); - if (!root) { + const roots = uninstallAppId + ? await uninstallScopePaths( + host, + options.scope, + home, + cwd, + options.skillName, + uninstallAppId, + ) + : [ + await chooseScopePath( + host, + options.scope, + home, + cwd, + options.skillName, + ), + ].filter((root): root is string => Boolean(root)); + if (roots.length === 0) { errors.push({ hostId: host.id, skillName: options.skillName, @@ -779,14 +821,16 @@ export async function resolveInstallTargets( }); continue; } - const targetDir = join(root, options.skillName); - const group = byTarget.get(targetDir) ?? { - hostIds: [], - skillName: options.skillName, - targetDir, - }; - group.hostIds.push(host.id); - byTarget.set(targetDir, group); + for (const root of roots) { + const targetDir = join(root, options.skillName); + const group = byTarget.get(targetDir) ?? { + hostIds: [], + skillName: options.skillName, + targetDir, + }; + if (!group.hostIds.includes(host.id)) group.hostIds.push(host.id); + byTarget.set(targetDir, group); + } } const targets = [...byTarget.values()].sort((a, b) => @@ -1234,10 +1278,10 @@ export async function uninstallBundledSkill( }; } - const { targets, errors } = await resolveInstallTargets({ - ...options, - skillName: options.skillName, - }); + const { targets, errors } = await resolveInstallTargetsForLifecycle( + { ...options, skillName: options.skillName }, + options.appId, + ); const report: UninstallReport = { removed: [], skipped: [], @@ -1635,15 +1679,48 @@ async function chooseScopePath( scope: Scope, home: string, cwd: string, + skillName: string, ) { const paths = scopePaths(host, scope); + const existing: string[] = []; for (const path of paths) { const expanded = expandHostPath(path, home, cwd); - if (await exists(expanded)) return expanded; + if (await isDirectory(expanded)) existing.push(expanded); + } + for (const root of existing) { + const metadata = await readMetadata(join(root, skillName)); + if (metadata.value?.skillName === skillName) { + return root; + } } + if (existing[0]) return existing[0]; return paths[0] ? expandHostPath(paths[0], home, cwd) : undefined; } +async function uninstallScopePaths( + host: Host, + scope: Scope, + home: string, + cwd: string, + skillName: string, + appId: string, +) { + const owned: string[] = []; + for (const path of scopePaths(host, scope)) { + const root = expandHostPath(path, home, cwd); + const metadata = await readMetadata(join(root, skillName)); + if ( + metadata.value?.skillName === skillName && + metadata.value.appId === appId + ) { + owned.push(root); + } + } + if (owned.length > 0) return owned; + const fallback = await chooseScopePath(host, scope, home, cwd, skillName); + return fallback ? [fallback] : []; +} + function scopePaths(host: Host, scope: Scope) { return scope === "user" ? host.userSkillsDirs : host.projectSkillsDirs; } @@ -1686,12 +1763,17 @@ function skipName(name: string) { ); } +const GENERIC_DETECT_PATHS = new Set([ + "~/.agents", + "~/.agents/skills", + "~/.config/agents", + ".agents", + ".agents/skills", + "package.json", +]); + function isGenericDetectPath(path: string) { - return ( - path === "~/.agents" || - path === "~/.agents/skills" || - path === "~/.config/agents" - ); + return GENERIC_DETECT_PATHS.has(path); } async function exists(path: string) { @@ -1702,3 +1784,11 @@ async function exists(path: string) { return false; } } + +async function isDirectory(path: string) { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 2ad482d..371e797 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -26,6 +26,7 @@ import { planBundledSkill, parseInstallFlags, resolveInstallSelection, + resolveInstallTargets, resolveHosts, runBundledSkillInstall, uninstallBundledSkill, @@ -177,6 +178,22 @@ async function runCase(testCase: any, home: string, workspace: string) { return; } + if (testCase.operation === "resolve-install-targets") { + const result = await resolveInstallTargets({ + home, + cwd: workspace, + hostsFile, + agents: options.agents, + scope: options.scope, + skillName: options.skillName, + }); + assert.deepEqual( + result.targets, + expandValue(testCase.expected.targets, home, workspace), + ); + return; + } + if (testCase.operation === "run-install-workflow") { const output = { text: "",