mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
feat(cli): add ao skills unlink — rollback inverse of skills link
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.
This commit is contained in:
@@ -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).")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2374,6 +2374,22 @@ ao skills retire <slug> [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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user