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:
boshu
2026-07-13 18:13:09 -04:00
parent feac5b9448
commit 8bb08edc9c
8 changed files with 599 additions and 3 deletions
+236
View File
@@ -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).")
}
}
+244
View File
@@ -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)
}
}
+16
View File
@@ -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`
+7
View File
@@ -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",
+1
View File
@@ -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. |
+92
View File
@@ -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