From 8bb08edc9c5f3d327a64c67893d86080c221e714 Mon Sep 17 00:00:00 2001 From: boshu <241868352+boshu2@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:13:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(cli):=20add=20ao=20skills=20unlink=20?= =?UTF-8?q?=E2=80=94=20rollback=20inverse=20of=20skills=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the uninstall/rollback twin for `ao skills link`: `ao skills unlink` removes exactly the live-tier symlinks link minted — those whose target resolves into this repo's skills/ tree — across every installed runtime (~/.claude, ~/.codex, ~/.gemini, ~/.cursor, ~/.pi). Idempotent and non-destructive: foreign symlinks pointing elsewhere and real directories (a foreign corpus such as jsm) are reported as foreign and never removed; stale owned links (skill since removed from the repo) are still cleaned up. Supports --dest, --dry-run (persistent), and --json, mirroring skills link. Document the uninstall path in docs/install-day2-ops.md: per-runtime plugin/ skill removal (Claude, Codex, AGY, OpenCode), `brew uninstall agentops`, `ao skills unlink` for clone-linked skills, and an explicit 'what is kept' note that .agents/ and quick-start artifacts (CLAUDE.md block, GOALS.md) are user-owned data the uninstall deliberately never touches. Regenerate the affected command-surface projections (COMMANDS.md, cli-surface .{json,md}, the eval surface matrix + smoke fixture). The matrix/smoke counts also absorb pre-existing origin/main drift (checked-in expected sub=120 vs actual tree 112); regen brings them to the truthful 113 (112 + unlink). Tests (L2 round-trip, t.TempDir): RemovesOnlyOwnLinks (foreign symlink + real dir survive), DryRunWritesNothing, Idempotent, MissingDestIsNoop, RemovesStaleOwnedLink, EmptySrcFailsClosed, ResilientAcrossDests. --- cli/cmd/ao/skills_unlink.go | 236 +++++++++++++++++ cli/cmd/ao/skills_unlink_test.go | 244 ++++++++++++++++++ cli/docs/COMMANDS.md | 16 ++ docs/cli-surface.json | 7 + docs/cli-surface.md | 1 + docs/install-day2-ops.md | 92 +++++++ .../cli-command-surface-matrix.json | 2 +- .../fixtures/cli-command-surface-smoke.sh | 4 +- 8 files changed, 599 insertions(+), 3 deletions(-) create mode 100644 cli/cmd/ao/skills_unlink.go create mode 100644 cli/cmd/ao/skills_unlink_test.go diff --git a/cli/cmd/ao/skills_unlink.go b/cli/cmd/ao/skills_unlink.go new file mode 100644 index 000000000..6bda63e70 --- /dev/null +++ b/cli/cmd/ao/skills_unlink.go @@ -0,0 +1,236 @@ +// practices: [design-by-contract, code-complete] +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +var ( + skillsUnlinkDest string + skillsUnlinkJSON bool +) + +// skillUnlinkResult summarizes a `skills unlink` sweep of one destination: which +// AgentOps-owned live-tier symlinks were removed, and which entries were left +// untouched because they are NOT ours — a foreign symlink pointing outside the +// repo, or a real directory/file (a foreign corpus such as jsm). It is the exact +// inverse of skillLinkResult. +type skillUnlinkResult struct { + Dest string `json:"dest"` + DryRun bool `json:"dry_run"` + Removed []string `json:"removed"` + Foreign []string `json:"foreign"` + // Err is this destination's error, if any. A per-dest error does NOT abort + // the fan-out — every other installed runtime is still swept and reported. + Err string `json:"error,omitempty"` +} + +// unlinkOwnedSkills removes EXACTLY the live-tier symlinks under destDir that +// `skills link` minted: symlinks whose target resolves INTO the absolute repo +// skills/ tree (srcDir). It is the exact inverse of linkMissingSkills — idempotent +// and non-destructive to everything else. A foreign symlink pointing outside the +// repo and a real directory/file (a foreign corpus such as jsm) are both reported +// as Foreign and never removed. A stale link to a skill since removed from the +// repo is still ours to clean up (the target need not exist). When dryRun is true +// nothing is removed but the would-be removals are still reported under Removed. +func unlinkOwnedSkills(srcDir, destDir string, dryRun bool) (skillUnlinkResult, error) { + res := skillUnlinkResult{Dest: destDir, DryRun: dryRun} + + // Fail-closed on an unresolved source, exactly as linkMissingSkills does: an + // empty srcDir would let filepath.Abs("") resolve to the CURRENT directory, + // and any symlink pointing there would be wrongly claimed as ours and + // removed. Refuse rather than guess (mirror of the age-u031 guard on link). + if strings.TrimSpace(srcDir) == "" { + return res, fmt.Errorf("skills source dir is empty — cannot resolve the repo skills/ tree to identify owned links (run from inside the agentops repo)") + } + absSrc, err := filepath.Abs(srcDir) + if err != nil { + return res, fmt.Errorf("resolve skills dir %s: %w", srcDir, err) + } + + entries, err := os.ReadDir(destDir) + if err != nil { + if os.IsNotExist(err) { + return res, nil // nothing installed here — a clean no-op, idempotent + } + return res, fmt.Errorf("read dest dir %s: %w", destDir, err) + } + + for _, e := range entries { + name := e.Name() + tgt := filepath.Join(destDir, name) + info, lerr := os.Lstat(tgt) + if lerr != nil { + return res, fmt.Errorf("lstat %s: %w", tgt, lerr) + } + if info.Mode()&os.ModeSymlink == 0 { + res.Foreign = append(res.Foreign, name) // real dir/file — foreign corpus + continue + } + if owned, _ := symlinkResolvesInto(tgt, destDir, absSrc); !owned { + res.Foreign = append(res.Foreign, name) // symlink pointing outside the repo + continue + } + res.Removed = append(res.Removed, name) + if !dryRun { + if rmErr := os.Remove(tgt); rmErr != nil { + return res, fmt.Errorf("remove link %s: %w", tgt, rmErr) + } + } + } + + sort.Strings(res.Removed) + sort.Strings(res.Foreign) + return res, nil +} + +// symlinkResolvesInto reports whether the symlink at linkPath points at a target +// inside absRoot (the repo skills/ tree). A relative link target is resolved +// against destDir first. The target need NOT exist — a stale link to a skill +// since removed from the repo is still ours. Returns the resolved absolute target +// for reporting. +func symlinkResolvesInto(linkPath, destDir, absRoot string) (bool, string) { + dst, err := os.Readlink(linkPath) + if err != nil { + return false, "" + } + if !filepath.IsAbs(dst) { + dst = filepath.Join(destDir, dst) + } + dst = filepath.Clean(dst) + rel, err := filepath.Rel(absRoot, dst) + if err != nil { + return false, dst + } + // Owned iff the target is absRoot itself or a descendant of it — i.e. the + // relative path does not escape upward (never "" ".." or "../…"). + inRoot := rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) + return inRoot, dst +} + +var skillsUnlinkCmd = &cobra.Command{ + Use: "unlink", + Short: "Remove the repo-skill symlinks that `skills link` minted (Claude, Codex, AGY, Cursor, Pi)", + Long: `The clean uninstall inverse of ` + "`ao skills link`" + `. Scan each runtime's +live tier and remove EXACTLY the symlinks that link minted — those whose target +resolves into THIS repo's skills/ tree. By DEFAULT it sweeps EVERY agent runtime +you have installed — ~/.claude/skills, ~/.codex/skills, ~/.gemini/skills +(AGY/Gemini), ~/.cursor/skills, and ~/.pi/skills — detected by the runtime's +config dir existing under $HOME; --dest overrides to a single dir. Idempotent and +non-destructive: a foreign symlink pointing outside the repo and a name owned by +a real directory (a foreign corpus such as jsm) are both reported as foreign and +never removed. A stale link to a skill since removed from the repo is still +cleaned up. + +This is the documented rollback for the clone-linked "track main" install path: +after you stop following a repo clone, this leaves your runtimes with only the +skills they had before. It removes only symlinks, never your own directories or +another corpus. + +Must be run from inside the agentops repo (guarded) — it needs the repo skills/ +path to know which links are its own. + + ao skills unlink # remove owned links from every installed runtime + ao skills unlink --dry-run # show what would be removed without removing + ao skills unlink --dest ~/.codex/skills # sweep ONE specific dir only`, + Args: cobra.NoArgs, + RunE: runSkillsUnlink, +} + +func init() { + skillsCmd.AddCommand(skillsUnlinkCmd) + skillsUnlinkCmd.Flags().StringVar(&skillsUnlinkDest, "dest", "", "Sweep this single dir instead of the auto-detected runtimes (default: every installed runtime — ~/.claude, ~/.codex, ~/.gemini, ~/.cursor, ~/.pi)") + skillsUnlinkCmd.Flags().BoolVar(&skillsUnlinkJSON, "json", false, "Emit machine-readable JSON") +} + +func runSkillsUnlink(cmd *cobra.Command, args []string) error { + skillsDir, err := resolveRepoSkillsDir() + if err != nil { + cmd.SilenceUsage = true + return err + } + + dests, err := resolveTargetDests(skillsUnlinkDest) + if err != nil { + cmd.SilenceUsage = true + return err + } + + results, anyErr := unlinkAllDests(skillsDir, dests, GetDryRun()) + + if skillsUnlinkJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + if eerr := enc.Encode(results); eerr != nil { + return eerr + } + } else { + out := cmd.OutOrStdout() + for _, res := range results { + renderUnlinkResult(out, res) + } + } + + // A per-dest failure is reported per-dest above but must still surface as a + // non-zero exit — after every runtime was attempted, never before. + if anyErr { + cmd.SilenceUsage = true + return fmt.Errorf("one or more runtime skill dirs could not be swept (see per-runtime errors)") + } + return nil +} + +// unlinkAllDests unlinks owned skills from every destination, RESILIENTLY: a +// per-dest error is captured on that dest's result and the sweep continues to +// the remaining runtimes rather than aborting (which would leave earlier dests +// mutated and later ones silently skipped). Returns the per-dest results and +// whether any dest errored. +func unlinkAllDests(srcDir string, dests []string, dryRun bool) ([]skillUnlinkResult, bool) { + results := make([]skillUnlinkResult, 0, len(dests)) + anyErr := false + for _, dest := range dests { + res, err := unlinkOwnedSkills(srcDir, dest, dryRun) + if err != nil { + res.Err = err.Error() + anyErr = true + } + results = append(results, res) + } + return results, anyErr +} + +// renderUnlinkResult prints one destination's unlink summary. +func renderUnlinkResult(out io.Writer, res skillUnlinkResult) { + fmt.Fprintf(out, "Skills unlink → %s\n", res.Dest) + if res.Err != "" { + fmt.Fprintf(out, " ERROR: %s (other runtimes still attempted)\n", res.Err) + return + } + if res.DryRun { + fmt.Fprintf(out, " would remove (dry-run): %d\n", len(res.Removed)) + } else { + fmt.Fprintf(out, " removed: %d\n", len(res.Removed)) + } + fmt.Fprintf(out, " foreign (kept): %d\n", len(res.Foreign)) + for _, n := range res.Removed { + mark := "-" + if res.DryRun { + mark = "?" + } + fmt.Fprintf(out, " %s %s\n", mark, n) + } + for _, n := range res.Foreign { + fmt.Fprintf(out, " . %s (not AgentOps-owned — kept)\n", n) + } + if len(res.Removed) == 0 { + fmt.Fprintln(out, " no AgentOps-owned links found (nothing to remove).") + } +} diff --git a/cli/cmd/ao/skills_unlink_test.go b/cli/cmd/ao/skills_unlink_test.go new file mode 100644 index 000000000..06a66fd9e --- /dev/null +++ b/cli/cmd/ao/skills_unlink_test.go @@ -0,0 +1,244 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The command's --help is the user-facing contract for the documented rollback +// path: it must name the inverse relationship, the multi-runtime coverage, and +// the dry-run rehearsal. Guard against silent removal. +func TestSkillsUnlinkHelp_DocumentsRollbackAndRuntimes(t *testing.T) { + long := skillsUnlinkCmd.Long + for _, want := range []string{"inverse", "track main", "~/.codex/skills", "~/.gemini/skills", "--dry-run"} { + if !strings.Contains(long, want) { + t.Errorf("`ao skills unlink --help` no longer documents %q", want) + } + } +} + +// TestSkillsUnlink_RemovesOnlyOwnLinks is the core acceptance test: after a +// round-trip (link → unlink) the runtime is restored to its pre-link state, +// while a foreign symlink pointing elsewhere and a real foreign-corpus directory +// both survive untouched. +func TestSkillsUnlink_RemovesOnlyOwnLinks(t *testing.T) { + src := t.TempDir() + dest := t.TempDir() + mkSkill(t, src, "goal-design") + mkSkill(t, src, "using-gc") + + // Mint our own live-tier links exactly as `ao skills link` would. + if _, err := linkMissingSkills(src, dest, false); err != nil { + t.Fatalf("link setup: %v", err) + } + + // A foreign symlink pointing OUTSIDE the repo (e.g. a hand-linked skill from + // another corpus) must be left alone. + otherSrc := t.TempDir() + mkSkill(t, otherSrc, "foreign-skill") + foreignLink := filepath.Join(dest, "foreign-skill") + if err := os.Symlink(filepath.Join(otherSrc, "foreign-skill"), foreignLink); err != nil { + t.Fatalf("make foreign symlink: %v", err) + } + + // A real directory (a foreign corpus such as jsm) must be left alone. + realDir := filepath.Join(dest, "jsm-corpus") + if err := os.MkdirAll(realDir, 0o755); err != nil { + t.Fatalf("mkdir foreign corpus: %v", err) + } + sentinel := filepath.Join(realDir, "sentinel.txt") + if err := os.WriteFile(sentinel, []byte("jsm"), 0o644); err != nil { + t.Fatalf("write sentinel: %v", err) + } + + res, err := unlinkOwnedSkills(src, dest, false) + if err != nil { + t.Fatalf("unlinkOwnedSkills: %v", err) + } + + // Exactly our two links were removed. + if len(res.Removed) != 2 || res.Removed[0] != "goal-design" || res.Removed[1] != "using-gc" { + t.Fatalf("Removed = %v, want [goal-design using-gc]", res.Removed) + } + // The foreign symlink and the real dir are both reported as foreign. + wantForeign := map[string]bool{"foreign-skill": true, "jsm-corpus": true} + if len(res.Foreign) != 2 { + t.Fatalf("Foreign = %v, want the 2 foreign entries", res.Foreign) + } + for _, f := range res.Foreign { + if !wantForeign[f] { + t.Fatalf("unexpected foreign entry %q (Foreign=%v)", f, res.Foreign) + } + } + + // Our links are gone from disk. + for _, name := range []string{"goal-design", "using-gc"} { + if _, err := os.Lstat(filepath.Join(dest, name)); !os.IsNotExist(err) { + t.Fatalf("owned link %q not removed; Lstat err = %v, want IsNotExist", name, err) + } + } + // The foreign symlink survives, still a symlink. + fi, err := os.Lstat(foreignLink) + if err != nil || fi.Mode()&os.ModeSymlink == 0 { + t.Fatalf("foreign symlink was removed or changed; Lstat=%v mode=%v", err, fi.Mode()) + } + // The real dir + its sentinel survive untouched. + di, err := os.Lstat(realDir) + if err != nil || di.Mode()&os.ModeSymlink != 0 || !di.IsDir() { + t.Fatalf("foreign dir was replaced; Lstat=%v mode=%v", err, di.Mode()) + } + if b, err := os.ReadFile(sentinel); err != nil || string(b) != "jsm" { + t.Fatalf("sentinel clobbered; b=%q err=%v", b, err) + } +} + +// TestSkillsUnlink_DryRunWritesNothing: dry-run reports the would-be removals but +// leaves every link on disk. +func TestSkillsUnlink_DryRunWritesNothing(t *testing.T) { + src := t.TempDir() + dest := t.TempDir() + mkSkill(t, src, "gc-membrane") + if _, err := linkMissingSkills(src, dest, false); err != nil { + t.Fatalf("link setup: %v", err) + } + + res, err := unlinkOwnedSkills(src, dest, true) + if err != nil { + t.Fatalf("dry run: %v", err) + } + if len(res.Removed) != 1 || res.Removed[0] != "gc-membrane" { + t.Fatalf("dry-run Removed = %v, want [gc-membrane]", res.Removed) + } + if !res.DryRun { + t.Fatalf("res.DryRun = false, want true") + } + // The link is still on disk. + if _, err := os.Lstat(filepath.Join(dest, "gc-membrane")); err != nil { + t.Fatalf("dry-run removed the link; Lstat err = %v, want nil", err) + } +} + +// TestSkillsUnlink_Idempotent: a second sweep removes nothing and does not error. +func TestSkillsUnlink_Idempotent(t *testing.T) { + src := t.TempDir() + dest := t.TempDir() + mkSkill(t, src, "using-gc") + if _, err := linkMissingSkills(src, dest, false); err != nil { + t.Fatalf("link setup: %v", err) + } + + if _, err := unlinkOwnedSkills(src, dest, false); err != nil { + t.Fatalf("first unlink: %v", err) + } + res, err := unlinkOwnedSkills(src, dest, false) + if err != nil { + t.Fatalf("second unlink: %v", err) + } + if len(res.Removed) != 0 { + t.Fatalf("second run Removed = %v, want [] (idempotent)", res.Removed) + } + if len(res.Foreign) != 0 { + t.Fatalf("second run Foreign = %v, want []", res.Foreign) + } +} + +// A missing destination dir (runtime never installed) is a clean no-op, not an +// error — unlink is safe to run against any subset of runtimes. +func TestSkillsUnlink_MissingDestIsNoop(t *testing.T) { + src := t.TempDir() + mkSkill(t, src, "goal-design") + dest := filepath.Join(t.TempDir(), "does-not-exist") + + res, err := unlinkOwnedSkills(src, dest, false) + if err != nil { + t.Fatalf("missing dest should be a no-op, got err: %v", err) + } + if len(res.Removed) != 0 || len(res.Foreign) != 0 { + t.Fatalf("missing dest should report nothing, got %+v", res) + } +} + +// A stale link — one pointing into the repo skills/ tree at a skill that no +// longer exists there — is still ours to remove. The target need not resolve. +func TestSkillsUnlink_RemovesStaleOwnedLink(t *testing.T) { + src := t.TempDir() + dest := t.TempDir() + // Link points into src but the target skill dir was never created (removed + // from the repo since it was linked). + stale := filepath.Join(dest, "retired-skill") + if err := os.Symlink(filepath.Join(src, "retired-skill"), stale); err != nil { + t.Fatalf("make stale link: %v", err) + } + + res, err := unlinkOwnedSkills(src, dest, false) + if err != nil { + t.Fatalf("unlinkOwnedSkills: %v", err) + } + if len(res.Removed) != 1 || res.Removed[0] != "retired-skill" { + t.Fatalf("Removed = %v, want [retired-skill]", res.Removed) + } + if _, err := os.Lstat(stale); !os.IsNotExist(err) { + t.Fatalf("stale owned link not removed; err = %v, want IsNotExist", err) + } +} + +// Cross-family refuter regression, mirror of the link guard (age-u031): an empty +// source dir must fail CLOSED — never fall through to filepath.Abs("")→cwd and +// wrongly claim links pointing into cwd as owned, then delete them. +func TestSkillsUnlink_EmptySrcFailsClosed(t *testing.T) { + dest := t.TempDir() + res, err := unlinkOwnedSkills("", dest, false) + if err == nil { + t.Fatalf("empty srcDir must fail closed with an error, got nil (res=%+v)", res) + } + if len(res.Removed) != 0 { + t.Fatalf("empty srcDir must remove nothing, got Removed=%v", res.Removed) + } + if _, werr := unlinkOwnedSkills(" ", dest, false); werr == nil { + t.Fatal("whitespace-only srcDir must fail closed with an error, got nil") + } +} + +// The fan-out must be RESILIENT — a per-dest failure records an error on that +// dest but must NOT abort the loop, so a failing runtime (listed first) never +// skips the ones after it. Mirror of TestLinkAllDests_ResilientAcrossDests. +func TestUnlinkAllDests_ResilientAcrossDests(t *testing.T) { + src := t.TempDir() + mkSkill(t, src, "alpha") + + good := t.TempDir() + if _, err := linkMissingSkills(src, good, false); err != nil { + t.Fatalf("link setup: %v", err) + } + + // A bad dest that is a regular FILE (not a dir) → os.ReadDir fails with a + // non-IsNotExist error. + badFile := filepath.Join(t.TempDir(), "afile") + if err := os.WriteFile(badFile, []byte("x"), 0o644); err != nil { + t.Fatalf("write bad dest file: %v", err) + } + + // bad FIRST: the good dest after it must still be swept. + results, anyErr := unlinkAllDests(src, []string{badFile, good}, false) + + if !anyErr { + t.Fatal("anyErr should be true when a dest fails") + } + if len(results) != 2 { + t.Fatalf("want 2 per-dest results (never skip), got %d", len(results)) + } + if results[0].Err == "" { + t.Fatalf("the failing dest should carry Err, got %+v", results[0]) + } + if results[1].Err != "" { + t.Fatalf("the good dest should succeed despite the earlier failure, got Err=%q", results[1].Err) + } + if len(results[1].Removed) != 1 || results[1].Removed[0] != "alpha" { + t.Fatalf("good dest Removed = %v, want [alpha]", results[1].Removed) + } + if _, err := os.Lstat(filepath.Join(good, "alpha")); !os.IsNotExist(err) { + t.Fatalf("good dest was skipped after the earlier failure (link still present): %v", err) + } +} diff --git a/cli/docs/COMMANDS.md b/cli/docs/COMMANDS.md index c8848b822..688306459 100644 --- a/cli/docs/COMMANDS.md +++ b/cli/docs/COMMANDS.md @@ -2374,6 +2374,22 @@ ao skills retire [flags] --no-regen Skip the regen scripts after the ledger flip ``` +#### `ao skills unlink` + +The clean uninstall inverse of `ao skills link`. Scan each runtime's + +``` +ao skills unlink [flags] +``` + +**Flags:** + +``` + --dest string Sweep this single dir instead of the auto-detected runtimes (default: every installed runtime — ~/.claude, ~/.codex, ~/.gemini, ~/.cursor, ~/.pi) + -h, --help help for unlink + --json Emit machine-readable JSON +``` + --- ### `ao verdict-gate` diff --git a/docs/cli-surface.json b/docs/cli-surface.json index 9f802277d..9d3d078ba 100644 --- a/docs/cli-surface.json +++ b/docs/cli-surface.json @@ -960,6 +960,13 @@ "kind": "leaf", "reason": "Covered by release smoke tests, direct command tests, or command handler tests." }, + { + "category": "public-tested", + "command": "skills unlink", + "coverage_status": "covered", + "kind": "leaf", + "reason": "Covered by release smoke tests, direct command tests, or command handler tests." + }, { "category": "public-tested", "command": "status", diff --git a/docs/cli-surface.md b/docs/cli-surface.md index d0d44151a..9738c7be5 100644 --- a/docs/cli-surface.md +++ b/docs/cli-surface.md @@ -141,6 +141,7 @@ | `ao skills producers` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. | | `ao skills resolve` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. | | `ao skills retire` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. | +| `ao skills unlink` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. | | `ao status` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. | | `ao validate` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. | | `ao verdict-gate` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. | diff --git a/docs/install-day2-ops.md b/docs/install-day2-ops.md index 9a3b28bcf..9213541ef 100644 --- a/docs/install-day2-ops.md +++ b/docs/install-day2-ops.md @@ -61,6 +61,98 @@ runtime installer supports it. reinstalling, then keep any exported Antigravity workspace settings with the project backup. +## Uninstall + +A clean, documented exit. Two categories, stated up front so you know which is +which before you remove anything: + +- **AgentOps-owned artifacts** — the plugin/skill installs each runtime installer + wrote. Safe to remove; the steps below remove exactly these. +- **User-owned data** — anything in your own repos. AgentOps never removes it, + and the uninstall deliberately leaves it in place (see "What is kept"). + +### Per-runtime plugin/skill removal + +Remove the runtime(s) you installed: + +**Claude Code** + +```bash +claude plugin uninstall agentops@agentops-marketplace +claude plugin marketplace remove agentops-marketplace +``` + +**Codex CLI** + +The Codex installer writes the native plugin cache and one enable entry. Remove +both, plus the install manifest: + +```bash +rm -rf ~/.codex/plugins/cache/agentops-marketplace # cached plugin bundle +rm -f ~/.codex/.agentops-codex-install.json # install manifest + backup pointers +# then delete the AgentOps plugin's enable entry from ~/.codex/config.toml (edit by hand) +``` + +If the installer archived overlapping raw skills into a timestamped backup +directory, its path is recorded in `~/.codex/.agentops-codex-install.json`; +restore or discard that backup as you prefer. + +**Gemini / Antigravity (AGY)** + +```bash +agy plugin disable agentops-core-gemini +agy plugin uninstall agentops-core-gemini +``` + +**OpenCode** + +The OpenCode installer symlinks a plugin and a skills dir; remove both symlinks +(they are links, so removing them never touches the repo they point at): + +```bash +rm -f ~/.config/opencode/plugins/agentops.js +rm -f ~/.config/opencode/skills/agentops +``` + +**Clone-linked skills (`ao skills link` / the generic `scripts/install.sh` and +npx skill paths)** + +If you followed a repo clone with `ao skills link` (the "track main" path), run +its inverse from inside the clone. It removes exactly the symlinks link minted — +those pointing into this repo's `skills/` tree — across every runtime, and leaves +every foreign skill and real directory (e.g. the jsm corpus) untouched: + +```bash +ao skills unlink --dry-run # rehearse: show what would be removed +ao skills unlink # remove AgentOps-owned links from every installed runtime +``` + +### CLI binary + +If you installed the `ao` CLI via Homebrew: + +```bash +brew uninstall agentops +``` + +For a source checkout (`scripts/install.sh --dev`), remove the checkout directory +itself; nothing was installed outside it except the clone-linked skills handled +by `ao skills unlink` above. + +### What is kept (by design) + +Uninstall stops at the AgentOps-owned artifacts above. It deliberately does not +touch your data — this is the whole portability pitch, that AgentOps rides on top +of your work without owning it: + +- **`.agents/` in your repos is YOUR data**, not an AgentOps artifact — the local + knowledge corpus, provenance, and runtime state. It is never removed. Delete it + yourself only if you want to discard that history. +- **Quick-start artifacts are your files.** The `CLAUDE.md` block the quick-start + appended and the generated `GOALS.md` are checked into your repo and owned by + you. Edit or delete them by hand if you no longer want them; the uninstall + leaves them alone. + ## Permissions All installers are user-space installers. They must not require `sudo`. diff --git a/evals/agentops-core/cli-command-surface-matrix.json b/evals/agentops-core/cli-command-surface-matrix.json index ff9f3fe30..ad77bbdd4 100644 --- a/evals/agentops-core/cli-command-surface-matrix.json +++ b/evals/agentops-core/cli-command-surface-matrix.json @@ -41,7 +41,7 @@ }, "expectations": [ {"type": "exit_code", "value": 0}, - {"type": "stdout_contains", "value": "cli-command-headings: top=32 sub=112 all=144"}, + {"type": "stdout_contains", "value": "cli-command-headings: top=32 sub=113 all=145"}, {"type": "stdout_contains", "value": "cli-help-matrix-ok"} ], "dimensions": ["correctness", "runtime_compatibility", "artifact_quality"], diff --git a/evals/agentops-core/fixtures/cli-command-surface-smoke.sh b/evals/agentops-core/fixtures/cli-command-surface-smoke.sh index 85876b3f8..45ed6176e 100755 --- a/evals/agentops-core/fixtures/cli-command-surface-smoke.sh +++ b/evals/agentops-core/fixtures/cli-command-surface-smoke.sh @@ -17,7 +17,7 @@ top_count="$(rg -c '^### `ao ' "$DOCS_PATH")" sub_count="$(rg -c '^#### `ao ' "$DOCS_PATH")" all_count="$(rg -c '^#{3,4} `ao ' "$DOCS_PATH")" -if [[ "$top_count" != "32" || "$sub_count" != "112" || "$all_count" != "144" ]]; then +if [[ "$top_count" != "32" || "$sub_count" != "113" || "$all_count" != "145" ]]; then printf 'unexpected command heading counts: top=%s sub=%s all=%s\n' "$top_count" "$sub_count" "$all_count" >&2 exit 1 fi @@ -25,7 +25,7 @@ fi # shellcheck disable=SC2016 # literal backticks delimit generated Markdown command headings. mapfile -t commands < <(rg '^#{3,4} `ao ' "$DOCS_PATH" | sed -E 's/^.*`([^`]+)`.*/\1/') -if [[ "${#commands[@]}" -ne 144 ]]; then +if [[ "${#commands[@]}" -ne 145 ]]; then printf 'unexpected command matrix size: %s\n' "${#commands[@]}" >&2 exit 1 fi