From 3e3d92f3b6f720929e5cb6e504f6cc6fdb6a48e1 Mon Sep 17 00:00:00 2001 From: rwrife Date: Fri, 10 Jul 2026 20:44:19 +0000 Subject: [PATCH] Add headless scratch capture: sp new --stdin/--content/--from-file (#27) --- README.md | 28 ++++++ internal/cli/new.go | 93 +++++++++++++++++++- internal/cli/new_stdin_test.go | 150 +++++++++++++++++++++++++++++++++ internal/store/lifecycle.go | 12 +++ 4 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 internal/cli/new_stdin_test.go diff --git a/README.md b/README.md index 07c0e4b..419f530 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,34 @@ sp new draft --no-edit # create it but don't open an editor nothing is lost. - Defaults: extension **md**, TTL **7d**. +#### Headless capture (pipes, agents, generated output) + +The interactive editor is great for humans and useless in a pipeline. Seed a +scratch's content directly and skip `$EDITOR` entirely — perfect for parking +throwaway logs, API responses, or AI-agent temp output in the reaped store +instead of littering your repo: + +```bash +pytest -q 2>&1 | sp new failing-tests --stdin --tag ci # capture from a pipe +sp new note --stdin <<'EOF' # capture from a heredoc +remember to revoke that token +EOF +curl -s https://api.example.com/thing | sp new resp --ext json --stdin +sp new todo --content "ship the thing" # one-liner, no pipe +sp new seed --from-file ./scratch.txt # seed from a file +``` + +- `--stdin`, `--content`, and `--from-file` each suppress `$EDITOR`. Pick + exactly one per invocation (combining them is an error). +- All the usual flags apply: `--ttl`, `--ext`, `--tag`. +- Output still leads with the stable `created scratch ` anchor, so scripts + and tests keep working. +- Captured content is run through the **secret tripwire** just like + editor-created scratches, so `sp ls` shows the 🔑 marker and `sp promote` + guards a piped-in credential. +- `--content ""` deliberately creates an empty scratch. `--stdin` on a bare TTY + with nothing piped in refuses rather than hanging. + ### `sp ls` Lists live scratches: id, name, age, time-to-expiry, tags, and size. diff --git a/internal/cli/new.go b/internal/cli/new.go index ffd6bec..aa7c3ba 100644 --- a/internal/cli/new.go +++ b/internal/cli/new.go @@ -3,6 +3,7 @@ package cli import ( "errors" "fmt" + "io" "os" "os/exec" "regexp" @@ -17,10 +18,14 @@ import ( // newFlags holds the parsed `sp new` options. type newFlags struct { - ttl string - ext string - tags []string - noEdit bool + ttl string + ext string + tags []string + noEdit bool + stdin bool + content string + fromFile string + contentSet bool } // nonSlugChars matches runs of characters we don't want in an auto-generated @@ -55,15 +60,81 @@ func newNewCommand() *cobra.Command { cmd.Flags().StringArrayVar(&f.tags, "tag", nil, "tag to attach; may be repeated") cmd.Flags().BoolVar(&f.noEdit, "no-edit", false, "create the scratch without opening $EDITOR") + // Headless capture flags (issue #27): seed content programmatically instead + // of shelling out to $EDITOR. Any of these suppresses the editor so + // scratchpatch can act as a sink for piped output, generated snippets, or + // AI-agent temp files. Interactive `sp new` is unchanged when none are set. + cmd.Flags().BoolVar(&f.stdin, "stdin", false, "read scratch content from standard input (no $EDITOR)") + cmd.Flags().StringVar(&f.content, "content", "", "use this string as the scratch content (no $EDITOR)") + cmd.Flags().StringVar(&f.fromFile, "from-file", "", "seed the scratch from an existing file (no $EDITOR)") + + cmd.PreRunE = func(c *cobra.Command, _ []string) error { + f.contentSet = c.Flags().Changed("content") + return nil + } + return cmd } +// captureContent gathers headless content from --stdin/--content/--from-file. +// It returns the bytes, whether a headless source was requested at all, and an +// error for conflicting sources or read failures. Exactly one source may be +// used per invocation; --content "" is a deliberate empty scratch. +func captureContent(cmd *cobra.Command, f newFlags) (data []byte, headless bool, err error) { + sources := 0 + if f.stdin { + sources++ + } + if f.contentSet { + sources++ + } + if strings.TrimSpace(f.fromFile) != "" { + sources++ + } + if sources == 0 { + return nil, false, nil + } + if sources > 1 { + return nil, true, errors.New("choose only one of --stdin, --content, or --from-file") + } + + switch { + case f.stdin: + in := cmd.InOrStdin() + // Guard against a hang when --stdin is used on a bare TTY with nothing + // piped in: refuse rather than block the terminal forever. + if file, ok := in.(*os.File); ok && isTerminal(file) { + return nil, true, errors.New("--stdin expects piped input; nothing is attached to stdin") + } + b, rerr := io.ReadAll(in) + if rerr != nil { + return nil, true, fmt.Errorf("read stdin: %w", rerr) + } + return b, true, nil + case f.contentSet: + return []byte(f.content), true, nil + default: + b, rerr := os.ReadFile(f.fromFile) + if rerr != nil { + return nil, true, fmt.Errorf("read --from-file: %w", rerr) + } + return b, true, nil + } +} + func runNew(cmd *cobra.Command, name string, f newFlags) error { st, err := store.Open() if err != nil { return err } + // Gather any headless content up front so a bad source (conflicting flags, + // unreadable file, TTY with no pipe) fails before we create a scratch. + captured, headless, err := captureContent(cmd, f) + if err != nil { + return err + } + // Parse the human TTL up front so a bad value fails before we create // anything. An empty flag means "use the configured default", which Create // applies when it sees a zero duration. @@ -95,6 +166,20 @@ func runNew(cmd *cobra.Command, name string, f newFlags) error { out := cmd.OutOrStdout() + // Headless capture path (issue #27): seed content from stdin/--content/ + // --from-file, skip $EDITOR entirely, and refresh size. Content is written + // to disk, so `sp ls`'s live secret scan flags piped-in credentials (🔑) + // and `sp promote` guards them, exactly like editor-created scratches. + if headless { + if touched, werr := st.WriteContent(sc, captured); werr == nil { + sc = touched + } else { + return werr + } + fmt.Fprintf(out, "created scratch %s (%s) — %s\n", sc.ID, sc.Name, lifespanNote(sc.ExpiresAt, time.Now())) + return nil + } + if !f.noEdit { if err := openInEditor(cmd, path); err != nil { // A missing/failed editor shouldn't lose the scratch — it's diff --git a/internal/cli/new_stdin_test.go b/internal/cli/new_stdin_test.go new file mode 100644 index 0000000..983fc84 --- /dev/null +++ b/internal/cli/new_stdin_test.go @@ -0,0 +1,150 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// runNewWithStdin executes the root command with a custom stdin (an *os.File +// pipe, so the --stdin TTY guard sees a non-terminal reader) against an +// isolated store. It returns combined stdout+stderr. EDITOR is set to a binary +// that fails loudly if invoked, so tests prove the editor is NOT launched on +// the headless path. +func runNewWithStdin(t *testing.T, stdin string, args ...string) (string, string, error) { + t.Helper() + home := t.TempDir() + t.Setenv("SCRATCHPATCH_HOME", home) + // A bogus editor: if the headless path ever shells out, the run visibly + // changes (a "created at" fallback line), which the assertions catch. + t.Setenv("EDITOR", "this-editor-should-never-run") + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + go func() { + _, _ = w.WriteString(stdin) + _ = w.Close() + }() + + root := NewRootCommand() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetIn(r) + root.SetArgs(args) + execErr := root.Execute() + _ = r.Close() + return out.String(), home, execErr +} + +func TestNewStdinCapturesContentWithoutEditor(t *testing.T) { + out, home, err := runNewWithStdin(t, "remember to revoke that token\n", "new", "note", "--stdin", "--ext", "txt", "--tag", "ci") + if err != nil { + t.Fatalf("new --stdin: %v (out=%s)", err, out) + } + // Stable anchor scripts/tests rely on. + if !strings.Contains(out, "created scratch") { + t.Errorf("missing stable creation anchor; got %q", out) + } + // The editor must NOT have run: no fallback "created at" line, no editor err. + if strings.Contains(out, "created at") || strings.Contains(out, "this-editor-should-never-run") { + t.Errorf("editor should not be invoked on --stdin path; got %q", out) + } + + matches, _ := filepath.Glob(filepath.Join(home, "scratches", "*.txt")) + if len(matches) != 1 { + t.Fatalf("expected one .txt scratch, found %v", matches) + } + body, _ := os.ReadFile(matches[0]) + if string(body) != "remember to revoke that token\n" { + t.Errorf("stdin content not persisted; got %q", string(body)) + } +} + +func TestNewContentFlag(t *testing.T) { + out, home, err := runNewWithStdin(t, "", "new", "one-liner", "--content", "quick note", "--ext", "md") + if err != nil { + t.Fatalf("new --content: %v (out=%s)", err, out) + } + matches, _ := filepath.Glob(filepath.Join(home, "scratches", "*.md")) + if len(matches) != 1 { + t.Fatalf("expected one .md scratch, found %v", matches) + } + body, _ := os.ReadFile(matches[0]) + if string(body) != "quick note" { + t.Errorf("content flag not persisted; got %q", string(body)) + } +} + +func TestNewContentEmptyIsDeliberate(t *testing.T) { + // --content "" must create an empty scratch, not fall through to $EDITOR. + out, home, err := runNewWithStdin(t, "", "new", "empty", "--content", "") + if err != nil { + t.Fatalf("new --content '': %v (out=%s)", err, out) + } + if strings.Contains(out, "created at") { + t.Errorf("empty --content should not invoke editor; got %q", out) + } + matches, _ := filepath.Glob(filepath.Join(home, "scratches", "*")) + if len(matches) != 1 { + t.Fatalf("expected one scratch, found %v", matches) + } + if info, _ := os.Stat(matches[0]); info.Size() != 0 { + t.Errorf("expected empty scratch, size=%d", info.Size()) + } +} + +func TestNewFromFile(t *testing.T) { + src := filepath.Join(t.TempDir(), "seed.json") + if err := os.WriteFile(src, []byte(`{"ok":true}`), 0o600); err != nil { + t.Fatalf("seed file: %v", err) + } + out, home, err := runNewWithStdin(t, "", "new", "resp", "--from-file", src, "--ext", "json") + if err != nil { + t.Fatalf("new --from-file: %v (out=%s)", err, out) + } + matches, _ := filepath.Glob(filepath.Join(home, "scratches", "*.json")) + if len(matches) != 1 { + t.Fatalf("expected one .json scratch, found %v", matches) + } + body, _ := os.ReadFile(matches[0]) + if string(body) != `{"ok":true}` { + t.Errorf("from-file content not persisted; got %q", string(body)) + } +} + +func TestNewConflictingSourcesRejected(t *testing.T) { + out, _, err := runNewWithStdin(t, "x", "new", "bad", "--stdin", "--content", "y") + if err == nil { + t.Fatalf("expected error for conflicting sources, got none (out=%s)", out) + } + if !strings.Contains(err.Error(), "only one of") { + t.Errorf("expected conflicting-source error, got %v", err) + } +} + +func TestNewStdinSecretIsScannedByLs(t *testing.T) { + // A piped-in secret must be flagged (🔑) by `sp ls`, same as editor scratches. + secretBody := "AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLEKEYDATA1234567890ab\n" + out, home, err := runNewWithStdin(t, secretBody, "new", "leaky", "--stdin") + if err != nil { + t.Fatalf("new --stdin: %v (out=%s)", err, out) + } + _ = home + + root := NewRootCommand() + var lsOut bytes.Buffer + root.SetOut(&lsOut) + root.SetErr(&lsOut) + root.SetArgs([]string{"ls"}) + if err := root.Execute(); err != nil { + t.Fatalf("ls: %v", err) + } + if !strings.Contains(lsOut.String(), "🔑") { + t.Errorf("piped-in secret should be flagged by ls; got %q", lsOut.String()) + } +} diff --git a/internal/store/lifecycle.go b/internal/store/lifecycle.go index 93c9e43..51c03f1 100644 --- a/internal/store/lifecycle.go +++ b/internal/store/lifecycle.go @@ -233,6 +233,18 @@ func (s *Store) ReadContent(sc index.Scratch) ([]byte, error) { return b, nil } +// WriteContent overwrites a live scratch's content file with data and refreshes +// the recorded size in the index. It writes to the live path (scratches/) and +// is used by headless capture (`sp new --stdin`) to seed content without an +// editor round-trip. The returned Scratch carries the updated size. +func (s *Store) WriteContent(sc index.Scratch, data []byte) (index.Scratch, error) { + path := s.LivePath(sc) + if err := os.WriteFile(path, data, filePerm); err != nil { + return index.Scratch{}, fmt.Errorf("write scratch %s content: %w", sc.ID, err) + } + return s.Touch(sc.ID) +} + // moveFile relocates src to dst. It tries an atomic rename first (the common // case: same filesystem) and falls back to a copy+remove when rename fails with // a cross-device error, so the store still works if scratches/ and morgue/ ever