pipe in task

This commit is contained in:
booleanmaybe 2026-02-12 22:42:26 -05:00
parent fe2a849d3f
commit f3fed538e5
3 changed files with 226 additions and 5 deletions

115
internal/pipe/create.go Normal file
View file

@ -0,0 +1,115 @@
package pipe
import (
"fmt"
"io"
"log/slog"
"os"
"strings"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/internal/bootstrap"
)
// IsPipedInput reports whether stdin is connected to a pipe or redirected file
// rather than a terminal (character device).
func IsPipedInput() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice == 0
}
// HasPositionalArgs reports whether args contains any non-flag positional arguments.
// It skips known flag-value pairs (e.g. --log-level debug) so that only real
// positional arguments like file paths, "-", "init", etc. are detected.
func HasPositionalArgs(args []string) bool {
skipNext := false
for _, arg := range args {
if skipNext {
skipNext = false
continue
}
if arg == "--" {
return true // everything after "--" is positional
}
// Bare "-" means "read stdin for the viewer", treat as positional
if arg == "-" {
return true
}
if strings.HasPrefix(arg, "-") {
if arg == "--log-level" {
skipNext = true
}
continue
}
return true
}
return false
}
// CreateTaskFromReader reads piped input, parses it into title/description,
// and creates a new tiki task. Returns the task ID (e.g. "TIKI-ABC123").
func CreateTaskFromReader(r io.Reader) (string, error) {
// Suppress info/debug logs for the non-interactive pipe path.
// The pipe path bypasses bootstrap (which normally configures logging),
// so the default slog handler would write INFO+ messages to stderr.
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelError,
})))
data, err := io.ReadAll(r)
if err != nil {
return "", fmt.Errorf("read input: %w", err)
}
input := strings.TrimSpace(string(data))
if input == "" {
return "", fmt.Errorf("empty input: title is required")
}
title, description := parseInput(input)
if err := bootstrap.EnsureGitRepo(); err != nil {
return "", err
}
if !config.IsProjectInitialized() {
return "", fmt.Errorf("project not initialized: run 'tiki init' first")
}
tikiStore, _, err := bootstrap.InitStores()
if err != nil {
return "", fmt.Errorf("initialize store: %w", err)
}
task, err := tikiStore.NewTaskTemplate()
if err != nil {
return "", fmt.Errorf("create task template: %w", err)
}
task.Title = title
task.Description = description
if errs := task.Validate(); errs.HasErrors() {
return "", fmt.Errorf("validation failed: %s", errs.Error())
}
if err := tikiStore.CreateTask(task); err != nil {
return "", fmt.Errorf("create task: %w", err)
}
return task.ID, nil
}
// parseInput splits piped text into title and description.
// Single line: entire input becomes the title, no description.
// Multi-line: first line is the title, everything after is the description (trimmed).
func parseInput(input string) (title, description string) {
first, rest, found := strings.Cut(input, "\n")
if !found {
return strings.TrimSpace(input), ""
}
return strings.TrimSpace(first), strings.TrimSpace(rest)
}

View file

@ -0,0 +1,93 @@
package pipe
import "testing"
func TestParseInput(t *testing.T) {
tests := []struct {
name string
input string
wantTitle string
wantDesc string
}{
{
name: "single line",
input: "Fix the login bug",
wantTitle: "Fix the login bug",
wantDesc: "",
},
{
name: "multi-line with blank separator",
input: "Bug title\n\nDetailed description here",
wantTitle: "Bug title",
wantDesc: "Detailed description here",
},
{
name: "multi-line without blank separator",
input: "Bug title\nDescription starts immediately",
wantTitle: "Bug title",
wantDesc: "Description starts immediately",
},
{
name: "leading and trailing whitespace trimmed",
input: " Fix the bug \n\n Some details ",
wantTitle: "Fix the bug",
wantDesc: "Some details",
},
{
name: "multi-line description",
input: "Title\n\nLine 1\nLine 2\nLine 3",
wantTitle: "Title",
wantDesc: "Line 1\nLine 2\nLine 3",
},
{
name: "title with trailing newline only",
input: "Just a title\n",
wantTitle: "Just a title",
wantDesc: "",
},
{
name: "multiple blank lines between title and description",
input: "Title\n\n\n\nDescription after gaps",
wantTitle: "Title",
wantDesc: "Description after gaps",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotTitle, gotDesc := parseInput(tt.input)
if gotTitle != tt.wantTitle {
t.Errorf("title = %q, want %q", gotTitle, tt.wantTitle)
}
if gotDesc != tt.wantDesc {
t.Errorf("description = %q, want %q", gotDesc, tt.wantDesc)
}
})
}
}
func TestHasPositionalArgs(t *testing.T) {
tests := []struct {
name string
args []string
want bool
}{
{name: "empty args", args: nil, want: false},
{name: "flags only", args: []string{"--version"}, want: false},
{name: "log-level flag with value", args: []string{"--log-level", "debug"}, want: false},
{name: "log-level=value", args: []string{"--log-level=debug"}, want: false},
{name: "positional file", args: []string{"file.md"}, want: true},
{name: "init command", args: []string{"init"}, want: true},
{name: "stdin dash", args: []string{"-"}, want: true},
{name: "flag then positional", args: []string{"--log-level", "debug", "file.md"}, want: true},
{name: "double dash", args: []string{"--", "file.md"}, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := HasPositionalArgs(tt.args); got != tt.want {
t.Errorf("HasPositionalArgs(%v) = %v, want %v", tt.args, got, tt.want)
}
})
}
}

23
main.go
View file

@ -10,6 +10,7 @@ import (
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/internal/app"
"github.com/boolean-maybe/tiki/internal/bootstrap"
"github.com/boolean-maybe/tiki/internal/pipe"
"github.com/boolean-maybe/tiki/internal/viewer"
"github.com/boolean-maybe/tiki/util/sysinfo"
)
@ -44,6 +45,17 @@ func main() {
os.Exit(1)
}
// Handle piped stdin: create a task and exit without launching TUI
if pipe.IsPipedInput() && !pipe.HasPositionalArgs(os.Args[1:]) {
taskID, err := pipe.CreateTaskFromReader(os.Stdin)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Println(taskID)
return
}
// Handle init command
initRequested := len(os.Args) > 1 && os.Args[1] == "init"
@ -124,11 +136,12 @@ func printUsage() {
fmt.Print(`tiki - Terminal-based task and documentation management
Usage:
tiki Launch TUI in initialized repo
tiki init Initialize project in current git repo
tiki file.md/URL View markdown file
tiki sysinfo Display system information
tiki --version Show version
tiki Launch TUI in initialized repo
tiki init Initialize project in current git repo
tiki file.md/URL View markdown file
echo "Title" | tiki Create task from piped input
tiki sysinfo Display system information
tiki --version Show version
Run 'tiki init' to initialize this repository.
`)