--- phase: 07-import-cicd plan: 05 type: execute wave: 2 depends_on: ["07-04"] files_modified: - cmd/hook.go - cmd/stubs.go - cmd/hook_script.sh - cmd/hook_test.go autonomous: true requirements: [CICD-01] must_haves: truths: - "keyhunter hook install writes an executable .git/hooks/pre-commit" - "The installed hook calls keyhunter scan on staged files and propagates the exit code" - "keyhunter hook uninstall removes a KeyHunter-owned hook, preserving non-KeyHunter content via backup" - "Both commands error cleanly when run outside a git repository" artifacts: - path: cmd/hook.go provides: "keyhunter hook install/uninstall implementation" contains: "var hookCmd" - path: cmd/hook_script.sh provides: "embedded pre-commit shell script" contains: "keyhunter scan" key_links: - from: cmd/hook.go to: cmd/hook_script.sh via: "go:embed compile-time bundling" pattern: "//go:embed hook_script.sh" --- Replace the cmd/hook stub with working install/uninstall logic. The install subcommand writes a pre-commit script (embedded via go:embed) that invokes `keyhunter scan` on staged files and exits with scan's exit code. Purpose: CICD-01 — git pre-commit integration prevents leaked keys from being committed. First line of defense for developer workflows. Output: Working `keyhunter hook install` / `keyhunter hook uninstall` subcommands, embedded script, tests. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/07-import-cicd/07-CONTEXT.md @cmd/stubs.go @cmd/root.go Task 1: cmd/hook.go with install/uninstall subcommands + embedded script cmd/hook.go, cmd/stubs.go, cmd/hook_script.sh, cmd/hook_test.go Remove the `hookCmd` stub block from cmd/stubs.go. Keep all other stubs. Create cmd/hook_script.sh (exact contents below — trailing newline important): ```sh #!/usr/bin/env bash # KEYHUNTER-HOOK v1 — managed by `keyhunter hook install` # Remove via `keyhunter hook uninstall`. set -e files=$(git diff --cached --name-only --diff-filter=ACMR) if [ -z "$files" ]; then exit 0 fi # Run keyhunter against each staged file. Exit code 1 from keyhunter # means findings present; 2 means scan error. Either blocks the commit. echo "$files" | xargs -r keyhunter scan --exit-code status=$? if [ $status -ne 0 ]; then echo "keyhunter: pre-commit blocked (exit $status). Run 'git commit --no-verify' to bypass." >&2 exit $status fi exit 0 ``` Create cmd/hook.go: ```go package cmd import ( _ "embed" "fmt" "os" "path/filepath" "strings" "time" "github.com/spf13/cobra" ) //go:embed hook_script.sh var hookScript string // hookMarker identifies a KeyHunter-managed hook. Uninstall refuses to // delete pre-commit files that don't contain this marker unless --force. const hookMarker = "KEYHUNTER-HOOK v1" var ( hookForce bool ) var hookCmd = &cobra.Command{ Use: "hook", Short: "Install or manage git pre-commit hooks", } var hookInstallCmd = &cobra.Command{ Use: "install", Short: "Install the keyhunter pre-commit hook into .git/hooks/", RunE: runHookInstall, } var hookUninstallCmd = &cobra.Command{ Use: "uninstall", Short: "Remove the keyhunter pre-commit hook", RunE: runHookUninstall, } func init() { hookInstallCmd.Flags().BoolVar(&hookForce, "force", false, "overwrite any existing pre-commit hook without prompt") hookUninstallCmd.Flags().BoolVar(&hookForce, "force", false, "delete pre-commit even if it is not KeyHunter-managed") hookCmd.AddCommand(hookInstallCmd) hookCmd.AddCommand(hookUninstallCmd) } func hookPath() (string, error) { gitDir := ".git" info, err := os.Stat(gitDir) if err != nil || !info.IsDir() { return "", fmt.Errorf("not a git repository (no .git/ in current directory)") } return filepath.Join(gitDir, "hooks", "pre-commit"), nil } func runHookInstall(cmd *cobra.Command, args []string) error { target, err := hookPath() if err != nil { return err } if _, err := os.Stat(target); err == nil { existing, _ := os.ReadFile(target) if strings.Contains(string(existing), hookMarker) { // Already ours — overwrite silently to update script. } else if !hookForce { return fmt.Errorf("pre-commit hook already exists at %s (use --force to overwrite; a .bak backup will be kept)", target) } else { backup := target + ".bak." + time.Now().Format("20060102150405") if err := os.Rename(target, backup); err != nil { return fmt.Errorf("backing up existing hook: %w", err) } fmt.Fprintf(cmd.OutOrStdout(), "Backed up existing hook to %s\n", backup) } } if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return fmt.Errorf("creating hooks dir: %w", err) } if err := os.WriteFile(target, []byte(hookScript), 0o755); err != nil { return fmt.Errorf("writing hook: %w", err) } fmt.Fprintf(cmd.OutOrStdout(), "Installed pre-commit hook at %s\n", target) return nil } func runHookUninstall(cmd *cobra.Command, args []string) error { target, err := hookPath() if err != nil { return err } data, err := os.ReadFile(target) if err != nil { if os.IsNotExist(err) { fmt.Fprintln(cmd.OutOrStdout(), "No pre-commit hook to remove.") return nil } return fmt.Errorf("reading hook: %w", err) } if !strings.Contains(string(data), hookMarker) && !hookForce { return fmt.Errorf("pre-commit at %s is not KeyHunter-managed (use --force to remove anyway)", target) } if err := os.Remove(target); err != nil { return fmt.Errorf("removing hook: %w", err) } fmt.Fprintf(cmd.OutOrStdout(), "Removed pre-commit hook at %s\n", target) return nil } ``` hookCmd is already registered in cmd/root.go via `rootCmd.AddCommand(hookCmd)`. Removing the stub declaration lets this new `var hookCmd` take over the same identifier. Verify cmd/root.go still compiles. Create cmd/hook_test.go: - Each test uses t.TempDir(), chdirs into it (t.Chdir(tmp) in Go 1.24+, else os.Chdir with cleanup), creates .git/ subdirectory. - TestHookInstall_FreshRepo: run install, assert .git/hooks/pre-commit exists, is 0o755 executable, and contains the hookMarker string. - TestHookInstall_NotAGitRepo: no .git/ dir → install returns error containing "not a git repository". - TestHookInstall_ExistingNonKeyhunterRefuses: pre-create pre-commit with "# my hook"; install without --force returns error; file unchanged. - TestHookInstall_ForceBackupsExisting: same as above with --force=true; assert original moved to *.bak.*, new hook installed. - TestHookInstall_ExistingKeyhunterOverwrites: pre-create pre-commit containing the marker; install succeeds without --force, file updated. - TestHookUninstall_RemovesKeyhunter: install then uninstall → file gone. - TestHookUninstall_RefusesForeign: pre-create foreign pre-commit; uninstall without --force errors; file unchanged. - TestHookUninstall_Force: same with --force → file removed. - TestHookUninstall_Missing: no pre-commit → succeeds with "No pre-commit hook to remove." output. - TestHookScript_ContainsRequired: the embedded hookScript variable contains "keyhunter scan" and "git diff --cached". cd /home/salva/Documents/apikey && go build ./... && go test ./cmd/... -run Hook -v - cmd/hook.go implements install/uninstall - hook_script.sh embedded via go:embed - All hook tests pass - Stub removed from cmd/stubs.go - go build succeeds In a scratch git repo: ``` keyhunter hook install # creates .git/hooks/pre-commit cat .git/hooks/pre-commit # shows embedded script git add file-with-key.txt && git commit -m test # should block keyhunter hook uninstall # removes hook ``` CICD-01 delivered. Hook lifecycle (install → trigger on commit → uninstall) works on a real git repo; tests cover edge cases (non-repo, existing hook, force flag, missing marker). After completion, create `.planning/phases/07-import-cicd/07-05-SUMMARY.md`.