Compare commits

..

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

52 changed files with 201 additions and 3875 deletions

View File

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

View File

@ -1,68 +0,0 @@
# Project Analysis: Grokkit
## Tech Stack & Layout
- **Language/version, build system, key dependencies and why they were chosen**: This project is written in Go (likely version 1.20+ based on modern idioms inferred from file structure and testing patterns). It uses the standard Go build system (go build/go test) for simplicity and portability, avoiding external build tools like Make for core compilation. Key dependencies are minimal and internal-focused, but likely include standard libraries like os, fmt, and testing, plus possible external ones like go-git for git operations (inferred from internal/git) and net/http for API clients (in internal/grok/client.go). These were chosen for their reliability in CLI tools: go-git enables programmatic Git interactions without shelling out, and http supports potential AI/model integrations (e.g., "grok" suggesting code understanding or AI assistance) to keep the tool lightweight and self-contained.
- **High-level directory structure**:
- cmd/: Contains command-line entry points for various subcommands (e.g., agent.go, analyze.go), each defining a Cobra-style CLI command.
- config/: Handles configuration loading and management (e.g., config.go).
- internal/: Core logic packages like errors, git, grok, linter, logger, prompts, recipe, version kept internal to encapsulate functionality.
- scripts/: Utility scripts for installation and release (e.g., install.sh, release.sh).
- Root: main.go as the entry point, plus test files scattered throughout.
## Module & Package Relationships
- **How packages depend on each other**: The project follows a modular structure where cmd/ packages import and orchestrate internal/ packages. For example, internal/git provides Git utilities used by cmd/commit, cmd/review, etc.; internal/grok likely handles AI/code analysis, depended on by cmd/analyze, cmd/lint, and cmd/chat; internal/recipe manages recipe-based workflows, used in cmd/recipe and cmd/scaffold; config/ is imported broadly for settings; internal/errors and internal/logger are foundational, imported across most packages for error handling and logging. Dependencies flow from high-level cmds to low-level internals, promoting loose coupling via interfaces (e.g., internal/git/interface.go, internal/grok/interface.go).
- **Main public APIs and their purpose**: The project exposes CLI commands via cmd/root.go as the root command, with subcommands like Analyze (code analysis), Commit (git commit assistance), Lint (code linting), and Chat (interactive queries). Public APIs are minimal since it's a CLI tool, but exported types in internal/ (e.g., Git interface) allow extension. The purpose is to provide a toolkit for developers to "grok" (deeply understand) codebases, automate git tasks, generate docs/tests, and integrate AI-like features for productivity.
## Function & Method Reference
### cmd
- **RunAgent** (in agent.go): Starts an agent process for background tasks like monitoring changes. It initializes config and loops on git events using internal/git. Exists to enable automated workflows in development environments.
- **AnalyzeCode** (in analyze.go): Parses and analyzes code diffs or files. Uses internal/grok for insights and internal/prompts for queries; key logic involves diff parsing and AI prompting. Solves the problem of manual code review by automating understanding.
- **GenerateChangelog** (in changelog.go): Builds changelogs from git history. Fetches commits via internal/git and formats them. Designed to streamline release processes.
- **ChatWithCode** (in chat.go): Handles interactive chat sessions about code. Relies on internal/grok/client for API calls; uses a loop for user input and responses. Enables conversational code assistance.
- **CommitChanges** (in commit.go): Automates git commits with AI-generated messages. Integrates internal/git and internal/grok; logic includes staging files and prompting for messages. Addresses inconsistent commit practices.
- **GenerateCompletion** (in completion.go): Provides shell completion scripts. Uses Cobra's built-in generation. Simplifies CLI usability.
- **GenerateDocs** (in docs.go): Creates documentation from code. Parses structs/methods and uses templates. Automates doc maintenance.
- **EditCode** (in edit.go): Assists in editing files with suggestions. Calls internal/grok for edits; handles file I/O. Improves code editing efficiency.
- **ManageHistory** (in history.go): Views or manages command history. Stores in a simple file-based log. Useful for auditing past interactions.
- **LintCode** (in lint.go): Runs linters on code. Uses internal/linter; scans languages and applies rules. Ensures code quality.
- **DescribePR** (in prdescribe.go): Generates PR descriptions. Pulls git data and formats. Streamlines PR creation.
- **QueryCode** (in query.go): Executes ad-hoc queries on codebases. Forwards to internal/grok. Enables flexible code searching.
- **RunRecipe** (in recipe.go): Executes predefined recipes (workflows). Loads from internal/recipe/loader and runs via runner. Automates multi-step tasks.
- **ReviewCode** (in review.go): Performs code reviews. Uses internal/grok for analysis. Mimics human review processes.
- **ScaffoldProject** (in scaffold.go): Generates project scaffolds. Uses templates from internal/recipe. Bootstraps new projects.
- **GenerateTests** (in testgen.go): Auto-generates tests. Analyzes code with internal/grok and writes test files. Reduces testing boilerplate.
### config
- **LoadConfig** (in config.go): Reads and parses configuration files. Uses JSON/YAML unmarshaling; falls back to defaults. Exists for customizable tool behavior.
### internal/errors
- **NewError** (in errors.go): Creates wrapped errors with context. Uses fmt.Errorf patterns. Improves error traceability.
### internal/git
- **GetDiff** (in git.go): Retrieves git diffs. Calls git commands or go-git APIs; parses output. Centralizes git interactions.
- **Commit** (in git.go): Performs git commits. Stages and commits with messages. Abstracts git ops for cmds.
### internal/grok
- **NewClient** (in client.go): Initializes an API client for grok services. Sets up HTTP with auth; uses interfaces for mocking. Enables AI integrations.
- **Query** (in client.go): Sends queries to a backend. Handles request/response with retries. Powers analysis features.
### internal/linter
- **Lint** (in linter.go): Applies lint rules to code. Detects language via internal/linter/language.go and runs checks. Ensures consistent styling.
### internal/logger
- **Log** (in logger.go): Logs messages with levels. Uses fmt or a lib like zap; configurable verbosity. Provides unified logging.
### internal/recipe
- **LoadRecipe** (in loader.go): Loads recipe definitions from files. Parses YAML/JSON into types. Supports extensible workflows.
- **Run** (in runner.go): Executes loaded recipes step-by-step. Uses a state machine pattern. Automates complex tasks.
### internal/version
- **GetVersion** (in version.go): Returns the current version string. Likely build-embedded. For versioning and updates.
## Object & Data Flow
- **Important structs/types and their relationships**: Key structs include Config (in config/) holding settings like API keys, linked to all cmds; GitInterface (in internal/git/interface.go) defining git ops, implemented by a concrete Git struct that interacts with Commit and Diff types; GrokClient (in internal/grok/) manages HTTP sessions, related to QueryRequest/Response structs for data exchange; Recipe (in internal/recipe/types.go) as a struct with steps, loaded by Loader and executed by Runner; Linter (in internal/linter/) with Language detectors. Data flows from CLI inputs to internal services (e.g., cmd -> config -> git/grok -> output), with errors propagating via custom Error types.
- **Any database/ORM mappings or persistence patterns (if present)**: No evident databases or ORMs; persistence is file-based (e.g., config files, git repos, history logs). Patterns include simple JSON serialization for configs and recipes, with no complex mappings.
## Learning Path & Gotchas
- **Recommended order to read/understand the code**: Start with main.go and cmd/root.go to grasp the CLI structure, then explore config/ for setup. Dive into internal/git and internal/logger as foundations. Next, tackle internal/grok and internal/recipe for core features. Finally, review individual cmd/ files like analyze.go or commit.go to see integrations. Run tests (e.g., *_test.go) alongside to verify understanding.
- **Common pitfalls or tricky parts for newcomers**: Watch for interface-based dependencies (e.g., in git and grok) they're great for testing but can obscure concrete implementations; ensure you mock them in tests. CLI commands use Cobra idioms, so unfamiliarity might trip you up study cmd/root.go first. Git operations assume a valid repo; test in a real git dir to avoid nil pointers. Recipes are powerful but YAML parsing can fail silently validate files early. Overall, the project's modular design is encouraging for learning Go best practices!

View File

@ -1,218 +1,3 @@
## [v0.5.0] - 2026-04-06
MCP server mode arrives, ready to serve your AI coding whims.
### Added
- Implement `grokkit mcp` command for MCP server over stdio.
- Add ListRecipes and ListAvailablePrompts functions.
- Export analysis, docs, and testgen functions for MCP tools.
- Add tools: lint_code, analyze_code, generate_docs, generate_tests, generate_commit_msg, run_recipe.
- Register resources: recipes://local, recipes://global, prompts://language.
- Add MCP config options and dependencies.
- Add unit tests for analyze, docs, mcp, prompts, recipe, and testgen packages.
- Add user guide for MCP.
- Add detailed plan in todo/queued/mcp-feature.md.
- Add MCP server mode to queued tasks in TODO README.
### Changed
- Update MCP server handlers for local/global recipes and prompts.
- Update README with MCP details.
## [v0.4.0] - 2026-03-31
Workon command arrives: because who needs manual todos when AI can branch out for you?
### Added
- Add workon command for automating todo/fix workflows.
- Add AI-powered work plan generation using Grok.
- Add git branching for features and fixes.
- Add todo package with bootstrap for directory structure.
- Add flags for fix mode (-f), complete mode (-c), and custom messages (-M).
- Add config options for workon.model and workon.ide.
- Add IDE integration to open on task start.
- Add unit tests for commands, config, git, linter, logger, prompts, todo, and workon.
- Add prompts loading tests with fallback logic.
- Add todo/queued/workon.md documentation.
- Add workon guide to README and user docs.
### Changed
- Update workon command to handle modes: todo, fix, complete.
- Change custom message flag from -m to -M.
- Implement branch prefixing with feature/ or fix/.
- Update README index on task completion.
- Refine error handling, logging, and colorized output.
- Replace placeholder with actual Grok client calls.
- Remove temporary stubs and add TODOs for integrations.
- Reorder queued tasks and update workon descriptions in docs.
- Update workon.md to use 'todo item' instead of 'feature'.
- Clarify -c flag usage and exclusivity in docs.
- Refine workon command steps, usage, and benefits in docs.
- Move workon todo item from queued to doing, then completed.
- Mark recipe feature as completed in todo structure.
- Bump version to 0.3.9 in README and scripts (prior release).
- Update expected git diff ranges in tests.
### Fixed
- Discard errors explicitly from git commands in tests to satisfy linters.
## [v0.3.9] - 2026-03-31
Another tiny step forward, now with origin-al git flair.
### Changed
- Bump version to 0.3.9 in README.md.
- Update version references in Bash and PowerShell installation scripts in README.md.
- Update expected git diff ranges in tests to include origin prefix.
## [v0.3.8] - 2026-03-31
Bumping to v0.3.8 with git tweaks and doc updates.
### Added
- Add changelog entry for v0.3.7.
### Changed
- Update version to 0.3.8 in README installation instructions.
- Update bash and powershell scripts to reference new release version.
- Update git diff command to prefer remote origin/base branch.
- Fall back to local base branch if remote diff fails or is empty.
## [v0.3.7] - 2026-03-31
Version v0.3.7: Patchin' up git diffs because exit codes shouldn't be drama queens.
### Added
- Introduce `gitDiff` variable as a mockable wrapper for `git.Diff`.
- Introduce git.Diff function that uses CombinedOutput and treats exit code 1 as success.
### Changed
- Update tests to use `withMockGitDiff` for injecting diff mocks.
- Tweak flag description for clarity.
- Update prdescribe command to use git.Diff and trim whitespace when checking for empty diffs.
- Enhance prdescribe output to include base branch in the status message.
- Update installation and download scripts in README.md to reference v0.3.7.
### Fixed
- Improve diff handling to tolerate exit code 1.
## [v0.3.6] - 2026-03-30
### Fixed
- Handle fprintf error in version output by wrapping with error check.
### Changed
- Promote "Logging" to H2 and adjust subheadings to H3 in README.
- Bump version references from 0.3.3 to 0.3.5 in installation scripts.
- Convert plain text sections to Markdown headings in README.
- Add v0.3.5 entry to changelog documenting Markdown support and Windows tweaks.
- Add v0.3.4 entry to changelog for Windows support.
## [v0.3.5] - 2026-03-30
Now with Markdown editing flair and Windows install tweaks!
### Added
- Add support for editing Markdown files using a tailored technical writer prompt.
- Add PowerShell quick install snippet to release notes.
- Include PowerShell install script in release build artifacts.
- Add entry for v0.3.4 to changelog with Windows support details.
### Changed
- Simplify logging setup and remove unnecessary comments in analyze command.
- Improve directory skipping logic in source file discovery.
- Add error handling and include Git remotes in project context building.
- Simplify removal of "last modified" comments by direct slice append.
- Update MIME type detection to include .ps1 files as text/plain.
- Remove unnecessary comments from release workflow script.
- Ensure changelog entries include cross-platform consistency notes.
### Fixed
- Implement removal of "last modified" comments in edit command by filtering lines case-insensitively.
- Add missing newline at end of edited files to avoid Git warnings.
- Adjust response cleaning for Markdown files and use CleanCodeResponse for code files.
- Remove nolint comment and unnecessary line skipping in comment removal function.
## [v0.3.4] - 2026-03-30
Windows users, your PowerShell dreams just came true.
### Added
- Introduced grokkit-install.ps1 for Windows with auto-detection, checksum verification, and safe installation.
- Added one-line and download-first install methods for Bash and PowerShell in README.
- Added Windows-specific notes, tables, and examples in README.
### Changed
- Updated README formatting for improved readability.
- Ensured cross-platform consistency in installation instructions.
## [v0.3.2] - 2026-03-30
Polishing the install script and catching up on past changelog entries.
### Added
- Add debug echo statements for download and extraction.
- Add changelog entry for v0.3.1, documenting additions like .grokkit directory creation.
- Add changelog entry for v0.3.1, documenting changes like default output path, project analysis movement, analysis refinements, and v0.3.0 changelog update.
### Changed
- Adjust asset filename to include 'v' prefix for version.
- Comment out checksum download and verification.
- Update binary naming and extraction handling.
- Comment out temp dir cleanup trap.
### Fixed
- Fix quoting in case statements and echo commands.
## [v0.3.1] - 2026-03-28
### Added
- Add directory creation for .grokkit to ensure it exists before writing.
### Changed
- Change default output path to .grokkit/analysis.md for project-local storage.
- Move project analysis from analyze.md to .grokkit/analysis.md.
- Refine project analysis with more accurate tech stack, directory structure, function references, and learning path details.
- Update CHANGELOG.md with entry for v0.3.0 release.
## [v0.3.0] - 2026-03-28
Grokkit levels up with deep project analysis—because who doesn't love a good codebase dissection?
### Added
- Add "analyze" command for educational codebase analysis using AI prompts.
- Add Go-specific analysis prompt in .grokkit/prompts/go.md.
- Add feature specification for "analyze" command in todo/queued.
- Add project name inference in Go analysis prompts.
- Add documentation page for "analyze" command in docs/user-guide.
- Add link to "analyze" guide in index.md.
- Add project analysis Markdown report example in docs/analyze.md.
- Add Rexx language detection with extensions .rx, .rex, .rex, .rexlib, .rexx, .cls.
- Add empty Linters array for Rexx in internal/linter/linter.go.
- Add "none" linter configuration for Rexx language.
- Add internal/linter/language.go for primary language detection.
- Add internal/prompts/analyze.go for loading analysis prompts.
- Add error handling for confirmation input in analyze command.
### Changed
- Update Go analysis prompt for clarity, structure, and educational focus.
- Update cmd/analyze.go to include detected language in AI prompt.
- Update analyze command to infer project name from directory or go.mod.
- Enhance prompt formatting with bold text and rephrased sections.
- Enhance discoverSourceFiles to skip noise directories like "build" and "dist".
- Refine buildProjectContext with better labeling and git remote handling.
- Update DetectPrimaryLanguage to normalize "C/C++" and "C++" to "c".
- Revise function comments and internal comments in DetectPrimaryLanguage.
- Remove redundant comment on finding most common language.
- Change default output filename in analyze command from analyze.md to analysis.md.
- Update .grokkit/recipes/result-refactor.md.
- Move analyze-command task file from queued to completed.
- Update feature spec for "analyze" command with educational details and prompt discovery.
- Revise acceptance criteria, implementation plan, CLI usage, and flags in spec.
- Switch to package-level logger functions in analyze command.
- Update git.IsRepo to take no arguments.
- Simplify linter language detection comments.
- Adjust Grok client creation to NewClient().StreamSilent.
- Refine comments in linter and string adjustments for clarity.
- Implement DetectPrimaryLanguage with counting logic and Go bias.
- Enhance LoadAnalysisPrompt with better language handling and fallbacks.
- Expand cmd/analyze.go to discover files, detect language, load prompts, and generate reports.
- Integrate analyzeCmd into root command.
### Fixed
- Remove misplaced rootCmd.AddCommand(analyzeCmd) from cmd/analyze.go init function.
- Fix config.GetModel to use command name and flag in analyze command.
- Update safety check to use package-level logger without .Get().
- Remove unnecessary imports in linter.
## [v0.2.0] - 2026-03-08
Version 0.2.0: Recipes are cooking—now with extra safety and a dash of refactoring flair.

120
README.md
View File

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

54
analyze.md Normal file
View File

@ -0,0 +1,54 @@
# Project Analysis: Grokkit
## Tech Stack & Layout
- **Language/version, build system, key dependencies and why they were chosen**: This project is written in Go (likely version 1.20 or later, based on modern idioms inferred from file structure and testing patterns). It uses the standard Go build system (`go build` and `go test`) for compilation and testing, which is lightweight and integrates seamlessly with Go's module system. Key dependencies are minimal and mostly internal, but inferred external ones include libraries for Git operations (e.g., `go-git` or similar for `internal/git`), CLI handling (likely Cobra, given the `cmd/root.go` structure for command hierarchies), and possibly HTTP clients for AI interactions (e.g., in `internal/grok/client.go`). These were chosen for their efficiency: Go for performance in CLI tools, Cobra for structured command-line interfaces, and Git libs to handle version control without external binaries, enabling portable developer workflows.
- **High-level directory structure**:
- `cmd/`: Contains command-line entry points and tests for tools like `analyze`, `chat`, `commit`, etc., organized as subcommands.
- `config/`: Handles configuration loading and management.
- `internal/`: Core logic packages including `errors`, `git`, `grok` (AI-related), `linter`, `logger`, `prompts`, `recipe`, and `version`.
- `scripts/`: Utility scripts like `grokkit-install.sh` for installation.
- Root: `main.go` (entry point), `install.sh`, `release.sh` (deployment scripts).
## Module & Package Relationships
- **How packages depend on each other**: The root module (inferred as `gitea@repos.gmgauthier.com:gmgauthier/grokkit`) serves as the entry point via `main.go`, which imports and executes commands from `cmd/`. Packages in `cmd/` depend on `internal/` subpackages: for example, `cmd/analyze.go` likely imports `internal/prompts` and `internal/grok` for AI-driven analysis, while `cmd/lint.go` depends on `internal/linter`. `internal/git` is a foundational dependency used across commands for repository interactions. `internal/errors` and `internal/logger` are utility layers imported broadly for error handling and logging. `internal/recipe` depends on `internal/prompts` for dynamic task execution. Overall, it's a layered design: CLI layer (`cmd/`) → business logic (`internal/` specialized packages) → utilities (`internal/errors`, `internal/logger`).
- **Main public APIs and their purpose**: The primary public APIs are exported from `internal/` packages, such as `internal/git`'s Git operations (e.g., for diffing or committing) to abstract version control; `internal/grok`'s client interfaces for AI queries (e.g., code analysis or chat); `internal/linter`'s linting functions for code quality checks; and `internal/recipe`'s recipe loading/running APIs for scripted workflows. These APIs enable extensible developer tools, allowing commands to compose complex behaviors like AI-assisted code reviews or test generation without tight coupling.
## Function & Method Reference
### internal/errors
- **New**: Creates a new error with stack trace. It wraps `fmt.Errorf` and appends caller info using `runtime` package. Exists to provide traceable errors for debugging in a CLI context.
- **Wrap**: Wraps an existing error with additional context. Uses string formatting and stack capture; employs Go's error wrapping idiom (`%w`). Solves the need for contextual error propagation in multi-layered calls.
### internal/git
- **Diff**: Computes git diff for staged or unstaged changes. It executes git commands via `os/exec` or a lib, parses output into structured data. Designed to feed changes into analysis tools without shell dependency.
- **Commit**: Performs a git commit with a message. Validates inputs, runs `git commit`; uses interfaces for testability. Addresses automated committing in workflows like `cmd/commit.go`.
### internal/grok
- **Client.Query**: Sends a query to an AI service (e.g., Grok API). Handles HTTP requests, JSON marshaling/unmarshaling; retries on failure using exponential backoff. Enables AI integration for features like chat or analysis.
- **CleanCode**: Processes code for cleanliness (inferred from `cleancode_test.go`). Applies heuristics or AI calls to refactor; uses patterns like visitor for AST if parsing involved. Solves code grooming in automation scripts.
### internal/linter
- **Lint**: Runs linting on code in a given language. Detects language, applies rules (e.g., via regex or external tools); returns issues as a list. Exists to enforce code quality in commands like `cmd/lint.go`.
- **DetectLanguage**: Infers programming language from file extension or content. Simple switch-based logic; extensible for multi-language support. Facilitates polyglot linting in diverse repos.
### internal/logger
- **Info**, **Error**: Logs messages at different levels. Uses `log` package with formatting; possibly integrates with stdout/stderr for CLI. Provides consistent logging across the toolset.
- **SetLevel**: Configures log verbosity. Switch-based on config; uses atomic operations for thread-safety. Allows users to control output noise.
### internal/recipe
- **Load**: Loads a recipe from file or config. Parses YAML/JSON into structs; validates fields. Enables reusable task definitions for commands like `cmd/recipe.go`.
- **Run**: Executes a loaded recipe. Sequences steps, handles errors with recovery; uses goroutines for concurrency if parallel. Solves scripted automation for complex dev tasks.
### internal/version
- **GetVersion**: Retrieves the current version string. Likely reads from build tags or const; formats for display. Used in CLI help or updates to inform users.
- **CheckUpdate**: Checks for newer versions (e.g., via API). Compares semver; non-blocking. Facilitates self-updating CLI tools.
### config
- **LoadConfig**: Loads app configuration from files/env. Merges sources using Viper-like patterns; handles defaults. Centralizes setup for all commands.
## Object & Data Flow
- **Important structs/types and their relationships**: Key structs include `GitRepo` (in `internal/git`) holding repo state (e.g., branch, diff); it relates to `Diff` type for change sets. `GrokClient` (in `internal/grok`) embeds HTTP client and config, interfacing with `QueryRequest`/`QueryResponse` for AI data flow. `LinterIssue` (in `internal/linter`) represents lint problems, aggregated into `LintResult`. `Recipe` (in `internal/recipe`) is a struct with steps (slice of funcs or commands), depending on `Prompt` types from `internal/prompts`. Errors are wrapped in `AppError` (from `internal/errors`) with stack traces. Data flows from CLI inputs → config/git state → AI/linter processing → output (e.g., commit messages or reviews). Relationships are compositional: commands orchestrate data through these structs via interfaces for mocking in tests.
- **Any database/ORM mappings or persistence patterns (if present)**: No evident database usage; persistence is file-based (e.g., git repos, config files, recipes in YAML/JSON). Patterns include loading configs atomically and writing changes via git, ensuring idempotency without external DBs—suitable for a lightweight CLI.
## Learning Path & Gotchas
- **Recommended order to read/understand the code**: Start with `main.go` and `cmd/root.go` to grasp the CLI structure, then explore `cmd/` files for specific commands (e.g., `cmd/chat.go` for AI features). Dive into `internal/git` and `internal/grok` next, as they form the core. Follow with `internal/linter` and `internal/recipe` for specialized logic, and finish with utilities like `internal/logger` and `internal/errors`. Run tests (e.g., `_test.go` files) alongside to see behaviors in action—this project has strong test coverage to aid learning.
- **Common pitfalls or tricky parts for newcomers**: Watch for interface-based designs (e.g., in `internal/git/interface.go` and `internal/grok/interface.go`)— they're great for testing but can obscure concrete implementations at first; mock them in your own experiments. Git operations assume a valid repo context, so test in a real git directory to avoid nil pointer panics. AI integrations (e.g., in `internal/grok`) may require API keys—set them via config to prevent silent failures. Remember, the tool's power comes from composition, so experiment with combining commands like `analyze` and `commit` to build intuition. Keep going; mastering this will level up your Go CLI skills!

View File

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

View File

@ -1,238 +0,0 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestAnalyzeCmd — FAST DEFAULT TEST (runs on make test / go test ./cmd)
func TestAnalyzeCmd(t *testing.T) {
t.Log("✓ Fast analyze unit test (no Grok API call)")
found := false
for _, c := range rootCmd.Commands() {
if c.Use == "analyze [flags]" {
found = true
break
}
}
if !found {
t.Fatal("analyze command not registered on rootCmd")
}
}
func TestAnalyzeFlagRegistration(t *testing.T) {
tests := []struct {
name string
flagName string
shorthand string
}{
{"output flag", "output", "o"},
{"yes flag", "yes", "y"},
{"dir flag", "dir", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
flag := analyzeCmd.Flags().Lookup(tt.flagName)
if flag == nil {
t.Fatalf("flag %q not found on analyze command", tt.flagName)
}
if tt.shorthand != "" && flag.Shorthand != tt.shorthand {
t.Errorf("flag %q shorthand = %q, want %q", tt.flagName, flag.Shorthand, tt.shorthand)
}
})
}
}
func TestDiscoverSourceFiles(t *testing.T) {
tmpDir := t.TempDir()
// Create some source files
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "helper.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
// Create a non-source file (should be ignored)
if err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("notes"), 0644); err != nil {
t.Fatal(err)
}
files, err := DiscoverSourceFiles(tmpDir)
if err != nil {
t.Fatalf("DiscoverSourceFiles failed: %v", err)
}
if len(files) < 2 {
t.Errorf("expected at least 2 Go files, got %d: %v", len(files), files)
}
// Verify .txt was not included
for _, f := range files {
if strings.HasSuffix(f, ".txt") {
t.Errorf("unexpected .txt file in results: %s", f)
}
}
}
func TestDiscoverSourceFilesSkipsDotDirs(t *testing.T) {
tmpDir := t.TempDir()
// Create a hidden directory with a Go file
hiddenDir := filepath.Join(tmpDir, ".hidden")
if err := os.MkdirAll(hiddenDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(hiddenDir, "secret.go"), []byte("package hidden"), 0644); err != nil {
t.Fatal(err)
}
// Create vendor directory with a Go file
vendorDir := filepath.Join(tmpDir, "vendor")
if err := os.MkdirAll(vendorDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(vendorDir, "dep.go"), []byte("package dep"), 0644); err != nil {
t.Fatal(err)
}
// Create a normal Go file
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
files, err := DiscoverSourceFiles(tmpDir)
if err != nil {
t.Fatalf("DiscoverSourceFiles failed: %v", err)
}
for _, f := range files {
if strings.Contains(f, ".hidden") {
t.Errorf("should not include files from hidden dirs: %s", f)
}
if strings.Contains(f, "vendor") {
t.Errorf("should not include files from vendor dir: %s", f)
}
}
if len(files) != 1 {
t.Errorf("expected 1 file (main.go only), got %d: %v", len(files), files)
}
}
func TestDiscoverSourceFilesSkipsBuildAndDist(t *testing.T) {
tmpDir := t.TempDir()
for _, dir := range []string{"build", "dist", "node_modules"} {
d := filepath.Join(tmpDir, dir)
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "file.go"), []byte("package x"), 0644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
files, err := DiscoverSourceFiles(tmpDir)
if err != nil {
t.Fatalf("DiscoverSourceFiles failed: %v", err)
}
if len(files) != 1 {
t.Errorf("expected 1 file, got %d: %v", len(files), files)
}
}
func TestDiscoverSourceFilesEmptyDir(t *testing.T) {
tmpDir := t.TempDir()
files, err := DiscoverSourceFiles(tmpDir)
if err != nil {
t.Fatalf("DiscoverSourceFiles failed: %v", err)
}
if len(files) != 0 {
t.Errorf("expected 0 files for empty dir, got %d", len(files))
}
}
func TestBuildProjectContext(t *testing.T) {
tmpDir := t.TempDir()
files := []string{
filepath.Join(tmpDir, "main.go"),
filepath.Join(tmpDir, "cmd", "root.go"),
}
ctx := BuildProjectContext(tmpDir, files)
if !strings.Contains(ctx, "Project Root:") {
t.Error("expected context to contain 'Project Root:'")
}
if !strings.Contains(ctx, "Total source files discovered: 2") {
t.Error("expected context to contain file count")
}
if !strings.Contains(ctx, "Key files") {
t.Error("expected context to contain 'Key files'")
}
}
func TestPreviewLines(t *testing.T) {
tests := []struct {
name string
content string
n int
}{
{
name: "fewer lines than limit",
content: "line1\nline2\nline3\n",
n: 10,
},
{
name: "more lines than limit",
content: "a\nb\nc\nd\ne\nf\ng\nh\n",
n: 3,
},
{
name: "empty content",
content: "",
n: 5,
},
{
name: "zero lines requested",
content: "line1\nline2\n",
n: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// previewLines prints to stdout; just verify it doesn't panic
previewLines(tt.content, tt.n)
})
}
}
func TestBuildProjectContextLimitsSize(t *testing.T) {
tmpDir := t.TempDir()
// Generate many files to test the 2500 byte limit
var files []string
for i := 0; i < 200; i++ {
files = append(files, filepath.Join(tmpDir, "file_with_a_long_name_for_testing_"+strings.Repeat("x", 20)+".go"))
}
ctx := BuildProjectContext(tmpDir, files)
// Should be reasonably bounded (the 2500 byte check truncates the file list)
if len(ctx) > 4000 {
t.Errorf("context unexpectedly large: %d bytes", len(ctx))
}
}

View File

@ -48,11 +48,11 @@ func runDocs(cmd *cobra.Command, args []string) {
client := newGrokClient()
for _, filePath := range args {
ProcessDocsFile(client, model, filePath)
processDocsFile(client, model, filePath)
}
}
func ProcessDocsFile(client grok.AIClient, model, filePath string) {
func processDocsFile(client grok.AIClient, model, filePath string) {
logger.Info("starting docs operation", "file", filePath)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
@ -79,7 +79,7 @@ func ProcessDocsFile(client grok.AIClient, model, filePath string) {
color.Cyan("📝 Generating %s docs for: %s", lang.Name, filePath)
logger.Info("requesting AI documentation", "file", filePath, "language", lang.Name)
messages := BuildDocsMessages(lang.Name, string(originalContent))
messages := buildDocsMessages(lang.Name, string(originalContent))
response := client.StreamSilent(messages, model)
if response == "" {
@ -134,7 +134,7 @@ func ProcessDocsFile(client grok.AIClient, model, filePath string) {
color.Green("✅ Documentation applied: %s", filePath)
}
func BuildDocsMessages(language, code string) []map[string]string {
func buildDocsMessages(language, code string) []map[string]string {
style := docStyle(language)
systemPrompt := fmt.Sprintf(
"You are a documentation expert. Add %s documentation comments to the provided code. "+

View File

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

View File

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

View File

@ -1,50 +0,0 @@
package cmd
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"gmgauthier.com/grokkit/internal/logger"
"gmgauthier.com/grokkit/internal/mcp"
)
var mcpCmd = &cobra.Command{
Use: "mcp",
Short: "Start Grokkit as an MCP (Model Context Protocol) server",
Long: `Starts Grokkit in MCP server mode over stdio.
This allows MCP-compatible clients (such as Claude Code, Cursor, or other AI coding agents)
to call Grokkit tools like lint_code, analyze_code, generate_docs, etc. directly.
The server runs until the client closes the connection.`,
Run: runMCP,
}
func init() {
rootCmd.AddCommand(mcpCmd)
}
func runMCP(cmd *cobra.Command, args []string) {
logger.Info("mcp command started")
// Check if MCP is enabled in config
if !viper.GetBool("mcp.enabled") {
fmt.Println("MCP is disabled in configuration.")
os.Exit(0)
}
srv, err := mcp.NewServer()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to create MCP server: %v\n", err)
os.Exit(1)
}
ctx := context.Background()
if err := srv.Run(ctx); err != nil {
fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
os.Exit(1)
}
}

View File

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

View File

@ -1,149 +0,0 @@
package cmd
import (
"os"
"path/filepath"
"testing"
)
func TestResolveRecipePathDirectFile(t *testing.T) {
tmpDir := t.TempDir()
recipePath := filepath.Join(tmpDir, "my-recipe.md")
if err := os.WriteFile(recipePath, []byte("# Recipe\n"), 0644); err != nil {
t.Fatal(err)
}
got, err := resolveRecipePath(recipePath)
if err != nil {
t.Fatalf("resolveRecipePath(%q) failed: %v", recipePath, err)
}
if got != recipePath {
t.Errorf("resolveRecipePath() = %q, want %q", got, recipePath)
}
}
func TestResolveRecipePathDirectFileMissing(t *testing.T) {
_, err := resolveRecipePath("/nonexistent/recipe.md")
if err == nil {
t.Fatal("expected error for missing recipe file, got nil")
}
}
func TestResolveRecipePathProjectLocal(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Create .grokkit/recipes with a recipe, and a .git dir for project root detection
recipeDir := filepath.Join(tmpDir, ".grokkit", "recipes")
if err := os.MkdirAll(recipeDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0755); err != nil {
t.Fatal(err)
}
recipePath := filepath.Join(recipeDir, "refactor.md")
if err := os.WriteFile(recipePath, []byte("# Refactor\n"), 0644); err != nil {
t.Fatal(err)
}
got, err := resolveRecipePath("refactor")
if err != nil {
t.Fatalf("resolveRecipePath('refactor') failed: %v", err)
}
if got != recipePath {
t.Errorf("resolveRecipePath() = %q, want %q", got, recipePath)
}
}
func TestResolveRecipePathNotFound(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Create .git so findProjectRoot works, but no recipes
if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0755); err != nil {
t.Fatal(err)
}
// Override HOME to empty dir so global fallback also fails
oldHome := os.Getenv("HOME")
defer func() { _ = os.Setenv("HOME", oldHome) }()
_ = os.Setenv("HOME", tmpDir)
_, err := resolveRecipePath("nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent recipe, got nil")
}
}
func TestFindProjectRoot(t *testing.T) {
tests := []struct {
name string
marker string
}{
{"git repo", ".git"},
{"gitignore", ".gitignore"},
{"grokkit dir", ".grokkit"},
{"go module", "go.mod"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
// Create a subdirectory and chdir into it
subDir := filepath.Join(tmpDir, "sub", "deep")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.Chdir(subDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Place the marker at the root
markerPath := filepath.Join(tmpDir, tt.marker)
if tt.marker == ".git" || tt.marker == ".grokkit" {
if err := os.MkdirAll(markerPath, 0755); err != nil {
t.Fatal(err)
}
} else {
if err := os.WriteFile(markerPath, []byte(""), 0644); err != nil {
t.Fatal(err)
}
}
root, err := findProjectRoot()
if err != nil {
t.Fatalf("findProjectRoot() failed: %v", err)
}
if root != tmpDir {
t.Errorf("findProjectRoot() = %q, want %q", root, tmpDir)
}
})
}
}
func TestFindProjectRootNotInProject(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Empty dir — no markers at all. This will walk up to / and fail.
_, err := findProjectRoot()
if err == nil {
// Might succeed if a parent dir has .git — that's OK in CI
t.Log("findProjectRoot() succeeded (likely found .git in a parent dir)")
}
}

View File

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

View File

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

View File

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

View File

@ -1,60 +0,0 @@
package cmd
import (
"fmt"
"strings"
"github.com/fatih/color"
"github.com/spf13/cobra"
"gmgauthier.com/grokkit/internal/logger"
"gmgauthier.com/grokkit/internal/workon"
)
var workonCmd = &cobra.Command{
Use: "workon <todo_item_title>",
Short: "Start or complete work on a todo item or fix",
Long: `workon automates starting or completing a todo/fix:
- Moves queued todo to doing/ or creates new fix .md
- Creates matching git branch
- Generates + appends "Work Plan" via Grok
- Commits to the branch
- Optional: cnadd log + open in configured IDE
Purely transactional. See todo/doing/workon.md for full spec.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
title := strings.ReplaceAll(args[0], " ", "-")
customMsg, _ := cmd.Flags().GetString("message")
isFix, _ := cmd.Flags().GetBool("fix")
isComplete, _ := cmd.Flags().GetBool("complete")
if isComplete && customMsg != "" {
return fmt.Errorf("-c cannot be combined with -m")
}
logger.Info("workon starting", "title", title, "fix", isFix, "complete", isComplete)
if err := workon.Run(title, customMsg, isFix, isComplete); err != nil {
color.Red("workon failed: %v", err)
return err
}
mode := "todo"
if isFix {
mode = "fix"
}
action := "started"
if isComplete {
action = "completed"
}
color.Green("workon %s: %s (%s)", action, title, mode)
return nil
},
}
func init() {
workonCmd.Flags().StringP("message", "M", "", "Custom commit message (default: \"Start working on <todo_item_title>\")")
workonCmd.Flags().BoolP("fix", "f", false, "Treat as fix instead of todo (create new .md in doing/)")
workonCmd.Flags().BoolP("complete", "c", false, "Complete the item (move to completed/; exclusive)")
}

View File

@ -1,107 +0,0 @@
package cmd
import (
"os"
"testing"
)
// TestWorkonCmd — FAST DEFAULT TEST (runs on make test / go test ./cmd)
func TestWorkonCmd(t *testing.T) {
t.Log("✓ Fast workon unit test (no Grok API call)")
// Verify the command is registered
found := false
for _, c := range rootCmd.Commands() {
if c.Use == "workon <todo_item_title>" {
found = true
break
}
}
if !found {
t.Fatal("workon command not registered on rootCmd")
}
}
func TestWorkonFlagRegistration(t *testing.T) {
tests := []struct {
name string
flagName string
shorthand string
}{
{"message flag", "message", "M"},
{"fix flag", "fix", "f"},
{"complete flag", "complete", "c"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
flag := workonCmd.Flags().Lookup(tt.flagName)
if flag == nil {
t.Fatalf("flag %q not found on workon command", tt.flagName)
}
if flag.Shorthand != tt.shorthand {
t.Errorf("flag %q shorthand = %q, want %q", tt.flagName, flag.Shorthand, tt.shorthand)
}
})
}
}
func TestWorkonFlagExclusivity(t *testing.T) {
// Run in a temp dir so todo.Bootstrap() doesn't leave orphan dirs in cmd/
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
tests := []struct {
name string
args []string
expectErr bool
}{
{
name: "complete alone is valid",
args: []string{"workon", "test-item", "-c"},
expectErr: false, // will fail at Run level (no git repo), but not at flag validation
},
{
name: "complete with fix is valid",
args: []string{"workon", "test-item", "-c", "-f"},
expectErr: false,
},
{
name: "complete with message is invalid",
args: []string{"workon", "test-item", "-c", "-M", "some msg"},
expectErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rootCmd.SetArgs(tt.args)
err := rootCmd.Execute()
if tt.expectErr && err == nil {
t.Error("expected error, got nil")
}
// Note: non-error cases may still fail (no git, no API key, etc.)
// We only assert that the flag validation error fires when expected
if tt.expectErr && err != nil {
// Verify it's the right error
if err.Error() != "-c cannot be combined with -m" {
// The error might be wrapped
t.Logf("got error: %v (flag validation may have been triggered)", err)
}
}
})
}
}
func TestWorkonRequiresArgs(t *testing.T) {
rootCmd.SetArgs([]string{"workon"})
err := rootCmd.Execute()
if err == nil {
t.Error("expected error when no args provided, got nil")
}
}

View File

@ -23,8 +23,6 @@ fast = "grok-4-1-fast-non-reasoning"
history.model = "grok-4"
prdescribe.model = "grok-4"
review.model = "grok-4"
workon.model = "grok-4-1-fast-non-reasoning" # Fast model for work plan generation
workon.ide = "" # IDE command to open repo (e.g. "code", "goland")
# Chat history settings
[chat]

View File

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

View File

@ -136,26 +136,6 @@ func TestGetTimeout(t *testing.T) {
}
}
func TestGetIDECommand(t *testing.T) {
viper.Reset()
t.Run("empty by default", func(t *testing.T) {
got := GetIDECommand()
if got != "" {
t.Errorf("GetIDECommand() default = %q, want empty", got)
}
})
t.Run("returns configured value", func(t *testing.T) {
viper.Set("commands.workon.ide", "code")
got := GetIDECommand()
if got != "code" {
t.Errorf("GetIDECommand() = %q, want 'code'", got)
}
viper.Set("commands.workon.ide", "") // reset
})
}
func TestGetLogLevel(t *testing.T) {
viper.Reset()
viper.SetDefault("log_level", "info")

View File

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

View File

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

View File

@ -1,61 +0,0 @@
# MCP Server Mode (`grokkit mcp`)
Grokkit can run as an **MCP (Model Context Protocol)** server, allowing AI coding agents like **Claude Code**, Cursor, or other MCP-compatible clients to call Grokkit tools directly during their workflows.
## What is MCP?
MCP is a protocol that lets LLMs call external tools and access resources in a standardized way. By running `grokkit mcp`, you turn Grokkit into a service that other AI tools can use.
## Starting the MCP Server
```bash
grokkit mcp
```
This starts the server over **stdio** (standard input/output), which is the default transport used by Claude Code and most MCP clients.
The server will run until the client disconnects.
## Available Tools
When running in MCP mode, the following tools are available:
- **`lint_code`** — Run linter on a file and return results
- **`analyze_code`** — Deep project analysis with educational report
- **`generate_docs`** — Generate documentation comments for a source file
- **`generate_tests`** — Generate unit tests for supported languages
- **`generate_commit_msg`** — Generate conventional commit message from staged changes
- **`run_recipe`** — Execute a named recipe from `.grokkit/recipes/`
## Configuration
You can control MCP behavior in your config file (`~/.config/grokkit/config.toml` or `./config.toml`):
```toml
[mcp]
enabled = true
tools = ["lint_code", "analyze_code", "generate_docs"]
resources = ["recipes://local", "prompts://language"]
```
## Example Usage with Claude Code
Once `grokkit mcp` is running, you can tell Claude Code:
> "Use the grokkit MCP server to lint this file and then generate tests for it."
Claude can then call the tools directly.
## Resources
MCP also exposes resources:
- `recipes://local` — List of local recipes
- `recipes://global` — List of global recipes
- `prompts://language` — Available language prompts
---
**See also:**
- [Developer Guide](../developer-guide/mcp.md) for technical details
- [Main README](../../README.md) for installation

View File

@ -1,109 +0,0 @@
# 🔨 Workon Guide
The `workon` command automates starting and completing work on todo items and fixes. It integrates with Grokkit's in-repo todo system, Git branching, and Grok AI to keep your workflow uniform and consistent.
### Why use Workon?
- **Automated Workflow**: Moves todo items, creates branches, generates work plans, and commits — all in one command.
- **AI Work Plans**: Grok generates a concrete, numbered implementation plan tailored to each item.
- **Branch Conventions**: Automatically prefixes branches with `feature/` or `fix/` for clean git history.
- **Completion Tracking**: Moves items to completed, updates the todo index, and commits the change.
- **Optional Integrations**: Logs work start via `cnadd` and opens your configured IDE automatically.
### Usage
```bash
# Start working on a queued todo item
grokkit workon my-todo-item
# Start a fix (creates a new .md in doing/)
grokkit workon my-bugfix -f
# Custom commit message
grokkit workon my-feature -M "feat: begin API redesign"
# Complete a todo item (move to completed/, update index)
grokkit workon my-todo-item -c
# Complete a fix (move to completed/, no index update)
grokkit workon my-bugfix -c -f
```
### Options
| Flag | Short | Description | Default |
|-----------|-------|-----------------------------------------------------|------------------------------------------|
| --message | -M | Custom commit message | "Start working on \<todo_item_title\>" |
| --fix | -f | Treat as fix instead of todo | false |
| --complete| -c | Complete the item (cannot be combined with -M) | false |
### How It Works
#### Starting Work (default)
1. **Bootstraps todo structure** — Creates `todo/queued/`, `todo/doing/`, and `todo/completed/` directories if they don't exist.
2. **Moves or creates the item**:
- **Todo mode** (default): Moves `todo/queued/<title>.md` to `todo/doing/<title>.md`.
- **Fix mode** (`-f`): Creates a new `todo/doing/<title>.md` with a skeleton `## Work Plan` heading.
3. **Creates a git branch**`feature/<title>` for todos, `fix/<title>` for fixes. If the branch already exists, checks it out instead.
4. **Generates a Work Plan** — Sends the item content to Grok and appends a numbered implementation plan to the markdown file.
5. **Commits** — Stages the `todo/` directory and commits with the default or custom message.
6. **Optional post-steps** (graceful fallbacks if unavailable):
- Logs the start of work via `cnadd` (if installed).
- Opens the repository in your configured IDE.
#### Completing Work (`-c`)
1. Moves `todo/doing/<title>.md` to `todo/completed/<title>.md`.
2. For non-fix items: updates `todo/README.md` — removes the entry from `## Queued` and appends it to `## Completed`.
3. Commits the change with the message `Complete work on <title>`.
### Todo Folder Structure
Workon assumes and maintains this in-repo ticketing layout:
```
todo/
README.md # Index of all items (Queued / Completed sections)
queued/ # Items waiting to be worked on
my-feature.md
another-item.md
doing/ # Items currently in progress
active-item.md
completed/ # Finished items
done-item.md
```
If the `todo/` folder doesn't exist, `workon` creates it automatically.
### Configuration
The workon command respects two config keys in `~/.config/grokkit/config.toml`:
```toml
[commands]
workon.model = "grok-4-1-fast-non-reasoning" # Model for work plan generation
workon.ide = "code" # IDE command to open repo (e.g. "code", "goland", "nvim")
```
If `workon.ide` is empty or unset, the IDE step is silently skipped. If `cnadd` is not installed, logging is silently skipped.
### Title Handling
- Spaces in the title are automatically replaced with dashes.
- The title is used as both the markdown filename (without prefix) and the branch name (with `feature/` or `fix/` prefix).
### Examples
```bash
# Full workflow: queue an item, work on it, complete it
echo "# Add caching" > todo/queued/add-caching.md
grokkit workon add-caching
# ... do the work ...
grokkit workon add-caching -c
# Quick fix workflow
grokkit workon fix-null-pointer -f
# ... fix the bug ...
grokkit workon fix-null-pointer -c -f
```

6
go.mod
View File

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

8
go.sum
View File

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

View File

@ -1,63 +0,0 @@
package analyze
import (
"fmt"
"os"
"path/filepath"
"strings"
"gmgauthier.com/grokkit/internal/git"
"gmgauthier.com/grokkit/internal/linter"
)
// DiscoverSourceFiles finds all supported source files in a directory tree.
// Exported for use by MCP server and other packages.
func DiscoverSourceFiles(root string) ([]string, error) {
var files []string
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
name := info.Name()
if info.IsDir() {
if strings.HasPrefix(name, ".") && name != "." && name != ".." ||
name == "node_modules" || name == "vendor" || name == "build" || name == "dist" {
return filepath.SkipDir
}
return nil
}
if _, detectErr := linter.DetectLanguage(path); detectErr == nil {
files = append(files, path)
}
return nil
})
return files, err
}
// BuildProjectContext builds a context string for analysis prompts.
// Exported for use by MCP server.
func BuildProjectContext(dir string, files []string) string {
var sb strings.Builder
sb.WriteString("Project Root: " + dir + "\n\n")
_, _ = fmt.Fprintf(&sb, "Total source files discovered: %d\n\n", len(files))
sb.WriteString("Key files (top-level view):\n")
for _, f := range files {
rel, _ := filepath.Rel(dir, f)
if strings.Count(rel, string(filepath.Separator)) <= 2 {
sb.WriteString(" - " + rel + "\n")
}
if sb.Len() > 2500 {
break
}
}
// Add git remotes if available
if out, err := git.Run([]string{"remote", "-v"}); err == nil && out != "" {
sb.WriteString("\nGit Remotes:\n" + out + "\n")
}
return sb.String()
}

View File

@ -1,50 +0,0 @@
package analyze
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestDiscoverSourceFiles(t *testing.T) {
tmpDir := t.TempDir()
// Create some source files
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(tmpDir, "helper.go"), []byte("package main"), 0644); err != nil {
t.Fatal(err)
}
// Create a non-source file (should be ignored)
if err := os.WriteFile(filepath.Join(tmpDir, "notes.txt"), []byte("notes"), 0644); err != nil {
t.Fatal(err)
}
files, err := DiscoverSourceFiles(tmpDir)
assert.NoError(t, err)
assert.GreaterOrEqual(t, len(files), 2)
// Verify .txt was not included
for _, f := range files {
assert.False(t, strings.HasSuffix(f, ".txt"))
}
}
func TestBuildProjectContext(t *testing.T) {
tmpDir := t.TempDir()
files := []string{
filepath.Join(tmpDir, "main.go"),
filepath.Join(tmpDir, "cmd", "root.go"),
}
ctx := BuildProjectContext(tmpDir, files)
assert.Contains(t, ctx, "Project Root:")
assert.Contains(t, ctx, "Total source files discovered: 2")
assert.Contains(t, ctx, "Key files")
}

View File

@ -1,48 +0,0 @@
package docs
import (
"fmt"
"strings"
)
// BuildDocsMessages builds the messages for documentation generation.
// Exported for use by MCP server.
func BuildDocsMessages(language, code string) []map[string]string {
style := docStyle(language)
systemPrompt := fmt.Sprintf(
"You are a documentation expert. Add %s documentation comments to the provided code. "+
"Return ONLY the documented code with no explanations, markdown, or extra text. "+
"Do NOT include markdown code fences. Document all public functions, methods, types, and constants.",
style,
)
userPrompt := fmt.Sprintf("Add documentation comments to the following %s code:\n\n%s", language, code)
return []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
}
}
func docStyle(language string) string {
switch strings.ToLower(language) {
case "go":
return "godoc"
case "python":
return "PEP 257 docstring"
case "c", "c++":
return "doxygen style"
case "javascript", "typescript":
return "JSDoc"
case "rust":
return "rustdoc"
case "ruby":
return "YARD"
case "java":
return "javadoc"
case "shell":
return "shell style comments"
default:
return "standard"
}
}

View File

@ -1,30 +0,0 @@
package docs
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildDocsMessages(t *testing.T) {
tests := []struct {
language string
code string
styleCheck string
}{
{language: "Go", code: "func Foo() {}", styleCheck: "godoc"},
{language: "Python", code: "def foo(): pass", styleCheck: "PEP 257"},
{language: "JavaScript", code: "function foo() {}", styleCheck: "JSDoc"},
}
for _, tt := range tests {
t.Run(tt.language, func(t *testing.T) {
msgs := BuildDocsMessages(tt.language, tt.code)
assert.Len(t, msgs, 2)
assert.Equal(t, "system", msgs[0]["role"])
assert.Equal(t, "user", msgs[1]["role"])
assert.Contains(t, msgs[0]["content"], tt.styleCheck)
})
}
}

View File

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

View File

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

View File

@ -317,91 +317,6 @@ func TestLanguageStructure(t *testing.T) {
}
}
func TestDetectPrimaryLanguage(t *testing.T) {
tests := []struct {
name string
files []string
expected string
}{
{
name: "empty list returns unknown",
files: []string{},
expected: "unknown",
},
{
name: "all Go files",
files: []string{"main.go", "handler.go", "util.go"},
expected: "go",
},
{
name: "all Python files",
files: []string{"app.py", "utils.py"},
expected: "python",
},
{
name: "mixed with Go bias",
files: []string{"main.go", "helper.go", "script.py", "app.js", "style.ts"},
expected: "go",
},
{
name: "unsupported files only returns unknown",
files: []string{"notes.txt", "data.csv", "readme.md"},
expected: "unknown",
},
{
name: "C files normalized",
files: []string{"main.c", "util.c", "helper.c"},
expected: "c",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create real temp files so DetectLanguage can stat them
tmpDir := t.TempDir()
var paths []string
for _, f := range tt.files {
path := filepath.Join(tmpDir, f)
if err := os.WriteFile(path, []byte("content"), 0644); err != nil {
t.Fatal(err)
}
paths = append(paths, path)
}
got := DetectPrimaryLanguage(paths)
if got != tt.expected {
t.Errorf("DetectPrimaryLanguage() = %q, want %q", got, tt.expected)
}
})
}
}
func TestSupportedLanguages(t *testing.T) {
langs := SupportedLanguages()
if len(langs) == 0 {
t.Fatal("SupportedLanguages() returned empty list")
}
// Should be lowercase
for _, l := range langs {
if l == "" {
t.Error("SupportedLanguages() contains empty string")
}
}
// Should contain "go"
foundGo := false
for _, l := range langs {
if l == "go" {
foundGo = true
break
}
}
if !foundGo {
t.Errorf("SupportedLanguages() missing 'go', got: %v", langs)
}
}
// Helper function
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&

View File

@ -105,24 +105,6 @@ func TestSetLevel(t *testing.T) {
}
}
func TestSetLevelAllBranches(t *testing.T) {
tmpDir := t.TempDir()
oldHome := os.Getenv("HOME")
setHome(t, tmpDir)
defer func() { _ = os.Setenv("HOME", oldHome) }()
if err := Init("info"); err != nil {
t.Fatalf("Init() failed: %v", err)
}
levels := []string{"debug", "info", "warn", "error"}
for _, lvl := range levels {
t.Run(lvl, func(t *testing.T) {
SetLevel(lvl) // should not panic
})
}
}
func TestWith(t *testing.T) {
tmpDir := t.TempDir()
oldHome := os.Getenv("HOME")

View File

@ -1,423 +0,0 @@
package mcp
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"gmgauthier.com/grokkit/internal/analyze"
"gmgauthier.com/grokkit/internal/docs"
"gmgauthier.com/grokkit/internal/git"
"gmgauthier.com/grokkit/internal/grok"
"gmgauthier.com/grokkit/internal/linter"
"gmgauthier.com/grokkit/internal/logger"
"gmgauthier.com/grokkit/internal/prompts"
"gmgauthier.com/grokkit/internal/recipe"
"gmgauthier.com/grokkit/internal/testgen"
)
// Server wraps an MCP server and provides Grokkit-specific tools.
type Server struct {
mcpServer *server.MCPServer
grokClient *grok.Client
}
// NewServer creates a new MCP server with Grokkit tools.
func NewServer() (*Server, error) {
// Create the underlying MCP server
s := server.NewMCPServer(
"grokkit",
"0.1.0",
server.WithToolCapabilities(false),
server.WithRecovery(),
)
grokClient := grok.NewClient()
mcpSrv := &Server{
mcpServer: s,
grokClient: grokClient,
}
if err := mcpSrv.registerTools(); err != nil {
return nil, fmt.Errorf("failed to register tools: %w", err)
}
mcpSrv.registerResources()
return mcpSrv, nil
}
// registerTools registers all available Grokkit tools.
func (s *Server) registerTools() error {
// lint_code tool
lintTool := mcp.NewTool("lint_code",
mcp.WithDescription("Run a linter on a source file and return results"),
mcp.WithString("file_path", mcp.Required(), mcp.Description("Path to the file to lint")),
)
s.mcpServer.AddTool(lintTool, s.handleLintCode)
// analyze_code tool
analyzeTool := mcp.NewTool("analyze_code",
mcp.WithDescription("Perform deep project analysis and return an educational Markdown report"),
mcp.WithString("dir", mcp.Description("Directory to analyze (default: current directory)")),
)
s.mcpServer.AddTool(analyzeTool, s.handleAnalyzeCode)
// generate_docs tool
docsTool := mcp.NewTool("generate_docs",
mcp.WithDescription("Generate documentation comments for a source file"),
mcp.WithString("file_path", mcp.Required(), mcp.Description("Path to the file to document")),
mcp.WithString("style", mcp.Description("Documentation style (optional)")),
)
s.mcpServer.AddTool(docsTool, s.handleGenerateDocs)
// generate_tests tool
testsTool := mcp.NewTool("generate_tests",
mcp.WithDescription("Generate unit tests for a source file"),
mcp.WithString("file_path", mcp.Required(), mcp.Description("Path to the file to generate tests for")),
)
s.mcpServer.AddTool(testsTool, s.handleGenerateTests)
// generate_commit_msg tool
commitTool := mcp.NewTool("generate_commit_msg",
mcp.WithDescription("Generate a conventional commit message from staged changes"),
)
s.mcpServer.AddTool(commitTool, s.handleGenerateCommitMsg)
// run_recipe tool
recipeTool := mcp.NewTool("run_recipe",
mcp.WithDescription("Execute a named recipe from .grokkit/recipes"),
mcp.WithString("name", mcp.Required(), mcp.Description("Name of the recipe to run")),
mcp.WithObject("params", mcp.Description("Parameters for the recipe (optional)")),
)
s.mcpServer.AddTool(recipeTool, s.handleRunRecipe)
logger.Info("MCP tools registered", "count", 6)
return nil
}
// handleLintCode implements the lint_code MCP tool.
func (s *Server) handleLintCode(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
filePath, err := req.RequireString("file_path")
if err != nil {
return mcp.NewToolResultText("Error: file_path is required"), nil
}
logger.Info("MCP lint_code called", "file", filePath)
result, err := linter.LintFile(filePath)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Linting failed: %v", err)), nil
}
output := fmt.Sprintf("✅ Lint completed for %s\n", filePath)
output += fmt.Sprintf("Language: %s\n", result.Language)
output += fmt.Sprintf("Linter: %s\n", result.LinterUsed)
output += fmt.Sprintf("Has issues: %v\n", result.HasIssues)
if result.Output != "" {
output += "\n--- Linter Output ---\n" + result.Output
}
return mcp.NewToolResultText(output), nil
}
// handleAnalyzeCode implements the analyze_code MCP tool.
func (s *Server) handleAnalyzeCode(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
dir := "."
if d := req.GetString("dir", ""); d != "" {
dir = d
}
logger.Info("MCP analyze_code called", "dir", dir)
// Fully functional implementation using logic from cmd/analyze.go
files, err := analyze.DiscoverSourceFiles(dir)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Failed to discover files: %v", err)), nil
}
if len(files) == 0 {
return mcp.NewToolResultText("No supported source files found in directory."), nil
}
lang := linter.DetectPrimaryLanguage(files)
if lang == "" {
lang = "unknown"
}
_, promptContent, err := prompts.LoadAnalysisPrompt(dir, lang)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Failed to load analysis prompt: %v", err)), nil
}
context := analyze.BuildProjectContext(dir, files)
messages := []map[string]string{
{"role": "system", "content": promptContent},
{"role": "user", "content": fmt.Sprintf("Analyze this %s project and generate the full educational Markdown report now:\n\n%s", lang, context)},
}
report := s.grokClient.StreamSilent(messages, "grok-4")
result := fmt.Sprintf("✅ Analysis complete for %s\n", dir)
result += fmt.Sprintf("Files analyzed: %d\n", len(files))
result += fmt.Sprintf("Primary language: %s\n\n", lang)
result += report
return mcp.NewToolResultText(result), nil
}
// handleGenerateDocs implements the generate_docs MCP tool.
func (s *Server) handleGenerateDocs(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
filePath, err := req.RequireString("file_path")
if err != nil {
return mcp.NewToolResultText("Error: file_path is required"), nil
}
style := req.GetString("style", "")
logger.Info("MCP generate_docs called", "file", filePath, "style", style)
// Fully functional implementation using logic from cmd/docs.go
lang, err := linter.DetectLanguage(filePath)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Unsupported language: %v", err)), nil
}
content, err := os.ReadFile(filePath)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Failed to read file: %v", err)), nil
}
messages := docs.BuildDocsMessages(lang.Name, string(content))
response := s.grokClient.StreamSilent(messages, "grok-4")
documented := grok.CleanCodeResponse(response)
result := fmt.Sprintf("✅ Documentation generated for %s\n", filePath)
result += fmt.Sprintf("Language: %s\n", lang.Name)
result += "\n--- Documented Code ---\n"
result += documented
return mcp.NewToolResultText(result), nil
}
// handleGenerateTests implements the generate_tests MCP tool.
func (s *Server) handleGenerateTests(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
filePath, err := req.RequireString("file_path")
if err != nil {
return mcp.NewToolResultText("Error: file_path is required"), nil
}
logger.Info("MCP generate_tests called", "file", filePath)
// Fully functional using logic from cmd/testgen.go
langObj, err := linter.DetectLanguage(filePath)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Unsupported language: %v", err)), nil
}
lang := langObj.Name
if lang == "C/C++" {
ext := strings.ToLower(filepath.Ext(filePath))
if ext == ".cpp" || ext == ".cc" || ext == ".cxx" {
lang = "C++"
} else {
lang = "C"
}
}
prompt := testgen.GetTestPrompt(lang)
if prompt == "" {
return mcp.NewToolResultText("No test prompt available for this language"), nil
}
content, err := os.ReadFile(filePath)
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Failed to read file: %v", err)), nil
}
messages := []map[string]string{
{"role": "system", "content": prompt},
{"role": "user", "content": fmt.Sprintf("Generate comprehensive tests for the following %s code:\n\n%s", lang, string(content))},
}
response := s.grokClient.StreamSilent(messages, "grok-4")
cleaned := grok.CleanCodeResponse(response)
result := fmt.Sprintf("✅ Tests generated for %s\n", filePath)
result += fmt.Sprintf("Language: %s\n\n", lang)
result += "--- Generated Test Code ---\n"
result += cleaned
return mcp.NewToolResultText(result), nil
}
// handleGenerateCommitMsg implements the generate_commit_msg MCP tool.
func (s *Server) handleGenerateCommitMsg(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
logger.Info("MCP generate_commit_msg called")
// Fully functional implementation using git diff + Grok
if !git.IsRepo() {
return mcp.NewToolResultText("Error: Not in a git repository"), nil
}
diff, err := git.Run([]string{"diff", "--cached"})
if err != nil || strings.TrimSpace(diff) == "" {
return mcp.NewToolResultText("No staged changes found. Stage some changes with `git add` first."), nil
}
prompt := `You are an expert at writing conventional commit messages.
Generate a single, concise conventional commit message for the following git diff.
Rules:
- Use format: <type>(<scope>): <description>
- Types: feat, fix, docs, style, refactor, perf, test, chore, ci, build
- Keep subject line under 72 characters
- Do not include body unless absolutely necessary
- Return ONLY the commit message, no explanations, no markdown, no quotes.
Diff:
` + diff
messages := []map[string]string{
{"role": "system", "content": prompt},
{"role": "user", "content": "Generate the commit message now."},
}
response := s.grokClient.StreamSilent(messages, "grok-4")
cleaned := strings.TrimSpace(grok.CleanCodeResponse(response))
result := "✅ Conventional commit message generated:\n\n"
result += cleaned
return mcp.NewToolResultText(result), nil
}
// handleRunRecipe implements the run_recipe MCP tool.
func (s *Server) handleRunRecipe(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name, err := req.RequireString("name")
if err != nil {
return mcp.NewToolResultText("Error: name is required"), nil
}
logger.Info("MCP run_recipe called", "recipe", name)
// Fully functional using internal/recipe package
r, err := recipe.Load(name, nil) // no custom params for now
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Failed to load recipe '%s': %v", name, err)), nil
}
client := grok.NewClient()
runner := recipe.NewRunner(r, client, "grok-4")
err = runner.Run()
if err != nil {
return mcp.NewToolResultText(fmt.Sprintf("Recipe execution failed: %v", err)), nil
}
result := fmt.Sprintf("✅ Recipe '%s' executed successfully\n", name)
result += "Check the terminal output above for full details.\n"
return mcp.NewToolResultText(result), nil
}
// registerResources registers MCP resources (recipes and prompts).
func (s *Server) registerResources() {
// Recipes as resources
s.mcpServer.AddResource(
mcp.NewResource("recipes://local", "Local Recipes",
mcp.WithResourceDescription("Project-local workflow recipes from .grokkit/recipes/"),
mcp.WithMIMEType("application/json"),
),
s.handleRecipesLocal,
)
s.mcpServer.AddResource(
mcp.NewResource("recipes://global", "Global Recipes",
mcp.WithResourceDescription("Global recipes from ~/.config/grokkit/recipes/"),
mcp.WithMIMEType("application/json"),
),
s.handleRecipesGlobal,
)
// Prompts as resources
s.mcpServer.AddResource(
mcp.NewResource("prompts://language", "Language Prompts",
mcp.WithResourceDescription("Language-specific analysis prompts"),
mcp.WithMIMEType("text/markdown"),
),
s.handlePrompts,
)
logger.Info("MCP resources registered", "count", 3)
}
// handleRecipesLocal returns local recipes as a resource
func (s *Server) handleRecipesLocal(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
recipes, err := recipe.ListRecipes(".")
if err != nil {
recipes = []string{} // fallback to empty list
}
json := fmt.Sprintf(`{"type": "local", "recipes": %v}`, recipes)
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: "recipes://local",
MIMEType: "application/json",
Text: json,
},
}, nil
}
// handleRecipesGlobal returns global recipes as a resource
func (s *Server) handleRecipesGlobal(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
home, _ := os.UserHomeDir()
globalPath := filepath.Join(home, ".config", "grokkit", "recipes")
recipes, err := recipe.ListRecipes(globalPath)
if err != nil {
recipes = []string{} // fallback to empty list
}
json := fmt.Sprintf(`{"type": "global", "recipes": %v}`, recipes)
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: "recipes://global",
MIMEType: "application/json",
Text: json,
},
}, nil
}
// handlePrompts returns available language prompts as a resource
func (s *Server) handlePrompts(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
promptsList := prompts.ListAvailablePrompts()
json := fmt.Sprintf(`{"type": "prompts", "prompts": %v}`, promptsList)
return []mcp.ResourceContents{
mcp.TextResourceContents{
URI: "prompts://language",
MIMEType: "application/json",
Text: json,
},
}, nil
}
// Run starts the MCP server using stdio transport (for Claude Code, etc.).
func (s *Server) Run(ctx context.Context) error {
logger.Info("Starting Grokkit MCP server (stdio transport)")
return server.ServeStdio(s.mcpServer)
}

View File

@ -1,78 +0,0 @@
package mcp
import (
"context"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
)
func TestNewServer(t *testing.T) {
srv, err := NewServer()
assert.NoError(t, err)
assert.NotNil(t, srv)
assert.NotNil(t, srv.mcpServer)
assert.NotNil(t, srv.grokClient)
}
func TestServer_RegistersTools(t *testing.T) {
srv, err := NewServer()
assert.NoError(t, err)
// We can't easily inspect registered tools without exposing internals,
// so we just verify server creation succeeds with tools.
assert.NotNil(t, srv)
}
func TestHandleLintCode(t *testing.T) {
srv, _ := NewServer()
// Use a file that exists and is lintable
req := mockCallToolRequest(map[string]any{
"file_path": "main.go",
})
result, err := srv.handleLintCode(context.Background(), req)
assert.NoError(t, err)
assert.NotNil(t, result)
// The test may fail if no linter is installed, so we just check it doesn't error
assert.NotNil(t, result.Content)
}
func TestHandleAnalyzeCode(t *testing.T) {
srv, _ := NewServer()
req := mockCallToolRequest(map[string]any{
"dir": ".",
})
result, err := srv.handleAnalyzeCode(context.Background(), req)
assert.NoError(t, err)
assert.NotNil(t, result)
// The test may fail if prompt files are missing, so we just check it doesn't error
assert.NotNil(t, result.Content)
}
func TestHandleGenerateDocs(t *testing.T) {
srv, _ := NewServer()
req := mockCallToolRequest(map[string]any{
"file_path": "main.go",
})
result, err := srv.handleGenerateDocs(context.Background(), req)
assert.NoError(t, err)
assert.NotNil(t, result)
// The test may fail if the file is not readable in test env, so we just check it doesn't error
assert.NotNil(t, result.Content)
}
// Helper to create mock CallToolRequest
func mockCallToolRequest(args map[string]any) mcp.CallToolRequest {
return mcp.CallToolRequest{
Params: mcp.CallToolParams{
Arguments: args,
},
}
}

View File

@ -1,162 +0,0 @@
package prompts
import (
"os"
"path/filepath"
"testing"
)
func TestLoadAnalysisPromptProjectLocal(t *testing.T) {
tmpDir := t.TempDir()
promptDir := filepath.Join(tmpDir, ".grokkit", "prompts")
if err := os.MkdirAll(promptDir, 0755); err != nil {
t.Fatal(err)
}
expected := "You are a Go expert educator."
if err := os.WriteFile(filepath.Join(promptDir, "go.md"), []byte(expected), 0644); err != nil {
t.Fatal(err)
}
path, content, err := LoadAnalysisPrompt(tmpDir, "go")
if err != nil {
t.Fatalf("LoadAnalysisPrompt failed: %v", err)
}
if content != expected {
t.Errorf("content = %q, want %q", content, expected)
}
if !filepath.IsAbs(path) || path == "" {
// path should be meaningful
t.Logf("returned path: %s", path)
}
}
func TestLoadAnalysisPromptGlobalFallback(t *testing.T) {
projectDir := t.TempDir() // no .grokkit/prompts here
globalDir := t.TempDir()
promptDir := filepath.Join(globalDir, ".config", "grokkit", "prompts")
if err := os.MkdirAll(promptDir, 0755); err != nil {
t.Fatal(err)
}
expected := "You are a Python expert."
if err := os.WriteFile(filepath.Join(promptDir, "python.md"), []byte(expected), 0644); err != nil {
t.Fatal(err)
}
// Override HOME to point to our temp global dir
oldHome := os.Getenv("HOME")
defer func() { _ = os.Setenv("HOME", oldHome) }()
_ = os.Setenv("HOME", globalDir)
path, content, err := LoadAnalysisPrompt(projectDir, "python")
if err != nil {
t.Fatalf("LoadAnalysisPrompt (global fallback) failed: %v", err)
}
if content != expected {
t.Errorf("content = %q, want %q", content, expected)
}
_ = path
}
func TestLoadAnalysisPromptProjectOverridesGlobal(t *testing.T) {
projectDir := t.TempDir()
globalDir := t.TempDir()
// Create both project-local and global prompts
localPromptDir := filepath.Join(projectDir, ".grokkit", "prompts")
if err := os.MkdirAll(localPromptDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(localPromptDir, "go.md"), []byte("local prompt"), 0644); err != nil {
t.Fatal(err)
}
globalPromptDir := filepath.Join(globalDir, ".config", "grokkit", "prompts")
if err := os.MkdirAll(globalPromptDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(globalPromptDir, "go.md"), []byte("global prompt"), 0644); err != nil {
t.Fatal(err)
}
oldHome := os.Getenv("HOME")
defer func() { _ = os.Setenv("HOME", oldHome) }()
_ = os.Setenv("HOME", globalDir)
_, content, err := LoadAnalysisPrompt(projectDir, "go")
if err != nil {
t.Fatalf("LoadAnalysisPrompt failed: %v", err)
}
if content != "local prompt" {
t.Errorf("expected project-local to override global, got: %q", content)
}
}
func TestLoadAnalysisPromptNotFound(t *testing.T) {
projectDir := t.TempDir()
emptyHome := t.TempDir()
oldHome := os.Getenv("HOME")
defer func() { _ = os.Setenv("HOME", oldHome) }()
_ = os.Setenv("HOME", emptyHome)
_, _, err := LoadAnalysisPrompt(projectDir, "rust")
if err == nil {
t.Fatal("expected error when no prompt found, got nil")
}
if !os.IsNotExist(err) {
t.Errorf("expected os.ErrNotExist, got: %v", err)
}
}
func TestLoadAnalysisPromptDefaultsToGo(t *testing.T) {
projectDir := t.TempDir()
promptDir := filepath.Join(projectDir, ".grokkit", "prompts")
if err := os.MkdirAll(promptDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(promptDir, "go.md"), []byte("go prompt"), 0644); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
language string
}{
{"empty string defaults to go", ""},
{"unknown defaults to go", "unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, content, err := LoadAnalysisPrompt(projectDir, tt.language)
if err != nil {
t.Fatalf("LoadAnalysisPrompt(%q) failed: %v", tt.language, err)
}
if content != "go prompt" {
t.Errorf("expected go prompt, got: %q", content)
}
})
}
}
func TestLoadAnalysisPromptNormalizesCase(t *testing.T) {
projectDir := t.TempDir()
promptDir := filepath.Join(projectDir, ".grokkit", "prompts")
if err := os.MkdirAll(promptDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(promptDir, "python.md"), []byte("python prompt"), 0644); err != nil {
t.Fatal(err)
}
_, content, err := LoadAnalysisPrompt(projectDir, "Python")
if err != nil {
t.Fatalf("LoadAnalysisPrompt with uppercase failed: %v", err)
}
if content != "python prompt" {
t.Errorf("expected case-insensitive match, got: %q", content)
}
}

View File

@ -1,53 +0,0 @@
package prompts
import (
"os"
"path/filepath"
"strings"
)
// ListAvailablePrompts returns a list of available language prompts.
// It scans both project-local and global prompt directories.
func ListAvailablePrompts() []string {
var prompts []string
// Check project-local prompts
localDir := ".grokkit/prompts"
if entries, err := os.ReadDir(localDir); err == nil {
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") {
name := strings.TrimSuffix(entry.Name(), ".md")
prompts = append(prompts, name)
}
}
}
// Check global prompts
home := os.Getenv("HOME")
if home == "" {
home = os.Getenv("USERPROFILE")
}
if home != "" {
globalDir := filepath.Join(home, ".config", "grokkit", "prompts")
if entries, err := os.ReadDir(globalDir); err == nil {
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") {
name := strings.TrimSuffix(entry.Name(), ".md")
// Avoid duplicates
found := false
for _, p := range prompts {
if p == name {
found = true
break
}
}
if !found {
prompts = append(prompts, name)
}
}
}
}
}
return prompts
}

View File

@ -1,45 +0,0 @@
package recipe
import (
"os"
"path/filepath"
"strings"
)
// ListRecipes returns a list of recipe names in the given directory.
// It looks for .md files that contain recipe frontmatter.
func ListRecipes(dir string) ([]string, error) {
if dir == "" {
dir = "."
}
var recipes []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
// Skip hidden directories and common ignore dirs
name := info.Name()
if strings.HasPrefix(name, ".") && name != "." || name == "node_modules" || name == "vendor" {
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(strings.ToLower(info.Name()), ".md") {
// Simple check for recipe files (contains "Objective:" or "Instructions:")
data, err := os.ReadFile(path)
if err == nil && (strings.Contains(string(data), "**Objective:**") || strings.Contains(string(data), "Objective:")) {
name := strings.TrimSuffix(info.Name(), ".md")
recipes = append(recipes, name)
}
}
return nil
})
return recipes, err
}

View File

@ -1,79 +0,0 @@
package testgen
// GetTestPrompt returns the appropriate test prompt for the given language.
// Exported for use by MCP server.
func GetTestPrompt(lang string) string {
switch lang {
case "Go":
return `You are an expert Go test writer.
Generate a COMPLETE, production-ready *_test.go file using ONLY idiomatic Go.
STRICT RULES NEVER VIOLATE THESE:
- NO monkey-patching: never assign to runtime.GOOS, exec.Command, os.Stdout, os.Exit, or ANY package-level func/var
- NO global variable reassignment
- NO reflect tricks for mocking
- Use ONLY real function calls + table-driven tests
- For os.Exit or stdout testing, use simple smoke tests or pipes (no reassignment)
- Prefer simple happy-path + error-path tests
1. Fast unit test:
func TestXXX_Unit(t *testing.T) {
t.Parallel()
t.Log("✓ Fast XXX unit test")
// table-driven, real calls only
}
2. Optional live test:
func TestXXX_Live(t *testing.T) {
if !testing.Short() {
t.Skip("skipping live integration test. Run with:\n go test -run TestXXX_Live -short -v")
}
t.Log("🧪 Running live integration test...")
}
Exact rules:
- Derive test names from functions (e.g. isInstalled TestIsInstalled_Unit)
- The XXX in t.Skip MUST exactly match the live function name
- t.Parallel() on unit tests only
- NO unused imports
- Keep tests simple and idiomatic Go
- Return ONLY the full test file. No explanations, no markdown, no backticks.`
case "Python":
return `You are a pytest expert. Generate COMPLETE pytest unit tests for the Python source.
- Use pytest fixtures where appropriate
- @pytest.mark.parametrize for tables
- Cover ALL functions/classes/methods: happy/edge/error cases
- pytest.raises for exceptions
- Modern Python 3.12+: type hints, match/case if applicable
- NO external deps unless source requires
Respond ONLY with full test_*.py file: imports, fixtures, tests. Pure Python test code.`
case "C":
return `You are a C testing expert using the Check framework.
Generate COMPLETE test code for the given C source.
- Use Check framework (check.h)
- Test all major functions
- Include setup/teardown if needed
- Cover edge cases and errors
- Return ONLY the full test file.`
case "C++":
return `You are a C++ testing expert using Google Test.
Generate COMPLETE Google Test code for the given C++ source.
- Use TEST() and TEST_F() macros
- Test all major functions and classes
- Cover edge cases and errors
- Return ONLY the full test file.`
default:
return ""
}
}

View File

@ -1,31 +0,0 @@
package testgen
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetTestPrompt(t *testing.T) {
tests := []struct {
lang string
expected string
}{
{"Go", "expert Go test writer"},
{"Python", "pytest expert"},
{"C", "Check framework"},
{"C++", "Google Test"},
{"Unknown", ""},
}
for _, tt := range tests {
t.Run(tt.lang, func(t *testing.T) {
prompt := GetTestPrompt(tt.lang)
if tt.expected == "" {
assert.Empty(t, prompt)
} else {
assert.Contains(t, prompt, tt.expected)
}
})
}
}

View File

@ -1,28 +0,0 @@
package todo
import (
"os"
"path/filepath"
)
const todoRoot = "todo"
// Bootstrap creates the todo folder structure + basic index if missing.
func Bootstrap() error {
dirs := []string{
filepath.Join(todoRoot, "queued"),
filepath.Join(todoRoot, "doing"),
filepath.Join(todoRoot, "completed"),
}
for _, d := range dirs {
if err := os.MkdirAll(d, 0755); err != nil {
return err
}
}
// Basic index README stub
index := filepath.Join(todoRoot, "README.md")
if _, err := os.Stat(index); os.IsNotExist(err) {
return os.WriteFile(index, []byte("# Todo Index\n\n## Queued\n## Doing\n## Completed\n"), 0644)
}
return nil
}

View File

@ -1,123 +0,0 @@
package todo
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestBootstrapCreatesStructure(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
if err := Bootstrap(); err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
// Verify directories
for _, dir := range []string{"todo/queued", "todo/doing", "todo/completed"} {
info, err := os.Stat(dir)
if err != nil {
t.Errorf("expected %s to exist: %v", dir, err)
continue
}
if !info.IsDir() {
t.Errorf("expected %s to be a directory", dir)
}
}
// Verify README
data, err := os.ReadFile("todo/README.md")
if err != nil {
t.Fatalf("expected todo/README.md to exist: %v", err)
}
content := string(data)
if !strings.Contains(content, "## Queued") {
t.Error("README missing ## Queued heading")
}
if !strings.Contains(content, "## Completed") {
t.Error("README missing ## Completed heading")
}
}
func TestBootstrapIdempotent(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Run twice
if err := Bootstrap(); err != nil {
t.Fatalf("first Bootstrap failed: %v", err)
}
if err := Bootstrap(); err != nil {
t.Fatalf("second Bootstrap failed: %v", err)
}
// Verify structure still intact
for _, dir := range []string{"todo/queued", "todo/doing", "todo/completed"} {
if _, err := os.Stat(dir); err != nil {
t.Errorf("expected %s to exist after second Bootstrap: %v", dir, err)
}
}
}
func TestBootstrapPreservesExistingReadme(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Create structure with custom README
if err := os.MkdirAll("todo", 0755); err != nil {
t.Fatal(err)
}
customContent := "# My Custom TODO\n\nCustom content here.\n"
if err := os.WriteFile("todo/README.md", []byte(customContent), 0644); err != nil {
t.Fatal(err)
}
if err := Bootstrap(); err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
// Verify custom README was not overwritten
data, err := os.ReadFile("todo/README.md")
if err != nil {
t.Fatal(err)
}
if string(data) != customContent {
t.Errorf("Bootstrap overwrote existing README.\ngot: %q\nwant: %q", string(data), customContent)
}
}
func TestBootstrapCreatesReadmeWithCorrectPermissions(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
if err := Bootstrap(); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join("todo", "README.md"))
if err != nil {
t.Fatal(err)
}
perm := info.Mode().Perm()
if perm != 0644 {
t.Errorf("README permissions = %o, want 0644", perm)
}
}

View File

@ -1,222 +0,0 @@
package workon
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gmgauthier.com/grokkit/config"
"gmgauthier.com/grokkit/internal/grok"
"gmgauthier.com/grokkit/internal/logger"
"gmgauthier.com/grokkit/internal/todo"
)
// Run executes the full transactional workon flow per todo/doing/workon.md spec.
func Run(title, customMsg string, isFix, isComplete bool) error {
if title == "" {
return fmt.Errorf("todo_item_title is required")
}
logger.Info("workon starting", "title", title, "fix", isFix, "complete", isComplete)
// 1. Bootstrap todo structure if missing
if err := todo.Bootstrap(); err != nil {
return fmt.Errorf("todo bootstrap failed: %w", err)
}
if isComplete {
return completeItem(title, isFix)
}
// 2. Handle todo or fix mode
branchPrefix := "feature/"
if isFix {
branchPrefix = "fix/"
}
branchName := branchPrefix + title
mdPath := filepath.Join("todo", "doing", title+".md")
if isFix {
if err := createFixFile(mdPath, title); err != nil {
return fmt.Errorf("create fix file failed: %w", err)
}
} else {
if err := moveQueuedToDoing(title); err != nil {
return fmt.Errorf("move to doing failed: %w", err)
}
}
// 3. Create git branch (safe)
if err := createGitBranch(branchName); err != nil {
return fmt.Errorf("failed to create branch %s: %w", branchName, err)
}
// 4. Generate + append Work Plan via Grok
if err := appendWorkPlan(mdPath, title); err != nil {
return fmt.Errorf("failed to generate/append Work Plan: %w", err)
}
// 5. Commit
commitMsg := customMsg
if commitMsg == "" {
commitMsg = fmt.Sprintf("Start working on %s", title)
}
if err := commitChanges(commitMsg); err != nil {
return fmt.Errorf("commit failed: %w", err)
}
// 6. Optional post-steps (graceful)
runCnaddIfAvailable(title)
openIDEIfConfigured()
logger.Info("workon transaction complete", "branch", branchName, "mode", map[bool]string{true: "fix", false: "todo"}[isFix])
return nil
}
func moveQueuedToDoing(title string) error {
src := filepath.Join("todo", "queued", title+".md")
dst := filepath.Join("todo", "doing", title+".md")
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("move %s -> doing failed: %w", title, err)
}
return nil
}
func createFixFile(path, title string) error {
content := fmt.Sprintf("# %s\n\n## Work Plan\n\n", title)
return os.WriteFile(path, []byte(content), 0644)
}
func createGitBranch(name string) error {
// Safe: if branch already exists, just checkout it; else create new
if err := exec.Command("git", "checkout", name).Run(); err == nil {
return nil
}
return exec.Command("git", "checkout", "-b", name).Run()
}
func appendWorkPlan(path, title string) error {
content := readFileContent(path)
prompt := fmt.Sprintf(`You are helping implement a todo item titled "%s".
Here is the current markdown content of the todo/fix file:
%s
Generate a concise, actionable **Work Plan** section.
Use numbered steps. Be specific to this item. Include testing and commit notes where relevant.
Output ONLY the markdown starting with "## Work Plan" no extra text, no introduction.`, title, content)
// Real Grok call using the project's standard client (StreamSilent for clean output)
client := grok.NewClient()
model := config.GetModel("workon", "")
plan := client.StreamSilent([]map[string]string{
{"role": "system", "content": "You are a precise software engineering assistant."},
{"role": "user", "content": prompt},
}, model) // or pull model from config/env if available
// Append the plan
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil {
logger.Error("failed to close todo file", "err", cerr)
}
}()
_, err = f.WriteString(plan)
return err
}
func readFileContent(path string) string {
b, _ := os.ReadFile(path)
return string(b)
}
func commitChanges(msg string) error {
if err := exec.Command("git", "add", "todo/").Run(); err != nil {
return err
}
return exec.Command("git", "commit", "-m", msg).Run()
}
func completeItem(title string, isFix bool) error {
src := filepath.Join("todo", "doing", title+".md")
dst := filepath.Join("todo", "completed", title+".md")
if err := os.Rename(src, dst); err != nil {
return fmt.Errorf("move to completed failed: %w", err)
}
if !isFix {
if err := updateReadmeIndex(title); err != nil {
logger.Error("failed to update README index", "err", err)
}
}
commitMsg := fmt.Sprintf("Complete work on %s", title)
if err := commitChanges(commitMsg); err != nil {
return fmt.Errorf("complete commit failed: %w", err)
}
return nil
}
func updateReadmeIndex(title string) error {
readmePath := filepath.Join("todo", "README.md")
data, err := os.ReadFile(readmePath)
if err != nil {
return fmt.Errorf("read README: %w", err)
}
lines := strings.Split(string(data), "\n")
var result []string
completedIdx := -1
for i, line := range lines {
// Skip the line referencing this title in the Queued section
if strings.Contains(line, title+".md") && strings.Contains(line, "queued/") {
continue
}
// Track the last line in the Completed section
if strings.HasPrefix(line, "## Completed") {
completedIdx = i
}
result = append(result, line)
}
// Find insertion point: after last non-empty line following ## Completed
if completedIdx >= 0 {
insertIdx := completedIdx + 1
for insertIdx < len(result) && (strings.HasPrefix(result[insertIdx], "*") || strings.TrimSpace(result[insertIdx]) == "") {
if strings.HasPrefix(result[insertIdx], "*") {
insertIdx++
} else {
break
}
}
entry := fmt.Sprintf("* [%s](./completed/%s.md) : %s *(done)*", title, title, title)
result = append(result[:insertIdx], append([]string{entry}, result[insertIdx:]...)...)
}
return os.WriteFile(readmePath, []byte(strings.Join(result, "\n")), 0644)
}
func runCnaddIfAvailable(title string) {
if _, err := exec.LookPath("cnadd"); err == nil {
_ = exec.Command("cnadd", "log", fmt.Sprintf("started work on %s", title)).Run()
}
}
func openIDEIfConfigured() {
ideCmd := config.GetIDECommand()
if ideCmd == "" {
logger.Debug("IDE open skipped (no IDE configured)")
return
}
if err := exec.Command(ideCmd, ".").Start(); err != nil {
logger.Error("failed to open IDE", "cmd", ideCmd, "err", err)
}
}

View File

@ -1,474 +0,0 @@
package workon
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestRunRejectsEmptyTitle(t *testing.T) {
err := Run("", "", false, false)
if err == nil {
t.Fatal("expected error for empty title, got nil")
}
if !strings.Contains(err.Error(), "todo_item_title is required") {
t.Errorf("unexpected error message: %s", err.Error())
}
}
func TestMoveQueuedToDoing(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Set up todo structure
if err := os.MkdirAll("todo/queued", 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll("todo/doing", 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("todo/queued/test-item.md", []byte("# Test Item\n"), 0644); err != nil {
t.Fatal(err)
}
if err := moveQueuedToDoing("test-item"); err != nil {
t.Fatalf("moveQueuedToDoing failed: %v", err)
}
// Verify source is gone
if _, err := os.Stat("todo/queued/test-item.md"); !os.IsNotExist(err) {
t.Error("expected queued file to be removed")
}
// Verify destination exists
if _, err := os.Stat("todo/doing/test-item.md"); err != nil {
t.Errorf("expected doing file to exist: %v", err)
}
}
func TestMoveQueuedToDoingMissingFile(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
if err := os.MkdirAll("todo/queued", 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll("todo/doing", 0755); err != nil {
t.Fatal(err)
}
err := moveQueuedToDoing("nonexistent")
if err == nil {
t.Fatal("expected error for missing queued file, got nil")
}
}
func TestCreateFixFile(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "my-fix.md")
if err := createFixFile(path, "my-fix"); err != nil {
t.Fatalf("createFixFile failed: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read created file: %v", err)
}
content := string(data)
if !strings.Contains(content, "# my-fix") {
t.Errorf("expected title heading, got: %s", content)
}
if !strings.Contains(content, "## Work Plan") {
t.Errorf("expected Work Plan heading, got: %s", content)
}
}
func TestReadFileContent(t *testing.T) {
tmpDir := t.TempDir()
t.Run("existing file", func(t *testing.T) {
path := filepath.Join(tmpDir, "exists.md")
expected := "hello world"
if err := os.WriteFile(path, []byte(expected), 0644); err != nil {
t.Fatal(err)
}
got := readFileContent(path)
if got != expected {
t.Errorf("readFileContent = %q, want %q", got, expected)
}
})
t.Run("missing file returns empty string", func(t *testing.T) {
got := readFileContent(filepath.Join(tmpDir, "missing.md"))
if got != "" {
t.Errorf("readFileContent for missing file = %q, want empty", got)
}
})
}
func TestCompleteItemMovesFile(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Set up todo structure with a doing item
if err := os.MkdirAll("todo/doing", 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll("todo/completed", 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("todo/doing/test-item.md", []byte("# Test\n"), 0644); err != nil {
t.Fatal(err)
}
// completeItem will try to git commit — that will fail in a non-git temp dir,
// but we can verify the file move happened before the commit step
_ = completeItem("test-item", true)
// For a fix, file should be moved regardless of commit outcome
if _, err := os.Stat("todo/completed/test-item.md"); err != nil {
t.Errorf("expected completed file to exist: %v", err)
}
if _, err := os.Stat("todo/doing/test-item.md"); !os.IsNotExist(err) {
t.Error("expected doing file to be removed")
}
}
func TestUpdateReadmeIndex(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
if err := os.MkdirAll("todo", 0755); err != nil {
t.Fatal(err)
}
readme := `# Todo Index
## Queued
* [1] [my-feature](queued/my-feature.md): My feature
* [2] [other-item](queued/other-item.md): Other item
## Completed
* [old-item](./completed/old-item.md) : old-item *(done)*
`
if err := os.WriteFile("todo/README.md", []byte(readme), 0644); err != nil {
t.Fatal(err)
}
if err := updateReadmeIndex("my-feature"); err != nil {
t.Fatalf("updateReadmeIndex failed: %v", err)
}
data, err := os.ReadFile("todo/README.md")
if err != nil {
t.Fatal(err)
}
result := string(data)
// Should have removed the queued entry
if strings.Contains(result, "queued/my-feature.md") {
t.Error("expected my-feature to be removed from Queued section")
}
// Should still have the other queued item
if !strings.Contains(result, "queued/other-item.md") {
t.Error("expected other-item to remain in Queued section")
}
// Should have added to Completed section
if !strings.Contains(result, "completed/my-feature.md") {
t.Error("expected my-feature to appear in Completed section")
}
// Old completed item should still be there
if !strings.Contains(result, "completed/old-item.md") {
t.Error("expected old-item to remain in Completed section")
}
}
func TestUpdateReadmeIndexMissingReadme(t *testing.T) {
tmpDir := t.TempDir()
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
err := updateReadmeIndex("anything")
if err == nil {
t.Fatal("expected error when README doesn't exist, got nil")
}
}
// initTempGitRepo creates a temp dir with a git repo and returns it.
// The caller is responsible for chdir-ing back.
func initTempGitRepo(t *testing.T) string {
t.Helper()
tmpDir := t.TempDir()
cmds := [][]string{
{"git", "init"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test"},
}
for _, args := range cmds {
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = tmpDir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init setup failed (%v): %s", err, out)
}
}
return tmpDir
}
func TestCreateGitBranchNew(t *testing.T) {
tmpDir := initTempGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Need at least one commit for branches to work
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
_ = exec.Command("git", "add", ".").Run()
_ = exec.Command("git", "commit", "-m", "initial").Run()
if err := createGitBranch("feature/test-branch"); err != nil {
t.Fatalf("createGitBranch failed: %v", err)
}
// Verify we're on the new branch
out, err := exec.Command("git", "branch", "--show-current").Output()
if err != nil {
t.Fatalf("git branch --show-current failed: %v", err)
}
branch := strings.TrimSpace(string(out))
if branch != "feature/test-branch" {
t.Errorf("expected branch 'feature/test-branch', got %q", branch)
}
}
func TestCreateGitBranchExisting(t *testing.T) {
tmpDir := initTempGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Initial commit + create branch
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
_ = exec.Command("git", "add", ".").Run()
_ = exec.Command("git", "commit", "-m", "initial").Run()
_ = exec.Command("git", "checkout", "-b", "fix/existing").Run()
_ = exec.Command("git", "checkout", "-").Run() // back to main/master
// Should checkout existing branch, not error
if err := createGitBranch("fix/existing"); err != nil {
t.Fatalf("createGitBranch for existing branch failed: %v", err)
}
out, _ := exec.Command("git", "branch", "--show-current").Output()
branch := strings.TrimSpace(string(out))
if branch != "fix/existing" {
t.Errorf("expected branch 'fix/existing', got %q", branch)
}
}
func TestCommitChangesInGitRepo(t *testing.T) {
tmpDir := initTempGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Initial commit
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
_ = exec.Command("git", "add", ".").Run()
_ = exec.Command("git", "commit", "-m", "initial").Run()
// Create a todo file to commit
if err := os.MkdirAll("todo/doing", 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("todo/doing/test.md", []byte("# Test\n"), 0644); err != nil {
t.Fatal(err)
}
if err := commitChanges("test commit message"); err != nil {
t.Fatalf("commitChanges failed: %v", err)
}
// Verify the commit was created
out, err := exec.Command("git", "log", "--oneline", "-1").Output()
if err != nil {
t.Fatalf("git log failed: %v", err)
}
if !strings.Contains(string(out), "test commit message") {
t.Errorf("expected commit message in log, got: %s", out)
}
}
func TestCommitChangesNothingToCommit(t *testing.T) {
tmpDir := initTempGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Initial commit
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
_ = exec.Command("git", "add", ".").Run()
_ = exec.Command("git", "commit", "-m", "initial").Run()
// Create empty todo dir but no files to stage
if err := os.MkdirAll("todo", 0755); err != nil {
t.Fatal(err)
}
// Should fail — nothing to commit
err := commitChanges("empty commit")
if err == nil {
t.Error("expected error when nothing to commit, got nil")
}
}
func TestCompleteItemWithGit(t *testing.T) {
tmpDir := initTempGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Initial commit
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
_ = exec.Command("git", "add", ".").Run()
_ = exec.Command("git", "commit", "-m", "initial").Run()
// Set up todo structure
for _, d := range []string{"todo/doing", "todo/completed"} {
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile("todo/doing/my-fix.md", []byte("# Fix\n"), 0644); err != nil {
t.Fatal(err)
}
// Stage and commit the doing file first so git tracks it
_ = exec.Command("git", "add", "todo/").Run()
_ = exec.Command("git", "commit", "-m", "add doing item").Run()
// Now complete as a fix (no README update)
if err := completeItem("my-fix", true); err != nil {
t.Fatalf("completeItem failed: %v", err)
}
// Verify moved
if _, err := os.Stat("todo/completed/my-fix.md"); err != nil {
t.Errorf("expected completed file: %v", err)
}
if _, err := os.Stat("todo/doing/my-fix.md"); !os.IsNotExist(err) {
t.Error("expected doing file to be gone")
}
// Verify commit
out, _ := exec.Command("git", "log", "--oneline", "-1").Output()
if !strings.Contains(string(out), "Complete work on my-fix") {
t.Errorf("expected completion commit, got: %s", out)
}
}
func TestCompleteItemNonFixUpdatesReadme(t *testing.T) {
tmpDir := initTempGitRepo(t)
origDir, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatal(err)
}
defer func() { _ = os.Chdir(origDir) }()
// Initial commit
if err := os.WriteFile("init.txt", []byte("init"), 0644); err != nil {
t.Fatal(err)
}
_ = exec.Command("git", "add", ".").Run()
_ = exec.Command("git", "commit", "-m", "initial").Run()
// Set up todo structure with README
for _, d := range []string{"todo/doing", "todo/completed"} {
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
}
readme := "# Todo\n\n## Queued\n\n* [my-todo](queued/my-todo.md): My todo\n\n## Completed\n\n"
if err := os.WriteFile("todo/README.md", []byte(readme), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("todo/doing/my-todo.md", []byte("# My Todo\n"), 0644); err != nil {
t.Fatal(err)
}
_ = exec.Command("git", "add", "todo/").Run()
_ = exec.Command("git", "commit", "-m", "add todo item").Run()
// Complete as a non-fix (should update README)
if err := completeItem("my-todo", false); err != nil {
t.Fatalf("completeItem (non-fix) failed: %v", err)
}
data, err := os.ReadFile("todo/README.md")
if err != nil {
t.Fatal(err)
}
result := string(data)
if strings.Contains(result, "queued/my-todo.md") {
t.Error("expected my-todo removed from Queued")
}
if !strings.Contains(result, "completed/my-todo.md") {
t.Error("expected my-todo added to Completed")
}
}
func TestRunCnaddIfAvailable(t *testing.T) {
// Just exercise the function — it should not panic regardless
// of whether cnadd is installed
runCnaddIfAvailable("test-item")
}
func TestOpenIDEIfConfigured(t *testing.T) {
// With no IDE configured (default), this should silently skip
openIDEIfConfigured()
}

View File

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

View File

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

View File

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

View File

@ -1,137 +0,0 @@
# MCP Server Feature
**Description**: Add an MCP (Model Context Protocol) server mode to grokkit, exposing its capabilities as tools and resources for Claude Code and other MCP-compatible clients.
## Problem It Solves
Grokkit's AI-powered code operations (linting, docs, test generation, analysis) are locked behind the CLI. MCP integration lets external AI clients invoke them programmatically, composing grokkit's strengths with broader workflows.
## Benefits
- **Composability**: Claude Code (or any MCP client) can call grokkit tools mid-conversation.
- **No core changes**: New `grokkit mcp` command wraps existing functionality via interfaces.
- **Stdio transport**: No HTTP server to manage — Claude Code pipes stdio directly.
- **Recipes as resources**: MCP clients can browse and invoke workflow recipes.
- **Leverages existing architecture**: `AIClient` and `GitRunner` interfaces already support dependency injection.
## Architecture Notes
Grokkit is well-suited for this:
- **Interface-based design**: `AIClient` and `GitRunner` are already abstractions — inject real implementations into MCP tool handlers.
- **Clean package separation**: Each internal package (grok, git, linter, recipe, prompts) maps naturally to an MCP tool.
- **Cobra command tree**: Adding `cmd/mcp.go` is trivial.
- **Streaming caveat**: MCP tools return complete results, so use `StreamSilent` (captures without printing) rather than `Stream`.
- **File-writing tools** (`edit`, `workon`): Need careful scoping so the MCP client understands what changed.
## High-Level Implementation
### Phase 1: Foundation
1. **Add MCP Go SDK** (e.g., `github.com/mark3labs/mcp-go`) to `go.mod`.
2. **Create `internal/mcp/server.go`** — MCP server that registers tools and resources.
3. **Create `cmd/mcp.go`**`grokkit mcp` command that starts the stdio server.
### Phase 2: Tools
Wrap existing functionality as MCP tools. Priority order:
| Tool | Wraps | Notes |
|------|-------|-------|
| `lint_code` | `internal/linter` | Stateless. File path in, results out. |
| `generate_commit_msg` | `cmd/commit.go` logic | Reads git diff, calls Grok, returns message. |
| `analyze_code` | `cmd/analyze.go` + `internal/prompts` | File in, analysis out. |
| `generate_docs` | `cmd/docs.go` | File in, docs out (9 styles available). |
| `generate_tests` | `cmd/testgen.go` | File in, tests out. |
| `run_recipe` | `internal/recipe` | Recipe name + params in, results out. |
Each tool handler:
- Extracts core logic from the Cobra `RunE` function into a reusable function.
- Injects real `AIClient` and `GitRunner` implementations.
- Returns complete text results (no streaming to client).
### Phase 3: Resources
| Resource | Source | Description |
|----------|--------|-------------|
| `recipes://local` | `.grokkit/recipes/` | Project-local workflow recipes |
| `recipes://global` | `~/.local/share/grokkit/recipes/` | Global recipes |
| `prompts://language` | `.grokkit/prompts/` | Language-specific analysis prompts |
## Flags / Config
| Key | Description |
|-----|-------------|
| `mcp.enabled` | Enable MCP server mode |
| `mcp.tools` | List of tools to expose (default: all) |
| `mcp.resources` | List of resources to expose (default: all) |
## Implementation Notes
- **Entry point**: `grokkit mcp` starts stdio server, blocks until EOF.
- **No breaking changes**: Entirely additive — new command, new package.
- **Testing**: Mock `AIClient` and `GitRunner` interfaces; table-driven tests per tool.
- **Effort**: Medium (~300-500 LOC). SDK does the protocol heavy lifting.
- **Prereqs**: Choose and evaluate a Go MCP SDK.
## ROI
**High**. Turns grokkit from a standalone CLI into a composable service:
| Capability | Standalone | With MCP |
|------------|-----------|----------|
| Lint | `grokkit lint` | Claude calls it mid-review |
| Docs | `grokkit docs` | Claude generates docs in context |
| Tests | `grokkit testgen` | Claude generates + validates tests |
| Recipes | `grokkit recipe` | Claude orchestrates multi-step workflows |
| Analysis | `grokkit analyze` | Claude gets language-aware code analysis |
## Work Plan
1. **Evaluate and add MCP SDK**
- Research Go MCP SDKs (`github.com/mark3labs/mcp-go` or equivalent).
- Add to `go.mod`, run `go mod tidy`.
- Commit: `feat(mcp): add MCP SDK dependency`.
2. **Implement MCP server foundation** (`internal/mcp`)
- Create `internal/mcp/server.go`: Initialize stdio server, register tools/resources.
- Inject real `AIClient` and `GitRunner` impls.
- Use `StreamSilent` for non-streaming results.
- Commit: `feat(mcp): add MCP server foundation`.
3. **Add `cmd/mcp.go`**
- Cobra command `grokkit mcp` to start stdio server.
- Add config flags: `--tools`, `--resources` (comma-separated).
- Blocks until EOF.
- Test: Manual `grokkit mcp` + simple MCP client.
- Commit: `feat(mcp): add grokkit mcp CLI command`.
4. **Extract reusable functions from existing commands**
- `lint_code`: Extract from `cmd/lint.go``internal/linter.RunLint(path) string`.
- `generate_commit_msg`: Extract from `cmd/commit.go``internal/git.GenerateCommitMsg() string`.
- Commit per extraction: `refactor(lint): extract reusable lint function`.
5. **Implement Phase 2 tools** (priority order, 1 commit per 2 tools)
- Register each as MCP tool in `server.go`.
- `lint_code`, `analyze_code`.
- `generate_docs`, `generate_tests`.
- `generate_commit_msg`, `run_recipe`.
- Table-driven unit tests with mocked `AIClient`/`GitRunner`.
- Commit: `feat(mcp): add lint_code and analyze_code tools`.
6. **Implement Phase 3 resources**
- `recipes://local`, `recipes://global`, `prompts://language`.
- List directories and contents as MCP resources.
- Unit tests for path resolution and listing.
- Commit: `feat(mcp): add recipe and prompt resources`.
7. **Add config flags and validation**
- Support `mcp.enabled`, `mcp.tools`, `mcp.resources` in config.
- Validate tool/resource lists on startup.
- Integration test: Full `grokkit mcp` run with subset tools.
- Commit: `feat(mcp): add config flags and validation`.
8. **End-to-end testing and docs**
- Test with Claude Code or MCP client simulator.
- Add `README.md` section: "MCP Server Mode".
- Smoke test all tools/resources.
- Final commit: `feat(mcp): complete MCP server + docs/tests`.

View File

@ -1,46 +0,0 @@
# `grokkit workon`
**Description**: Automates the initiation of new todo item and fix development.
1. selects a todo item out of the todo queue and moves it to the "doing" folder;
2. creates a branch based on the item;
3. generates a plan for implementation (amending it to the todo item under the heading "Work Plan");
4. commits the plan to the branch.
5. [optional] logs the beginning of the work using `cnadd` (if available).
6. [optional] opens the branch in the IDE (specified in the config).
**Assumptions**:
Grokkit is being customized to suit my own development workflow. So, there are a number of features that may not be translatable to other developers' habits. As such, this feature assumes that the repository has a "todo" folder with a "queued", "doing" and "completed" subfolder, that function as the in-situ ticketing system. If the "todo" folder is not present, grokkit will create it.
Also, if no IDE is specified in the config, the `workon` command will default to not opening the editor. If `cnadd` is not available, the `workon` command will default to not logging the beginning of the work.
**Benefits**:
1. Boosts productivity by automating routine tasks.
2. Makes it easier to track the progress of the work.
3. Makes feature development more uniform and consistent.
**Usage**:
```
grokkit workon <todo_item_title> [[-m '<custom_commit_message>'] [-f]] | [-c]
```
**Arguments**:
- `<todo_item_title>`: The item to be worked on.
- If this is a todo, then there should be a Markdown entry in the queued folder with the same name, which will be moved to the doing folder. That should also be the name of the branch.
- If this is a fix, then use the argument as a branch name and as the name of a markdown file to be created, where the Work Plan will be stored in the "doing" folder.
- In both instances, If spaces are present, replace them with dashes.
- `-m '<custom_commit_message>'`: custom commit message to use for the branch. Default is "Start working on <todo_item_title>".
- `-f`: This signifies a "fix" rather than a todo item (the default is always a todo item)
- `-c`: "completes" the work item (note: cannot be used with other flags). This means:
- If it is a todo, it will be moved to the "completed" folder, and the index readme will be updated.
- If it is a fix, the work plan will be moved to the "completed folder". No index update is necessary.
- In either case, the update will be committed to the branch.
**High-level implementation**:
1. TBD