diff --git a/README.md b/README.md index 719e735..7e1936c 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ dotagents sessions [--no-open] [--ssh-host user@host] [--port N] [--host ADDR] dotagents skill new|list|info|update|promote dotagents publish [--target NAME] [--skills a,b] [--dry-run] [--json] [--yes] # push skills to a remote registry dotagents mcp list|add|import|remove +dotagents hook list [query] | remove [--dry-run] ``` ## Supported integrations @@ -117,6 +118,8 @@ dotagents can launch two optional external tools. Neither is installed, vendored `dotagents skill list` remains the built-in provenance view for each harness skill root. It reports managed links, foreign symlinks, unmanaged directories, drift, broken links, and estimated context cost. +`dotagents hook list [query]` inventories native hook registrations and marks canonical entries as managed and missing script targets as stale. To clean up a hook installed outside dotagents, preview with `dotagents hook remove --dry-run `, then rerun without `--dry-run`; unrelated hook entries are preserved. `dotagents doctor` reports stale native hooks, and `dotagents sync` reconciles the remaining canonical hooks afterward. + `dotagents inspect` shells out to HarnessKit (`hk serve`). Treat it as read-mostly: HarnessKit's enable/disable/deploy actions bypass dotagents, so reconcile any changes with `dotagents sync`. Install HarnessKit separately. `dotagents sessions` shells out to AgentsView (`agentsview serve`). AgentsView owns its local transcript index and configuration; dotagents does not sync or mutate either. `--no-open` maps to AgentsView's `--no-browser`; `--ssh-host user@host` prints a loopback tunnel command on a remote machine. Other flags are forwarded to `agentsview serve`. Install AgentsView separately. diff --git a/cmd/dotagents/cli_launch_test.go b/cmd/dotagents/cli_launch_test.go index bdf582d..47a6ab5 100644 --- a/cmd/dotagents/cli_launch_test.go +++ b/cmd/dotagents/cli_launch_test.go @@ -167,7 +167,7 @@ func TestRootHelpAdvertisesCanonicalDescriptiveFamilies(t *testing.T) { } families = append(families, fields[0]) } - if got, want := strings.Join(families, ","), "setup,status,sync,doctor,config,view,inspect,sessions,skill,publish,mcp"; got != want { + if got, want := strings.Join(families, ","), "setup,status,sync,doctor,config,view,inspect,sessions,skill,publish,mcp,hook"; got != want { t.Fatalf("short-help families = %q, want %q:\n%s", got, want, stdout) } if !strings.Contains(stdout, `Run "dotagents help --all" for flags, maintenance commands, and compatibility aliases.`) { diff --git a/cmd/dotagents/doctor.go b/cmd/dotagents/doctor.go index efe6cd3..6d53cc5 100644 --- a/cmd/dotagents/doctor.go +++ b/cmd/dotagents/doctor.go @@ -68,6 +68,7 @@ func runDoctor(opts runOptions) error { results = append(results, checkMaterializedExternalSkills(repoRoot, cfg, home)) results = append(results, checkExternalSkillLock(repoRoot, cfg, home)) results = append(results, checkExternalSkillAudit(cfg, home)) + results = append(results, checkNativeHookHealth(home, cfg, selected)) fmt.Println("checks:") labelWidth := 0 @@ -100,6 +101,32 @@ func runDoctor(opts runOptions) error { return doctorExitError(failed, warned) } +func checkNativeHookHealth(home string, cfg config, selected []agentConfig) checkResult { + entries, unsupported, err := collectNativeHooks(home, cfg, selected) + if err != nil { + return checkResult{"native hooks", checkStatusFail, err.Error()} + } + managed := 0 + var stale []string + for _, entry := range entries { + if entry.Managed { + managed++ + } + if entry.MissingTarget != "" { + stale = append(stale, fmt.Sprintf("%s/%s -> %s", entry.Agent, entry.Event, entry.MissingTarget)) + } + } + if len(stale) > 0 { + sort.Strings(stale) + return checkResult{"native hooks", checkStatusWarn, fmt.Sprintf("%d stale registration(s); first: %s; review with: dotagents hook list", len(stale), stale[0])} + } + detail := fmt.Sprintf("%d registrations, %d managed, %d unmanaged", len(entries), managed, len(entries)-managed) + if len(unsupported) > 0 { + detail += fmt.Sprintf("; unsupported: %s", strings.Join(unsupported, ", ")) + } + return checkResult{"native hooks", checkStatusPass, detail} +} + // doctorExitError derives the doctor exit result from check outcomes only. It // intentionally takes no note/advisory input so context notes cannot affect it. func doctorExitError(failed, warned int) error { diff --git a/cmd/dotagents/hook_cli.go b/cmd/dotagents/hook_cli.go new file mode 100644 index 0000000..1d7d389 --- /dev/null +++ b/cmd/dotagents/hook_cli.go @@ -0,0 +1,468 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type nativeHookEntry struct { + Agent string + Event string + Command string + ConfigPath string + Managed bool + MissingTarget string +} + +type hookCommandOptions struct { + runOptions + Query string +} + +func runHookCommand(args []string) error { + if len(args) == 0 { + return errors.New("hook requires subcommand: list or remove") + } + switch args[0] { + case "list": + opts, err := parseHookCommandFlags("hook list", args[1:], false) + if err != nil { + return err + } + return runHookList(opts) + case "remove": + opts, err := parseHookCommandFlags("hook remove", args[1:], true) + if err != nil { + return err + } + return runHookRemove(opts) + default: + return fmt.Errorf("unknown hook subcommand %q", args[0]) + } +} + +func parseHookCommandFlags(name string, args []string, requireQuery bool) (hookCommandOptions, error) { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var opts hookCommandOptions + fs.StringVar(&opts.ConfigPath, "config", "", "Path to dotagents YAML config") + fs.StringVar(&opts.Agents, "agents", "", "Comma-separated agent names to inspect") + fs.BoolVar(&opts.DryRun, "dry-run", false, "Preview removals without changing native config") + if err := fs.Parse(args); err != nil { + return hookCommandOptions{}, err + } + if fs.NArg() > 1 { + return hookCommandOptions{}, fmt.Errorf("%s accepts at most one query", name) + } + if fs.NArg() == 1 { + opts.Query = strings.TrimSpace(fs.Arg(0)) + } + if requireQuery && opts.Query == "" { + return hookCommandOptions{}, fmt.Errorf("usage: dotagents hook remove [--dry-run] [--agents ...] ") + } + return opts, nil +} + +func runHookList(opts hookCommandOptions) error { + _, home, cfg, selected, err := loadContext(opts.runOptions) + if err != nil { + return err + } + entries, unsupported, err := collectNativeHooks(home, cfg, selected) + if err != nil { + return err + } + entries = filterNativeHooks(entries, opts.Query) + printNativeHooks(entries, unsupported) + return nil +} + +func runHookRemove(opts hookCommandOptions) error { + _, home, cfg, selected, err := loadContext(opts.runOptions) + if err != nil { + return err + } + entries, unsupported, err := collectNativeHooks(home, cfg, selected) + if err != nil { + return err + } + matches := filterNativeHooks(entries, opts.Query) + printNativeHooks(matches, unsupported) + if len(matches) == 0 { + fmt.Printf("no native hooks match %q\n", opts.Query) + return nil + } + if opts.DryRun { + fmt.Printf("dry-run: would remove %d hook registration(s)\n", len(matches)) + return nil + } + changed, err := removeNativeHookEntries(matches) + if err != nil { + return err + } + fmt.Printf("removed %d hook registration(s) from %d native config file(s)\n", len(matches), changed) + return nil +} + +func collectNativeHooks(home string, cfg config, selected []agentConfig) ([]nativeHookEntry, []string, error) { + var entries []nativeHookEntry + var unsupported []string + seenPaths := make(map[string]bool) + for _, agent := range selected { + name := normalizeAgentName(agent.Name) + paths, simple, supported := nativeHookConfigPaths(name, home) + if !supported { + unsupported = append(unsupported, name) + continue + } + for _, path := range paths { + key := name + "\x00" + path + if seenPaths[key] { + continue + } + seenPaths[key] = true + var found []nativeHookEntry + var err error + if simple { + found, err = readSimpleNativeHooks(name, path) + } else { + found, err = readGroupedNativeHooks(name, path) + } + if err != nil { + return nil, nil, err + } + for i := range found { + found[i].Managed = nativeHookIsManaged(found[i], cfg) + found[i].MissingTarget = missingHookTarget(found[i].Command, home) + } + entries = append(entries, found...) + } + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].Agent != entries[j].Agent { + return entries[i].Agent < entries[j].Agent + } + if entries[i].Event != entries[j].Event { + return entries[i].Event < entries[j].Event + } + return entries[i].Command < entries[j].Command + }) + sort.Strings(unsupported) + return entries, unsupported, nil +} + +func nativeHookConfigPaths(agent string, home string) ([]string, bool, bool) { + switch agent { + case agentClaudeCode: + return []string{claudeHooksConfigPath(home)}, false, true + case agentCodex: + return []string{codexHooksConfigPath(home)}, false, true + case agentDroid: + return []string{droidHooksConfigPath(home), droidLegacyHooksConfigPath(home)}, false, true + case agentHermes: + return []string{filepath.Join(home, ".hermes", "config.yaml")}, true, true + case agentQwenCode: + return []string{qwenSettingsPath(home)}, false, true + default: + return nil, false, false + } +} + +func readGroupedNativeHooks(agent string, path string) ([]nativeHookEntry, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read %s: %w", path, err) + } + var raw map[string]interface{} + if err := parseJSONConfig(path, data, &raw); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + root, _ := raw["hooks"].(map[string]interface{}) + var entries []nativeHookEntry + for event, groupsRaw := range root { + groups, _ := groupsRaw.([]interface{}) + for _, groupRaw := range groups { + group, _ := groupRaw.(map[string]interface{}) + items, _ := group["hooks"].([]interface{}) + for _, itemRaw := range items { + item, _ := itemRaw.(map[string]interface{}) + command, _ := item["command"].(string) + if strings.TrimSpace(command) != "" { + entries = append(entries, nativeHookEntry{Agent: agent, Event: event, Command: command, ConfigPath: path}) + } + } + } + } + return entries, nil +} + +func readSimpleNativeHooks(agent string, path string) ([]nativeHookEntry, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read %s: %w", path, err) + } + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + root, _ := raw["hooks"].(map[string]interface{}) + var entries []nativeHookEntry + for event, itemsRaw := range root { + items, _ := itemsRaw.([]interface{}) + for _, itemRaw := range items { + item, _ := itemRaw.(map[string]interface{}) + command, _ := item["command"].(string) + if strings.TrimSpace(command) != "" { + entries = append(entries, nativeHookEntry{Agent: agent, Event: event, Command: command, ConfigPath: path}) + } + } + } + return entries, nil +} + +func nativeHookIsManaged(entry nativeHookEntry, cfg config) bool { + for _, hook := range cfg.Hooks { + if !hook.Enabled || (len(hook.Agents) > 0 && !stringInSlice(entry.Agent, hook.Agents)) { + continue + } + event := hook.Event + if entry.Agent == agentHermes { + var ok bool + event, ok = hermesHookEvent(event) + if !ok { + continue + } + } + if event == entry.Event && hookCommandMatches(entry.Command, hook.Command) { + return true + } + } + return false +} + +func filterNativeHooks(entries []nativeHookEntry, query string) []nativeHookEntry { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return entries + } + var filtered []nativeHookEntry + for _, entry := range entries { + haystack := strings.ToLower(entry.Agent + "\n" + entry.Event + "\n" + entry.Command + "\n" + entry.ConfigPath) + if strings.Contains(haystack, query) { + filtered = append(filtered, entry) + } + } + return filtered +} + +func printNativeHooks(entries []nativeHookEntry, unsupported []string) { + for _, entry := range entries { + ownership := "unmanaged" + if entry.Managed { + ownership = "managed" + } + stale := "" + if entry.MissingTarget != "" { + stale = " stale=" + entry.MissingTarget + } + fmt.Printf("%s\t%s\t%s%s\t%s\n", entry.Agent, entry.Event, ownership, stale, compactHookCommand(entry.Command)) + } + if len(unsupported) > 0 { + fmt.Printf("unsupported hook surfaces: %s\n", strings.Join(unsupported, ", ")) + } + fmt.Printf("%d native hook registration(s)\n", len(entries)) +} + +func compactHookCommand(command string) string { + command = strings.Join(strings.Fields(command), " ") + const limit = 180 + if len(command) <= limit { + return command + } + return command[:limit-3] + "..." +} + +func removeNativeHookEntries(entries []nativeHookEntry) (int, error) { + byPath := make(map[string][]nativeHookEntry) + agentByPath := make(map[string]string) + for _, entry := range entries { + byPath[entry.ConfigPath] = append(byPath[entry.ConfigPath], entry) + agentByPath[entry.ConfigPath] = entry.Agent + } + changed := 0 + for path, pathEntries := range byPath { + var didChange bool + var err error + if agentByPath[path] == agentHermes { + didChange, err = removeSimpleNativeHookEntries(path, pathEntries) + } else { + didChange, err = removeGroupedNativeHookEntries(path, pathEntries) + } + if err != nil { + return changed, err + } + if didChange { + changed++ + } + } + return changed, nil +} + +func removeGroupedNativeHookEntries(path string, entries []nativeHookEntry) (bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return false, fmt.Errorf("read %s: %w", path, err) + } + var raw map[string]interface{} + if err := parseJSONConfig(path, data, &raw); err != nil { + return false, fmt.Errorf("parse %s: %w", path, err) + } + if !removeSelectedGroupedHooks(raw, entries) { + return false, nil + } + out, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return false, fmt.Errorf("marshal %s: %w", path, err) + } + if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { + return false, fmt.Errorf("write %s: %w", path, err) + } + return true, nil +} + +func removeSelectedGroupedHooks(raw map[string]interface{}, entries []nativeHookEntry) bool { + root, ok := raw["hooks"].(map[string]interface{}) + if !ok { + return false + } + byEvent := nativeHookCommandsByEvent(entries) + changed := false + for event, commands := range byEvent { + groups, ok := root[event].([]interface{}) + if !ok { + continue + } + keptGroups := groups[:0] + for _, groupRaw := range groups { + group, ok := groupRaw.(map[string]interface{}) + if !ok { + keptGroups = append(keptGroups, groupRaw) + continue + } + items, ok := group["hooks"].([]interface{}) + if !ok { + keptGroups = append(keptGroups, groupRaw) + continue + } + filtered, removed := removeHookCommands(items, commands) + if removed { + changed = true + group["hooks"] = filtered + } + if len(filtered) > 0 || !removed { + keptGroups = append(keptGroups, groupRaw) + } + } + if len(keptGroups) == 0 { + delete(root, event) + } else { + root[event] = keptGroups + } + } + if len(root) == 0 { + delete(raw, "hooks") + } + return changed +} + +func removeSimpleNativeHookEntries(path string, entries []nativeHookEntry) (bool, error) { + data, err := os.ReadFile(path) + if err != nil { + return false, fmt.Errorf("read %s: %w", path, err) + } + var raw map[string]interface{} + if err := yaml.Unmarshal(data, &raw); err != nil { + return false, fmt.Errorf("parse %s: %w", path, err) + } + root, ok := raw["hooks"].(map[string]interface{}) + if !ok { + return false, nil + } + changed := false + for event, commands := range nativeHookCommandsByEvent(entries) { + items, ok := root[event].([]interface{}) + if !ok { + continue + } + filtered, removed := removeHookCommands(items, commands) + if !removed { + continue + } + changed = true + if len(filtered) == 0 { + delete(root, event) + } else { + root[event] = filtered + } + } + if !changed { + return false, nil + } + if len(root) == 0 { + delete(raw, "hooks") + } + out, err := yaml.Marshal(raw) + if err != nil { + return false, fmt.Errorf("marshal %s: %w", path, err) + } + if err := os.WriteFile(path, out, 0o644); err != nil { + return false, fmt.Errorf("write %s: %w", path, err) + } + return true, nil +} + +func nativeHookCommandsByEvent(entries []nativeHookEntry) map[string][]string { + byEvent := make(map[string][]string) + for _, entry := range entries { + byEvent[entry.Event] = append(byEvent[entry.Event], entry.Command) + } + return byEvent +} + +var hookScriptPathPattern = regexp.MustCompile(`(?:~|\$\{HOME-\}|\$HOME|/)[^'"[:space:];]+\.(?:sh|py|cmd|ts)`) + +func missingHookTarget(command string, home string) string { + paths := hookScriptPathPattern.FindAllString(command, -1) + if len(paths) == 0 { + return "" + } + seen := make(map[string]bool) + for _, path := range paths { + path = strings.ReplaceAll(path, "${HOME-}", home) + path = strings.ReplaceAll(path, "$HOME", home) + path = expandPath(path, home) + if seen[path] { + continue + } + seen[path] = true + if _, err := os.Stat(path); err != nil && os.IsNotExist(err) { + return path + } + } + return "" +} diff --git a/cmd/dotagents/hook_cli_test.go b/cmd/dotagents/hook_cli_test.go new file mode 100644 index 0000000..da014aa --- /dev/null +++ b/cmd/dotagents/hook_cli_test.go @@ -0,0 +1,98 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCollectAndRemoveNativeHooksAcrossSupportedFormats(t *testing.T) { + home := t.TempDir() + writeSyncTestFile(t, claudeHooksConfigPath(home), []byte(`{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"~/.orca/agent-hooks/claude-hook.sh"},{"type":"command","command":"echo keep"}]}]}}`)) + writeSyncTestFile(t, codexHooksConfigPath(home), []byte(`{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"~/.orca/agent-hooks/codex-hook.sh"}]}]}}`)) + writeSyncTestFile(t, droidHooksConfigPath(home), []byte(`{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"~/.orca/agent-hooks/droid-hook.sh"}]}]}}`)) + writeSyncTestFile(t, filepath.Join(home, ".hermes", "config.yaml"), []byte("hooks:\n on_session_end:\n - command: ~/.orca/agent-hooks/hermes-hook.sh\n")) + writeSyncTestFile(t, qwenSettingsPath(home), []byte(`{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"~/.orca/agent-hooks/qwen-hook.sh"}]}]}}`)) + + selected := []agentConfig{{Name: agentClaudeCode}, {Name: agentCodex}, {Name: agentDroid}, {Name: agentHermes}, {Name: agentQwenCode}} + entries, unsupported, err := collectNativeHooks(home, config{}, selected) + if err != nil { + t.Fatal(err) + } + if len(unsupported) != 0 || len(filterNativeHooks(entries, "orca")) != 5 { + t.Fatalf("entries=%#v unsupported=%#v", entries, unsupported) + } + changed, err := removeNativeHookEntries(filterNativeHooks(entries, "orca")) + if err != nil { + t.Fatal(err) + } + if changed != 5 { + t.Fatalf("changed configs = %d, want 5", changed) + } + for _, path := range []string{claudeHooksConfigPath(home), codexHooksConfigPath(home), droidHooksConfigPath(home), filepath.Join(home, ".hermes", "config.yaml"), qwenSettingsPath(home)} { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(string(data)), "orca") { + t.Fatalf("orca hook remains in %s:\n%s", path, data) + } + } + data, err := os.ReadFile(claudeHooksConfigPath(home)) + if err != nil || !strings.Contains(string(data), "echo keep") { + t.Fatalf("unrelated hook was not preserved: err=%v data=%s", err, data) + } +} + +func TestMissingHookTarget(t *testing.T) { + home := t.TempDir() + missing := filepath.Join(home, ".orca", "agent-hooks", "hook.sh") + command := "if [ -f '" + missing + "' ]; then /bin/sh '" + missing + "'; fi" + if got := missingHookTarget(command, home); got != missing { + t.Fatalf("missingHookTarget() = %q, want %q", got, missing) + } + if err := os.MkdirAll(filepath.Dir(missing), 0o755); err != nil { + t.Fatal(err) + } + writeSyncTestFile(t, missing, []byte("#!/bin/sh\n")) + if got := missingHookTarget(command, home); got != "" { + t.Fatalf("existing target reported missing: %q", got) + } + + // A command that chains an existing script with a missing one is still + // stale: report the missing target rather than clearing on the first hit. + present := filepath.Join(home, ".agents", "hooks", "present.sh") + writeSyncTestFile(t, present, []byte("#!/bin/sh\n")) + gone := filepath.Join(home, ".agents", "hooks", "gone.py") + chained := "'" + present + "' && python3 '" + gone + "'" + if got := missingHookTarget(chained, home); got != gone { + t.Fatalf("chained missing target = %q, want %q", got, gone) + } +} + +func TestRemoveNativeHookEntriesScopesRemovalToMatchedEvent(t *testing.T) { + home := t.TempDir() + path := codexHooksConfigPath(home) + command := "~/.agents/hooks/shared.sh" + writeSyncTestFile(t, path, []byte(`{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"`+command+`"}]}],"SessionStart":[{"hooks":[{"type":"command","command":"`+command+`"}]}]}}`)) + + entries, _, err := collectNativeHooks(home, config{}, []agentConfig{{Name: agentCodex}}) + if err != nil { + t.Fatal(err) + } + matches := filterNativeHooks(entries, "Stop") + if len(matches) != 1 { + t.Fatalf("Stop matches = %d, want 1", len(matches)) + } + if _, err := removeNativeHookEntries(matches); err != nil { + t.Fatal(err) + } + remaining, _, err := collectNativeHooks(home, config{}, []agentConfig{{Name: agentCodex}}) + if err != nil { + t.Fatal(err) + } + if len(remaining) != 1 || remaining[0].Event != "SessionStart" { + t.Fatalf("remaining hooks = %#v, want only SessionStart", remaining) + } +} diff --git a/cmd/dotagents/hooks.go b/cmd/dotagents/hooks.go index 0506830..c9b9b27 100644 --- a/cmd/dotagents/hooks.go +++ b/cmd/dotagents/hooks.go @@ -466,7 +466,9 @@ func claudeHooksConfigPath(home string) string { } func inspectClaudeHookMap(raw map[string]interface{}, hook hookConfig) string { - return inspectGroupedHookMap(raw, hook, false) + // Claude Code ignores grouped hook entries that lack "type": "command", + // so require it: a type-less entry is drift that must be rewritten. + return inspectGroupedHookMap(raw, hook, true) } func inspectNestedJSONHookMap(raw map[string]interface{}, hook hookConfig) string { @@ -510,7 +512,9 @@ func inspectGroupedHookMap(raw map[string]interface{}, hook hookConfig, requireT } func upsertClaudeHookMap(raw map[string]interface{}, hook hookConfig) error { - return upsertGroupedHookMap(raw, hook, renderHookEntry) + // Claude Code requires "type": "command" on grouped hook entries; render + // the nested form so patched hooks are honored rather than silently ignored. + return upsertGroupedHookMap(raw, hook, renderNestedHookEntry) } func upsertNestedJSONHookMap(raw map[string]interface{}, hook hookConfig) error { diff --git a/cmd/dotagents/hooks_test.go b/cmd/dotagents/hooks_test.go index 1fb7b17..45639b0 100644 --- a/cmd/dotagents/hooks_test.go +++ b/cmd/dotagents/hooks_test.go @@ -84,6 +84,44 @@ func TestClaudeHookPatchPreservesUnrelatedHooks(t *testing.T) { } } +func TestClaudeHookPatchRendersTypeCommand(t *testing.T) { + // Regression: Claude Code ignores grouped hook entries that lack + // "type": "command". A type-less entry (even with a matching timeout) must + // read as drift so a resync heals it, and the patch must write the type. + raw := map[string]interface{}{ + "hooks": map[string]interface{}{ + "Stop": []interface{}{ + map[string]interface{}{ + "hooks": []interface{}{ + map[string]interface{}{"command": "~/.agents/memory/hooks/stop.sh", "timeout": 15}, + }, + }, + }, + }, + } + + if state := inspectClaudeHookMap(raw, testHook()); state != stateDrifted { + t.Fatalf("type-less entry inspected as %q, want drifted", state) + } + + if err := upsertClaudeHookMap(raw, testHook()); err != nil { + t.Fatal(err) + } + + groups := raw["hooks"].(map[string]interface{})["Stop"].([]interface{}) + items := groups[0].(map[string]interface{})["hooks"].([]interface{}) + if len(items) != 1 { + t.Fatalf("managed hook duplicated instead of updated in place: %#v", items) + } + if item := items[0].(map[string]interface{}); item["type"] != "command" { + t.Fatalf("patched hook missing type: %#v", item) + } + + if state := inspectClaudeHookMap(raw, testHook()); state != stateSynced { + t.Fatalf("after patch inspected as %q, want synced", state) + } +} + func TestClaudeHookPatchUpdatesExistingHookInLaterGroup(t *testing.T) { raw := map[string]interface{}{ "hooks": map[string]interface{}{ diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index 38b079e..82175c4 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -195,6 +195,8 @@ func run(args []string) error { return runPublishCommand(args[1:]) case "mcp": return runMCP(args[1:]) + case "hook": + return runHookCommand(args[1:]) case "cron": opts, err := parseCronFlags(args[1:]) if err != nil { @@ -561,6 +563,7 @@ func printUsage() { fmt.Println(" skill Inspect, create, update, and promote skills") fmt.Println(" publish Push canonical skills to a remote skill registry") fmt.Println(" mcp Manage MCP servers") + fmt.Println(" hook Review and remove native hook registrations") fmt.Println() fmt.Println("Run \"dotagents help --all\" for flags, maintenance commands, and compatibility aliases.") } @@ -584,6 +587,9 @@ func printAllUsage() { fmt.Println(" dotagents skill promote [--dry-run]") fmt.Println(" dotagents publish [--target NAME] [--skills a,b] [--dry-run] [--json] [--yes]") fmt.Println(" dotagents mcp [options]") + fmt.Println(" dotagents hook list [--agents ...] [query]") + fmt.Println(" dotagents hook remove [--dry-run] [--agents ...] ") + fmt.Println() fmt.Println("Maintenance and compatibility aliases:") fmt.Println(" dotagents cron [--interval 30m|--deps|--remove]") fmt.Println(" dotagents deps [options]") diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d6fb135..7cd2158 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,6 +1,7 @@ # Troubleshooting - **`dotagents doctor`** is the first stop: it validates skill frontmatter, role definitions, lock pins, materialized copies, hook registration, and audits external sources. +- **A removed tool left stale native hooks:** review them with `dotagents hook list `, preview a surgical cleanup with `dotagents hook remove --dry-run `, remove them by dropping `--dry-run`, then run `dotagents sync` and `dotagents doctor`. - **Pi MCP entries do not appear:** install `pi-mcp-adapter` or declare its pinned source under the Pi target's `packages`, keep the canonical server targeted at `pi`, run `dotagents sync`, then restart Pi or run `/reload`. Dotagents writes only its named entries under `~/.pi/agent/mcp.json` and preserves adapter-specific settings. - **Pi packages are listed but not installed:** dotagents manages the `packages` declaration in `~/.pi/agent/settings.json`, not the Pi executable or npm runtime. Install Pi first, run `dotagents sync`, then start Pi once so its package manager installs missing declarations. - **A sync proposed removals you didn't expect:** setup-driven syncs always preview removals per harness and default to keeping your files; answer `n` and inspect with `dotagents status`.