diff --git a/AGENTS.md b/AGENTS.md index b9ae299..bc0de99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ Host support is data-first. - Put paths and aliases in `spec/hosts.json` whenever possible. - Do not add host-specific branching unless the generic resolver cannot express the host. -- `projectSkillsDirs` and `userSkillsDirs` are ordered; the first path is the canonical install target. +- `projectSkillsDirs` and `userSkillsDirs` are ordered; reuse the first existing compatible path, or fall back to the first canonical path. - Project paths must be relative. User paths must start with `~/`. - A host may be project-only or user-only. - Multiple selected hosts may resolve to the same target directory; copy once and report all matching hosts. diff --git a/Makefile b/Makefile index 59a40c6..d74871b 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ GO_FILES := $(shell find $(GO_DIR) $(GO_COBRA_DIR) $(EXAMPLE_GO_DIR) -name '*.go # ── Quality ────────────────────────────────────────────────────────────────── -.PHONY: check test test-ts test-go test-go-cobra test-rust test-python fmt fmt-ts fmt-go fmt-rust fmt-python +.PHONY: check test test-ts test-go test-go-cobra test-go-release test-rust test-python fmt fmt-ts fmt-go fmt-rust fmt-python check: ## Full parity gate node scripts/check.mjs @@ -31,10 +31,13 @@ test-ts: ## Run TypeScript tests pnpm --dir $(TS_DIR) test test-go: ## Run Go SDK tests - cd $(GO_DIR) && go test ./... + cd $(GO_DIR) && GOWORK=off go test ./... test-go-cobra: ## Run Go Cobra adapter tests - cd $(GO_COBRA_DIR) && go test ./... + sh scripts/check-go-cobra.sh + +test-go-release: ## Verify packaged Go modules from an external consumer + sh scripts/check-go-release.sh test-rust: ## Run Rust SDK tests cargo test --manifest-path $(RUST_DIR)/Cargo.toml @@ -62,11 +65,13 @@ fmt-python: ## Lint and format Python code .PHONY: generate generate-check -generate: ## Refresh generated host constants +generate: ## Refresh generated host constants and Go test fixtures node scripts/sync-hosts.mjs + node scripts/sync-go-testdata.mjs -generate-check: ## Verify generated host constants +generate-check: ## Verify generated host constants and Go test fixtures node scripts/sync-hosts.mjs --check + node scripts/sync-go-testdata.mjs --check # ── Examples ───────────────────────────────────────────────────────────────── diff --git a/README.md b/README.md index a9d51fb..13e7924 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ mycli skill install - validate bundled skills - install from a local directory, embedded bundle tree, or public GitHub bundle directory - copy, update, and uninstall kitup-owned installs +- inspect installed ownership, source, CLI version, and revision metadata - refuse unsafe overwrite conflicts - return structured install reports @@ -143,11 +144,21 @@ import ( ) root.AddCommand(kitupcobra.NewSkillCommand(kitupcobra.Options{ - AppID: "mycli", - Bundle: kitup.FSBundle(embeddedSkills, "skills/mycli"), + AppID: "mycli", + SkillName: "mycli", + Bundle: kitup.WithBundleMetadata( + kitup.FSBundle(embeddedSkills, "skills/mycli"), + kitup.BundledMetadata{ + CLIVersion: "1.2.3", + Revision: "abc123", + SourceID: "mycli:embedded", + }, + ), })) ``` +The command includes `skill install`, `skill status`, and `skill uninstall`. Status and uninstall support `--json`; uninstall only removes directories with valid `.kitup.json` ownership matching `AppID`. + ### Rust Install: diff --git a/docs/API.md b/docs/API.md index 2727443..96ef54d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -8,8 +8,8 @@ The core flow is: 2. resolve safe target agent selection for CLI workflows 3. validate `SKILL.md` 4. copy, update, skip, or report conflicts -5. write `.kitup.json` ownership metadata -6. return a structured report +5. write or read `.kitup.json` ownership and provenance metadata +6. return a structured install, status, or uninstall report ## TypeScript @@ -17,6 +17,7 @@ Package: `@kitup/sdk` ```ts import { + type BundledMetadata, detectHosts, directoryBundle, filesBundle, @@ -27,6 +28,7 @@ import { installUxText, moduleDirBundle, planBundledSkill, + readInstalledMetadata, parseInstallFlags, classifyInstallWorkflowExit, resolveInstallSelection, @@ -42,7 +44,12 @@ Primitive install call: ```ts const report = await installBundledSkill({ appId: "mycli", - skillBundle: directoryBundle("./skills/mycli"), + skillBundle: directoryBundle("./skills/mycli", { + cliVersion: "1.2.3", + revision: "abc123", + sourceId: "mycli:embedded", + provenance: { build: "release" }, + }), scope: "user", }); ``` @@ -77,9 +84,9 @@ Implemented functions: - `resolveInstallTargets({ home?, cwd?, hostsFile?, agents?, scope, skillName })` - `validateSkillBundle(bundle, cwd?)` - `computeBundleContentHash(bundle, cwd?)` -- `directoryBundle(path)` -- `filesBundle(files)` -- `moduleDirBundle(importMetaUrl, relativePath)` +- `directoryBundle(path, metadata?)` +- `filesBundle(files, metadata?)` +- `moduleDirBundle(importMetaUrl, relativePath, metadata?)` - `githubBundle(options)` - `parseInstallFlags(flags)` - `agentSelectorFromFlags(values)` @@ -92,6 +99,7 @@ Implemented functions: - `installBundledSkill(options)` - `updateBundledSkill(options)` - `uninstallBundledSkill(options)` +- `readInstalledMetadata(targetDir)` - `installUxText` ## Go @@ -107,7 +115,15 @@ Primitive install call: ```go report, err := kitup.InstallBundledSkill(kitup.InstallOptions{ AppID: "mycli", - SkillBundle: kitup.DirectoryBundle("./skills/mycli"), + SkillBundle: kitup.WithBundleMetadata( + kitup.DirectoryBundle("./skills/mycli"), + kitup.BundledMetadata{ + CLIVersion: "1.2.3", + Revision: "abc123", + SourceID: "mycli:embedded", + Provenance: map[string]string{"build": "release"}, + }, + ), Scope: kitup.UserScope, }) ``` @@ -140,6 +156,7 @@ Implemented functions: - `FSBundle(fsys, root)` - `FilesBundle(files)` - `GitHubBundle(opts)` +- `WithBundleMetadata(bundle, metadata)` - `ParseInstallFlags(flags)` - `AgentSelectorFromFlags(values)` - `ParseScopeFlag(value)` @@ -151,12 +168,16 @@ Implemented functions: - `InstallBundledSkill(opts)` - `UpdateBundledSkill(opts)` - `UninstallBundledSkill(opts)` +- `StatusBundledSkill(opts)` +- `ReadInstalledMetadata(targetDir)` - `InstallUX` Optional Cobra adapter module: `github.com/lathe-cli/kitup/go-cobra` - `NewSkillCommand(opts)` - `NewInstallCommand(opts)` +- `NewStatusCommand(opts)` +- `NewUninstallCommand(opts)` ## Rust @@ -168,7 +189,15 @@ Primitive install call: let report = kitup::install_bundled_skill(&kitup::InstallOptions { base: kitup::BaseOptions::default(), app_id: "mycli".to_string(), - skill_bundle: kitup::directory_bundle("./skills/mycli"), + skill_bundle: kitup::with_bundle_metadata( + kitup::directory_bundle("./skills/mycli"), + kitup::BundledMetadata { + cli_version: Some("1.2.3".to_string()), + revision: Some("abc123".to_string()), + source_id: Some("mycli:embedded".to_string()), + provenance: [("build".to_string(), "release".to_string())].into(), + }, + ), scope: kitup::Scope::User, agents: kitup::AgentSelector::Auto, force: false, @@ -217,6 +246,7 @@ Implemented functions: - `files_bundle(files)` - `include_dir_bundle(dir)` with the `include-dir` feature - `github_bundle(options)` +- `with_bundle_metadata(bundle, metadata)` - `parse_install_flags(flags)` - `agent_selector_from_flags(values, errors)` - `parse_scope_flag(value, errors)` @@ -229,6 +259,7 @@ Implemented functions: - `install_bundled_skill(options)` - `update_bundled_skill(options)` - `uninstall_bundled_skill(options)` +- `read_installed_metadata(target_dir)` - `INSTALL_UX` ## Python @@ -238,6 +269,7 @@ Package: `kitup-sdk` ```python from kitup import ( BaseOptions, + BundledMetadata, InstallOptions, InstallWorkflowOptions, classify_install_workflow_exit, @@ -253,6 +285,7 @@ from kitup import ( parse_install_flags, parse_scope_flag, plan_bundled_skill, + read_installed_metadata, resolve_hosts, resolve_install_selection, resolve_install_targets, @@ -273,7 +306,15 @@ report = install_bundled_skill( InstallOptions( base=BaseOptions(), app_id="mycli", - skill_bundle=directory_bundle("./skills/mycli"), + skill_bundle=directory_bundle( + "./skills/mycli", + BundledMetadata( + cli_version="1.2.3", + revision="abc123", + source_id="mycli:embedded", + provenance={"build": "release"}, + ), + ), scope="user", ) ) @@ -322,9 +363,9 @@ Implemented functions: - `resolve_install_targets(options, agents, scope, skill_name)` - `validate_skill_bundle(bundle, cwd=None)` - `compute_bundle_content_hash(bundle, cwd=None)` -- `directory_bundle(path)` -- `files_bundle(files)` -- `resources_bundle(root)` +- `directory_bundle(path, metadata=None)` +- `files_bundle(files, metadata=None)` +- `resources_bundle(root, metadata=None)` - `github_bundle(options)` - `parse_install_flags(flags)` - `agent_selector_from_flags(values, errors)` @@ -338,6 +379,7 @@ Implemented functions: - `install_bundled_skill(options)` - `update_bundled_skill(options)` - `uninstall_bundled_skill(options)` +- `read_installed_metadata(target_dir)` - `INSTALL_UX` ## Options @@ -361,6 +403,28 @@ The first non-local bundle constructor is GitHub only: GitHub bundle resolution downloads only files under the configured directory path, requires `SKILL.md` at that bundle root, records the requested ref and resolved commit, and writes GitHub provenance into `.kitup.json`. It does not search GitHub, install dependencies, execute scripts, handle private auth, or install whole repositories by default. +Bundled and embedded bundles can provide optional `cliVersion`, `revision`, `sourceId`, and string-valued `provenance`. These values describe the embedding CLI build and source; they do not change the skill content hash or ownership rules. + +Reinstalling an unchanged skill refreshes `.kitup.json` when these source or build fields changed, so status does not remain pinned to metadata from an older CLI release. + +## Installed metadata + +`InstalledMetadata` is public in all four SDKs. The reader APIs are `readInstalledMetadata`, `ReadInstalledMetadata`, and `read_installed_metadata`. A missing `.kitup.json` is reported as absent; malformed content, an unsupported schema, invalid ownership fields, or invalid optional field types is reported as an error. + +For a missing file, TypeScript returns `undefined`, Python returns `None`, Rust returns `Ok(None)`, and Go returns `ErrInstalledMetadataNotFound`. Go uses `ErrInvalidInstalledMetadata` for invalid content; the other SDKs use their standard invalid-data error mechanism. + +Optional string fields may be omitted, but when present they must be non-empty strings. `provenance` may be omitted, but when present it must be an object whose values are strings. Explicit `null` values fail closed in every SDK. + +The `.kitup.json` schema remains at `schemaVersion: 1`. Existing required fields remain unchanged: + +- `appId`, `skillName`: ownership identity +- `source`: `bundled` or `github` +- `hash`: installed bundle content hash + +The existing optional `sourceId`, `version`, and `provenance` fields remain compatible. `cliVersion` and `revision` are new optional fields. Older version 1 metadata remains readable without them. + +Install, update, status, and uninstall treat malformed metadata as unmanaged and fail closed. Uninstall moves a matching target to a same-parent quarantine path, revalidates its metadata after the move, and only then removes that exact tree. It restores a target that fails revalidation where safe. There is no implicit force behavior. + The embedding CLI owns command names and framework attachment. `kitup` owns standard install flag semantics, selector mapping, user-facing workflow text, summary rendering, confirmation, dry-run planning, workflow exit classification, and execution. For user-facing commands, call `runBundledSkillInstall` / `RunBundledSkillInstall` / `run_bundled_skill_install` with values from the shared flag parsing helpers. Workflow-only options: @@ -401,8 +465,14 @@ mycli skill install mycli skill install --scope user --agent codex mycli skill install --scope project --agent codex --agent claude-code mycli skill install --scope user --agent codex --force +mycli skill status --scope user --agent codex --json +mycli skill uninstall --scope user --agent codex --json ``` +The Go Cobra adapter provides `skill status` and `skill uninstall`. Both accept `--scope`, repeatable `--agent`, and optional `--json`, and reuse the same existing-compatible-directory-first target resolution as install. When `CurrentAgent` is set and `--agent` is omitted, both commands inspect the current agent and universal targets selected by install. Neither command exposes `--force`. + +Set Cobra `Options.SkillName` for lifecycle commands that must remain available when the original local, embedded, or GitHub bundle cannot be read. When it is omitted, the adapter derives the name by validating `Options.Bundle` for backward compatibility. + The lower-level selection resolver remains available for custom shells. It returns one of: - `install`: proceed to plan and confirmation with `selectedHostIds` @@ -415,8 +485,8 @@ 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. +- `scope: "user"` reuses the first existing `userSkillsDirs` path, or creates the first canonical path when none exist. +- `scope: "project"` reuses the first existing `projectSkillsDirs` path, or creates the first canonical path when none exist. - `agents: "auto"` uses host detection. - `agents: "*"` selects every host adapter. - explicit agents select canonical host ids or aliases. @@ -439,7 +509,14 @@ Uninstall reports include: - `conflicts` - `errors` -TypeScript returns typed report objects. Go exposes `InstallReport`, `UninstallReport`, `TargetResult`, `TargetStatus`, and `ReportError`. Rust exposes `InstallReport`, `UninstallReport`, `TargetResult`, `TargetStatus`, and `ReportError`. +Go status reports include: + +- `installed`, with an `InstalledMetadata` object for each target +- `missing` +- `conflicts` +- `errors` + +TypeScript returns typed report objects. Go exposes `InstallReport`, `StatusReport`, `UninstallReport`, `InstalledMetadata`, `TargetResult`, `TargetStatus`, and `ReportError`. Rust exposes `InstallReport`, `UninstallReport`, `InstalledMetadata`, `TargetResult`, `TargetStatus`, and `ReportError`. Python exposes the same installed metadata fields as `InstalledMetadata`. The serialized JSON report shape is the same across TypeScript, Go, and Rust. `installed`, `updated`, and `removed` contain target results. `skipped` and `conflicts` contain target results plus `reason`. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index a67d090..6489819 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -55,6 +55,16 @@ Do not tag the release branch. Do not publish packages by hand during the normal The release workflow publishes npm, PyPI, and crates.io packages, creates the `go/vX.Y.Z` and `go-cobra/vX.Y.Z` tags, creates the GitHub Release, and runs the public install smoke check. +The Go modules share the same release version. No workspace or local replacement +is committed. Source checks copy the modules to a temporary directory and add a +one-off replacement there so the Cobra adapter can test unreleased core APIs. +Release checks disable workspace resolution and verify both module archives from +a temporary consumer: + +```bash +make test-go-release +``` + ## First npm Release npm trusted publishing is configured in the npm package settings. For the first package version, the package settings may not exist yet. diff --git a/docs/architecture.mmd b/docs/architecture.mmd index 13bc913..635386a 100644 --- a/docs/architecture.mmd +++ b/docs/architecture.mmd @@ -7,8 +7,8 @@ flowchart TB BUNDLE["Bundle Resolver\nlocal · embedded · GitHub"]:::execution VALIDATE["Validator\nSKILL.md frontmatter"]:::execution HOST["Host Resolver\nids · aliases · detection · targets"]:::execution - INSTALL["Installer\nplan · conflict policy · copy · update · uninstall"]:::execution - REPORT["Reports\nInstallReport · UninstallReport"]:::execution + INSTALL["Lifecycle\nplan · copy · update · status · uninstall"]:::execution + REPORT["Reports\nInstallReport · StatusReport · UninstallReport"]:::execution end HOSTSPEC["Host Spec\nspec/hosts.json"]:::contract @@ -18,7 +18,7 @@ flowchart TB VERIFY["Verification\ncheck.mjs · sync-hosts.mjs"]:::control GITHUB["GitHub API"]:::external TARGETS["Agent Host\nDirectory State"]:::state - METADATA[".kitup.json"]:::state + METADATA[".kitup.json\nownership · source · CLI build · provenance"]:::state AUTHOR -->|"provides flags"| WORKFLOW WORKFLOW --> BUNDLE @@ -31,6 +31,7 @@ flowchart TB HOST --> INSTALL INSTALL -->|"copies, updates, removes"| TARGETS INSTALL -->|"writes .kitup.json"| METADATA + METADATA -->|"reads fail closed"| INSTALL INSTALL -->|"returns report"| REPORT SCHEMAS -.-> HOSTSPEC diff --git a/docs/host-adapter-contract.md b/docs/host-adapter-contract.md index 99d50dc..f991bba 100644 --- a/docs/host-adapter-contract.md +++ b/docs/host-adapter-contract.md @@ -8,7 +8,7 @@ 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 roots that the host also scans. SDKs reuse the first supported path that already exists, preserving an established installation location. If none exist, SDKs create the first canonical path. 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. @@ -27,6 +27,8 @@ Aliases are for ecosystem compatibility only. SDK result objects should return t Detection should check path existence. Entries may be home-relative paths such as `~/.codex` or project-relative paths such as `.replit`. +SDKs check every non-generic detection path for a host. Every `detect` entry must be evidence that the specific host is present; a compatibility root that merely belongs to another host belongs in `projectSkillsDirs` or `userSkillsDirs`, not in `detect`. Shared roots and files such as `~/.agents` and `package.json` do not identify a specific host by themselves and must not cause that host to be auto-selected. + Detection must not run host binaries, start editors, mutate configuration, or require network access. Explicit host selection should still resolve install targets even when detection paths are absent. 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.go b/go-cobra/skill.go index d7dee5d..2b0a5c5 100644 --- a/go-cobra/skill.go +++ b/go-cobra/skill.go @@ -1,7 +1,11 @@ package kitupcobra import ( + "encoding/json" + "errors" + "fmt" "io" + "strings" kitup "github.com/lathe-cli/kitup/go" "github.com/spf13/cobra" @@ -10,6 +14,7 @@ import ( type Options struct { AppID string Bundle kitup.SkillBundle + SkillName string DefaultScope kitup.Scope Home string CWD string @@ -28,6 +33,8 @@ func NewSkillCommand(opts Options) *cobra.Command { SilenceUsage: true, } cmd.AddCommand(NewInstallCommand(opts)) + cmd.AddCommand(NewStatusCommand(opts)) + cmd.AddCommand(NewUninstallCommand(opts)) return cmd } @@ -92,6 +99,195 @@ func NewInstallCommand(opts Options) *cobra.Command { return cmd } +func NewStatusCommand(opts Options) *cobra.Command { + var scope string + var agents []string + var jsonOutput bool + cmd := &cobra.Command{ + Use: "status", + Short: "Show installed bundled Agent Skill metadata", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + skillName, err := lifecycleSkillName(opts) + if err != nil { + return err + } + parsed, err := parseLifecycleFlags(scope, agents, opts) + if err != nil { + return err + } + report, err := kitup.StatusBundledSkill(kitup.StatusOptions{ + BaseOptions: baseOptions(opts), + AppID: opts.AppID, + SkillName: skillName, + Scope: parsed.Scope, + Agents: parsed.Agents, + }) + if err != nil { + return err + } + if jsonOutput { + if err := json.NewEncoder(output(cmd, opts)).Encode(report); err != nil { + return err + } + } else { + renderStatus(output(cmd, opts), report) + } + if len(report.Conflicts)+len(report.Errors) > 0 { + return errors.New("skill status has conflicts") + } + return nil + }, + } + addLifecycleFlags(cmd, &scope, &agents, &jsonOutput) + return cmd +} + +func NewUninstallCommand(opts Options) *cobra.Command { + var scope string + var agents []string + var jsonOutput bool + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Uninstall the bundled Agent Skill", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + skillName, err := lifecycleSkillName(opts) + if err != nil { + return err + } + parsed, err := parseLifecycleFlags(scope, agents, opts) + if err != nil { + return err + } + report, err := kitup.UninstallBundledSkill(kitup.UninstallOptions{ + BaseOptions: baseOptions(opts), + AppID: opts.AppID, + SkillName: skillName, + Scope: parsed.Scope, + Agents: parsed.Agents, + }) + if err != nil { + return err + } + if jsonOutput { + if err := json.NewEncoder(output(cmd, opts)).Encode(report); err != nil { + return err + } + } else { + renderUninstall(output(cmd, opts), report) + } + if len(report.Conflicts)+len(report.Errors) > 0 { + return errors.New("skill uninstall has conflicts") + } + return nil + }, + } + addLifecycleFlags(cmd, &scope, &agents, &jsonOutput) + return cmd +} + +func addLifecycleFlags(cmd *cobra.Command, scope *string, agents *[]string, jsonOutput *bool) { + cmd.Flags().StringVar(scope, "scope", "", kitup.InstallUX.ScopeFlag) + cmd.Flags().StringArrayVar(agents, "agent", nil, kitup.InstallUX.AgentFlag) + cmd.Flags().BoolVar(jsonOutput, "json", false, "Output structured JSON") +} + +func parseLifecycleFlags(scope string, agents []string, opts Options) (kitup.ParsedInstallFlags, error) { + if scope == "" && opts.DefaultScope != "" { + scope = string(opts.DefaultScope) + } + parsed := kitup.ParseInstallFlags(kitup.InstallFlagValues{Scope: scope, Agents: agents}) + if err := kitup.InstallFlagError(parsed.Errors); err != nil { + return parsed, err + } + if len(agents) == 0 && opts.CurrentAgent != "" { + selection, err := kitup.ResolveInstallSelection(kitup.InstallSelectionOptions{ + BaseOptions: baseOptions(opts), + Scope: parsed.Scope, + Agents: kitup.AutoAgents(), + Yes: true, + CurrentAgent: opts.CurrentAgent, + }) + if err != nil { + return parsed, err + } + if len(selection.Errors) > 0 { + return parsed, errors.New("invalid lifecycle agent selection") + } + parsed.Agents = kitup.ExplicitAgents(selection.SelectedHostIDs...) + } + return parsed, nil +} + +func baseOptions(opts Options) kitup.BaseOptions { + return kitup.BaseOptions{Home: opts.Home, CWD: opts.CWD, HostsFile: opts.HostsFile} +} + +func lifecycleSkillName(opts Options) (string, error) { + if opts.SkillName != "" { + return opts.SkillName, nil + } + info := kitup.ValidateSkillBundle(opts.Bundle) + if !info.Valid || info.SkillName == "" { + return "", errors.New("invalid bundled skill") + } + return info.SkillName, nil +} + +func renderStatus(out io.Writer, report kitup.StatusReport) { + for _, target := range report.Installed { + parts := []string{fmt.Sprintf("installed %s at %s", target.SkillName, target.TargetDir)} + if target.Metadata.CLIVersion != "" { + parts = append(parts, "cli-version="+target.Metadata.CLIVersion) + } + if target.Metadata.Revision != "" { + parts = append(parts, "revision="+target.Metadata.Revision) + } + if target.Metadata.SourceID != "" { + parts = append(parts, "source-id="+target.Metadata.SourceID) + } + fmt.Fprintln(out, strings.Join(parts, " ")) + } + for _, target := range report.Missing { + fmt.Fprintf(out, "missing %s at %s\n", target.SkillName, target.TargetDir) + } + for _, target := range report.Conflicts { + fmt.Fprintf(out, "conflict %s at %s: %s\n", target.SkillName, target.TargetDir, target.Reason) + } + renderErrors(out, report.Errors) +} + +func renderUninstall(out io.Writer, report kitup.UninstallReport) { + for _, target := range report.Removed { + fmt.Fprintf(out, "removed %s from %s\n", target.SkillName, target.TargetDir) + } + for _, target := range report.Skipped { + fmt.Fprintf(out, "skipped %s at %s: %s\n", target.SkillName, target.TargetDir, target.Reason) + } + for _, target := range report.Conflicts { + fmt.Fprintf(out, "conflict %s at %s: %s\n", target.SkillName, target.TargetDir, target.Reason) + } + renderErrors(out, report.Errors) +} + +func renderErrors(out io.Writer, errors []kitup.ReportError) { + for _, reportErr := range errors { + context := reportErr.Agent + if context == "" { + context = reportErr.HostID + } + if context == "" { + context = reportErr.SkillName + } + if context == "" { + fmt.Fprintf(out, "error: %s\n", reportErr.Reason) + } else { + fmt.Fprintf(out, "error %s: %s\n", context, reportErr.Reason) + } + } +} + func input(cmd *cobra.Command, opts Options) io.Reader { if opts.In != nil { return opts.In diff --git a/go-cobra/skill_test.go b/go-cobra/skill_test.go index 15a1639..39ca733 100644 --- a/go-cobra/skill_test.go +++ b/go-cobra/skill_test.go @@ -2,20 +2,30 @@ package kitupcobra import ( "bytes" + "encoding/json" "os" "path/filepath" "strings" "testing" + "testing/fstest" kitup "github.com/lathe-cli/kitup/go" ) +func testBundle() kitup.SkillBundle { + return kitup.FSBundle(fstest.MapFS{ + "basic/SKILL.md": { + Data: []byte("---\nname: basic\ndescription: Basic skill.\n---\n"), + }, + }, "basic") +} + func TestSkillCommandInstallsWithCoreFlags(t *testing.T) { home := t.TempDir() var out bytes.Buffer cmd := NewSkillCommand(Options{ AppID: "example-cli", - Bundle: kitup.DirectoryBundle(filepath.Join("..", "testdata", "skills", "basic")), + Bundle: testBundle(), Home: home, Out: &out, }) @@ -36,7 +46,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: testBundle(), Home: home, CWD: workspace, StdinTTY: true, @@ -71,7 +81,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: testBundle(), Home: home, Out: &out, }) @@ -89,7 +99,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: testBundle(), Home: t.TempDir(), }) cmd.SetArgs([]string{"--scope", "bad"}) @@ -99,3 +109,209 @@ func TestInstallCommandReturnsCoreFlagError(t *testing.T) { t.Fatalf("got %v, want %q", err, kitup.InstallUX.InvalidFlags) } } + +func TestStatusCommandJSONReportsInstalledMetadata(t *testing.T) { + home := t.TempDir() + bundle := kitup.WithBundleMetadata(testBundle(), kitup.BundledMetadata{ + CLIVersion: "1.2.3", + Revision: "abc123", + SourceID: "example-cli:embedded", + Provenance: map[string]string{"channel": "release"}, + }) + install := NewSkillCommand(Options{AppID: "example-cli", Bundle: bundle, Home: home}) + install.SetArgs([]string{"install", "--agent", "codex", "--yes"}) + if err := install.Execute(); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + status := NewSkillCommand(Options{AppID: "example-cli", Bundle: bundle, Home: home, Out: &out}) + status.SetArgs([]string{"status", "--agent", "codex", "--json"}) + if err := status.Execute(); err != nil { + t.Fatal(err) + } + var report kitup.StatusReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatal(err) + } + if len(report.Installed) != 1 { + t.Fatalf("installed = %+v", report.Installed) + } + metadata := report.Installed[0].Metadata + if metadata.CLIVersion != "1.2.3" || metadata.Revision != "abc123" || metadata.SourceID != "example-cli:embedded" { + t.Fatalf("metadata = %+v", metadata) + } +} + +func TestUninstallCommandJSONRemovesOwnedSkill(t *testing.T) { + home := t.TempDir() + install := NewSkillCommand(Options{AppID: "example-cli", Bundle: testBundle(), Home: home}) + install.SetArgs([]string{"install", "--agent", "codex", "--yes"}) + if err := install.Execute(); err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + uninstall := NewSkillCommand(Options{AppID: "example-cli", Bundle: testBundle(), Home: home, Out: &out}) + uninstall.SetArgs([]string{"uninstall", "--agent", "codex", "--json"}) + if err := uninstall.Execute(); err != nil { + t.Fatal(err) + } + var report kitup.UninstallReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatal(err) + } + if len(report.Removed) != 1 { + t.Fatalf("removed = %+v", report.Removed) + } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "basic")); !os.IsNotExist(err) { + t.Fatalf("expected owned skill removal, got %v", err) + } +} + +func TestLifecycleCommandsReuseCurrentAgentTargetsWithoutSourceBundle(t *testing.T) { + home := t.TempDir() + install := NewSkillCommand(Options{ + AppID: "example-cli", + Bundle: testBundle(), + Home: home, + CurrentAgent: "claude-code", + }) + install.SetArgs([]string{"install", "--yes"}) + if err := install.Execute(); err != nil { + t.Fatal(err) + } + + for _, target := range []string{ + filepath.Join(home, ".claude", "skills", "basic"), + filepath.Join(home, ".agents", "skills", "basic"), + } { + if _, err := os.Stat(target); err != nil { + t.Fatalf("expected current-agent install at %s: %v", target, err) + } + } + + options := Options{ + AppID: "example-cli", + SkillName: "basic", + Home: home, + CurrentAgent: "claude-code", + } + var statusOut bytes.Buffer + status := NewSkillCommand(options) + status.SetOut(&statusOut) + status.SetArgs([]string{"status", "--json"}) + if err := status.Execute(); err != nil { + t.Fatal(err) + } + var statusReport kitup.StatusReport + if err := json.Unmarshal(statusOut.Bytes(), &statusReport); err != nil { + t.Fatal(err) + } + if len(statusReport.Installed) != 2 { + t.Fatalf("installed = %+v", statusReport.Installed) + } + + var uninstallOut bytes.Buffer + uninstall := NewSkillCommand(options) + uninstall.SetOut(&uninstallOut) + uninstall.SetArgs([]string{"uninstall", "--json"}) + if err := uninstall.Execute(); err != nil { + t.Fatal(err) + } + var uninstallReport kitup.UninstallReport + if err := json.Unmarshal(uninstallOut.Bytes(), &uninstallReport); err != nil { + t.Fatal(err) + } + if len(uninstallReport.Removed) != 2 { + t.Fatalf("removed = %+v", uninstallReport.Removed) + } + for _, target := range []string{ + filepath.Join(home, ".claude", "skills", "basic"), + filepath.Join(home, ".agents", "skills", "basic"), + } { + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("expected removal at %s, got %v", target, err) + } + } +} + +func TestLifecycleCurrentAgentSupportsHostSpecWithoutUniversal(t *testing.T) { + home := t.TempDir() + hostsFile := filepath.Join(t.TempDir(), "hosts.json") + hosts := `{ + "schemaVersion": 1, + "hosts": [{ + "id": "custom-agent", + "displayName": "Custom Agent", + "projectSkillsDirs": [".custom/skills"], + "userSkillsDirs": ["~/.custom/skills"], + "detect": ["~/.custom"], + "status": "verified" + }] +} +` + if err := os.WriteFile(hostsFile, []byte(hosts), 0o644); err != nil { + t.Fatal(err) + } + install := NewSkillCommand(Options{ + AppID: "example-cli", + Bundle: testBundle(), + Home: home, + HostsFile: hostsFile, + CurrentAgent: "custom-agent", + }) + install.SetArgs([]string{"install", "--yes"}) + if err := install.Execute(); err != nil { + t.Fatal(err) + } + target := filepath.Join(home, ".custom", "skills", "basic") + if _, err := os.Stat(target); err != nil { + t.Fatal(err) + } + + uninstall := NewSkillCommand(Options{ + AppID: "example-cli", + SkillName: "basic", + Home: home, + HostsFile: hostsFile, + CurrentAgent: "custom-agent", + }) + uninstall.SetArgs([]string{"uninstall", "--json"}) + if err := uninstall.Execute(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("expected custom target removal, got %v", err) + } +} + +func TestUninstallCommandFailsClosedOnCorruptMetadata(t *testing.T) { + home := t.TempDir() + target := filepath.Join(home, ".agents", "skills", "basic") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, ".kitup.json"), []byte("{\"schemaVersion\":1,\"appId\":\"example-cli\"}\n"), 0o644); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + uninstall := NewSkillCommand(Options{AppID: "example-cli", Bundle: testBundle(), Home: home, Out: &out}) + uninstall.SetArgs([]string{"uninstall", "--agent", "codex", "--json"}) + if err := uninstall.Execute(); err == nil { + t.Fatal("expected corrupt metadata conflict") + } + if _, err := os.Stat(target); err != nil { + t.Fatalf("corrupt target was removed: %v", err) + } + var report kitup.UninstallReport + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatal(err) + } + if len(report.Conflicts) != 1 || report.Conflicts[0].Reason != "unmanaged" { + t.Fatalf("conflicts = %+v", report.Conflicts) + } + if NewUninstallCommand(Options{}).Flags().Lookup("force") != nil { + t.Fatal("uninstall must not expose a force flag") + } +} diff --git a/go/hosts_gen.go b/go/hosts_gen.go index 521b4e6..eb2ba14 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\",\"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\"],\"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\"],\"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\"],\"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..22bab64 100644 --- a/go/kitup.go +++ b/go/kitup.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "io/fs" + "maps" "net/http" "net/url" "os" @@ -241,6 +242,8 @@ type UninstallOptions struct { Agents AgentSelector } +type StatusOptions = UninstallOptions + type InstallSelectionOptions struct { BaseOptions Scope Scope @@ -284,6 +287,14 @@ type SkillBundle struct { root string files []SkillFile github GitHubBundleOptions + meta BundledMetadata +} + +type BundledMetadata struct { + CLIVersion string `json:"cliVersion,omitempty"` + Revision string `json:"revision,omitempty"` + SourceID string `json:"sourceId,omitempty"` + Provenance map[string]string `json:"provenance,omitempty"` } type GitHubBundleOptions struct { @@ -309,6 +320,11 @@ func GitHubBundle(opts GitHubBundleOptions) SkillBundle { return SkillBundle{kind: "github", github: opts} } +func WithBundleMetadata(bundle SkillBundle, metadata BundledMetadata) SkillBundle { + bundle.meta = metadata + return bundle +} + type TargetGroup struct { HostIDs []string SkillName string @@ -352,6 +368,18 @@ type UninstallReport struct { Errors []ReportError `json:"errors"` } +type InstalledTarget struct { + TargetResult + Metadata InstalledMetadata `json:"metadata"` +} + +type StatusReport struct { + Installed []InstalledTarget `json:"installed"` + Missing []TargetStatus `json:"missing"` + Conflicts []TargetStatus `json:"conflicts"` + Errors []ReportError `json:"errors"` +} + type InstallSelection struct { Action string `json:"action"` SelectedHostIDs []string `json:"selectedHostIds"` @@ -370,7 +398,7 @@ type InstallWorkflowReport struct { DryRun bool `json:"dryRun"` } -type metadata struct { +type InstalledMetadata struct { SchemaVersion int `json:"schemaVersion"` AppID string `json:"appId"` SkillName string `json:"skillName"` @@ -378,6 +406,8 @@ type metadata struct { Hash string `json:"hash"` SourceID string `json:"sourceId,omitempty"` Version string `json:"version,omitempty"` + CLIVersion string `json:"cliVersion,omitempty"` + Revision string `json:"revision,omitempty"` Provenance map[string]string `json:"provenance,omitempty"` } @@ -385,9 +415,16 @@ type bundleMetadata struct { Source string SourceID string Version string + CLIVersion string + Revision string Provenance map[string]string } +var ( + ErrInstalledMetadataNotFound = errors.New("installed metadata not found") + ErrInvalidInstalledMetadata = errors.New("invalid installed metadata") +) + type bundleFile struct { Path string Contents []byte @@ -504,11 +541,11 @@ 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 _, detectPath := range host.Detect { + if !isGenericDetectPath(detectPath) && exists(expandHostPath(detectPath, home, cwd)) { + detected = append(detected, host) + break + } } } if scope == "" { @@ -836,6 +873,32 @@ func UpdateBundledSkill(opts InstallOptions) (InstallReport, error) { return InstallBundledSkill(opts) } +func StatusBundledSkill(opts StatusOptions) (StatusReport, error) { + if opts.AppID == "" { + return emptyStatusReport([]map[string]any{{"reason": "invalid-app-id"}}), nil + } + targets, errs, _, err := ResolveInstallTargets(opts.BaseOptions, opts.Agents, opts.Scope, opts.SkillName) + if err != nil { + return StatusReport{}, err + } + report := emptyStatusReport(errs) + for _, target := range targets { + result := targetResult(target) + meta, present, managed := readMetadata(target.TargetDir) + switch { + case !present: + report.Missing = append(report.Missing, withReason(result, "missing")) + case !managed || meta.SkillName != opts.SkillName: + report.Conflicts = append(report.Conflicts, withReason(result, "unmanaged")) + case meta.AppID != opts.AppID: + report.Conflicts = append(report.Conflicts, withReason(result, "owner-mismatch")) + default: + report.Installed = append(report.Installed, InstalledTarget{TargetResult: result, Metadata: meta}) + } + } + return report, nil +} + func UninstallBundledSkill(opts UninstallOptions) (UninstallReport, error) { if opts.AppID == "" { return emptyUninstallReport([]map[string]any{{"reason": "invalid-app-id"}}), nil @@ -856,15 +919,56 @@ func UninstallBundledSkill(opts UninstallOptions) (UninstallReport, error) { case meta.AppID != opts.AppID: report.Conflicts = append(report.Conflicts, withReason(result, "owner-mismatch")) default: - if err := os.RemoveAll(target.TargetDir); err != nil { + removed, reason, err := removeManagedTarget(target.TargetDir, opts.AppID, opts.SkillName) + if err != nil { return report, err } + if !removed { + report.Conflicts = append(report.Conflicts, withReason(result, reason)) + continue + } report.Removed = append(report.Removed, result) } } return report, nil } +func removeManagedTarget(targetDir, appID, skillName string) (bool, string, error) { + quarantine, err := makeStagingDir(targetDir) + if err != nil { + return false, "", err + } + if err := os.Remove(quarantine); err != nil { + return false, "", err + } + if err := os.Rename(targetDir, quarantine); err != nil { + return false, "", err + } + restore := func() error { + if exists(targetDir) { + return fmt.Errorf("uninstall target changed; preserved quarantined target at %s", quarantine) + } + return os.Rename(quarantine, targetDir) + } + meta, err := ReadInstalledMetadata(quarantine) + reason := "" + if err != nil || meta.SkillName != skillName { + reason = "unmanaged" + } else if meta.AppID != appID { + reason = "owner-mismatch" + } + if reason != "" { + if err := restore(); err != nil { + return false, "", err + } + return false, reason, nil + } + if err := os.RemoveAll(quarantine); err != nil { + return false, "", err + } + return true, "", nil +} + func installOrPlan(opts InstallOptions, write bool) (InstallReport, error) { if opts.AppID == "" { return emptyInstallReport([]map[string]any{{"reason": "invalid-app-id"}}), nil @@ -925,7 +1029,7 @@ func installOrPlan(opts InstallOptions, write bool) (InstallReport, error) { if err != nil { return report, err } - if repaired { + if repaired || !installedMetadataMatchesBundle(meta, bundleMeta) { if write { if err := writeMetadata(target.TargetDir, opts.AppID, skill.SkillName, hash, bundleMeta); err != nil { return report, err @@ -1055,7 +1159,7 @@ func repairSkillBundleModes(bundle normalizedSkillBundle, dest string, write boo } func writeMetadata(targetDir, appID, skillName, hash string, bundleMeta bundleMetadata) error { - meta := metadata{ + meta := InstalledMetadata{ SchemaVersion: 1, AppID: appID, SkillName: skillName, @@ -1063,6 +1167,8 @@ func writeMetadata(targetDir, appID, skillName, hash string, bundleMeta bundleMe Hash: hash, SourceID: bundleMeta.SourceID, Version: bundleMeta.Version, + CLIVersion: bundleMeta.CLIVersion, + Revision: bundleMeta.Revision, Provenance: bundleMeta.Provenance, } if meta.Source == "" { @@ -1075,25 +1181,63 @@ func writeMetadata(targetDir, appID, skillName, hash string, bundleMeta bundleMe return os.WriteFile(filepath.Join(targetDir, ".kitup.json"), append(data, '\n'), 0o644) } -func readMetadata(targetDir string) (metadata, bool, bool) { - if !exists(targetDir) { - return metadata{}, false, false - } +func ReadInstalledMetadata(targetDir string) (InstalledMetadata, error) { data, err := os.ReadFile(filepath.Join(targetDir, ".kitup.json")) + if errors.Is(err, os.ErrNotExist) { + return InstalledMetadata{}, ErrInstalledMetadataNotFound + } if err != nil { - return metadata{}, true, false + return InstalledMetadata{}, err + } + if !hasValidOptionalMetadataFields(data) { + return InstalledMetadata{}, ErrInvalidInstalledMetadata + } + var meta InstalledMetadata + if err := json.Unmarshal(data, &meta); err != nil || !isOwnedMetadata(meta) { + return InstalledMetadata{}, ErrInvalidInstalledMetadata + } + return meta, nil +} + +func hasValidOptionalMetadataFields(data []byte) bool { + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return false + } + for _, key := range []string{"sourceId", "version", "cliVersion", "revision"} { + if value, present := raw[key]; present { + text, ok := value.(string) + if !ok || text == "" { + return false + } + } + } + if value, present := raw["provenance"]; present { + provenance, ok := value.(map[string]any) + if !ok { + return false + } + for _, item := range provenance { + if _, ok := item.(string); !ok { + return false + } + } } - var meta metadata - if err := json.Unmarshal(data, &meta); err != nil { - return metadata{}, true, false + return true +} + +func readMetadata(targetDir string) (InstalledMetadata, bool, bool) { + if !exists(targetDir) { + return InstalledMetadata{}, false, false } - if !isOwnedMetadata(meta) { - return metadata{}, true, false + meta, err := ReadInstalledMetadata(targetDir) + if err != nil { + return InstalledMetadata{}, true, false } return meta, true, true } -func isOwnedMetadata(meta metadata) bool { +func isOwnedMetadata(meta InstalledMetadata) bool { return meta.SchemaVersion == 1 && meta.AppID != "" && isValidSkillName(meta.SkillName) && @@ -1101,6 +1245,19 @@ func isOwnedMetadata(meta metadata) bool { meta.Hash != "" } +func installedMetadataMatchesBundle(installed InstalledMetadata, bundled bundleMetadata) bool { + source := bundled.Source + if source == "" { + source = "bundled" + } + return installed.Source == source && + installed.SourceID == bundled.SourceID && + installed.Version == bundled.Version && + installed.CLIVersion == bundled.CLIVersion && + installed.Revision == bundled.Revision && + maps.Equal(installed.Provenance, bundled.Provenance) +} + func targetResult(target TargetGroup) TargetResult { result := TargetResult{SkillName: target.SkillName, TargetDir: target.TargetDir} if len(target.HostIDs) == 1 { @@ -1134,6 +1291,15 @@ func emptyUninstallReport(errs []map[string]any) UninstallReport { } } +func emptyStatusReport(errs []map[string]any) StatusReport { + return StatusReport{ + Installed: []InstalledTarget{}, + Missing: []TargetStatus{}, + Conflicts: []TargetStatus{}, + Errors: reportErrors(errs), + } +} + func reportErrors(errs []map[string]any) []ReportError { if errs == nil { return []ReportError{} @@ -1381,14 +1547,27 @@ func parseFrontmatter(content string) map[string]string { func resolveSkillBundle(bundle SkillBundle) (normalizedSkillBundle, bundleMetadata, error) { switch bundle.kind { case "github": - return resolveGitHubBundle(bundle.github) + normalized, metadata, err := resolveGitHubBundle(bundle.github) + return normalized, mergeBundledMetadata(metadata, bundle.meta), err default: normalized, err := readSkillBundle(bundle) if err != nil { return normalizedSkillBundle{}, bundleMetadata{}, err } - return normalized, bundleMetadata{Source: "bundled"}, nil + return normalized, mergeBundledMetadata(bundleMetadata{Source: "bundled"}, bundle.meta), nil + } +} + +func mergeBundledMetadata(resolved bundleMetadata, bundled BundledMetadata) bundleMetadata { + if bundled.SourceID != "" { + resolved.SourceID = bundled.SourceID + } + resolved.CLIVersion = bundled.CLIVersion + resolved.Revision = bundled.Revision + if bundled.Provenance != nil { + resolved.Provenance = bundled.Provenance } + return resolved } func resolveGitHubBundle(opts GitHubBundleOptions) (normalizedSkillBundle, bundleMetadata, error) { @@ -1687,7 +1866,7 @@ func skipName(name string) bool { } func isGenericDetectPath(path string) bool { - return path == "~/.agents" || path == "~/.agents/skills" || path == "~/.config/agents" + return path == "~/.agents" || path == "~/.agents/skills" || path == "~/.config/agents" || path == "package.json" } func exists(path string) bool { diff --git a/go/kitup_test.go b/go/kitup_test.go index 23bb6c3..d4772f4 100644 --- a/go/kitup_test.go +++ b/go/kitup_test.go @@ -28,7 +28,7 @@ type goldenCase struct { func TestGoldenCases(t *testing.T) { var file goldenFile - readJSON(t, "../testdata/cases/bundled-skill-install.json", &file) + 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() @@ -86,6 +86,16 @@ func runCase(t *testing.T, tc goldenCase, home, workspace string) { result := ValidateSkillBundle(skillBundleFromOptions(opts)) equal(t, result.Valid, tc.Expected["valid"]) equal(t, result.ErrorCode, tc.Expected["errorCode"]) + case "read-installed-metadata": + meta, err := ReadInstalledMetadata(opts["targetDir"].(string)) + if boolValue(tc.Expected["throws"]) { + if err == nil { + t.Fatal("expected metadata read to fail") + } + return + } + must(t, err) + equal(t, meta, tc.Expected["installedMetadata"]) case "parse-install-flags": assertParsedFlags(t, ParseInstallFlags(InstallFlagValues{ Scope: stringValue(opts["scope"]), @@ -145,6 +155,9 @@ func runCase(t *testing.T, tc goldenCase, home, workspace string) { must(t, err) equal(t, hostIDs(hosts), expected) } + if tc.Operation == "detect" { + return + } report, err := runReportCase(t, tc, opts, base) if throws, ok := tc.Expected["throws"].(bool); ok && throws { if err == nil { @@ -416,21 +429,40 @@ func agentSelector(value any) AgentSelector { } func skillBundleFromOptions(opts map[string]any) SkillBundle { + var bundle SkillBundle if files, ok := opts["skillFiles"].([]any); ok { - return FilesBundle(skillFiles(files)) - } - if dir, ok := opts["skillBundleDir"].(string); ok { - return DirectoryBundle(repoPathFromCase(dir)) + bundle = FilesBundle(skillFiles(files)) + } else if dir, ok := opts["skillBundleDir"].(string); ok { + bundle = DirectoryBundle(repoPathFromCase(dir)) + } else if github, ok := opts["githubBundle"].(map[string]any); ok { + bundle = GitHubBundle(GitHubBundleOptions{ + Owner: github["owner"].(string), + Repo: github["repo"].(string), + Path: github["path"].(string), + Ref: github["ref"].(string), + }) } - 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), + if metadata, ok := opts["bundleMetadata"].(map[string]any); ok { + bundle = WithBundleMetadata(bundle, BundledMetadata{ + CLIVersion: stringValue(metadata["cliVersion"]), + Revision: stringValue(metadata["revision"]), + SourceID: stringValue(metadata["sourceId"]), + Provenance: stringMap(metadata["provenance"]), }) } - return SkillBundle{} + return bundle +} + +func stringMap(value any) map[string]string { + raw, ok := value.(map[string]any) + if !ok { + return nil + } + out := make(map[string]string, len(raw)) + for key, value := range raw { + out[key] = value.(string) + } + return out } func skillFiles(values []any) []SkillFile { @@ -530,7 +562,7 @@ func repoPathFromCase(path string) string { if filepath.IsAbs(path) { return path } - return filepath.Join("..", path) + return path } func hostIDs(hosts []Host) []string { diff --git a/go/spec/hosts.json b/go/spec/hosts.json new file mode 100644 index 0000000..0343079 --- /dev/null +++ b/go/spec/hosts.json @@ -0,0 +1,1094 @@ +{ + "$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" + ], + "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" + ], + "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" + ], + "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/testdata/cases/bundled-skill-install.json b/go/testdata/cases/bundled-skill-install.json new file mode 100644 index 0000000..da5c129 --- /dev/null +++ b/go/testdata/cases/bundled-skill-install.json @@ -0,0 +1,3417 @@ +{ + "$schema": "../cases.schema.json", + "schemaVersion": 1, + "cases": [ + { + "id": "all-supported-hosts-load", + "operation": "resolve-hosts", + "description": "Loads the full host adapter baseline from spec/hosts.json.", + "options": { + "agents": "*" + }, + "given": { + "hostsFile": "spec/hosts.json" + }, + "expected": { + "count": 72, + "hostIds": [ + "adal", + "aider-desk", + "amp", + "antigravity", + "antigravity-cli", + "astrbot", + "augment", + "autohand-code", + "bob", + "claude-code", + "cline", + "codearts-agent", + "codebuddy", + "codemaker", + "codestudio", + "codex", + "command-code", + "continue", + "cortex", + "crush", + "cursor", + "deepagents", + "devin", + "dexto", + "droid", + "eve", + "firebender", + "forgecode", + "gemini-cli", + "github-copilot", + "goose", + "hermes-agent", + "iflow-cli", + "inference-sh", + "jazz", + "junie", + "kilo", + "kimi-cli", + "kiro-cli", + "kode", + "lingma", + "loaf", + "mcpjam", + "mistral-vibe", + "moxby", + "mux", + "neovate", + "ona", + "openclaw", + "opencode", + "openhands", + "pi", + "pochi", + "promptscript", + "qoder", + "qoder-cn", + "qwen-code", + "reasonix", + "replit", + "roo", + "rovodev", + "tabnine-cli", + "terramind", + "tinycloud", + "trae", + "trae-cn", + "universal", + "warp", + "windsurf", + "zed", + "zencoder", + "zenflow" + ] + } + }, + { + "id": "alias-resolution", + "operation": "resolve-hosts", + "description": "Resolves legacy or ecosystem-compatible host aliases to canonical adapter ids.", + "options": { + "agents": [ + "roo-code" + ] + }, + "given": { + "hostsFile": "spec/hosts.json" + }, + "expected": { + "resolvedHostIds": [ + "roo" + ] + } + }, + { + "id": "kimi-alias-resolution", + "operation": "resolve-hosts", + "description": "Resolves the kimi-code-cli alias to the canonical kimi-cli adapter id.", + "options": { + "agents": [ + "kimi-code-cli" + ] + }, + "given": { + "hostsFile": "spec/hosts.json" + }, + "expected": { + "resolvedHostIds": [ + "kimi-cli" + ] + } + }, + { + "id": "unknown-host-id", + "operation": "resolve-hosts", + "description": "Reports a structured error when an explicit host id or alias is unknown.", + "options": { + "agents": [ + "missing-agent" + ] + }, + "given": { + "hostsFile": "spec/hosts.json" + }, + "expected": { + "resolvedHostIds": [], + "errors": [ + { + "agent": "missing-agent", + "reason": "unknown-host" + } + ] + } + }, + { + "id": "parse-install-flags-defaults", + "operation": "parse-install-flags", + "description": "Maps missing install flags to the user default while recording that scope was not explicit.", + "options": {}, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "parsed": { + "scope": "user", + "scopeSet": false, + "agentKind": "auto", + "agentIds": [], + "yes": false, + "dryRun": false, + "force": false, + "errors": [] + } + } + }, + { + "id": "parse-install-flags-explicit", + "operation": "parse-install-flags", + "description": "Maps repeated and comma-separated agent flags to a deduplicated explicit selector.", + "options": { + "scope": "project", + "agents": [ + "codex,claude-code", + "codex" + ], + "yes": true, + "dryRun": true, + "force": true + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "parsed": { + "scope": "project", + "scopeSet": true, + "agentKind": "explicit", + "agentIds": [ + "codex", + "claude-code" + ], + "yes": true, + "dryRun": true, + "force": true, + "errors": [] + } + } + }, + { + "id": "parse-install-flags-star", + "operation": "parse-install-flags", + "description": "Maps --agent '*' to the all-host selector.", + "options": { + "agents": [ + "*" + ] + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "parsed": { + "scope": "user", + "scopeSet": false, + "agentKind": "*", + "agentIds": [], + "yes": false, + "dryRun": false, + "force": false, + "errors": [] + } + } + }, + { + "id": "parse-install-flags-errors", + "operation": "parse-install-flags", + "description": "Reports invalid scope and mixed star agent selector flags.", + "options": { + "scope": "global", + "agents": [ + "*", + "codex" + ] + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "parsed": { + "scope": "user", + "scopeSet": true, + "agentKind": "*", + "agentIds": [], + "yes": false, + "dryRun": false, + "force": false, + "errors": [ + { + "flag": "scope", + "reason": "invalid-scope", + "value": "global" + }, + { + "flag": "agent", + "reason": "agent-star-must-be-alone", + "value": "*,codex" + } + ] + } + } + }, + { + "id": "shared-target-deduplication-many-hosts", + "operation": "install", + "description": "Copies once when many supported agents share the .agents/skills project target.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "project", + "agents": [ + "amp", + "antigravity", + "antigravity-cli", + "cline", + "codex", + "cursor", + "deepagents", + "dexto", + "firebender", + "gemini-cli", + "github-copilot", + "kimi-cli", + "loaf", + "opencode", + "promptscript", + "replit", + "universal", + "warp", + "zed" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "report": { + "installed": [ + { + "hostIds": [ + "amp", + "antigravity", + "antigravity-cli", + "cline", + "codex", + "cursor", + "deepagents", + "dexto", + "firebender", + "gemini-cli", + "github-copilot", + "kimi-cli", + "loaf", + "opencode", + "promptscript", + "replit", + "universal", + "warp", + "zed" + ], + "skillName": "basic", + "targetDir": "$WORKSPACE/.agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "writeCountByTargetDir": { + "$WORKSPACE/.agents/skills/basic": 1 + } + } + }, + { + "id": "user-scope-install", + "operation": "install", + "description": "Installs a bundled skill into the canonical user scope for an explicit host.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills" + ], + "files": {} + }, + "expected": { + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/SKILL.md", + "$HOME/.agents/skills/basic/references/guide.md", + "$HOME/.agents/skills/basic/scripts/helper.sh", + "$HOME/.agents/skills/basic/assets/template.json", + "$HOME/.agents/skills/basic/.kitup.json" + ], + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled" + }, + "hash": "from-skill-bundle-dir" + } + } + }, + { + "id": "codex-user-scope-reuses-existing-compatible-dir", + "operation": "install", + "description": "Reuses the existing Codex-compatible user directory when the canonical directory does not exist.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex/skills" + ], + "files": {} + }, + "expected": { + "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-prefers-first-user-dir", + "operation": "install", + "description": "Prefers the first canonical Codex user path when both compatible directories already exist.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills", + "$HOME/.codex/skills" + ], + "files": {} + }, + "expected": { + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/SKILL.md", + "$HOME/.agents/skills/basic/.kitup.json" + ], + "filesAbsent": [ + "$HOME/.codex/skills/basic" + ] + } + }, + { + "id": "project-scope-install", + "operation": "install", + "description": "Installs a bundled skill into the canonical project scope for an explicit host.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "project", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$WORKSPACE/.agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$WORKSPACE/.agents/skills/basic/SKILL.md", + "$WORKSPACE/.agents/skills/basic/.kitup.json" + ], + "fileModes": { + "$WORKSPACE/.agents/skills/basic": "755" + } + } + }, + { + "id": "project-scope-plan", + "operation": "plan", + "description": "Reports the project-scope install target without writing skill files.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "project", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$WORKSPACE/.agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesAbsent": [ + "$WORKSPACE/.agents/skills/basic" + ] + } + }, + { + "id": "project-only-host-project-scope-install", + "operation": "install", + "description": "Installs into a project-only host when project scope is selected explicitly.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "project", + "agents": [ + "eve" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "report": { + "installed": [ + { + "hostId": "eve", + "skillName": "basic", + "targetDir": "$WORKSPACE/agent/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$WORKSPACE/agent/skills/basic/SKILL.md", + "$WORKSPACE/agent/skills/basic/.kitup.json" + ] + } + }, + { + "id": "project-only-host-user-scope-error", + "operation": "install", + "description": "Reports an unsupported-scope error when a project-only host is selected for user scope.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "eve" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [ + { + "hostId": "eve", + "skillName": "basic", + "scope": "user", + "reason": "unsupported-scope" + } + ] + } + } + }, + { + "id": "explicit-host-selection", + "operation": "install", + "description": "Installs only into the explicitly selected host targets.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex", + "claude-code" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills", + "$HOME/.claude/skills" + ], + "files": {} + }, + "expected": { + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + }, + { + "hostId": "claude-code", + "skillName": "basic", + "targetDir": "$HOME/.claude/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + } + } + }, + { + "id": "auto-host-detection", + "operation": "install", + "description": "Uses detection paths to select installed hosts for automatic installation.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": "auto", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex", + "$HOME/.claude", + "$HOME/.agents/skills", + "$HOME/.claude/skills" + ], + "files": {} + }, + "expected": { + "detectedHosts": [ + "codex", + "claude-code" + ], + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + }, + { + "hostId": "claude-code", + "skillName": "basic", + "targetDir": "$HOME/.claude/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + } + } + }, + { + "id": "auto-host-detection-empty", + "operation": "install", + "description": "Returns an empty report when automatic host detection finds no supported hosts.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": "auto", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "detectedHosts": [], + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + } + } + }, + { + "id": "auto-host-detection-secondary-specific-path", + "operation": "detect", + "description": "Detects a host when a non-primary specific detection path exists.", + "options": { + "scope": "user", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.clawdbot" + ], + "files": {} + }, + "expected": { + "detectedHosts": [ + "openclaw" + ] + } + }, + { + "id": "auto-host-detection-generic-path-only", + "operation": "detect", + "description": "Does not infer a specific host from a shared compatibility root alone.", + "options": { + "scope": "user", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents" + ], + "files": { + "$WORKSPACE/package.json": "{}\n" + } + }, + "expected": { + "detectedHosts": [] + } + }, + { + "id": "shared-target-deduplication", + "operation": "install", + "description": "Copies once when multiple selected hosts resolve to the same canonical target directory.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex", + "warp", + "gemini-cli" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills" + ], + "files": {} + }, + "expected": { + "report": { + "installed": [ + { + "hostIds": [ + "codex", + "warp", + "gemini-cli" + ], + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "writeCountByTargetDir": { + "$HOME/.agents/skills/basic": 1 + } + } + }, + { + "id": "unchanged-noop", + "operation": "install", + "description": "Skips a kitup-owned target when its metadata hash matches the bundled skill.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "copySkillBundleTo": "$HOME/.agents/skills/basic", + "metadata": { + "path": "$HOME/.agents/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/.agents/skills/basic", + "reason": "unchanged" + } + ], + "conflicts": [], + "errors": [] + } + } + }, + { + "id": "unchanged-content-refreshes-bundled-metadata", + "operation": "install", + "description": "Refreshes bundled CLI metadata when the skill content hash is unchanged.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "bundleMetadata": { + "cliVersion": "2.0.0", + "revision": "new456", + "sourceId": "example-cli:embedded", + "provenance": { + "build": "release" + } + }, + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "copySkillBundleTo": "$HOME/.agents/skills/basic", + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "cliVersion": "1.0.0", + "revision": "old123", + "sourceId": "example-cli:embedded", + "provenance": { + "build": "development" + } + }, + "hash": "from-skill-bundle-dir" + } + }, + "expected": { + "report": { + "installed": [], + "updated": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "hash": "from-skill-bundle-dir", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "cliVersion": "2.0.0", + "revision": "new456", + "sourceId": "example-cli:embedded", + "provenance": { + "build": "release" + } + } + } + } + }, + { + "id": "workflow-unchanged-silent", + "operation": "run-install-workflow", + "description": "Does not render a user-facing summary or confirmation when every selected target is unchanged.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "stdinTTY": true, + "yes": false, + "input": "y\n", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "copySkillBundleTo": "$HOME/.agents/skills/basic", + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled" + }, + "hash": "from-skill-bundle-dir" + } + }, + "expected": { + "workflow": { + "canceled": false, + "dryRun": false + }, + "exit": { + "ok": true, + "code": "ok", + "message": "" + }, + "output": "", + "report": { + "installed": [], + "updated": [], + "skipped": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "unchanged" + } + ], + "conflicts": [], + "errors": [] + } + } + }, + { + "id": "unchanged-repairs-script-mode", + "operation": "install", + "description": "Repairs script executable mode drift even when the stored content hash is unchanged.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills" + ], + "copySkillBundleTo": "$HOME/.agents/skills/basic", + "fileModes": { + "$HOME/.agents/skills/basic/scripts/helper.sh": "644" + }, + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled" + }, + "hash": "from-skill-bundle-dir" + } + }, + "expected": { + "report": { + "installed": [], + "updated": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "fileModes": { + "$HOME/.agents/skills/basic/scripts/helper.sh": "755" + } + } + }, + { + "id": "workflow-conflict-exit", + "operation": "run-install-workflow", + "description": "Returns a conflict exit classification when the plan has conflicts but no writes.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "stdinTTY": true, + "yes": false, + "input": "y\n", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/SKILL.md": "---\nname: basic\ndescription: Unmanaged fixture.\n---\n\n# Basic\n" + } + }, + "expected": { + "workflow": { + "canceled": false, + "dryRun": false + }, + "exit": { + "ok": false, + "code": "conflict", + "message": "Installation has conflicts." + }, + "output": "", + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "unmanaged" + } + ], + "errors": [] + } + } + }, + { + "id": "workflow-conflict-blocks-writes", + "operation": "run-install-workflow", + "description": "Does not write any target when the plan mixes writable targets with conflicts.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex", + "claude-code" + ], + "stdinTTY": true, + "yes": true, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/SKILL.md": "---\nname: basic\ndescription: Unmanaged fixture.\n---\n\n# Basic\n" + } + }, + "expected": { + "workflow": { + "canceled": false, + "dryRun": false + }, + "exit": { + "ok": false, + "code": "conflict", + "message": "Installation has conflicts." + }, + "output": "", + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "unmanaged" + } + ], + "errors": [] + }, + "filesAbsent": [ + "$HOME/.claude/skills/basic/SKILL.md" + ] + } + }, + { + "id": "workflow-dry-run-conflict-renders-plan", + "operation": "run-install-workflow", + "description": "Dry-run renders writable plan items even when another target conflicts.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex", + "claude-code" + ], + "stdinTTY": true, + "yes": true, + "dryRun": true, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/SKILL.md": "---\nname: basic\ndescription: Unmanaged fixture.\n---\n\n# Basic\n" + } + }, + "expected": { + "workflow": { + "canceled": false, + "dryRun": true + }, + "exit": { + "ok": false, + "code": "conflict", + "message": "Installation has conflicts." + }, + "outputContains": [ + " - basic -> ", + "(claude-code)" + ], + "report": { + "installed": [ + { + "hostId": "claude-code", + "skillName": "basic", + "targetDir": "$HOME/.claude/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "unmanaged" + } + ], + "errors": [] + }, + "filesAbsent": [ + "$HOME/.claude/skills/basic/SKILL.md" + ] + } + }, + { + "id": "workflow-force-overwrites-conflicts", + "operation": "run-install-workflow", + "description": "Force turns unmanaged and different-owner targets into explicit updates.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex", + "claude-code" + ], + "stdinTTY": true, + "yes": true, + "force": true, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic", + "$HOME/.claude/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/SKILL.md": "---\nname: basic\ndescription: Unmanaged fixture.\n---\n\n# Basic\n", + "$HOME/.claude/skills/basic/SKILL.md": "---\nname: basic\ndescription: Other owner fixture.\n---\n\n# Basic\n", + "$HOME/.claude/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "other-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:old" + } + } + }, + "expected": { + "workflow": { + "canceled": false, + "dryRun": false + }, + "exit": { + "ok": true, + "code": "ok", + "message": "" + }, + "outputContains": [ + "(codex)", + "(claude-code)" + ], + "report": { + "installed": [], + "updated": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + }, + { + "hostId": "claude-code", + "skillName": "basic", + "targetDir": "$HOME/.claude/skills/basic" + } + ], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/SKILL.md", + "$HOME/.claude/skills/basic/SKILL.md" + ], + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "hash": "from-skill-bundle-dir", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled" + } + } + } + }, + { + "id": "changed-update", + "operation": "update", + "description": "Replaces a kitup-owned target when its metadata hash differs from the bundled skill.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/SKILL.md": "---\nname: basic\ndescription: Old fixture.\n---\n\n# Old\n", + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:old" + } + } + }, + "expected": { + "report": { + "installed": [], + "updated": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/references/guide.md", + "$HOME/.agents/skills/basic/assets/template.json" + ] + } + }, + { + "id": "unmanaged-conflict", + "operation": "install", + "description": "Refuses to overwrite a target directory that has no kitup metadata.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/SKILL.md": "---\nname: basic\ndescription: Existing unmanaged skill.\n---\n" + } + }, + "expected": { + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "unmanaged" + } + ], + "errors": [] + } + } + }, + { + "id": "install-incomplete-metadata-conflict", + "operation": "install", + "description": "Treats incomplete .kitup.json as unmanaged and refuses overwrite.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "appId": "example-cli" + } + } + }, + "expected": { + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "unmanaged" + } + ], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/.kitup.json" + ] + } + }, + { + "id": "install-skill-name-mismatch-conflict", + "operation": "install", + "description": "Treats metadata for a different skill name as unmanaged and refuses overwrite.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "other", + "source": "bundled", + "hash": "sha256:old" + } + } + }, + "expected": { + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "unmanaged" + } + ], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/.kitup.json" + ] + } + }, + { + "id": "update-skill-name-mismatch-with-force", + "operation": "update", + "description": "Force replaces metadata for a different skill name and records the requested owner.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "force": true, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "other", + "source": "bundled", + "hash": "sha256:old" + } + } + }, + "expected": { + "report": { + "installed": [], + "updated": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/SKILL.md" + ], + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "hash": "from-skill-bundle-dir", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled" + } + } + } + }, + { + "id": "different-owner-conflict", + "operation": "install", + "description": "Refuses to overwrite a kitup-managed target owned by another app.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "other-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:old" + } + } + }, + "expected": { + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "owner-mismatch" + } + ], + "errors": [] + } + } + }, + { + "id": "uninstall-owned-skill", + "operation": "uninstall", + "description": "Removes only a kitup-owned skill target for the same app id.", + "options": { + "appId": "example-cli", + "skillName": "basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "copySkillBundleTo": "$HOME/.agents/skills/basic", + "metadata": { + "path": "$HOME/.agents/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/.agents/skills/basic" + } + ], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesAbsent": [ + "$HOME/.agents/skills/basic" + ] + } + }, + { + "id": "uninstall-owner-mismatch", + "operation": "uninstall", + "description": "Refuses to remove a skill target owned by another app id.", + "options": { + "appId": "example-cli", + "skillName": "basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "other-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:old" + } + } + }, + "expected": { + "report": { + "removed": [], + "skipped": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "owner-mismatch" + } + ], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/.kitup.json" + ] + } + }, + { + "id": "missing-skill-md", + "operation": "validate", + "description": "Rejects a skill directory without SKILL.md.", + "options": { + "skillBundleDir": "testdata/skills/missing-skill-md", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "valid": false, + "errorCode": "missing-skill-md" + } + }, + { + "id": "invalid-frontmatter", + "operation": "validate", + "description": "Rejects SKILL.md frontmatter that violates the Agent Skills naming and description rules.", + "options": { + "skillBundleDir": "testdata/skills/invalid-frontmatter", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "valid": false, + "errorCode": "invalid-frontmatter" + } + }, + { + "id": "nested-resources-copied", + "operation": "install", + "description": "Copies nested references, scripts, and assets with the bundled skill.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills" + ], + "files": {} + }, + "expected": { + "filesPresent": [ + "$HOME/.agents/skills/basic/SKILL.md", + "$HOME/.agents/skills/basic/references/guide.md", + "$HOME/.agents/skills/basic/scripts/helper.sh", + "$HOME/.agents/skills/basic/assets/template.json" + ] + } + }, + { + "id": "embedded-skill-source", + "operation": "install", + "description": "Installs a bundled skill from an embedded directory tree source without materializing a directory source first.", + "options": { + "appId": "example-cli", + "skillFiles": [ + { + "path": "SKILL.md", + "contents": "---\nname: embedded\ndescription: Embedded whole skill tree fixture.\n---\n\n# Embedded\n" + }, + { + "path": "references/guide.md", + "contents": "# Guide\n" + }, + { + "path": "assets/template.json", + "contents": "{\"ok\":true}\n" + }, + { + "path": "scripts/helper.sh", + "contents": "#!/usr/bin/env sh\necho embedded\n" + }, + { + "path": "scripts/disabled.sh", + "contents": "#!/usr/bin/env sh\necho disabled\n", + "mode": 420 + }, + { + "path": ".kitup.json", + "contents": "{\"ignored\":true}\n" + }, + { + "path": ".DS_Store", + "contents": "ignored" + } + ], + "bundleMetadata": { + "cliVersion": "1.2.3", + "revision": "abc123", + "sourceId": "example-cli:embedded", + "provenance": { + "channel": "release", + "build": "42" + } + }, + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills" + ], + "files": {} + }, + "expected": { + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "embedded", + "targetDir": "$HOME/.agents/skills/embedded" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/embedded/SKILL.md", + "$HOME/.agents/skills/embedded/references/guide.md", + "$HOME/.agents/skills/embedded/assets/template.json", + "$HOME/.agents/skills/embedded/scripts/helper.sh", + "$HOME/.agents/skills/embedded/.kitup.json" + ], + "filesAbsent": [ + "$HOME/.agents/skills/embedded/.DS_Store" + ], + "fileModes": { + "$HOME/.agents/skills/embedded/scripts/helper.sh": "755", + "$HOME/.agents/skills/embedded/scripts/disabled.sh": "644" + }, + "metadata": { + "path": "$HOME/.agents/skills/embedded/.kitup.json", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "embedded", + "source": "bundled", + "cliVersion": "1.2.3", + "revision": "abc123", + "sourceId": "example-cli:embedded", + "provenance": { + "channel": "release", + "build": "42" + } + }, + "hash": "from-skill-files" + } + } + }, + { + "id": "workflow-explicit-agent", + "operation": "resolve-install-selection", + "description": "Explicit agents select canonical hosts without detection.", + "options": { + "scope": "user", + "agents": [ + "codex" + ], + "stdinTTY": false, + "yes": false, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "selection": { + "action": "install", + "selectedHostIds": [ + "codex" + ], + "candidateHostIds": [], + "detectedHostIds": [], + "needsConfirmation": false, + "errors": [] + } + } + }, + { + "id": "workflow-agent-star", + "operation": "resolve-install-selection", + "description": "Agent star explicitly selects every supported host.", + "options": { + "scope": "user", + "agents": "*", + "stdinTTY": false, + "yes": true, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "selection": { + "action": "install", + "selectedCount": 72, + "candidateHostIds": [], + "detectedHostIds": [], + "needsConfirmation": false, + "errors": [] + } + } + }, + { + "id": "workflow-scope-prompt-before-agent", + "operation": "run-install-workflow", + "description": "TTY workflow prompts for scope before planning an explicit agent install when scope was not provided.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "agents": [ + "codex" + ], + "stdinTTY": true, + "promptScope": true, + "input": "project\ny\n", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "workflow": { + "scope": "project", + "canceled": false, + "dryRun": false + }, + "outputContains": [ + "Select install scope:", + "Scope (user/project) [user]: ", + " - basic -> ", + "(codex)" + ], + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$WORKSPACE/.agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$WORKSPACE/.agents/skills/basic/SKILL.md" + ] + } + }, + { + "id": "workflow-scope-non-tty-error", + "operation": "run-install-workflow", + "description": "Non-TTY workflow refuses to choose scope implicitly when scope was not provided.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "agents": [ + "codex" + ], + "stdinTTY": false, + "promptScope": true, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "workflow": { + "scope": "", + "canceled": false, + "dryRun": false + }, + "exit": { + "ok": false, + "code": "selection-error", + "message": "Agent selection failed." + }, + "output": "kitup: scope-selection-required\n", + "filesAbsent": [ + "$HOME/.agents/skills/basic/SKILL.md", + "$WORKSPACE/.agents/skills/basic/SKILL.md" + ] + } + }, + { + "id": "workflow-scope-yes-default", + "operation": "run-install-workflow", + "description": "Yes mode uses the configured default scope when scope was not provided.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "agents": [ + "codex" + ], + "stdinTTY": false, + "promptScope": true, + "defaultScope": "project", + "yes": true, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "workflow": { + "scope": "project", + "canceled": false, + "dryRun": false + }, + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$WORKSPACE/.agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$WORKSPACE/.agents/skills/basic/SKILL.md" + ] + } + }, + { + "id": "workflow-shared-target-renders-host-rows", + "operation": "run-install-workflow", + "description": "Shared target install still renders one user-facing row per selected host.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "project", + "agents": [ + "codex", + "cursor", + "github-copilot", + "opencode" + ], + "stdinTTY": true, + "input": "y\n", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "workflow": { + "scope": "project", + "canceled": false, + "dryRun": false + }, + "outputContains": [ + "(codex)", + "(cursor)", + "(github-copilot)", + "(opencode)", + "Proceed? [y/N]" + ], + "report": { + "installed": [ + { + "hostIds": [ + "codex", + "cursor", + "github-copilot", + "opencode" + ], + "skillName": "basic", + "targetDir": "$WORKSPACE/.agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$WORKSPACE/.agents/skills/basic/SKILL.md" + ] + } + }, + { + "id": "workflow-zero-detected-tty-prompts", + "operation": "resolve-install-selection", + "description": "TTY with no detected hosts asks the user to choose from supported hosts.", + "options": { + "scope": "user", + "stdinTTY": true, + "yes": false, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "selection": { + "action": "select-agents", + "selectedHostIds": [], + "candidateCount": 72, + "detectedHostIds": [], + "needsConfirmation": true, + "errors": [] + } + } + }, + { + "id": "workflow-one-detected-auto-selects", + "operation": "resolve-install-selection", + "description": "TTY with one detected host can select it before summary confirmation.", + "options": { + "scope": "user", + "stdinTTY": true, + "yes": false, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex" + ], + "files": {} + }, + "expected": { + "selection": { + "action": "install", + "selectedHostIds": [ + "codex" + ], + "candidateHostIds": [], + "detectedHostIds": [ + "codex" + ], + "needsConfirmation": true, + "errors": [] + } + } + }, + { + "id": "workflow-many-detected-tty-prompts", + "operation": "resolve-install-selection", + "description": "TTY with multiple detected hosts enters agent multi-selection instead of auto-installing all.", + "options": { + "scope": "user", + "stdinTTY": true, + "yes": false, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex", + "$HOME/.claude" + ], + "files": {} + }, + "expected": { + "selection": { + "action": "select-agents", + "selectedHostIds": [], + "candidateHostIds": [ + "codex", + "claude-code" + ], + "detectedHostIds": [ + "codex", + "claude-code" + ], + "needsConfirmation": true, + "errors": [] + } + } + }, + { + "id": "workflow-many-detected-enter-cancels", + "operation": "run-install-workflow", + "description": "TTY multi-select with an empty selection cancels instead of installing every detected host.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "stdinTTY": true, + "yes": false, + "input": "\n", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex", + "$HOME/.claude" + ], + "files": {} + }, + "expected": { + "workflow": { + "canceled": true, + "dryRun": false + }, + "exit": { + "ok": false, + "code": "canceled", + "message": "Installation canceled." + }, + "outputContains": [ + "Select agents:" + ], + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesAbsent": [ + "$HOME/.agents/skills/basic/SKILL.md", + "$HOME/.claude/skills/basic/SKILL.md" + ] + } + }, + { + "id": "workflow-many-detected-select-one-confirms", + "operation": "run-install-workflow", + "description": "TTY multi-select installs only the selected host after summary confirmation.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "stdinTTY": true, + "yes": false, + "input": "1\ny\n", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex", + "$HOME/.claude" + ], + "files": {} + }, + "expected": { + "workflow": { + "canceled": false, + "dryRun": false + }, + "exit": { + "ok": true, + "code": "ok", + "message": "" + }, + "outputContains": [ + "Select agents:", + "(codex)", + "Proceed? [y/N]" + ], + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/SKILL.md", + "$HOME/.agents/skills/basic/assets/template.json" + ], + "filesAbsent": [ + "$HOME/.claude/skills/basic/SKILL.md" + ] + } + }, + { + "id": "workflow-one-detected-confirms-installs", + "operation": "run-install-workflow", + "description": "TTY with one detected host still renders a summary and waits for confirmation before writing.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "stdinTTY": true, + "yes": false, + "input": "y\n", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex" + ], + "files": {} + }, + "expected": { + "workflow": { + "canceled": false, + "dryRun": false + }, + "exit": { + "ok": true, + "code": "ok", + "message": "" + }, + "outputContains": [ + "(codex)", + "Proceed? [y/N]" + ], + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/SKILL.md" + ] + } + }, + { + "id": "workflow-yes-batch-installs-detected", + "operation": "run-install-workflow", + "description": "Yes mode skips prompts and installs every detected host explicitly selected by policy.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "stdinTTY": false, + "yes": true, + "input": "", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex", + "$HOME/.claude" + ], + "files": {} + }, + "expected": { + "workflow": { + "canceled": false, + "dryRun": false + }, + "exit": { + "ok": true, + "code": "ok", + "message": "" + }, + "outputContains": [ + "(codex)" + ], + "report": { + "installed": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + }, + { + "hostId": "claude-code", + "skillName": "basic", + "targetDir": "$HOME/.claude/skills/basic" + } + ], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/SKILL.md", + "$HOME/.claude/skills/basic/SKILL.md" + ] + } + }, + { + "id": "workflow-many-detected-yes-installs", + "operation": "resolve-install-selection", + "description": "Yes mode can accept detected hosts non-interactively.", + "options": { + "scope": "user", + "stdinTTY": false, + "yes": true, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex", + "$HOME/.claude" + ], + "files": {} + }, + "expected": { + "selection": { + "action": "install", + "selectedHostIds": [ + "codex", + "claude-code" + ], + "candidateHostIds": [], + "detectedHostIds": [ + "codex", + "claude-code" + ], + "needsConfirmation": false, + "errors": [] + } + } + }, + { + "id": "workflow-non-tty-no-agent-error", + "operation": "resolve-install-selection", + "description": "Non-TTY without explicit agent or yes cannot enter interactive selection.", + "options": { + "scope": "user", + "stdinTTY": false, + "yes": false, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex" + ], + "files": {} + }, + "expected": { + "selection": { + "action": "error", + "selectedHostIds": [], + "candidateHostIds": [], + "detectedHostIds": [ + "codex" + ], + "needsConfirmation": false, + "errors": [ + { + "reason": "agent-selection-required" + } + ] + } + } + }, + { + "id": "workflow-zero-detected-yes-error", + "operation": "resolve-install-selection", + "description": "Yes mode does not turn zero detection into all-host installation.", + "options": { + "scope": "user", + "stdinTTY": false, + "yes": true, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": {} + }, + "expected": { + "selection": { + "action": "error", + "selectedHostIds": [], + "candidateHostIds": [], + "detectedHostIds": [], + "needsConfirmation": false, + "errors": [ + { + "reason": "no-detected-hosts" + } + ] + } + } + }, + { + "id": "github-bundle-install", + "operation": "install", + "description": "Installs a public GitHub bundle with provenance metadata while keeping the user-facing install flow unchanged.", + "options": { + "appId": "example-cli", + "githubBundle": { + "owner": "acme", + "repo": "mycli-skills", + "path": "skills/github-basic", + "ref": "main" + }, + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex" + ], + "files": {}, + "github": { + "owner": "acme", + "repo": "mycli-skills", + "ref": "main", + "commit": "abc123", + "treeSha": "tree123", + "files": { + "skills/github-basic/SKILL.md": "---\nname: github-basic\ndescription: GitHub sourced skill.\n---\n", + "skills/github-basic/references/guide.md": "GitHub guide.\n", + "other/SKILL.md": "---\nname: ignored\ndescription: Ignored skill.\n---\n" + } + } + }, + "expected": { + "filesPresent": [ + "$HOME/.agents/skills/github-basic/SKILL.md", + "$HOME/.agents/skills/github-basic/references/guide.md" + ], + "filesAbsent": [ + "$HOME/.agents/skills/github-basic/other/SKILL.md" + ], + "metadata": { + "path": "$HOME/.agents/skills/github-basic/.kitup.json", + "hash": "from-github-bundle", + "fields": { + "appId": "example-cli", + "skillName": "github-basic", + "source": "github", + "sourceId": "github:acme/mycli-skills/skills/github-basic", + "version": "main", + "provenance": { + "owner": "acme", + "repo": "mycli-skills", + "path": "skills/github-basic", + "ref": "main", + "resolvedCommit": "abc123" + } + } + } + } + }, + { + "id": "github-bundle-mode-only-update-refreshes-metadata", + "operation": "install", + "description": "Refreshes GitHub provenance metadata when an unchanged content hash only needs mode repair.", + "options": { + "appId": "example-cli", + "githubBundle": { + "owner": "acme", + "repo": "mycli-skills", + "path": "skills/github-mode", + "ref": "main" + }, + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex" + ], + "files": { + "$HOME/.agents/skills/github-mode/SKILL.md": "---\nname: github-mode\ndescription: GitHub mode skill.\n---\n", + "$HOME/.agents/skills/github-mode/scripts/helper.sh": "#!/bin/sh\n" + }, + "fileModes": { + "$HOME/.agents/skills/github-mode/scripts/helper.sh": "644" + }, + "metadata": { + "path": "$HOME/.agents/skills/github-mode/.kitup.json", + "hash": "from-github-bundle", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "github-mode", + "source": "github", + "sourceId": "github:acme/mycli-skills/skills/github-mode", + "version": "old", + "provenance": { + "owner": "acme", + "repo": "mycli-skills", + "path": "skills/github-mode", + "ref": "old", + "resolvedCommit": "old123" + } + } + }, + "github": { + "owner": "acme", + "repo": "mycli-skills", + "ref": "main", + "commit": "new123", + "treeSha": "tree-mode", + "files": { + "skills/github-mode/SKILL.md": "---\nname: github-mode\ndescription: GitHub mode skill.\n---\n", + "skills/github-mode/scripts/helper.sh": "#!/bin/sh\n" + } + } + }, + "expected": { + "report": { + "installed": [], + "updated": [ + { + "hostId": "codex", + "skillName": "github-mode", + "targetDir": "$HOME/.agents/skills/github-mode" + } + ], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "fileModes": { + "$HOME/.agents/skills/github-mode/scripts/helper.sh": "755" + }, + "metadata": { + "path": "$HOME/.agents/skills/github-mode/.kitup.json", + "hash": "from-github-bundle", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "github-mode", + "source": "github", + "sourceId": "github:acme/mycli-skills/skills/github-mode", + "version": "main", + "provenance": { + "owner": "acme", + "repo": "mycli-skills", + "path": "skills/github-mode", + "ref": "main", + "resolvedCommit": "new123" + } + } + } + } + }, + { + "id": "github-bundle-dry-run", + "operation": "run-install-workflow", + "description": "Plans a GitHub bundle install without writing files in dry-run mode.", + "options": { + "appId": "example-cli", + "githubBundle": { + "owner": "acme", + "repo": "mycli-skills", + "path": "skills/github-basic", + "ref": "main" + }, + "scope": "user", + "agents": [ + "codex" + ], + "yes": true, + "dryRun": true, + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex" + ], + "files": {}, + "github": { + "owner": "acme", + "repo": "mycli-skills", + "ref": "main", + "commit": "abc123", + "treeSha": "tree123", + "files": { + "skills/github-basic/SKILL.md": "---\nname: github-basic\ndescription: GitHub sourced skill.\n---\n" + } + } + }, + "expected": { + "workflow": { + "canceled": false, + "dryRun": true + }, + "filesAbsent": [ + "$HOME/.agents/skills/github-basic/SKILL.md", + "$HOME/.agents/skills/github-basic/.kitup.json" + ] + } + }, + { + "id": "github-bundle-resolve-failure", + "operation": "install", + "description": "Reports a structured error when the configured GitHub bundle path has no files.", + "options": { + "appId": "example-cli", + "githubBundle": { + "owner": "acme", + "repo": "mycli-skills", + "path": "skills/missing", + "ref": "main" + }, + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex" + ], + "files": {}, + "github": { + "owner": "acme", + "repo": "mycli-skills", + "ref": "main", + "commit": "abc123", + "treeSha": "tree123", + "files": { + "skills/github-basic/SKILL.md": "---\nname: github-basic\ndescription: GitHub sourced skill.\n---\n" + } + } + }, + "expected": { + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [ + { + "reason": "bundle-resolve-failed" + } + ] + }, + "filesAbsent": [ + "$HOME/.agents/skills/github-basic/SKILL.md" + ] + } + }, + { + "id": "github-bundle-metadata-refresh", + "operation": "install", + "description": "Refreshes missing GitHub source metadata even when kitup ownership and content hash are unchanged.", + "options": { + "appId": "example-cli", + "githubBundle": { + "owner": "acme", + "repo": "mycli-skills", + "path": "skills/github-basic", + "ref": "main" + }, + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex" + ], + "files": {}, + "metadata": { + "path": "$HOME/.agents/skills/github-basic/.kitup.json", + "hash": "from-github-bundle", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "github-basic", + "source": "github" + } + }, + "github": { + "owner": "acme", + "repo": "mycli-skills", + "ref": "main", + "commit": "abc123", + "treeSha": "tree123", + "files": { + "skills/github-basic/SKILL.md": "---\nname: github-basic\ndescription: GitHub sourced skill.\n---\n" + } + } + }, + "expected": { + "report": { + "installed": [], + "updated": [ + { + "hostId": "codex", + "skillName": "github-basic", + "targetDir": "$HOME/.agents/skills/github-basic" + } + ], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "metadata": { + "path": "$HOME/.agents/skills/github-basic/.kitup.json", + "hash": "from-github-bundle", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "github-basic", + "source": "github", + "sourceId": "github:acme/mycli-skills/skills/github-basic", + "version": "main", + "provenance": { + "owner": "acme", + "repo": "mycli-skills", + "path": "skills/github-basic", + "ref": "main", + "resolvedCommit": "abc123" + } + } + } + } + }, + { + "id": "uninstall-rejects-path-traversal-skill-name", + "operation": "uninstall", + "description": "Refuses skill names that escape the install root and never deletes a sibling owned install.", + "options": { + "appId": "example-cli", + "skillName": "../victim", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/victim" + ], + "files": { + "$HOME/.agents/victim/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "victim", + "source": "bundled", + "hash": "sha256:abc" + } + } + }, + "expected": { + "report": { + "removed": [], + "skipped": [], + "conflicts": [], + "errors": [ + { + "skillName": "../victim", + "reason": "invalid-skill-name" + } + ] + }, + "filesPresent": [ + "$HOME/.agents/victim/.kitup.json" + ] + } + }, + { + "id": "uninstall-incomplete-metadata-conflict", + "operation": "uninstall", + "description": "Treats incomplete .kitup.json as unmanaged and refuses deletion.", + "options": { + "appId": "example-cli", + "skillName": "basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "appId": "example-cli" + } + } + }, + "expected": { + "report": { + "removed": [], + "skipped": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "unmanaged" + } + ], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/.kitup.json" + ] + } + }, + { + "id": "uninstall-skill-name-mismatch-conflict", + "operation": "uninstall", + "description": "Refuses deletion when metadata skillName does not exactly match the requested skill.", + "options": { + "appId": "example-cli", + "skillName": "basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "other", + "source": "bundled", + "hash": "sha256:abc" + } + } + }, + "expected": { + "report": { + "removed": [], + "skipped": [], + "conflicts": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic", + "reason": "unmanaged" + } + ], + "errors": [] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/.kitup.json" + ] + } + }, + { + "id": "hosts-file-rejects-escaped-project-path", + "operation": "install", + "description": "Rejects a custom hosts file whose project path escapes the workspace root.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "project", + "agents": [ + "evil" + ], + "hostsFile": "$WORKSPACE/hosts.json", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": { + "$WORKSPACE/hosts.json": { + "schemaVersion": 1, + "hosts": [ + { + "id": "evil", + "displayName": "Evil", + "projectSkillsDirs": [ + "../outside" + ], + "userSkillsDirs": [], + "detect": [ + ".agents" + ], + "status": "community" + } + ] + } + } + }, + "expected": { + "throws": true, + "filesAbsent": [ + "$WORKSPACE/../outside/basic", + "$HOME/../outside/basic" + ] + } + }, + { + "id": "hosts-file-rejects-windows-project-path", + "operation": "install", + "description": "Rejects a custom hosts file whose Windows-style project path escapes the workspace root.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "project", + "agents": [ + "evil" + ], + "hostsFile": "$WORKSPACE/hosts.json", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": { + "$WORKSPACE/hosts.json": { + "schemaVersion": 1, + "hosts": [ + { + "id": "evil", + "displayName": "Evil", + "projectSkillsDirs": [ + "..\\outside" + ], + "userSkillsDirs": [], + "detect": [ + ".agents" + ], + "status": "community" + } + ] + } + } + }, + "expected": { + "throws": true, + "filesAbsent": [ + "$WORKSPACE/../outside/basic" + ] + } + }, + { + "id": "hosts-file-rejects-windows-home-path", + "operation": "install", + "description": "Rejects a custom hosts file whose home path contains a Windows volume path.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "evil" + ], + "hostsFile": "$WORKSPACE/hosts.json", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [], + "files": { + "$WORKSPACE/hosts.json": { + "schemaVersion": 1, + "hosts": [ + { + "id": "evil", + "displayName": "Evil", + "projectSkillsDirs": [], + "userSkillsDirs": [ + "~/C:/outside" + ], + "detect": [ + "~/.agents" + ], + "status": "community" + } + ] + } + } + }, + "expected": { + "throws": true, + "filesAbsent": [ + "$HOME/C:/outside/basic" + ] + } + }, + { + "id": "install-rejects-empty-app-id", + "operation": "install", + "description": "Rejects an empty owner id before writing install metadata.", + "options": { + "appId": "", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills" + ], + "files": {} + }, + "expected": { + "report": { + "installed": [], + "updated": [], + "skipped": [], + "conflicts": [], + "errors": [ + { + "reason": "invalid-app-id" + } + ] + }, + "filesAbsent": [ + "$HOME/.agents/skills/basic" + ] + } + }, + { + "id": "uninstall-rejects-empty-app-id", + "operation": "uninstall", + "description": "Rejects an empty owner id before considering a managed install for deletion.", + "options": { + "appId": "", + "skillName": "basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc" + } + } + }, + "expected": { + "report": { + "removed": [], + "skipped": [], + "conflicts": [], + "errors": [ + { + "reason": "invalid-app-id" + } + ] + }, + "filesPresent": [ + "$HOME/.agents/skills/basic/.kitup.json" + ] + } + }, + { + "id": "read-installed-metadata", + "operation": "read-installed-metadata", + "description": "Reads stable installed metadata including optional bundled provenance fields.", + "options": { + "targetDir": "$HOME/.agents/skills/basic" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "sourceId": "example-cli:embedded", + "cliVersion": "1.2.3", + "revision": "abc123", + "provenance": { + "channel": "release" + } + } + } + }, + "expected": { + "installedMetadata": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "sourceId": "example-cli:embedded", + "cliVersion": "1.2.3", + "revision": "abc123", + "provenance": { + "channel": "release" + } + } + } + }, + { + "id": "read-installed-metadata-corrupt", + "operation": "read-installed-metadata", + "description": "Fails closed when an optional installed metadata field has an invalid type.", + "options": { + "targetDir": "$HOME/.agents/skills/basic" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "provenance": { + "channel": 42 + } + } + } + }, + "expected": { + "throws": true + } + }, + { + "id": "read-installed-metadata-null-optional", + "operation": "read-installed-metadata", + "description": "Fails closed when an optional installed metadata string is explicitly null.", + "options": { + "targetDir": "$HOME/.agents/skills/basic" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "cliVersion": null + } + } + }, + "expected": { + "throws": true + } + }, + { + "id": "read-installed-metadata-empty-optional", + "operation": "read-installed-metadata", + "description": "Fails closed when an optional installed metadata string is empty.", + "options": { + "targetDir": "$HOME/.agents/skills/basic" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "sourceId": "" + } + } + }, + "expected": { + "throws": true + } + }, + { + "id": "read-installed-metadata-null-provenance", + "operation": "read-installed-metadata", + "description": "Fails closed when installed metadata provenance is explicitly null.", + "options": { + "targetDir": "$HOME/.agents/skills/basic" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "provenance": null + } + } + }, + "expected": { + "throws": true + } + }, + { + "id": "initial-install-copy-failure-is-atomic", + "operation": "install", + "description": "Leaves no target behind when a new install fails while copying its bundle.", + "options": { + "appId": "example-cli", + "skillFiles": [ + { + "path": "SKILL.md", + "contents": "---\nname: atomic-failure\ndescription: Atomic initial install fixture.\n---\n" + }, + { + "path": "a", + "contents": "file\n" + }, + { + "path": "a/b", + "contents": "nested\n" + } + ], + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills" + ], + "files": {} + }, + "expected": { + "throws": true, + "filesAbsent": [ + "$HOME/.agents/skills/atomic-failure" + ] + } + } + ] +} diff --git a/go/testdata/skills/basic/SKILL.md b/go/testdata/skills/basic/SKILL.md new file mode 100644 index 0000000..0a45681 --- /dev/null +++ b/go/testdata/skills/basic/SKILL.md @@ -0,0 +1,12 @@ +--- +name: basic +description: Validate kitup installation behavior. Use when testing bundled skill copy, metadata, and nested resource handling. +--- + +# Basic Skill + +Use `references/guide.md` for the expected behavior. + +Use `assets/template.json` as fixture data when a test needs a bundled resource. + +Run `scripts/helper.sh` only when an embedding test explicitly asks for script behavior. diff --git a/go/testdata/skills/basic/assets/template.json b/go/testdata/skills/basic/assets/template.json new file mode 100644 index 0000000..8961d7f --- /dev/null +++ b/go/testdata/skills/basic/assets/template.json @@ -0,0 +1,4 @@ +{ + "name": "basic", + "purpose": "fixture" +} diff --git a/go/testdata/skills/basic/references/guide.md b/go/testdata/skills/basic/references/guide.md new file mode 100644 index 0000000..2949a95 --- /dev/null +++ b/go/testdata/skills/basic/references/guide.md @@ -0,0 +1,5 @@ +# Basic Fixture Guide + +This fixture represents a small valid Agent Skill with nested resources. + +Installers must copy this file with the rest of the skill directory. diff --git a/go/testdata/skills/basic/scripts/helper.sh b/go/testdata/skills/basic/scripts/helper.sh new file mode 100755 index 0000000..352e354 --- /dev/null +++ b/go/testdata/skills/basic/scripts/helper.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +set -eu +printf '%s\n' "basic helper" diff --git a/go/testdata/skills/invalid-frontmatter/SKILL.md b/go/testdata/skills/invalid-frontmatter/SKILL.md new file mode 100644 index 0000000..da2e3d5 --- /dev/null +++ b/go/testdata/skills/invalid-frontmatter/SKILL.md @@ -0,0 +1,8 @@ +--- +name: Invalid Name +description: +--- + +# Invalid Frontmatter + +This fixture intentionally violates the Agent Skills frontmatter rules. diff --git a/go/testdata/skills/missing-skill-md/README.md b/go/testdata/skills/missing-skill-md/README.md new file mode 100644 index 0000000..f717a71 --- /dev/null +++ b/go/testdata/skills/missing-skill-md/README.md @@ -0,0 +1,3 @@ +# Missing SKILL Fixture + +This directory intentionally has no `SKILL.md`. diff --git a/python/src/kitup/__init__.py b/python/src/kitup/__init__.py index d6d1893..873e63f 100644 --- a/python/src/kitup/__init__.py +++ b/python/src/kitup/__init__.py @@ -14,6 +14,7 @@ uninstall_bundled_skill, update_bundled_skill, ) +from ._metadata import read_installed_metadata from .workflow import ( agent_selector_from_flags, classify_install_workflow_exit, @@ -27,11 +28,13 @@ ) from .types import ( BaseOptions, + BundledMetadata, GitHubBundleOptions, Host, HostSpec, INSTALL_UX, InstallOptions, + InstalledMetadata, InstallReport, InstallSelection, InstallSelectionOptions, @@ -51,11 +54,13 @@ __all__ = [ "BaseOptions", + "BundledMetadata", "GitHubBundleOptions", "Host", "HostSpec", "INSTALL_UX", "InstallOptions", + "InstalledMetadata", "InstallReport", "InstallSelection", "InstallSelectionOptions", @@ -89,6 +94,7 @@ "resolve_hosts", "resolve_install_selection", "resolve_install_targets", + "read_installed_metadata", "run_bundled_skill_install", "run_bundled_skill_install_with_io", "uninstall_bundled_skill", diff --git a/python/src/kitup/_hosts_generated.py b/python/src/kitup/_hosts_generated.py index 71b68c1..788188d 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\",\"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\"],\"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\"],\"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\"],\"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..f280779 100644 --- a/python/src/kitup/_metadata.py +++ b/python/src/kitup/_metadata.py @@ -4,6 +4,8 @@ import re from pathlib import Path +from .types import InstalledMetadata, KitupError + _SKILL_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") @@ -20,6 +22,8 @@ def write_install_metadata( source: str, source_id: str | None = None, version: str | None = None, + cli_version: str | None = None, + revision: str | None = None, provenance: dict[str, object] | None = None, ) -> None: payload = { @@ -33,6 +37,10 @@ def write_install_metadata( payload["sourceId"] = source_id if version is not None: payload["version"] = version + if cli_version is not None: + payload["cliVersion"] = cli_version + if revision is not None: + payload["revision"] = revision if provenance is not None: payload["provenance"] = provenance (target_dir / ".kitup.json").write_text( @@ -41,18 +49,56 @@ def write_install_metadata( def read_install_metadata(target_dir: Path) -> dict[str, object] | None: + try: + metadata = read_installed_metadata(target_dir) + except KitupError: + return None + if metadata is None: + return None + payload: dict[str, object] = { + "schemaVersion": metadata.schema_version, + "appId": metadata.app_id, + "skillName": metadata.skill_name, + "source": metadata.source, + "hash": metadata.hash, + } + for key, value in ( + ("sourceId", metadata.source_id), + ("version", metadata.version), + ("cliVersion", metadata.cli_version), + ("revision", metadata.revision), + ): + if value is not None: + payload[key] = value + if metadata.provenance: + payload["provenance"] = metadata.provenance + return payload + + +def read_installed_metadata(target_dir: Path) -> InstalledMetadata | None: metadata_file = target_dir / ".kitup.json" if not metadata_file.exists(): return None try: payload = json.loads(metadata_file.read_text(encoding="utf-8")) except (OSError, ValueError): - return None + raise KitupError("invalid installed metadata") if not isinstance(payload, dict): - return None + raise KitupError("invalid installed metadata") if not is_owned_metadata(payload): - return None - return payload + raise KitupError("invalid installed metadata") + return InstalledMetadata( + schema_version=1, + app_id=str(payload["appId"]), + skill_name=str(payload["skillName"]), + source=payload["source"], + hash=str(payload["hash"]), + source_id=_optional_text(payload, "sourceId"), + version=_optional_text(payload, "version"), + cli_version=_optional_text(payload, "cliVersion"), + revision=_optional_text(payload, "revision"), + provenance=dict(payload.get("provenance", {})), + ) def is_owned_metadata(payload: dict[str, object]) -> bool: @@ -62,7 +108,7 @@ def is_owned_metadata(payload: dict[str, object]) -> bool: skill_name = payload.get("skillName") source = payload.get("source") digest = payload.get("hash") - return ( + if not ( isinstance(app_id, str) and bool(app_id) and isinstance(skill_name, str) @@ -70,4 +116,23 @@ def is_owned_metadata(payload: dict[str, object]) -> bool: and source in ("bundled", "github") and isinstance(digest, str) and bool(digest) + ): + return False + for key in ("sourceId", "version", "cliVersion", "revision"): + if key not in payload: + continue + value = payload[key] + if not isinstance(value, str) or not value: + return False + if "provenance" not in payload: + return True + provenance = payload["provenance"] + return isinstance(provenance, dict) and all( + isinstance(key, str) and isinstance(value, str) + for key, value in provenance.items() ) + + +def _optional_text(payload: dict[str, object], key: str) -> str | None: + value = payload.get(key) + return value if isinstance(value, str) else None diff --git a/python/src/kitup/bundle.py b/python/src/kitup/bundle.py index b0cae0a..208920f 100644 --- a/python/src/kitup/bundle.py +++ b/python/src/kitup/bundle.py @@ -3,7 +3,7 @@ import hashlib import os import re -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path try: @@ -15,6 +15,7 @@ from ._metadata import is_valid_skill_name from ._paths import normalize_bundle_path, resolve_path, skip_name from .types import ( + BundledMetadata, BundleFile, GitHubBundleOptions, KitupError, @@ -27,11 +28,13 @@ @dataclass(frozen=True) class DirectoryBundle: path: str + metadata: BundledMetadata = field(default_factory=BundledMetadata) @dataclass(frozen=True) class FilesBundle: files: list[SkillFile] + metadata: BundledMetadata = field(default_factory=BundledMetadata) @dataclass(frozen=True) @@ -42,18 +45,24 @@ class GitHubBundle: SkillBundle = DirectoryBundle | FilesBundle | GitHubBundle -def directory_bundle(path: str) -> DirectoryBundle: - return DirectoryBundle(path=path) +def directory_bundle( + path: str, metadata: BundledMetadata | None = None +) -> DirectoryBundle: + return DirectoryBundle(path=path, metadata=metadata or BundledMetadata()) -def files_bundle(files: list[SkillFile]) -> FilesBundle: - return FilesBundle(files=files) +def files_bundle( + files: list[SkillFile], metadata: BundledMetadata | None = None +) -> FilesBundle: + return FilesBundle(files=files, metadata=metadata or BundledMetadata()) -def resources_bundle(root: Traversable) -> FilesBundle: +def resources_bundle( + root: Traversable, metadata: BundledMetadata | None = None +) -> FilesBundle: files: list[SkillFile] = [] _collect_resource_files(root, "", files) - return files_bundle(files) + return files_bundle(files, metadata) def github_bundle(options: GitHubBundleOptions) -> GitHubBundle: diff --git a/python/src/kitup/hosts.py b/python/src/kitup/hosts.py index 046b697..87cc66c 100644 --- a/python/src/kitup/hosts.py +++ b/python/src/kitup/hosts.py @@ -4,7 +4,12 @@ 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", + "package.json", +} def load_host_spec(hosts_file: str | None = None) -> HostSpec: @@ -99,7 +104,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 _specific_detect_path_exists(host, home=home, cwd=cwd): detected.append(host) if scope is not None: @@ -121,13 +126,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 _specific_detect_path_exists(host: Host, *, home: Path, cwd: Path) -> bool: + return any( + path not in _GENERIC_DETECT_PATHS + and _expand_host_path(path, home=home, cwd=cwd).exists() + for path in host.detect + ) 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..34d9c4f 100644 --- a/python/src/kitup/install.py +++ b/python/src/kitup/install.py @@ -9,6 +9,7 @@ from ._metadata import ( is_valid_skill_name, read_install_metadata, + read_installed_metadata, write_install_metadata, ) from .bundle import ( @@ -244,7 +245,10 @@ def install_or_plan(options: InstallOptions, *, write: bool) -> InstallReport: report.conflicts.append(target_status(target, "owner-mismatch")) continue if metadata.get("hash") == digest: - if repair_bundle_modes(normalized.files, target_dir, write=write): + repaired = repair_bundle_modes(normalized.files, target_dir, write=write) + if repaired or not _installed_metadata_matches_bundle( + metadata, bundle_metadata + ): if write: write_bundle_metadata( target_dir, @@ -306,6 +310,8 @@ def write_bundle_metadata( source=str(metadata["source"]), source_id=_metadata_text(metadata, "source_id"), version=_metadata_text(metadata, "version"), + cli_version=_metadata_text(metadata, "cli_version"), + revision=_metadata_text(metadata, "revision"), provenance=_metadata_provenance(metadata), ) @@ -336,21 +342,62 @@ def uninstall_bundled_skill(options: UninstallOptions) -> UninstallReport: report.conflicts.append(target_status(target, "owner-mismatch")) continue - shutil.rmtree(target_dir) + reason = _remove_managed_target( + target_dir, app_id=options.app_id, skill_name=options.skill_name + ) + if reason is not None: + report.conflicts.append(target_status(target, reason)) + continue report.removed.append(result) return report +def _remove_managed_target( + target_dir: Path, *, app_id: str, skill_name: str +) -> str | None: + quarantine = Path( + tempfile.mkdtemp( + prefix=f".{target_dir.name}.kitup-uninstall-", + dir=target_dir.parent, + ) + ) + quarantine.rmdir() + target_dir.replace(quarantine) + + def restore() -> None: + if target_dir.exists(): + raise RuntimeError( + f"uninstall target changed; preserved quarantined target at {quarantine}" + ) + quarantine.replace(target_dir) + + try: + metadata = read_installed_metadata(quarantine) + except Exception: + restore() + return "unmanaged" + if metadata is None or metadata.skill_name != skill_name: + restore() + return "unmanaged" + if metadata.app_id != app_id: + restore() + return "owner-mismatch" + shutil.rmtree(quarantine) + return None + + def _resolve_bundle_and_metadata( skill_bundle: object, *, cwd: str | None ) -> tuple[object, dict[str, object]]: if isinstance(skill_bundle, DirectoryBundle): - return normalize_directory_bundle(skill_bundle.path, cwd=cwd), { - "source": "bundled" - } + return normalize_directory_bundle( + skill_bundle.path, cwd=cwd + ), _resolved_bundled_metadata(skill_bundle.metadata) if isinstance(skill_bundle, FilesBundle): - return normalize_files_bundle(skill_bundle.files), {"source": "bundled"} + return normalize_files_bundle(skill_bundle.files), _resolved_bundled_metadata( + skill_bundle.metadata + ) if isinstance(skill_bundle, GitHubBundle): files, metadata = fetch_github_directory_with_metadata(skill_bundle.options) return normalize_files_bundle(files), metadata @@ -404,9 +451,46 @@ def _resolve_install_targets_with_errors( def _metadata_text(metadata: dict[str, object], key: str) -> str | None: value = metadata.get(key) - return value if isinstance(value, str) else None + return value if isinstance(value, str) and value else None def _metadata_provenance(metadata: dict[str, object]) -> dict[str, object] | None: value = metadata.get("provenance") - return value if isinstance(value, dict) else None + return value if isinstance(value, dict) and value else None + + +def _resolved_bundled_metadata(metadata: object) -> dict[str, object]: + source_id = getattr(metadata, "source_id", None) + cli_version = getattr(metadata, "cli_version", None) + revision = getattr(metadata, "revision", None) + provenance = getattr(metadata, "provenance", None) + for value in (source_id, cli_version, revision): + if value is not None and not isinstance(value, str): + raise TypeError("invalid bundled metadata") + if not isinstance(provenance, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in provenance.items() + ): + raise TypeError("invalid bundled metadata") + return { + "source": "bundled", + "source_id": source_id or None, + "cli_version": cli_version or None, + "revision": revision or None, + "provenance": provenance, + } + + +def _installed_metadata_matches_bundle( + installed: dict[str, object], bundled: dict[str, object] +) -> bool: + return all( + installed.get(installed_key) == bundled.get(bundle_key) + for installed_key, bundle_key in ( + ("source", "source"), + ("sourceId", "source_id"), + ("version", "version"), + ("cliVersion", "cli_version"), + ("revision", "revision"), + ) + ) and installed.get("provenance", {}) == bundled.get("provenance", {}) diff --git a/python/src/kitup/types.py b/python/src/kitup/types.py index b14eda6..fc896c2 100644 --- a/python/src/kitup/types.py +++ b/python/src/kitup/types.py @@ -63,6 +63,28 @@ class SkillFile: mode: int | None = None +@dataclass(frozen=True) +class BundledMetadata: + cli_version: str | None = None + revision: str | None = None + source_id: str | None = None + provenance: dict[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class InstalledMetadata: + schema_version: int + app_id: str + skill_name: str + source: Literal["bundled", "github"] + hash: str + source_id: str | None = None + version: str | None = None + cli_version: str | None = None + revision: str | None = None + provenance: dict[str, str] = field(default_factory=dict) + + @dataclass(frozen=True) class GitHubBundleOptions: owner: str diff --git a/python/tests/golden_test.py b/python/tests/golden_test.py index 13460ef..140bd61 100644 --- a/python/tests/golden_test.py +++ b/python/tests/golden_test.py @@ -12,6 +12,7 @@ from kitup import ( BaseOptions, + BundledMetadata, InstallOptions, InstallSelectionOptions, InstallWorkflowOptions, @@ -27,6 +28,7 @@ load_host_spec, parse_install_flags, plan_bundled_skill, + read_installed_metadata, resolve_hosts, resolve_install_selection, run_bundled_skill_install_with_io, @@ -83,6 +85,24 @@ def run_case(case, home: Path, workspace: Path) -> None: assert result.error_code == case["expected"].get("errorCode") return + if operation == "read-installed-metadata": + try: + metadata = read_installed_metadata( + Path( + case["options"]["targetDir"] + .replace("$HOME", str(home)) + .replace("$WORKSPACE", str(workspace)) + ) + ) + except Exception: + assert case["expected"].get("throws") is True + return + assert not case["expected"].get("throws") + assert normalize_value(metadata) == camel_to_snake_dict( + case["expected"]["installedMetadata"] + ) + return + if operation == "parse-install-flags": parsed = parse_install_flags(case["options"]) assert normalize_parsed_flags(parsed) == case["expected"]["parsed"] @@ -138,6 +158,8 @@ def run_case(case, home: Path, workspace: Path) -> None: case["options"]["scope"], ) assert [host.id for host in hosts] == case["expected"]["detectedHosts"] + if operation == "detect": + return if case["expected"].get("throws"): try: @@ -448,10 +470,19 @@ def workflow_options_from_case( def skill_bundle_from_case(case) -> object: + raw_metadata = case["options"].get("bundleMetadata", {}) + metadata = BundledMetadata( + cli_version=raw_metadata.get("cliVersion"), + revision=raw_metadata.get("revision"), + source_id=raw_metadata.get("sourceId"), + provenance=raw_metadata.get("provenance", {}), + ) if "skillFiles" in case["options"]: - return files_bundle(skill_files(case["options"]["skillFiles"])) + return files_bundle(skill_files(case["options"]["skillFiles"]), metadata) if "skillBundleDir" in case["options"]: - return directory_bundle(str(repo_path(case["options"]["skillBundleDir"]))) + return directory_bundle( + str(repo_path(case["options"]["skillBundleDir"])), metadata + ) if "githubBundle" in case["options"]: bundle = case["options"]["githubBundle"] return github_bundle( diff --git a/python/tests/test_hosts.py b/python/tests/test_hosts.py index 3323ec4..9ea54e1 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_past_primary_generic_detect_path(tmp_path): home = tmp_path / "home" workspace = tmp_path / "workspace" home.mkdir() @@ -100,4 +100,4 @@ 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 [host.id for host in hosts] == ["kimi-cli"] diff --git a/rust/src/hosts_generated.rs b/rust/src/hosts_generated.rs index a75dd27..ddb4d1b 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","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"],"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"],"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"],"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..4b76ceb 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -179,11 +179,20 @@ pub struct SkillFile { pub mode: Option, } +#[derive(Clone, Debug, Default)] +pub struct BundledMetadata { + pub cli_version: Option, + pub revision: Option, + pub source_id: Option, + pub provenance: BTreeMap, +} + #[derive(Clone, Debug)] pub enum SkillBundle { Directory(PathBuf), Files(Vec), GitHub(GitHubBundleOptions), + WithMetadata(Box, BundledMetadata), } #[derive(Clone, Debug)] @@ -232,6 +241,10 @@ pub fn github_bundle(options: GitHubBundleOptions) -> SkillBundle { SkillBundle::GitHub(options) } +pub fn with_bundle_metadata(bundle: SkillBundle, metadata: BundledMetadata) -> SkillBundle { + SkillBundle::WithMetadata(Box::new(bundle), metadata) +} + #[derive(Clone, Debug)] pub struct TargetGroup { pub host_ids: Vec, @@ -325,16 +338,24 @@ pub struct InstallWorkflowExit { pub message: String, } -#[derive(Deserialize)] -struct Metadata { - #[serde(rename = "schemaVersion")] - schema_version: u32, - #[serde(rename = "appId")] - app_id: String, - #[serde(rename = "skillName")] - skill_name: String, - source: String, - hash: String, +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledMetadata { + pub schema_version: u32, + pub app_id: String, + pub skill_name: String, + pub source: String, + pub hash: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cli_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub provenance: BTreeMap, } #[derive(Clone, Debug)] @@ -355,6 +376,8 @@ struct BundleMetadata { source: String, source_id: Option, version: Option, + cli_version: Option, + revision: Option, provenance: BTreeMap, } @@ -551,10 +574,10 @@ pub fn detect_hosts(options: &BaseOptions, scope: Option) -> io::Result io::Result { - fs::remove_dir_all(&target.target_dir)?; - report.removed.push(result); + if let Some(reason) = + remove_managed_target(&target.target_dir, &options.app_id, &options.skill_name)? + { + report.conflicts.push(with_reason(result, &reason)); + } else { + report.removed.push(result); + } } } } Ok(report) } +fn remove_managed_target( + target_dir: &Path, + app_id: &str, + skill_name: &str, +) -> io::Result> { + let quarantine = make_staging_dir(target_dir)?; + fs::remove_dir(&quarantine)?; + fs::rename(target_dir, &quarantine)?; + let metadata = match read_installed_metadata(&quarantine) { + Ok(metadata) => metadata, + Err(_) => { + restore_quarantined_target(target_dir, &quarantine)?; + return Ok(Some("unmanaged".to_string())); + } + }; + let Some(metadata) = metadata else { + restore_quarantined_target(target_dir, &quarantine)?; + return Ok(Some("unmanaged".to_string())); + }; + let reason = if metadata.skill_name != skill_name { + Some("unmanaged") + } else if metadata.app_id != app_id { + Some("owner-mismatch") + } else { + None + }; + if let Some(reason) = reason { + restore_quarantined_target(target_dir, &quarantine)?; + return Ok(Some(reason.to_string())); + } + fs::remove_dir_all(quarantine)?; + Ok(None) +} + +fn restore_quarantined_target(target_dir: &Path, quarantine: &Path) -> io::Result<()> { + if target_dir.exists() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "uninstall target changed; preserved quarantined target at {}", + quarantine.display() + ), + )); + } + fs::rename(quarantine, target_dir) +} + fn install_or_plan(options: &InstallOptions, write: bool) -> io::Result { if options.app_id.is_empty() { return Ok(install_report(vec![json!({ @@ -1114,7 +1189,8 @@ fn install_or_plan(options: &InstallOptions, write: bool) -> io::Result { - if repair_skill_bundle_modes(&bundle, &target.target_dir, write)? { + let repaired = repair_skill_bundle_modes(&bundle, &target.target_dir, write)?; + if repaired || !installed_metadata_matches_bundle(&meta, &bundle_metadata) { if write { write_metadata( &target.target_dir, @@ -1150,7 +1226,7 @@ fn install_or_plan(options: &InstallOptions, write: bool) -> io::Result), } fn copy_managed_skill( @@ -1297,6 +1373,12 @@ fn write_metadata( if let Some(version) = &bundle_metadata.version { value["version"] = json!(version); } + if let Some(cli_version) = &bundle_metadata.cli_version { + value["cliVersion"] = json!(cli_version); + } + if let Some(revision) = &bundle_metadata.revision { + value["revision"] = json!(revision); + } if !bundle_metadata.provenance.is_empty() { value["provenance"] = json!(bundle_metadata.provenance); } @@ -1310,16 +1392,60 @@ fn read_metadata(target_dir: &Path) -> MetadataState { if !target_dir.exists() { return MetadataState::Missing; } - let Ok(data) = fs::read(target_dir.join(".kitup.json")) else { - return MetadataState::Unmanaged; - }; - match serde_json::from_slice::(&data) { - Ok(meta) if is_owned_metadata(&meta) => MetadataState::Managed(meta), + match read_installed_metadata(target_dir) { + Ok(Some(meta)) => MetadataState::Managed(Box::new(meta)), _ => MetadataState::Unmanaged, } } -fn is_owned_metadata(meta: &Metadata) -> bool { +pub fn read_installed_metadata(target_dir: &Path) -> io::Result> { + let data = match fs::read(target_dir.join(".kitup.json")) { + Ok(data) => data, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + let value: Value = serde_json::from_slice(&data) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid installed metadata"))?; + if !has_valid_optional_metadata_fields(&value) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid installed metadata", + )); + } + let metadata: InstalledMetadata = serde_json::from_value(value) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid installed metadata"))?; + if !is_owned_metadata(&metadata) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid installed metadata", + )); + } + Ok(Some(metadata)) +} + +fn has_valid_optional_metadata_fields(value: &Value) -> bool { + let Some(object) = value.as_object() else { + return false; + }; + for key in ["sourceId", "version", "cliVersion", "revision"] { + if let Some(value) = object.get(key) { + if !matches!(value.as_str(), Some(text) if !text.is_empty()) { + return false; + } + } + } + if let Some(value) = object.get("provenance") { + let Some(provenance) = value.as_object() else { + return false; + }; + if provenance.values().any(|value| !value.is_string()) { + return false; + } + } + true +} + +fn is_owned_metadata(meta: &InstalledMetadata) -> bool { meta.schema_version == 1 && !meta.app_id.is_empty() && valid_skill_name(&meta.skill_name) @@ -1327,6 +1453,18 @@ fn is_owned_metadata(meta: &Metadata) -> bool { && !meta.hash.is_empty() } +fn installed_metadata_matches_bundle( + installed: &InstalledMetadata, + bundled: &BundleMetadata, +) -> bool { + installed.source == bundled.source + && installed.source_id == bundled.source_id + && installed.version == bundled.version + && installed.cli_version == bundled.cli_version + && installed.revision == bundled.revision + && installed.provenance == bundled.provenance +} + fn target_result(target: &TargetGroup) -> TargetResult { if target.host_ids.len() == 1 { TargetResult { @@ -1661,12 +1799,38 @@ fn resolve_skill_bundle( ) -> io::Result<(NormalizedSkillBundle, BundleMetadata)> { match bundle { SkillBundle::GitHub(options) => resolve_github_bundle(options), + SkillBundle::WithMetadata(bundle, metadata) => { + let (bundle, mut resolved) = resolve_skill_bundle(bundle)?; + if metadata + .source_id + .as_deref() + .is_some_and(|value| !value.is_empty()) + { + resolved.source_id = metadata.source_id.clone(); + } + resolved.cli_version = metadata + .cli_version + .as_ref() + .filter(|value| !value.is_empty()) + .cloned(); + resolved.revision = metadata + .revision + .as_ref() + .filter(|value| !value.is_empty()) + .cloned(); + if !metadata.provenance.is_empty() { + resolved.provenance = metadata.provenance.clone(); + } + Ok((bundle, resolved)) + } _ => Ok(( read_skill_bundle(bundle)?, BundleMetadata { source: "bundled".to_string(), source_id: None, version: None, + cli_version: None, + revision: None, provenance: BTreeMap::new(), }, )), @@ -1764,6 +1928,8 @@ fn resolve_github_bundle( options.owner, options.repo, root )), version: Some(options.ref_name.clone()), + cli_version: None, + revision: None, provenance, }, )) @@ -1830,6 +1996,7 @@ fn read_skill_bundle(bundle: &SkillBundle) -> io::Result let (bundle, _) = resolve_github_bundle(options)?; Ok(bundle) } + SkillBundle::WithMetadata(bundle, _) => read_skill_bundle(bundle), } } @@ -1958,7 +2125,10 @@ fn skip_name(name: &str) -> bool { } fn is_generic_detect_path(path: &str) -> bool { - path == "~/.agents" || path == "~/.agents/skills" || path == "~/.config/agents" + path == "~/.agents" + || path == "~/.agents/skills" + || path == "~/.config/agents" + || path == "package.json" } fn scope_text(scope: Scope) -> &'static str { diff --git a/rust/tests/golden.rs b/rust/tests/golden.rs index 6783ce8..5a6c337 100644 --- a/rust/tests/golden.rs +++ b/rust/tests/golden.rs @@ -1,11 +1,11 @@ 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, read_installed_metadata, resolve_hosts, resolve_install_selection, run_bundled_skill_install_with_io, uninstall_bundled_skill, update_bundled_skill, - validate_skill_bundle, AgentSelector, BaseOptions, GitHubBundleOptions, InstallFlagValues, - InstallOptions, InstallSelectionOptions, InstallWorkflowOptions, ParsedInstallFlags, Scope, - SkillBundle, SkillFile, UninstallOptions, + validate_skill_bundle, with_bundle_metadata, AgentSelector, BaseOptions, BundledMetadata, + GitHubBundleOptions, InstallFlagValues, InstallOptions, InstallSelectionOptions, + InstallWorkflowOptions, ParsedInstallFlags, Scope, SkillBundle, SkillFile, UninstallOptions, }; use serde::Deserialize; use serde_json::{json, Map, Value}; @@ -114,6 +114,18 @@ fn run_case(case: &GoldenCase, home: &Path, workspace: &Path) { case.expected.get("errorCode").and_then(Value::as_str) ); } + "read-installed-metadata" => { + let target = PathBuf::from(options["targetDir"].as_str().unwrap()); + let result = read_installed_metadata(&target); + if case.expected.get("throws").and_then(Value::as_bool) == Some(true) { + assert!(result.is_err(), "expected metadata read to fail"); + } else { + assert_json_eq( + &serde_json::to_value(result.unwrap().unwrap()).unwrap(), + case.expected["installedMetadata"].clone(), + ); + } + } "parse-install-flags" => { let parsed = parse_install_flags(InstallFlagValues { scope: options @@ -272,6 +284,9 @@ fn run_case(case: &GoldenCase, home: &Path, workspace: &Path) { detect_hosts(&base, Some(scope(options["scope"].as_str().unwrap()))).unwrap(); assert_json_eq(&json!(host_ids(&hosts)), expected.clone()); } + if case.operation == "detect" { + return; + } let result = run_report_case(case, options, base); if case.expected.get("throws").and_then(Value::as_bool) == Some(true) { assert!(result.is_err(), "expected operation to throw"); @@ -619,21 +634,48 @@ fn agent_selector(value: &Value) -> AgentSelector { } fn skill_bundle_from_options(options: &Map) -> SkillBundle { - if let Some(files) = options.get("skillFiles").and_then(Value::as_array) { - return files_bundle(skill_files(files)); - } - if let Some(dir) = options.get("skillBundleDir").and_then(Value::as_str) { - return directory_bundle(repo_path(dir)); - } - if let Some(bundle) = options.get("githubBundle").and_then(Value::as_object) { - return github_bundle(GitHubBundleOptions { + let mut bundle = if let Some(files) = options.get("skillFiles").and_then(Value::as_array) { + files_bundle(skill_files(files)) + } else if let Some(dir) = options.get("skillBundleDir").and_then(Value::as_str) { + directory_bundle(repo_path(dir)) + } else if let Some(bundle) = options.get("githubBundle").and_then(Value::as_object) { + github_bundle(GitHubBundleOptions { owner: bundle["owner"].as_str().unwrap().to_string(), repo: bundle["repo"].as_str().unwrap().to_string(), path: bundle["path"].as_str().unwrap().to_string(), ref_name: bundle["ref"].as_str().unwrap().to_string(), - }); + }) + } else { + files_bundle(Vec::new()) + }; + if let Some(metadata) = options.get("bundleMetadata").and_then(Value::as_object) { + let provenance = metadata + .get("provenance") + .and_then(Value::as_object) + .into_iter() + .flatten() + .map(|(key, value)| (key.clone(), value.as_str().unwrap().to_string())) + .collect(); + bundle = with_bundle_metadata( + bundle, + BundledMetadata { + cli_version: metadata + .get("cliVersion") + .and_then(Value::as_str) + .map(String::from), + revision: metadata + .get("revision") + .and_then(Value::as_str) + .map(String::from), + source_id: metadata + .get("sourceId") + .and_then(Value::as_str) + .map(String::from), + provenance, + }, + ); } - files_bundle(Vec::new()) + bundle } fn skill_files(values: &[Value]) -> Vec { diff --git a/scripts/check-go-cobra.sh b/scripts/check-go-cobra.sh new file mode 100644 index 0000000..4a1f0a5 --- /dev/null +++ b/scripts/check-go-cobra.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT INT TERM + +mkdir -p "$tmp/modules" +tar -C "$root" -cf "$tmp/go.tar" go +tar -C "$root" -cf "$tmp/go-cobra.tar" go-cobra +tar -C "$tmp/modules" -xf "$tmp/go.tar" +tar -C "$tmp/modules" -xf "$tmp/go-cobra.tar" + +cd "$tmp/modules/go-cobra" +go mod edit "-replace=github.com/lathe-cli/kitup/go=$tmp/modules/go" +GOWORK=off go test ./... + +echo "ok: Go Cobra adapter passes against the local core module" diff --git a/scripts/check-go-release.sh b/scripts/check-go-release.sh new file mode 100644 index 0000000..3c68487 --- /dev/null +++ b/scripts/check-go-release.sh @@ -0,0 +1,62 @@ +#!/bin/sh +set -eu + +root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +version="$(node -p 'require(process.argv[1]).version' "$root/ts/package.json")" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT INT TERM + +mkdir -p "$tmp/modules" +tar -C "$root" -cf "$tmp/go.tar" go +tar -C "$root" -cf "$tmp/go-cobra.tar" go-cobra +tar -C "$tmp/modules" -xf "$tmp/go.tar" +tar -C "$tmp/modules" -xf "$tmp/go-cobra.tar" + +( + cd "$tmp/modules/go" + GOWORK=off go test ./... +) +( + cd "$tmp/modules/go-cobra" + go mod edit "-replace=github.com/lathe-cli/kitup/go=$tmp/modules/go" + GOWORK=off go test ./... + go mod edit -dropreplace=github.com/lathe-cli/kitup/go +) + +consumer="$tmp/consumer" +mkdir -p "$consumer" +cd "$consumer" +go mod init kitup-release-consumer >/dev/null +go mod edit \ + "-require=github.com/lathe-cli/kitup/go@v$version" \ + "-require=github.com/lathe-cli/kitup/go-cobra@v$version" \ + "-replace=github.com/lathe-cli/kitup/go=$tmp/modules/go" \ + "-replace=github.com/lathe-cli/kitup/go-cobra=$tmp/modules/go-cobra" + +cat > main.go <<'GO' +package main + +import ( + "fmt" + + kitup "github.com/lathe-cli/kitup/go" + kitupcobra "github.com/lathe-cli/kitup/go-cobra" +) + +func main() { + cmd := kitupcobra.NewSkillCommand(kitupcobra.Options{}) + if cmd.Use != kitup.InstallUX.SkillUse { + panic(fmt.Sprintf("expected %s, got %s", kitup.InstallUX.SkillUse, cmd.Use)) + } +} +GO + +GOWORK=off go mod tidy +GOWORK=off go list -m all >/dev/null +GOWORK=off go test ./... +GOWORK=off go build . + +test "$(GOWORK=off go list -m -f '{{.Version}}' github.com/lathe-cli/kitup/go)" = "v$version" +test "$(GOWORK=off go list -m -f '{{.Version}}' github.com/lathe-cli/kitup/go-cobra)" = "v$version" + +echo "ok: packaged Go modules are self-contained at v$version" diff --git a/scripts/check.mjs b/scripts/check.mjs index c67bbff..28c7b85 100755 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -157,14 +157,18 @@ function validateCases(cases, hosts) { "parse-install-flags-errors", "shared-target-deduplication-many-hosts", "user-scope-install", + "codex-user-scope-reuses-existing-compatible-dir", "codex-user-scope-prefers-first-user-dir", "project-scope-install", "project-scope-plan", "project-only-host-project-scope-install", "project-only-host-user-scope-error", "auto-host-detection", + "auto-host-detection-secondary-specific-path", + "auto-host-detection-generic-path-only", "auto-host-detection-empty", "unchanged-noop", + "unchanged-content-refreshes-bundled-metadata", "unchanged-repairs-script-mode", "workflow-unchanged-silent", "workflow-conflict-exit", @@ -177,6 +181,11 @@ function validateCases(cases, hosts) { "invalid-frontmatter", "nested-resources-copied", "embedded-skill-source", + "read-installed-metadata", + "read-installed-metadata-corrupt", + "read-installed-metadata-null-optional", + "read-installed-metadata-empty-optional", + "read-installed-metadata-null-provenance", "workflow-explicit-agent", "workflow-agent-star", "workflow-scope-prompt-before-agent", @@ -197,7 +206,7 @@ function validateCases(cases, hosts) { "github-bundle-mode-only-update-refreshes-metadata", "github-bundle-dry-run", "github-bundle-resolve-failure", - "github-bundle-unchanged", + "github-bundle-metadata-refresh", ]) { assert(caseIds.has(id), `missing golden case: ${id}`); } @@ -331,6 +340,13 @@ for (const [group, name, command, args, cwd, env] of [ ["scripts/sync-hosts.mjs", "--check"], rootPath, ], + [ + "source", + "go-test-fixtures", + "node", + ["scripts/sync-go-testdata.mjs", "--check"], + rootPath, + ], [ "typescript", "typescript-format", @@ -356,14 +372,10 @@ for (const [group, name, command, args, cwd, env] of [ "go", ["test", "-count=1", "./..."], new URL("../go/", import.meta.url), + { GOWORK: "off" }, ], - [ - "go", - "go-cobra", - "go", - ["test", "./..."], - new URL("../go-cobra/", import.meta.url), - ], + ["go", "go-cobra", "sh", ["scripts/check-go-cobra.sh"], rootPath], + ["go", "go-release", "sh", ["scripts/check-go-release.sh"], rootPath], ["rust", "rust", "cargo", ["test"], new URL("../rust/", import.meta.url)], [ "rust", diff --git a/scripts/sync-go-testdata.mjs b/scripts/sync-go-testdata.mjs new file mode 100644 index 0000000..c5879e0 --- /dev/null +++ b/scripts/sync-go-testdata.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import { cpSync, mkdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const root = new URL("../", import.meta.url); +const check = process.argv.slice(2).includes("--check"); +const files = [ + "spec/hosts.json", + "testdata/cases/bundled-skill-install.json", + "testdata/skills/basic/SKILL.md", + "testdata/skills/basic/assets/template.json", + "testdata/skills/basic/references/guide.md", + "testdata/skills/basic/scripts/helper.sh", + "testdata/skills/invalid-frontmatter/SKILL.md", + "testdata/skills/missing-skill-md/README.md", +]; + +for (const sourcePath of files) { + const targetPath = `go/${sourcePath}`; + const source = new URL(sourcePath, root); + const target = new URL(targetPath, root); + if (check) { + let targetContents; + try { + targetContents = readFileSync(target); + } catch { + fail(`missing generated Go test fixture: ${targetPath}`); + } + if (!readFileSync(source).equals(targetContents)) { + fail(`stale generated Go test fixture: ${targetPath}`); + } + continue; + } + mkdirSync(new URL("./", target), { recursive: true }); + cpSync(source, target); +} + +console.log( + check + ? `ok: ${files.length} Go test fixtures are current` + : `updated ${files.length} Go test fixtures`, +); + +function fail(message) { + console.error(message); + process.exit(1); +} diff --git a/spec/hosts.json b/spec/hosts.json index d1af3c3..0343079 100644 --- a/spec/hosts.json +++ b/spec/hosts.json @@ -449,9 +449,7 @@ "~/.claude/skills" ], "detect": [ - "~/.copilot", - "~/.agents", - "~/.claude" + "~/.copilot" ], "status": "documented" }, @@ -749,9 +747,7 @@ "~/.claude/skills" ], "detect": [ - "~/.config/opencode", - "~/.agents", - "~/.claude" + "~/.config/opencode" ], "status": "verified" }, @@ -1033,16 +1029,7 @@ "~/.opencode/skills" ], "detect": [ - "~/.warp", - "~/.agents", - "~/.claude", - "~/.codex", - "~/.cursor", - "~/.gemini", - "~/.copilot", - "~/.factory", - "~/.github", - "~/.opencode" + "~/.warp" ], "status": "documented" }, diff --git a/testdata/cases.schema.json b/testdata/cases.schema.json index e64fd4f..abe95f2 100644 --- a/testdata/cases.schema.json +++ b/testdata/cases.schema.json @@ -46,6 +46,7 @@ "install", "parse-install-flags", "plan", + "read-installed-metadata", "run-install-workflow", "resolve-install-selection", "resolve-hosts", diff --git a/testdata/cases/bundled-skill-install.json b/testdata/cases/bundled-skill-install.json index fcc5407..da5c129 100644 --- a/testdata/cases/bundled-skill-install.json +++ b/testdata/cases/bundled-skill-install.json @@ -397,10 +397,53 @@ } } }, + { + "id": "codex-user-scope-reuses-existing-compatible-dir", + "operation": "install", + "description": "Reuses the existing Codex-compatible user directory when the canonical directory does not exist.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.codex/skills" + ], + "files": {} + }, + "expected": { + "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-prefers-first-user-dir", "operation": "install", - "description": "Installs Codex user-scope skills into the first canonical user path when multiple user paths are valid.", + "description": "Prefers the first canonical Codex user path when both compatible directories already exist.", "options": { "appId": "example-cli", "skillBundleDir": "testdata/skills/basic", @@ -708,6 +751,48 @@ } } }, + { + "id": "auto-host-detection-secondary-specific-path", + "operation": "detect", + "description": "Detects a host when a non-primary specific detection path exists.", + "options": { + "scope": "user", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.clawdbot" + ], + "files": {} + }, + "expected": { + "detectedHosts": [ + "openclaw" + ] + } + }, + { + "id": "auto-host-detection-generic-path-only", + "operation": "detect", + "description": "Does not infer a specific host from a shared compatibility root alone.", + "options": { + "scope": "user", + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents" + ], + "files": { + "$WORKSPACE/package.json": "{}\n" + } + }, + "expected": { + "detectedHosts": [] + } + }, { "id": "shared-target-deduplication", "operation": "install", @@ -800,6 +885,82 @@ } } }, + { + "id": "unchanged-content-refreshes-bundled-metadata", + "operation": "install", + "description": "Refreshes bundled CLI metadata when the skill content hash is unchanged.", + "options": { + "appId": "example-cli", + "skillBundleDir": "testdata/skills/basic", + "bundleMetadata": { + "cliVersion": "2.0.0", + "revision": "new456", + "sourceId": "example-cli:embedded", + "provenance": { + "build": "release" + } + }, + "scope": "user", + "agents": [ + "codex" + ], + "home": "$HOME", + "cwd": "$WORKSPACE" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "copySkillBundleTo": "$HOME/.agents/skills/basic", + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "cliVersion": "1.0.0", + "revision": "old123", + "sourceId": "example-cli:embedded", + "provenance": { + "build": "development" + } + }, + "hash": "from-skill-bundle-dir" + } + }, + "expected": { + "report": { + "installed": [], + "updated": [ + { + "hostId": "codex", + "skillName": "basic", + "targetDir": "$HOME/.agents/skills/basic" + } + ], + "skipped": [], + "conflicts": [], + "errors": [] + }, + "metadata": { + "path": "$HOME/.agents/skills/basic/.kitup.json", + "hash": "from-skill-bundle-dir", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "cliVersion": "2.0.0", + "revision": "new456", + "sourceId": "example-cli:embedded", + "provenance": { + "build": "release" + } + } + } + } + }, { "id": "workflow-unchanged-silent", "operation": "run-install-workflow", @@ -1645,6 +1806,15 @@ "contents": "ignored" } ], + "bundleMetadata": { + "cliVersion": "1.2.3", + "revision": "abc123", + "sourceId": "example-cli:embedded", + "provenance": { + "channel": "release", + "build": "42" + } + }, "scope": "user", "agents": [ "codex" @@ -1692,7 +1862,14 @@ "schemaVersion": 1, "appId": "example-cli", "skillName": "embedded", - "source": "bundled" + "source": "bundled", + "cliVersion": "1.2.3", + "revision": "abc123", + "sourceId": "example-cli:embedded", + "provenance": { + "channel": "release", + "build": "42" + } }, "hash": "from-skill-files" } @@ -2619,9 +2796,9 @@ } }, { - "id": "github-bundle-unchanged", + "id": "github-bundle-metadata-refresh", "operation": "install", - "description": "Skips a GitHub bundle install when kitup ownership and content hash are unchanged.", + "description": "Refreshes missing GitHub source metadata even when kitup ownership and content hash are unchanged.", "options": { "appId": "example-cli", "githubBundle": { @@ -2666,17 +2843,35 @@ "expected": { "report": { "installed": [], - "updated": [], - "skipped": [ + "updated": [ { "hostId": "codex", "skillName": "github-basic", - "targetDir": "$HOME/.agents/skills/github-basic", - "reason": "unchanged" + "targetDir": "$HOME/.agents/skills/github-basic" } ], + "skipped": [], "conflicts": [], "errors": [] + }, + "metadata": { + "path": "$HOME/.agents/skills/github-basic/.kitup.json", + "hash": "from-github-bundle", + "fields": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "github-basic", + "source": "github", + "sourceId": "github:acme/mycli-skills/skills/github-basic", + "version": "main", + "provenance": { + "owner": "acme", + "repo": "mycli-skills", + "path": "skills/github-basic", + "ref": "main", + "resolvedCommit": "abc123" + } + } } } }, @@ -3029,6 +3224,155 @@ ] } }, + { + "id": "read-installed-metadata", + "operation": "read-installed-metadata", + "description": "Reads stable installed metadata including optional bundled provenance fields.", + "options": { + "targetDir": "$HOME/.agents/skills/basic" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "sourceId": "example-cli:embedded", + "cliVersion": "1.2.3", + "revision": "abc123", + "provenance": { + "channel": "release" + } + } + } + }, + "expected": { + "installedMetadata": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "sourceId": "example-cli:embedded", + "cliVersion": "1.2.3", + "revision": "abc123", + "provenance": { + "channel": "release" + } + } + } + }, + { + "id": "read-installed-metadata-corrupt", + "operation": "read-installed-metadata", + "description": "Fails closed when an optional installed metadata field has an invalid type.", + "options": { + "targetDir": "$HOME/.agents/skills/basic" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "provenance": { + "channel": 42 + } + } + } + }, + "expected": { + "throws": true + } + }, + { + "id": "read-installed-metadata-null-optional", + "operation": "read-installed-metadata", + "description": "Fails closed when an optional installed metadata string is explicitly null.", + "options": { + "targetDir": "$HOME/.agents/skills/basic" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "cliVersion": null + } + } + }, + "expected": { + "throws": true + } + }, + { + "id": "read-installed-metadata-empty-optional", + "operation": "read-installed-metadata", + "description": "Fails closed when an optional installed metadata string is empty.", + "options": { + "targetDir": "$HOME/.agents/skills/basic" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "sourceId": "" + } + } + }, + "expected": { + "throws": true + } + }, + { + "id": "read-installed-metadata-null-provenance", + "operation": "read-installed-metadata", + "description": "Fails closed when installed metadata provenance is explicitly null.", + "options": { + "targetDir": "$HOME/.agents/skills/basic" + }, + "given": { + "dirs": [ + "$HOME/.agents/skills/basic" + ], + "files": { + "$HOME/.agents/skills/basic/.kitup.json": { + "schemaVersion": 1, + "appId": "example-cli", + "skillName": "basic", + "source": "bundled", + "hash": "sha256:abc", + "provenance": null + } + } + }, + "expected": { + "throws": true + } + }, { "id": "initial-install-copy-failure-is-atomic", "operation": "install", diff --git a/ts/src/hosts.generated.ts b/ts/src/hosts.generated.ts index 419abb5..ad04c1e 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\",\"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\"],\"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\"],\"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\"],\"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..58ba331 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -73,9 +73,16 @@ export interface SkillFile { mode?: number; } +export interface BundledMetadata { + cliVersion?: string; + revision?: string; + sourceId?: string; + provenance?: Record; +} + export type SkillBundle = - | { kind: "directory"; path: string } - | { kind: "files"; files: SkillFile[] } + | { kind: "directory"; path: string; metadata?: BundledMetadata } + | { kind: "files"; files: SkillFile[]; metadata?: BundledMetadata } | { kind: "github"; options: GitHubBundleOptions }; export interface GitHubBundleOptions { @@ -245,7 +252,7 @@ export interface SkillInfo { "missing-skill-md" | "invalid-frontmatter" | "invalid-skill-bundle"; } -interface InstallMetadata { +export interface InstalledMetadata { schemaVersion: 1; appId: string; skillName: string; @@ -253,6 +260,8 @@ interface InstallMetadata { hash: string; sourceId?: string; version?: string; + cliVersion?: string; + revision?: string; provenance?: Record; } @@ -270,27 +279,36 @@ interface NormalizedSkillBundle { byPath: Map; } -interface BundleMetadata { - source: InstallMetadata["source"]; +interface ResolvedBundleMetadata { + source: InstalledMetadata["source"]; sourceId?: string; version?: string; + cliVersion?: string; + revision?: string; provenance?: Record; } -export function directoryBundle(path: string): SkillBundle { - return { kind: "directory", path }; +export function directoryBundle( + path: string, + metadata?: BundledMetadata, +): SkillBundle { + return { kind: "directory", path, metadata }; } -export function filesBundle(files: SkillFile[]): SkillBundle { - return { kind: "files", files }; +export function filesBundle( + files: SkillFile[], + metadata?: BundledMetadata, +): SkillBundle { + return { kind: "files", files, metadata }; } export async function moduleDirBundle( importMetaUrl: string | URL, relativePath: string, + metadata?: BundledMetadata, ): Promise { const root = fileURLToPath(new URL(relativePath, importMetaUrl)); - return filesBundle(await readDirectoryBundleFiles(root)); + return filesBundle(await readDirectoryBundleFiles(root), metadata); } export function githubBundle(options: GitHubBundleOptions): SkillBundle { @@ -488,13 +506,14 @@ 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) && + (await exists(expandHostPath(detectPath, home, cwd))) + ) { + detected.push(host); + break; + } } } @@ -911,26 +930,30 @@ async function readSkillBundle( async function resolveSkillBundle( bundle: SkillBundle, cwd = process.cwd(), -): Promise<{ bundle: NormalizedSkillBundle; metadata: BundleMetadata }> { +): Promise<{ + bundle: NormalizedSkillBundle; + metadata: ResolvedBundleMetadata; +}> { if (bundle.kind === "directory") { const dir = resolvePath(bundle.path, cwd); return { bundle: normalizeSkillFiles(await readDirectoryBundleFiles(dir), dir), - metadata: { source: "bundled" }, + metadata: resolvedBundledMetadata(bundle.metadata), }; } if (bundle.kind === "files") { return { bundle: normalizeSkillFiles(bundle.files), - metadata: { source: "bundled" }, + metadata: resolvedBundledMetadata(bundle.metadata), }; } return resolveGitHubBundle(bundle.options); } -async function resolveGitHubBundle( - options: GitHubBundleOptions, -): Promise<{ bundle: NormalizedSkillBundle; metadata: BundleMetadata }> { +async function resolveGitHubBundle(options: GitHubBundleOptions): Promise<{ + bundle: NormalizedSkillBundle; + metadata: ResolvedBundleMetadata; +}> { const root = trimGitHubPath(options.path); if (!options.owner || !options.repo || !root || !options.ref) { throw new Error("invalid github bundle"); @@ -985,6 +1008,33 @@ async function resolveGitHubBundle( }; } +function resolvedBundledMetadata( + metadata: BundledMetadata | undefined, +): ResolvedBundleMetadata { + if (!metadata) return { source: "bundled" }; + for (const value of [ + metadata.cliVersion, + metadata.revision, + metadata.sourceId, + ]) { + if (value !== undefined && typeof value !== "string") { + throw new Error("invalid bundled metadata"); + } + } + if (metadata.provenance && !isStringRecord(metadata.provenance)) { + throw new Error("invalid bundled metadata"); + } + return { + source: "bundled", + ...(metadata.cliVersion ? { cliVersion: metadata.cliVersion } : {}), + ...(metadata.revision ? { revision: metadata.revision } : {}), + ...(metadata.sourceId ? { sourceId: metadata.sourceId } : {}), + ...(metadata.provenance && Object.keys(metadata.provenance).length > 0 + ? { provenance: metadata.provenance } + : {}), + }; +} + function envBaseUrl(name: string, fallback: string) { return (process.env[name] ?? fallback).replace(/\/+$/, ""); } @@ -1106,7 +1156,7 @@ async function installOrPlan( const cwd = options.cwd ?? process.cwd(); let bundle: NormalizedSkillBundle; - let bundleMetadata: BundleMetadata; + let bundleMetadata: ResolvedBundleMetadata; try { ({ bundle, metadata: bundleMetadata } = await resolveSkillBundle( options.skillBundle, @@ -1185,7 +1235,15 @@ async function installOrPlan( } report.conflicts.push({ ...result, reason: "owner-mismatch" }); } else if (metadata.value.hash === hash) { - if (await repairSkillBundleModes(bundle, target.targetDir, write)) { + const repaired = await repairSkillBundleModes( + bundle, + target.targetDir, + write, + ); + if ( + repaired || + !installedMetadataMatchesBundle(metadata.value, bundleMetadata) + ) { if (write) await writeMetadata( target.targetDir, @@ -1258,7 +1316,15 @@ export async function uninstallBundledSkill( } else if (metadata.value.appId !== options.appId) { report.conflicts.push({ ...result, reason: "owner-mismatch" }); } else { - await rm(target.targetDir, { recursive: true, force: true }); + const reason = await removeManagedTarget( + target.targetDir, + options.appId, + options.skillName, + ); + if (reason) { + report.conflicts.push({ ...result, reason }); + continue; + } report.removed.push(result); } } @@ -1266,13 +1332,48 @@ export async function uninstallBundledSkill( return report; } +async function removeManagedTarget( + targetDir: string, + appId: string, + skillName: string, +): Promise<"unmanaged" | "owner-mismatch" | undefined> { + const quarantine = await makeStagingDir(targetDir); + await rm(quarantine, { recursive: true }); + await rename(targetDir, quarantine); + const restore = async () => { + if (await exists(targetDir)) { + throw new Error( + `uninstall target changed; preserved quarantined target at ${quarantine}`, + ); + } + await rename(quarantine, targetDir); + }; + let metadata: InstalledMetadata | undefined; + try { + metadata = await readInstalledMetadata(quarantine); + } catch { + await restore(); + return "unmanaged"; + } + if (!metadata || metadata.skillName !== skillName) { + await restore(); + return "unmanaged"; + } + if (metadata.appId !== appId) { + await restore(); + return "owner-mismatch"; + } + await rm(quarantine, { recursive: true }); + return undefined; +} + async function copyManagedSkill( bundle: NormalizedSkillBundle, targetDir: string, appId: string, skillName: string, hash: string, - metadata: BundleMetadata, + metadata: ResolvedBundleMetadata, ) { const tmp = await makeStagingDir(targetDir); try { @@ -1291,7 +1392,7 @@ async function replaceManagedSkill( appId: string, skillName: string, hash: string, - metadata: BundleMetadata, + metadata: ResolvedBundleMetadata, ) { const tmp = await makeStagingDir(targetDir); const backup = `${tmp}-backup`; @@ -1360,9 +1461,9 @@ async function writeMetadata( appId: string, skillName: string, hash: string, - metadata: BundleMetadata, + metadata: ResolvedBundleMetadata, ) { - const value: InstallMetadata = { + const value: InstalledMetadata = { schemaVersion: 1, appId, skillName, @@ -1371,6 +1472,8 @@ async function writeMetadata( }; if (metadata.sourceId) value.sourceId = metadata.sourceId; if (metadata.version) value.version = metadata.version; + if (metadata.cliVersion) value.cliVersion = metadata.cliVersion; + if (metadata.revision) value.revision = metadata.revision; if (metadata.provenance) value.provenance = metadata.provenance; await writeFile( join(targetDir, ".kitup.json"), @@ -1380,20 +1483,40 @@ async function writeMetadata( async function readMetadata( targetDir: string, -): Promise<{ exists: boolean; value?: InstallMetadata }> { +): Promise<{ exists: boolean; value?: InstalledMetadata }> { if (!(await exists(targetDir))) return { exists: false }; try { - const raw = JSON.parse( - await readFile(join(targetDir, ".kitup.json"), "utf8"), - ); - const value = parseOwnedMetadata(raw); - return value ? { exists: true, value } : { exists: true }; + return { + exists: true, + value: await readInstalledMetadata(targetDir), + }; } catch { return { exists: true }; } } -function parseOwnedMetadata(raw: unknown): InstallMetadata | undefined { +export async function readInstalledMetadata( + targetDir: string, +): Promise { + let data: string; + try { + data = await readFile(join(targetDir, ".kitup.json"), "utf8"); + } catch (error: any) { + if (error.code === "ENOENT") return undefined; + throw error; + } + let raw: unknown; + try { + raw = JSON.parse(data); + } catch { + throw new Error("invalid installed metadata"); + } + const metadata = parseOwnedMetadata(raw); + if (!metadata) throw new Error("invalid installed metadata"); + return metadata; +} + +function parseOwnedMetadata(raw: unknown): InstalledMetadata | undefined { if (!raw || typeof raw !== "object") return undefined; const value = raw as Record; if (value.schemaVersion !== 1) return undefined; @@ -1404,21 +1527,68 @@ function parseOwnedMetadata(raw: unknown): InstallMetadata | undefined { if (value.source !== "bundled" && value.source !== "github") return undefined; if (typeof value.hash !== "string" || value.hash.length === 0) return undefined; - const metadata: InstallMetadata = { + const metadata: InstalledMetadata = { schemaVersion: 1, appId: value.appId, skillName: value.skillName, source: value.source, hash: value.hash, }; - if (typeof value.sourceId === "string") metadata.sourceId = value.sourceId; - if (typeof value.version === "string") metadata.version = value.version; - if (value.provenance && typeof value.provenance === "object") { - metadata.provenance = value.provenance as Record; - } + if (!optionalString(value.sourceId)) return undefined; + if (!optionalString(value.version)) return undefined; + if (!optionalString(value.cliVersion)) return undefined; + if (!optionalString(value.revision)) return undefined; + if (value.provenance !== undefined && !isStringRecord(value.provenance)) + return undefined; + if (value.sourceId) metadata.sourceId = value.sourceId; + if (value.version) metadata.version = value.version; + if (value.cliVersion) metadata.cliVersion = value.cliVersion; + if (value.revision) metadata.revision = value.revision; + if (value.provenance) metadata.provenance = value.provenance; return metadata; } +function optionalString(value: unknown): value is string | undefined { + return value === undefined || (typeof value === "string" && value.length > 0); +} + +function isStringRecord(value: unknown): value is Record { + return ( + Boolean(value) && + typeof value === "object" && + !Array.isArray(value) && + Object.values(value as Record).every( + (item) => typeof item === "string", + ) + ); +} + +function installedMetadataMatchesBundle( + installed: InstalledMetadata, + bundled: ResolvedBundleMetadata, +): boolean { + return ( + installed.source === bundled.source && + installed.sourceId === bundled.sourceId && + installed.version === bundled.version && + installed.cliVersion === bundled.cliVersion && + installed.revision === bundled.revision && + stringRecordsEqual(installed.provenance, bundled.provenance) + ); +} + +function stringRecordsEqual( + left: Record | undefined, + right: Record | undefined, +): boolean { + const leftEntries = Object.entries(left ?? {}); + const rightEntries = Object.entries(right ?? {}); + return ( + leftEntries.length === rightEntries.length && + leftEntries.every(([key, value]) => right?.[key] === value) + ); +} + function targetResult(target: TargetGroup): TargetResult { const base = { skillName: target.skillName, targetDir: target.targetDir }; return target.hostIds.length === 1 @@ -1690,7 +1860,8 @@ function isGenericDetectPath(path: string) { return ( path === "~/.agents" || path === "~/.agents/skills" || - path === "~/.config/agents" + path === "~/.config/agents" || + path === "package.json" ); } diff --git a/ts/test/golden.test.ts b/ts/test/golden.test.ts index 2ad482d..09fc6fe 100644 --- a/ts/test/golden.test.ts +++ b/ts/test/golden.test.ts @@ -25,6 +25,7 @@ import { loadHostSpec, planBundledSkill, parseInstallFlags, + readInstalledMetadata, resolveInstallSelection, resolveHosts, runBundledSkillInstall, @@ -160,6 +161,13 @@ async function runCase(testCase: any, home: string, workspace: string) { return; } + if (testCase.operation === "read-installed-metadata") { + const result = readInstalledMetadata(options.targetDir); + if (testCase.expected.throws) await assert.rejects(result); + else assert.deepEqual(await result, testCase.expected.installedMetadata); + return; + } + if (testCase.operation === "parse-install-flags") { assert.deepEqual( normalizeParsedFlags(parseInstallFlags(options)), @@ -221,6 +229,7 @@ async function runCase(testCase: any, home: string, workspace: string) { testCase.expected.detectedHosts, ); } + if (testCase.operation === "detect") return; const reportPromise = testCase.operation === "uninstall" @@ -470,9 +479,13 @@ function expandOptions(options: any, home: string, workspace: string) { if (expanded.skillBundleDir) expanded.skillBundle = directoryBundle( resolveRepoPath(expanded.skillBundleDir), + expanded.bundleMetadata, ); if (expanded.skillFiles) - expanded.skillBundle = filesBundle(expanded.skillFiles); + expanded.skillBundle = filesBundle( + expanded.skillFiles, + expanded.bundleMetadata, + ); if (expanded.githubBundle) expanded.skillBundle = githubBundle(expanded.githubBundle); return expanded;