Compare commits

..

No commits in common. "master" and "v0.3.2" have entirely different histories.

50 changed files with 141 additions and 3730 deletions

View File

@ -45,7 +45,7 @@ jobs:
done
sha256sum build/grokkit-*.tar.gz | tee build/checksums.txt
cp scripts/grokkit-install.sh build/
cp scripts/grokkit-install.ps1 build/
# Clean up raw binaries (keep only tars, checksums, sh)
for plat in 'linux/amd64' 'linux/arm64' 'darwin/amd64' 'darwin/arm64' 'windows/amd64'; do
IFS='/' read -r OS ARCH <<< "$plat"
BIN="grokkit-${OS}-${ARCH}"
@ -60,18 +60,20 @@ jobs:
run: |
VERSION=${GITHUB_REF#refs/tags/}
GITEA_API=https://repos.gmgauthier.com/api/v1
# Create release
curl -X POST "${GITEA_API}/repos/${GITHUB_REPOSITORY}/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${VERSION}\",\"name\": \"Grokkit ${VERSION}\",\"body\": \"## Quick Install\\n\\n### Bash (Linux/macOS)\\n\\n```bash\\ncurl -L https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v${VERSION}/grokkit-install.sh | VERSION=${VERSION} bash\\n```\\n\\n### PowerShell (Windows/macOS/Linux)\\n\\n```powershell\\nirm https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v${VERSION}/grokkit-install.ps1; & .\\grokkit-install.ps1 -Version ${VERSION}\\n```\\n\\nAssets include platform binaries (tar.gz), checksums.txt. See CHANGELOG.md.\"}" > release.json
-d "{\"tag_name\": \"${VERSION}\",\"name\": \"Grokkit ${VERSION}\",\"body\": \"## Quick Install\\n\\n```bash\\ncurl -L https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v${VERSION}/grokkit-install.sh | VERSION=${VERSION} bash\\n```\\n\\nAssets include platform binaries (tar.gz), checksums.txt. See CHANGELOG.md.\"}" > release.json
RELEASE_ID=$(jq .id release.json)
# Upload assets
for asset in build/* ; do
name=$(basename "$asset")
mime="application/octet-stream"
[[ "$name" =~ \.tar\.gz$ ]] && mime="application/gzip"
[[ "$name" =~ \.(txt|sh|ps1)$ ]] && mime="text/plain"
[[ "$name" =~ \.(txt|sh)$ ]] && mime="text/plain"
curl -X POST "${GITEA_API}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${name}" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: ${mime}" \
--data-binary "@$asset"
done
done

View File

@ -1,159 +1,3 @@
## [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
Now with Markdown editing flair and Windows install tweaks!
### Added
- Add support for editing Markdown files using a tailored technical writer prompt.
- Add PowerShell quick install snippet to release notes.
- Include PowerShell install script in release build artifacts.
- Add entry for v0.3.4 to changelog with Windows support details.
### Changed
- Simplify logging setup and remove unnecessary comments in analyze command.
- Improve directory skipping logic in source file discovery.
- Add error handling and include Git remotes in project context building.
- Simplify removal of "last modified" comments by direct slice append.
- Update MIME type detection to include .ps1 files as text/plain.
- Remove unnecessary comments from release workflow script.
- Ensure changelog entries include cross-platform consistency notes.
### Fixed
- Implement removal of "last modified" comments in edit command by filtering lines case-insensitively.
- Add missing newline at end of edited files to avoid Git warnings.
- Adjust response cleaning for Markdown files and use CleanCodeResponse for code files.
- Remove nolint comment and unnecessary line skipping in comment removal function.
## [v0.3.4] - 2026-03-30
Windows users, your PowerShell dreams just came true.
### Added
- Introduced grokkit-install.ps1 for Windows with auto-detection, checksum verification, and safe installation.
- Added one-line and download-first install methods for Bash and PowerShell in README.
- Added Windows-specific notes, tables, and examples in README.
### Changed
- Updated README formatting for improved readability.
- Ensured cross-platform consistency in installation instructions.
## [v0.3.2] - 2026-03-30
Polishing the install script and catching up on past changelog entries.
### Added
- Add debug echo statements for download and extraction.
- Add changelog entry for v0.3.1, documenting additions like .grokkit directory creation.
- Add changelog entry for v0.3.1, documenting changes like default output path, project analysis movement, analysis refinements, and v0.3.0 changelog update.
### Changed
- Adjust asset filename to include 'v' prefix for version.
- Comment out checksum download and verification.
- Update binary naming and extraction handling.
- Comment out temp dir cleanup trap.
### Fixed
- Fix quoting in case statements and echo commands.
## [v0.3.1] - 2026-03-28
### Added

120
README.md
View File

@ -3,7 +3,7 @@
Grokkit is a fast Go CLI integrating Grok AI with git workflows and general chat/edit functionality.
[![Test Coverage](https://img.shields.io/badge/coverage-62%25-yellow)]()
[![Test Coverage](https://img.shields.io/badge/coverage-54%25-orange)]()
[![Go Version](https://img.shields.io/badge/go-1.24.2-blue)]()
[![License](https://img.shields.io/badge/license-Unlicense-lightgrey)]()
@ -13,50 +13,15 @@ Grokkit is a fast Go CLI integrating Grok AI with git workflows and general chat
- Git (for git-related commands)
- XAI API key
## 🚀 Quick Start
## 🚀 Quick Start Installation
### One-line Install (Recommended)
**Bash (Linux/macOS):**
### From pre-built release (recommended)
```bash
export VERSION=0.4.0
curl -fsSL https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v${VERSION}/grokkit-install.sh | bash -s -- --verify
VERSION=0.1.3 # Replace with latest version tag (omit 'v')
curl -L https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v${VERSION}/grokkit-install.sh | VERSION=${VERSION} bash -
```
**PowerShell (Windows):**
```pwsh
$env:VERSION="0.4.0"
irm https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v$env:VERSION/grokkit-install.ps1 | iex
```
Both installers feature:
- ✅ **Automatic platform detection** (Linux, macOS, Windows)
- ✅ **SHA256 checksum verification**
- ✅ **Safe extraction to `~/.local/bin` or `$env:LOCALAPPDATA\bin`**
- ✅ **No temporary files left behind**
### Download First Approach
**Bash (Linux/macOS):**
```bash
export VERSION=0.4.0
curl -fsSL https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v${VERSION}/grokkit-install.sh -o grokkit-install.sh
bash grokkit-install.sh --verify
```
**PowerShell (Windows):**
```pwsh
$env:VERSION="0.4.0"
irm https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v$env:VERSION/grokkit-install.ps1 -OutFile grokkit-install.ps1
.\grokkit-install.ps1 -Verify
```
> **💡 Windows Note**: PowerShell installers auto-detect Windows architecture (x86_64, ARM64) and place the binary in `%LOCALAPPDATA%\bin\grokkit.exe`. Add this path to your PATH if not already present.
### From Source (for development)
```bash
# Set your API key
export XAI_API_KEY=sk-...
@ -67,10 +32,9 @@ cd grokkit
make test
make build
make install
```
````
### Verify:
```bash
grokkit version
```
@ -109,8 +73,6 @@ history_file = "~/.config/grokkit/chat_history.json"
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
Grokkit is designed to work seamlessly with Git, using version control instead of redundant backup files to manage changes and rollbacks.
@ -118,20 +80,20 @@ Grokkit is designed to work seamlessly with Git, using version control instead o
[Read the Safety & Change Management Guide](docs/user-guide/safety.md)
## Logging
### Logging
Logs are written to `~/.config/grokkit/grokkit.log` in JSON format.
```bash
# View logs in real-time
tail -f ~/.config/grokkit/grokkit.log
### View logs in real-time
`tail -f ~/.config/grokkit/grokkit.log`
### Find errors
`cat ~/.config/grokkit/grokkit.log | jq 'select(.level=="ERROR")'`
### Track API performance
`cat ~/.config/grokkit/grokkit.log | jq 'select(.msg=="API request completed") | {model, duration_ms, response_length}'`
# Find errors
cat ~/.config/grokkit/grokkit.log | jq 'select(.level=="ERROR")'
# Track API performance
cat ~/.config/grokkit/grokkit.log | jq 'select(.msg=="API request completed") | {model, duration_ms, response_length}'
```
## Workflows
@ -149,29 +111,27 @@ Generate shell completions for faster command entry:
### Global Flags (work with all commands)
| Flag | Short | Description |
|-------------|--------|------------------------------------------|
| `--model` | `-m` | Override model (e.g., grok-4, grok-beta) |
| `--debug` | | Enable debug logging (stderr + file) |
| `--verbose` | `-v` | Enable verbose logging |
| `--help` | `-h` | Show help |
| Flag | Short | Description |
|------|-------|-------------|
| `--model` | `-m` | Override model (e.g., grok-4, grok-beta) |
| `--debug` | | Enable debug logging (stderr + file) |
| `--verbose` | `-v` | Enable verbose logging |
| `--help` | `-h` | Show help |
### Examples
#### Use different model
```bash
# Use different model
grokkit chat -m grok-beta
`grokkit chat -m grok-beta`
# Debug API issues
grokkit edit main.go "refactor" --debug
#### Debug API issues
# Verbose logging
grokkit review -v
```
`grokkit edit main.go "refactor" --debug`
#### Verbose logging
`grokkit review -v`
#### Features
## Features
### Core Features
- ✅ **Structured logging with slog** - JSON logs with request tracing, timing, and context
@ -187,10 +147,9 @@ Generate shell completions for faster command entry:
- ✅ **AI unit test generation** - Go (table-driven), Python (pytest), C (Check), C++ (GTest)
- ✅ **AI file scaffolding** - Create new files with project-aware style matching
- ✅ **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
- ✅ **Test coverage 60%+** - Comprehensive unit tests including all command message builders
- ✅ **Test coverage 54%+** - Comprehensive unit tests including all command message builders
- ✅ **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
- ✅ **Golangci-lint configured** - `.golangci.yml` with govet, errcheck, staticcheck, and more
@ -238,17 +197,18 @@ Grokkit uses the xAI Grok API. Be aware:
**Common issues:**
### API key not set
```bash
# API key not set
Error: XAI_API_KEY environment variable not set
→ Solution: `export XAI_API_KEY=sk-your-key`
→ Solution: export XAI_API_KEY=sk-your-key
### Request timeout
# Request timeout
Error: Request failed: context deadline exceeded
→ Solution: Increase timeout in `config.toml` or check network
### Permission denied on log file
→ Solution: `chmod 644 ~/.config/grokkit/grokkit.log`
→ Solution: Increase timeout in config.toml or check network
# Permission denied on log file
→ Solution: chmod 644 ~/.config/grokkit/grokkit.log
```
**See [docs/developer-guide/TROUBLESHOOTING.md](docs/developer-guide/TROUBLESHOOTING.md) for more details.**
@ -258,4 +218,4 @@ Error: Request failed: context deadline exceeded
---
**Made with ❤️ using Grok AI**
**Made with ❤️ using Grok AI**

View File

@ -14,7 +14,7 @@ import (
"gmgauthier.com/grokkit/internal/git"
"gmgauthier.com/grokkit/internal/grok"
"gmgauthier.com/grokkit/internal/linter"
"gmgauthier.com/grokkit/internal/logger"
"gmgauthier.com/grokkit/internal/logger" // note: we use package-level funcs
"gmgauthier.com/grokkit/internal/prompts"
)
@ -30,16 +30,23 @@ Uses language-specific prompts discovered in .grokkit/prompts/ or ~/.config/grok
}
output := viper.GetString("output")
if output == "" {
// Default to project-local context file inside .grokkit/
output = filepath.Join(".grokkit", "analysis.md")
}
// Fixed: config.GetModel takes (commandName, flagModel)
model := config.GetModel("analyze", viper.GetString("model"))
yes := viper.GetBool("yes")
if !git.IsRepo() {
// Logger (use the package-level logger as done in other commands)
// (remove the old "log := logger.Get()")
// Safety check
if !git.IsRepo() { // IsRepo takes no arguments
logger.Warn("Not inside a git repository. Git metadata in report will be limited.")
}
files, err := DiscoverSourceFiles(dir)
// 1. Discover source files
files, err := discoverSourceFiles(dir)
if err != nil {
logger.Error("Failed to discover source files", "dir", dir, "error", err)
os.Exit(1)
@ -49,11 +56,13 @@ Uses language-specific prompts discovered in .grokkit/prompts/ or ~/.config/grok
os.Exit(1)
}
// 2. Detect primary language
lang := linter.DetectPrimaryLanguage(files)
if lang == "" {
lang = "unknown"
}
// 3. Load language-specific prompt (project → global)
promptPath, promptContent, err := prompts.LoadAnalysisPrompt(dir, lang)
if err != nil {
fmt.Printf("Error: Could not find analysis prompt for language '%s'.\n\n", lang)
@ -65,21 +74,27 @@ Uses language-specific prompts discovered in .grokkit/prompts/ or ~/.config/grok
os.Exit(1)
}
logger.Info("Loaded analysis prompt", "language", lang, "path", promptPath)
// Improve prompt with the project name if possible
projectName := filepath.Base(dir)
if projectName == "." {
projectName = "Current Project"
}
promptContent = strings.Replace(promptContent, "[Inferred Project Name]", projectName, 1)
context := BuildProjectContext(dir, files)
// 4. Build rich project context
context := buildProjectContext(dir, files)
// 5. Call Grok — use the exact working pattern you provided
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)},
}
// Fixed: NewClient() + Stream() (or StreamSilent if you prefer no live output)
// For a long report, StreamSilent is usually better (no live printing)
report := grok.NewClient().StreamSilent(messages, model)
// 6. Transactional preview + confirmation
if !yes {
fmt.Println("\n=== Proposed Analysis Report Preview (first 60 lines) ===")
previewLines(report, 60)
@ -95,15 +110,18 @@ Uses language-specific prompts discovered in .grokkit/prompts/ or ~/.config/grok
}
}
// 7. Output
if output == "-" {
fmt.Println(report)
return
}
// Ensure .grokkit directory exists
if err := os.MkdirAll(".grokkit", 0755); err != nil {
logger.Error("Failed to create .grokkit directory", "error", err)
os.Exit(1)
}
// Write the file
if err := os.WriteFile(output, []byte(report), 0644); err != nil {
logger.Error("Failed to write report", "file", output, "error", err)
os.Exit(1)
@ -118,27 +136,28 @@ func init() {
analyzeCmd.Flags().String("dir", ".", "Repository root to analyze")
analyzeCmd.Flags().StringP("output", "o", "analyze.md", "Output file (use - for stdout)")
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) {
// discoverSourceFiles walks the directory and collects all supported source files.
// It skips common noise directories but DOES descend into cmd/, internal/, etc.
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
}
// Skip common hidden/noise directories but still allow descent into source dirs
name := info.Name()
if info.IsDir() {
if strings.HasPrefix(name, ".") && name != "." && name != ".." ||
if strings.HasPrefix(name, ".") && name != "." && name != ".." || // skip .git, .grokkit (except we want .grokkit/prompts? but for source we skip)
name == "node_modules" || name == "vendor" || name == "build" || name == "dist" {
return filepath.SkipDir
}
return nil
}
// Check if this is a supported source file
if _, detectErr := linter.DetectLanguage(path); detectErr == nil {
files = append(files, path)
}
@ -147,6 +166,7 @@ func DiscoverSourceFiles(root string) ([]string, error) {
return files, err
}
// previewLines prints the first N lines of the report (unchanged)
func previewLines(content string, n int) {
scanner := bufio.NewScanner(strings.NewReader(content))
for i := 0; i < n && scanner.Scan(); i++ {
@ -157,13 +177,11 @@ func previewLines(content string, n int) {
}
}
func BuildProjectContext(dir string, files []string) string {
// buildProjectContext — small improvement for better context (optional but nice)
func buildProjectContext(dir string, files []string) string {
var sb strings.Builder
sb.WriteString("Project Root: " + dir + "\n\n")
_, err := fmt.Fprintf(&sb, "Total source files discovered: %d\n\n", len(files))
if err != nil {
return ""
}
sb.WriteString(fmt.Sprintf("Total source files discovered: %d\n\n", len(files)))
sb.WriteString("Key files (top-level view):\n")
for _, f := range files {
@ -176,6 +194,7 @@ func BuildProjectContext(dir string, files []string) string {
}
}
// Git remotes
if out, err := git.Run([]string{"remote", "-v"}); err == nil && out != "" {
sb.WriteString("\nGit Remotes:\n" + out + "\n")
}

View File

@ -1,238 +0,0 @@
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))
}
}

View File

@ -48,11 +48,11 @@ func runDocs(cmd *cobra.Command, args []string) {
client := newGrokClient()
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)
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)
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)
if response == "" {
@ -134,7 +134,7 @@ func ProcessDocsFile(client grok.AIClient, model, filePath string) {
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)
systemPrompt := fmt.Sprintf(
"You are a documentation expert. Add %s documentation comments to the provided code. "+

View File

@ -25,7 +25,7 @@ func TestBuildDocsMessages(t *testing.T) {
for _, tt := range tests {
t.Run(tt.language, func(t *testing.T) {
msgs := BuildDocsMessages(tt.language, tt.code)
msgs := buildDocsMessages(tt.language, tt.code)
if len(msgs) != 2 {
t.Fatalf("expected 2 messages, got %d", len(msgs))

View File

@ -35,6 +35,7 @@ var editCmd = &cobra.Command{
os.Exit(1)
}
// nolint:gosec // intentional file read from user input
original, err := os.ReadFile(filePath)
if err != nil {
logger.Error("failed to read file", "file", filePath, "error", err)
@ -45,28 +46,14 @@ var editCmd = &cobra.Command{
cleanedOriginal := removeLastModifiedComments(string(original))
client := grok.NewClient()
isMarkdown := filepath.Ext(filePath) == ".md"
var messages []map[string]string
if isMarkdown {
messages = []map[string]string{
{"role": "system", "content": "You are an expert technical writer. Edit markdown content clearly and concisely. Return only the edited markdown with no explanations, no markdown formatting, no extra text."},
{"role": "user", "content": fmt.Sprintf("File: %s\n\nOriginal content:\n%s\n\nTask: %s", filepath.Base(filePath), cleanedOriginal, instruction)},
}
} else {
messages = []map[string]string{
{"role": "system", "content": "You are an expert programmer. Remove all unnecessary comments including last modified timestamps and ownership comments. Return only the cleaned code with no explanations, no markdown, no extra text."},
{"role": "user", "content": fmt.Sprintf("File: %s\n\nOriginal content:\n%s\n\nTask: %s", filepath.Base(filePath), cleanedOriginal, instruction)},
}
messages := []map[string]string{
{"role": "system", "content": "You are an expert programmer. Remove all unnecessary comments including last modified timestamps and ownership comments. Return only the cleaned code with no explanations, no markdown, no extra text."},
{"role": "user", "content": fmt.Sprintf("File: %s\n\nOriginal content:\n%s\n\nTask: %s", filepath.Base(filePath), cleanedOriginal, instruction)},
}
color.Yellow("Asking Grok to %s...\n", instruction)
raw := client.StreamSilent(messages, model)
var newContent string
if isMarkdown {
newContent = strings.TrimSpace(raw)
} else {
newContent = grok.CleanCodeResponse(raw)
}
newContent := grok.CleanCodeResponse(raw)
color.Green("✓ Response received")
color.Cyan("\nProposed changes:")
@ -103,11 +90,13 @@ var editCmd = &cobra.Command{
func removeLastModifiedComments(content string) string {
lines := strings.Split(content, "\n")
cleanedLines := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.Contains(strings.ToLower(trimmed), "last modified") {
cleanedLines = append(cleanedLines, line)
if strings.Contains(line, "Last modified") {
continue
}
cleanedLines = append(cleanedLines, line)
}
return strings.Join(cleanedLines, "\n")
}

View File

@ -1,50 +0,0 @@
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)
}
}

View File

@ -2,17 +2,12 @@ package cmd
import (
"fmt"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"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{
Use: "pr-describe",
Short: "Generate full PR description from current branch",
@ -20,32 +15,30 @@ var prDescribeCmd = &cobra.Command{
}
func init() {
prDescribeCmd.Flags().StringP("base", "b", "master", "Base branch to compare against (default: master)")
prDescribeCmd.Flags().StringP("base", "b", "master", "Base branch to compare against")
}
func runPRDescribe(cmd *cobra.Command, _ []string) {
base, _ := cmd.Flags().GetString("base")
// Prefer remote (more likely up-to-date for PRs), fallback to local.
diff, err := gitDiff([]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"})
diff, err := gitRun([]string{"diff", fmt.Sprintf("%s..HEAD", base), "--no-color"})
if err != nil || diff == "" {
diff, err = gitRun([]string{"diff", fmt.Sprintf("origin/%s..HEAD", base), "--no-color"})
if err != nil {
color.Red("Failed to get branch diff: %v", err)
return
}
}
if strings.TrimSpace(diff) == "" {
if diff == "" {
color.Yellow("No changes on this branch compared to %s/origin/%s.", base, base)
return
}
modelFlag, _ := cmd.Flags().GetString("model")
model := config.GetModel("prdescribe", modelFlag)
client := newGrokClient()
messages := buildPRDescribeMessages(diff)
color.Yellow("Writing PR description (base=%s)...", base)
color.Yellow("Writing PR description...")
client.Stream(messages, model)
}

View File

@ -1,149 +0,0 @@
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)")
}
}

View File

@ -42,10 +42,7 @@ var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the version information",
Run: func(cmd *cobra.Command, args []string) {
_, err := fmt.Fprintf(os.Stdout, "grokkit version %s (commit %s)\n", version.Version, version.Commit)
if err != nil {
return
}
fmt.Printf("grokkit version %s (commit %s)\\n", version.Version, version.Commit)
},
}
@ -65,7 +62,6 @@ func init() {
rootCmd.AddCommand(scaffoldCmd)
rootCmd.AddCommand(queryCmd)
rootCmd.AddCommand(analyzeCmd)
rootCmd.AddCommand(workonCmd)
// Add model flag to all commands
rootCmd.PersistentFlags().StringP("model", "m", "", "Grok model to use (overrides config)")

View File

@ -47,13 +47,6 @@ func withMockGit(fn func([]string) (string, error)) func() {
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.
func testCmd() *cobra.Command {
c := &cobra.Command{}
@ -267,7 +260,7 @@ func TestRunPRDescribe(t *testing.T) {
t.Run("no changes on branch — skips AI", func(t *testing.T) {
mock := &mockStreamer{}
defer withMockClient(mock)()
defer withMockGitDiff(func(args []string) (string, error) {
defer withMockGit(func(args []string) (string, error) {
return "", nil // both diff calls return empty
})()
@ -282,7 +275,7 @@ func TestRunPRDescribe(t *testing.T) {
mock := &mockStreamer{response: "## PR Title\n\nDescription"}
defer withMockClient(mock)()
callCount := 0
defer withMockGitDiff(func(args []string) (string, error) {
defer withMockGit(func(args []string) (string, error) {
callCount++
if callCount == 1 {
return "diff --git a/foo.go b/foo.go", nil
@ -301,7 +294,7 @@ func TestRunPRDescribe(t *testing.T) {
mock := &mockStreamer{response: "PR description"}
defer withMockClient(mock)()
callCount := 0
defer withMockGitDiff(func(args []string) (string, error) {
defer withMockGit(func(args []string) (string, error) {
callCount++
if callCount == 2 {
return "diff --git a/bar.go b/bar.go", nil
@ -320,7 +313,7 @@ func TestRunPRDescribe(t *testing.T) {
mock := &mockStreamer{response: "PR description"}
defer withMockClient(mock)()
var capturedArgs []string
defer withMockGitDiff(func(args []string) (string, error) {
defer withMockGit(func(args []string) (string, error) {
capturedArgs = args
return "diff content", nil
})()
@ -334,8 +327,8 @@ func TestRunPRDescribe(t *testing.T) {
if mock.calls != 1 {
t.Errorf("expected 1 AI call, got %d", mock.calls)
}
// Expect "diff", "origin/develop..HEAD", "--no-color"
expectedArg := "origin/develop..HEAD"
// Expect "diff", "develop..HEAD", "--no-color"
expectedArg := "develop..HEAD"
found := false
for _, arg := range capturedArgs {
if arg == expectedArg {
@ -352,7 +345,7 @@ func TestRunPRDescribe(t *testing.T) {
mock := &mockStreamer{response: "PR description"}
defer withMockClient(mock)()
var capturedArgs []string
defer withMockGitDiff(func(args []string) (string, error) {
defer withMockGit(func(args []string) (string, error) {
capturedArgs = args
return "diff content", nil
})()
@ -362,7 +355,7 @@ func TestRunPRDescribe(t *testing.T) {
if mock.calls != 1 {
t.Errorf("expected 1 AI call, got %d", mock.calls)
}
expectedArg := "origin/master..HEAD"
expectedArg := "master..HEAD"
found := false
for _, arg := range capturedArgs {
if arg == expectedArg {
@ -396,7 +389,7 @@ func TestRunLintFileNotFound(t *testing.T) {
func TestProcessDocsFileNotFound(t *testing.T) {
mock := &mockStreamer{}
ProcessDocsFile(mock, "grok-4", "/nonexistent/path/file.go")
processDocsFile(mock, "grok-4", "/nonexistent/path/file.go")
if mock.calls != 0 {
t.Errorf("expected 0 AI calls for missing file, got %d", mock.calls)
@ -415,7 +408,7 @@ func TestProcessDocsFileUnsupportedLanguage(t *testing.T) {
defer func() { _ = os.Remove(f.Name()) }()
mock := &mockStreamer{}
ProcessDocsFile(mock, "grok-4", f.Name())
processDocsFile(mock, "grok-4", f.Name())
if mock.calls != 0 {
t.Errorf("expected 0 AI calls for unsupported language, got %d", mock.calls)
@ -453,7 +446,7 @@ func TestProcessDocsFilePreviewAndCancel(t *testing.T) {
}
defer func() { os.Stdin = origStdin }()
ProcessDocsFile(mock, "grok-4", f.Name())
processDocsFile(mock, "grok-4", f.Name())
if mock.calls != 1 {
t.Errorf("expected 1 AI call, got %d", mock.calls)
@ -483,7 +476,7 @@ func TestProcessDocsFileAutoApply(t *testing.T) {
autoApply = true
defer func() { autoApply = origAutoApply }()
ProcessDocsFile(mock, "grok-4", f.Name())
processDocsFile(mock, "grok-4", f.Name())
if mock.calls != 1 {
t.Errorf("expected 1 AI call, got %d", mock.calls)

View File

@ -3,8 +3,6 @@ package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@ -18,83 +16,6 @@ func TestScaffoldCmd(t *testing.T) {
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)
func TestScaffoldCmd_Live(t *testing.T) {
if !testing.Short() {

View File

@ -1,60 +0,0 @@
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)")
}

View File

@ -1,107 +0,0 @@
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")
}
}

View File

@ -23,8 +23,6 @@ fast = "grok-4-1-fast-non-reasoning"
history.model = "grok-4"
prdescribe.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]

View File

@ -38,13 +38,6 @@ func Load() {
viper.SetDefault("commands.review.model", "grok-4")
viper.SetDefault("commands.docs.model", "grok-4")
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
_ = viper.ReadInConfig()
@ -79,11 +72,6 @@ func GetTimeout() int {
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
func GetLogLevel() string {
return viper.GetString("log_level")

View File

@ -136,26 +136,6 @@ 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) {
viper.Reset()
viper.SetDefault("log_level", "info")

View File

@ -35,7 +35,7 @@ grokkit analyze --model grok-4
| Flag | Short | Description | Default |
|----------|-------|----------------------------|-------------------|
| --dir | | Repository root to analyze | Current directory |
| --output | -o | Output file (- for stdout) | .grokkit/analysis.md |
| --output | -o | Output file (- for stdout) | analyze.md |
| --yes | -y | Skip confirmation prompt | false |
| --model | -m | Override model | from config |

View File

@ -24,15 +24,12 @@ Welcome to the full user documentation for **Grokkit** — a fast, native Go CLI
- **[📋 PR-Describe](pr-describe.md)** — Generate full PR descriptions.
- **[📜 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
- **[💬 Chat](chat.md)** — Full interactive chat with history and streaming
- **[🤖 Query](query.md)** — One-shot programming-focused queries
- **[🔍 Review](review.md)** — AI code review of the current repo/directory
- **[🔧 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
- **[Completion](completion.md)** — Generate shell completion scripts
- **[🏷️ Version](version.md)** — Print version information

View File

@ -1,61 +0,0 @@
# 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

View File

@ -1,109 +0,0 @@
# 🔨 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
View File

@ -7,17 +7,13 @@ require (
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.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/mark3labs/mcp-go v0.47.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
@ -28,8 +24,8 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // 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
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

8
go.sum
View File

@ -11,18 +11,12 @@ 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/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/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/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
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/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/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@ -54,8 +48,6 @@ 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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
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/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@ -1,63 +0,0 @@
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()
}

View File

@ -1,50 +0,0 @@
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")
}

View File

@ -1,48 +0,0 @@
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"
}
}

View File

@ -1,30 +0,0 @@
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)
})
}
}

View File

@ -1,7 +1,6 @@
package git
import (
"errors"
"fmt"
"os/exec"
"strings"
@ -61,24 +60,3 @@ func LogSince(since string) (string, error) {
args := []string{"log", "--pretty=format:%s%n%b%n---", since + "..HEAD"}
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
}

View File

@ -1,9 +1,6 @@
package git
import (
"os"
"os/exec"
"strings"
"testing"
)
@ -67,162 +64,3 @@ func TestGitRunner(t *testing.T) {
// Test IsRepo
_ = 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)
}
})
}

View File

@ -317,91 +317,6 @@ 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
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&

View File

@ -105,24 +105,6 @@ 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) {
tmpDir := t.TempDir()
oldHome := os.Getenv("HOME")

View File

@ -1,423 +0,0 @@
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)
}

View File

@ -1,78 +0,0 @@
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,
},
}
}

View File

@ -1,162 +0,0 @@
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)
}
}

View File

@ -1,53 +0,0 @@
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
}

View File

@ -1,45 +0,0 @@
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
}

View File

@ -1,79 +0,0 @@
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 ""
}
}

View File

@ -1,31 +0,0 @@
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)
}
})
}
}

View File

@ -1,28 +0,0 @@
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
}

View File

@ -1,123 +0,0 @@
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)
}
}

View File

@ -1,222 +0,0 @@
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)
}
}

View File

@ -1,474 +0,0 @@
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()
}

View File

@ -1,80 +0,0 @@
<#
.SYNOPSIS
Grokkit Windows installer (PowerShell)
.DESCRIPTION
Downloads, verifies checksum, extracts, and installs grokkit.exe.
Mirrors the hardened grokkit-install.sh exactly.
#>
$ErrorActionPreference = 'Stop'
$VERSION = $env:VERSION
if (-not $VERSION) {
Write-Error "❌ VERSION environment variable not set. Example: `$env:VERSION='0.3.3'; ./grokkit-install.ps1"
exit 1
}
# Strip leading 'v' if present
$VERSION = $VERSION.TrimStart('v')
$GITEA_BASE = "https://repos.gmgauthier.com/gmgauthier/grokkit"
# Platform detection (Windows only supports amd64 for now; arm64 ready for future)
$ARCH = "amd64"
if ($env:PROCESSOR_ARCHITECTURE -like "*ARM*") {
$ARCH = "arm64"
}
$ASSET = "grokkit-windows-${ARCH}-v${VERSION}.tar.gz"
Write-Host "Installing grokkit ${VERSION} for windows/${ARCH}..."
$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $TempDir | Out-Null
try {
Push-Location $TempDir
# Download asset + checksums
Write-Host "Downloading ${ASSET}..."
Invoke-WebRequest -Uri "${GITEA_BASE}/releases/download/v${VERSION}/${ASSET}" -OutFile "asset.tar.gz"
Write-Host "Downloading checksums.txt..."
Invoke-WebRequest -Uri "${GITEA_BASE}/releases/download/v${VERSION}/checksums.txt" -OutFile "checksums.txt"
# Robust checksum verification
Write-Host "Verifying checksum..."
$ChecksumContent = Get-Content "checksums.txt" -Raw
$HashLine = $ChecksumContent -split "`n" | Where-Object { $_ -match $ASSET }
if ($HashLine) {
$ExpectedHash = ($HashLine -split '\s+')[0]
$ActualHash = (Get-FileHash "asset.tar.gz" -Algorithm SHA256).Hash.ToLower()
if ($ActualHash -ne $ExpectedHash) {
Write-Error "❌ Checksum mismatch for ${ASSET}!"
exit 1
}
Write-Host "✅ Checksum verified successfully"
}
else {
Write-Warning "⚠️ No checksum entry found for ${ASSET} continuing without verification"
}
# Extract (modern Windows has tar.exe)
Write-Host "Extracting asset..."
tar -xzf "asset.tar.gz"
# The binary inside is named grokkit-windows-amd64.exe (per release workflow)
$BinaryName = "grokkit-windows-${ARCH}.exe"
# Install
$InstallDir = Join-Path $env:USERPROFILE ".local\bin"
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
Move-Item $BinaryName "${InstallDir}\grokkit.exe" -Force
Write-Host "✅ grokkit ${VERSION} installed to ${InstallDir}\grokkit.exe"
Write-Host "Add to PATH if needed: `$env:PATH += `";${InstallDir}`""
& "${InstallDir}\grokkit.exe" version
}
finally {
Pop-Location
Remove-Item -Recurse -Force $TempDir -ErrorAction SilentlyContinue
}

View File

@ -1,24 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION=${VERSION:-${1:?Provide VERSION env or arg, e.g. VERSION=0.3.2 bash grokkit-install.sh}}
# Strip leading 'v' if present (makes invocation flexible)
VERSION=${VERSION#v}
VERSION=${VERSION:-${1:?Provide VERSION env or arg, e.g. VERSION=1.0.0 bash grokkit-install.sh}}
GITEA_BASE=https://repos.gmgauthier.com/gmgauthier/grokkit
# Platform detection
# Detect platform
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$OS" in
linux) OS=linux ;;
darwin) OS=darwin ;;
linux) OS=linux ;;
darwin) OS=darwin ;;
esac
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64) ARCH=amd64 ;;
arm64|aarch64) ARCH=arm64 ;;
x86_64|amd64) ARCH=amd64 ;;
arm64|aarch64) ARCH=arm64 ;;
esac
ASSET="grokkit-${OS}-${ARCH}-v${VERSION}.tar.gz"
@ -26,48 +23,28 @@ ASSET="grokkit-${OS}-${ARCH}-v${VERSION}.tar.gz"
echo "Installing grokkit ${VERSION} for ${OS}/${ARCH}..."
TEMP_DIR=$(mktemp -d)
trap 'rm -rf "${TEMP_DIR}"' EXIT
#trap 'rm -rf "${TEMP_DIR}" EXIT'
cd "${TEMP_DIR}"
# Download asset + checksums
echo "Downloading ${ASSET}..."
# Download
echo "Downloading From ${GITEA_BASE}/releases/download/v${VERSION}/${ASSET}"
curl -fL "${GITEA_BASE}/releases/download/v${VERSION}/${ASSET}" -o asset.tar.gz
#echo "Downloading checksums..."
#curl -fL "${GITEA_BASE}/releases/download/v${VERSION}/checksums.txt" -o checksums.txt
echo "Downloading checksums.txt..."
curl -fL "${GITEA_BASE}/releases/download/v${VERSION}/checksums.txt" -o checksums.txt
# Robust checksum verification
echo "Verifying checksum..."
CHECKSUM_CMD=""
if command -v sha256sum >/dev/null 2>&1; then
CHECKSUM_CMD="sha256sum"
elif command -v shasum >/dev/null 2>&1; then
CHECKSUM_CMD="shasum -a 256"
fi
if [ -n "$CHECKSUM_CMD" ] && [ -f checksums.txt ]; then
# Extract hash (handles "build/..." prefix in checksums.txt)
HASH=$(grep -oE '[0-9a-f]{64}\s+build/[^ ]*' checksums.txt | grep "${ASSET}" | cut -d' ' -f1)
if [ -z "$HASH" ]; then
echo "⚠️ No checksum entry found for ${ASSET} continuing without verification"
else
echo "${HASH} asset.tar.gz" | $CHECKSUM_CMD --check - || {
echo "❌ Checksum mismatch for ${ASSET}!"
exit 1
}
echo "✅ Checksum verified successfully"
fi
else
echo "⚠️ Checksum tool not found (sha256sum/shasum) skipping verification"
fi
# Verify checksum
#echo "Verifying checksums..."
#HASH=$(grep " ${ASSET}$" checksums.txt | cut -d " " -f1)
#echo "${HASH} asset.tar.gz" | shasum -a 256 --check - || { echo "Checksum mismatch!"; exit 1; }
# Extract
echo "Extracting asset..."
echo "Extracting asset"
tar xzf asset.tar.gz
BINARY="grokkit-${OS}-${ARCH}"
# Install
echo "Installing ${BINARY}..."
INSTALL_DIR="${HOME}/.local/bin"
mkdir -p "${INSTALL_DIR}"
mv "${BINARY}" "${INSTALL_DIR}/grokkit"

View File

@ -4,19 +4,18 @@ This document provides a table of contents for all tasks and features currently
## Queued
* [1] [workon](doing/workon.md): Grokkit workon for bootstrapping a new feature
* [2] [cnotes.md](./queued/cnotes.md) : grokkit cnotes integration
* [3] [make.md](./queued/make.md) : grokkit make integration
* [4] [rg.md](./queued/rg.md) : grokkit ripgrep (rg) integration
* [5] [gotools.md](./queued/gotools.md) : grokkit Go tools integration
* [6] [tea.md](./queued/tea.md) : grokkit tea (Gitea CLI) integration
* [1] [interactive-agent.md](./queued/interactive-agent.md) : Grokkit Interactive Agent
* [2] [rg.md](./queued/rg.md) : grokkit agent ripgrep (rg) integration
* [3] [gotools.md](./queued/gotools.md) : grokkit agent Go tools integration
* [4] [make.md](./queued/make.md) : grokkit agent make integration
* [5] [tea.md](./queued/tea.md) : grokkit agent tea (Gitea CLI) integration
* [6] [cnotes.md](./queued/cnotes.md) : grokkit agent cnotes integration
* [7] [profile.md](./queued/profile.md) : grokkit profile
* [8] [pprof.md](./queued/pprof.md) : grokkit agent pprof integration
* [9] [audit.md](./queued/audit.md) : grokkit audit
* [10] [git-chglog.md](./queued/git-chglog.md) : grokkit agent git-chglog integration
* [11] [admin.md](./queued/admin.md) : [optional] grokkit admin tool (to show token usage and other admin-only features)
* [12] [interactive-agent.md](./queued/interactive-agent.md) : [optional] Grokkit Interactive Agent
* [13] [mcp-feature.md](./queued/mcp-feature.md) : grokkit MCP server mode
* [11] [admin.md](./queued/admin.md) : grokkit admin tool (to show token usage and other admin-only features)
* [99] [TODO_ITEM.md](./queued/TODO_ITEM.md) : TODO ITEM template
## Completed

View File

@ -1,137 +0,0 @@
# 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`.

View File

@ -1,46 +0,0 @@
# `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