Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ It previews what it will remove and asks on a terminal (`--yes` skips the questi

Skills are removed from both `~/.agents/skills/` and `~/.claude/skills/` when recorded in a component's `skills` or `skillCopies`, or selected by `global/skills.json` with a matching source in the skills.sh lock. An unreadable or malformed skills lock stops discovery before removal. Policy, command, skill, and Codex hook backups go under `~/.gap/backups/<time>/`; utility removals can use separate timestamped folders. Before removing utilities, GAP saves their skill and command ownership in `~/.gap/state.json` so cleanup can resume after an interruption. After cleanup succeeds, it deletes `~/.gap/store/`, `~/.gap/state.json`, and `~/.gap/published.json`. A failed cleanup exits `1` and keeps the records for a retry.

It leaves `~/.gap/backups/`, `~/.gap/recovery/`, `~/.gap/checkout/`, `~/.gap/checkouts/`, your remaining `~/.config/gap/` settings, and the `gap` binary. It prints a `trash` command for the binary. Copy out any saved files you want, then trash `~/.gap/` yourself.
It leaves `~/.gap/backups/`, `~/.gap/recovery/`, `~/.gap/checkout/`, `~/.gap/checkouts/`, your remaining `~/.config/gap/` settings, and the `gap` binary. It prints a `trash` command for the binary and confirms successful cleanup with `Uninstall complete.` Copy out any saved files you want, then trash `~/.gap/` yourself.

## Commands

Expand All @@ -74,7 +74,7 @@ It leaves `~/.gap/backups/`, `~/.gap/recovery/`, `~/.gap/checkout/`, `~/.gap/che
| `gap init [--adopt] [dir]` | Create project configuration and a policy pointer; `--adopt` reports and preserves existing project files |
| `go test ./...` | Run the self-test against throwaway folders |

Use `gap --help` or `gap <command> --help` for usage and flags. Status and doctor reports use color and tables in terminals, and plain text when redirected.
Use `gap --help` or `gap <command> --help` for usage and flags. Status and doctor reports use color and tables in terminals, and plain text when redirected. Add, update, restore, pin, remove, and uninstall outcome lines also appear when redirected, as plain text.

## Change the policy

Expand Down Expand Up @@ -191,6 +191,8 @@ names = ["do-commit", "do-todo"]

`gap pin do-skills@master` writes the requested ref, resolved commit, and tree to `gap.lock`. If a branch and tag share a name, use `refs/heads/<name>` or `refs/tags/<name>` to choose one. Machine sync fetches the locked commit into `~/.gap/store/<id>/<commit>/`, checks its tree against the lock, and installs the selected skills as described above. Sync never moves a pin. A component missing from `gap.lock` stops the sync and names the `gap pin` command to run. `gap.toml` and `gap.lock` pass the same merge gate as `global/`.

Pin confirms the new pin, shows the old and new commits when it moves, or reports that it is already current. A new or moved pin includes a reminder to run `gap sync` to install it.

The distro includes Do-Skills, Forge, Echo, and Recall. Forge supplies `forge-interview`. Echo and Recall have empty skill lists: machine sync runs their `install` commands from their verified stores, and those installers own their skills and runtime prerequisites. Hook timing and state are described under [Hooks and trust](#hooks-and-trust). Gap copies no skills from them and never installs a harness.

`gap doctor` runs each configured `doctor` command from its verified pinned store, then reports harness presence using `PATH`. Missing pins, missing or changed stores, and failed doctors produce exit `1`; doctor does not fetch missing components.
Expand All @@ -201,6 +203,8 @@ Doctor also records the adapters Echo reports in `~/.gap/state.json`, when the p

Status and `gap restore [id]` use the same component overrides and skill selection as sync. `gap restore do-skills` repairs that component's selected missing or drifted skills without asking; omit the id to repair all components. Restore backs up replaced copies and rolls them back if installation fails. A verified store avoids network access.

Restore names each repaired skill and its source pin, then reports the total restored. If nothing needs repair, it reports how many selected skills were checked, or that none were selected. An id limits that confirmation to the requested component.

### Load levels

A component's skills are the `skills` in its `gap-utility.toml`, or its `names` array when its repo has no manifest. `names` beside a manifest narrows that list. An optional `[components.<id>.skills.<name>]` table selects how an existing name installs:
Expand Down Expand Up @@ -271,6 +275,8 @@ gap add owner/repo --id tools # another id; the default is the repo name, lowerc

`gap add` reads the manifest at the resolved commit, appends the repo to `~/.config/gap/gap.toml`, pins it in `~/.config/gap/gap.lock`, and installs what it declares in the [manifest](#publish-a-utility). These two files are yours: they never touch the distro's `gap.toml` or `gap.lock`, and the sync merge gate does not cover them. An id the distro already uses is refused, even when disabled. `policy` and `manifest` are reserved sync group names. User utility skills install under home; `load = "project"` is available only to distro components. Disabling a utility skips installation while keeping it available to `gap list`, `gap update`, and `gap remove`.

Add confirms the utility id, ref, and commit on success, and flags skipped installation steps when registration succeeded but installation is incomplete.

```toml
# ~/.config/gap/gap.toml, as written by gap add
[components.tools]
Expand All @@ -285,9 +291,11 @@ ref = "master"
| See what is added | `gap list` | One line per component: id, `distro` or `user`, repo, ref, commit, and whether its hooks run, ask, or are trusted |
| Remove | `gap remove <id>` | Runs the `uninstall` hook, unregisters its [MCP servers](#publish-a-utility), moves the skills and commands GAP placed for it into one `~/.gap/backups/<time>/` folder, and drops its config entry, pin, and record. An edited copy is named in the output |

Update reports each utility's pin change or that it is up to date, then totals the utilities checked, updated, and up to date. Those counts describe pins; the summary also flags failed updates or skipped installation steps. Once utilities are selected, failures still print the summary and name utilities whose pins were not checked. With no added utilities, update explains there is nothing to update, shows `gap add owner/repo`, and lists any distro components with their `gap pin <id>@<ref>`, then `gap sync` update path.

Before installing any utility, update verifies every requested source checkout, including disabled utilities, and rebuilds missing or corrupt caches even when the pin is already current. Update compares each skill with its last copied version, tracked separately from hook completion in `~/.gap/state.json`. If an update hook fails, fix its cause and rerun `gap update [id]`: installation retries even when the pin is already current.

GAP removes only the files it recorded for that utility in `~/.gap/state.json`, keeping files another source still selects. If it cannot determine remaining component selections from verified local checkouts, removal stops before running the uninstall hook; run `gap sync` before retrying. If a successfully installed, untrusted utility has an `install` hook and no `uninstall` hook, `gap remove` warns that it cannot undo what the installer did. A failing or unapproved `uninstall` hook stops `gap remove` before anything moves.
GAP removes only the files it recorded for that utility in `~/.gap/state.json`, keeping files another source still selects. If it cannot determine remaining component selections from verified local checkouts, removal stops before running the uninstall hook; run `gap sync` before retrying. If a successfully installed, untrusted utility has an `install` hook and no `uninstall` hook, `gap remove` warns that it cannot undo what the installer did. A failing or unapproved `uninstall` hook stops `gap remove` before anything moves. Successful removal confirms the utility id and, when files were moved, the backup folder.

### Hooks and trust

Expand Down
36 changes: 35 additions & 1 deletion cmd/gap/components.go
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,9 @@ func restoreAll(id string) error {
if err != nil {
return err
}
out := newReport(os.Stdout)
if d.Profile == "crew" {
out.done(true, "Crew profile: no component skills are managed here, so there is nothing to restore.")
return nil
}
if id != "" {
Expand All @@ -723,6 +725,8 @@ func restoreAll(id string) error {
}
}
ok := true
restored := 0
checked := 0
for _, cid := range sortedIDs(d.Components) {
if id != "" && cid != id {
continue
Expand All @@ -742,20 +746,37 @@ func restoreAll(id string) error {
for _, entry := range selectedEntries(c, dir, projectRoot) {
target := entryHome(home, entry)
for _, name := range entry.Skills {
checked++
if skillDriftCount(dir, target, name) == 0 {
continue
}
replaced, err := replaceSkill("", repoRoot, target, entry, name)
if err != nil || !replaced {
fmt.Fprintf(os.Stderr, "Restoring %s: %v\n", name, err)
ok = false
continue
}
restored++
out.done(true, "restored "+name+" from "+cid+pinLabel(e))
}
}
}
if !ok {
return errSkipped
}
if restored == 0 {
scope := ""
if id != "" {
scope = " for " + id
}
if checked == 0 {
out.done(true, "Nothing to restore: no component skills selected"+scope+".")
} else {
out.done(true, "Nothing to restore: checked "+plural(checked, "selected skill")+scope+"; each matches its pin.")
}
return nil
}
out.done(true, "Restored "+plural(restored, "skill")+".")
return nil
}

Expand Down Expand Up @@ -792,8 +813,21 @@ func pinComponent(spec string) error {
if err != nil {
return err
}
prev, pinned := lk.Components[id]
lk.Components[id] = pin
return writeLock(repoRoot, lk)
if err := writeLock(repoRoot, lk); err != nil {
return err
}
out := newReport(os.Stdout)
switch {
case pinned && prev.Commit == pin.Commit && prev.Ref == pin.Ref:
out.done(true, id+" is already pinned"+pinLabel(pin)+"; gap.lock unchanged")
case pinned:
out.done(true, fmt.Sprintf("%s pin moved from %s to %s @ %s in gap.lock; run gap sync to install it", id, prev.Commit[:min(7, len(prev.Commit))], pin.Commit[:7], pin.Ref))
default:
out.done(true, id+" pinned"+pinLabel(pin)+" in gap.lock; run gap sync to install it")
}
return nil
}

func pinCommit(home string, c component, ref, commit string, prev lockEntryM1) (lockEntryM1, error) {
Expand Down
Loading
Loading