diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c9e71fc..696901f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -26,7 +26,7 @@ Current capabilities: * Local and cached `origin` branch inventory with explicit refresh, tracking, divergence, and provider-enriched single-branch safety inspection * Guarded branch publication and local/origin creation, renaming, and deletion, including safe checkout transitions for checked-out branch deletion * Read-only, bounded branch cleanup candidates with documented reachability rules -* Agent guidance installation and removal for Codex, Claude Code, Pi, OpenCode, GitHub Copilot, and Gemini CLI; installation detects configured harnesses and preserves managed-content ownership +* Agent skill installation and removal for Codex, Claude Code, Pi, OpenCode, GitHub Copilot, Gemini CLI, Cursor, Hermes Agent, and OpenClaw; installation detects configured harnesses and tracks skill-content ownership to preserve user changes * Build identity reporting * Versioned command capability inventory, including explicit unavailable features diff --git a/DESIGN.md b/DESIGN.md index 805e431..ee4114d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -579,9 +579,15 @@ Uninstall uses those recorded paths and retains destinations still shared by another configured harness. `gha agent list` reports the recorded harnesses, destinations, and current file presence without claiming to detect agents GHA has not configured. Codex, Claude Code, Pi, OpenCode, GitHub Copilot, and Gemini -CLI are supported; the latter four share a globally discoverable skills path. -Cursor remains deferred until a safe global discovery path exists. Installation -detects configured supported harnesses from their setup directories, offers +CLI, Cursor, Hermes Agent, and OpenClaw are supported. Pi, OpenCode, Copilot, +and Gemini share a globally discoverable skills path. Cursor, Hermes, and +OpenClaw use their documented global roots; Hermes honors `HERMES_HOME` and +OpenClaw honors `OPENCLAW_STATE_DIR`. Skill ownership digests prevent GHA from +overwriting unowned skills and let update and uninstall preserve user-modified +content. See the [harness documentation](docs/command-guide.md#agent-guidance-installation) +and [roadmap](docs/roadmap.md). +Installation detects configured supported harnesses from their setup +directories, offers `--binary-only`, and reports when none are found; detection does not depend on harness executables. Skill discovery paths have been checked against current harness documentation. `gha update` now diff --git a/PROJECT_SUMMARY.md b/PROJECT_SUMMARY.md index d9fca9f..d16e722 100644 --- a/PROJECT_SUMMARY.md +++ b/PROJECT_SUMMARY.md @@ -19,7 +19,7 @@ context, safety, or workflow value beyond a raw provider invocation. - Guarded release-tag publication with commit, origin, CI, and workflow preflight - Offline local repository analysis - Guarded local and origin branch lifecycle operations, including publication and safe checked-out-branch deletion -- Agent guidance installation and removal for Codex, Claude Code, Pi, OpenCode, GitHub Copilot, and Gemini CLI; setup detects configured harnesses and supports binary-only installation +- Agent skill installation and removal for Codex, Claude Code, Pi, OpenCode, GitHub Copilot, Gemini CLI, Cursor, Hermes Agent, and OpenClaw; setup detects configured harnesses, preserves modified skills, and supports binary-only installation - Installed build identity reporting - Versioned capability inventory and structured diagnostics for automation @@ -64,7 +64,7 @@ context, safety, or workflow value beyond a raw provider invocation. 10. **Human-readable parity** - Render the same domain result clearly for terminal users ## Current Commands -- `gha agent install|uninstall` - Safely manage bundled GHA skills and guidance for six supported harnesses; install detects configured harnesses by default +- `gha agent install|uninstall` - Safely manage bundled GHA skills and guidance for nine supported harnesses; install detects configured harnesses by default - `gha version` and `gha capabilities` - Identify the installed build and enumerate its versioned command surface - `gha pr prepare|create` - Preflight and create a pull request unless `--dry-run` is set - `gha prs` and `gha review ` - Bounded PR discovery and decision-ready single-PR inspection diff --git a/README.md b/README.md index b7fbc34..84c9a4d 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,14 @@ GHA bundles a portable `gha` skill that helps coding agents use its structured G gha agent install ``` -The command detects configured Codex, Claude Code, Pi, OpenCode, GitHub Copilot, and Gemini CLI setup directories and configures those harnesses without requiring their executables. Use `--agent` with comma-separated names to choose explicitly, or `--binary-only` to skip harness setup. If none are detected, GHA reports how to configure one later. It copies the bundled skill into a discoverable global skill directory and idempotently adds a marked GHA guidance block where a documented global instruction file exists. `gha agent list` shows recorded harnesses and current file presence. +The command detects configured Codex, Claude Code, Pi, OpenCode, GitHub Copilot, Gemini CLI, Cursor, Hermes Agent, and OpenClaw setup directories and configures those harnesses without requiring their executables. Use `--agent` with comma-separated names to choose explicitly, or `--binary-only` to skip harness setup. If none are detected, GHA reports how to configure one later. It copies the bundled skill into a discoverable global skill directory and idempotently adds a marked GHA guidance block where a documented global instruction file exists. Existing skills are not overwritten, and modified GHA skills are preserved by update and uninstall. `gha agent list` shows recorded harnesses and current file presence. ```bash # Preview paths without writing files. gha agent install --agent codex,claude --dry-run -# Preview setup for Pi, OpenCode, Copilot, and Gemini CLI. -gha agent install --agent pi,opencode,copilot,gemini --dry-run +# Preview setup for Pi, OpenCode, Copilot, Gemini CLI, Cursor, Hermes, and OpenClaw. +gha agent install --agent pi,opencode,copilot,gemini,cursor,hermes,openclaw --dry-run # Remove GHA's managed guidance block and its installed skill. gha agent uninstall --agent codex --dry-run diff --git a/docs/command-guide.md b/docs/command-guide.md index 218cab6..c288d26 100644 --- a/docs/command-guide.md +++ b/docs/command-guide.md @@ -56,7 +56,7 @@ gha version --format json `gha agent list` reports harnesses recorded by `gha agent install`, the exact instruction and skill destinations GHA manages, and whether each file is -present, missing, or unavailable. An empty list means no harnesses are recorded by GHA; it +present, modified, missing, or unavailable. An empty list means no harnesses are recorded by GHA; it does not search the machine for manually configured or installed agents. Structured output is available with `--format json` or `--format yaml`. @@ -66,7 +66,8 @@ gha agent list --format json ``` `gha agent install` detects configured supported harnesses and copies the skill bundled with the installed GHA binary to -Codex, Claude Code, Pi, OpenCode, GitHub Copilot, or Gemini CLI. It adds a +Codex, Claude Code, Pi, OpenCode, GitHub Copilot, Gemini CLI, Cursor, Hermes +Agent, or OpenClaw. It adds a clearly marked GHA section to the selected global instruction file where the harness supports a documented global file. Copilot uses its shared skill directory without a separate global instructions file. Existing instruction @@ -74,9 +75,10 @@ content is preserved; rerunning the command refreshes the skill without duplicating the managed section. Installation records configured harnesses and their exact destinations in the user's GHA config directory (`agent-installations.json`). Uninstall uses those recorded destinations, removes only managed guidance and -the bundled GHA skill, and retains paths still shared by another configured -harness. The ownership record is removed after the last configured harness is -uninstalled. +an unchanged GHA skill, and retains paths still shared by another configured +harness. Existing unowned skills are never overwritten; a skill changed after +installation is preserved during update and uninstall. The ownership record is +removed after the last configured harness is uninstalled. When `--agent` is omitted, the command configures detected harnesses based on their setup directories; it does not require harness executables. If none are @@ -84,7 +86,12 @@ found, it explains how to select one later. Use `--binary-only` to skip harness setup, or comma-separated names such as `--agent pi,gemini,copilot` to select explicitly. Pi, OpenCode, Copilot, and Gemini share `~/.agents/skills/gha/SKILL.md` where their documented discovery supports -it. Inspect destinations with `--dry-run`; otherwise the selected operation runs. +it. Cursor uses `~/.cursor/skills/gha/SKILL.md`, Hermes Agent uses +`$HERMES_HOME/skills/gha/SKILL.md` (default `~/.hermes/skills/gha/SKILL.md`), +and OpenClaw uses `$OPENCLAW_STATE_DIR/skills/gha/SKILL.md` (default +`~/.openclaw/skills/gha/SKILL.md`). Harness setup directories are detected +without requiring executables. Inspect destinations with `--dry-run`; otherwise +the selected operation runs. ```bash # Preview a Codex installation without changing files. @@ -111,8 +118,13 @@ gha update --dry-run --format json `gha update` discovers the latest published GitHub release, then fetches that release's bundled skill and managed instruction block. It updates only the destinations recorded by `gha agent install`, preserving content outside the -managed block. Writing runs by default; use `--dry-run` to preview. This command -does not update the GHA executable. +managed block. Before writing, it checks the skill's declared GHA command +requirements against this executable's available capability inventory. If any +required command is unavailable or the skill has no compatibility manifest, +the update is rejected and existing files are preserved. Writing runs by +default; use `--dry-run` to preview. This command does not update the GHA +executable; update that through its installation method first. Structured +results report `compatibility_state` and any `missing_capabilities`. ## Pull-request review diff --git a/docs/roadmap.md b/docs/roadmap.md index da62b84..a5b1fcc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -24,7 +24,8 @@ confirmation boundaries, and report what actually happened. - **Repository analysis:** offline worktree, history, storage, and large-file analysis. - **Agent guidance:** install, list, and uninstall for Codex, Claude Code, Pi, - OpenCode, GitHub Copilot, and Gemini CLI, with recorded destination ownership. + OpenCode, GitHub Copilot, Gemini CLI, Cursor, Hermes Agent, and OpenClaw, with + recorded destination ownership and modified-skill preservation. - **Guidance updates:** `gha update` discovers the latest published release and refreshes skills and managed instructions at recorded destinations; the GHA executable is not updated. @@ -39,12 +40,15 @@ confirmation boundaries, and report what actually happened. Priorities are ordered by expected user value and prerequisite: -1. **Assess additional harnesses.** Assess Cursor, Hermes Agent, and OpenClaw only after confirming - their current loading and safe-removal contracts. -2. **Show coverage and project status.** Publish the CI coverage result and its +1. **Show coverage and project status.** Publish the CI coverage result and its 95% pass/fail status in pull requests. Add README badges for coverage, CI, and the latest release; include prereleases during the alpha phase. Keep the existing license badge and avoid manually maintained values. +2. **Package-manager distribution.** Add maintained package-manager paths for + released GHA binaries, starting with a Homebrew tap and assessing Linux + options. Keep executable upgrades owned by the selected package manager and + refresh agent skills only when their declared GHA capabilities are + supported by the installed binary. 3. **Design cleanup actions.** Before adding writes to `branches cleanup`, define candidate selection, per-target confirmation, provider safety, and checked-out-branch transitions. The current command stays read-only. diff --git a/internal/commands/agent.go b/internal/commands/agent.go index 8523486..88506c5 100644 --- a/internal/commands/agent.go +++ b/internal/commands/agent.go @@ -4,6 +4,8 @@ package commands import ( "bufio" "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -20,7 +22,9 @@ type agentInstallation struct { ID string `json:"id"` Name string `json:"name"` SkillPath string `json:"skill_path"` + SkillDigest string `json:"skill_digest,omitempty"` InstructionsPath string `json:"instructions_path"` + skillOwned bool `json:"-"` } type agentOwnership struct { @@ -33,6 +37,8 @@ type agentUninstallResult struct { skillRemoved bool guidanceRetained bool skillRetained bool + skillModified bool + skillUnowned bool } func newAgentCmd() *cobra.Command { @@ -59,7 +65,7 @@ explicitly. Use --dry-run to inspect the destination paths.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runAgentInstall(cmd, agent, dryRun, binaryOnly) }, } - command.Flags().StringVar(&agent, "agent", "", "Comma-separated agents (codex, claude, pi, opencode, copilot, gemini, all); detects configured harnesses when omitted") + command.Flags().StringVar(&agent, "agent", "", "Comma-separated agents (codex, claude, pi, opencode, copilot, gemini, cursor, hermes, openclaw, all); detects configured harnesses when omitted") command.Flags().BoolVar(&dryRun, "dry-run", false, "Show the files that would be written") command.Flags().BoolVar(&binaryOnly, "binary-only", false, "Skip coding-agent harness setup") command.SilenceUsage = true @@ -83,8 +89,27 @@ func runAgentInstall(cmd *cobra.Command, agent string, dryRun, binaryOnly bool) _, err := fmt.Fprintln(cmd.OutOrStdout(), "No supported coding-agent harnesses were detected. GHA is ready to use; install setup later with `gha agent install --agent ` or select `--binary-only`.") return err } + return installAgentTargets(cmd, targets, dryRun) +} + +func installAgentTargets(cmd *cobra.Command, targets []agentInstallation, dryRun bool) error { + ownership, err := readAgentOwnership() + if err != nil { + return err + } + plannedSkills := map[string]bool{} + for _, target := range targets { + if plannedSkills[target.SkillPath] { + continue + } + if err := validateSkillInstallTarget(target, ownership); err != nil { + return fmt.Errorf("install skill for %s: %w", target.Name, err) + } + plannedSkills[target.SkillPath] = true + } installedSkills := map[string]bool{} for _, target := range targets { + target.SkillDigest = skillDigest(ghaskill.Skill) if dryRun { if err := printAgentInstallPlan(cmd.OutOrStdout(), target); err != nil { return err @@ -155,7 +180,7 @@ func detectedOrSelectedAgentInstallations(agent string) ([]agentInstallation, er return selectedAgentInstallations(agent, "install the gha skill for", strings.NewReader(""), io.Discard) } var targets []agentInstallation - for _, id := range []string{"codex", "claude", "pi", "opencode", "copilot", "gemini"} { + for _, id := range supportedAgentIDs() { target, err := agentInstallationFor(id) if err != nil { return nil, err @@ -170,6 +195,9 @@ func detectedOrSelectedAgentInstallations(agent string) ([]agentInstallation, er return nil, err } } + if target.ID == "cursor" { + root = filepath.Dir(filepath.Dir(filepath.Dir(target.SkillPath))) + } info, err := os.Stat(root) if err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("detect %s configuration: %w", target.Name, err) @@ -189,15 +217,15 @@ func newAgentUninstallCmd() *cobra.Command { Short: "Remove GHA guidance and skill for selected agents", Long: `Remove the managed GHA guidance section and bundled gha skill for selected supported agents. -Every instruction outside the marked GHA section and other files in the skill -directory are preserved. Without --agent, choose an agent interactively. Use +Every instruction outside the marked GHA section, other files in the skill +directory, and skills modified after installation are preserved. Without --agent, choose an agent interactively. Use --dry-run to inspect the destination paths. Removal runs unless --dry-run is specified.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runAgentUninstall(cmd, agent, dryRun) }, } - command.Flags().StringVar(&agent, "agent", "", "Comma-separated agents (codex, claude, pi, opencode, copilot, gemini, all); prompts when omitted") + command.Flags().StringVar(&agent, "agent", "", "Comma-separated agents (codex, claude, pi, opencode, copilot, gemini, cursor, hermes, openclaw, all); prompts when omitted") command.Flags().BoolVar(&dryRun, "dry-run", false, "Show the GHA files that would be removed") command.SilenceUsage = true command.SilenceErrors = true @@ -219,12 +247,15 @@ func runAgentUninstall(cmd *cobra.Command, agent string, dryRun bool) error { selectedIDs = append(selectedIDs, target.ID) } for _, target := range targets { + recordedSkill := false for _, recorded := range ownership.Agents { if recorded.ID == target.ID { target = recorded + recordedSkill = true break } } + target.skillOwned = recordedSkill remaining := ownership.withoutAgents(selectedIDs) if !dryRun { remaining = ownership.without(target.ID) @@ -244,23 +275,18 @@ func runAgentUninstall(cmd *cobra.Command, agent string, dryRun bool) error { func uninstallAgentTarget(output io.Writer, target agentInstallation, dryRun bool, remaining agentOwnership) error { if dryRun { - message := "Would remove managed GHA guidance for " + target.Name - if target.InstructionsPath == "" { - message = "Would remove gha skill for " + target.Name - } - if target.InstructionsPath != "" { - message += " from " + target.InstructionsPath - } - if target.SkillPath != "" { - message += " and gha skill at " + target.SkillPath - } - if remaining.owns(target.InstructionsPath) || remaining.owns(target.SkillPath) { - message += " (shared destination retained)" - } - _, err := fmt.Fprintln(output, message) + return printAgentUninstallPlan(output, target, remaining) + } + + result, err := uninstallAgentChanges(target, remaining) + if err != nil { return err } + _, err = fmt.Fprint(output, agentUninstallResultMessage(target.Name, result)) + return err +} +func uninstallAgentChanges(target agentInstallation, remaining agentOwnership) (agentUninstallResult, error) { result := agentUninstallResult{} var err error if remaining.owns(target.InstructionsPath) { @@ -271,22 +297,71 @@ func uninstallAgentTarget(output io.Writer, target agentInstallation, dryRun boo if err == nil { if remaining.owns(target.SkillPath) { result.skillRetained = true + } else if !target.skillOwned { + result.skillRetained = true + result.skillUnowned = true } else { - result.skillRemoved, err = removeAgentSkill(target.SkillPath) + result.skillRemoved, result.skillModified, err = removeAgentSkillIfUnchanged(target.SkillPath, target.SkillDigest) if err != nil { err = fmt.Errorf("remove skill for %s: %w", target.Name, err) } } } if err != nil { - return err + return result, err + } + return result, nil +} + +func printAgentUninstallPlan(output io.Writer, target agentInstallation, remaining agentOwnership) error { + message := "Would remove managed GHA guidance for " + target.Name + if target.InstructionsPath == "" { + message = "Would remove gha skill for " + target.Name + } + if target.InstructionsPath != "" { + message += " from " + target.InstructionsPath } - message := agentUninstallResultMessage(target.Name, result) - _, err = fmt.Fprint(output, message) + if target.SkillPath != "" { + message += " and gha skill at " + target.SkillPath + } + if remaining.owns(target.InstructionsPath) || remaining.owns(target.SkillPath) { + message += " (shared destination retained)" + } else if !target.skillOwned { + message += " (skill is not recorded as GHA-managed and will be preserved)" + } else if target.SkillPath != "" { + if data, err := os.ReadFile(target.SkillPath); err == nil && skillChanged(data, target.SkillDigest) { + message += " (modified skill will be preserved)" + } + } + _, err := fmt.Fprintln(output, message) return err } func agentUninstallResultMessage(name string, result agentUninstallResult) string { + if result.skillUnowned { + return unownedSkillRemovalMessage(name, result.guidanceRemoved) + } + if result.skillModified { + return modifiedSkillRemovalMessage(name, result.guidanceRemoved) + } + return ordinaryAgentUninstallMessage(name, result) +} + +func unownedSkillRemovalMessage(name string, guidanceRemoved bool) string { + if guidanceRemoved { + return fmt.Sprintf("Removed managed GHA guidance for %s; preserved the unowned gha skill.\n", name) + } + return fmt.Sprintf("Preserved the unowned gha skill for %s; no managed guidance was found.\n", name) +} + +func modifiedSkillRemovalMessage(name string, guidanceRemoved bool) string { + if guidanceRemoved { + return fmt.Sprintf("Removed managed GHA guidance for %s; preserved the modified gha skill.\n", name) + } + return fmt.Sprintf("Preserved the modified gha skill for %s; no managed guidance was found.\n", name) +} + +func ordinaryAgentUninstallMessage(name string, result agentUninstallResult) string { switch { case result.guidanceRemoved && result.skillRetained: return fmt.Sprintf("Removed managed GHA guidance for %s; shared gha skill remains in use.\n", name) @@ -311,7 +386,7 @@ func agentUninstallResultMessage(name string, result agentUninstallResult) strin func selectedAgentInstallations(agent, action string, input io.Reader, output io.Writer) ([]agentInstallation, error) { if agent == "" { - if _, err := fmt.Fprintf(output, "Which agent(s) should %s?\n codex) Codex\n claude) Claude Code\n pi) Pi\n opencode) OpenCode\n copilot) GitHub Copilot\n gemini) Gemini CLI\n all) All supported harnesses\nEnter one or more names, separated by commas: ", action); err != nil { + if _, err := fmt.Fprintf(output, "Which agent(s) should %s?\n codex) Codex\n claude) Claude Code\n pi) Pi\n opencode) OpenCode\n copilot) GitHub Copilot\n gemini) Gemini CLI\n cursor) Cursor\n hermes) Hermes Agent\n openclaw) OpenClaw\n all) All supported harnesses\nEnter one or more names, separated by commas: ", action); err != nil { return nil, err } line, err := bufio.NewReader(input).ReadString('\n') @@ -354,10 +429,10 @@ func selectedAgentIDs(value string) ([]string, error) { } } if selected["all"] { - return []string{"codex", "claude", "pi", "opencode", "copilot", "gemini"}, nil + return supportedAgentIDs(), nil } ids := make([]string, 0, len(selected)) - for _, id := range []string{"codex", "claude", "pi", "opencode", "copilot", "gemini"} { + for _, id := range supportedAgentIDs() { if selected[id] { ids = append(ids, id) } @@ -368,10 +443,10 @@ func selectedAgentIDs(value string) ([]string, error) { func normalizeAgentID(value string) (string, error) { value = strings.ToLower(strings.TrimSpace(value)) switch value { - case "codex", "claude", "pi", "opencode", "copilot", "gemini", "all": + case "codex", "claude", "pi", "opencode", "copilot", "gemini", "cursor", "hermes", "openclaw", "all": return value, nil default: - return "", fmt.Errorf("invalid agent %q; use codex, claude, pi, opencode, copilot, gemini, or all", value) + return "", fmt.Errorf("invalid agent %q; use codex, claude, pi, opencode, copilot, gemini, cursor, hermes, openclaw, or all", value) } } @@ -415,6 +490,12 @@ func agentInstallationFor(id string) (agentInstallation, error) { return copilotInstallation() case "gemini": return geminiInstallation() + case "cursor": + return cursorInstallation() + case "hermes": + return hermesInstallation() + case "openclaw": + return openClawInstallation() default: return agentInstallation{}, fmt.Errorf("unsupported agent %q", id) } @@ -477,6 +558,63 @@ func geminiInstallation() (agentInstallation, error) { return agentInstallation{ID: "gemini", Name: "Gemini CLI", SkillPath: skill, InstructionsPath: filepath.Join(root, "GEMINI.md")}, nil } +func cursorInstallation() (agentInstallation, error) { + root, err := agentConfigRoot("", ".cursor") + if err != nil { + return agentInstallation{}, err + } + return agentInstallation{ID: "cursor", Name: "Cursor", SkillPath: filepath.Join(root, "skills", "gha", "SKILL.md")}, nil +} + +func hermesInstallation() (agentInstallation, error) { + root, err := agentConfigRoot("HERMES_HOME", ".hermes") + if err != nil { + return agentInstallation{}, err + } + return agentInstallation{ID: "hermes", Name: "Hermes Agent", SkillPath: filepath.Join(root, "skills", "gha", "SKILL.md")}, nil +} + +func openClawInstallation() (agentInstallation, error) { + root, err := agentConfigRoot("OPENCLAW_STATE_DIR", ".openclaw") + if err != nil { + return agentInstallation{}, err + } + return agentInstallation{ID: "openclaw", Name: "OpenClaw", SkillPath: filepath.Join(root, "skills", "gha", "SKILL.md")}, nil +} + +func supportedAgentIDs() []string { + return []string{"codex", "claude", "pi", "opencode", "copilot", "gemini", "cursor", "hermes", "openclaw"} +} + +func skillDigest(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +func validateSkillInstallTarget(target agentInstallation, ownership agentOwnership) error { + current, err := os.ReadFile(target.SkillPath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read existing %s skill: %w", target.Name, err) + } + expected, managed := ownership.skillDigest(target.SkillPath) + if !managed { + return fmt.Errorf("%s skill already exists at %s and is not managed by GHA; move it or remove it before installing", target.Name, target.SkillPath) + } + if expected == "" { + if bytes.Equal(current, ghaskill.Skill) { + return nil + } + return fmt.Errorf("managed %s skill at %s has changed; preserve it or restore the GHA version before reinstalling", target.Name, target.SkillPath) + } + if skillDigest(current) != expected { + return fmt.Errorf("managed %s skill at %s has changed; preserve it or restore the GHA version before reinstalling", target.Name, target.SkillPath) + } + return nil +} + func agentConfigRoot(environment, defaultDirectory string) (string, error) { if configured := os.Getenv(environment); configured != "" { return configured, nil @@ -595,7 +733,19 @@ func recordAgentInstallation(target agentInstallation) error { if err != nil { return err } + if target.SkillDigest == "" { + if data, readErr := os.ReadFile(target.SkillPath); readErr == nil { + target.SkillDigest = skillDigest(data) + } else if !os.IsNotExist(readErr) { + return fmt.Errorf("read installed skill for ownership: %w", readErr) + } + } ownership.Version = 1 + for i := range ownership.Agents { + if ownership.Agents[i].SkillPath == target.SkillPath && target.SkillDigest != "" { + ownership.Agents[i].SkillDigest = target.SkillDigest + } + } for i, current := range ownership.Agents { if current.ID == target.ID { ownership.Agents[i] = target @@ -636,6 +786,15 @@ func (ownership agentOwnership) owns(path string) bool { return false } +func (ownership agentOwnership) skillDigest(path string) (string, bool) { + for _, agent := range ownership.Agents { + if agent.SkillPath == path { + return agent.SkillDigest, true + } + } + return "", false +} + func removeAgentSkill(skillPath string) (bool, error) { if err := os.Remove(skillPath); err != nil { if os.IsNotExist(err) { @@ -648,6 +807,32 @@ func removeAgentSkill(skillPath string) (bool, error) { return true, nil } +func removeAgentSkillIfUnchanged(skillPath, expectedDigest string) (bool, bool, error) { + content, err := os.ReadFile(skillPath) + if os.IsNotExist(err) { + return false, false, nil + } + if err != nil { + return false, false, err + } + if skillChanged(content, expectedDigest) { + return false, true, nil + } + if err := os.Remove(skillPath); err != nil { + return false, false, err + } + removeEmptyDirectory(filepath.Dir(skillPath)) + removeEmptyDirectory(filepath.Dir(filepath.Dir(skillPath))) + return true, false, nil +} + +func skillChanged(content []byte, expectedDigest string) bool { + if expectedDigest == "" { + return !bytes.Equal(content, ghaskill.Skill) + } + return skillDigest(content) != expectedDigest +} + func removeEmptyDirectory(path string) { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return diff --git a/internal/commands/agent_list.go b/internal/commands/agent_list.go index 16e6292..ddbbada 100644 --- a/internal/commands/agent_list.go +++ b/internal/commands/agent_list.go @@ -14,7 +14,7 @@ func newAgentListCmd() *cobra.Command { command := &cobra.Command{ Use: "list", Short: "List coding-agent harnesses configured by GHA", - Long: "List harnesses recorded by gha agent install, their managed destinations, and whether the guidance and skill files are present.", + Long: "List harnesses recorded by gha agent install, their managed destinations, and whether the guidance and skill files are present or modified.", Args: noArgsWithFormat(&format), RunE: func(cmd *cobra.Command, _ []string) error { outputFormat, err := output.ParseFormat(format) @@ -30,7 +30,7 @@ func newAgentListCmd() *cobra.Command { result.Agents = append(result.Agents, model.AgentInstallation{ ID: agent.ID, Name: agent.Name, SkillPath: agent.SkillPath, InstructionsPath: agent.InstructionsPath, - SkillState: fileState(agent.SkillPath), GuidanceState: fileState(agent.InstructionsPath), + SkillState: agentSkillState(agent), GuidanceState: fileState(agent.InstructionsPath), }) } return output.AgentInstallations(cmd.OutOrStdout(), outputFormat, result) @@ -42,6 +42,23 @@ func newAgentListCmd() *cobra.Command { return command } +func agentSkillState(agent agentInstallation) string { + if agent.SkillPath == "" { + return "not_configured" + } + content, err := os.ReadFile(agent.SkillPath) + if os.IsNotExist(err) { + return "missing" + } + if err != nil { + return "unavailable" + } + if skillChanged(content, agent.SkillDigest) { + return "modified" + } + return "present" +} + func fileState(path string) string { if path == "" { return "not_configured" diff --git a/internal/commands/agent_list_test.go b/internal/commands/agent_list_test.go index f76be08..26ec327 100644 --- a/internal/commands/agent_list_test.go +++ b/internal/commands/agent_list_test.go @@ -36,6 +36,13 @@ func TestAgentListReportsRecordedHarnessAndCurrentFiles(t *testing.T) { assert.Contains(t, output.String(), "present") assert.Contains(t, output.String(), "Guidance:") assert.Contains(t, output.String(), "missing") + require.NoError(t, os.WriteFile(target.SkillPath, []byte("user edit"), 0o644)) + root = NewRootCmd(nil, nil, nil) + output.Reset() + root.SetOut(&output) + root.SetArgs([]string{"agent", "list", "--format", "json"}) + require.NoError(t, root.Execute()) + assert.Contains(t, output.String(), `"skill_state": "modified"`) } func TestAgentListEmptyInventoryAndYAML(t *testing.T) { diff --git a/internal/commands/agent_test.go b/internal/commands/agent_test.go index 5c8ed10..dfb31f3 100644 --- a/internal/commands/agent_test.go +++ b/internal/commands/agent_test.go @@ -150,6 +150,174 @@ func TestAgentInstallationsUseDocumentedGlobalPaths(t *testing.T) { assert.Equal(t, tc.instruction, target.InstructionsPath) assert.Equal(t, filepath.Join(home, ".agents", "skills", "gha", "SKILL.md"), target.SkillPath) } + t.Setenv("HERMES_HOME", filepath.Join(home, "hermes-profile")) + t.Setenv("OPENCLAW_STATE_DIR", filepath.Join(home, "openclaw-state")) + for _, tc := range []struct{ id, root string }{ + {"cursor", filepath.Join(home, ".cursor")}, + {"hermes", filepath.Join(home, "hermes-profile")}, + {"openclaw", filepath.Join(home, "openclaw-state")}, + } { + target, err := agentInstallationFor(tc.id) + require.NoError(t, err) + assert.Equal(t, filepath.Join(tc.root, "skills", "gha", "SKILL.md"), target.SkillPath) + } +} + +func TestAgentInstallRefusesToOverwriteUnownedSkillAndUninstallPreservesModifiedSkill(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + target, err := agentInstallationFor("cursor") + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(target.SkillPath), 0o755)) + require.NoError(t, os.WriteFile(target.SkillPath, []byte("personal skill"), 0o644)) + root := NewRootCmd(nil, nil, nil) + root.SetArgs([]string{"agent", "install", "--agent", "cursor"}) + assert.ErrorContains(t, root.Execute(), "is not managed by GHA") + content, err := os.ReadFile(target.SkillPath) + require.NoError(t, err) + assert.Equal(t, "personal skill", string(content)) + + require.NoError(t, os.Remove(target.SkillPath)) + root = NewRootCmd(nil, nil, nil) + root.SetArgs([]string{"agent", "install", "--agent", "cursor"}) + require.NoError(t, root.Execute()) + require.NoError(t, os.WriteFile(target.SkillPath, []byte("user edit"), 0o644)) + root = NewRootCmd(nil, nil, nil) + root.SetArgs([]string{"agent", "uninstall", "--agent", "cursor"}) + require.NoError(t, root.Execute()) + content, err = os.ReadFile(target.SkillPath) + require.NoError(t, err) + assert.Equal(t, "user edit", string(content)) +} + +func TestAgentUninstallDoesNotRemoveUnownedGhaSkill(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + target, err := agentInstallationFor("cursor") + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(target.SkillPath), 0o755)) + require.NoError(t, os.WriteFile(target.SkillPath, ghaskill.Skill, 0o644)) + root := NewRootCmd(nil, nil, nil) + root.SetArgs([]string{"agent", "uninstall", "--agent", "cursor"}) + require.NoError(t, root.Execute()) + content, err := os.ReadFile(target.SkillPath) + require.NoError(t, err) + assert.Equal(t, ghaskill.Skill, content) +} + +func TestSkillOwnershipHelpersCoverVerifiedMissingAndUnreadableFiles(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "SKILL.md") + target := agentInstallation{Name: "Cursor", SkillPath: path} + assert.NoError(t, validateSkillInstallTarget(target, agentOwnership{})) + assert.Equal(t, "missing", agentSkillState(target)) + + require.NoError(t, os.WriteFile(path, ghaskill.Skill, 0o644)) + ownership := agentOwnership{Agents: []agentInstallation{{SkillPath: path, SkillDigest: skillDigest(ghaskill.Skill)}}} + assert.NoError(t, validateSkillInstallTarget(target, ownership)) + target.SkillDigest = skillDigest(ghaskill.Skill) + assert.Equal(t, "present", agentSkillState(target)) + + require.NoError(t, os.WriteFile(path, []byte("changed"), 0o644)) + assert.ErrorContains(t, validateSkillInstallTarget(target, ownership), "has changed") + assert.Equal(t, "modified", agentSkillState(target)) + + target.SkillPath = root + assert.Equal(t, "unavailable", agentSkillState(target)) + assert.Equal(t, "not_configured", agentSkillState(agentInstallation{})) + assert.Error(t, validateSkillInstallTarget(target, ownership)) + _, _, err := removeAgentSkillIfUnchanged(root, "") + assert.Error(t, err) + assert.Contains(t, agentUninstallResultMessage("Cursor", agentUninstallResult{skillModified: true}), "Preserved the modified gha skill") +} + +func TestRemoveAgentSkillIfUnchangedCoversDigestAndMissingCases(t *testing.T) { + path := filepath.Join(t.TempDir(), "SKILL.md") + removed, modified, err := removeAgentSkillIfUnchanged(path, "") + require.NoError(t, err) + assert.False(t, removed) + assert.False(t, modified) + + require.NoError(t, os.WriteFile(path, []byte("modified"), 0o644)) + removed, modified, err = removeAgentSkillIfUnchanged(path, skillDigest(ghaskill.Skill)) + require.NoError(t, err) + assert.False(t, removed) + assert.True(t, modified) + + require.NoError(t, os.WriteFile(path, ghaskill.Skill, 0o644)) + removed, modified, err = removeAgentSkillIfUnchanged(path, skillDigest(ghaskill.Skill)) + require.NoError(t, err) + assert.True(t, removed) + assert.False(t, modified) +} + +func TestAgentUninstallMessagesCoverOwnershipAndSharedStates(t *testing.T) { + cases := []struct { + result agentUninstallResult + want string + }{ + {agentUninstallResult{guidanceRemoved: true, skillUnowned: true}, "preserved the unowned gha skill"}, + {agentUninstallResult{skillUnowned: true}, "no managed guidance was found"}, + {agentUninstallResult{guidanceRemoved: true, skillModified: true}, "preserved the modified gha skill"}, + {agentUninstallResult{skillModified: true}, "no managed guidance was found"}, + {agentUninstallResult{guidanceRemoved: true, skillRetained: true}, "shared gha skill remains in use"}, + {agentUninstallResult{guidanceRetained: true, skillRemoved: true}, "shared GHA guidance remains in use"}, + {agentUninstallResult{guidanceRetained: true, skillRetained: true}, "Retained shared managed GHA guidance and skill"}, + {agentUninstallResult{skillRetained: true}, "no managed GHA guidance was found"}, + {agentUninstallResult{guidanceRetained: true}, "no gha skill was found"}, + {agentUninstallResult{guidanceRemoved: true, skillRemoved: true}, "Removed managed GHA guidance and skill"}, + {agentUninstallResult{guidanceRemoved: true}, "no gha skill was found"}, + {agentUninstallResult{skillRemoved: true}, "no managed GHA guidance was found"}, + {agentUninstallResult{}, "No managed GHA guidance or skill found"}, + } + for _, tc := range cases { + assert.Contains(t, agentUninstallResultMessage("Cursor", tc.result), tc.want) + } + assert.Contains(t, modifiedSkillRemovalMessage("Cursor", true), "Removed managed GHA guidance") +} + +func TestAgentUninstallPlanReportsModifiedAndUnownedSkills(t *testing.T) { + path := filepath.Join(t.TempDir(), "SKILL.md") + require.NoError(t, os.WriteFile(path, []byte("changed"), 0o644)) + var output bytes.Buffer + target := agentInstallation{Name: "Cursor", SkillPath: path, skillOwned: true, SkillDigest: skillDigest(ghaskill.Skill)} + require.NoError(t, printAgentUninstallPlan(&output, target, agentOwnership{})) + assert.Contains(t, output.String(), "modified skill will be preserved") + output.Reset() + target.skillOwned = false + require.NoError(t, printAgentUninstallPlan(&output, target, agentOwnership{})) + assert.Contains(t, output.String(), "not recorded as GHA-managed") + output.Reset() + require.NoError(t, printAgentUninstallPlan(&output, target, agentOwnership{Agents: []agentInstallation{{SkillPath: path}}})) + assert.Contains(t, output.String(), "shared destination retained") +} + +func TestSkillChangedSupportsLegacyAndDigestOwnership(t *testing.T) { + assert.False(t, skillChanged(ghaskill.Skill, "")) + assert.True(t, skillChanged([]byte("custom"), "")) + assert.False(t, skillChanged(ghaskill.Skill, skillDigest(ghaskill.Skill))) + assert.True(t, skillChanged([]byte("custom"), skillDigest(ghaskill.Skill))) +} + +func TestAdditionalHarnessPathsUseDocumentedDefaultsAndOverrides(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("HERMES_HOME", "") + t.Setenv("OPENCLAW_STATE_DIR", "") + for _, tc := range []struct{ id, directory string }{{"cursor", ".cursor"}, {"hermes", ".hermes"}, {"openclaw", ".openclaw"}} { + target, err := agentInstallationFor(tc.id) + require.NoError(t, err) + assert.Contains(t, target.SkillPath, filepath.Join(home, tc.directory, "skills", "gha")) + } + t.Setenv("HOME", "") + _, err := cursorInstallation() + assert.ErrorContains(t, err, "home directory") + _, err = hermesInstallation() + assert.ErrorContains(t, err, "home directory") + _, err = openClawInstallation() + assert.ErrorContains(t, err, "home directory") } func TestAgentOwnershipReportsCorruptVersionsAndFilesystemFailures(t *testing.T) { @@ -198,7 +366,7 @@ func TestAgentGlobalPathResolutionFailuresAndEmptySelection(t *testing.T) { assert.ErrorContains(t, err, "invalid agent") ids, err := selectedAgentIDs("all") require.NoError(t, err) - assert.Equal(t, []string{"codex", "claude", "pi", "opencode", "copilot", "gemini"}, ids) + assert.Equal(t, supportedAgentIDs(), ids) assert.ErrorContains(t, agentOwnershipReadError(), "user config directory") } @@ -225,7 +393,7 @@ func TestAgentInstallReportsEveryOutputAndOwnershipFailure(t *testing.T) { t.Setenv("CODEX_HOME", filepath.Join(t.TempDir(), "codex")) root = NewRootCmd(nil, nil, nil) root.SetArgs([]string{"agent", "install", "--agent", "codex"}) - assert.ErrorContains(t, root.Execute(), "record Codex installation ownership") + assert.ErrorContains(t, root.Execute(), "read agent ownership") } func TestAgentUninstallReportsManifestAndSharedDestinationErrors(t *testing.T) { @@ -242,6 +410,7 @@ func TestAgentUninstallReportsManifestAndSharedDestinationErrors(t *testing.T) { target := agentInstallation{Name: "Pi", InstructionsPath: filepath.Join(home, "shared.md"), SkillPath: filepath.Join(home, "skills", "SKILL.md")} remaining := agentOwnership{Agents: []agentInstallation{{InstructionsPath: target.InstructionsPath, SkillPath: target.SkillPath}}} writer := &bytes.Buffer{} + target.skillOwned = true require.NoError(t, uninstallAgentTarget(writer, target, false, remaining)) assert.Contains(t, writer.String(), "Retained shared managed GHA guidance and skill") assert.Error(t, uninstallAgentTarget(&failOnWriteNumber{failAt: 1}, target, true, agentOwnership{})) @@ -342,6 +511,26 @@ func TestAgentDetectionReportsUnreadableConfigurationPath(t *testing.T) { assert.ErrorContains(t, err, "detect Codex configuration") } +func TestAgentDetectionFindsCursorHermesAndOpenClawConfiguration(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + hermes := filepath.Join(home, "hermes-profile") + openclaw := filepath.Join(home, "openclaw-profile") + t.Setenv("HERMES_HOME", hermes) + t.Setenv("OPENCLAW_STATE_DIR", openclaw) + for _, path := range []string{filepath.Join(home, ".cursor"), hermes, openclaw} { + require.NoError(t, os.MkdirAll(path, 0o755)) + } + targets, err := detectedOrSelectedAgentInstallations("") + require.NoError(t, err) + var ids []string + for _, target := range targets { + ids = append(ids, target.ID) + } + assert.Equal(t, []string{"cursor", "hermes", "openclaw"}, ids) +} + func TestAgentInstallDryRunDoesNotWrite(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) codexHome := filepath.Join(t.TempDir(), "codex") @@ -387,9 +576,10 @@ func TestAgentUninstallRemovesManagedGuidanceAndSkill(t *testing.T) { assert.NotContains(t, string(guidance), "gha:begin") assert.Contains(t, string(guidance), "# Personal instructions") assert.Contains(t, string(guidance), "# More personal instructions") - _, err = os.Stat(skillPath) - assert.True(t, os.IsNotExist(err)) - assert.Contains(t, output.String(), "Removed managed GHA guidance and skill for Codex") + skill, err := os.ReadFile(skillPath) + require.NoError(t, err) + assert.Equal(t, "installed skill", string(skill)) + assert.Contains(t, output.String(), "preserved the unowned gha skill") } func TestAgentUninstallPreservesOtherFilesInTheSkillDirectory(t *testing.T) { @@ -406,8 +596,9 @@ func TestAgentUninstallPreservesOtherFilesInTheSkillDirectory(t *testing.T) { root.SetArgs([]string{"agent", "uninstall", "--agent", "codex"}) require.NoError(t, root.Execute()) - _, err := os.Stat(skillPath) - assert.True(t, os.IsNotExist(err)) + skill, err := os.ReadFile(skillPath) + require.NoError(t, err) + assert.Equal(t, "installed skill", string(skill)) note, err := os.ReadFile(additionalPath) require.NoError(t, err) assert.Equal(t, "personal note", string(note)) diff --git a/internal/commands/capabilities.go b/internal/commands/capabilities.go index f36e5e9..73116f0 100644 --- a/internal/commands/capabilities.go +++ b/internal/commands/capabilities.go @@ -36,8 +36,8 @@ func ghaCapabilities() *model.Capabilities { return &model.Capabilities{ SchemaVersion: model.CapabilitiesSchemaVersion, Commands: []model.Capability{ - {Command: "update", Status: "available", ReadOnly: false, Formats: []string{"text", "json", "yaml"}, SchemaVersion: model.GuidanceUpdateSchemaVersion, Notes: "Discovers the latest published GHA release (including prereleases) and refreshes skill and managed guidance for recorded agent installations; writes by default, --dry-run previews, and unavailable sources are reported; does not update the executable."}, - {Command: "agent list", Status: "available", ReadOnly: true, Formats: []string{"text", "json", "yaml"}, SchemaVersion: model.AgentInstallationListSchemaVersion, Notes: "Lists harnesses recorded as configured by GHA, managed destinations, and whether guidance and skill files are present; does not detect unconfigured harnesses."}, + {Command: "update", Status: "available", ReadOnly: false, Formats: []string{"text", "json", "yaml"}, SchemaVersion: model.GuidanceUpdateSchemaVersion, Notes: "Discovers the latest published GHA release (including prereleases) and refreshes guidance for recorded agent installations only when the skill's declared GHA capabilities are available in this executable; --dry-run previews, incompatible skills are preserved, and the executable is not updated."}, + {Command: "agent list", Status: "available", ReadOnly: true, Formats: []string{"text", "json", "yaml"}, SchemaVersion: model.AgentInstallationListSchemaVersion, Notes: "Lists harnesses recorded as configured by GHA, managed destinations, and whether guidance and skill files are present or modified; does not detect unconfigured harnesses."}, {Command: "agent install", Status: "available", ReadOnly: false, Notes: "Detects configured supported harnesses and installs bundled gha skills and guidance; --agent selects harnesses explicitly, --binary-only skips setup, and --dry-run previews without writing."}, {Command: "agent uninstall", Status: "available", ReadOnly: false, Notes: "Removes GHA-managed guidance and skills for selected configured agents while retaining destinations shared by other configured agents; accepts comma-separated agents; --dry-run previews without writing."}, {Command: "version", Status: "available", ReadOnly: true, Formats: []string{"text", "json", "yaml"}, SchemaVersion: model.VersionInfoSchemaVersion, Notes: "Identifies the installed build version, commit, and build time."}, diff --git a/internal/commands/capabilities_test.go b/internal/commands/capabilities_test.go index 4858230..57dff37 100644 --- a/internal/commands/capabilities_test.go +++ b/internal/commands/capabilities_test.go @@ -22,9 +22,9 @@ func TestCapabilitiesCommandProvidesCompleteVersionedInventory(t *testing.T) { require.NoError(t, json.Unmarshal(output.Bytes(), &capabilities)) assert.Equal(t, model.CapabilitiesSchemaVersion, capabilities.SchemaVersion) require.Len(t, capabilities.Commands, 23) - assert.Contains(t, capabilities.Commands, model.Capability{Command: "update", Status: "available", ReadOnly: false, Formats: []string{"text", "json", "yaml"}, SchemaVersion: model.GuidanceUpdateSchemaVersion, Notes: "Discovers the latest published GHA release (including prereleases) and refreshes skill and managed guidance for recorded agent installations; writes by default, --dry-run previews, and unavailable sources are reported; does not update the executable."}) + assert.Contains(t, capabilities.Commands, model.Capability{Command: "update", Status: "available", ReadOnly: false, Formats: []string{"text", "json", "yaml"}, SchemaVersion: model.GuidanceUpdateSchemaVersion, Notes: "Discovers the latest published GHA release (including prereleases) and refreshes guidance for recorded agent installations only when the skill's declared GHA capabilities are available in this executable; --dry-run previews, incompatible skills are preserved, and the executable is not updated."}) assert.Contains(t, capabilities.Commands, model.Capability{Command: "agent install", Status: "available", ReadOnly: false, Notes: "Detects configured supported harnesses and installs bundled gha skills and guidance; --agent selects harnesses explicitly, --binary-only skips setup, and --dry-run previews without writing."}) - assert.Contains(t, capabilities.Commands, model.Capability{Command: "agent list", Status: "available", ReadOnly: true, Formats: []string{"text", "json", "yaml"}, SchemaVersion: model.AgentInstallationListSchemaVersion, Notes: "Lists harnesses recorded as configured by GHA, managed destinations, and whether guidance and skill files are present; does not detect unconfigured harnesses."}) + assert.Contains(t, capabilities.Commands, model.Capability{Command: "agent list", Status: "available", ReadOnly: true, Formats: []string{"text", "json", "yaml"}, SchemaVersion: model.AgentInstallationListSchemaVersion, Notes: "Lists harnesses recorded as configured by GHA, managed destinations, and whether guidance and skill files are present or modified; does not detect unconfigured harnesses."}) assert.Contains(t, capabilities.Commands, model.Capability{Command: "agent uninstall", Status: "available", ReadOnly: false, Notes: "Removes GHA-managed guidance and skills for selected configured agents while retaining destinations shared by other configured agents; accepts comma-separated agents; --dry-run previews without writing."}) assert.Contains(t, capabilities.Commands, model.Capability{Command: "releases", Status: "available", ReadOnly: true, Formats: []string{"text", "json", "yaml"}, SchemaVersion: model.ReleaseListSchemaVersion, Notes: "Bounded listing of published releases; drafts are excluded."}) assert.Contains(t, capabilities.Commands, model.Capability{Command: "release create-notes --since ", Status: "available", ReadOnly: true, Formats: []string{"text", "json", "yaml"}, SchemaVersion: model.ReleaseNotesSchemaVersion, Notes: "Generates bounded local release notes; does not publish a GitHub release."}) diff --git a/internal/commands/coverage_test.go b/internal/commands/coverage_test.go index 2c110f9..b38e412 100644 --- a/internal/commands/coverage_test.go +++ b/internal/commands/coverage_test.go @@ -408,6 +408,6 @@ func TestAgentInstallationFailurePathsPreserveUnrelatedFiles(t *testing.T) { skillDirectory := filepath.Join(root, "skills", "gha", "broken") require.NoError(t, os.MkdirAll(skillDirectory, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(skillDirectory, "keep"), []byte("content"), 0o644)) - err = uninstallAgentTarget(io.Discard, agentInstallation{Name: "Codex", SkillPath: skillDirectory}, false, agentOwnership{}) + err = uninstallAgentTarget(io.Discard, agentInstallation{Name: "Codex", SkillPath: skillDirectory, skillOwned: true}, false, agentOwnership{}) assert.ErrorContains(t, err, "remove skill for Codex") } diff --git a/internal/commands/skill_compatibility.go b/internal/commands/skill_compatibility.go new file mode 100644 index 0000000..72f6a33 --- /dev/null +++ b/internal/commands/skill_compatibility.go @@ -0,0 +1,70 @@ +package commands + +import ( + "fmt" + "sort" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/raithlin/gha/pkg/model" +) + +const requiredCapabilitiesMetadata = "gha-required-capabilities" + +func validateSkillCapabilities(skill []byte, capabilities *model.Capabilities) ([]string, error) { + metadata, err := skillFrontmatterMetadata(skill) + if err != nil { + return nil, err + } + requirements := strings.Split(metadata[requiredCapabilitiesMetadata], ",") + if len(requirements) == 0 || strings.TrimSpace(metadata[requiredCapabilitiesMetadata]) == "" { + return nil, fmt.Errorf("skill does not declare %s", requiredCapabilitiesMetadata) + } + available := map[string]bool{} + if capabilities != nil { + for _, capability := range capabilities.Commands { + if capability.Status == "available" { + available[capability.Command] = true + } + } + } + missingSet := map[string]bool{} + for _, requirement := range requirements { + command := strings.TrimSpace(requirement) + if command == "" { + return nil, fmt.Errorf("skill declares an empty required capability") + } + if !available[command] { + missingSet[command] = true + } + } + missing := make([]string, 0, len(missingSet)) + for command := range missingSet { + missing = append(missing, command) + } + sort.Strings(missing) + return missing, nil +} + +func skillFrontmatterMetadata(skill []byte) (map[string]string, error) { + content := strings.TrimPrefix(string(skill), "\ufeff") + if !strings.HasPrefix(content, "---\n") { + return nil, fmt.Errorf("skill has no YAML frontmatter") + } + content = strings.TrimPrefix(content, "---\n") + end := strings.Index(content, "\n---") + if end < 0 { + return nil, fmt.Errorf("skill YAML frontmatter is not closed") + } + var frontmatter struct { + Metadata map[string]string `yaml:"metadata"` + } + if err := yaml.Unmarshal([]byte(content[:end]), &frontmatter); err != nil { + return nil, fmt.Errorf("parse skill YAML frontmatter: %w", err) + } + if frontmatter.Metadata == nil { + return nil, fmt.Errorf("skill does not declare %s", requiredCapabilitiesMetadata) + } + return frontmatter.Metadata, nil +} diff --git a/internal/commands/skill_compatibility_test.go b/internal/commands/skill_compatibility_test.go new file mode 100644 index 0000000..9d30307 --- /dev/null +++ b/internal/commands/skill_compatibility_test.go @@ -0,0 +1,50 @@ +package commands + +import ( + "testing" + + ghaskill "github.com/raithlin/gha/skills/gha" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEmbeddedSkillRequiresCapabilitiesAvailableInThisBuild(t *testing.T) { + missing, err := validateSkillCapabilities(ghaskill.Skill, ghaCapabilities()) + require.NoError(t, err) + assert.Empty(t, missing) +} + +func TestValidateSkillCapabilitiesReportsUnsupportedCommands(t *testing.T) { + skill := skillWithRequirements("capabilities,dashboard,worktree create") + missing, err := validateSkillCapabilities(skill, ghaCapabilities()) + require.NoError(t, err) + assert.Equal(t, []string{"dashboard", "worktree create"}, missing) +} + +func TestValidateSkillCapabilitiesRequiresAManifest(t *testing.T) { + _, err := validateSkillCapabilities([]byte("---\nname: gha\n---\nInstructions"), ghaCapabilities()) + require.ErrorContains(t, err, "does not declare gha-required-capabilities") +} + +func TestValidateSkillCapabilitiesRejectsMalformedMetadata(t *testing.T) { + for _, skill := range [][]byte{ + []byte("not frontmatter"), + []byte("---\nname: gha\nmetadata:\n gha-required-capabilities: capabilities\n"), + []byte("---\nname: gha\nmetadata: [\n---\nInstructions"), + skillWithRequirements(""), + skillWithRequirements("capabilities,,update"), + } { + _, err := validateSkillCapabilities(skill, ghaCapabilities()) + require.Error(t, err) + } +} + +func TestValidateSkillCapabilitiesTreatsUnknownInventoryAsUnavailable(t *testing.T) { + missing, err := validateSkillCapabilities(skillWithRequirements("capabilities"), nil) + require.NoError(t, err) + assert.Equal(t, []string{"capabilities"}, missing) +} + +func skillWithRequirements(requirements string) []byte { + return []byte("---\nname: gha\nmetadata:\n gha-required-capabilities: \"" + requirements + "\"\n---\nInstructions\n") +} diff --git a/internal/commands/update.go b/internal/commands/update.go index d5aeaee..d21e3d7 100644 --- a/internal/commands/update.go +++ b/internal/commands/update.go @@ -17,6 +17,7 @@ import ( "github.com/raithlin/gha/internal/buildinfo" "github.com/raithlin/gha/internal/output" "github.com/raithlin/gha/pkg/model" + ghaskill "github.com/raithlin/gha/skills/gha" ) const updateRepository = "Raithlin/gha" @@ -26,9 +27,10 @@ func newUpdateCmd() *cobra.Command { var format string command := &cobra.Command{ Use: "update", - Short: "Refresh GHA guidance for configured coding agents", + Short: "Refresh compatible GHA guidance for configured coding agents", Long: `Find the latest published GHA release and refresh guidance and skills only -for harnesses recorded by ` + "`gha agent install`" + `. This command does not update the GHA executable. +for harnesses recorded by ` + "`gha agent install`" + `. The skill declares the GHA commands it requires; +updates are rejected if this executable does not provide every required capability. This command does not update the GHA executable. Use --dry-run to inspect configured destinations without writing files.`, Args: cobra.NoArgs, @@ -52,7 +54,7 @@ func runGuidanceUpdate(ctx context.Context, writer io.Writer, client *http.Clien if err != nil { return err } - result := &model.GuidanceUpdate{SchemaVersion: model.GuidanceUpdateSchemaVersion, BinaryVersion: buildinfo.Version, BinaryUpdated: false, DryRun: dryRun, SourceState: "not_checked", Targets: []model.GuidanceUpdateTarget{}} + result := &model.GuidanceUpdate{SchemaVersion: model.GuidanceUpdateSchemaVersion, BinaryVersion: buildinfo.Version, BinaryUpdated: false, DryRun: dryRun, SourceState: "not_checked", CompatibilityState: "not_checked", Targets: []model.GuidanceUpdateTarget{}} if len(ownership.Agents) == 0 { return output.GuidanceUpdate(writer, format, result) } @@ -69,31 +71,114 @@ func runGuidanceUpdate(ctx context.Context, writer io.Writer, client *http.Clien } result.LatestVersion = release result.SourceState = "available" + missing, compatibilityErr := validateSkillCapabilities(skill, ghaCapabilities()) + if compatibilityErr != nil || len(missing) > 0 { + result.CompatibilityState = "incompatible" + result.MissingCapabilities = missing + var message string + if compatibilityErr != nil { + message = compatibilityErr.Error() + } else { + message = "skill requires GHA capabilities not available in this binary: " + strings.Join(missing, ", ") + } + result.CompatibilityMessage = message + for _, target := range ownership.Agents { + result.Targets = append(result.Targets, model.GuidanceUpdateTarget{AgentID: target.ID, AgentName: target.Name, SkillPath: target.SkillPath, InstructionsPath: target.InstructionsPath, State: "incompatible"}) + } + if err := output.GuidanceUpdate(writer, format, result); err != nil { + return err + } + return fmt.Errorf("refuse GHA skill update: %s", message) + } + result.CompatibilityState = "compatible" + targets, err := refreshGuidanceTargets(ownership, skill, guidance, dryRun) + if err != nil { + return err + } + result.Targets = targets + return output.GuidanceUpdate(writer, format, result) +} + +func refreshGuidanceTargets(ownership agentOwnership, skill, guidance []byte, dryRun bool) ([]model.GuidanceUpdateTarget, error) { + for _, target := range ownership.Agents { + if err := validateOwnedSkillForUpdate(target, ownership); err != nil { + return nil, fmt.Errorf("update skill for %s: %w", target.Name, err) + } + } + updatedSkills := map[string]bool{} + var result []model.GuidanceUpdateTarget for _, target := range ownership.Agents { state := "updated" if dryRun { state = "planned" } else { - if err := writeFileAtomically(target.SkillPath, skill); err != nil { - return fmt.Errorf("update skill for %s: %w", target.Name, err) + if err := updateAgentTargetFiles(target, skill, guidance, updatedSkills); err != nil { + return nil, err } - if target.InstructionsPath != "" { - existing, err := os.ReadFile(target.InstructionsPath) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("read %s guidance: %w", target.Name, err) - } - updated, err := withManagedGuidanceContent(existing, guidance) - if err != nil { - return fmt.Errorf("update %s guidance: %w", target.Name, err) - } - if err := writeFileAtomically(target.InstructionsPath, updated); err != nil { - return fmt.Errorf("write %s guidance: %w", target.Name, err) + } + if !dryRun { + for i := range ownership.Agents { + if ownership.Agents[i].SkillPath == target.SkillPath { + ownership.Agents[i].SkillDigest = skillDigest(skill) } } } - result.Targets = append(result.Targets, model.GuidanceUpdateTarget{AgentID: target.ID, AgentName: target.Name, SkillPath: target.SkillPath, InstructionsPath: target.InstructionsPath, State: state}) + result = append(result, model.GuidanceUpdateTarget{AgentID: target.ID, AgentName: target.Name, SkillPath: target.SkillPath, InstructionsPath: target.InstructionsPath, State: state}) } - return output.GuidanceUpdate(writer, format, result) + if !dryRun { + if err := writeAgentOwnership(ownership); err != nil { + return nil, fmt.Errorf("record updated skill ownership: %w", err) + } + } + return result, nil +} + +func updateAgentTargetFiles(target agentInstallation, skill, guidance []byte, updatedSkills map[string]bool) error { + if !updatedSkills[target.SkillPath] { + if err := writeFileAtomically(target.SkillPath, skill); err != nil { + return fmt.Errorf("update skill for %s: %w", target.Name, err) + } + updatedSkills[target.SkillPath] = true + } + if target.InstructionsPath == "" { + return nil + } + existing, err := os.ReadFile(target.InstructionsPath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("read %s guidance: %w", target.Name, err) + } + updated, err := withManagedGuidanceContent(existing, guidance) + if err != nil { + return fmt.Errorf("update %s guidance: %w", target.Name, err) + } + if err := writeFileAtomically(target.InstructionsPath, updated); err != nil { + return fmt.Errorf("write %s guidance: %w", target.Name, err) + } + return nil +} + +func validateOwnedSkillForUpdate(target agentInstallation, ownership agentOwnership) error { + content, err := os.ReadFile(target.SkillPath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + expected, owned := ownership.skillDigest(target.SkillPath) + if !owned { + return fmt.Errorf("skill at %s is not recorded as GHA-managed", target.SkillPath) + } + if expected == "" { + if !bytes.Equal(content, ghaskill.Skill) { + return fmt.Errorf("skill at %s has changed and has no ownership digest; preserve it or restore the GHA version", target.SkillPath) + } + return nil + } + if skillDigest(content) != expected { + return fmt.Errorf("skill at %s has changed; preserve it or restore the GHA version", target.SkillPath) + } + return nil } func reportUnavailableGuidanceSource(writer io.Writer, format output.Format, result *model.GuidanceUpdate, ownership agentOwnership, sourceErr error) error { diff --git a/internal/commands/update_test.go b/internal/commands/update_test.go index f708ccc..8a55546 100644 --- a/internal/commands/update_test.go +++ b/internal/commands/update_test.go @@ -13,6 +13,7 @@ import ( outputfmt "github.com/raithlin/gha/internal/output" "github.com/raithlin/gha/pkg/model" + ghaskill "github.com/raithlin/gha/skills/gha" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -45,7 +46,7 @@ func TestUpdateRefreshesRecordedFilesAndPreservesPersonalInstructions(t *testing body := `[{"tag_name":"v9.0.0","published_at":"2026-09-26T00:00:00Z"}]` if strings.Contains(request.URL.Host, "raw.githubusercontent.com") { if strings.HasSuffix(request.URL.Path, "SKILL.md") { - body = "name: gha\nversion: new\n" + body = testUpdateSkill("version: new\n") } else { body = "\nnew guidance\n\n" } @@ -56,7 +57,7 @@ func TestUpdateRefreshesRecordedFilesAndPreservesPersonalInstructions(t *testing require.NoError(t, runGuidanceUpdate(context.Background(), &output, client, false, outputfmt.Text)) updatedSkill, err := os.ReadFile(skill) require.NoError(t, err) - assert.Equal(t, "name: gha\nversion: new\n", string(updatedSkill)) + assert.Equal(t, testUpdateSkill("version: new\n"), string(updatedSkill)) updatedInstructions, err := os.ReadFile(instructions) require.NoError(t, err) assert.Contains(t, string(updatedInstructions), "# Personal") @@ -73,7 +74,7 @@ func TestUpdateCreatesMissingManagedInstructions(t *testing.T) { client := &http.Client{Transport: updateRoundTripper(func(request *http.Request) (*http.Response, error) { body := `[{"tag_name":"v1","published_at":"2026-09-26T00:00:00Z"}]` if strings.Contains(request.URL.Host, "raw.githubusercontent.com") { - body = "name: gha\n" + body = testUpdateSkill("") if strings.HasSuffix(request.URL.Path, "AGENT-GUIDANCE.md") { body = "\nmanaged guidance\n" } @@ -205,7 +206,7 @@ func TestFetchReleaseGuidanceRejectsInvalidPayload(t *testing.T) { func TestFetchReleaseGuidanceReportsSecondFileFailure(t *testing.T) { client := &http.Client{Transport: updateRoundTripper(func(request *http.Request) (*http.Response, error) { if strings.HasSuffix(request.URL.Path, "SKILL.md") { - return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("name: gha\n"))}, nil + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(testUpdateSkill("")))}, nil } return &http.Response{StatusCode: 502, Status: "502 unavailable", Body: io.NopCloser(strings.NewReader(""))}, nil })} @@ -250,7 +251,7 @@ func TestFetchGuidanceFileReportsHTTPAndTransportErrors(t *testing.T) { _, err = fetchGuidanceFile(context.Background(), readFailureClient, "https://example.test/SKILL.md") require.ErrorContains(t, err, "read failed") closeFailureClient := &http.Client{Transport: updateRoundTripper(func(*http.Request) (*http.Response, error) { - return &http.Response{StatusCode: 200, Body: failingCloseBody{strings.NewReader("name: gha\n")}}, nil + return &http.Response{StatusCode: 200, Body: failingCloseBody{strings.NewReader(testUpdateSkill(""))}}, nil })} _, err = fetchGuidanceFile(context.Background(), closeFailureClient, "https://example.test/SKILL.md") require.ErrorContains(t, err, "close failed") @@ -264,7 +265,7 @@ func TestUpdateDryRunDoesNotWriteRecordedFiles(t *testing.T) { client := &http.Client{Transport: updateRoundTripper(func(request *http.Request) (*http.Response, error) { body := `[{"tag_name":"v1","published_at":"2026-09-26T00:00:00Z"}]` if strings.Contains(request.URL.Host, "raw.githubusercontent.com") { - body = "name: gha\n" + body = testUpdateSkill("") if strings.HasSuffix(request.URL.Path, "AGENT-GUIDANCE.md") { body = "\ncontent\n" } @@ -278,6 +279,55 @@ func TestUpdateDryRunDoesNotWriteRecordedFiles(t *testing.T) { assert.Contains(t, output.String(), "Copilot: planned skill") } +func TestUpdateRejectsSkillRequiringUnavailableCapabilities(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + root := t.TempDir() + skillPath := filepath.Join(root, "skill", "SKILL.md") + instructionsPath := filepath.Join(root, "AGENTS.md") + require.NoError(t, os.MkdirAll(filepath.Dir(skillPath), 0o755)) + require.NoError(t, os.WriteFile(skillPath, []byte("existing compatible skill"), 0o644)) + require.NoError(t, os.WriteFile(instructionsPath, []byte("personal\n\nold guidance\n\n"), 0o644)) + require.NoError(t, recordAgentInstallation(agentInstallation{ID: "codex", Name: "Codex", SkillPath: skillPath, InstructionsPath: instructionsPath})) + skillPayload := string(skillWithRequirements("capabilities,worktree create")) + client := &http.Client{Transport: updateRoundTripper(func(request *http.Request) (*http.Response, error) { + body := `[{"tag_name":"v9.0.0","published_at":"2026-09-26T00:00:00Z"}]` + if strings.Contains(request.URL.Host, "raw.githubusercontent.com") { + if strings.HasSuffix(request.URL.Path, "SKILL.md") { + body = skillPayload + } else { + body = "\nnew guidance\n\n" + } + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + })} + var rendered strings.Builder + err := runGuidanceUpdate(context.Background(), &rendered, client, false, outputfmt.JSON) + require.ErrorContains(t, err, "worktree create") + var result model.GuidanceUpdate + require.NoError(t, json.Unmarshal([]byte(rendered.String()), &result)) + assert.Equal(t, "incompatible", result.CompatibilityState) + assert.Contains(t, result.CompatibilityMessage, "worktree create") + assert.Equal(t, []string{"worktree create"}, result.MissingCapabilities) + require.Len(t, result.Targets, 1) + assert.Equal(t, "incompatible", result.Targets[0].State) + unchangedSkill, err := os.ReadFile(skillPath) + require.NoError(t, err) + assert.Equal(t, "existing compatible skill", string(unchangedSkill)) + unchangedInstructions, err := os.ReadFile(instructionsPath) + require.NoError(t, err) + assert.Contains(t, string(unchangedInstructions), "old guidance") + assert.NotContains(t, string(unchangedInstructions), "new guidance") + + skillPayload = "name: gha\n" + rendered.Reset() + err = runGuidanceUpdate(context.Background(), &rendered, client, false, outputfmt.JSON) + require.ErrorContains(t, err, "no YAML frontmatter") + assert.Contains(t, rendered.String(), `"compatibility_state": "incompatible"`) + unchangedSkill, err = os.ReadFile(skillPath) + require.NoError(t, err) + assert.Equal(t, "existing compatible skill", string(unchangedSkill)) +} + func TestUpdateReportsReleaseAndDestinationFailures(t *testing.T) { t.Run("release lookup", func(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) @@ -303,7 +353,7 @@ func TestUpdateReportsReleaseAndDestinationFailures(t *testing.T) { client := &http.Client{Transport: updateRoundTripper(func(request *http.Request) (*http.Response, error) { body := `[{"tag_name":"v1","published_at":"2026-09-26T00:00:00Z"}]` if strings.Contains(request.URL.Host, "raw.githubusercontent.com") { - body = "name: gha\n" + body = testUpdateSkill("") if strings.HasSuffix(request.URL.Path, "AGENT-GUIDANCE.md") { body = "\nnew\n" } @@ -322,7 +372,7 @@ func TestUpdateReportsReleaseAndDestinationFailures(t *testing.T) { client := &http.Client{Transport: updateRoundTripper(func(request *http.Request) (*http.Response, error) { body := `[{"tag_name":"v1","published_at":"2026-09-26T00:00:00Z"}]` if strings.Contains(request.URL.Host, "raw.githubusercontent.com") { - body = "name: gha\n" + body = testUpdateSkill("") if strings.HasSuffix(request.URL.Path, "AGENT-GUIDANCE.md") { body = "\nnew\n" } @@ -344,7 +394,7 @@ func TestUpdatePropagatesOutputFailure(t *testing.T) { client := &http.Client{Transport: updateRoundTripper(func(request *http.Request) (*http.Response, error) { body := `[{"tag_name":"v1","published_at":"2026-09-26T00:00:00Z"}]` if strings.Contains(request.URL.Host, "raw.githubusercontent.com") { - body = "name: gha\n" + body = testUpdateSkill("") if strings.HasSuffix(request.URL.Path, "AGENT-GUIDANCE.md") { body = "\nnew\n" } @@ -389,11 +439,11 @@ func TestUpdateReportsSkillWriteFailure(t *testing.T) { root := t.TempDir() blockedParent := filepath.Join(root, "not-a-directory") require.NoError(t, os.WriteFile(blockedParent, []byte("file"), 0o644)) - require.NoError(t, recordAgentInstallation(agentInstallation{ID: "copilot", Name: "Copilot", SkillPath: filepath.Join(blockedParent, "SKILL.md")})) + require.NoError(t, writeAgentOwnership(agentOwnership{Version: 1, Agents: []agentInstallation{{ID: "copilot", Name: "Copilot", SkillPath: filepath.Join(blockedParent, "SKILL.md")}}})) client := &http.Client{Transport: updateRoundTripper(func(request *http.Request) (*http.Response, error) { body := `[{"tag_name":"v1","published_at":"2026-09-26T00:00:00Z"}]` if strings.Contains(request.URL.Host, "raw.githubusercontent.com") { - body = "name: gha\n" + body = testUpdateSkill("") if strings.HasSuffix(request.URL.Path, "AGENT-GUIDANCE.md") { body = "\nnew\n" } @@ -403,3 +453,63 @@ func TestUpdateReportsSkillWriteFailure(t *testing.T) { err := runGuidanceUpdate(context.Background(), io.Discard, client, false, outputfmt.Text) require.ErrorContains(t, err, "update skill for Copilot") } + +func testUpdateSkill(body string) string { + return "---\nname: gha\nmetadata:\n gha-required-capabilities: \"capabilities,update\"\n---\n" + body +} + +func TestUpdatePreservesUserModifiedSkill(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + path := filepath.Join(t.TempDir(), "skills", "gha", "SKILL.md") + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte("GHA skill v1"), 0o644)) + require.NoError(t, recordAgentInstallation(agentInstallation{ID: "cursor", Name: "Cursor", SkillPath: path})) + require.NoError(t, os.WriteFile(path, []byte("user customization"), 0o644)) + client := &http.Client{Transport: updateRoundTripper(func(request *http.Request) (*http.Response, error) { + body := `[ {"tag_name":"v2","published_at":"2026-09-26T00:00:00Z"} ]` + if strings.Contains(request.URL.Host, "raw.githubusercontent.com") { + body = string(ghaskill.Skill) + if strings.HasSuffix(request.URL.Path, "AGENT-GUIDANCE.md") { + body = "\nnew\n" + } + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil + })} + err := runGuidanceUpdate(context.Background(), io.Discard, client, false, outputfmt.JSON) + require.ErrorContains(t, err, "skill at "+path+" has changed") + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "user customization", string(content)) +} + +func TestValidateOwnedSkillForUpdateHandlesLegacyAndMissingOwnership(t *testing.T) { + path := filepath.Join(t.TempDir(), "SKILL.md") + target := agentInstallation{Name: "OpenClaw", SkillPath: path} + assert.NoError(t, validateOwnedSkillForUpdate(target, agentOwnership{})) + require.NoError(t, os.WriteFile(path, ghaskill.Skill, 0o644)) + assert.NoError(t, validateOwnedSkillForUpdate(target, agentOwnership{Agents: []agentInstallation{{SkillPath: path}}})) + require.NoError(t, os.WriteFile(path, []byte("legacy custom"), 0o644)) + assert.ErrorContains(t, validateOwnedSkillForUpdate(target, agentOwnership{Agents: []agentInstallation{{SkillPath: path}}}), "has changed") + assert.ErrorContains(t, validateOwnedSkillForUpdate(target, agentOwnership{}), "not recorded as GHA-managed") + target.SkillPath = t.TempDir() + assert.Error(t, validateOwnedSkillForUpdate(target, agentOwnership{})) +} + +func TestUpdateAgentTargetFilesHandlesSkillsAndManagedGuidance(t *testing.T) { + root := t.TempDir() + skill := filepath.Join(root, "skills", "gha", "SKILL.md") + updatedSkill := []byte("skill") + updatedGuidance := []byte("\nnew\n") + updated := map[string]bool{} + target := agentInstallation{Name: "Hermes", SkillPath: skill} + require.NoError(t, updateAgentTargetFiles(target, updatedSkill, updatedGuidance, updated)) + content, err := os.ReadFile(skill) + require.NoError(t, err) + assert.Equal(t, updatedSkill, content) + + brokenGuidance := filepath.Join(root, "AGENTS.md") + require.NoError(t, os.WriteFile(brokenGuidance, []byte(""), 0o644)) + target.InstructionsPath = brokenGuidance + err = updateAgentTargetFiles(target, updatedSkill, updatedGuidance, updated) + require.ErrorContains(t, err, "update Hermes guidance") +} diff --git a/internal/output/agent.go b/internal/output/agent.go index c4de4ae..c59bf11 100644 --- a/internal/output/agent.go +++ b/internal/output/agent.go @@ -40,6 +40,9 @@ func GuidanceUpdate(writer io.Writer, format Format, update *model.GuidanceUpdat return err } } + if err := guidanceUpdateCompatibility(writer, update); err != nil { + return err + } for _, target := range update.Targets { if _, err := fmt.Fprintf(writer, "%s: %s skill %s", sanitizeTerminal(target.AgentName), sanitizeTerminal(target.State), sanitizeTerminal(target.SkillPath)); err != nil { return err @@ -61,3 +64,16 @@ func GuidanceUpdate(writer io.Writer, format Format, update *model.GuidanceUpdat _, err := fmt.Fprintf(writer, "GHA executable remains at version %s.\n", sanitizeTerminal(update.BinaryVersion)) return err } + +func guidanceUpdateCompatibility(writer io.Writer, update *model.GuidanceUpdate) error { + switch update.CompatibilityState { + case "incompatible": + _, err := fmt.Fprintf(writer, "Skill incompatible with this binary: %s\n", sanitizeTerminal(update.CompatibilityMessage)) + return err + case "compatible": + _, err := fmt.Fprintln(writer, "Skill compatibility: compatible with this binary.") + return err + default: + return nil + } +} diff --git a/internal/output/agent_test.go b/internal/output/agent_test.go index f3beb38..0844199 100644 --- a/internal/output/agent_test.go +++ b/internal/output/agent_test.go @@ -54,16 +54,18 @@ func TestAgentInstallationsReturnsWriterErrors(t *testing.T) { func TestGuidanceUpdateRendersTextAndStructuredForms(t *testing.T) { update := &model.GuidanceUpdate{ - SchemaVersion: model.GuidanceUpdateSchemaVersion, - LatestVersion: "v1.2.3", - BinaryVersion: "v1.0.0", - DryRun: true, - Targets: []model.GuidanceUpdateTarget{{AgentID: "codex", AgentName: "Codex", SkillPath: "/home/test/skills/gha/SKILL.md", InstructionsPath: "/home/test/AGENTS.md", State: "planned"}}, + SchemaVersion: model.GuidanceUpdateSchemaVersion, + LatestVersion: "v1.2.3", + BinaryVersion: "v1.0.0", + DryRun: true, + CompatibilityState: "compatible", + Targets: []model.GuidanceUpdateTarget{{AgentID: "codex", AgentName: "Codex", SkillPath: "/home/test/skills/gha/SKILL.md", InstructionsPath: "/home/test/AGENTS.md", State: "planned"}}, } var rendered bytes.Buffer require.NoError(t, GuidanceUpdate(&rendered, Text, update)) assert.Contains(t, rendered.String(), "Codex: planned skill") assert.Contains(t, rendered.String(), "AGENTS.md") + assert.Contains(t, rendered.String(), "Skill compatibility: compatible with this binary.") assert.Contains(t, rendered.String(), "executable remains at version v1.0.0") rendered.Reset() require.NoError(t, GuidanceUpdate(&rendered, JSON, update)) @@ -71,6 +73,18 @@ func TestGuidanceUpdateRendersTextAndStructuredForms(t *testing.T) { rendered.Reset() require.NoError(t, GuidanceUpdate(&rendered, YAML, update)) assert.Contains(t, rendered.String(), "binary_updated: false") + assert.Contains(t, rendered.String(), "compatibility_state: compatible") +} + +func TestGuidanceUpdateRendersIncompatibility(t *testing.T) { + update := &model.GuidanceUpdate{CompatibilityState: "incompatible", CompatibilityMessage: "missing: worktree create", MissingCapabilities: []string{"worktree create"}, Targets: []model.GuidanceUpdateTarget{}} + var rendered bytes.Buffer + require.NoError(t, GuidanceUpdate(&rendered, Text, update)) + assert.Contains(t, rendered.String(), "Skill incompatible with this binary: missing: worktree create") + rendered.Reset() + require.NoError(t, GuidanceUpdate(&rendered, JSON, update)) + assert.Contains(t, rendered.String(), `"missing_capabilities"`) + assert.Contains(t, rendered.String(), `"worktree create"`) } func TestGuidanceUpdateRendersNoTargetsAndWriterErrors(t *testing.T) { diff --git a/pkg/model/model.go b/pkg/model/model.go index 950346d..4a1f98f 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -206,14 +206,17 @@ const GuidanceUpdateSchemaVersion = "v1" // GuidanceUpdate reports the published guidance refresh plan and outcome. type GuidanceUpdate struct { - SchemaVersion string `json:"schema_version" yaml:"schema_version"` - LatestVersion string `json:"latest_version" yaml:"latest_version"` - BinaryVersion string `json:"binary_version" yaml:"binary_version"` - BinaryUpdated bool `json:"binary_updated" yaml:"binary_updated"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - SourceState string `json:"source_state" yaml:"source_state"` - SourceMessage string `json:"source_message,omitempty" yaml:"source_message,omitempty"` - Targets []GuidanceUpdateTarget `json:"targets" yaml:"targets"` + SchemaVersion string `json:"schema_version" yaml:"schema_version"` + LatestVersion string `json:"latest_version" yaml:"latest_version"` + BinaryVersion string `json:"binary_version" yaml:"binary_version"` + BinaryUpdated bool `json:"binary_updated" yaml:"binary_updated"` + DryRun bool `json:"dry_run" yaml:"dry_run"` + SourceState string `json:"source_state" yaml:"source_state"` + SourceMessage string `json:"source_message,omitempty" yaml:"source_message,omitempty"` + CompatibilityState string `json:"compatibility_state" yaml:"compatibility_state"` + CompatibilityMessage string `json:"compatibility_message,omitempty" yaml:"compatibility_message,omitempty"` + MissingCapabilities []string `json:"missing_capabilities,omitempty" yaml:"missing_capabilities,omitempty"` + Targets []GuidanceUpdateTarget `json:"targets" yaml:"targets"` } // GuidanceUpdateTarget is one recorded agent installation selected for refresh. @@ -225,7 +228,7 @@ type GuidanceUpdateTarget struct { State string `json:"state" yaml:"state"` } -// AgentInstallation records the managed destinations and their current file presence. +// AgentInstallation records the managed destinations and their current file state. type AgentInstallation struct { ID string `json:"id" yaml:"id"` Name string `json:"name" yaml:"name"` diff --git a/skills/gha/SKILL.md b/skills/gha/SKILL.md index 7dc6bc7..2828283 100644 --- a/skills/gha/SKILL.md +++ b/skills/gha/SKILL.md @@ -1,6 +1,13 @@ --- name: gha description: Use GHA to inspect GitHub and local-repository workflows when its installed command can provide a safer, structured answer. +metadata: + gha-required-capabilities: >- + capabilities,version,analyze,branches,branches cleanup,branch show , + branch create ,branch publish ,branch rename , + branch delete ,tag publish ,prs,review ,releases, + release create-notes --since ,release publish ,update, + agent list,agent install,agent uninstall,pr prepare,pr create --- # GHA @@ -30,6 +37,9 @@ command or feature is available because it appears in a roadmap or another installation. Use JSON for agent decisions and assertions; use text output when checking the human terminal experience. +The skill frontmatter lists the GHA commands it requires. Keep that capability +manifest aligned with the commands used in these instructions. + `gha version --format json` identifies the installed build. `gha dashboard` is intentionally unavailable in current builds; do not attempt to launch it. `help` and `completion` are standard Cobra assistance rather than GHA workflow @@ -140,10 +150,13 @@ global instructions file in this workflow. These commands modify the selected coding-agent configuration unless `--dry-run` is specified; inspect their destinations with `--dry-run`. `gha update --dry-run` discovers the latest published release and previews changes for recorded installations; -`gha update` applies the bundled skill and managed guidance while preserving +`gha update` applies the published skill and managed guidance while preserving instructions outside the GHA-managed block; `--dry-run` previews without -writing. It does not update the GHA executable. Release discovery includes prereleases and reports unavailable -sources in its structured result. `pr prepare` is read-only and drafts a title and +writing. Before writing, it checks that this binary provides every GHA command +declared in the skill's compatibility metadata. An incompatible skill is +preserved; `gha update` does not update the executable. Release discovery +includes prereleases and reports unavailable sources in its structured result. +`pr prepare` is read-only and drafts a title and body from up to 50 local commit subjects; changed file paths remain in JSON as source context, not in the proposed body. JSON reports whether local refs were available. Explicit `--title` and