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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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.
Expand Down
93 changes: 89 additions & 4 deletions internal/cli/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"regexp"
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
150 changes: 150 additions & 0 deletions internal/cli/new_stdin_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
12 changes: 12 additions & 0 deletions internal/store/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading