Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80fd15fd9f | |||
| 8d19cff217 | |||
| 3f55485926 | |||
| fe25d7fa37 | |||
| 6066e65af8 | |||
| f3a2bfd5a3 | |||
| 646e0dbeb4 | |||
| d701ee3793 | |||
| 53c464bbdd | |||
| 9ed525ea0a | |||
| 7a16b2bcd2 | |||
| 5c9689e4da | |||
| 65f67ff7b1 | |||
| 6692b9a050 | |||
| ce878f058f | |||
| 8a3257b3a7 | |||
| 49ec38a2ca | |||
| 9694d72463 | |||
| d6f507b8cb | |||
| 9080cf7f3e | |||
| a4a1b49c9d | |||
|
|
4dff2039b0 | ||
|
|
8031190d81 | ||
|
|
18c23b1d6d | ||
|
|
d752a73742 | ||
|
|
5f864307b8 | ||
|
|
3bcba4976a | ||
|
|
eb9bc410fc | ||
|
|
4038bc1a92 | ||
|
|
88b912fc8f | ||
|
|
3a91a94b45 | ||
|
|
99b9405298 | ||
|
|
a2a1dbf33a | ||
|
|
9fc9ae346b | ||
|
|
029621b916 | ||
|
|
e0e1100396 | ||
|
|
5bf073377d | ||
| debcf94f2e | |||
|
|
403a408f8d | ||
|
|
452051f0c1 | ||
| f483a36de8 | |||
| c94a64c688 |
103
CHANGELOG.md
103
CHANGELOG.md
@ -1,3 +1,106 @@
|
|||||||
|
## [v0.5.0] - 2026-04-06
|
||||||
|
|
||||||
|
MCP server mode arrives, ready to serve your AI coding whims.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Implement `grokkit mcp` command for MCP server over stdio.
|
||||||
|
- Add ListRecipes and ListAvailablePrompts functions.
|
||||||
|
- Export analysis, docs, and testgen functions for MCP tools.
|
||||||
|
- Add tools: lint_code, analyze_code, generate_docs, generate_tests, generate_commit_msg, run_recipe.
|
||||||
|
- Register resources: recipes://local, recipes://global, prompts://language.
|
||||||
|
- Add MCP config options and dependencies.
|
||||||
|
- Add unit tests for analyze, docs, mcp, prompts, recipe, and testgen packages.
|
||||||
|
- Add user guide for MCP.
|
||||||
|
- Add detailed plan in todo/queued/mcp-feature.md.
|
||||||
|
- Add MCP server mode to queued tasks in TODO README.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Update MCP server handlers for local/global recipes and prompts.
|
||||||
|
- Update README with MCP details.
|
||||||
|
## [v0.4.0] - 2026-03-31
|
||||||
|
|
||||||
|
Workon command arrives: because who needs manual todos when AI can branch out for you?
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Add workon command for automating todo/fix workflows.
|
||||||
|
- Add AI-powered work plan generation using Grok.
|
||||||
|
- Add git branching for features and fixes.
|
||||||
|
- Add todo package with bootstrap for directory structure.
|
||||||
|
- Add flags for fix mode (-f), complete mode (-c), and custom messages (-M).
|
||||||
|
- Add config options for workon.model and workon.ide.
|
||||||
|
- Add IDE integration to open on task start.
|
||||||
|
- Add unit tests for commands, config, git, linter, logger, prompts, todo, and workon.
|
||||||
|
- Add prompts loading tests with fallback logic.
|
||||||
|
- Add todo/queued/workon.md documentation.
|
||||||
|
- Add workon guide to README and user docs.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Update workon command to handle modes: todo, fix, complete.
|
||||||
|
- Change custom message flag from -m to -M.
|
||||||
|
- Implement branch prefixing with feature/ or fix/.
|
||||||
|
- Update README index on task completion.
|
||||||
|
- Refine error handling, logging, and colorized output.
|
||||||
|
- Replace placeholder with actual Grok client calls.
|
||||||
|
- Remove temporary stubs and add TODOs for integrations.
|
||||||
|
- Reorder queued tasks and update workon descriptions in docs.
|
||||||
|
- Update workon.md to use 'todo item' instead of 'feature'.
|
||||||
|
- Clarify -c flag usage and exclusivity in docs.
|
||||||
|
- Refine workon command steps, usage, and benefits in docs.
|
||||||
|
- Move workon todo item from queued to doing, then completed.
|
||||||
|
- Mark recipe feature as completed in todo structure.
|
||||||
|
- Bump version to 0.3.9 in README and scripts (prior release).
|
||||||
|
- Update expected git diff ranges in tests.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Discard errors explicitly from git commands in tests to satisfy linters.
|
||||||
|
## [v0.3.9] - 2026-03-31
|
||||||
|
|
||||||
|
Another tiny step forward, now with origin-al git flair.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Bump version to 0.3.9 in README.md.
|
||||||
|
- Update version references in Bash and PowerShell installation scripts in README.md.
|
||||||
|
- Update expected git diff ranges in tests to include origin prefix.
|
||||||
|
## [v0.3.8] - 2026-03-31
|
||||||
|
|
||||||
|
Bumping to v0.3.8 with git tweaks and doc updates.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Add changelog entry for v0.3.7.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Update version to 0.3.8 in README installation instructions.
|
||||||
|
- Update bash and powershell scripts to reference new release version.
|
||||||
|
- Update git diff command to prefer remote origin/base branch.
|
||||||
|
- Fall back to local base branch if remote diff fails or is empty.
|
||||||
|
## [v0.3.7] - 2026-03-31
|
||||||
|
|
||||||
|
Version v0.3.7: Patchin' up git diffs because exit codes shouldn't be drama queens.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Introduce `gitDiff` variable as a mockable wrapper for `git.Diff`.
|
||||||
|
- Introduce git.Diff function that uses CombinedOutput and treats exit code 1 as success.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Update tests to use `withMockGitDiff` for injecting diff mocks.
|
||||||
|
- Tweak flag description for clarity.
|
||||||
|
- Update prdescribe command to use git.Diff and trim whitespace when checking for empty diffs.
|
||||||
|
- Enhance prdescribe output to include base branch in the status message.
|
||||||
|
- Update installation and download scripts in README.md to reference v0.3.7.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Improve diff handling to tolerate exit code 1.
|
||||||
|
## [v0.3.6] - 2026-03-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Handle fprintf error in version output by wrapping with error check.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Promote "Logging" to H2 and adjust subheadings to H3 in README.
|
||||||
|
- Bump version references from 0.3.3 to 0.3.5 in installation scripts.
|
||||||
|
- Convert plain text sections to Markdown headings in README.
|
||||||
|
- Add v0.3.5 entry to changelog documenting Markdown support and Windows tweaks.
|
||||||
|
- Add v0.3.4 entry to changelog for Windows support.
|
||||||
## [v0.3.5] - 2026-03-30
|
## [v0.3.5] - 2026-03-30
|
||||||
|
|
||||||
Now with Markdown editing flair and Windows install tweaks!
|
Now with Markdown editing flair and Windows install tweaks!
|
||||||
|
|||||||
15
README.md
15
README.md
@ -3,7 +3,7 @@
|
|||||||
Grokkit is a fast Go CLI integrating Grok AI with git workflows and general chat/edit functionality.
|
Grokkit is a fast Go CLI integrating Grok AI with git workflows and general chat/edit functionality.
|
||||||
|
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[]()
|
[]()
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
@ -19,13 +19,13 @@ Grokkit is a fast Go CLI integrating Grok AI with git workflows and general chat
|
|||||||
|
|
||||||
**Bash (Linux/macOS):**
|
**Bash (Linux/macOS):**
|
||||||
```bash
|
```bash
|
||||||
export VERSION=0.3.5
|
export VERSION=0.4.0
|
||||||
curl -fsSL https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v${VERSION}/grokkit-install.sh | bash -s -- --verify
|
curl -fsSL https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v${VERSION}/grokkit-install.sh | bash -s -- --verify
|
||||||
```
|
```
|
||||||
|
|
||||||
**PowerShell (Windows):**
|
**PowerShell (Windows):**
|
||||||
```pwsh
|
```pwsh
|
||||||
$env:VERSION="0.3.5"
|
$env:VERSION="0.4.0"
|
||||||
irm https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v$env:VERSION/grokkit-install.ps1 | iex
|
irm https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v$env:VERSION/grokkit-install.ps1 | iex
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ Both installers feature:
|
|||||||
**Bash (Linux/macOS):**
|
**Bash (Linux/macOS):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export VERSION=0.3.5
|
export VERSION=0.4.0
|
||||||
curl -fsSL https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v${VERSION}/grokkit-install.sh -o grokkit-install.sh
|
curl -fsSL https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v${VERSION}/grokkit-install.sh -o grokkit-install.sh
|
||||||
bash grokkit-install.sh --verify
|
bash grokkit-install.sh --verify
|
||||||
```
|
```
|
||||||
@ -48,7 +48,7 @@ bash grokkit-install.sh --verify
|
|||||||
**PowerShell (Windows):**
|
**PowerShell (Windows):**
|
||||||
|
|
||||||
```pwsh
|
```pwsh
|
||||||
$env:VERSION="0.3.5"
|
$env:VERSION="0.4.0"
|
||||||
irm https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v$env:VERSION/grokkit-install.ps1 -OutFile grokkit-install.ps1
|
irm https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v$env:VERSION/grokkit-install.ps1 -OutFile grokkit-install.ps1
|
||||||
.\grokkit-install.ps1 -Verify
|
.\grokkit-install.ps1 -Verify
|
||||||
```
|
```
|
||||||
@ -109,6 +109,8 @@ history_file = "~/.config/grokkit/chat_history.json"
|
|||||||
|
|
||||||
See [The User Guide](docs/user-guide/index.md) for complete details on command usage.
|
See [The User Guide](docs/user-guide/index.md) for complete details on command usage.
|
||||||
|
|
||||||
|
**New:** Grokkit can now run as an **[MCP Server](docs/user-guide/mcp.md)** (`grokkit mcp`), allowing AI coding agents like Claude Code to call its tools directly.
|
||||||
|
|
||||||
## 🛡️ Safety & Change Management
|
## 🛡️ Safety & Change Management
|
||||||
|
|
||||||
Grokkit is designed to work seamlessly with Git, using version control instead of redundant backup files to manage changes and rollbacks.
|
Grokkit is designed to work seamlessly with Git, using version control instead of redundant backup files to manage changes and rollbacks.
|
||||||
@ -185,9 +187,10 @@ Generate shell completions for faster command entry:
|
|||||||
- ✅ **AI unit test generation** - Go (table-driven), Python (pytest), C (Check), C++ (GTest)
|
- ✅ **AI unit test generation** - Go (table-driven), Python (pytest), C (Check), C++ (GTest)
|
||||||
- ✅ **AI file scaffolding** - Create new files with project-aware style matching
|
- ✅ **AI file scaffolding** - Create new files with project-aware style matching
|
||||||
- ✅ **Transactional Recipes** - Markdown-based multi-step AI workflows
|
- ✅ **Transactional Recipes** - Markdown-based multi-step AI workflows
|
||||||
|
- ✅ **Automated Workon** - AI-powered todo/fix workflow with branch management, work plan generation, and completion tracking
|
||||||
|
|
||||||
### Quality & Testing
|
### Quality & Testing
|
||||||
- ✅ **Test coverage 54%+** - Comprehensive unit tests including all command message builders
|
- ✅ **Test coverage 60%+** - Comprehensive unit tests including all command message builders
|
||||||
- ✅ **Coverage gate in CI** - Builds fail if coverage drops below 50%
|
- ✅ **Coverage gate in CI** - Builds fail if coverage drops below 50%
|
||||||
- ✅ **CI/CD with Gitea Actions** - Tests must pass before lint and build jobs run
|
- ✅ **CI/CD with Gitea Actions** - Tests must pass before lint and build jobs run
|
||||||
- ✅ **Golangci-lint configured** - `.golangci.yml` with govet, errcheck, staticcheck, and more
|
- ✅ **Golangci-lint configured** - `.golangci.yml` with govet, errcheck, staticcheck, and more
|
||||||
|
|||||||
@ -39,7 +39,7 @@ Uses language-specific prompts discovered in .grokkit/prompts/ or ~/.config/grok
|
|||||||
logger.Warn("Not inside a git repository. Git metadata in report will be limited.")
|
logger.Warn("Not inside a git repository. Git metadata in report will be limited.")
|
||||||
}
|
}
|
||||||
|
|
||||||
files, err := discoverSourceFiles(dir)
|
files, err := DiscoverSourceFiles(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to discover source files", "dir", dir, "error", err)
|
logger.Error("Failed to discover source files", "dir", dir, "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@ -71,7 +71,7 @@ Uses language-specific prompts discovered in .grokkit/prompts/ or ~/.config/grok
|
|||||||
}
|
}
|
||||||
promptContent = strings.Replace(promptContent, "[Inferred Project Name]", projectName, 1)
|
promptContent = strings.Replace(promptContent, "[Inferred Project Name]", projectName, 1)
|
||||||
|
|
||||||
context := buildProjectContext(dir, files)
|
context := BuildProjectContext(dir, files)
|
||||||
|
|
||||||
messages := []map[string]string{
|
messages := []map[string]string{
|
||||||
{"role": "system", "content": promptContent},
|
{"role": "system", "content": promptContent},
|
||||||
@ -118,9 +118,12 @@ func init() {
|
|||||||
analyzeCmd.Flags().String("dir", ".", "Repository root to analyze")
|
analyzeCmd.Flags().String("dir", ".", "Repository root to analyze")
|
||||||
analyzeCmd.Flags().StringP("output", "o", "analyze.md", "Output file (use - for stdout)")
|
analyzeCmd.Flags().StringP("output", "o", "analyze.md", "Output file (use - for stdout)")
|
||||||
analyzeCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
analyzeCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
||||||
|
|
||||||
|
// Export analysis functions for MCP use
|
||||||
|
// mcp.RegisterAnalyzeHelpers(DiscoverSourceFiles, BuildProjectContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func discoverSourceFiles(root string) ([]string, error) {
|
func DiscoverSourceFiles(root string) ([]string, error) {
|
||||||
var files []string
|
var files []string
|
||||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -154,7 +157,7 @@ func previewLines(content string, n int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildProjectContext(dir string, files []string) string {
|
func BuildProjectContext(dir string, files []string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("Project Root: " + dir + "\n\n")
|
sb.WriteString("Project Root: " + dir + "\n\n")
|
||||||
_, err := fmt.Fprintf(&sb, "Total source files discovered: %d\n\n", len(files))
|
_, err := fmt.Fprintf(&sb, "Total source files discovered: %d\n\n", len(files))
|
||||||
|
|||||||
238
cmd/analyze_test.go
Normal file
238
cmd/analyze_test.go
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAnalyzeCmd — FAST DEFAULT TEST (runs on make test / go test ./cmd)
|
||||||
|
func TestAnalyzeCmd(t *testing.T) {
|
||||||
|
t.Log("✓ Fast analyze unit test (no Grok API call)")
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, c := range rootCmd.Commands() {
|
||||||
|
if c.Use == "analyze [flags]" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("analyze command not registered on rootCmd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeFlagRegistration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
flagName string
|
||||||
|
shorthand string
|
||||||
|
}{
|
||||||
|
{"output flag", "output", "o"},
|
||||||
|
{"yes flag", "yes", "y"},
|
||||||
|
{"dir flag", "dir", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
flag := analyzeCmd.Flags().Lookup(tt.flagName)
|
||||||
|
if flag == nil {
|
||||||
|
t.Fatalf("flag %q not found on analyze command", tt.flagName)
|
||||||
|
}
|
||||||
|
if tt.shorthand != "" && flag.Shorthand != tt.shorthand {
|
||||||
|
t.Errorf("flag %q shorthand = %q, want %q", tt.flagName, flag.Shorthand, tt.shorthand)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverSourceFiles(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create some source files
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "helper.go"), []byte("package main"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Create a non-source file (should be ignored)
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("notes"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := DiscoverSourceFiles(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiscoverSourceFiles failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) < 2 {
|
||||||
|
t.Errorf("expected at least 2 Go files, got %d: %v", len(files), files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify .txt was not included
|
||||||
|
for _, f := range files {
|
||||||
|
if strings.HasSuffix(f, ".txt") {
|
||||||
|
t.Errorf("unexpected .txt file in results: %s", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverSourceFilesSkipsDotDirs(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a hidden directory with a Go file
|
||||||
|
hiddenDir := filepath.Join(tmpDir, ".hidden")
|
||||||
|
if err := os.MkdirAll(hiddenDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(hiddenDir, "secret.go"), []byte("package hidden"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create vendor directory with a Go file
|
||||||
|
vendorDir := filepath.Join(tmpDir, "vendor")
|
||||||
|
if err := os.MkdirAll(vendorDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(vendorDir, "dep.go"), []byte("package dep"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a normal Go file
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := DiscoverSourceFiles(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiscoverSourceFiles failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
if strings.Contains(f, ".hidden") {
|
||||||
|
t.Errorf("should not include files from hidden dirs: %s", f)
|
||||||
|
}
|
||||||
|
if strings.Contains(f, "vendor") {
|
||||||
|
t.Errorf("should not include files from vendor dir: %s", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Errorf("expected 1 file (main.go only), got %d: %v", len(files), files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverSourceFilesSkipsBuildAndDist(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
for _, dir := range []string{"build", "dist", "node_modules"} {
|
||||||
|
d := filepath.Join(tmpDir, dir)
|
||||||
|
if err := os.MkdirAll(d, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(d, "file.go"), []byte("package x"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := DiscoverSourceFiles(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiscoverSourceFiles failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Errorf("expected 1 file, got %d: %v", len(files), files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverSourceFilesEmptyDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
files, err := DiscoverSourceFiles(tmpDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiscoverSourceFiles failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 0 {
|
||||||
|
t.Errorf("expected 0 files for empty dir, got %d", len(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildProjectContext(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
files := []string{
|
||||||
|
filepath.Join(tmpDir, "main.go"),
|
||||||
|
filepath.Join(tmpDir, "cmd", "root.go"),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := BuildProjectContext(tmpDir, files)
|
||||||
|
|
||||||
|
if !strings.Contains(ctx, "Project Root:") {
|
||||||
|
t.Error("expected context to contain 'Project Root:'")
|
||||||
|
}
|
||||||
|
if !strings.Contains(ctx, "Total source files discovered: 2") {
|
||||||
|
t.Error("expected context to contain file count")
|
||||||
|
}
|
||||||
|
if !strings.Contains(ctx, "Key files") {
|
||||||
|
t.Error("expected context to contain 'Key files'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviewLines(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
n int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fewer lines than limit",
|
||||||
|
content: "line1\nline2\nline3\n",
|
||||||
|
n: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "more lines than limit",
|
||||||
|
content: "a\nb\nc\nd\ne\nf\ng\nh\n",
|
||||||
|
n: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty content",
|
||||||
|
content: "",
|
||||||
|
n: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero lines requested",
|
||||||
|
content: "line1\nline2\n",
|
||||||
|
n: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// previewLines prints to stdout; just verify it doesn't panic
|
||||||
|
previewLines(tt.content, tt.n)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildProjectContextLimitsSize(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Generate many files to test the 2500 byte limit
|
||||||
|
var files []string
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
files = append(files, filepath.Join(tmpDir, "file_with_a_long_name_for_testing_"+strings.Repeat("x", 20)+".go"))
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := BuildProjectContext(tmpDir, files)
|
||||||
|
|
||||||
|
// Should be reasonably bounded (the 2500 byte check truncates the file list)
|
||||||
|
if len(ctx) > 4000 {
|
||||||
|
t.Errorf("context unexpectedly large: %d bytes", len(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -48,11 +48,11 @@ func runDocs(cmd *cobra.Command, args []string) {
|
|||||||
|
|
||||||
client := newGrokClient()
|
client := newGrokClient()
|
||||||
for _, filePath := range args {
|
for _, filePath := range args {
|
||||||
processDocsFile(client, model, filePath)
|
ProcessDocsFile(client, model, filePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func processDocsFile(client grok.AIClient, model, filePath string) {
|
func ProcessDocsFile(client grok.AIClient, model, filePath string) {
|
||||||
logger.Info("starting docs operation", "file", filePath)
|
logger.Info("starting docs operation", "file", filePath)
|
||||||
|
|
||||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
@ -79,7 +79,7 @@ func processDocsFile(client grok.AIClient, model, filePath string) {
|
|||||||
color.Cyan("📝 Generating %s docs for: %s", lang.Name, filePath)
|
color.Cyan("📝 Generating %s docs for: %s", lang.Name, filePath)
|
||||||
logger.Info("requesting AI documentation", "file", filePath, "language", lang.Name)
|
logger.Info("requesting AI documentation", "file", filePath, "language", lang.Name)
|
||||||
|
|
||||||
messages := buildDocsMessages(lang.Name, string(originalContent))
|
messages := BuildDocsMessages(lang.Name, string(originalContent))
|
||||||
response := client.StreamSilent(messages, model)
|
response := client.StreamSilent(messages, model)
|
||||||
|
|
||||||
if response == "" {
|
if response == "" {
|
||||||
@ -134,7 +134,7 @@ func processDocsFile(client grok.AIClient, model, filePath string) {
|
|||||||
color.Green("✅ Documentation applied: %s", filePath)
|
color.Green("✅ Documentation applied: %s", filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDocsMessages(language, code string) []map[string]string {
|
func BuildDocsMessages(language, code string) []map[string]string {
|
||||||
style := docStyle(language)
|
style := docStyle(language)
|
||||||
systemPrompt := fmt.Sprintf(
|
systemPrompt := fmt.Sprintf(
|
||||||
"You are a documentation expert. Add %s documentation comments to the provided code. "+
|
"You are a documentation expert. Add %s documentation comments to the provided code. "+
|
||||||
|
|||||||
@ -25,7 +25,7 @@ func TestBuildDocsMessages(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.language, func(t *testing.T) {
|
t.Run(tt.language, func(t *testing.T) {
|
||||||
msgs := buildDocsMessages(tt.language, tt.code)
|
msgs := BuildDocsMessages(tt.language, tt.code)
|
||||||
|
|
||||||
if len(msgs) != 2 {
|
if len(msgs) != 2 {
|
||||||
t.Fatalf("expected 2 messages, got %d", len(msgs))
|
t.Fatalf("expected 2 messages, got %d", len(msgs))
|
||||||
|
|||||||
50
cmd/mcp.go
Normal file
50
cmd/mcp.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"gmgauthier.com/grokkit/internal/logger"
|
||||||
|
"gmgauthier.com/grokkit/internal/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mcpCmd = &cobra.Command{
|
||||||
|
Use: "mcp",
|
||||||
|
Short: "Start Grokkit as an MCP (Model Context Protocol) server",
|
||||||
|
Long: `Starts Grokkit in MCP server mode over stdio.
|
||||||
|
|
||||||
|
This allows MCP-compatible clients (such as Claude Code, Cursor, or other AI coding agents)
|
||||||
|
to call Grokkit tools like lint_code, analyze_code, generate_docs, etc. directly.
|
||||||
|
|
||||||
|
The server runs until the client closes the connection.`,
|
||||||
|
Run: runMCP,
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(mcpCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMCP(cmd *cobra.Command, args []string) {
|
||||||
|
logger.Info("mcp command started")
|
||||||
|
|
||||||
|
// Check if MCP is enabled in config
|
||||||
|
if !viper.GetBool("mcp.enabled") {
|
||||||
|
fmt.Println("MCP is disabled in configuration.")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := mcp.NewServer()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to create MCP server: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := srv.Run(ctx); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,12 +2,17 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gmgauthier.com/grokkit/config"
|
"gmgauthier.com/grokkit/config"
|
||||||
|
"gmgauthier.com/grokkit/internal/git"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// gitDiff is the mockable entry point (exactly like gitRun used elsewhere).
|
||||||
|
var gitDiff = git.Diff
|
||||||
|
|
||||||
var prDescribeCmd = &cobra.Command{
|
var prDescribeCmd = &cobra.Command{
|
||||||
Use: "pr-describe",
|
Use: "pr-describe",
|
||||||
Short: "Generate full PR description from current branch",
|
Short: "Generate full PR description from current branch",
|
||||||
@ -15,30 +20,32 @@ var prDescribeCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
prDescribeCmd.Flags().StringP("base", "b", "master", "Base branch to compare against")
|
prDescribeCmd.Flags().StringP("base", "b", "master", "Base branch to compare against (default: master)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func runPRDescribe(cmd *cobra.Command, _ []string) {
|
func runPRDescribe(cmd *cobra.Command, _ []string) {
|
||||||
base, _ := cmd.Flags().GetString("base")
|
base, _ := cmd.Flags().GetString("base")
|
||||||
|
|
||||||
diff, err := gitRun([]string{"diff", fmt.Sprintf("%s..HEAD", base), "--no-color"})
|
// Prefer remote (more likely up-to-date for PRs), fallback to local.
|
||||||
if err != nil || diff == "" {
|
diff, err := gitDiff([]string{"diff", fmt.Sprintf("origin/%s..HEAD", base), "--no-color"})
|
||||||
diff, err = gitRun([]string{"diff", fmt.Sprintf("origin/%s..HEAD", base), "--no-color"})
|
if err != nil || strings.TrimSpace(diff) == "" {
|
||||||
|
diff, err = gitDiff([]string{"diff", fmt.Sprintf("%s..HEAD", base), "--no-color"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
color.Red("Failed to get branch diff: %v", err)
|
color.Red("Failed to get branch diff: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if diff == "" {
|
if strings.TrimSpace(diff) == "" {
|
||||||
color.Yellow("No changes on this branch compared to %s/origin/%s.", base, base)
|
color.Yellow("No changes on this branch compared to %s/origin/%s.", base, base)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
modelFlag, _ := cmd.Flags().GetString("model")
|
modelFlag, _ := cmd.Flags().GetString("model")
|
||||||
model := config.GetModel("prdescribe", modelFlag)
|
model := config.GetModel("prdescribe", modelFlag)
|
||||||
|
|
||||||
client := newGrokClient()
|
client := newGrokClient()
|
||||||
messages := buildPRDescribeMessages(diff)
|
messages := buildPRDescribeMessages(diff)
|
||||||
color.Yellow("Writing PR description...")
|
color.Yellow("Writing PR description (base=%s)...", base)
|
||||||
client.Stream(messages, model)
|
client.Stream(messages, model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
149
cmd/recipe_test.go
Normal file
149
cmd/recipe_test.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveRecipePathDirectFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
recipePath := filepath.Join(tmpDir, "my-recipe.md")
|
||||||
|
if err := os.WriteFile(recipePath, []byte("# Recipe\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := resolveRecipePath(recipePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveRecipePath(%q) failed: %v", recipePath, err)
|
||||||
|
}
|
||||||
|
if got != recipePath {
|
||||||
|
t.Errorf("resolveRecipePath() = %q, want %q", got, recipePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRecipePathDirectFileMissing(t *testing.T) {
|
||||||
|
_, err := resolveRecipePath("/nonexistent/recipe.md")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing recipe file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRecipePathProjectLocal(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Create .grokkit/recipes with a recipe, and a .git dir for project root detection
|
||||||
|
recipeDir := filepath.Join(tmpDir, ".grokkit", "recipes")
|
||||||
|
if err := os.MkdirAll(recipeDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
recipePath := filepath.Join(recipeDir, "refactor.md")
|
||||||
|
if err := os.WriteFile(recipePath, []byte("# Refactor\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := resolveRecipePath("refactor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveRecipePath('refactor') failed: %v", err)
|
||||||
|
}
|
||||||
|
if got != recipePath {
|
||||||
|
t.Errorf("resolveRecipePath() = %q, want %q", got, recipePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRecipePathNotFound(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Create .git so findProjectRoot works, but no recipes
|
||||||
|
if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override HOME to empty dir so global fallback also fails
|
||||||
|
oldHome := os.Getenv("HOME")
|
||||||
|
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
||||||
|
_ = os.Setenv("HOME", tmpDir)
|
||||||
|
|
||||||
|
_, err := resolveRecipePath("nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent recipe, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindProjectRoot(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
marker string
|
||||||
|
}{
|
||||||
|
{"git repo", ".git"},
|
||||||
|
{"gitignore", ".gitignore"},
|
||||||
|
{"grokkit dir", ".grokkit"},
|
||||||
|
{"go module", "go.mod"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
|
||||||
|
// Create a subdirectory and chdir into it
|
||||||
|
subDir := filepath.Join(tmpDir, "sub", "deep")
|
||||||
|
if err := os.MkdirAll(subDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chdir(subDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Place the marker at the root
|
||||||
|
markerPath := filepath.Join(tmpDir, tt.marker)
|
||||||
|
if tt.marker == ".git" || tt.marker == ".grokkit" {
|
||||||
|
if err := os.MkdirAll(markerPath, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := os.WriteFile(markerPath, []byte(""), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := findProjectRoot()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("findProjectRoot() failed: %v", err)
|
||||||
|
}
|
||||||
|
if root != tmpDir {
|
||||||
|
t.Errorf("findProjectRoot() = %q, want %q", root, tmpDir)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindProjectRootNotInProject(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Empty dir — no markers at all. This will walk up to / and fail.
|
||||||
|
_, err := findProjectRoot()
|
||||||
|
if err == nil {
|
||||||
|
// Might succeed if a parent dir has .git — that's OK in CI
|
||||||
|
t.Log("findProjectRoot() succeeded (likely found .git in a parent dir)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -65,6 +65,7 @@ func init() {
|
|||||||
rootCmd.AddCommand(scaffoldCmd)
|
rootCmd.AddCommand(scaffoldCmd)
|
||||||
rootCmd.AddCommand(queryCmd)
|
rootCmd.AddCommand(queryCmd)
|
||||||
rootCmd.AddCommand(analyzeCmd)
|
rootCmd.AddCommand(analyzeCmd)
|
||||||
|
rootCmd.AddCommand(workonCmd)
|
||||||
|
|
||||||
// Add model flag to all commands
|
// Add model flag to all commands
|
||||||
rootCmd.PersistentFlags().StringP("model", "m", "", "Grok model to use (overrides config)")
|
rootCmd.PersistentFlags().StringP("model", "m", "", "Grok model to use (overrides config)")
|
||||||
|
|||||||
@ -47,6 +47,13 @@ func withMockGit(fn func([]string) (string, error)) func() {
|
|||||||
return func() { gitRun = orig }
|
return func() { gitRun = orig }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withMockGitDiff injects a fake for the new git.Diff (mockable var).
|
||||||
|
func withMockGitDiff(fn func([]string) (string, error)) func() {
|
||||||
|
orig := gitDiff
|
||||||
|
gitDiff = fn
|
||||||
|
return func() { gitDiff = orig }
|
||||||
|
}
|
||||||
|
|
||||||
// testCmd returns a minimal cobra command with common flags registered.
|
// testCmd returns a minimal cobra command with common flags registered.
|
||||||
func testCmd() *cobra.Command {
|
func testCmd() *cobra.Command {
|
||||||
c := &cobra.Command{}
|
c := &cobra.Command{}
|
||||||
@ -260,7 +267,7 @@ func TestRunPRDescribe(t *testing.T) {
|
|||||||
t.Run("no changes on branch — skips AI", func(t *testing.T) {
|
t.Run("no changes on branch — skips AI", func(t *testing.T) {
|
||||||
mock := &mockStreamer{}
|
mock := &mockStreamer{}
|
||||||
defer withMockClient(mock)()
|
defer withMockClient(mock)()
|
||||||
defer withMockGit(func(args []string) (string, error) {
|
defer withMockGitDiff(func(args []string) (string, error) {
|
||||||
return "", nil // both diff calls return empty
|
return "", nil // both diff calls return empty
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@ -275,7 +282,7 @@ func TestRunPRDescribe(t *testing.T) {
|
|||||||
mock := &mockStreamer{response: "## PR Title\n\nDescription"}
|
mock := &mockStreamer{response: "## PR Title\n\nDescription"}
|
||||||
defer withMockClient(mock)()
|
defer withMockClient(mock)()
|
||||||
callCount := 0
|
callCount := 0
|
||||||
defer withMockGit(func(args []string) (string, error) {
|
defer withMockGitDiff(func(args []string) (string, error) {
|
||||||
callCount++
|
callCount++
|
||||||
if callCount == 1 {
|
if callCount == 1 {
|
||||||
return "diff --git a/foo.go b/foo.go", nil
|
return "diff --git a/foo.go b/foo.go", nil
|
||||||
@ -294,7 +301,7 @@ func TestRunPRDescribe(t *testing.T) {
|
|||||||
mock := &mockStreamer{response: "PR description"}
|
mock := &mockStreamer{response: "PR description"}
|
||||||
defer withMockClient(mock)()
|
defer withMockClient(mock)()
|
||||||
callCount := 0
|
callCount := 0
|
||||||
defer withMockGit(func(args []string) (string, error) {
|
defer withMockGitDiff(func(args []string) (string, error) {
|
||||||
callCount++
|
callCount++
|
||||||
if callCount == 2 {
|
if callCount == 2 {
|
||||||
return "diff --git a/bar.go b/bar.go", nil
|
return "diff --git a/bar.go b/bar.go", nil
|
||||||
@ -313,7 +320,7 @@ func TestRunPRDescribe(t *testing.T) {
|
|||||||
mock := &mockStreamer{response: "PR description"}
|
mock := &mockStreamer{response: "PR description"}
|
||||||
defer withMockClient(mock)()
|
defer withMockClient(mock)()
|
||||||
var capturedArgs []string
|
var capturedArgs []string
|
||||||
defer withMockGit(func(args []string) (string, error) {
|
defer withMockGitDiff(func(args []string) (string, error) {
|
||||||
capturedArgs = args
|
capturedArgs = args
|
||||||
return "diff content", nil
|
return "diff content", nil
|
||||||
})()
|
})()
|
||||||
@ -327,8 +334,8 @@ func TestRunPRDescribe(t *testing.T) {
|
|||||||
if mock.calls != 1 {
|
if mock.calls != 1 {
|
||||||
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||||||
}
|
}
|
||||||
// Expect "diff", "develop..HEAD", "--no-color"
|
// Expect "diff", "origin/develop..HEAD", "--no-color"
|
||||||
expectedArg := "develop..HEAD"
|
expectedArg := "origin/develop..HEAD"
|
||||||
found := false
|
found := false
|
||||||
for _, arg := range capturedArgs {
|
for _, arg := range capturedArgs {
|
||||||
if arg == expectedArg {
|
if arg == expectedArg {
|
||||||
@ -345,7 +352,7 @@ func TestRunPRDescribe(t *testing.T) {
|
|||||||
mock := &mockStreamer{response: "PR description"}
|
mock := &mockStreamer{response: "PR description"}
|
||||||
defer withMockClient(mock)()
|
defer withMockClient(mock)()
|
||||||
var capturedArgs []string
|
var capturedArgs []string
|
||||||
defer withMockGit(func(args []string) (string, error) {
|
defer withMockGitDiff(func(args []string) (string, error) {
|
||||||
capturedArgs = args
|
capturedArgs = args
|
||||||
return "diff content", nil
|
return "diff content", nil
|
||||||
})()
|
})()
|
||||||
@ -355,7 +362,7 @@ func TestRunPRDescribe(t *testing.T) {
|
|||||||
if mock.calls != 1 {
|
if mock.calls != 1 {
|
||||||
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||||||
}
|
}
|
||||||
expectedArg := "master..HEAD"
|
expectedArg := "origin/master..HEAD"
|
||||||
found := false
|
found := false
|
||||||
for _, arg := range capturedArgs {
|
for _, arg := range capturedArgs {
|
||||||
if arg == expectedArg {
|
if arg == expectedArg {
|
||||||
@ -389,7 +396,7 @@ func TestRunLintFileNotFound(t *testing.T) {
|
|||||||
|
|
||||||
func TestProcessDocsFileNotFound(t *testing.T) {
|
func TestProcessDocsFileNotFound(t *testing.T) {
|
||||||
mock := &mockStreamer{}
|
mock := &mockStreamer{}
|
||||||
processDocsFile(mock, "grok-4", "/nonexistent/path/file.go")
|
ProcessDocsFile(mock, "grok-4", "/nonexistent/path/file.go")
|
||||||
|
|
||||||
if mock.calls != 0 {
|
if mock.calls != 0 {
|
||||||
t.Errorf("expected 0 AI calls for missing file, got %d", mock.calls)
|
t.Errorf("expected 0 AI calls for missing file, got %d", mock.calls)
|
||||||
@ -408,7 +415,7 @@ func TestProcessDocsFileUnsupportedLanguage(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(f.Name()) }()
|
defer func() { _ = os.Remove(f.Name()) }()
|
||||||
|
|
||||||
mock := &mockStreamer{}
|
mock := &mockStreamer{}
|
||||||
processDocsFile(mock, "grok-4", f.Name())
|
ProcessDocsFile(mock, "grok-4", f.Name())
|
||||||
|
|
||||||
if mock.calls != 0 {
|
if mock.calls != 0 {
|
||||||
t.Errorf("expected 0 AI calls for unsupported language, got %d", mock.calls)
|
t.Errorf("expected 0 AI calls for unsupported language, got %d", mock.calls)
|
||||||
@ -446,7 +453,7 @@ func TestProcessDocsFilePreviewAndCancel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer func() { os.Stdin = origStdin }()
|
defer func() { os.Stdin = origStdin }()
|
||||||
|
|
||||||
processDocsFile(mock, "grok-4", f.Name())
|
ProcessDocsFile(mock, "grok-4", f.Name())
|
||||||
|
|
||||||
if mock.calls != 1 {
|
if mock.calls != 1 {
|
||||||
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||||||
@ -476,7 +483,7 @@ func TestProcessDocsFileAutoApply(t *testing.T) {
|
|||||||
autoApply = true
|
autoApply = true
|
||||||
defer func() { autoApply = origAutoApply }()
|
defer func() { autoApply = origAutoApply }()
|
||||||
|
|
||||||
processDocsFile(mock, "grok-4", f.Name())
|
ProcessDocsFile(mock, "grok-4", f.Name())
|
||||||
|
|
||||||
if mock.calls != 1 {
|
if mock.calls != 1 {
|
||||||
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
t.Errorf("expected 1 AI call, got %d", mock.calls)
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@ -16,6 +18,83 @@ func TestScaffoldCmd(t *testing.T) {
|
|||||||
assert.True(t, true, "command is registered and basic structure is intact")
|
assert.True(t, true, "command is registered and basic structure is intact")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetectLanguage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
override string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"Go file", "main.go", "", "Go"},
|
||||||
|
{"Python file", "script.py", "", "Python"},
|
||||||
|
{"JS file", "app.js", "", "TypeScript"},
|
||||||
|
{"TS file", "app.ts", "", "TypeScript"},
|
||||||
|
{"C file", "main.c", "", "C"},
|
||||||
|
{"C++ file", "main.cpp", "", "C++"},
|
||||||
|
{"Java file", "Main.java", "", "Java"},
|
||||||
|
{"unknown extension", "data.xyz", "", "code"},
|
||||||
|
{"override takes precedence", "main.go", "Rust", "Rust"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := detectLanguage(tt.path, tt.override)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("detectLanguage(%q, %q) = %q, want %q", tt.path, tt.override, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHarvestContext(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create the target file
|
||||||
|
targetPath := filepath.Join(tmpDir, "target.go")
|
||||||
|
if err := os.WriteFile(targetPath, []byte("package main\n\nfunc Target() {}\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a sibling .go file (should be included)
|
||||||
|
siblingPath := filepath.Join(tmpDir, "sibling.go")
|
||||||
|
if err := os.WriteFile(siblingPath, []byte("package main\n\nfunc Sibling() {}\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a non-.go sibling (should not be included)
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("notes"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := harvestContext(targetPath, "Go")
|
||||||
|
|
||||||
|
if !strings.Contains(ctx, "sibling.go") {
|
||||||
|
t.Error("expected sibling.go in context")
|
||||||
|
}
|
||||||
|
if !strings.Contains(ctx, "func Sibling()") {
|
||||||
|
t.Error("expected sibling content in context")
|
||||||
|
}
|
||||||
|
if strings.Contains(ctx, "target.go") {
|
||||||
|
t.Error("target file should not appear in its own context")
|
||||||
|
}
|
||||||
|
if strings.Contains(ctx, "notes.txt") {
|
||||||
|
t.Error("non-matching extension should not appear in context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHarvestContextEmptyDir(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
targetPath := filepath.Join(tmpDir, "lonely.go")
|
||||||
|
if err := os.WriteFile(targetPath, []byte("package main"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := harvestContext(targetPath, "Go")
|
||||||
|
if ctx != "" {
|
||||||
|
t.Errorf("expected empty context for solo file, got: %q", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestScaffoldCmd_Live — LIVE INTEGRATION TEST (only runs when you ask)
|
// TestScaffoldCmd_Live — LIVE INTEGRATION TEST (only runs when you ask)
|
||||||
func TestScaffoldCmd_Live(t *testing.T) {
|
func TestScaffoldCmd_Live(t *testing.T) {
|
||||||
if !testing.Short() {
|
if !testing.Short() {
|
||||||
|
|||||||
60
cmd/workon.go
Normal file
60
cmd/workon.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"gmgauthier.com/grokkit/internal/logger"
|
||||||
|
"gmgauthier.com/grokkit/internal/workon"
|
||||||
|
)
|
||||||
|
|
||||||
|
var workonCmd = &cobra.Command{
|
||||||
|
Use: "workon <todo_item_title>",
|
||||||
|
Short: "Start or complete work on a todo item or fix",
|
||||||
|
Long: `workon automates starting or completing a todo/fix:
|
||||||
|
- Moves queued todo to doing/ or creates new fix .md
|
||||||
|
- Creates matching git branch
|
||||||
|
- Generates + appends "Work Plan" via Grok
|
||||||
|
- Commits to the branch
|
||||||
|
- Optional: cnadd log + open in configured IDE
|
||||||
|
|
||||||
|
Purely transactional. See todo/doing/workon.md for full spec.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
title := strings.ReplaceAll(args[0], " ", "-")
|
||||||
|
customMsg, _ := cmd.Flags().GetString("message")
|
||||||
|
isFix, _ := cmd.Flags().GetBool("fix")
|
||||||
|
isComplete, _ := cmd.Flags().GetBool("complete")
|
||||||
|
|
||||||
|
if isComplete && customMsg != "" {
|
||||||
|
return fmt.Errorf("-c cannot be combined with -m")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("workon starting", "title", title, "fix", isFix, "complete", isComplete)
|
||||||
|
|
||||||
|
if err := workon.Run(title, customMsg, isFix, isComplete); err != nil {
|
||||||
|
color.Red("workon failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := "todo"
|
||||||
|
if isFix {
|
||||||
|
mode = "fix"
|
||||||
|
}
|
||||||
|
action := "started"
|
||||||
|
if isComplete {
|
||||||
|
action = "completed"
|
||||||
|
}
|
||||||
|
color.Green("workon %s: %s (%s)", action, title, mode)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
workonCmd.Flags().StringP("message", "M", "", "Custom commit message (default: \"Start working on <todo_item_title>\")")
|
||||||
|
workonCmd.Flags().BoolP("fix", "f", false, "Treat as fix instead of todo (create new .md in doing/)")
|
||||||
|
workonCmd.Flags().BoolP("complete", "c", false, "Complete the item (move to completed/; exclusive)")
|
||||||
|
}
|
||||||
107
cmd/workon_test.go
Normal file
107
cmd/workon_test.go
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWorkonCmd — FAST DEFAULT TEST (runs on make test / go test ./cmd)
|
||||||
|
func TestWorkonCmd(t *testing.T) {
|
||||||
|
t.Log("✓ Fast workon unit test (no Grok API call)")
|
||||||
|
|
||||||
|
// Verify the command is registered
|
||||||
|
found := false
|
||||||
|
for _, c := range rootCmd.Commands() {
|
||||||
|
if c.Use == "workon <todo_item_title>" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("workon command not registered on rootCmd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkonFlagRegistration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
flagName string
|
||||||
|
shorthand string
|
||||||
|
}{
|
||||||
|
{"message flag", "message", "M"},
|
||||||
|
{"fix flag", "fix", "f"},
|
||||||
|
{"complete flag", "complete", "c"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
flag := workonCmd.Flags().Lookup(tt.flagName)
|
||||||
|
if flag == nil {
|
||||||
|
t.Fatalf("flag %q not found on workon command", tt.flagName)
|
||||||
|
}
|
||||||
|
if flag.Shorthand != tt.shorthand {
|
||||||
|
t.Errorf("flag %q shorthand = %q, want %q", tt.flagName, flag.Shorthand, tt.shorthand)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkonFlagExclusivity(t *testing.T) {
|
||||||
|
// Run in a temp dir so todo.Bootstrap() doesn't leave orphan dirs in cmd/
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "complete alone is valid",
|
||||||
|
args: []string{"workon", "test-item", "-c"},
|
||||||
|
expectErr: false, // will fail at Run level (no git repo), but not at flag validation
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complete with fix is valid",
|
||||||
|
args: []string{"workon", "test-item", "-c", "-f"},
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complete with message is invalid",
|
||||||
|
args: []string{"workon", "test-item", "-c", "-M", "some msg"},
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
rootCmd.SetArgs(tt.args)
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
|
||||||
|
if tt.expectErr && err == nil {
|
||||||
|
t.Error("expected error, got nil")
|
||||||
|
}
|
||||||
|
// Note: non-error cases may still fail (no git, no API key, etc.)
|
||||||
|
// We only assert that the flag validation error fires when expected
|
||||||
|
if tt.expectErr && err != nil {
|
||||||
|
// Verify it's the right error
|
||||||
|
if err.Error() != "-c cannot be combined with -m" {
|
||||||
|
// The error might be wrapped
|
||||||
|
t.Logf("got error: %v (flag validation may have been triggered)", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkonRequiresArgs(t *testing.T) {
|
||||||
|
rootCmd.SetArgs([]string{"workon"})
|
||||||
|
err := rootCmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when no args provided, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,6 +23,8 @@ fast = "grok-4-1-fast-non-reasoning"
|
|||||||
history.model = "grok-4"
|
history.model = "grok-4"
|
||||||
prdescribe.model = "grok-4"
|
prdescribe.model = "grok-4"
|
||||||
review.model = "grok-4"
|
review.model = "grok-4"
|
||||||
|
workon.model = "grok-4-1-fast-non-reasoning" # Fast model for work plan generation
|
||||||
|
workon.ide = "" # IDE command to open repo (e.g. "code", "goland")
|
||||||
|
|
||||||
# Chat history settings
|
# Chat history settings
|
||||||
[chat]
|
[chat]
|
||||||
|
|||||||
@ -38,6 +38,13 @@ func Load() {
|
|||||||
viper.SetDefault("commands.review.model", "grok-4")
|
viper.SetDefault("commands.review.model", "grok-4")
|
||||||
viper.SetDefault("commands.docs.model", "grok-4")
|
viper.SetDefault("commands.docs.model", "grok-4")
|
||||||
viper.SetDefault("commands.query.model", "grok-4-1-fast-non-reasoning")
|
viper.SetDefault("commands.query.model", "grok-4-1-fast-non-reasoning")
|
||||||
|
viper.SetDefault("commands.workon.model", "grok-4-1-fast-non-reasoning")
|
||||||
|
viper.SetDefault("commands.workon.ide", "")
|
||||||
|
|
||||||
|
// MCP configuration
|
||||||
|
viper.SetDefault("mcp.enabled", true)
|
||||||
|
viper.SetDefault("mcp.tools", []string{"lint_code", "analyze_code", "generate_docs", "generate_tests", "generate_commit_msg", "run_recipe"})
|
||||||
|
viper.SetDefault("mcp.resources", []string{"recipes://local", "recipes://global", "prompts://language"})
|
||||||
|
|
||||||
// Config file is optional, so we ignore read errors
|
// Config file is optional, so we ignore read errors
|
||||||
_ = viper.ReadInConfig()
|
_ = viper.ReadInConfig()
|
||||||
@ -72,6 +79,11 @@ func GetTimeout() int {
|
|||||||
return timeout
|
return timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetIDECommand returns the configured IDE command for workon, or empty string if unset.
|
||||||
|
func GetIDECommand() string {
|
||||||
|
return viper.GetString("commands.workon.ide")
|
||||||
|
}
|
||||||
|
|
||||||
// GetLogLevel returns the log level from the configuration
|
// GetLogLevel returns the log level from the configuration
|
||||||
func GetLogLevel() string {
|
func GetLogLevel() string {
|
||||||
return viper.GetString("log_level")
|
return viper.GetString("log_level")
|
||||||
|
|||||||
@ -136,6 +136,26 @@ func TestGetTimeout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetIDECommand(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
|
||||||
|
t.Run("empty by default", func(t *testing.T) {
|
||||||
|
got := GetIDECommand()
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("GetIDECommand() default = %q, want empty", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns configured value", func(t *testing.T) {
|
||||||
|
viper.Set("commands.workon.ide", "code")
|
||||||
|
got := GetIDECommand()
|
||||||
|
if got != "code" {
|
||||||
|
t.Errorf("GetIDECommand() = %q, want 'code'", got)
|
||||||
|
}
|
||||||
|
viper.Set("commands.workon.ide", "") // reset
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetLogLevel(t *testing.T) {
|
func TestGetLogLevel(t *testing.T) {
|
||||||
viper.Reset()
|
viper.Reset()
|
||||||
viper.SetDefault("log_level", "info")
|
viper.SetDefault("log_level", "info")
|
||||||
|
|||||||
@ -35,7 +35,7 @@ grokkit analyze --model grok-4
|
|||||||
| Flag | Short | Description | Default |
|
| Flag | Short | Description | Default |
|
||||||
|----------|-------|----------------------------|-------------------|
|
|----------|-------|----------------------------|-------------------|
|
||||||
| --dir | | Repository root to analyze | Current directory |
|
| --dir | | Repository root to analyze | Current directory |
|
||||||
| --output | -o | Output file (- for stdout) | analyze.md |
|
| --output | -o | Output file (- for stdout) | .grokkit/analysis.md |
|
||||||
| --yes | -y | Skip confirmation prompt | false |
|
| --yes | -y | Skip confirmation prompt | false |
|
||||||
| --model | -m | Override model | from config |
|
| --model | -m | Override model | from config |
|
||||||
|
|
||||||
|
|||||||
@ -24,12 +24,15 @@ Welcome to the full user documentation for **Grokkit** — a fast, native Go CLI
|
|||||||
- **[📋 PR-Describe](pr-describe.md)** — Generate full PR descriptions.
|
- **[📋 PR-Describe](pr-describe.md)** — Generate full PR descriptions.
|
||||||
- **[📜 History](history.md)** — Summarize recent git history.
|
- **[📜 History](history.md)** — Summarize recent git history.
|
||||||
|
|
||||||
|
### Productivity Commands
|
||||||
|
- **[🔨 Workon](workon.md)** — Automate starting and completing todo items and fixes with AI work plans.
|
||||||
|
|
||||||
### Other Useful Commands
|
### Other Useful Commands
|
||||||
- **[💬 Chat](chat.md)** — Full interactive chat with history and streaming
|
- **[💬 Chat](chat.md)** — Full interactive chat with history and streaming
|
||||||
- **[🤖 Query](query.md)** — One-shot programming-focused queries
|
- **[🤖 Query](query.md)** — One-shot programming-focused queries
|
||||||
- **[🔍 Review](review.md)** — AI code review of the current repo/directory
|
- **[🔍 Review](review.md)** — AI code review of the current repo/directory
|
||||||
- **[🔧 Lint](lint.md)** — Lint + AI-suggested fixes
|
- **[🔧 Lint](lint.md)** — Lint + AI-suggested fixes
|
||||||
- - **[🔎 Analyze](analyze.md)** — Deep educational analysis of any codebase with custom language prompts.
|
- **[🔎 Analyze](analyze.md)** — Deep educational analysis of any codebase with custom language prompts.
|
||||||
- **[📖 Docs](docs.md)** — Generate documentation comments
|
- **[📖 Docs](docs.md)** — Generate documentation comments
|
||||||
- **[Completion](completion.md)** — Generate shell completion scripts
|
- **[Completion](completion.md)** — Generate shell completion scripts
|
||||||
- **[🏷️ Version](version.md)** — Print version information
|
- **[🏷️ Version](version.md)** — Print version information
|
||||||
|
|||||||
61
docs/user-guide/mcp.md
Normal file
61
docs/user-guide/mcp.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# MCP Server Mode (`grokkit mcp`)
|
||||||
|
|
||||||
|
Grokkit can run as an **MCP (Model Context Protocol)** server, allowing AI coding agents like **Claude Code**, Cursor, or other MCP-compatible clients to call Grokkit tools directly during their workflows.
|
||||||
|
|
||||||
|
## What is MCP?
|
||||||
|
|
||||||
|
MCP is a protocol that lets LLMs call external tools and access resources in a standardized way. By running `grokkit mcp`, you turn Grokkit into a service that other AI tools can use.
|
||||||
|
|
||||||
|
## Starting the MCP Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grokkit mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts the server over **stdio** (standard input/output), which is the default transport used by Claude Code and most MCP clients.
|
||||||
|
|
||||||
|
The server will run until the client disconnects.
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
When running in MCP mode, the following tools are available:
|
||||||
|
|
||||||
|
- **`lint_code`** — Run linter on a file and return results
|
||||||
|
- **`analyze_code`** — Deep project analysis with educational report
|
||||||
|
- **`generate_docs`** — Generate documentation comments for a source file
|
||||||
|
- **`generate_tests`** — Generate unit tests for supported languages
|
||||||
|
- **`generate_commit_msg`** — Generate conventional commit message from staged changes
|
||||||
|
- **`run_recipe`** — Execute a named recipe from `.grokkit/recipes/`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
You can control MCP behavior in your config file (`~/.config/grokkit/config.toml` or `./config.toml`):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[mcp]
|
||||||
|
enabled = true
|
||||||
|
tools = ["lint_code", "analyze_code", "generate_docs"]
|
||||||
|
resources = ["recipes://local", "prompts://language"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Usage with Claude Code
|
||||||
|
|
||||||
|
Once `grokkit mcp` is running, you can tell Claude Code:
|
||||||
|
|
||||||
|
> "Use the grokkit MCP server to lint this file and then generate tests for it."
|
||||||
|
|
||||||
|
Claude can then call the tools directly.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
MCP also exposes resources:
|
||||||
|
|
||||||
|
- `recipes://local` — List of local recipes
|
||||||
|
- `recipes://global` — List of global recipes
|
||||||
|
- `prompts://language` — Available language prompts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**See also:**
|
||||||
|
- [Developer Guide](../developer-guide/mcp.md) for technical details
|
||||||
|
- [Main README](../../README.md) for installation
|
||||||
109
docs/user-guide/workon.md
Normal file
109
docs/user-guide/workon.md
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# 🔨 Workon Guide
|
||||||
|
|
||||||
|
The `workon` command automates starting and completing work on todo items and fixes. It integrates with Grokkit's in-repo todo system, Git branching, and Grok AI to keep your workflow uniform and consistent.
|
||||||
|
|
||||||
|
### Why use Workon?
|
||||||
|
|
||||||
|
- **Automated Workflow**: Moves todo items, creates branches, generates work plans, and commits — all in one command.
|
||||||
|
- **AI Work Plans**: Grok generates a concrete, numbered implementation plan tailored to each item.
|
||||||
|
- **Branch Conventions**: Automatically prefixes branches with `feature/` or `fix/` for clean git history.
|
||||||
|
- **Completion Tracking**: Moves items to completed, updates the todo index, and commits the change.
|
||||||
|
- **Optional Integrations**: Logs work start via `cnadd` and opens your configured IDE automatically.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start working on a queued todo item
|
||||||
|
grokkit workon my-todo-item
|
||||||
|
|
||||||
|
# Start a fix (creates a new .md in doing/)
|
||||||
|
grokkit workon my-bugfix -f
|
||||||
|
|
||||||
|
# Custom commit message
|
||||||
|
grokkit workon my-feature -M "feat: begin API redesign"
|
||||||
|
|
||||||
|
# Complete a todo item (move to completed/, update index)
|
||||||
|
grokkit workon my-todo-item -c
|
||||||
|
|
||||||
|
# Complete a fix (move to completed/, no index update)
|
||||||
|
grokkit workon my-bugfix -c -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Flag | Short | Description | Default |
|
||||||
|
|-----------|-------|-----------------------------------------------------|------------------------------------------|
|
||||||
|
| --message | -M | Custom commit message | "Start working on \<todo_item_title\>" |
|
||||||
|
| --fix | -f | Treat as fix instead of todo | false |
|
||||||
|
| --complete| -c | Complete the item (cannot be combined with -M) | false |
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
#### Starting Work (default)
|
||||||
|
|
||||||
|
1. **Bootstraps todo structure** — Creates `todo/queued/`, `todo/doing/`, and `todo/completed/` directories if they don't exist.
|
||||||
|
2. **Moves or creates the item**:
|
||||||
|
- **Todo mode** (default): Moves `todo/queued/<title>.md` to `todo/doing/<title>.md`.
|
||||||
|
- **Fix mode** (`-f`): Creates a new `todo/doing/<title>.md` with a skeleton `## Work Plan` heading.
|
||||||
|
3. **Creates a git branch** — `feature/<title>` for todos, `fix/<title>` for fixes. If the branch already exists, checks it out instead.
|
||||||
|
4. **Generates a Work Plan** — Sends the item content to Grok and appends a numbered implementation plan to the markdown file.
|
||||||
|
5. **Commits** — Stages the `todo/` directory and commits with the default or custom message.
|
||||||
|
6. **Optional post-steps** (graceful fallbacks if unavailable):
|
||||||
|
- Logs the start of work via `cnadd` (if installed).
|
||||||
|
- Opens the repository in your configured IDE.
|
||||||
|
|
||||||
|
#### Completing Work (`-c`)
|
||||||
|
|
||||||
|
1. Moves `todo/doing/<title>.md` to `todo/completed/<title>.md`.
|
||||||
|
2. For non-fix items: updates `todo/README.md` — removes the entry from `## Queued` and appends it to `## Completed`.
|
||||||
|
3. Commits the change with the message `Complete work on <title>`.
|
||||||
|
|
||||||
|
### Todo Folder Structure
|
||||||
|
|
||||||
|
Workon assumes and maintains this in-repo ticketing layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
todo/
|
||||||
|
README.md # Index of all items (Queued / Completed sections)
|
||||||
|
queued/ # Items waiting to be worked on
|
||||||
|
my-feature.md
|
||||||
|
another-item.md
|
||||||
|
doing/ # Items currently in progress
|
||||||
|
active-item.md
|
||||||
|
completed/ # Finished items
|
||||||
|
done-item.md
|
||||||
|
```
|
||||||
|
|
||||||
|
If the `todo/` folder doesn't exist, `workon` creates it automatically.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The workon command respects two config keys in `~/.config/grokkit/config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[commands]
|
||||||
|
workon.model = "grok-4-1-fast-non-reasoning" # Model for work plan generation
|
||||||
|
workon.ide = "code" # IDE command to open repo (e.g. "code", "goland", "nvim")
|
||||||
|
```
|
||||||
|
|
||||||
|
If `workon.ide` is empty or unset, the IDE step is silently skipped. If `cnadd` is not installed, logging is silently skipped.
|
||||||
|
|
||||||
|
### Title Handling
|
||||||
|
|
||||||
|
- Spaces in the title are automatically replaced with dashes.
|
||||||
|
- The title is used as both the markdown filename (without prefix) and the branch name (with `feature/` or `fix/` prefix).
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full workflow: queue an item, work on it, complete it
|
||||||
|
echo "# Add caching" > todo/queued/add-caching.md
|
||||||
|
grokkit workon add-caching
|
||||||
|
# ... do the work ...
|
||||||
|
grokkit workon add-caching -c
|
||||||
|
|
||||||
|
# Quick fix workflow
|
||||||
|
grokkit workon fix-null-pointer -f
|
||||||
|
# ... fix the bug ...
|
||||||
|
grokkit workon fix-null-pointer -c -f
|
||||||
|
```
|
||||||
6
go.mod
6
go.mod
@ -7,13 +7,17 @@ require (
|
|||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/mark3labs/mcp-go v0.47.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
@ -24,8 +28,8 @@ require (
|
|||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/text v0.28.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
8
go.sum
8
go.sum
@ -11,12 +11,18 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
|
|||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mark3labs/mcp-go v0.47.0 h1:h44yeM3DduDyQgzImYWu4pt6VRkqP/0p/95AGhWngnA=
|
||||||
|
github.com/mark3labs/mcp-go v0.47.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
@ -48,6 +54,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
63
internal/analyze/analyze.go
Normal file
63
internal/analyze/analyze.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package analyze
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gmgauthier.com/grokkit/internal/git"
|
||||||
|
"gmgauthier.com/grokkit/internal/linter"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiscoverSourceFiles finds all supported source files in a directory tree.
|
||||||
|
// Exported for use by MCP server and other packages.
|
||||||
|
func DiscoverSourceFiles(root string) ([]string, error) {
|
||||||
|
var files []string
|
||||||
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := info.Name()
|
||||||
|
if info.IsDir() {
|
||||||
|
if strings.HasPrefix(name, ".") && name != "." && name != ".." ||
|
||||||
|
name == "node_modules" || name == "vendor" || name == "build" || name == "dist" {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, detectErr := linter.DetectLanguage(path); detectErr == nil {
|
||||||
|
files = append(files, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return files, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildProjectContext builds a context string for analysis prompts.
|
||||||
|
// Exported for use by MCP server.
|
||||||
|
func BuildProjectContext(dir string, files []string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Project Root: " + dir + "\n\n")
|
||||||
|
_, _ = fmt.Fprintf(&sb, "Total source files discovered: %d\n\n", len(files))
|
||||||
|
|
||||||
|
sb.WriteString("Key files (top-level view):\n")
|
||||||
|
for _, f := range files {
|
||||||
|
rel, _ := filepath.Rel(dir, f)
|
||||||
|
if strings.Count(rel, string(filepath.Separator)) <= 2 {
|
||||||
|
sb.WriteString(" - " + rel + "\n")
|
||||||
|
}
|
||||||
|
if sb.Len() > 2500 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add git remotes if available
|
||||||
|
if out, err := git.Run([]string{"remote", "-v"}); err == nil && out != "" {
|
||||||
|
sb.WriteString("\nGit Remotes:\n" + out + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
50
internal/analyze/analyze_test.go
Normal file
50
internal/analyze/analyze_test.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package analyze
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiscoverSourceFiles(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create some source files
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "helper.go"), []byte("package main"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Create a non-source file (should be ignored)
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("notes"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := DiscoverSourceFiles(tmpDir)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.GreaterOrEqual(t, len(files), 2)
|
||||||
|
|
||||||
|
// Verify .txt was not included
|
||||||
|
for _, f := range files {
|
||||||
|
assert.False(t, strings.HasSuffix(f, ".txt"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildProjectContext(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
files := []string{
|
||||||
|
filepath.Join(tmpDir, "main.go"),
|
||||||
|
filepath.Join(tmpDir, "cmd", "root.go"),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := BuildProjectContext(tmpDir, files)
|
||||||
|
|
||||||
|
assert.Contains(t, ctx, "Project Root:")
|
||||||
|
assert.Contains(t, ctx, "Total source files discovered: 2")
|
||||||
|
assert.Contains(t, ctx, "Key files")
|
||||||
|
}
|
||||||
48
internal/docs/docs.go
Normal file
48
internal/docs/docs.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildDocsMessages builds the messages for documentation generation.
|
||||||
|
// Exported for use by MCP server.
|
||||||
|
func BuildDocsMessages(language, code string) []map[string]string {
|
||||||
|
style := docStyle(language)
|
||||||
|
systemPrompt := fmt.Sprintf(
|
||||||
|
"You are a documentation expert. Add %s documentation comments to the provided code. "+
|
||||||
|
"Return ONLY the documented code with no explanations, markdown, or extra text. "+
|
||||||
|
"Do NOT include markdown code fences. Document all public functions, methods, types, and constants.",
|
||||||
|
style,
|
||||||
|
)
|
||||||
|
|
||||||
|
userPrompt := fmt.Sprintf("Add documentation comments to the following %s code:\n\n%s", language, code)
|
||||||
|
|
||||||
|
return []map[string]string{
|
||||||
|
{"role": "system", "content": systemPrompt},
|
||||||
|
{"role": "user", "content": userPrompt},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func docStyle(language string) string {
|
||||||
|
switch strings.ToLower(language) {
|
||||||
|
case "go":
|
||||||
|
return "godoc"
|
||||||
|
case "python":
|
||||||
|
return "PEP 257 docstring"
|
||||||
|
case "c", "c++":
|
||||||
|
return "doxygen style"
|
||||||
|
case "javascript", "typescript":
|
||||||
|
return "JSDoc"
|
||||||
|
case "rust":
|
||||||
|
return "rustdoc"
|
||||||
|
case "ruby":
|
||||||
|
return "YARD"
|
||||||
|
case "java":
|
||||||
|
return "javadoc"
|
||||||
|
case "shell":
|
||||||
|
return "shell style comments"
|
||||||
|
default:
|
||||||
|
return "standard"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
internal/docs/docs_test.go
Normal file
30
internal/docs/docs_test.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildDocsMessages(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
language string
|
||||||
|
code string
|
||||||
|
styleCheck string
|
||||||
|
}{
|
||||||
|
{language: "Go", code: "func Foo() {}", styleCheck: "godoc"},
|
||||||
|
{language: "Python", code: "def foo(): pass", styleCheck: "PEP 257"},
|
||||||
|
{language: "JavaScript", code: "function foo() {}", styleCheck: "JSDoc"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.language, func(t *testing.T) {
|
||||||
|
msgs := BuildDocsMessages(tt.language, tt.code)
|
||||||
|
|
||||||
|
assert.Len(t, msgs, 2)
|
||||||
|
assert.Equal(t, "system", msgs[0]["role"])
|
||||||
|
assert.Equal(t, "user", msgs[1]["role"])
|
||||||
|
assert.Contains(t, msgs[0]["content"], tt.styleCheck)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
@ -60,3 +61,24 @@ func LogSince(since string) (string, error) {
|
|||||||
args := []string{"log", "--pretty=format:%s%n%b%n---", since + "..HEAD"}
|
args := []string{"log", "--pretty=format:%s%n%b%n---", since + "..HEAD"}
|
||||||
return Run(args)
|
return Run(args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Diff runs `git diff` and tolerates exit code 1 (differences found).
|
||||||
|
// This is normal git behavior when there *are* changes — unlike .Output().
|
||||||
|
func Diff(args []string) (string, error) {
|
||||||
|
cmdStr := "git " + strings.Join(args, " ")
|
||||||
|
logger.Debug("executing git diff", "command", cmdStr)
|
||||||
|
|
||||||
|
// nolint:gosec // intentional git subprocess
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
||||||
|
// legitimate diff (changes exist)
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
logger.Error("git diff failed", "command", cmdStr, "error", err)
|
||||||
|
return "", fmt.Errorf("git diff failed: %w", err)
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
package git
|
package git
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -64,3 +67,162 @@ func TestGitRunner(t *testing.T) {
|
|||||||
// Test IsRepo
|
// Test IsRepo
|
||||||
_ = runner.IsRepo() // Just ensure it doesn't panic
|
_ = runner.IsRepo() // Just ensure it doesn't panic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initTaggedGitRepo creates a temp git repo with an initial commit and a tag.
|
||||||
|
func initTaggedGitRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cmds := [][]string{
|
||||||
|
{"git", "init"},
|
||||||
|
{"git", "config", "user.email", "test@test.com"},
|
||||||
|
{"git", "config", "user.name", "Test"},
|
||||||
|
}
|
||||||
|
for _, args := range cmds {
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git setup failed (%v): %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial commit and tag
|
||||||
|
if err := os.WriteFile(tmpDir+"/init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"git", "add", "."},
|
||||||
|
{"git", "commit", "-m", "initial commit"},
|
||||||
|
{"git", "tag", "v0.1.0"},
|
||||||
|
} {
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git setup (%v) failed: %s", args, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tmpDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestTag(t *testing.T) {
|
||||||
|
tmpDir := initTaggedGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
tag, err := LatestTag()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestTag() failed: %v", err)
|
||||||
|
}
|
||||||
|
if tag != "v0.1.0" {
|
||||||
|
t.Errorf("LatestTag() = %q, want 'v0.1.0'", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatestTagMultiple(t *testing.T) {
|
||||||
|
tmpDir := initTaggedGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Add a second commit and tag
|
||||||
|
if err := os.WriteFile("second.txt", []byte("second"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = exec.Command("git", "add", ".").Run()
|
||||||
|
_ = exec.Command("git", "commit", "-m", "second commit").Run()
|
||||||
|
_ = exec.Command("git", "tag", "v0.2.0").Run()
|
||||||
|
|
||||||
|
tag, err := LatestTag()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestTag() failed: %v", err)
|
||||||
|
}
|
||||||
|
if tag != "v0.2.0" {
|
||||||
|
t.Errorf("LatestTag() = %q, want 'v0.2.0'", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviousTag(t *testing.T) {
|
||||||
|
tmpDir := initTaggedGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Add second commit and tag
|
||||||
|
if err := os.WriteFile("second.txt", []byte("second"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = exec.Command("git", "add", ".").Run()
|
||||||
|
_ = exec.Command("git", "commit", "-m", "second commit").Run()
|
||||||
|
_ = exec.Command("git", "tag", "v0.2.0").Run()
|
||||||
|
|
||||||
|
prev, err := PreviousTag("v0.2.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PreviousTag() failed: %v", err)
|
||||||
|
}
|
||||||
|
if prev != "v0.1.0" {
|
||||||
|
t.Errorf("PreviousTag('v0.2.0') = %q, want 'v0.1.0'", prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogSince(t *testing.T) {
|
||||||
|
tmpDir := initTaggedGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Add commits after the tag
|
||||||
|
if err := os.WriteFile("new.txt", []byte("new"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = exec.Command("git", "add", ".").Run()
|
||||||
|
_ = exec.Command("git", "commit", "-m", "feat: add new feature").Run()
|
||||||
|
|
||||||
|
log, err := LogSince("v0.1.0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LogSince() failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(log, "feat: add new feature") {
|
||||||
|
t.Errorf("LogSince() missing expected commit message, got: %q", log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiff(t *testing.T) {
|
||||||
|
tmpDir := initTaggedGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
t.Run("no changes returns empty", func(t *testing.T) {
|
||||||
|
out, err := Diff([]string{"diff"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Diff() failed: %v", err)
|
||||||
|
}
|
||||||
|
if out != "" {
|
||||||
|
t.Errorf("Diff() with no changes = %q, want empty", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with unstaged changes returns diff", func(t *testing.T) {
|
||||||
|
if err := os.WriteFile("init.txt", []byte("modified"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := Diff([]string{"diff"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Diff() failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "modified") {
|
||||||
|
t.Errorf("Diff() missing change content, got: %q", out)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -317,6 +317,91 @@ func TestLanguageStructure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDetectPrimaryLanguage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
files []string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty list returns unknown",
|
||||||
|
files: []string{},
|
||||||
|
expected: "unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all Go files",
|
||||||
|
files: []string{"main.go", "handler.go", "util.go"},
|
||||||
|
expected: "go",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all Python files",
|
||||||
|
files: []string{"app.py", "utils.py"},
|
||||||
|
expected: "python",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed with Go bias",
|
||||||
|
files: []string{"main.go", "helper.go", "script.py", "app.js", "style.ts"},
|
||||||
|
expected: "go",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported files only returns unknown",
|
||||||
|
files: []string{"notes.txt", "data.csv", "readme.md"},
|
||||||
|
expected: "unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "C files normalized",
|
||||||
|
files: []string{"main.c", "util.c", "helper.c"},
|
||||||
|
expected: "c",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Create real temp files so DetectLanguage can stat them
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
var paths []string
|
||||||
|
for _, f := range tt.files {
|
||||||
|
path := filepath.Join(tmpDir, f)
|
||||||
|
if err := os.WriteFile(path, []byte("content"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := DetectPrimaryLanguage(paths)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("DetectPrimaryLanguage() = %q, want %q", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSupportedLanguages(t *testing.T) {
|
||||||
|
langs := SupportedLanguages()
|
||||||
|
if len(langs) == 0 {
|
||||||
|
t.Fatal("SupportedLanguages() returned empty list")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be lowercase
|
||||||
|
for _, l := range langs {
|
||||||
|
if l == "" {
|
||||||
|
t.Error("SupportedLanguages() contains empty string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should contain "go"
|
||||||
|
foundGo := false
|
||||||
|
for _, l := range langs {
|
||||||
|
if l == "go" {
|
||||||
|
foundGo = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundGo {
|
||||||
|
t.Errorf("SupportedLanguages() missing 'go', got: %v", langs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function
|
// Helper function
|
||||||
func contains(s, substr string) bool {
|
func contains(s, substr string) bool {
|
||||||
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
|
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
|
||||||
|
|||||||
@ -105,6 +105,24 @@ func TestSetLevel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetLevelAllBranches(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
oldHome := os.Getenv("HOME")
|
||||||
|
setHome(t, tmpDir)
|
||||||
|
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
||||||
|
|
||||||
|
if err := Init("info"); err != nil {
|
||||||
|
t.Fatalf("Init() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
levels := []string{"debug", "info", "warn", "error"}
|
||||||
|
for _, lvl := range levels {
|
||||||
|
t.Run(lvl, func(t *testing.T) {
|
||||||
|
SetLevel(lvl) // should not panic
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWith(t *testing.T) {
|
func TestWith(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
oldHome := os.Getenv("HOME")
|
oldHome := os.Getenv("HOME")
|
||||||
|
|||||||
423
internal/mcp/server.go
Normal file
423
internal/mcp/server.go
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/mark3labs/mcp-go/server"
|
||||||
|
|
||||||
|
"gmgauthier.com/grokkit/internal/analyze"
|
||||||
|
"gmgauthier.com/grokkit/internal/docs"
|
||||||
|
"gmgauthier.com/grokkit/internal/git"
|
||||||
|
"gmgauthier.com/grokkit/internal/grok"
|
||||||
|
"gmgauthier.com/grokkit/internal/linter"
|
||||||
|
"gmgauthier.com/grokkit/internal/logger"
|
||||||
|
"gmgauthier.com/grokkit/internal/prompts"
|
||||||
|
"gmgauthier.com/grokkit/internal/recipe"
|
||||||
|
"gmgauthier.com/grokkit/internal/testgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server wraps an MCP server and provides Grokkit-specific tools.
|
||||||
|
type Server struct {
|
||||||
|
mcpServer *server.MCPServer
|
||||||
|
grokClient *grok.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer creates a new MCP server with Grokkit tools.
|
||||||
|
func NewServer() (*Server, error) {
|
||||||
|
// Create the underlying MCP server
|
||||||
|
s := server.NewMCPServer(
|
||||||
|
"grokkit",
|
||||||
|
"0.1.0",
|
||||||
|
server.WithToolCapabilities(false),
|
||||||
|
server.WithRecovery(),
|
||||||
|
)
|
||||||
|
|
||||||
|
grokClient := grok.NewClient()
|
||||||
|
|
||||||
|
mcpSrv := &Server{
|
||||||
|
mcpServer: s,
|
||||||
|
grokClient: grokClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mcpSrv.registerTools(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to register tools: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpSrv.registerResources()
|
||||||
|
|
||||||
|
return mcpSrv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerTools registers all available Grokkit tools.
|
||||||
|
func (s *Server) registerTools() error {
|
||||||
|
// lint_code tool
|
||||||
|
lintTool := mcp.NewTool("lint_code",
|
||||||
|
mcp.WithDescription("Run a linter on a source file and return results"),
|
||||||
|
mcp.WithString("file_path", mcp.Required(), mcp.Description("Path to the file to lint")),
|
||||||
|
)
|
||||||
|
s.mcpServer.AddTool(lintTool, s.handleLintCode)
|
||||||
|
|
||||||
|
// analyze_code tool
|
||||||
|
analyzeTool := mcp.NewTool("analyze_code",
|
||||||
|
mcp.WithDescription("Perform deep project analysis and return an educational Markdown report"),
|
||||||
|
mcp.WithString("dir", mcp.Description("Directory to analyze (default: current directory)")),
|
||||||
|
)
|
||||||
|
s.mcpServer.AddTool(analyzeTool, s.handleAnalyzeCode)
|
||||||
|
|
||||||
|
// generate_docs tool
|
||||||
|
docsTool := mcp.NewTool("generate_docs",
|
||||||
|
mcp.WithDescription("Generate documentation comments for a source file"),
|
||||||
|
mcp.WithString("file_path", mcp.Required(), mcp.Description("Path to the file to document")),
|
||||||
|
mcp.WithString("style", mcp.Description("Documentation style (optional)")),
|
||||||
|
)
|
||||||
|
s.mcpServer.AddTool(docsTool, s.handleGenerateDocs)
|
||||||
|
|
||||||
|
// generate_tests tool
|
||||||
|
testsTool := mcp.NewTool("generate_tests",
|
||||||
|
mcp.WithDescription("Generate unit tests for a source file"),
|
||||||
|
mcp.WithString("file_path", mcp.Required(), mcp.Description("Path to the file to generate tests for")),
|
||||||
|
)
|
||||||
|
s.mcpServer.AddTool(testsTool, s.handleGenerateTests)
|
||||||
|
|
||||||
|
// generate_commit_msg tool
|
||||||
|
commitTool := mcp.NewTool("generate_commit_msg",
|
||||||
|
mcp.WithDescription("Generate a conventional commit message from staged changes"),
|
||||||
|
)
|
||||||
|
s.mcpServer.AddTool(commitTool, s.handleGenerateCommitMsg)
|
||||||
|
|
||||||
|
// run_recipe tool
|
||||||
|
recipeTool := mcp.NewTool("run_recipe",
|
||||||
|
mcp.WithDescription("Execute a named recipe from .grokkit/recipes"),
|
||||||
|
mcp.WithString("name", mcp.Required(), mcp.Description("Name of the recipe to run")),
|
||||||
|
mcp.WithObject("params", mcp.Description("Parameters for the recipe (optional)")),
|
||||||
|
)
|
||||||
|
s.mcpServer.AddTool(recipeTool, s.handleRunRecipe)
|
||||||
|
|
||||||
|
logger.Info("MCP tools registered", "count", 6)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLintCode implements the lint_code MCP tool.
|
||||||
|
func (s *Server) handleLintCode(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
filePath, err := req.RequireString("file_path")
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultText("Error: file_path is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("MCP lint_code called", "file", filePath)
|
||||||
|
|
||||||
|
result, err := linter.LintFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("Linting failed: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
output := fmt.Sprintf("✅ Lint completed for %s\n", filePath)
|
||||||
|
output += fmt.Sprintf("Language: %s\n", result.Language)
|
||||||
|
output += fmt.Sprintf("Linter: %s\n", result.LinterUsed)
|
||||||
|
output += fmt.Sprintf("Has issues: %v\n", result.HasIssues)
|
||||||
|
|
||||||
|
if result.Output != "" {
|
||||||
|
output += "\n--- Linter Output ---\n" + result.Output
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcp.NewToolResultText(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAnalyzeCode implements the analyze_code MCP tool.
|
||||||
|
func (s *Server) handleAnalyzeCode(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
dir := "."
|
||||||
|
if d := req.GetString("dir", ""); d != "" {
|
||||||
|
dir = d
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("MCP analyze_code called", "dir", dir)
|
||||||
|
|
||||||
|
// Fully functional implementation using logic from cmd/analyze.go
|
||||||
|
files, err := analyze.DiscoverSourceFiles(dir)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("Failed to discover files: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return mcp.NewToolResultText("No supported source files found in directory."), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lang := linter.DetectPrimaryLanguage(files)
|
||||||
|
if lang == "" {
|
||||||
|
lang = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, promptContent, err := prompts.LoadAnalysisPrompt(dir, lang)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("Failed to load analysis prompt: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
context := analyze.BuildProjectContext(dir, files)
|
||||||
|
|
||||||
|
messages := []map[string]string{
|
||||||
|
{"role": "system", "content": promptContent},
|
||||||
|
{"role": "user", "content": fmt.Sprintf("Analyze this %s project and generate the full educational Markdown report now:\n\n%s", lang, context)},
|
||||||
|
}
|
||||||
|
|
||||||
|
report := s.grokClient.StreamSilent(messages, "grok-4")
|
||||||
|
|
||||||
|
result := fmt.Sprintf("✅ Analysis complete for %s\n", dir)
|
||||||
|
result += fmt.Sprintf("Files analyzed: %d\n", len(files))
|
||||||
|
result += fmt.Sprintf("Primary language: %s\n\n", lang)
|
||||||
|
result += report
|
||||||
|
|
||||||
|
return mcp.NewToolResultText(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGenerateDocs implements the generate_docs MCP tool.
|
||||||
|
func (s *Server) handleGenerateDocs(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
filePath, err := req.RequireString("file_path")
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultText("Error: file_path is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
style := req.GetString("style", "")
|
||||||
|
|
||||||
|
logger.Info("MCP generate_docs called", "file", filePath, "style", style)
|
||||||
|
|
||||||
|
// Fully functional implementation using logic from cmd/docs.go
|
||||||
|
lang, err := linter.DetectLanguage(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("Unsupported language: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("Failed to read file: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := docs.BuildDocsMessages(lang.Name, string(content))
|
||||||
|
response := s.grokClient.StreamSilent(messages, "grok-4")
|
||||||
|
|
||||||
|
documented := grok.CleanCodeResponse(response)
|
||||||
|
|
||||||
|
result := fmt.Sprintf("✅ Documentation generated for %s\n", filePath)
|
||||||
|
result += fmt.Sprintf("Language: %s\n", lang.Name)
|
||||||
|
result += "\n--- Documented Code ---\n"
|
||||||
|
result += documented
|
||||||
|
|
||||||
|
return mcp.NewToolResultText(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGenerateTests implements the generate_tests MCP tool.
|
||||||
|
func (s *Server) handleGenerateTests(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
filePath, err := req.RequireString("file_path")
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultText("Error: file_path is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("MCP generate_tests called", "file", filePath)
|
||||||
|
|
||||||
|
// Fully functional using logic from cmd/testgen.go
|
||||||
|
langObj, err := linter.DetectLanguage(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("Unsupported language: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lang := langObj.Name
|
||||||
|
if lang == "C/C++" {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
if ext == ".cpp" || ext == ".cc" || ext == ".cxx" {
|
||||||
|
lang = "C++"
|
||||||
|
} else {
|
||||||
|
lang = "C"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := testgen.GetTestPrompt(lang)
|
||||||
|
if prompt == "" {
|
||||||
|
return mcp.NewToolResultText("No test prompt available for this language"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("Failed to read file: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := []map[string]string{
|
||||||
|
{"role": "system", "content": prompt},
|
||||||
|
{"role": "user", "content": fmt.Sprintf("Generate comprehensive tests for the following %s code:\n\n%s", lang, string(content))},
|
||||||
|
}
|
||||||
|
|
||||||
|
response := s.grokClient.StreamSilent(messages, "grok-4")
|
||||||
|
cleaned := grok.CleanCodeResponse(response)
|
||||||
|
|
||||||
|
result := fmt.Sprintf("✅ Tests generated for %s\n", filePath)
|
||||||
|
result += fmt.Sprintf("Language: %s\n\n", lang)
|
||||||
|
result += "--- Generated Test Code ---\n"
|
||||||
|
result += cleaned
|
||||||
|
|
||||||
|
return mcp.NewToolResultText(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGenerateCommitMsg implements the generate_commit_msg MCP tool.
|
||||||
|
func (s *Server) handleGenerateCommitMsg(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
logger.Info("MCP generate_commit_msg called")
|
||||||
|
|
||||||
|
// Fully functional implementation using git diff + Grok
|
||||||
|
if !git.IsRepo() {
|
||||||
|
return mcp.NewToolResultText("Error: Not in a git repository"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
diff, err := git.Run([]string{"diff", "--cached"})
|
||||||
|
if err != nil || strings.TrimSpace(diff) == "" {
|
||||||
|
return mcp.NewToolResultText("No staged changes found. Stage some changes with `git add` first."), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := `You are an expert at writing conventional commit messages.
|
||||||
|
|
||||||
|
Generate a single, concise conventional commit message for the following git diff.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Use format: <type>(<scope>): <description>
|
||||||
|
- Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build
|
||||||
|
- Keep subject line under 72 characters
|
||||||
|
- Do not include body unless absolutely necessary
|
||||||
|
- Return ONLY the commit message, no explanations, no markdown, no quotes.
|
||||||
|
|
||||||
|
Diff:
|
||||||
|
` + diff
|
||||||
|
|
||||||
|
messages := []map[string]string{
|
||||||
|
{"role": "system", "content": prompt},
|
||||||
|
{"role": "user", "content": "Generate the commit message now."},
|
||||||
|
}
|
||||||
|
|
||||||
|
response := s.grokClient.StreamSilent(messages, "grok-4")
|
||||||
|
cleaned := strings.TrimSpace(grok.CleanCodeResponse(response))
|
||||||
|
|
||||||
|
result := "✅ Conventional commit message generated:\n\n"
|
||||||
|
result += cleaned
|
||||||
|
|
||||||
|
return mcp.NewToolResultText(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRunRecipe implements the run_recipe MCP tool.
|
||||||
|
func (s *Server) handleRunRecipe(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
name, err := req.RequireString("name")
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultText("Error: name is required"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("MCP run_recipe called", "recipe", name)
|
||||||
|
|
||||||
|
// Fully functional using internal/recipe package
|
||||||
|
r, err := recipe.Load(name, nil) // no custom params for now
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("Failed to load recipe '%s': %v", name, err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client := grok.NewClient()
|
||||||
|
runner := recipe.NewRunner(r, client, "grok-4")
|
||||||
|
|
||||||
|
err = runner.Run()
|
||||||
|
if err != nil {
|
||||||
|
return mcp.NewToolResultText(fmt.Sprintf("Recipe execution failed: %v", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := fmt.Sprintf("✅ Recipe '%s' executed successfully\n", name)
|
||||||
|
result += "Check the terminal output above for full details.\n"
|
||||||
|
|
||||||
|
return mcp.NewToolResultText(result), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerResources registers MCP resources (recipes and prompts).
|
||||||
|
func (s *Server) registerResources() {
|
||||||
|
// Recipes as resources
|
||||||
|
s.mcpServer.AddResource(
|
||||||
|
mcp.NewResource("recipes://local", "Local Recipes",
|
||||||
|
mcp.WithResourceDescription("Project-local workflow recipes from .grokkit/recipes/"),
|
||||||
|
mcp.WithMIMEType("application/json"),
|
||||||
|
),
|
||||||
|
s.handleRecipesLocal,
|
||||||
|
)
|
||||||
|
|
||||||
|
s.mcpServer.AddResource(
|
||||||
|
mcp.NewResource("recipes://global", "Global Recipes",
|
||||||
|
mcp.WithResourceDescription("Global recipes from ~/.config/grokkit/recipes/"),
|
||||||
|
mcp.WithMIMEType("application/json"),
|
||||||
|
),
|
||||||
|
s.handleRecipesGlobal,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prompts as resources
|
||||||
|
s.mcpServer.AddResource(
|
||||||
|
mcp.NewResource("prompts://language", "Language Prompts",
|
||||||
|
mcp.WithResourceDescription("Language-specific analysis prompts"),
|
||||||
|
mcp.WithMIMEType("text/markdown"),
|
||||||
|
),
|
||||||
|
s.handlePrompts,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.Info("MCP resources registered", "count", 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRecipesLocal returns local recipes as a resource
|
||||||
|
func (s *Server) handleRecipesLocal(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
|
||||||
|
recipes, err := recipe.ListRecipes(".")
|
||||||
|
if err != nil {
|
||||||
|
recipes = []string{} // fallback to empty list
|
||||||
|
}
|
||||||
|
|
||||||
|
json := fmt.Sprintf(`{"type": "local", "recipes": %v}`, recipes)
|
||||||
|
|
||||||
|
return []mcp.ResourceContents{
|
||||||
|
mcp.TextResourceContents{
|
||||||
|
URI: "recipes://local",
|
||||||
|
MIMEType: "application/json",
|
||||||
|
Text: json,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRecipesGlobal returns global recipes as a resource
|
||||||
|
func (s *Server) handleRecipesGlobal(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
globalPath := filepath.Join(home, ".config", "grokkit", "recipes")
|
||||||
|
|
||||||
|
recipes, err := recipe.ListRecipes(globalPath)
|
||||||
|
if err != nil {
|
||||||
|
recipes = []string{} // fallback to empty list
|
||||||
|
}
|
||||||
|
|
||||||
|
json := fmt.Sprintf(`{"type": "global", "recipes": %v}`, recipes)
|
||||||
|
|
||||||
|
return []mcp.ResourceContents{
|
||||||
|
mcp.TextResourceContents{
|
||||||
|
URI: "recipes://global",
|
||||||
|
MIMEType: "application/json",
|
||||||
|
Text: json,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePrompts returns available language prompts as a resource
|
||||||
|
func (s *Server) handlePrompts(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
|
||||||
|
promptsList := prompts.ListAvailablePrompts()
|
||||||
|
|
||||||
|
json := fmt.Sprintf(`{"type": "prompts", "prompts": %v}`, promptsList)
|
||||||
|
|
||||||
|
return []mcp.ResourceContents{
|
||||||
|
mcp.TextResourceContents{
|
||||||
|
URI: "prompts://language",
|
||||||
|
MIMEType: "application/json",
|
||||||
|
Text: json,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the MCP server using stdio transport (for Claude Code, etc.).
|
||||||
|
func (s *Server) Run(ctx context.Context) error {
|
||||||
|
logger.Info("Starting Grokkit MCP server (stdio transport)")
|
||||||
|
return server.ServeStdio(s.mcpServer)
|
||||||
|
}
|
||||||
78
internal/mcp/server_test.go
Normal file
78
internal/mcp/server_test.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewServer(t *testing.T) {
|
||||||
|
srv, err := NewServer()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, srv)
|
||||||
|
assert.NotNil(t, srv.mcpServer)
|
||||||
|
assert.NotNil(t, srv.grokClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_RegistersTools(t *testing.T) {
|
||||||
|
srv, err := NewServer()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// We can't easily inspect registered tools without exposing internals,
|
||||||
|
// so we just verify server creation succeeds with tools.
|
||||||
|
assert.NotNil(t, srv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleLintCode(t *testing.T) {
|
||||||
|
srv, _ := NewServer()
|
||||||
|
|
||||||
|
// Use a file that exists and is lintable
|
||||||
|
req := mockCallToolRequest(map[string]any{
|
||||||
|
"file_path": "main.go",
|
||||||
|
})
|
||||||
|
|
||||||
|
result, err := srv.handleLintCode(context.Background(), req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
// The test may fail if no linter is installed, so we just check it doesn't error
|
||||||
|
assert.NotNil(t, result.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAnalyzeCode(t *testing.T) {
|
||||||
|
srv, _ := NewServer()
|
||||||
|
|
||||||
|
req := mockCallToolRequest(map[string]any{
|
||||||
|
"dir": ".",
|
||||||
|
})
|
||||||
|
|
||||||
|
result, err := srv.handleAnalyzeCode(context.Background(), req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
// The test may fail if prompt files are missing, so we just check it doesn't error
|
||||||
|
assert.NotNil(t, result.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleGenerateDocs(t *testing.T) {
|
||||||
|
srv, _ := NewServer()
|
||||||
|
|
||||||
|
req := mockCallToolRequest(map[string]any{
|
||||||
|
"file_path": "main.go",
|
||||||
|
})
|
||||||
|
|
||||||
|
result, err := srv.handleGenerateDocs(context.Background(), req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
// The test may fail if the file is not readable in test env, so we just check it doesn't error
|
||||||
|
assert.NotNil(t, result.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create mock CallToolRequest
|
||||||
|
func mockCallToolRequest(args map[string]any) mcp.CallToolRequest {
|
||||||
|
return mcp.CallToolRequest{
|
||||||
|
Params: mcp.CallToolParams{
|
||||||
|
Arguments: args,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
162
internal/prompts/analyze_test.go
Normal file
162
internal/prompts/analyze_test.go
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
package prompts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadAnalysisPromptProjectLocal(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
promptDir := filepath.Join(tmpDir, ".grokkit", "prompts")
|
||||||
|
if err := os.MkdirAll(promptDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "You are a Go expert educator."
|
||||||
|
if err := os.WriteFile(filepath.Join(promptDir, "go.md"), []byte(expected), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path, content, err := LoadAnalysisPrompt(tmpDir, "go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnalysisPrompt failed: %v", err)
|
||||||
|
}
|
||||||
|
if content != expected {
|
||||||
|
t.Errorf("content = %q, want %q", content, expected)
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(path) || path == "" {
|
||||||
|
// path should be meaningful
|
||||||
|
t.Logf("returned path: %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAnalysisPromptGlobalFallback(t *testing.T) {
|
||||||
|
projectDir := t.TempDir() // no .grokkit/prompts here
|
||||||
|
globalDir := t.TempDir()
|
||||||
|
|
||||||
|
promptDir := filepath.Join(globalDir, ".config", "grokkit", "prompts")
|
||||||
|
if err := os.MkdirAll(promptDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := "You are a Python expert."
|
||||||
|
if err := os.WriteFile(filepath.Join(promptDir, "python.md"), []byte(expected), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override HOME to point to our temp global dir
|
||||||
|
oldHome := os.Getenv("HOME")
|
||||||
|
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
||||||
|
_ = os.Setenv("HOME", globalDir)
|
||||||
|
|
||||||
|
path, content, err := LoadAnalysisPrompt(projectDir, "python")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnalysisPrompt (global fallback) failed: %v", err)
|
||||||
|
}
|
||||||
|
if content != expected {
|
||||||
|
t.Errorf("content = %q, want %q", content, expected)
|
||||||
|
}
|
||||||
|
_ = path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAnalysisPromptProjectOverridesGlobal(t *testing.T) {
|
||||||
|
projectDir := t.TempDir()
|
||||||
|
globalDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create both project-local and global prompts
|
||||||
|
localPromptDir := filepath.Join(projectDir, ".grokkit", "prompts")
|
||||||
|
if err := os.MkdirAll(localPromptDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(localPromptDir, "go.md"), []byte("local prompt"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
globalPromptDir := filepath.Join(globalDir, ".config", "grokkit", "prompts")
|
||||||
|
if err := os.MkdirAll(globalPromptDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(globalPromptDir, "go.md"), []byte("global prompt"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldHome := os.Getenv("HOME")
|
||||||
|
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
||||||
|
_ = os.Setenv("HOME", globalDir)
|
||||||
|
|
||||||
|
_, content, err := LoadAnalysisPrompt(projectDir, "go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnalysisPrompt failed: %v", err)
|
||||||
|
}
|
||||||
|
if content != "local prompt" {
|
||||||
|
t.Errorf("expected project-local to override global, got: %q", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAnalysisPromptNotFound(t *testing.T) {
|
||||||
|
projectDir := t.TempDir()
|
||||||
|
emptyHome := t.TempDir()
|
||||||
|
|
||||||
|
oldHome := os.Getenv("HOME")
|
||||||
|
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
||||||
|
_ = os.Setenv("HOME", emptyHome)
|
||||||
|
|
||||||
|
_, _, err := LoadAnalysisPrompt(projectDir, "rust")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when no prompt found, got nil")
|
||||||
|
}
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
t.Errorf("expected os.ErrNotExist, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAnalysisPromptDefaultsToGo(t *testing.T) {
|
||||||
|
projectDir := t.TempDir()
|
||||||
|
promptDir := filepath.Join(projectDir, ".grokkit", "prompts")
|
||||||
|
if err := os.MkdirAll(promptDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(promptDir, "go.md"), []byte("go prompt"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
language string
|
||||||
|
}{
|
||||||
|
{"empty string defaults to go", ""},
|
||||||
|
{"unknown defaults to go", "unknown"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, content, err := LoadAnalysisPrompt(projectDir, tt.language)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnalysisPrompt(%q) failed: %v", tt.language, err)
|
||||||
|
}
|
||||||
|
if content != "go prompt" {
|
||||||
|
t.Errorf("expected go prompt, got: %q", content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAnalysisPromptNormalizesCase(t *testing.T) {
|
||||||
|
projectDir := t.TempDir()
|
||||||
|
promptDir := filepath.Join(projectDir, ".grokkit", "prompts")
|
||||||
|
if err := os.MkdirAll(promptDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(promptDir, "python.md"), []byte("python prompt"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, content, err := LoadAnalysisPrompt(projectDir, "Python")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadAnalysisPrompt with uppercase failed: %v", err)
|
||||||
|
}
|
||||||
|
if content != "python prompt" {
|
||||||
|
t.Errorf("expected case-insensitive match, got: %q", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
internal/prompts/list.go
Normal file
53
internal/prompts/list.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package prompts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListAvailablePrompts returns a list of available language prompts.
|
||||||
|
// It scans both project-local and global prompt directories.
|
||||||
|
func ListAvailablePrompts() []string {
|
||||||
|
var prompts []string
|
||||||
|
|
||||||
|
// Check project-local prompts
|
||||||
|
localDir := ".grokkit/prompts"
|
||||||
|
if entries, err := os.ReadDir(localDir); err == nil {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") {
|
||||||
|
name := strings.TrimSuffix(entry.Name(), ".md")
|
||||||
|
prompts = append(prompts, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check global prompts
|
||||||
|
home := os.Getenv("HOME")
|
||||||
|
if home == "" {
|
||||||
|
home = os.Getenv("USERPROFILE")
|
||||||
|
}
|
||||||
|
if home != "" {
|
||||||
|
globalDir := filepath.Join(home, ".config", "grokkit", "prompts")
|
||||||
|
if entries, err := os.ReadDir(globalDir); err == nil {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") {
|
||||||
|
name := strings.TrimSuffix(entry.Name(), ".md")
|
||||||
|
// Avoid duplicates
|
||||||
|
found := false
|
||||||
|
for _, p := range prompts {
|
||||||
|
if p == name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
prompts = append(prompts, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return prompts
|
||||||
|
}
|
||||||
45
internal/recipe/list.go
Normal file
45
internal/recipe/list.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package recipe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListRecipes returns a list of recipe names in the given directory.
|
||||||
|
// It looks for .md files that contain recipe frontmatter.
|
||||||
|
func ListRecipes(dir string) ([]string, error) {
|
||||||
|
if dir == "" {
|
||||||
|
dir = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
var recipes []string
|
||||||
|
|
||||||
|
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
// Skip hidden directories and common ignore dirs
|
||||||
|
name := info.Name()
|
||||||
|
if strings.HasPrefix(name, ".") && name != "." || name == "node_modules" || name == "vendor" {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
|
||||||
|
// Simple check for recipe files (contains "Objective:" or "Instructions:")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err == nil && (strings.Contains(string(data), "**Objective:**") || strings.Contains(string(data), "Objective:")) {
|
||||||
|
name := strings.TrimSuffix(info.Name(), ".md")
|
||||||
|
recipes = append(recipes, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return recipes, err
|
||||||
|
}
|
||||||
79
internal/testgen/testgen.go
Normal file
79
internal/testgen/testgen.go
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
package testgen
|
||||||
|
|
||||||
|
// GetTestPrompt returns the appropriate test prompt for the given language.
|
||||||
|
// Exported for use by MCP server.
|
||||||
|
func GetTestPrompt(lang string) string {
|
||||||
|
switch lang {
|
||||||
|
case "Go":
|
||||||
|
return `You are an expert Go test writer.
|
||||||
|
|
||||||
|
Generate a COMPLETE, production-ready *_test.go file using ONLY idiomatic Go.
|
||||||
|
|
||||||
|
STRICT RULES — NEVER VIOLATE THESE:
|
||||||
|
- NO monkey-patching: never assign to runtime.GOOS, exec.Command, os.Stdout, os.Exit, or ANY package-level func/var
|
||||||
|
- NO global variable reassignment
|
||||||
|
- NO reflect tricks for mocking
|
||||||
|
- Use ONLY real function calls + table-driven tests
|
||||||
|
- For os.Exit or stdout testing, use simple smoke tests or pipes (no reassignment)
|
||||||
|
- Prefer simple happy-path + error-path tests
|
||||||
|
|
||||||
|
1. Fast unit test:
|
||||||
|
func TestXXX_Unit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
t.Log("✓ Fast XXX unit test")
|
||||||
|
// table-driven, real calls only
|
||||||
|
}
|
||||||
|
|
||||||
|
2. Optional live test:
|
||||||
|
func TestXXX_Live(t *testing.T) {
|
||||||
|
if !testing.Short() {
|
||||||
|
t.Skip("skipping live integration test. Run with:\n go test -run TestXXX_Live -short -v")
|
||||||
|
}
|
||||||
|
t.Log("🧪 Running live integration test...")
|
||||||
|
}
|
||||||
|
|
||||||
|
Exact rules:
|
||||||
|
- Derive test names from functions (e.g. isInstalled → TestIsInstalled_Unit)
|
||||||
|
- The XXX in t.Skip MUST exactly match the live function name
|
||||||
|
- t.Parallel() on unit tests only
|
||||||
|
- NO unused imports
|
||||||
|
- Keep tests simple and idiomatic Go
|
||||||
|
- Return ONLY the full test file. No explanations, no markdown, no backticks.`
|
||||||
|
|
||||||
|
case "Python":
|
||||||
|
return `You are a pytest expert. Generate COMPLETE pytest unit tests for the Python source.
|
||||||
|
|
||||||
|
- Use pytest fixtures where appropriate
|
||||||
|
- @pytest.mark.parametrize for tables
|
||||||
|
- Cover ALL functions/classes/methods: happy/edge/error cases
|
||||||
|
- pytest.raises for exceptions
|
||||||
|
- Modern Python 3.12+: type hints, match/case if applicable
|
||||||
|
- NO external deps unless source requires
|
||||||
|
|
||||||
|
Respond ONLY with full test_*.py file: imports, fixtures, tests. Pure Python test code.`
|
||||||
|
|
||||||
|
case "C":
|
||||||
|
return `You are a C testing expert using the Check framework.
|
||||||
|
|
||||||
|
Generate COMPLETE test code for the given C source.
|
||||||
|
|
||||||
|
- Use Check framework (check.h)
|
||||||
|
- Test all major functions
|
||||||
|
- Include setup/teardown if needed
|
||||||
|
- Cover edge cases and errors
|
||||||
|
- Return ONLY the full test file.`
|
||||||
|
|
||||||
|
case "C++":
|
||||||
|
return `You are a C++ testing expert using Google Test.
|
||||||
|
|
||||||
|
Generate COMPLETE Google Test code for the given C++ source.
|
||||||
|
|
||||||
|
- Use TEST() and TEST_F() macros
|
||||||
|
- Test all major functions and classes
|
||||||
|
- Cover edge cases and errors
|
||||||
|
- Return ONLY the full test file.`
|
||||||
|
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
31
internal/testgen/testgen_test.go
Normal file
31
internal/testgen/testgen_test.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package testgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetTestPrompt(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
lang string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"Go", "expert Go test writer"},
|
||||||
|
{"Python", "pytest expert"},
|
||||||
|
{"C", "Check framework"},
|
||||||
|
{"C++", "Google Test"},
|
||||||
|
{"Unknown", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.lang, func(t *testing.T) {
|
||||||
|
prompt := GetTestPrompt(tt.lang)
|
||||||
|
if tt.expected == "" {
|
||||||
|
assert.Empty(t, prompt)
|
||||||
|
} else {
|
||||||
|
assert.Contains(t, prompt, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
28
internal/todo/todo.go
Normal file
28
internal/todo/todo.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package todo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
const todoRoot = "todo"
|
||||||
|
|
||||||
|
// Bootstrap creates the todo folder structure + basic index if missing.
|
||||||
|
func Bootstrap() error {
|
||||||
|
dirs := []string{
|
||||||
|
filepath.Join(todoRoot, "queued"),
|
||||||
|
filepath.Join(todoRoot, "doing"),
|
||||||
|
filepath.Join(todoRoot, "completed"),
|
||||||
|
}
|
||||||
|
for _, d := range dirs {
|
||||||
|
if err := os.MkdirAll(d, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Basic index README stub
|
||||||
|
index := filepath.Join(todoRoot, "README.md")
|
||||||
|
if _, err := os.Stat(index); os.IsNotExist(err) {
|
||||||
|
return os.WriteFile(index, []byte("# Todo Index\n\n## Queued\n## Doing\n## Completed\n"), 0644)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
123
internal/todo/todo_test.go
Normal file
123
internal/todo/todo_test.go
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
package todo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBootstrapCreatesStructure(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
if err := Bootstrap(); err != nil {
|
||||||
|
t.Fatalf("Bootstrap failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify directories
|
||||||
|
for _, dir := range []string{"todo/queued", "todo/doing", "todo/completed"} {
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected %s to exist: %v", dir, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Errorf("expected %s to be a directory", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify README
|
||||||
|
data, err := os.ReadFile("todo/README.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected todo/README.md to exist: %v", err)
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
if !strings.Contains(content, "## Queued") {
|
||||||
|
t.Error("README missing ## Queued heading")
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "## Completed") {
|
||||||
|
t.Error("README missing ## Completed heading")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapIdempotent(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Run twice
|
||||||
|
if err := Bootstrap(); err != nil {
|
||||||
|
t.Fatalf("first Bootstrap failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := Bootstrap(); err != nil {
|
||||||
|
t.Fatalf("second Bootstrap failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify structure still intact
|
||||||
|
for _, dir := range []string{"todo/queued", "todo/doing", "todo/completed"} {
|
||||||
|
if _, err := os.Stat(dir); err != nil {
|
||||||
|
t.Errorf("expected %s to exist after second Bootstrap: %v", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapPreservesExistingReadme(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Create structure with custom README
|
||||||
|
if err := os.MkdirAll("todo", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
customContent := "# My Custom TODO\n\nCustom content here.\n"
|
||||||
|
if err := os.WriteFile("todo/README.md", []byte(customContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Bootstrap(); err != nil {
|
||||||
|
t.Fatalf("Bootstrap failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify custom README was not overwritten
|
||||||
|
data, err := os.ReadFile("todo/README.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(data) != customContent {
|
||||||
|
t.Errorf("Bootstrap overwrote existing README.\ngot: %q\nwant: %q", string(data), customContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapCreatesReadmeWithCorrectPermissions(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
if err := Bootstrap(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(filepath.Join("todo", "README.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
perm := info.Mode().Perm()
|
||||||
|
if perm != 0644 {
|
||||||
|
t.Errorf("README permissions = %o, want 0644", perm)
|
||||||
|
}
|
||||||
|
}
|
||||||
222
internal/workon/workon.go
Normal file
222
internal/workon/workon.go
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
package workon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gmgauthier.com/grokkit/config"
|
||||||
|
"gmgauthier.com/grokkit/internal/grok"
|
||||||
|
"gmgauthier.com/grokkit/internal/logger"
|
||||||
|
"gmgauthier.com/grokkit/internal/todo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run executes the full transactional workon flow per todo/doing/workon.md spec.
|
||||||
|
func Run(title, customMsg string, isFix, isComplete bool) error {
|
||||||
|
if title == "" {
|
||||||
|
return fmt.Errorf("todo_item_title is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("workon starting", "title", title, "fix", isFix, "complete", isComplete)
|
||||||
|
|
||||||
|
// 1. Bootstrap todo structure if missing
|
||||||
|
if err := todo.Bootstrap(); err != nil {
|
||||||
|
return fmt.Errorf("todo bootstrap failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isComplete {
|
||||||
|
return completeItem(title, isFix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Handle todo or fix mode
|
||||||
|
branchPrefix := "feature/"
|
||||||
|
if isFix {
|
||||||
|
branchPrefix = "fix/"
|
||||||
|
}
|
||||||
|
branchName := branchPrefix + title
|
||||||
|
mdPath := filepath.Join("todo", "doing", title+".md")
|
||||||
|
|
||||||
|
if isFix {
|
||||||
|
if err := createFixFile(mdPath, title); err != nil {
|
||||||
|
return fmt.Errorf("create fix file failed: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := moveQueuedToDoing(title); err != nil {
|
||||||
|
return fmt.Errorf("move to doing failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create git branch (safe)
|
||||||
|
if err := createGitBranch(branchName); err != nil {
|
||||||
|
return fmt.Errorf("failed to create branch %s: %w", branchName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Generate + append Work Plan via Grok
|
||||||
|
if err := appendWorkPlan(mdPath, title); err != nil {
|
||||||
|
return fmt.Errorf("failed to generate/append Work Plan: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Commit
|
||||||
|
commitMsg := customMsg
|
||||||
|
if commitMsg == "" {
|
||||||
|
commitMsg = fmt.Sprintf("Start working on %s", title)
|
||||||
|
}
|
||||||
|
if err := commitChanges(commitMsg); err != nil {
|
||||||
|
return fmt.Errorf("commit failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Optional post-steps (graceful)
|
||||||
|
runCnaddIfAvailable(title)
|
||||||
|
openIDEIfConfigured()
|
||||||
|
|
||||||
|
logger.Info("workon transaction complete", "branch", branchName, "mode", map[bool]string{true: "fix", false: "todo"}[isFix])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveQueuedToDoing(title string) error {
|
||||||
|
src := filepath.Join("todo", "queued", title+".md")
|
||||||
|
dst := filepath.Join("todo", "doing", title+".md")
|
||||||
|
if err := os.Rename(src, dst); err != nil {
|
||||||
|
return fmt.Errorf("move %s -> doing failed: %w", title, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createFixFile(path, title string) error {
|
||||||
|
content := fmt.Sprintf("# %s\n\n## Work Plan\n\n", title)
|
||||||
|
return os.WriteFile(path, []byte(content), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createGitBranch(name string) error {
|
||||||
|
// Safe: if branch already exists, just checkout it; else create new
|
||||||
|
if err := exec.Command("git", "checkout", name).Run(); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return exec.Command("git", "checkout", "-b", name).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendWorkPlan(path, title string) error {
|
||||||
|
content := readFileContent(path)
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf(`You are helping implement a todo item titled "%s".
|
||||||
|
|
||||||
|
Here is the current markdown content of the todo/fix file:
|
||||||
|
|
||||||
|
%s
|
||||||
|
|
||||||
|
Generate a concise, actionable **Work Plan** section.
|
||||||
|
Use numbered steps. Be specific to this item. Include testing and commit notes where relevant.
|
||||||
|
Output ONLY the markdown starting with "## Work Plan" — no extra text, no introduction.`, title, content)
|
||||||
|
|
||||||
|
// Real Grok call using the project's standard client (StreamSilent for clean output)
|
||||||
|
client := grok.NewClient()
|
||||||
|
model := config.GetModel("workon", "")
|
||||||
|
plan := client.StreamSilent([]map[string]string{
|
||||||
|
{"role": "system", "content": "You are a precise software engineering assistant."},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
}, model) // or pull model from config/env if available
|
||||||
|
|
||||||
|
// Append the plan
|
||||||
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if cerr := f.Close(); cerr != nil {
|
||||||
|
logger.Error("failed to close todo file", "err", cerr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, err = f.WriteString(plan)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFileContent(path string) string {
|
||||||
|
b, _ := os.ReadFile(path)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitChanges(msg string) error {
|
||||||
|
if err := exec.Command("git", "add", "todo/").Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return exec.Command("git", "commit", "-m", msg).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeItem(title string, isFix bool) error {
|
||||||
|
src := filepath.Join("todo", "doing", title+".md")
|
||||||
|
dst := filepath.Join("todo", "completed", title+".md")
|
||||||
|
if err := os.Rename(src, dst); err != nil {
|
||||||
|
return fmt.Errorf("move to completed failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isFix {
|
||||||
|
if err := updateReadmeIndex(title); err != nil {
|
||||||
|
logger.Error("failed to update README index", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commitMsg := fmt.Sprintf("Complete work on %s", title)
|
||||||
|
if err := commitChanges(commitMsg); err != nil {
|
||||||
|
return fmt.Errorf("complete commit failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateReadmeIndex(title string) error {
|
||||||
|
readmePath := filepath.Join("todo", "README.md")
|
||||||
|
data, err := os.ReadFile(readmePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read README: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
var result []string
|
||||||
|
completedIdx := -1
|
||||||
|
|
||||||
|
for i, line := range lines {
|
||||||
|
// Skip the line referencing this title in the Queued section
|
||||||
|
if strings.Contains(line, title+".md") && strings.Contains(line, "queued/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Track the last line in the Completed section
|
||||||
|
if strings.HasPrefix(line, "## Completed") {
|
||||||
|
completedIdx = i
|
||||||
|
}
|
||||||
|
result = append(result, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find insertion point: after last non-empty line following ## Completed
|
||||||
|
if completedIdx >= 0 {
|
||||||
|
insertIdx := completedIdx + 1
|
||||||
|
for insertIdx < len(result) && (strings.HasPrefix(result[insertIdx], "*") || strings.TrimSpace(result[insertIdx]) == "") {
|
||||||
|
if strings.HasPrefix(result[insertIdx], "*") {
|
||||||
|
insertIdx++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry := fmt.Sprintf("* [%s](./completed/%s.md) : %s *(done)*", title, title, title)
|
||||||
|
result = append(result[:insertIdx], append([]string{entry}, result[insertIdx:]...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(readmePath, []byte(strings.Join(result, "\n")), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCnaddIfAvailable(title string) {
|
||||||
|
if _, err := exec.LookPath("cnadd"); err == nil {
|
||||||
|
_ = exec.Command("cnadd", "log", fmt.Sprintf("started work on %s", title)).Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openIDEIfConfigured() {
|
||||||
|
ideCmd := config.GetIDECommand()
|
||||||
|
if ideCmd == "" {
|
||||||
|
logger.Debug("IDE open skipped (no IDE configured)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := exec.Command(ideCmd, ".").Start(); err != nil {
|
||||||
|
logger.Error("failed to open IDE", "cmd", ideCmd, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
474
internal/workon/workon_test.go
Normal file
474
internal/workon/workon_test.go
Normal file
@ -0,0 +1,474 @@
|
|||||||
|
package workon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunRejectsEmptyTitle(t *testing.T) {
|
||||||
|
err := Run("", "", false, false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty title, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "todo_item_title is required") {
|
||||||
|
t.Errorf("unexpected error message: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveQueuedToDoing(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Set up todo structure
|
||||||
|
if err := os.MkdirAll("todo/queued", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll("todo/doing", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("todo/queued/test-item.md", []byte("# Test Item\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := moveQueuedToDoing("test-item"); err != nil {
|
||||||
|
t.Fatalf("moveQueuedToDoing failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify source is gone
|
||||||
|
if _, err := os.Stat("todo/queued/test-item.md"); !os.IsNotExist(err) {
|
||||||
|
t.Error("expected queued file to be removed")
|
||||||
|
}
|
||||||
|
// Verify destination exists
|
||||||
|
if _, err := os.Stat("todo/doing/test-item.md"); err != nil {
|
||||||
|
t.Errorf("expected doing file to exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMoveQueuedToDoingMissingFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
if err := os.MkdirAll("todo/queued", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll("todo/doing", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := moveQueuedToDoing("nonexistent")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing queued file, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateFixFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
path := filepath.Join(tmpDir, "my-fix.md")
|
||||||
|
|
||||||
|
if err := createFixFile(path, "my-fix"); err != nil {
|
||||||
|
t.Fatalf("createFixFile failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to read created file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
if !strings.Contains(content, "# my-fix") {
|
||||||
|
t.Errorf("expected title heading, got: %s", content)
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "## Work Plan") {
|
||||||
|
t.Errorf("expected Work Plan heading, got: %s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFileContent(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
t.Run("existing file", func(t *testing.T) {
|
||||||
|
path := filepath.Join(tmpDir, "exists.md")
|
||||||
|
expected := "hello world"
|
||||||
|
if err := os.WriteFile(path, []byte(expected), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := readFileContent(path)
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("readFileContent = %q, want %q", got, expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing file returns empty string", func(t *testing.T) {
|
||||||
|
got := readFileContent(filepath.Join(tmpDir, "missing.md"))
|
||||||
|
if got != "" {
|
||||||
|
t.Errorf("readFileContent for missing file = %q, want empty", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteItemMovesFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Set up todo structure with a doing item
|
||||||
|
if err := os.MkdirAll("todo/doing", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll("todo/completed", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("todo/doing/test-item.md", []byte("# Test\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeItem will try to git commit — that will fail in a non-git temp dir,
|
||||||
|
// but we can verify the file move happened before the commit step
|
||||||
|
_ = completeItem("test-item", true)
|
||||||
|
|
||||||
|
// For a fix, file should be moved regardless of commit outcome
|
||||||
|
if _, err := os.Stat("todo/completed/test-item.md"); err != nil {
|
||||||
|
t.Errorf("expected completed file to exist: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("todo/doing/test-item.md"); !os.IsNotExist(err) {
|
||||||
|
t.Error("expected doing file to be removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateReadmeIndex(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
if err := os.MkdirAll("todo", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
readme := `# Todo Index
|
||||||
|
|
||||||
|
## Queued
|
||||||
|
|
||||||
|
* [1] [my-feature](queued/my-feature.md): My feature
|
||||||
|
* [2] [other-item](queued/other-item.md): Other item
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
* [old-item](./completed/old-item.md) : old-item *(done)*
|
||||||
|
`
|
||||||
|
if err := os.WriteFile("todo/README.md", []byte(readme), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := updateReadmeIndex("my-feature"); err != nil {
|
||||||
|
t.Fatalf("updateReadmeIndex failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile("todo/README.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result := string(data)
|
||||||
|
|
||||||
|
// Should have removed the queued entry
|
||||||
|
if strings.Contains(result, "queued/my-feature.md") {
|
||||||
|
t.Error("expected my-feature to be removed from Queued section")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should still have the other queued item
|
||||||
|
if !strings.Contains(result, "queued/other-item.md") {
|
||||||
|
t.Error("expected other-item to remain in Queued section")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have added to Completed section
|
||||||
|
if !strings.Contains(result, "completed/my-feature.md") {
|
||||||
|
t.Error("expected my-feature to appear in Completed section")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old completed item should still be there
|
||||||
|
if !strings.Contains(result, "completed/old-item.md") {
|
||||||
|
t.Error("expected old-item to remain in Completed section")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateReadmeIndexMissingReadme(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
err := updateReadmeIndex("anything")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when README doesn't exist, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initTempGitRepo creates a temp dir with a git repo and returns it.
|
||||||
|
// The caller is responsible for chdir-ing back.
|
||||||
|
func initTempGitRepo(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cmds := [][]string{
|
||||||
|
{"git", "init"},
|
||||||
|
{"git", "config", "user.email", "test@test.com"},
|
||||||
|
{"git", "config", "user.name", "Test"},
|
||||||
|
}
|
||||||
|
for _, args := range cmds {
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git init setup failed (%v): %s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tmpDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateGitBranchNew(t *testing.T) {
|
||||||
|
tmpDir := initTempGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Need at least one commit for branches to work
|
||||||
|
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = exec.Command("git", "add", ".").Run()
|
||||||
|
_ = exec.Command("git", "commit", "-m", "initial").Run()
|
||||||
|
|
||||||
|
if err := createGitBranch("feature/test-branch"); err != nil {
|
||||||
|
t.Fatalf("createGitBranch failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we're on the new branch
|
||||||
|
out, err := exec.Command("git", "branch", "--show-current").Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git branch --show-current failed: %v", err)
|
||||||
|
}
|
||||||
|
branch := strings.TrimSpace(string(out))
|
||||||
|
if branch != "feature/test-branch" {
|
||||||
|
t.Errorf("expected branch 'feature/test-branch', got %q", branch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateGitBranchExisting(t *testing.T) {
|
||||||
|
tmpDir := initTempGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Initial commit + create branch
|
||||||
|
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = exec.Command("git", "add", ".").Run()
|
||||||
|
_ = exec.Command("git", "commit", "-m", "initial").Run()
|
||||||
|
_ = exec.Command("git", "checkout", "-b", "fix/existing").Run()
|
||||||
|
_ = exec.Command("git", "checkout", "-").Run() // back to main/master
|
||||||
|
|
||||||
|
// Should checkout existing branch, not error
|
||||||
|
if err := createGitBranch("fix/existing"); err != nil {
|
||||||
|
t.Fatalf("createGitBranch for existing branch failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, _ := exec.Command("git", "branch", "--show-current").Output()
|
||||||
|
branch := strings.TrimSpace(string(out))
|
||||||
|
if branch != "fix/existing" {
|
||||||
|
t.Errorf("expected branch 'fix/existing', got %q", branch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitChangesInGitRepo(t *testing.T) {
|
||||||
|
tmpDir := initTempGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Initial commit
|
||||||
|
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = exec.Command("git", "add", ".").Run()
|
||||||
|
_ = exec.Command("git", "commit", "-m", "initial").Run()
|
||||||
|
|
||||||
|
// Create a todo file to commit
|
||||||
|
if err := os.MkdirAll("todo/doing", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("todo/doing/test.md", []byte("# Test\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := commitChanges("test commit message"); err != nil {
|
||||||
|
t.Fatalf("commitChanges failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the commit was created
|
||||||
|
out, err := exec.Command("git", "log", "--oneline", "-1").Output()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("git log failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "test commit message") {
|
||||||
|
t.Errorf("expected commit message in log, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommitChangesNothingToCommit(t *testing.T) {
|
||||||
|
tmpDir := initTempGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Initial commit
|
||||||
|
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = exec.Command("git", "add", ".").Run()
|
||||||
|
_ = exec.Command("git", "commit", "-m", "initial").Run()
|
||||||
|
|
||||||
|
// Create empty todo dir but no files to stage
|
||||||
|
if err := os.MkdirAll("todo", 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should fail — nothing to commit
|
||||||
|
err := commitChanges("empty commit")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when nothing to commit, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteItemWithGit(t *testing.T) {
|
||||||
|
tmpDir := initTempGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Initial commit
|
||||||
|
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = exec.Command("git", "add", ".").Run()
|
||||||
|
_ = exec.Command("git", "commit", "-m", "initial").Run()
|
||||||
|
|
||||||
|
// Set up todo structure
|
||||||
|
for _, d := range []string{"todo/doing", "todo/completed"} {
|
||||||
|
if err := os.MkdirAll(d, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("todo/doing/my-fix.md", []byte("# Fix\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Stage and commit the doing file first so git tracks it
|
||||||
|
_ = exec.Command("git", "add", "todo/").Run()
|
||||||
|
_ = exec.Command("git", "commit", "-m", "add doing item").Run()
|
||||||
|
|
||||||
|
// Now complete as a fix (no README update)
|
||||||
|
if err := completeItem("my-fix", true); err != nil {
|
||||||
|
t.Fatalf("completeItem failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify moved
|
||||||
|
if _, err := os.Stat("todo/completed/my-fix.md"); err != nil {
|
||||||
|
t.Errorf("expected completed file: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("todo/doing/my-fix.md"); !os.IsNotExist(err) {
|
||||||
|
t.Error("expected doing file to be gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify commit
|
||||||
|
out, _ := exec.Command("git", "log", "--oneline", "-1").Output()
|
||||||
|
if !strings.Contains(string(out), "Complete work on my-fix") {
|
||||||
|
t.Errorf("expected completion commit, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompleteItemNonFixUpdatesReadme(t *testing.T) {
|
||||||
|
tmpDir := initTempGitRepo(t)
|
||||||
|
origDir, _ := os.Getwd()
|
||||||
|
if err := os.Chdir(tmpDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Initial commit
|
||||||
|
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = exec.Command("git", "add", ".").Run()
|
||||||
|
_ = exec.Command("git", "commit", "-m", "initial").Run()
|
||||||
|
|
||||||
|
// Set up todo structure with README
|
||||||
|
for _, d := range []string{"todo/doing", "todo/completed"} {
|
||||||
|
if err := os.MkdirAll(d, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readme := "# Todo\n\n## Queued\n\n* [my-todo](queued/my-todo.md): My todo\n\n## Completed\n\n"
|
||||||
|
if err := os.WriteFile("todo/README.md", []byte(readme), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile("todo/doing/my-todo.md", []byte("# My Todo\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = exec.Command("git", "add", "todo/").Run()
|
||||||
|
_ = exec.Command("git", "commit", "-m", "add todo item").Run()
|
||||||
|
|
||||||
|
// Complete as a non-fix (should update README)
|
||||||
|
if err := completeItem("my-todo", false); err != nil {
|
||||||
|
t.Fatalf("completeItem (non-fix) failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile("todo/README.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result := string(data)
|
||||||
|
|
||||||
|
if strings.Contains(result, "queued/my-todo.md") {
|
||||||
|
t.Error("expected my-todo removed from Queued")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "completed/my-todo.md") {
|
||||||
|
t.Error("expected my-todo added to Completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCnaddIfAvailable(t *testing.T) {
|
||||||
|
// Just exercise the function — it should not panic regardless
|
||||||
|
// of whether cnadd is installed
|
||||||
|
runCnaddIfAvailable("test-item")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenIDEIfConfigured(t *testing.T) {
|
||||||
|
// With no IDE configured (default), this should silently skip
|
||||||
|
openIDEIfConfigured()
|
||||||
|
}
|
||||||
@ -4,18 +4,19 @@ This document provides a table of contents for all tasks and features currently
|
|||||||
|
|
||||||
## Queued
|
## Queued
|
||||||
|
|
||||||
* [1] [interactive-agent.md](./queued/interactive-agent.md) : Grokkit Interactive Agent
|
* [1] [workon](doing/workon.md): Grokkit workon for bootstrapping a new feature
|
||||||
* [2] [rg.md](./queued/rg.md) : grokkit agent ripgrep (rg) integration
|
* [2] [cnotes.md](./queued/cnotes.md) : grokkit cnotes integration
|
||||||
* [3] [gotools.md](./queued/gotools.md) : grokkit agent Go tools integration
|
* [3] [make.md](./queued/make.md) : grokkit make integration
|
||||||
* [4] [make.md](./queued/make.md) : grokkit agent make integration
|
* [4] [rg.md](./queued/rg.md) : grokkit ripgrep (rg) integration
|
||||||
* [5] [tea.md](./queued/tea.md) : grokkit agent tea (Gitea CLI) integration
|
* [5] [gotools.md](./queued/gotools.md) : grokkit Go tools integration
|
||||||
* [6] [cnotes.md](./queued/cnotes.md) : grokkit agent cnotes integration
|
* [6] [tea.md](./queued/tea.md) : grokkit tea (Gitea CLI) integration
|
||||||
* [7] [profile.md](./queued/profile.md) : grokkit profile
|
* [7] [profile.md](./queued/profile.md) : grokkit profile
|
||||||
* [8] [pprof.md](./queued/pprof.md) : grokkit agent pprof integration
|
* [8] [pprof.md](./queued/pprof.md) : grokkit agent pprof integration
|
||||||
* [9] [audit.md](./queued/audit.md) : grokkit audit
|
* [9] [audit.md](./queued/audit.md) : grokkit audit
|
||||||
* [10] [git-chglog.md](./queued/git-chglog.md) : grokkit agent git-chglog integration
|
* [10] [git-chglog.md](./queued/git-chglog.md) : grokkit agent git-chglog integration
|
||||||
* [11] [admin.md](./queued/admin.md) : grokkit admin tool (to show token usage and other admin-only features)
|
* [11] [admin.md](./queued/admin.md) : [optional] grokkit admin tool (to show token usage and other admin-only features)
|
||||||
* [99] [TODO_ITEM.md](./queued/TODO_ITEM.md) : TODO ITEM template
|
* [12] [interactive-agent.md](./queued/interactive-agent.md) : [optional] Grokkit Interactive Agent
|
||||||
|
* [13] [mcp-feature.md](./queued/mcp-feature.md) : grokkit MCP server mode
|
||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
|
|
||||||
|
|||||||
137
todo/completed/mcp-feature.md
Normal file
137
todo/completed/mcp-feature.md
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
# MCP Server Feature
|
||||||
|
|
||||||
|
**Description**: Add an MCP (Model Context Protocol) server mode to grokkit, exposing its capabilities as tools and resources for Claude Code and other MCP-compatible clients.
|
||||||
|
|
||||||
|
## Problem It Solves
|
||||||
|
|
||||||
|
Grokkit's AI-powered code operations (linting, docs, test generation, analysis) are locked behind the CLI. MCP integration lets external AI clients invoke them programmatically, composing grokkit's strengths with broader workflows.
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
- **Composability**: Claude Code (or any MCP client) can call grokkit tools mid-conversation.
|
||||||
|
- **No core changes**: New `grokkit mcp` command wraps existing functionality via interfaces.
|
||||||
|
- **Stdio transport**: No HTTP server to manage — Claude Code pipes stdio directly.
|
||||||
|
- **Recipes as resources**: MCP clients can browse and invoke workflow recipes.
|
||||||
|
- **Leverages existing architecture**: `AIClient` and `GitRunner` interfaces already support dependency injection.
|
||||||
|
|
||||||
|
## Architecture Notes
|
||||||
|
|
||||||
|
Grokkit is well-suited for this:
|
||||||
|
|
||||||
|
- **Interface-based design**: `AIClient` and `GitRunner` are already abstractions — inject real implementations into MCP tool handlers.
|
||||||
|
- **Clean package separation**: Each internal package (grok, git, linter, recipe, prompts) maps naturally to an MCP tool.
|
||||||
|
- **Cobra command tree**: Adding `cmd/mcp.go` is trivial.
|
||||||
|
- **Streaming caveat**: MCP tools return complete results, so use `StreamSilent` (captures without printing) rather than `Stream`.
|
||||||
|
- **File-writing tools** (`edit`, `workon`): Need careful scoping so the MCP client understands what changed.
|
||||||
|
|
||||||
|
## High-Level Implementation
|
||||||
|
|
||||||
|
### Phase 1: Foundation
|
||||||
|
|
||||||
|
1. **Add MCP Go SDK** (e.g., `github.com/mark3labs/mcp-go`) to `go.mod`.
|
||||||
|
2. **Create `internal/mcp/server.go`** — MCP server that registers tools and resources.
|
||||||
|
3. **Create `cmd/mcp.go`** — `grokkit mcp` command that starts the stdio server.
|
||||||
|
|
||||||
|
### Phase 2: Tools
|
||||||
|
|
||||||
|
Wrap existing functionality as MCP tools. Priority order:
|
||||||
|
|
||||||
|
| Tool | Wraps | Notes |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `lint_code` | `internal/linter` | Stateless. File path in, results out. |
|
||||||
|
| `generate_commit_msg` | `cmd/commit.go` logic | Reads git diff, calls Grok, returns message. |
|
||||||
|
| `analyze_code` | `cmd/analyze.go` + `internal/prompts` | File in, analysis out. |
|
||||||
|
| `generate_docs` | `cmd/docs.go` | File in, docs out (9 styles available). |
|
||||||
|
| `generate_tests` | `cmd/testgen.go` | File in, tests out. |
|
||||||
|
| `run_recipe` | `internal/recipe` | Recipe name + params in, results out. |
|
||||||
|
|
||||||
|
Each tool handler:
|
||||||
|
- Extracts core logic from the Cobra `RunE` function into a reusable function.
|
||||||
|
- Injects real `AIClient` and `GitRunner` implementations.
|
||||||
|
- Returns complete text results (no streaming to client).
|
||||||
|
|
||||||
|
### Phase 3: Resources
|
||||||
|
|
||||||
|
| Resource | Source | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `recipes://local` | `.grokkit/recipes/` | Project-local workflow recipes |
|
||||||
|
| `recipes://global` | `~/.local/share/grokkit/recipes/` | Global recipes |
|
||||||
|
| `prompts://language` | `.grokkit/prompts/` | Language-specific analysis prompts |
|
||||||
|
|
||||||
|
## Flags / Config
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `mcp.enabled` | Enable MCP server mode |
|
||||||
|
| `mcp.tools` | List of tools to expose (default: all) |
|
||||||
|
| `mcp.resources` | List of resources to expose (default: all) |
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
- **Entry point**: `grokkit mcp` starts stdio server, blocks until EOF.
|
||||||
|
- **No breaking changes**: Entirely additive — new command, new package.
|
||||||
|
- **Testing**: Mock `AIClient` and `GitRunner` interfaces; table-driven tests per tool.
|
||||||
|
- **Effort**: Medium (~300-500 LOC). SDK does the protocol heavy lifting.
|
||||||
|
- **Prereqs**: Choose and evaluate a Go MCP SDK.
|
||||||
|
|
||||||
|
## ROI
|
||||||
|
|
||||||
|
**High**. Turns grokkit from a standalone CLI into a composable service:
|
||||||
|
|
||||||
|
| Capability | Standalone | With MCP |
|
||||||
|
|------------|-----------|----------|
|
||||||
|
| Lint | `grokkit lint` | Claude calls it mid-review |
|
||||||
|
| Docs | `grokkit docs` | Claude generates docs in context |
|
||||||
|
| Tests | `grokkit testgen` | Claude generates + validates tests |
|
||||||
|
| Recipes | `grokkit recipe` | Claude orchestrates multi-step workflows |
|
||||||
|
| Analysis | `grokkit analyze` | Claude gets language-aware code analysis |
|
||||||
|
## Work Plan
|
||||||
|
|
||||||
|
1. **Evaluate and add MCP SDK**
|
||||||
|
- Research Go MCP SDKs (`github.com/mark3labs/mcp-go` or equivalent).
|
||||||
|
- Add to `go.mod`, run `go mod tidy`.
|
||||||
|
- Commit: `feat(mcp): add MCP SDK dependency`.
|
||||||
|
|
||||||
|
2. **Implement MCP server foundation** (`internal/mcp`)
|
||||||
|
- Create `internal/mcp/server.go`: Initialize stdio server, register tools/resources.
|
||||||
|
- Inject real `AIClient` and `GitRunner` impls.
|
||||||
|
- Use `StreamSilent` for non-streaming results.
|
||||||
|
- Commit: `feat(mcp): add MCP server foundation`.
|
||||||
|
|
||||||
|
3. **Add `cmd/mcp.go`**
|
||||||
|
- Cobra command `grokkit mcp` to start stdio server.
|
||||||
|
- Add config flags: `--tools`, `--resources` (comma-separated).
|
||||||
|
- Blocks until EOF.
|
||||||
|
- Test: Manual `grokkit mcp` + simple MCP client.
|
||||||
|
- Commit: `feat(mcp): add grokkit mcp CLI command`.
|
||||||
|
|
||||||
|
4. **Extract reusable functions from existing commands**
|
||||||
|
- `lint_code`: Extract from `cmd/lint.go` → `internal/linter.RunLint(path) string`.
|
||||||
|
- `generate_commit_msg`: Extract from `cmd/commit.go` → `internal/git.GenerateCommitMsg() string`.
|
||||||
|
- Commit per extraction: `refactor(lint): extract reusable lint function`.
|
||||||
|
|
||||||
|
5. **Implement Phase 2 tools** (priority order, 1 commit per 2 tools)
|
||||||
|
- Register each as MCP tool in `server.go`.
|
||||||
|
- `lint_code`, `analyze_code`.
|
||||||
|
- `generate_docs`, `generate_tests`.
|
||||||
|
- `generate_commit_msg`, `run_recipe`.
|
||||||
|
- Table-driven unit tests with mocked `AIClient`/`GitRunner`.
|
||||||
|
- Commit: `feat(mcp): add lint_code and analyze_code tools`.
|
||||||
|
|
||||||
|
6. **Implement Phase 3 resources**
|
||||||
|
- `recipes://local`, `recipes://global`, `prompts://language`.
|
||||||
|
- List directories and contents as MCP resources.
|
||||||
|
- Unit tests for path resolution and listing.
|
||||||
|
- Commit: `feat(mcp): add recipe and prompt resources`.
|
||||||
|
|
||||||
|
7. **Add config flags and validation**
|
||||||
|
- Support `mcp.enabled`, `mcp.tools`, `mcp.resources` in config.
|
||||||
|
- Validate tool/resource lists on startup.
|
||||||
|
- Integration test: Full `grokkit mcp` run with subset tools.
|
||||||
|
- Commit: `feat(mcp): add config flags and validation`.
|
||||||
|
|
||||||
|
8. **End-to-end testing and docs**
|
||||||
|
- Test with Claude Code or MCP client simulator.
|
||||||
|
- Add `README.md` section: "MCP Server Mode".
|
||||||
|
- Smoke test all tools/resources.
|
||||||
|
- Final commit: `feat(mcp): complete MCP server + docs/tests`.
|
||||||
46
todo/completed/workon.md
Normal file
46
todo/completed/workon.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# `grokkit workon`
|
||||||
|
|
||||||
|
**Description**: Automates the initiation of new todo item and fix development.
|
||||||
|
|
||||||
|
1. selects a todo item out of the todo queue and moves it to the "doing" folder;
|
||||||
|
2. creates a branch based on the item;
|
||||||
|
3. generates a plan for implementation (amending it to the todo item under the heading "Work Plan");
|
||||||
|
4. commits the plan to the branch.
|
||||||
|
5. [optional] logs the beginning of the work using `cnadd` (if available).
|
||||||
|
6. [optional] opens the branch in the IDE (specified in the config).
|
||||||
|
|
||||||
|
**Assumptions**:
|
||||||
|
|
||||||
|
Grokkit is being customized to suit my own development workflow. So, there are a number of features that may not be translatable to other developers' habits. As such, this feature assumes that the repository has a "todo" folder with a "queued", "doing" and "completed" subfolder, that function as the in-situ ticketing system. If the "todo" folder is not present, grokkit will create it.
|
||||||
|
|
||||||
|
Also, if no IDE is specified in the config, the `workon` command will default to not opening the editor. If `cnadd` is not available, the `workon` command will default to not logging the beginning of the work.
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
|
||||||
|
1. Boosts productivity by automating routine tasks.
|
||||||
|
2. Makes it easier to track the progress of the work.
|
||||||
|
3. Makes feature development more uniform and consistent.
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```
|
||||||
|
grokkit workon <todo_item_title> [[-m '<custom_commit_message>'] [-f]] | [-c]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Arguments**:
|
||||||
|
- `<todo_item_title>`: The item to be worked on.
|
||||||
|
- If this is a todo, then there should be a Markdown entry in the queued folder with the same name, which will be moved to the doing folder. That should also be the name of the branch.
|
||||||
|
- If this is a fix, then use the argument as a branch name and as the name of a markdown file to be created, where the Work Plan will be stored in the "doing" folder.
|
||||||
|
- In both instances, If spaces are present, replace them with dashes.
|
||||||
|
|
||||||
|
- `-m '<custom_commit_message>'`: custom commit message to use for the branch. Default is "Start working on <todo_item_title>".
|
||||||
|
|
||||||
|
- `-f`: This signifies a "fix" rather than a todo item (the default is always a todo item)
|
||||||
|
|
||||||
|
- `-c`: "completes" the work item (note: cannot be used with other flags). This means:
|
||||||
|
- If it is a todo, it will be moved to the "completed" folder, and the index readme will be updated.
|
||||||
|
- If it is a fix, the work plan will be moved to the "completed folder". No index update is necessary.
|
||||||
|
- In either case, the update will be committed to the branch.
|
||||||
|
|
||||||
|
**High-level implementation**:
|
||||||
|
|
||||||
|
1. TBD
|
||||||
Loading…
Reference in New Issue
Block a user