diff --git a/README.md b/README.md index 31a191a..037fe01 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,15 @@ Set `KTICKET_DIR` environment variable to override the storage directory. ```sh go install github.com/kostyay/kticket/cmd/kt@latest + +# First-time setup: generates kt.md, installs slash commands, configures permissions +kt install ``` +The installer prompts for: +- **Slash commands**: `/kt-create`, `/kt-run` (global or project scope) +- **kt permission**: Allows Claude to run kt commands without prompting + ## AI Agent Setup Add to your project's `CLAUDE.md`: @@ -28,6 +35,21 @@ Add to your project's `CLAUDE.md`: - Creating: break features into testable chunks (`kt create "title" -d "description" --parent `) ``` +### Slash Commands + +| Command | Description | +|---------|-------------| +| `/kt-create` | Create epic and tasks from a plan | +| `/kt-run` | Work through tasks: ready → start → implement → close | + +### Prompting Example + +``` +@kt.md create an epic and bite-sized tasks for this plan +``` + +The generated `kt.md` provides a compact reference for agents to understand ticket operations without bloating context. + ## Quick Start ```sh diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index e6b25a5..d027f85 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -1,11 +1,14 @@ package cmd import ( + "bufio" "bytes" "context" "encoding/json" "os" + "path/filepath" "slices" + "strings" "testing" "github.com/kostyay/kticket/internal/store" @@ -1323,7 +1326,7 @@ func TestRegisterKtPermission_FileNotExist(t *testing.T) { dir := t.TempDir() path := dir + "/nonexistent.json" - err := registerKtPermissionAt(path) + err := registerKtPermissionAt(path, false) require.NoError(t, err) // File should be created with permission @@ -1341,7 +1344,7 @@ func TestRegisterKtPermission_InvalidJSON(t *testing.T) { path := dir + "/settings.json" require.NoError(t, os.WriteFile(path, []byte("not json"), 0644)) - err := registerKtPermissionAt(path) + err := registerKtPermissionAt(path, false) require.Error(t, err) assert.Contains(t, err.Error(), "parse settings") } @@ -1350,7 +1353,7 @@ func TestRegisterKtPermission_CreatesDirectory(t *testing.T) { dir := t.TempDir() path := dir + "/.claude/settings.local.json" - err := registerKtPermissionAt(path) + err := registerKtPermissionAt(path, false) require.NoError(t, err) // Directory and file should be created @@ -1369,7 +1372,7 @@ func TestRegisterKtPermission_NoPermissionsSection(t *testing.T) { data := `{"other": "value"}` require.NoError(t, os.WriteFile(path, []byte(data), 0644)) - err := registerKtPermissionAt(path) + err := registerKtPermissionAt(path, false) require.NoError(t, err) // File should have permissions.allow created @@ -1388,7 +1391,7 @@ func TestRegisterKtPermission_NoAllowArray(t *testing.T) { data := `{"permissions": {"deny": ["something"]}}` require.NoError(t, os.WriteFile(path, []byte(data), 0644)) - err := registerKtPermissionAt(path) + err := registerKtPermissionAt(path, false) require.NoError(t, err) // File should have allow array created @@ -1408,7 +1411,7 @@ func TestRegisterKtPermission_AlreadyExists(t *testing.T) { data := `{"permissions": {"allow": ["Bash(kt:*)", "Other"]}}` require.NoError(t, os.WriteFile(path, []byte(data), 0644)) - err := registerKtPermissionAt(path) + err := registerKtPermissionAt(path, false) require.NoError(t, err) // Should skip if already exists // File should be unchanged (except formatting) @@ -1426,7 +1429,7 @@ func TestRegisterKtPermission_AddsPermission(t *testing.T) { data := `{"permissions": {"allow": ["Other"]}}` require.NoError(t, os.WriteFile(path, []byte(data), 0644)) - err := registerKtPermissionAt(path) + err := registerKtPermissionAt(path, false) require.NoError(t, err) // File should have new permission @@ -1446,7 +1449,7 @@ func TestRegisterKtPermission_EmptyAllowArray(t *testing.T) { data := `{"permissions": {"allow": []}}` require.NoError(t, os.WriteFile(path, []byte(data), 0644)) - err := registerKtPermissionAt(path) + err := registerKtPermissionAt(path, false) require.NoError(t, err) // File should have new permission @@ -1465,7 +1468,7 @@ func TestRegisterKtPermission_PreservesOtherSettings(t *testing.T) { data := `{"mcpServers": {"test": {}}, "permissions": {"allow": [], "deny": ["Bad"]}, "other": 123}` require.NoError(t, os.WriteFile(path, []byte(data), 0644)) - err := registerKtPermissionAt(path) + err := registerKtPermissionAt(path, false) require.NoError(t, err) // Check all settings preserved @@ -1481,3 +1484,85 @@ func TestRegisterKtPermission_PreservesOtherSettings(t *testing.T) { deny := perms["deny"].([]any) assert.Contains(t, deny, "Bad") } + +func TestGetClaudeConfigDir_Default(t *testing.T) { + // Unset env var + os.Unsetenv("CLAUDE_CONFIG_DIR") + + dir := getClaudeConfigDir() + home, _ := os.UserHomeDir() + assert.Equal(t, filepath.Join(home, ".claude"), dir) +} + +func TestGetClaudeConfigDir_EnvVar(t *testing.T) { + t.Setenv("CLAUDE_CONFIG_DIR", "/custom/path") + + dir := getClaudeConfigDir() + assert.Equal(t, "/custom/path", dir) +} + +func TestInstallSlashCommands_Project(t *testing.T) { + dir := t.TempDir() + oldWd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(oldWd) + + err := installSlashCommands(false) + require.NoError(t, err) + + // Check files created + _, err = os.Stat(filepath.Join(dir, ".claude/commands/kt-create.md")) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(dir, ".claude/commands/kt-run.md")) + assert.NoError(t, err) + + // Check content + content, _ := os.ReadFile(filepath.Join(dir, ".claude/commands/kt-create.md")) + assert.Contains(t, string(content), "epic") + assert.Contains(t, string(content), "kt create") +} + +func TestInstallSlashCommands_Global(t *testing.T) { + dir := t.TempDir() + t.Setenv("CLAUDE_CONFIG_DIR", dir) + + err := installSlashCommands(true) + require.NoError(t, err) + + // Check files created in custom config dir + _, err = os.Stat(filepath.Join(dir, "commands/kt-create.md")) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(dir, "commands/kt-run.md")) + assert.NoError(t, err) +} + +func TestWriteKtMd(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "kt.md") + + err := writeKtMd(path) + require.NoError(t, err) + + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(content), "kt - ticket tracker") + assert.Contains(t, string(content), "kt create") +} + +func TestPromptChoice_ValidInput(t *testing.T) { + reader := bufio.NewReader(strings.NewReader("2\n")) + choice := promptChoice(reader, "Pick one", []string{"A", "B", "C"}) + assert.Equal(t, 2, choice) +} + +func TestPromptChoice_InvalidInput(t *testing.T) { + reader := bufio.NewReader(strings.NewReader("invalid\n")) + choice := promptChoice(reader, "Pick one", []string{"A", "B", "C"}) + assert.Equal(t, 3, choice) // Defaults to last (Skip) +} + +func TestPromptChoice_OutOfRange(t *testing.T) { + reader := bufio.NewReader(strings.NewReader("5\n")) + choice := promptChoice(reader, "Pick one", []string{"A", "B", "C"}) + assert.Equal(t, 3, choice) // Defaults to last +} diff --git a/internal/cmd/install.go b/internal/cmd/install.go index e0c71e4..9b2be67 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -2,66 +2,73 @@ package cmd import ( "bufio" + "embed" "fmt" "os" "path/filepath" + "strconv" "strings" "github.com/Jeffail/gabs/v2" "github.com/spf13/cobra" ) -const ktMdContent = `## kt - ticket tracker - -Tickets in ` + "`.ktickets/`" + ` (markdown+YAML). Hierarchy: epic>task>subtask. - -` + "```sh" + ` -kt create "title" -d "desc" --parent # -t bug|feature|task|epic|chore -p 0-4 -kt ls [--status=open] [--parent=] # or: kt ready, kt blocked -kt show # partial ID ok: a1b2 → kt-a1b2c3d4 -kt start|pass|close # workflow transitions -kt add-note "text" -kt dep add|rm|tree [dep-id] -kt link add|rm -` + "```" + ` - -Create flags: ` + "`--design --acceptance --tests --external-ref`" + ` -Output: JSON when piped/--json. -` +//go:embed templates/* +var templatesFS embed.FS var installCmd = &cobra.Command{ Use: "install", - Short: "Create kt.md instructions file in current directory", - Long: "Creates a kt.md file containing usage instructions for AI agents", + Short: "Install kt.md and Claude slash commands", + Long: "Creates kt.md file and optionally installs Claude slash commands and permissions", RunE: func(cmd *cobra.Command, args []string) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("get working directory: %w", err) } - path := filepath.Join(cwd, "kt.md") - - // Check if file exists - if _, err := os.Stat(path); err == nil { - fmt.Print("kt.md already exists. Regenerate? [y/N] ") - reader := bufio.NewReader(os.Stdin) - answer, _ := reader.ReadString('\n') - answer = strings.TrimSpace(strings.ToLower(answer)) - if answer != "y" && answer != "yes" { - fmt.Println("Aborted") - return nil + reader := bufio.NewReader(os.Stdin) + + // Install kt.md + ktMdPath := filepath.Join(cwd, "kt.md") + if _, err := os.Stat(ktMdPath); err == nil { + if !promptYesNo(reader, "kt.md already exists. Regenerate?") { + fmt.Println("Skipped kt.md") + } else { + if err := writeKtMd(ktMdPath); err != nil { + return err + } + } + } else { + if err := writeKtMd(ktMdPath); err != nil { + return err } } - if err := os.WriteFile(path, []byte(ktMdContent), 0644); err != nil { - return fmt.Errorf("write kt.md: %w", err) + // Install slash commands + globalDir := getClaudeConfigDir() + cmdChoice := promptChoice(reader, "Install slash commands (/kt-create, /kt-run)?", []string{ + fmt.Sprintf("Global (%s/commands/)", globalDir), + "Project (.claude/commands/)", + "Skip", + }) + if cmdChoice != 3 { + global := cmdChoice == 1 + if err := installSlashCommands(global); err != nil { + fmt.Fprintf(os.Stderr, "warning: %v\n", err) + } } - fmt.Println("Created kt.md") - - // Register kt permission in Claude settings - if err := registerKtPermission(); err != nil { - fmt.Fprintf(os.Stderr, "warning: %v\n", err) + // Install kt permission + permChoice := promptChoice(reader, "Add kt permission (allows Claude to run kt commands without prompting)?", []string{ + fmt.Sprintf("Global (%s/settings.json)", globalDir), + "Project (.claude/settings.local.json)", + "Skip", + }) + if permChoice != 3 { + global := permChoice == 1 + if err := registerKtPermission(global); err != nil { + fmt.Fprintf(os.Stderr, "warning: %v\n", err) + } } return nil @@ -72,13 +79,98 @@ func init() { rootCmd.AddCommand(installCmd) } -// registerKtPermission adds "Bash(kt:*)" to .claude/settings.local.json if not present. -func registerKtPermission() error { - return registerKtPermissionAt(".claude/settings.local.json") +// writeKtMd writes kt.md from embedded template. +func writeKtMd(path string) error { + content, err := templatesFS.ReadFile("templates/kt.md") + if err != nil { + return fmt.Errorf("read template: %w", err) + } + if err := os.WriteFile(path, content, 0644); err != nil { + return fmt.Errorf("write kt.md: %w", err) + } + fmt.Println("Created kt.md") + return nil +} + +// promptYesNo asks a yes/no question and returns true if user answers yes. +func promptYesNo(reader *bufio.Reader, prompt string) bool { + fmt.Print(prompt + " [y/N] ") + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + return answer == "y" || answer == "yes" +} + +// promptChoice presents numbered options and returns 1-indexed selection. +func promptChoice(reader *bufio.Reader, prompt string, options []string) int { + fmt.Println(prompt) + for i, opt := range options { + fmt.Printf(" %d. %s\n", i+1, opt) + } + fmt.Print("> ") + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(answer) + choice, err := strconv.Atoi(answer) + if err != nil || choice < 1 || choice > len(options) { + return len(options) // Default to last option (Skip) + } + return choice +} + +// getClaudeConfigDir returns the Claude config directory, respecting CLAUDE_CONFIG_DIR env var. +func getClaudeConfigDir() string { + if dir := os.Getenv("CLAUDE_CONFIG_DIR"); dir != "" { + return dir + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".claude") +} + +// installSlashCommands installs kt-create.md and kt-run.md commands. +func installSlashCommands(global bool) error { + var commandsDir string + if global { + commandsDir = filepath.Join(getClaudeConfigDir(), "commands") + } else { + commandsDir = ".claude/commands" + } + + if err := os.MkdirAll(commandsDir, 0755); err != nil { + return fmt.Errorf("create commands directory: %w", err) + } + + commands := []string{"kt-create.md", "kt-run.md"} + for _, cmd := range commands { + content, err := templatesFS.ReadFile("templates/" + cmd) + if err != nil { + return fmt.Errorf("read template %s: %w", cmd, err) + } + path := filepath.Join(commandsDir, cmd) + if err := os.WriteFile(path, content, 0644); err != nil { + return fmt.Errorf("write %s: %w", cmd, err) + } + } + + scope := "project" + if global { + scope = "global" + } + fmt.Printf("Installed /kt-create, /kt-run (%s)\n", scope) + return nil +} + +// registerKtPermission adds "Bash(kt:*)" to Claude settings. +func registerKtPermission(global bool) error { + var settingsPath string + if global { + settingsPath = filepath.Join(getClaudeConfigDir(), "settings.json") + } else { + settingsPath = ".claude/settings.local.json" + } + return registerKtPermissionAt(settingsPath, global) } // registerKtPermissionAt adds "Bash(kt:*)" to the specified settings file if not present. -func registerKtPermissionAt(settingsPath string) error { +func registerKtPermissionAt(settingsPath string, global bool) error { const permission = "Bash(kt:*)" var settings *gabs.Container @@ -103,7 +195,12 @@ func registerKtPermissionAt(settingsPath string) error { if allow := settings.Path("permissions.allow"); allow != nil { for _, p := range allow.Children() { if p.Data().(string) == permission { - return nil // Already exists + scope := "project" + if global { + scope = "global" + } + fmt.Printf("kt permission already registered (%s)\n", scope) + return nil } } // Append to existing array @@ -118,7 +215,7 @@ func registerKtPermissionAt(settingsPath string) error { } } - // Ensure .claude directory exists + // Ensure directory exists if dir := filepath.Dir(settingsPath); dir != "." { if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("create directory: %w", err) @@ -129,6 +226,10 @@ func registerKtPermissionAt(settingsPath string) error { return fmt.Errorf("write settings: %w", err) } - fmt.Println("Registered kt in .claude/settings.local.json") + scope := "project" + if global { + scope = "global" + } + fmt.Printf("Registered kt permission (%s)\n", scope) return nil } diff --git a/internal/cmd/templates/kt-create.md b/internal/cmd/templates/kt-create.md new file mode 100644 index 0000000..8df3427 --- /dev/null +++ b/internal/cmd/templates/kt-create.md @@ -0,0 +1,14 @@ +Create an epic and bite-sized tasks for this plan. + +## kt reference +Tickets in `.ktickets/` (markdown+YAML). Hierarchy: epic>task>subtask. + +```sh +kt create "title" -d "desc" --parent # -t bug|feature|task|epic|chore -p 0-4 +kt ls [--status=open] [--parent=] # or: kt ready, kt blocked +kt show # partial ID ok: a1b2 → kt-a1b2c3d4 +kt start|pass|close # workflow transitions +kt dep add|rm|tree [dep-id] +``` + +Create flags: `--design --acceptance --tests --external-ref` diff --git a/internal/cmd/templates/kt-run.md b/internal/cmd/templates/kt-run.md new file mode 100644 index 0000000..4d60b01 --- /dev/null +++ b/internal/cmd/templates/kt-run.md @@ -0,0 +1,16 @@ +Use kt to implement all tasks. + +1. Run `kt ready` to see available tasks +2. Pick top priority task, `kt start ` +3. Implement the task +4. `kt close ` when done +5. Repeat until all tasks complete + +## kt reference +```sh +kt ready # show actionable tasks +kt show # view task details +kt start|close # workflow transitions +kt add-note "text" # log progress +kt ls --status=in_progress # see active work +``` diff --git a/internal/cmd/templates/kt.md b/internal/cmd/templates/kt.md new file mode 100644 index 0000000..10b32e0 --- /dev/null +++ b/internal/cmd/templates/kt.md @@ -0,0 +1,16 @@ +## kt - ticket tracker + +Tickets in `.ktickets/` (markdown+YAML). Hierarchy: epic>task>subtask. + +```sh +kt create "title" -d "desc" --parent # -t bug|feature|task|epic|chore -p 0-4 +kt ls [--status=open] [--parent=] # or: kt ready, kt blocked +kt show # partial ID ok: a1b2 → kt-a1b2c3d4 +kt start|pass|close # workflow transitions +kt add-note "text" +kt dep add|rm|tree [dep-id] +kt link add|rm +``` + +Create flags: `--design --acceptance --tests --external-ref` +Output: JSON when piped/--json.