Compare commits

..

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

49 changed files with 124 additions and 3673 deletions

View File

@ -45,7 +45,7 @@ jobs:
done done
sha256sum build/grokkit-*.tar.gz | tee build/checksums.txt sha256sum build/grokkit-*.tar.gz | tee build/checksums.txt
cp scripts/grokkit-install.sh build/ 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 for plat in 'linux/amd64' 'linux/arm64' 'darwin/amd64' 'darwin/arm64' 'windows/amd64'; do
IFS='/' read -r OS ARCH <<< "$plat" IFS='/' read -r OS ARCH <<< "$plat"
BIN="grokkit-${OS}-${ARCH}" BIN="grokkit-${OS}-${ARCH}"
@ -60,16 +60,18 @@ jobs:
run: | run: |
VERSION=${GITHUB_REF#refs/tags/} VERSION=${GITHUB_REF#refs/tags/}
GITEA_API=https://repos.gmgauthier.com/api/v1 GITEA_API=https://repos.gmgauthier.com/api/v1
# Create release
curl -X POST "${GITEA_API}/repos/${GITHUB_REPOSITORY}/releases" \ curl -X POST "${GITEA_API}/repos/${GITHUB_REPOSITORY}/releases" \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \ -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) RELEASE_ID=$(jq .id release.json)
# Upload assets
for asset in build/* ; do for asset in build/* ; do
name=$(basename "$asset") name=$(basename "$asset")
mime="application/octet-stream" mime="application/octet-stream"
[[ "$name" =~ \.tar\.gz$ ]] && mime="application/gzip" [[ "$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}" \ curl -X POST "${GITEA_API}/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${name}" \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: ${mime}" \ -H "Content-Type: ${mime}" \

View File

@ -1,142 +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 ## [v0.3.2] - 2026-03-30
Polishing the install script and catching up on past changelog entries. Polishing the install script and catching up on past changelog entries.

118
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. 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)]() [![Go Version](https://img.shields.io/badge/go-1.24.2-blue)]()
[![License](https://img.shields.io/badge/license-Unlicense-lightgrey)]() [![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) - Git (for git-related commands)
- XAI API key - XAI API key
## 🚀 Quick Start ## 🚀 Quick Start Installation
### One-line Install (Recommended) ### From pre-built release (recommended)
**Bash (Linux/macOS):**
```bash ```bash
export VERSION=0.4.0 VERSION=0.1.3 # Replace with latest version tag (omit 'v')
curl -fsSL https://repos.gmgauthier.com/gmgauthier/grokkit/releases/download/v${VERSION}/grokkit-install.sh | bash -s -- --verify 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) ### From Source (for development)
```bash ```bash
# Set your API key # Set your API key
export XAI_API_KEY=sk-... export XAI_API_KEY=sk-...
@ -67,10 +32,9 @@ cd grokkit
make test make test
make build make build
make install make install
``` ````
### Verify: ### Verify:
```bash ```bash
grokkit version 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. See [The User Guide](docs/user-guide/index.md) for complete details on command usage.
**New:** Grokkit can now run as an **[MCP Server](docs/user-guide/mcp.md)** (`grokkit mcp`), allowing AI coding agents like Claude Code to call its tools directly.
## 🛡️ Safety & Change Management ## 🛡️ Safety & Change Management
Grokkit is designed to work seamlessly with Git, using version control instead of redundant backup files to manage changes and rollbacks. Grokkit is designed to work seamlessly with Git, using version control instead of redundant backup files to manage changes and rollbacks.
@ -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) [Read the Safety & Change Management Guide](docs/user-guide/safety.md)
## Logging ### Logging
Logs are written to `~/.config/grokkit/grokkit.log` in JSON format. 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 # Find errors
`tail -f ~/.config/grokkit/grokkit.log` cat ~/.config/grokkit/grokkit.log | jq 'select(.level=="ERROR")'
### 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}'`
# Track API performance
cat ~/.config/grokkit/grokkit.log | jq 'select(.msg=="API request completed") | {model, duration_ms, response_length}'
```
## Workflows ## Workflows
@ -149,29 +111,27 @@ Generate shell completions for faster command entry:
### Global Flags (work with all commands) ### Global Flags (work with all commands)
| Flag | Short | Description | | Flag | Short | Description |
|-------------|--------|------------------------------------------| |------|-------|-------------|
| `--model` | `-m` | Override model (e.g., grok-4, grok-beta) | | `--model` | `-m` | Override model (e.g., grok-4, grok-beta) |
| `--debug` | | Enable debug logging (stderr + file) | | `--debug` | | Enable debug logging (stderr + file) |
| `--verbose` | `-v` | Enable verbose logging | | `--verbose` | `-v` | Enable verbose logging |
| `--help` | `-h` | Show help | | `--help` | `-h` | Show help |
### Examples ### 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` ## Features
#### Verbose logging
`grokkit review -v`
#### Features
### Core Features ### Core Features
- ✅ **Structured logging with slog** - JSON logs with request tracing, timing, and context - ✅ **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 unit test generation** - Go (table-driven), Python (pytest), C (Check), C++ (GTest)
- ✅ **AI file scaffolding** - Create new files with project-aware style matching - ✅ **AI file scaffolding** - Create new files with project-aware style matching
- ✅ **Transactional Recipes** - Markdown-based multi-step AI workflows - ✅ **Transactional Recipes** - Markdown-based multi-step AI workflows
- ✅ **Automated Workon** - AI-powered todo/fix workflow with branch management, work plan generation, and completion tracking
### Quality & Testing ### Quality & Testing
- ✅ **Test coverage 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% - ✅ **Coverage gate in CI** - Builds fail if coverage drops below 50%
- ✅ **CI/CD with Gitea Actions** - Tests must pass before lint and build jobs run - ✅ **CI/CD with Gitea Actions** - Tests must pass before lint and build jobs run
- ✅ **Golangci-lint configured** - `.golangci.yml` with govet, errcheck, staticcheck, and more - ✅ **Golangci-lint configured** - `.golangci.yml` with govet, errcheck, staticcheck, and more
@ -238,17 +197,18 @@ Grokkit uses the xAI Grok API. Be aware:
**Common issues:** **Common issues:**
### API key not set ```bash
# API key not set
Error: XAI_API_KEY environment variable 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 Error: Request failed: context deadline exceeded
→ Solution: Increase timeout in `config.toml` or check network → Solution: Increase timeout in config.toml or check network
### Permission denied on log file
→ Solution: `chmod 644 ~/.config/grokkit/grokkit.log`
# 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.** **See [docs/developer-guide/TROUBLESHOOTING.md](docs/developer-guide/TROUBLESHOOTING.md) for more details.**

View File

@ -14,7 +14,7 @@ import (
"gmgauthier.com/grokkit/internal/git" "gmgauthier.com/grokkit/internal/git"
"gmgauthier.com/grokkit/internal/grok" "gmgauthier.com/grokkit/internal/grok"
"gmgauthier.com/grokkit/internal/linter" "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" "gmgauthier.com/grokkit/internal/prompts"
) )
@ -30,16 +30,23 @@ Uses language-specific prompts discovered in .grokkit/prompts/ or ~/.config/grok
} }
output := viper.GetString("output") output := viper.GetString("output")
if output == "" { if output == "" {
// Default to project-local context file inside .grokkit/
output = filepath.Join(".grokkit", "analysis.md") output = filepath.Join(".grokkit", "analysis.md")
} }
// Fixed: config.GetModel takes (commandName, flagModel)
model := config.GetModel("analyze", viper.GetString("model")) model := config.GetModel("analyze", viper.GetString("model"))
yes := viper.GetBool("yes") 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.") 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 { if err != nil {
logger.Error("Failed to discover source files", "dir", dir, "error", err) logger.Error("Failed to discover source files", "dir", dir, "error", err)
os.Exit(1) os.Exit(1)
@ -49,11 +56,13 @@ Uses language-specific prompts discovered in .grokkit/prompts/ or ~/.config/grok
os.Exit(1) os.Exit(1)
} }
// 2. Detect primary language
lang := linter.DetectPrimaryLanguage(files) lang := linter.DetectPrimaryLanguage(files)
if lang == "" { if lang == "" {
lang = "unknown" lang = "unknown"
} }
// 3. Load language-specific prompt (project → global)
promptPath, promptContent, err := prompts.LoadAnalysisPrompt(dir, lang) promptPath, promptContent, err := prompts.LoadAnalysisPrompt(dir, lang)
if err != nil { if err != nil {
fmt.Printf("Error: Could not find analysis prompt for language '%s'.\n\n", lang) 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) os.Exit(1)
} }
logger.Info("Loaded analysis prompt", "language", lang, "path", promptPath) logger.Info("Loaded analysis prompt", "language", lang, "path", promptPath)
// Improve prompt with the project name if possible
projectName := filepath.Base(dir) projectName := filepath.Base(dir)
if projectName == "." { if projectName == "." {
projectName = "Current Project" projectName = "Current Project"
} }
promptContent = strings.Replace(promptContent, "[Inferred Project Name]", projectName, 1) 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{ messages := []map[string]string{
{"role": "system", "content": promptContent}, {"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)}, {"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) report := grok.NewClient().StreamSilent(messages, model)
// 6. Transactional preview + confirmation
if !yes { if !yes {
fmt.Println("\n=== Proposed Analysis Report Preview (first 60 lines) ===") fmt.Println("\n=== Proposed Analysis Report Preview (first 60 lines) ===")
previewLines(report, 60) previewLines(report, 60)
@ -95,15 +110,18 @@ Uses language-specific prompts discovered in .grokkit/prompts/ or ~/.config/grok
} }
} }
// 7. Output
if output == "-" { if output == "-" {
fmt.Println(report) fmt.Println(report)
return return
} }
// Ensure .grokkit directory exists
if err := os.MkdirAll(".grokkit", 0755); err != nil { if err := os.MkdirAll(".grokkit", 0755); err != nil {
logger.Error("Failed to create .grokkit directory", "error", err) logger.Error("Failed to create .grokkit directory", "error", err)
os.Exit(1) os.Exit(1)
} }
// Write the file
if err := os.WriteFile(output, []byte(report), 0644); err != nil { if err := os.WriteFile(output, []byte(report), 0644); err != nil {
logger.Error("Failed to write report", "file", output, "error", err) logger.Error("Failed to write report", "file", output, "error", err)
os.Exit(1) os.Exit(1)
@ -118,27 +136,28 @@ func init() {
analyzeCmd.Flags().String("dir", ".", "Repository root to analyze") analyzeCmd.Flags().String("dir", ".", "Repository root to analyze")
analyzeCmd.Flags().StringP("output", "o", "analyze.md", "Output file (use - for stdout)") analyzeCmd.Flags().StringP("output", "o", "analyze.md", "Output file (use - for stdout)")
analyzeCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") analyzeCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
// Export analysis functions for MCP use
// mcp.RegisterAnalyzeHelpers(DiscoverSourceFiles, BuildProjectContext)
} }
func DiscoverSourceFiles(root string) ([]string, error) { // 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 var files []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
// Skip common hidden/noise directories but still allow descent into source dirs
name := info.Name() name := info.Name()
if info.IsDir() { 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" { name == "node_modules" || name == "vendor" || name == "build" || name == "dist" {
return filepath.SkipDir return filepath.SkipDir
} }
return nil return nil
} }
// Check if this is a supported source file
if _, detectErr := linter.DetectLanguage(path); detectErr == nil { if _, detectErr := linter.DetectLanguage(path); detectErr == nil {
files = append(files, path) files = append(files, path)
} }
@ -147,6 +166,7 @@ func DiscoverSourceFiles(root string) ([]string, error) {
return files, err return files, err
} }
// previewLines prints the first N lines of the report (unchanged)
func previewLines(content string, n int) { func previewLines(content string, n int) {
scanner := bufio.NewScanner(strings.NewReader(content)) scanner := bufio.NewScanner(strings.NewReader(content))
for i := 0; i < n && scanner.Scan(); i++ { 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 var sb strings.Builder
sb.WriteString("Project Root: " + dir + "\n\n") sb.WriteString("Project Root: " + dir + "\n\n")
_, err := fmt.Fprintf(&sb, "Total source files discovered: %d\n\n", len(files)) sb.WriteString(fmt.Sprintf("Total source files discovered: %d\n\n", len(files)))
if err != nil {
return ""
}
sb.WriteString("Key files (top-level view):\n") sb.WriteString("Key files (top-level view):\n")
for _, f := range files { 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 != "" { if out, err := git.Run([]string{"remote", "-v"}); err == nil && out != "" {
sb.WriteString("\nGit Remotes:\n" + out + "\n") 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() client := newGrokClient()
for _, filePath := range args { for _, filePath := range args {
ProcessDocsFile(client, model, filePath) processDocsFile(client, model, filePath)
} }
} }
func ProcessDocsFile(client grok.AIClient, model, filePath string) { func processDocsFile(client grok.AIClient, model, filePath string) {
logger.Info("starting docs operation", "file", filePath) logger.Info("starting docs operation", "file", filePath)
if _, err := os.Stat(filePath); os.IsNotExist(err) { if _, err := os.Stat(filePath); os.IsNotExist(err) {
@ -79,7 +79,7 @@ func ProcessDocsFile(client grok.AIClient, model, filePath string) {
color.Cyan("📝 Generating %s docs for: %s", lang.Name, filePath) color.Cyan("📝 Generating %s docs for: %s", lang.Name, filePath)
logger.Info("requesting AI documentation", "file", filePath, "language", lang.Name) logger.Info("requesting AI documentation", "file", filePath, "language", lang.Name)
messages := BuildDocsMessages(lang.Name, string(originalContent)) messages := buildDocsMessages(lang.Name, string(originalContent))
response := client.StreamSilent(messages, model) response := client.StreamSilent(messages, model)
if response == "" { if response == "" {
@ -134,7 +134,7 @@ func ProcessDocsFile(client grok.AIClient, model, filePath string) {
color.Green("✅ Documentation applied: %s", filePath) color.Green("✅ Documentation applied: %s", filePath)
} }
func BuildDocsMessages(language, code string) []map[string]string { func buildDocsMessages(language, code string) []map[string]string {
style := docStyle(language) style := docStyle(language)
systemPrompt := fmt.Sprintf( systemPrompt := fmt.Sprintf(
"You are a documentation expert. Add %s documentation comments to the provided code. "+ "You are a documentation expert. Add %s documentation comments to the provided code. "+

View File

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

View File

@ -35,6 +35,7 @@ var editCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
// nolint:gosec // intentional file read from user input
original, err := os.ReadFile(filePath) original, err := os.ReadFile(filePath)
if err != nil { if err != nil {
logger.Error("failed to read file", "file", filePath, "error", err) logger.Error("failed to read file", "file", filePath, "error", err)
@ -45,28 +46,14 @@ var editCmd = &cobra.Command{
cleanedOriginal := removeLastModifiedComments(string(original)) cleanedOriginal := removeLastModifiedComments(string(original))
client := grok.NewClient() client := grok.NewClient()
isMarkdown := filepath.Ext(filePath) == ".md" messages := []map[string]string{
var 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."},
if isMarkdown { {"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 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)},
}
} }
color.Yellow("Asking Grok to %s...\n", instruction) color.Yellow("Asking Grok to %s...\n", instruction)
raw := client.StreamSilent(messages, model) raw := client.StreamSilent(messages, model)
var newContent string newContent := grok.CleanCodeResponse(raw)
if isMarkdown {
newContent = strings.TrimSpace(raw)
} else {
newContent = grok.CleanCodeResponse(raw)
}
color.Green("✓ Response received") color.Green("✓ Response received")
color.Cyan("\nProposed changes:") color.Cyan("\nProposed changes:")
@ -103,11 +90,13 @@ var editCmd = &cobra.Command{
func removeLastModifiedComments(content string) string { func removeLastModifiedComments(content string) string {
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
cleanedLines := make([]string, 0, len(lines)) cleanedLines := make([]string, 0, len(lines))
for _, line := range lines { for _, line := range lines {
trimmed := strings.TrimSpace(line) if strings.Contains(line, "Last modified") {
if !strings.Contains(strings.ToLower(trimmed), "last modified") { continue
cleanedLines = append(cleanedLines, line)
} }
cleanedLines = append(cleanedLines, line)
} }
return strings.Join(cleanedLines, "\n") 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 ( import (
"fmt" "fmt"
"strings"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"gmgauthier.com/grokkit/config" "gmgauthier.com/grokkit/config"
"gmgauthier.com/grokkit/internal/git"
) )
// gitDiff is the mockable entry point (exactly like gitRun used elsewhere).
var gitDiff = git.Diff
var prDescribeCmd = &cobra.Command{ var prDescribeCmd = &cobra.Command{
Use: "pr-describe", Use: "pr-describe",
Short: "Generate full PR description from current branch", Short: "Generate full PR description from current branch",
@ -20,32 +15,30 @@ var prDescribeCmd = &cobra.Command{
} }
func init() { 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) { func runPRDescribe(cmd *cobra.Command, _ []string) {
base, _ := cmd.Flags().GetString("base") base, _ := cmd.Flags().GetString("base")
// Prefer remote (more likely up-to-date for PRs), fallback to local. diff, err := gitRun([]string{"diff", fmt.Sprintf("%s..HEAD", base), "--no-color"})
diff, err := gitDiff([]string{"diff", fmt.Sprintf("origin/%s..HEAD", base), "--no-color"}) if err != nil || diff == "" {
if err != nil || strings.TrimSpace(diff) == "" { diff, err = gitRun([]string{"diff", fmt.Sprintf("origin/%s..HEAD", base), "--no-color"})
diff, err = gitDiff([]string{"diff", fmt.Sprintf("%s..HEAD", base), "--no-color"})
if err != nil { if err != nil {
color.Red("Failed to get branch diff: %v", err) color.Red("Failed to get branch diff: %v", err)
return return
} }
} }
if strings.TrimSpace(diff) == "" { if diff == "" {
color.Yellow("No changes on this branch compared to %s/origin/%s.", base, base) color.Yellow("No changes on this branch compared to %s/origin/%s.", base, base)
return return
} }
modelFlag, _ := cmd.Flags().GetString("model") modelFlag, _ := cmd.Flags().GetString("model")
model := config.GetModel("prdescribe", modelFlag) model := config.GetModel("prdescribe", modelFlag)
client := newGrokClient() client := newGrokClient()
messages := buildPRDescribeMessages(diff) messages := buildPRDescribeMessages(diff)
color.Yellow("Writing PR description (base=%s)...", base) color.Yellow("Writing PR description...")
client.Stream(messages, model) 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", Use: "version",
Short: "Print the version information", Short: "Print the version information",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
_, err := fmt.Fprintf(os.Stdout, "grokkit version %s (commit %s)\n", version.Version, version.Commit) fmt.Printf("grokkit version %s (commit %s)\\n", version.Version, version.Commit)
if err != nil {
return
}
}, },
} }
@ -65,7 +62,6 @@ func init() {
rootCmd.AddCommand(scaffoldCmd) rootCmd.AddCommand(scaffoldCmd)
rootCmd.AddCommand(queryCmd) rootCmd.AddCommand(queryCmd)
rootCmd.AddCommand(analyzeCmd) rootCmd.AddCommand(analyzeCmd)
rootCmd.AddCommand(workonCmd)
// Add model flag to all commands // Add model flag to all commands
rootCmd.PersistentFlags().StringP("model", "m", "", "Grok model to use (overrides config)") rootCmd.PersistentFlags().StringP("model", "m", "", "Grok model to use (overrides config)")

View File

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

View File

@ -3,8 +3,6 @@ package cmd
import ( import (
"os" "os"
"path/filepath"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "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") assert.True(t, true, "command is registered and basic structure is intact")
} }
func TestDetectLanguage(t *testing.T) {
tests := []struct {
name string
path string
override string
expected string
}{
{"Go file", "main.go", "", "Go"},
{"Python file", "script.py", "", "Python"},
{"JS file", "app.js", "", "TypeScript"},
{"TS file", "app.ts", "", "TypeScript"},
{"C file", "main.c", "", "C"},
{"C++ file", "main.cpp", "", "C++"},
{"Java file", "Main.java", "", "Java"},
{"unknown extension", "data.xyz", "", "code"},
{"override takes precedence", "main.go", "Rust", "Rust"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectLanguage(tt.path, tt.override)
if got != tt.expected {
t.Errorf("detectLanguage(%q, %q) = %q, want %q", tt.path, tt.override, got, tt.expected)
}
})
}
}
func TestHarvestContext(t *testing.T) {
tmpDir := t.TempDir()
// Create the target file
targetPath := filepath.Join(tmpDir, "target.go")
if err := os.WriteFile(targetPath, []byte("package main\n\nfunc Target() {}\n"), 0644); err != nil {
t.Fatal(err)
}
// Create a sibling .go file (should be included)
siblingPath := filepath.Join(tmpDir, "sibling.go")
if err := os.WriteFile(siblingPath, []byte("package main\n\nfunc Sibling() {}\n"), 0644); err != nil {
t.Fatal(err)
}
// Create a non-.go sibling (should not be included)
if err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("notes"), 0644); err != nil {
t.Fatal(err)
}
ctx := harvestContext(targetPath, "Go")
if !strings.Contains(ctx, "sibling.go") {
t.Error("expected sibling.go in context")
}
if !strings.Contains(ctx, "func Sibling()") {
t.Error("expected sibling content in context")
}
if strings.Contains(ctx, "target.go") {
t.Error("target file should not appear in its own context")
}
if strings.Contains(ctx, "notes.txt") {
t.Error("non-matching extension should not appear in context")
}
}
func TestHarvestContextEmptyDir(t *testing.T) {
tmpDir := t.TempDir()
targetPath := filepath.Join(tmpDir, "lonely.go")
if err := os.WriteFile(targetPath, []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
ctx := harvestContext(targetPath, "Go")
if ctx != "" {
t.Errorf("expected empty context for solo file, got: %q", ctx)
}
}
// TestScaffoldCmd_Live — LIVE INTEGRATION TEST (only runs when you ask) // TestScaffoldCmd_Live — LIVE INTEGRATION TEST (only runs when you ask)
func TestScaffoldCmd_Live(t *testing.T) { func TestScaffoldCmd_Live(t *testing.T) {
if !testing.Short() { if !testing.Short() {

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" history.model = "grok-4"
prdescribe.model = "grok-4" prdescribe.model = "grok-4"
review.model = "grok-4" review.model = "grok-4"
workon.model = "grok-4-1-fast-non-reasoning" # Fast model for work plan generation
workon.ide = "" # IDE command to open repo (e.g. "code", "goland")
# Chat history settings # Chat history settings
[chat] [chat]

View File

@ -38,13 +38,6 @@ func Load() {
viper.SetDefault("commands.review.model", "grok-4") viper.SetDefault("commands.review.model", "grok-4")
viper.SetDefault("commands.docs.model", "grok-4") viper.SetDefault("commands.docs.model", "grok-4")
viper.SetDefault("commands.query.model", "grok-4-1-fast-non-reasoning") viper.SetDefault("commands.query.model", "grok-4-1-fast-non-reasoning")
viper.SetDefault("commands.workon.model", "grok-4-1-fast-non-reasoning")
viper.SetDefault("commands.workon.ide", "")
// MCP configuration
viper.SetDefault("mcp.enabled", true)
viper.SetDefault("mcp.tools", []string{"lint_code", "analyze_code", "generate_docs", "generate_tests", "generate_commit_msg", "run_recipe"})
viper.SetDefault("mcp.resources", []string{"recipes://local", "recipes://global", "prompts://language"})
// Config file is optional, so we ignore read errors // Config file is optional, so we ignore read errors
_ = viper.ReadInConfig() _ = viper.ReadInConfig()
@ -79,11 +72,6 @@ func GetTimeout() int {
return timeout return timeout
} }
// GetIDECommand returns the configured IDE command for workon, or empty string if unset.
func GetIDECommand() string {
return viper.GetString("commands.workon.ide")
}
// GetLogLevel returns the log level from the configuration // GetLogLevel returns the log level from the configuration
func GetLogLevel() string { func GetLogLevel() string {
return viper.GetString("log_level") return viper.GetString("log_level")

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) { func TestGetLogLevel(t *testing.T) {
viper.Reset() viper.Reset()
viper.SetDefault("log_level", "info") viper.SetDefault("log_level", "info")

View File

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

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. - **[📋 PR-Describe](pr-describe.md)** — Generate full PR descriptions.
- **[📜 History](history.md)** — Summarize recent git history. - **[📜 History](history.md)** — Summarize recent git history.
### Productivity Commands
- **[🔨 Workon](workon.md)** — Automate starting and completing todo items and fixes with AI work plans.
### Other Useful Commands ### Other Useful Commands
- **[💬 Chat](chat.md)** — Full interactive chat with history and streaming - **[💬 Chat](chat.md)** — Full interactive chat with history and streaming
- **[🤖 Query](query.md)** — One-shot programming-focused queries - **[🤖 Query](query.md)** — One-shot programming-focused queries
- **[🔍 Review](review.md)** — AI code review of the current repo/directory - **[🔍 Review](review.md)** — AI code review of the current repo/directory
- **[🔧 Lint](lint.md)** — Lint + AI-suggested fixes - **[🔧 Lint](lint.md)** — Lint + AI-suggested fixes
- **[🔎 Analyze](analyze.md)** — Deep educational analysis of any codebase with custom language prompts. - - **[🔎 Analyze](analyze.md)** — Deep educational analysis of any codebase with custom language prompts.
- **[📖 Docs](docs.md)** — Generate documentation comments - **[📖 Docs](docs.md)** — Generate documentation comments
- **[Completion](completion.md)** — Generate shell completion scripts - **[Completion](completion.md)** — Generate shell completion scripts
- **[🏷️ Version](version.md)** — Print version information - **[🏷️ Version](version.md)** — Print version information

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/cobra v1.9.1
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mark3labs/mcp-go v0.47.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
@ -28,8 +24,8 @@ require (
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.38.0 // indirect golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

8
go.sum
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/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mark3labs/mcp-go v0.47.0 h1:h44yeM3DduDyQgzImYWu4pt6VRkqP/0p/95AGhWngnA=
github.com/mark3labs/mcp-go v0.47.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@ -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/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

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 package git
import ( import (
"errors"
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
@ -61,24 +60,3 @@ func LogSince(since string) (string, error) {
args := []string{"log", "--pretty=format:%s%n%b%n---", since + "..HEAD"} args := []string{"log", "--pretty=format:%s%n%b%n---", since + "..HEAD"}
return Run(args) return Run(args)
} }
// Diff runs `git diff` and tolerates exit code 1 (differences found).
// This is normal git behavior when there *are* changes — unlike .Output().
func Diff(args []string) (string, error) {
cmdStr := "git " + strings.Join(args, " ")
logger.Debug("executing git diff", "command", cmdStr)
// nolint:gosec // intentional git subprocess
cmd := exec.Command("git", args...)
out, err := cmd.CombinedOutput()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
// legitimate diff (changes exist)
return string(out), nil
}
logger.Error("git diff failed", "command", cmdStr, "error", err)
return "", fmt.Errorf("git diff failed: %w", err)
}
return string(out), nil
}

View File

@ -1,9 +1,6 @@
package git package git
import ( import (
"os"
"os/exec"
"strings"
"testing" "testing"
) )
@ -67,162 +64,3 @@ func TestGitRunner(t *testing.T) {
// Test IsRepo // Test IsRepo
_ = runner.IsRepo() // Just ensure it doesn't panic _ = runner.IsRepo() // Just ensure it doesn't panic
} }
// initTaggedGitRepo creates a temp git repo with an initial commit and a tag.
func initTaggedGitRepo(t *testing.T) string {
t.Helper()
tmpDir := t.TempDir()
cmds := [][]string{
{"git", "init"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test"},
}
for _, args := range cmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = tmpDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git setup failed (%v): %s", err, out)
}
}
// Create initial commit and tag
if err := os.WriteFile(tmpDir+"/init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"git", "add", "."},
{"git", "commit", "-m", "initial commit"},
{"git", "tag", "v0.1.0"},
} {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = tmpDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git setup (%v) failed: %s", args, out)
}
}
return tmpDir
}
func TestLatestTag(t *testing.T) {
tmpDir := initTaggedGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
tag, err := LatestTag()
if err != nil {
t.Fatalf("LatestTag() failed: %v", err)
}
if tag != "v0.1.0" {
t.Errorf("LatestTag() = %q, want 'v0.1.0'", tag)
}
}
func TestLatestTagMultiple(t *testing.T) {
tmpDir := initTaggedGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Add a second commit and tag
if err := os.WriteFile("second.txt", []byte("second"), 0644); err != nil {
t.Fatal(err)
}
_ = exec.Command("git", "add", ".").Run()
_ = exec.Command("git", "commit", "-m", "second commit").Run()
_ = exec.Command("git", "tag", "v0.2.0").Run()
tag, err := LatestTag()
if err != nil {
t.Fatalf("LatestTag() failed: %v", err)
}
if tag != "v0.2.0" {
t.Errorf("LatestTag() = %q, want 'v0.2.0'", tag)
}
}
func TestPreviousTag(t *testing.T) {
tmpDir := initTaggedGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Add second commit and tag
if err := os.WriteFile("second.txt", []byte("second"), 0644); err != nil {
t.Fatal(err)
}
_ = exec.Command("git", "add", ".").Run()
_ = exec.Command("git", "commit", "-m", "second commit").Run()
_ = exec.Command("git", "tag", "v0.2.0").Run()
prev, err := PreviousTag("v0.2.0")
if err != nil {
t.Fatalf("PreviousTag() failed: %v", err)
}
if prev != "v0.1.0" {
t.Errorf("PreviousTag('v0.2.0') = %q, want 'v0.1.0'", prev)
}
}
func TestLogSince(t *testing.T) {
tmpDir := initTaggedGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Add commits after the tag
if err := os.WriteFile("new.txt", []byte("new"), 0644); err != nil {
t.Fatal(err)
}
_ = exec.Command("git", "add", ".").Run()
_ = exec.Command("git", "commit", "-m", "feat: add new feature").Run()
log, err := LogSince("v0.1.0")
if err != nil {
t.Fatalf("LogSince() failed: %v", err)
}
if !strings.Contains(log, "feat: add new feature") {
t.Errorf("LogSince() missing expected commit message, got: %q", log)
}
}
func TestDiff(t *testing.T) {
tmpDir := initTaggedGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
t.Run("no changes returns empty", func(t *testing.T) {
out, err := Diff([]string{"diff"})
if err != nil {
t.Fatalf("Diff() failed: %v", err)
}
if out != "" {
t.Errorf("Diff() with no changes = %q, want empty", out)
}
})
t.Run("with unstaged changes returns diff", func(t *testing.T) {
if err := os.WriteFile("init.txt", []byte("modified"), 0644); err != nil {
t.Fatal(err)
}
out, err := Diff([]string{"diff"})
if err != nil {
t.Fatalf("Diff() failed: %v", err)
}
if !strings.Contains(out, "modified") {
t.Errorf("Diff() missing change content, got: %q", out)
}
})
}

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 // Helper function
func contains(s, substr string) bool { func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&

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) { func TestWith(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
oldHome := os.Getenv("HOME") 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

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