From 7169f53c20a2c5f63f7607caff4993e494a7ddc6 Mon Sep 17 00:00:00 2001 From: booleanmaybe Date: Wed, 15 Apr 2026 21:01:00 -0400 Subject: [PATCH] config reset --- .doc/doki/doc/command-line.md | 118 ++++++++++++++++ .doc/doki/doc/index.md | 3 +- cmd_config.go | 125 +++++++++++++++++ cmd_config_test.go | 117 ++++++++++++++++ config/paths.go | 3 + config/reset.go | 154 +++++++++++++++++++++ config/reset_test.go | 253 ++++++++++++++++++++++++++++++++++ config/system.go | 5 + main.go | 14 +- 9 files changed, 789 insertions(+), 3 deletions(-) create mode 100644 .doc/doki/doc/command-line.md create mode 100644 cmd_config.go create mode 100644 cmd_config_test.go create mode 100644 config/reset.go create mode 100644 config/reset_test.go diff --git a/.doc/doki/doc/command-line.md b/.doc/doki/doc/command-line.md new file mode 100644 index 0000000..e2c2f2a --- /dev/null +++ b/.doc/doki/doc/command-line.md @@ -0,0 +1,118 @@ +# Command line options + +## Usage + +``` +tiki [command] [options] +``` + +Running `tiki` with no arguments launches the TUI in an initialized project. + +## Commands + +### init + +Initialize a tiki project in the current git repository. Creates the `.doc/tiki/` directory structure for task storage. + +```bash +tiki init +``` + +### exec + +Execute a [ruki](ruki/index.md) query and exit. Requires an initialized project. + +```bash +tiki exec '' +``` + +Examples: +```bash +tiki exec 'select where status = "ready" order by priority' +tiki exec 'update where id = "TIKI-ABC123" set status="done"' +``` + +### config + +Manage configuration files. + +#### config reset + +Reset configuration files to their defaults. + +```bash +tiki config reset [target] --scope +``` + +**Targets** (omit to reset all three files): +- `config` — config.yaml +- `workflow` — workflow.yaml +- `new` — new.md (task template) + +**Scopes** (required): +- `--global` — user config directory +- `--local` — project config directory (`.doc/`) +- `--current` — current working directory + +For `--global`, workflow.yaml and new.md are overwritten with embedded defaults. config.yaml is deleted (built-in defaults take over). + +For `--local` and `--current`, files are deleted so the next tier in the [precedence chain](config.md#precedence-and-merging) takes effect. + +```bash +# restore all global config to defaults +tiki config reset --global + +# remove project workflow overrides (falls back to global) +tiki config reset workflow --local + +# remove cwd config override +tiki config reset config --current +``` + +### demo + +Clone the demo project and launch the TUI. If the `tiki-demo` directory already exists it is reused. + +```bash +tiki demo +``` + +### sysinfo + +Display system and terminal environment information useful for troubleshooting. + +```bash +tiki sysinfo +``` + +## Markdown viewer + +`tiki` doubles as a standalone markdown and image viewer. Pass a file path or URL as the first argument. + +```bash +tiki file.md +tiki https://github.com/user/repo/blob/main/README.md +tiki image.png +echo "# Hello" | tiki - +``` + +See [Markdown viewer](markdown-viewer.md) for navigation and keybindings. + +## Piped input + +When stdin is piped and no positional arguments are given, tiki creates a task from the input. The first line becomes the title; the rest becomes the description. + +```bash +echo "Fix the login bug" | tiki +tiki < bug-report.md +``` + +See [Quick capture](quick-capture.md) for more examples. + +## Flags + +| Flag | Description | +|---|---| +| `--help`, `-h` | Show usage information | +| `--version`, `-v` | Show version, commit, and build date | +| `--log-level ` | Set log level: `debug`, `info`, `warn`, `error` | diff --git a/.doc/doki/doc/index.md b/.doc/doki/doc/index.md index b285c44..0348894 100644 --- a/.doc/doki/doc/index.md +++ b/.doc/doki/doc/index.md @@ -1,7 +1,8 @@ # Documentation - [Quick start](quick-start.md) -- [Configuration](config.md) - [Installation](install.md) +- [Configuration](config.md) +- [Command line options](command-line.md) - [Markdown viewer](markdown-viewer.md) - [Image support](image-requirements.md) - [Custom fields](custom-fields.md) diff --git a/cmd_config.go b/cmd_config.go new file mode 100644 index 0000000..f3bc786 --- /dev/null +++ b/cmd_config.go @@ -0,0 +1,125 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/boolean-maybe/tiki/config" +) + +// runConfig dispatches config subcommands. Returns an exit code. +func runConfig(args []string) int { + if len(args) == 0 { + printConfigUsage() + return exitUsage + } + switch args[0] { + case "reset": + return runConfigReset(args[1:]) + case "--help", "-h": + printConfigUsage() + return exitOK + default: + _, _ = fmt.Fprintf(os.Stderr, "unknown config command: %s\n", args[0]) + printConfigUsage() + return exitUsage + } +} + +// runConfigReset implements `tiki config reset [target] --scope`. +func runConfigReset(args []string) int { + target, scope, err := parseConfigResetArgs(args) + if errors.Is(err, errHelpRequested) { + printConfigResetUsage() + return exitOK + } + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "error:", err) + printConfigResetUsage() + return exitUsage + } + + affected, err := config.ResetConfig(scope, target) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "error:", err) + return exitInternal + } + + if len(affected) == 0 { + fmt.Println("nothing to reset") + return exitOK + } + for _, path := range affected { + fmt.Println("reset", path) + } + return exitOK +} + +// parseConfigResetArgs parses arguments for `tiki config reset`. +func parseConfigResetArgs(args []string) (config.ResetTarget, config.ResetScope, error) { + var targetStr string + var scopeStr string + + for _, arg := range args { + switch arg { + case "--help", "-h": + return "", "", errHelpRequested + case "--global", "--local", "--current": + if scopeStr != "" { + return "", "", fmt.Errorf("only one scope allowed: already have --%s", scopeStr) + } + scopeStr = strings.TrimPrefix(arg, "--") + default: + if strings.HasPrefix(arg, "--") { + return "", "", fmt.Errorf("unknown flag: %s", arg) + } + if targetStr != "" { + return "", "", fmt.Errorf("multiple targets specified: %q and %q", targetStr, arg) + } + targetStr = arg + } + } + + if scopeStr == "" { + return "", "", fmt.Errorf("scope required: --global, --local, or --current") + } + + target := config.ResetTarget(targetStr) + switch target { + case config.TargetAll, config.TargetConfig, config.TargetWorkflow, config.TargetNew: + // valid + default: + return "", "", fmt.Errorf("unknown target: %q (use config, workflow, or new)", targetStr) + } + + return target, config.ResetScope(scopeStr), nil +} + +func printConfigUsage() { + fmt.Print(`Usage: tiki config + +Commands: + reset [target] --scope Reset config files to defaults + +Run 'tiki config reset --help' for details. +`) +} + +func printConfigResetUsage() { + fmt.Print(`Usage: tiki config reset [target] --scope + +Reset configuration files to their defaults. + +Targets (omit to reset all): + config Reset config.yaml + workflow Reset workflow.yaml + new Reset new.md (task template) + +Scopes (required): + --global User config directory + --local Project config directory (.doc/) + --current Current working directory +`) +} diff --git a/cmd_config_test.go b/cmd_config_test.go new file mode 100644 index 0000000..5c1aa1c --- /dev/null +++ b/cmd_config_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "errors" + "strings" + "testing" + + "github.com/boolean-maybe/tiki/config" +) + +func TestParseConfigResetArgs(t *testing.T) { + tests := []struct { + name string + args []string + target config.ResetTarget + scope config.ResetScope + wantErr error // sentinel match (nil = no error) + errSubstr string // substring match for non-sentinel errors + }{ + { + name: "global all", + args: []string{"--global"}, + target: config.TargetAll, + scope: config.ScopeGlobal, + }, + { + name: "local workflow", + args: []string{"workflow", "--local"}, + target: config.TargetWorkflow, + scope: config.ScopeLocal, + }, + { + name: "current config", + args: []string{"--current", "config"}, + target: config.TargetConfig, + scope: config.ScopeCurrent, + }, + { + name: "global new", + args: []string{"new", "--global"}, + target: config.TargetNew, + scope: config.ScopeGlobal, + }, + { + name: "help flag", + args: []string{"--help"}, + wantErr: errHelpRequested, + }, + { + name: "short help flag", + args: []string{"-h"}, + wantErr: errHelpRequested, + }, + { + name: "missing scope", + args: []string{"config"}, + errSubstr: "scope required", + }, + { + name: "unknown flag", + args: []string{"--verbose"}, + errSubstr: "unknown flag", + }, + { + name: "unknown target", + args: []string{"themes", "--global"}, + errSubstr: "unknown target", + }, + { + name: "multiple targets", + args: []string{"config", "workflow", "--global"}, + errSubstr: "multiple targets", + }, + { + name: "duplicate scopes", + args: []string{"--global", "--local"}, + errSubstr: "only one scope allowed", + }, + { + name: "no args", + args: nil, + errSubstr: "scope required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target, scope, err := parseConfigResetArgs(tt.args) + + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("expected error %v, got %v", tt.wantErr, err) + } + return + } + if tt.errSubstr != "" { + if err == nil { + t.Fatal("expected error, got nil") + } + if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) { + t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if target != tt.target { + t.Errorf("target = %q, want %q", target, tt.target) + } + if scope != tt.scope { + t.Errorf("scope = %q, want %q", scope, tt.scope) + } + }) + } +} diff --git a/config/paths.go b/config/paths.go index 95e86c6..58551d1 100644 --- a/config/paths.go +++ b/config/paths.go @@ -324,6 +324,9 @@ func GetUserConfigWorkflowFile() string { return mustGetPathManager().UserConfigWorkflowFile() } +// configFilename is the default name for the configuration file +const configFilename = "config.yaml" + // defaultWorkflowFilename is the default name for the workflow configuration file const defaultWorkflowFilename = "workflow.yaml" diff --git a/config/reset.go b/config/reset.go new file mode 100644 index 0000000..a557b8a --- /dev/null +++ b/config/reset.go @@ -0,0 +1,154 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// ResetScope identifies which config tier to reset. +type ResetScope string + +// ResetTarget identifies which config file to reset. +type ResetTarget string + +const ( + ScopeGlobal ResetScope = "global" + ScopeLocal ResetScope = "local" + ScopeCurrent ResetScope = "current" + + TargetAll ResetTarget = "" + TargetConfig ResetTarget = "config" + TargetWorkflow ResetTarget = "workflow" + TargetNew ResetTarget = "new" +) + +// resetEntry pairs a filename with the default content to restore for global scope. +// If defaultContent is empty, the file is always deleted (no embedded default exists). +type resetEntry struct { + filename string + defaultContent string +} + +var resetEntries = []resetEntry{ + // TODO: embed a default config.yaml once one exists; until then, global reset deletes the file + {filename: configFilename, defaultContent: ""}, + {filename: defaultWorkflowFilename, defaultContent: defaultWorkflowYAML}, + {filename: templateFilename, defaultContent: defaultNewTaskTemplate}, +} + +// ResetConfig resets configuration files for the given scope and target. +// Returns the list of file paths that were actually modified or deleted. +func ResetConfig(scope ResetScope, target ResetTarget) ([]string, error) { + dir, err := resolveDir(scope) + if err != nil { + return nil, err + } + + entries, err := filterEntries(target) + if err != nil { + return nil, err + } + + var affected []string + for _, e := range entries { + path := filepath.Join(dir, e.filename) + changed, err := resetFile(path, scope, e.defaultContent) + if err != nil { + return affected, fmt.Errorf("reset %s: %w", e.filename, err) + } + if changed { + affected = append(affected, path) + } + } + + return affected, nil +} + +// resolveDir returns the directory path for the given scope. +func resolveDir(scope ResetScope) (string, error) { + switch scope { + case ScopeGlobal: + return GetConfigDir(), nil + case ScopeLocal: + if !IsProjectInitialized() { + return "", fmt.Errorf("not in an initialized tiki project (run 'tiki init' first)") + } + return GetProjectConfigDir(), nil + case ScopeCurrent: + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } + return cwd, nil + default: + return "", fmt.Errorf("unknown scope: %s", scope) + } +} + +// filterEntries returns the reset entries matching the target. +func filterEntries(target ResetTarget) ([]resetEntry, error) { + if target == TargetAll { + return resetEntries, nil + } + var filename string + switch target { + case TargetConfig: + filename = configFilename + case TargetWorkflow: + filename = defaultWorkflowFilename + case TargetNew: + filename = templateFilename + default: + return nil, fmt.Errorf("unknown reset target: %q", target) + } + for _, e := range resetEntries { + if e.filename == filename { + return []resetEntry{e}, nil + } + } + return nil, fmt.Errorf("no reset entry for target %q", target) +} + +// resetFile either overwrites or deletes a file depending on scope and available defaults. +// For global scope with non-empty default content, the file is overwritten. +// Otherwise the file is deleted. Returns true if the file was changed. +func resetFile(path string, scope ResetScope, defaultContent string) (bool, error) { + if scope == ScopeGlobal && defaultContent != "" { + return writeDefault(path, defaultContent) + } + return deleteIfExists(path) +} + +// writeDefault writes defaultContent to path, creating parent dirs if needed. +// Returns true if the file was actually changed (skips write when content already matches). +func writeDefault(path string, content string) (bool, error) { + existing, err := os.ReadFile(path) + if err == nil && string(existing) == content { + return false, nil + } + + dir := filepath.Dir(path) + //nolint:gosec // G301: 0755 is appropriate for config directory + if err := os.MkdirAll(dir, 0755); err != nil { + return false, fmt.Errorf("create directory %s: %w", dir, err) + } + //nolint:gosec // G306: 0644 is appropriate for config file + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return false, err + } + return true, nil +} + +// deleteIfExists removes a file if it exists. Returns true if the file was deleted. +func deleteIfExists(path string) (bool, error) { + err := os.Remove(path) + if err == nil { + return true, nil + } + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err +} diff --git a/config/reset_test.go b/config/reset_test.go new file mode 100644 index 0000000..5c89c40 --- /dev/null +++ b/config/reset_test.go @@ -0,0 +1,253 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// setupResetTest creates a temp config dir, sets XDG_CONFIG_HOME, and resets +// the path manager so GetConfigDir() points to the temp dir. +// Returns the tiki config dir (e.g. /tiki). +func setupResetTest(t *testing.T) string { + t.Helper() + xdgDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgDir) + ResetPathManager() + t.Cleanup(ResetPathManager) + + tikiDir := filepath.Join(xdgDir, "tiki") + if err := os.MkdirAll(tikiDir, 0750); err != nil { + t.Fatal(err) + } + return tikiDir +} + +// writeFile is a test helper that writes content to path. +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func TestResetConfig_GlobalAll(t *testing.T) { + tikiDir := setupResetTest(t) + + // seed all three files with custom content + writeFile(t, filepath.Join(tikiDir, "config.yaml"), "logging:\n level: debug\n") + writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), "custom: true\n") + writeFile(t, filepath.Join(tikiDir, "new.md"), "custom template\n") + + affected, err := ResetConfig(ScopeGlobal, TargetAll) + if err != nil { + t.Fatalf("ResetConfig() error = %v", err) + } + if len(affected) != 3 { + t.Fatalf("expected 3 affected files, got %d: %v", len(affected), affected) + } + + // config.yaml should be deleted (no embedded default) + if _, err := os.Stat(filepath.Join(tikiDir, "config.yaml")); !os.IsNotExist(err) { + t.Error("config.yaml should be deleted after global reset") + } + + // workflow.yaml should contain embedded default + got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml")) + if err != nil { + t.Fatalf("read workflow.yaml: %v", err) + } + if string(got) != GetDefaultWorkflowYAML() { + t.Error("workflow.yaml does not match embedded default after global reset") + } + + // new.md should contain embedded default + got, err = os.ReadFile(filepath.Join(tikiDir, "new.md")) + if err != nil { + t.Fatalf("read new.md: %v", err) + } + if string(got) != GetDefaultNewTaskTemplate() { + t.Error("new.md does not match embedded default after global reset") + } +} + +func TestResetConfig_GlobalSingleTarget(t *testing.T) { + tests := []struct { + target ResetTarget + filename string + deleted bool // true = file deleted, false = file overwritten with default + }{ + {TargetConfig, "config.yaml", true}, + {TargetWorkflow, "workflow.yaml", false}, + {TargetNew, "new.md", false}, + } + + for _, tt := range tests { + t.Run(string(tt.target), func(t *testing.T) { + tikiDir := setupResetTest(t) + + writeFile(t, filepath.Join(tikiDir, tt.filename), "custom\n") + + affected, err := ResetConfig(ScopeGlobal, tt.target) + if err != nil { + t.Fatalf("ResetConfig() error = %v", err) + } + if len(affected) != 1 { + t.Fatalf("expected 1 affected file, got %d", len(affected)) + } + + _, statErr := os.Stat(filepath.Join(tikiDir, tt.filename)) + if tt.deleted { + if !os.IsNotExist(statErr) { + t.Errorf("%s should be deleted", tt.filename) + } + } else { + if statErr != nil { + t.Errorf("%s should exist after reset: %v", tt.filename, statErr) + } + } + }) + } +} + +func TestResetConfig_LocalDeletesFiles(t *testing.T) { + tikiDir := setupResetTest(t) + + // set up project dir with .doc/tiki so IsProjectInitialized() passes + projectDir := t.TempDir() + docDir := filepath.Join(projectDir, ".doc") + if err := os.MkdirAll(filepath.Join(docDir, "tiki"), 0750); err != nil { + t.Fatal(err) + } + + pm := mustGetPathManager() + pm.projectRoot = projectDir + + // seed project config files + writeFile(t, filepath.Join(docDir, "config.yaml"), "custom\n") + writeFile(t, filepath.Join(docDir, "workflow.yaml"), "custom\n") + writeFile(t, filepath.Join(docDir, "new.md"), "custom\n") + + // also write global defaults so we can verify local doesn't overwrite + writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), "global\n") + + affected, err := ResetConfig(ScopeLocal, TargetAll) + if err != nil { + t.Fatalf("ResetConfig() error = %v", err) + } + if len(affected) != 3 { + t.Fatalf("expected 3 affected files, got %d: %v", len(affected), affected) + } + + // all project files should be deleted + for _, name := range []string{"config.yaml", "workflow.yaml", "new.md"} { + if _, err := os.Stat(filepath.Join(docDir, name)); !os.IsNotExist(err) { + t.Errorf("project %s should be deleted after local reset", name) + } + } + + // global workflow should be untouched + got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml")) + if err != nil { + t.Fatalf("global workflow.yaml should still exist: %v", err) + } + if string(got) != "global\n" { + t.Error("global workflow.yaml should be untouched after local reset") + } +} + +func TestResetConfig_CurrentDeletesFiles(t *testing.T) { + _ = setupResetTest(t) + + cwdDir := t.TempDir() + originalDir, _ := os.Getwd() + defer func() { _ = os.Chdir(originalDir) }() + _ = os.Chdir(cwdDir) + + writeFile(t, filepath.Join(cwdDir, "workflow.yaml"), "override\n") + + affected, err := ResetConfig(ScopeCurrent, TargetWorkflow) + if err != nil { + t.Fatalf("ResetConfig() error = %v", err) + } + if len(affected) != 1 { + t.Fatalf("expected 1 affected file, got %d", len(affected)) + } + if _, err := os.Stat(filepath.Join(cwdDir, "workflow.yaml")); !os.IsNotExist(err) { + t.Error("cwd workflow.yaml should be deleted after current reset") + } +} + +func TestResetConfig_IdempotentOnMissingFiles(t *testing.T) { + _ = setupResetTest(t) + + // reset when no files exist — should succeed with 0 affected + affected, err := ResetConfig(ScopeGlobal, TargetConfig) + if err != nil { + t.Fatalf("ResetConfig() error = %v", err) + } + if len(affected) != 0 { + t.Errorf("expected 0 affected files for missing config.yaml, got %d", len(affected)) + } +} + +func TestResetConfig_GlobalWorkflowCreatesDir(t *testing.T) { + // use a fresh temp dir where tiki subdir doesn't exist yet + xdgDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgDir) + ResetPathManager() + t.Cleanup(ResetPathManager) + + affected, err := ResetConfig(ScopeGlobal, TargetWorkflow) + if err != nil { + t.Fatalf("ResetConfig() error = %v", err) + } + if len(affected) != 1 { + t.Fatalf("expected 1 affected file, got %d", len(affected)) + } + + // should have created the directory and written the default + tikiDir := filepath.Join(xdgDir, "tiki") + got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml")) + if err != nil { + t.Fatalf("read workflow.yaml: %v", err) + } + if string(got) != GetDefaultWorkflowYAML() { + t.Error("workflow.yaml should match embedded default") + } +} + +func TestResetConfig_GlobalSkipsWhenAlreadyDefault(t *testing.T) { + tikiDir := setupResetTest(t) + + // write the embedded default content — reset should detect no change + writeFile(t, filepath.Join(tikiDir, "workflow.yaml"), GetDefaultWorkflowYAML()) + + affected, err := ResetConfig(ScopeGlobal, TargetWorkflow) + if err != nil { + t.Fatalf("ResetConfig() error = %v", err) + } + if len(affected) != 0 { + t.Errorf("expected 0 affected files when already default, got %d", len(affected)) + } +} + +func TestResetConfig_LocalRejectsUninitializedProject(t *testing.T) { + // point projectRoot at a temp dir that has no .doc/tiki + xdgDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgDir) + ResetPathManager() + t.Cleanup(ResetPathManager) + + pm := mustGetPathManager() + pm.projectRoot = t.TempDir() // empty dir — not initialized + + _, err := ResetConfig(ScopeLocal, TargetAll) + if err == nil { + t.Fatal("expected error for uninitialized project, got nil") + } + if msg := err.Error(); !strings.Contains(msg, "not in an initialized tiki project") { + t.Errorf("unexpected error message: %s", msg) + } +} diff --git a/config/system.go b/config/system.go index 406778b..9c483ad 100644 --- a/config/system.go +++ b/config/system.go @@ -165,6 +165,11 @@ func GetDefaultNewTaskTemplate() string { return defaultNewTaskTemplate } +// GetDefaultWorkflowYAML returns the embedded default workflow.yaml content +func GetDefaultWorkflowYAML() string { + return defaultWorkflowYAML +} + // InstallDefaultWorkflow installs the default workflow.yaml to the user config directory // if it does not already exist. This runs on every launch to handle first-run and upgrade cases. func InstallDefaultWorkflow() error { diff --git a/main.go b/main.go index 69a6c39..2a08944 100644 --- a/main.go +++ b/main.go @@ -62,6 +62,11 @@ func main() { os.Exit(1) } + // Handle config command + if len(os.Args) > 1 && os.Args[1] == "config" { + os.Exit(runConfig(os.Args[2:])) + } + // Handle exec command: execute ruki statement and exit if len(os.Args) > 1 && os.Args[1] == "exec" { os.Exit(runExec(os.Args[2:])) @@ -83,7 +88,7 @@ func main() { // Handle viewer mode (standalone markdown viewer) // "init" is reserved to prevent treating it as a markdown file - viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}}) + viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}, "config": {}}) if err != nil { if errors.Is(err, viewer.ErrMultipleInputs) { _, _ = fmt.Fprintln(os.Stderr, "error:", err) @@ -178,7 +183,11 @@ func runDemo() error { return nil } -// exit codes for tiki exec +// errHelpRequested is returned by arg parsers when the user asks for help. +// Callers should print usage and exit cleanly — not treat it as a real error. +var errHelpRequested = errors.New("help requested") + +// exit codes for CLI subcommands const ( exitOK = 0 exitInternal = 1 @@ -254,6 +263,7 @@ Usage: tiki Launch TUI in initialized repo tiki init Initialize project in current git repo tiki exec '' Execute a ruki query and exit + tiki config reset [target] Reset config files (--global, --local, --current) tiki demo Clone demo project and launch TUI tiki file.md/URL View markdown file or image echo "Title" | tiki Create task from piped input