mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
pipe in task
This commit is contained in:
parent
fe2a849d3f
commit
f3fed538e5
3 changed files with 226 additions and 5 deletions
115
internal/pipe/create.go
Normal file
115
internal/pipe/create.go
Normal 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)
|
||||
}
|
||||
93
internal/pipe/create_test.go
Normal file
93
internal/pipe/create_test.go
Normal 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
23
main.go
|
|
@ -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.
|
||||
`)
|
||||
|
|
|
|||
Loading…
Reference in a new issue