chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+211
View File
@@ -0,0 +1,211 @@
// Package agents defines the contract Gortex uses to wire itself into
// external AI coding assistants (Claude Code, Cursor, Kiro, Continue.dev,
// Cline, Windsurf, OpenCode, VS Code / Copilot, Antigravity, …).
//
// Each integration lives in its own sub-package and implements Adapter.
// `gortex init` iterates over a Registry of adapters and for each one
// calls Detect → Plan → Apply. The split lets CI exercise planning
// without touching disk (Plan + --dry-run) and makes every writer
// funnel through a single code path (writer.go), so atomic-rename,
// dry-run reporting, and golden-fixture testing stay uniform across
// agents.
package agents
import "io"
// Adapter is the contract every agent integration implements.
//
// Semantics:
// - Name is stable across releases; users reference it via
// --agents=<csv>. Lowercase kebab-case.
// - DocsURL points to the agent's *own* MCP/hook documentation
// (not Gortex docs) so --json consumers can trace what schema
// we're targeting.
// - Detect never modifies disk. False means "skip" — not an error.
// - Plan is pure: it returns what Apply *would* do for a given
// Env, without writing. Callers use it to power --dry-run and
// `gortex init doctor`.
// - Apply executes the plan. It must respect ApplyOpts.DryRun
// (return planned actions without writing) and ApplyOpts.Force
// (overwrite merge-preserved keys).
type Adapter interface {
Name() string
DocsURL() string
Detect(env Env) (bool, error)
Plan(env Env) (*Plan, error)
Apply(env Env, opts ApplyOpts) (*Result, error)
}
// Mode selects between per-repo and user-level installation.
// `gortex init` runs adapters in ModeProject; `gortex install` runs
// them in ModeGlobal. Adapters branch on this to choose between
// project-local paths (.mcp.json, .cursor/mcp.json, CLAUDE.md, …) and
// user-level paths (~/.claude.json, ~/.gemini/settings.json, …).
type Mode int
const (
// ModeProject writes project-local files (.mcp.json, .cursor/mcp.json, …).
// Used by `gortex init`.
ModeProject Mode = iota
// ModeGlobal writes user-level files (~/.claude.json, ~/.gemini/settings.json, …).
// Used by `gortex install`.
ModeGlobal
)
// Env bundles the inputs every adapter needs: where to write, which
// binary to reference in hook commands, user home for home-rooted
// integrations, and a stderr-like writer for progress messages. Test
// code swaps Stderr for a buffer to assert on messages without
// capturing process stderr.
type Env struct {
// Root is the absolute path to the repository. Adapters join
// relative paths under this.
Root string
// Home is the user's home directory, resolved once by the caller
// so adapters don't each call os.UserHomeDir().
Home string
// HookCommand is the shell command to bake into agent hook
// config (e.g. "/usr/local/bin/gortex hook"). Resolved once by
// the caller so every adapter writes the same string.
HookCommand string
// Mode is per-repo vs user-level. See ModeProject / ModeGlobal.
Mode Mode
// InstallHooks is false when the user passed --no-hooks or
// answered "no" to the wizard. Hook-capable adapters use it to
// skip their lifecycle hook surfaces while still installing MCP.
InstallHooks bool
// HookMode is the posture for the PreToolUse / PostToolUse hook
// integration: "deny" (default — redirect by deny on the
// PreToolUse side, no PostToolUse) or "enrich" (never deny,
// install PostToolUse that augments tool output with graph
// context). Empty falls back to "deny". Only the Claude Code
// adapter currently honours it; other adapters ignore the field.
HookMode string
// InstallGlobalInstructions toggles whether `gortex install`
// merges the rule block into ~/.claude/CLAUDE.md. Only honoured
// in ModeGlobal; ignored elsewhere. Default true so a fresh
// install delivers full enforcement; set false by --no-claude-md.
InstallGlobalInstructions bool
// InstructionsDir overrides where the generated instruction
// profiles (internal/profiles) are materialised and where the
// CLAUDE.md pointer block resolves its @-include. Empty means
// the machine default (profiles.DefaultDir()); tests set a temp
// directory for hermeticity.
InstructionsDir string
// AnalyzeRepo is true when the caller wants a dynamic CLAUDE.md
// preamble built from a fresh index. Only Claude Code uses it.
AnalyzeRepo bool
// AnalyzedOverview, if non-empty, is a pre-built dynamic
// CLAUDE.md preamble the Claude Code adapter will prepend to
// its static block. Indexing is driven by the caller to keep
// the agents package free of the indexer dependency.
AnalyzedOverview string
// SkillsRouting, if non-empty, is the pre-rendered
// community-routing block each adapter writes into its
// per-repo instructions surface between CommunitiesStartMarker
// and CommunitiesEndMarker. Callers set this after running the
// community generator; adapters treat it as an opaque markdown
// payload.
SkillsRouting string
// GeneratedSkills, if non-empty, lists per-community skill
// files. The Claude Code adapter materialises these under
// .claude/skills/generated/<DirName>/SKILL.md. Other adapters
// rely on SkillsRouting alone.
GeneratedSkills []GeneratedSkill
// Stderr receives progress messages. nil means discard.
Stderr io.Writer
}
// GeneratedSkill is a small, package-local mirror of the skills
// generator's output — kept here so the agents package doesn't
// depend on internal/skills. The caller populates this from
// internal/skills.Generator output.
type GeneratedSkill struct {
CommunityID string
Label string // kebab-case, e.g. "mcp-server"
DirName string // e.g. "gortex-mcp-server"
Content string // full SKILL.md content
}
// ApplyOpts controls Apply's runtime behaviour. Plan doesn't take
// these — plans are observational.
type ApplyOpts struct {
// DryRun reports what Apply *would* do without writing.
// Actions in the returned Result use Would* variants.
DryRun bool
// Force overwrites keys we would otherwise preserve during a
// merge (e.g. a custom autoApprove list the user edited). Does
// not bypass the "skip if already configured" fast path.
Force bool
// ForceDetect makes an adapter render its artifacts even when the
// target tool isn't detected on this machine. Used by the
// skill-render drift fence (`gortex agents render`) to exercise
// every adapter regardless of which agents are installed; never set
// during a normal `gortex init`.
ForceDetect bool
}
// ActionKind tags what happened (or would happen) for a single file.
// "would-*" variants are emitted under DryRun; plain "create" /
// "merge" / "skip" are emitted by Apply when it actually wrote.
type ActionKind string
const (
ActionCreate ActionKind = "create"
ActionMerge ActionKind = "merge"
ActionSkip ActionKind = "skip"
ActionDelete ActionKind = "delete"
ActionWouldCreate ActionKind = "would-create"
ActionWouldMerge ActionKind = "would-merge"
ActionWouldDelete ActionKind = "would-delete"
)
// FileAction describes one file write (real or planned). Keys is the
// set of top-level JSON/YAML/TOML keys touched during a merge — used
// by the doctor subcommand to diff intended vs actual state.
type FileAction struct {
Path string `json:"path"`
Action ActionKind `json:"action"`
Keys []string `json:"keys,omitempty"`
Reason string `json:"reason,omitempty"`
}
// Plan is the declarative output of Adapter.Plan. Every file the
// adapter *would* touch is listed with a predicted Action. For
// never-overwrite files the prediction is ActionWouldCreate when the
// file is absent and ActionSkip when it already exists.
type Plan struct {
Files []FileAction `json:"files,omitempty"`
}
// Result is what Apply returned. Detected mirrors what Detect
// returned (captured on the result so callers don't need to
// re-invoke detection). Configured is true when at least one write
// succeeded (or would succeed under DryRun). Warnings carries
// non-fatal problems an adapter chose to continue past — e.g. a single
// profile config that failed to write while the rest succeeded — so the
// summary / --json report can surface them instead of burying them in a
// stderr log line.
type Result struct {
Name string `json:"name"`
Detected bool `json:"detected"`
Configured bool `json:"configured"`
Files []FileAction `json:"files,omitempty"`
Warnings []string `json:"warnings,omitempty"`
DocsURL string `json:"docs_url,omitempty"`
}
+182
View File
@@ -0,0 +1,182 @@
// Package agentstest provides shared helpers for writing
// golden-fixture-style tests against Adapter implementations.
// Every adapter test follows the same three-phase contract:
//
// 1. Create — Apply into an empty tmpdir. Expect N file creations.
// 2. Idempotent — Apply a second time into the same tmpdir. Expect
// N skip actions (no writes, no merges).
// 3. Merge — Apply into a tmpdir that already contains a user file
// with unrelated keys. Expect our content to be added without
// clobbering the user's keys.
//
// The harness asserts on the agents.Result each phase returns so
// behaviour regressions surface with clear diffs rather than byte-
// level noise about map ordering.
package agentstest
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"sort"
"testing"
"github.com/zzet/gortex/internal/agents"
yaml "gopkg.in/yaml.v3"
)
// NewEnv returns an Env pointed at a temporary Root and Home.
// Stderr is a bytes.Buffer so tests can assert on progress lines;
// HookCommand is fixed so hook-writing adapters produce deterministic
// output.
func NewEnv(t *testing.T) (agents.Env, *bytes.Buffer) {
t.Helper()
root := t.TempDir()
home := t.TempDir()
buf := &bytes.Buffer{}
return agents.Env{
Root: root,
Home: home,
HookCommand: "/tmp/test-gortex hook",
Mode: agents.ModeProject,
InstallHooks: true,
// Hermetic instruction-profile dir: global installs generate
// profile files and read the active profile from here instead
// of the developer's real ~/.gortex.
InstructionsDir: filepath.Join(home, ".gortex", "instructions"),
// SkillsRouting + GeneratedSkills are seeded so per-adapter
// tests exercise the community-routing write path — which
// is the only reason adapters touch per-repo instructions
// files now. A real `gortex init` run sets these after
// community generation; NewEnv fakes them with minimal
// stubs so every adapter's test path stays realistic.
SkillsRouting: StubSkillsRouting,
GeneratedSkills: []agents.GeneratedSkill{{
CommunityID: "test",
Label: "stub",
DirName: "gortex-stub",
Content: "---\nname: gortex-stub\ndescription: test stub\n---\n# Stub community\n",
}},
Stderr: buf,
}, buf
}
// StubSkillsRouting is the fake routing payload NewEnv seeds. Kept
// small but with the marker-guarded wrapper contract preserved so
// tests that grep for the block shape stay realistic.
const StubSkillsRouting = `## Gortex Community Skills (test stub)
- stub: routes to gortex-stub/
`
// AssertCountsByAction fails the test when the result's action
// counts differ from the expected map. Used to declare "phase 1
// should produce 7 creates" in a readable way.
func AssertCountsByAction(t *testing.T, res *agents.Result, want map[agents.ActionKind]int) {
t.Helper()
got := make(map[agents.ActionKind]int)
for _, f := range res.Files {
got[f.Action]++
}
if len(got) != len(want) {
t.Fatalf("unexpected action kinds: got %v, want %v", got, want)
}
for k, v := range want {
if got[k] != v {
t.Fatalf("%s: got %d, want %d (full: got %v, want %v)", k, got[k], v, got, want)
}
}
}
// AssertIdempotent re-runs Apply against the same env and checks
// that every returned action is a skip. This is the contract every
// adapter must honour: re-running `gortex init` never rewrites files
// that are already configured.
func AssertIdempotent(t *testing.T, a agents.Adapter, env agents.Env) {
t.Helper()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("second Apply: %v", err)
}
for _, f := range res.Files {
if f.Action != agents.ActionSkip {
t.Fatalf("expected all skips on re-run, got %s for %s", f.Action, f.Path)
}
}
}
// ReadJSON parses a JSON file into a map — convenience for golden
// tests that want to assert on top-level keys without wrestling
// with the any type.
func ReadJSON(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var out map[string]any
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return out
}
// WriteJSON writes a pretty-printed map to path — for seeding
// "pre-populated" scenarios in merge tests.
func WriteJSON(t *testing.T, path string, obj map[string]any) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
data, err := json.MarshalIndent(obj, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
// ReadYAML parses a YAML file into a map — the YAML cousin of
// ReadJSON for adapters whose config lives in YAML (Hermes, Aider).
func ReadYAML(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var out map[string]any
if err := yaml.Unmarshal(data, &out); err != nil {
t.Fatalf("parse %s: %v", path, err)
}
return out
}
// WriteYAML writes obj to path as YAML — for seeding "pre-populated"
// scenarios in merge tests.
func WriteYAML(t *testing.T, path string, obj map[string]any) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
data, err := yaml.Marshal(obj)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
// SortedFilePaths returns the Paths from a result's Files in sorted
// order. Makes golden assertions invariant to adapter iteration
// order over maps.
func SortedFilePaths(res *agents.Result) []string {
out := make([]string, 0, len(res.Files))
for _, f := range res.Files {
out = append(out, f.Path)
}
sort.Strings(out)
return out
}
+167
View File
@@ -0,0 +1,167 @@
// Package aider implements the Gortex init integration for Aider.
// Aider's CLI does not speak MCP natively today; we support it
// through two lightweight artifacts:
//
// - .aiderignore additions pointing at Gortex cache directories
// so Aider doesn't re-index them on every chat.
// - A brief pointer block in .aider.conf.yml documenting how to
// call Gortex as an external tool from an Aider session.
//
// This adapter is intentionally thin — when Aider adds native MCP
// support (or when the community aider-mcp-server bridge stabilises)
// the integration can be expanded.
//
// Docs: https://aider.chat/docs/config/aider_conf.html
package aider
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "aider"
const DocsURL = "https://aider.chat/docs/config/aider_conf.html"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// aiderIgnoreLines is the set of cache paths Aider should never
// ingest as source. Keeping them out of the chat avoids wasting
// tokens on Gortex's own binary index and Bleve scorer data.
var aiderIgnoreLines = []string{
"# Added by `gortex init` — Gortex cache artifacts are not source",
".gortex/",
"*.gortex-cache",
}
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("aider"); err == nil && p != "" {
return true, nil
}
for _, p := range []string{
filepath.Join(env.Root, ".aider.conf.yml"),
filepath.Join(env.Root, ".aiderignore"),
} {
if _, err := os.Stat(p); err == nil {
return true, nil
}
}
if env.Home != "" {
if _, err := os.Stat(filepath.Join(env.Home, ".aider.conf.yml")); err == nil {
return true, nil
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{Files: []agents.FileAction{
{Path: filepath.Join(env.Root, ".aiderignore"), Action: agents.ActionWouldMerge, Keys: []string{"gortex-ignore-block"}},
}}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, "CONVENTIONS.md"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
// Aider has no user-level MCP surface today — all artifacts
// are per-repo, so ModeGlobal is a no-op.
if env.Mode == agents.ModeGlobal {
return res, nil
}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Aider setup (aider not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Aider integration...")
action, err := appendAiderIgnoreBlock(env.Stderr, filepath.Join(env.Root, ".aiderignore"), opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// CONVENTIONS.md is the file aider users canonically `/read`
// (or wire into .aider.conf.yml's `read:` list). Write a
// marker-guarded community-routing block there when skills
// were generated. Otherwise leave the file alone so we don't
// create an empty CONVENTIONS.md users have to delete.
if env.SkillsRouting != "" {
conventionsPath := filepath.Join(env.Root, "CONVENTIONS.md")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, conventionsPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
if routingAction.Action == agents.ActionCreate {
internalutil.Logf(env.Stderr, "[gortex init] add CONVENTIONS.md to your .aider.conf.yml `read:` list to load it on every aider session")
}
}
res.Configured = true
return res, nil
}
// appendAiderIgnoreBlock appends our lines to .aiderignore unless
// they're already present. We key idempotency on the comment
// sentinel — if a user ran init once already, they see it on line
// one of our block.
func appendAiderIgnoreBlock(w interface {
Write(p []byte) (n int, err error)
}, path string, opts agents.ApplyOpts) (agents.FileAction, error) {
existing, readErr := os.ReadFile(path)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return agents.FileAction{}, readErr
}
existed := readErr == nil
sentinel := aiderIgnoreLines[0]
if existed && strings.Contains(string(existing), sentinel) {
return agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "block-present"}, nil
}
if opts.DryRun {
action := agents.ActionWouldMerge
if !existed {
action = agents.ActionWouldCreate
}
return agents.FileAction{Path: path, Action: action}, nil
}
prefix := ""
if existed && len(existing) > 0 && !strings.HasSuffix(string(existing), "\n") {
prefix = "\n"
}
block := prefix + strings.Join(aiderIgnoreLines, "\n") + "\n"
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return agents.FileAction{}, err
}
defer f.Close()
if _, err := f.WriteString(block); err != nil {
return agents.FileAction{}, err
}
fmt.Fprintf(w, "[gortex init] appended Gortex ignore block to %s\n", path)
action := agents.ActionMerge
if !existed {
action = agents.ActionCreate
}
return agents.FileAction{Path: path, Action: action}, nil
}
+107
View File
@@ -0,0 +1,107 @@
package antigravity
import (
"fmt"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
// Name identifies the Antigravity adapter for --agents=<name>.
const Name = "antigravity"
// DocsURL is the current public docs entry point for MCP in
// Antigravity. As of 2026 Antigravity supports both MCP server
// registration (at ~/.gemini/antigravity/mcp_config.json) and the
// older Knowledge Item mechanism. We write both today: MCP for
// runtime tool access, KI for the in-editor instructions panel.
const DocsURL = "https://antigravity.google/docs/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect returns true whenever a Home directory is resolvable.
// Both artifacts are cheap to install and harmless on machines
// without Antigravity installed — a user who installs later picks
// up the config automatically. The Step 4 audit will tighten this
// to a proper install check once Antigravity ships a PATH-visible
// CLI.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
return env.Home != "", nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
if env.Home == "" {
return &agents.Plan{}, nil
}
kiDir := filepath.Join(env.Home, ".gemini", "antigravity", "knowledge", "gortex-workflow")
return &agents.Plan{Files: []agents.FileAction{
{Path: filepath.Join(env.Home, ".gemini", "antigravity", "mcp_config.json"), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers"}},
{Path: filepath.Join(env.Home, ".gemini", "settings.json"), Action: agents.ActionWouldMerge, Keys: []string{"hooks"}},
{Path: filepath.Join(kiDir, "metadata.json"), Action: agents.ActionWouldCreate},
{Path: filepath.Join(kiDir, "artifacts", "gortex-instructions.md"), Action: agents.ActionWouldCreate},
}}, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
return res, nil
}
if env.Home == "" {
return res, fmt.Errorf("antigravity: requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Antigravity integration...")
// 1. Native MCP registration — new in 2026.
mcpPath := filepath.Join(env.Home, ".gemini", "antigravity", "mcp_config.json")
mcpAction, err := agents.MergeJSON(env.Stderr, mcpPath, func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, mcpAction)
// 1b. Lifecycle hooks — Antigravity reads Gemini CLI's
// ~/.gemini/settings.json hooks, so install SessionStart +
// AfterTool there (the handler is agent-agnostic). If the Gemini
// adapter already added a gortex hook, UpsertGeminiHooks is a
// no-op, so the two adapters never double-register.
hooksPath := filepath.Join(env.Home, ".gemini", "settings.json")
hooksAction, err := agents.MergeJSON(env.Stderr, hooksPath, func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertGeminiHooks(root, Name, opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, hooksAction)
// 2. Knowledge Item — kept as a secondary artifact. Teaches
// Antigravity *how to use* Gortex via run_command, which is
// useful even when the MCP server is registered (the KI
// gives the model intent / workflow guidance that a plain
// tool registration doesn't).
kiDir := filepath.Join(env.Home, ".gemini", "antigravity", "knowledge", "gortex-workflow")
metaAction, err := agents.WriteIfNotExists(env.Stderr, filepath.Join(kiDir, "metadata.json"), Metadata, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, metaAction)
instrAction, err := agents.WriteIfNotExists(env.Stderr, filepath.Join(kiDir, "artifacts", "gortex-instructions.md"), Instructions, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, instrAction)
res.Configured = true
return res, nil
}
@@ -0,0 +1,54 @@
package antigravity
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestAntigravityRegistersMCPAndWritesKI is the acceptance test for
// the 2026 audit fix: we must now write *both* the native MCP
// config (new) and the Knowledge Item (existing). A regression to
// KI-only would silently remove the runtime tool access.
func TestAntigravityRegistersMCPAndWritesKI(t *testing.T) {
env, _ := agentstest.NewEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true")
}
// 1. Native MCP stanza under ~/.gemini/antigravity/mcp_config.json.
mcpPath := filepath.Join(env.Home, ".gemini", "antigravity", "mcp_config.json")
if _, err := os.Stat(mcpPath); err != nil {
t.Fatalf("mcp_config.json missing: %v", err)
}
cfg := agentstest.ReadJSON(t, mcpPath)
servers, ok := cfg["mcpServers"].(map[string]any)
if !ok {
t.Fatalf("mcpServers missing: %v", cfg)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex server missing from mcpServers: %v", servers)
}
// 2. Knowledge Item artifacts.
kiBase := filepath.Join(env.Home, ".gemini", "antigravity", "knowledge", "gortex-workflow")
for _, p := range []string{
filepath.Join(kiBase, "metadata.json"),
filepath.Join(kiBase, "artifacts", "gortex-instructions.md"),
} {
if _, err := os.Stat(p); err != nil {
t.Errorf("KI artifact missing: %s (%v)", p, err)
}
}
agentstest.AssertIdempotent(t, a, env)
}
+66
View File
@@ -0,0 +1,66 @@
// Package antigravity implements the Gortex init integration for
// Google's Antigravity. Today we write a Knowledge Item at
// ~/.gemini/antigravity/knowledge/gortex-workflow/ — the official
// documented mechanism for teaching Antigravity about project-
// specific tooling. The Step 3 audit will add true MCP registration
// alongside the KI once Antigravity's MCP config path is verified.
package antigravity
// Metadata is the KI manifest. Antigravity reads it to show the KI
// in its UI and to locate the artifact files it references.
const Metadata = `{
"summary": "MANDATORY: Instructions on how to use the local gortex engine CLI to significantly improve codebase intelligence. Antigravity must use run_command with gortex query over standard file read commands.",
"references": ["artifacts/gortex-instructions.md"]
}
`
// Instructions is the KI body — a Knowledge Item in Antigravity's
// frontmatter-first markdown format. Lists the run_command
// invocations Antigravity should prefer over grep_search / view_file.
const Instructions = `---
type: "Knowledge Item"
description: "Gortex Workflow and Tools for Antigravity"
---
# Gortex Engine Usage
This codebase is indexed by Gortex, an in-memory knowledge graph code intelligence engine.
**MANDATORY:** You MUST prioritize using the Gortex CLI via ` + "`run_command`" + ` instead of using standard text searches (` + "`grep_search`" + `) or full file reads (` + "`view_file`" + `). This drastically improves token efficiency and prevents hallucination.
## Setup
If the daemon is not tracking your target repos, register the current repo so the graph tools activate:
` + "```bash" + `
gortex track .
` + "```" + `
## Standard Workflow Translation
| Instead of... | You MUST use... (via ` + "`run_command`" + `) |
|---|---|
| ` + "`grep_search`" + ` to find a class or function | ` + "`./gortex query symbol <name> --format text`" + ` (AST-aware search) |
| ` + "`grep_search`" + ` to find all references | ` + "`./gortex query usages <id>`" + ` (zero false positives) |
| ` + "`view_file`" + ` to read a whole file to find a method | ` + "`./gortex query symbol <name>`" + ` or ` + "`./gortex query callers <func_id>`" + ` |
| Guessing what breaks during a refactor | ` + "`./gortex query dependents <id>`" + ` (impact analysis) |
| Creating circular dependencies | Evaluate ` + "`./gortex query deps <id>`" + ` first |
## Example Usage
### 1. View Architecture and Communities
` + "```bash" + `
./gortex query stats
` + "```" + `
### 2. Find specific symbol definition
` + "```bash" + `
./gortex query symbol MyController
` + "```" + `
### 3. Trace blast radius
If you are modifying ` + "`core/parser.go::Parse`" + `, check what will break:
` + "```bash" + `
./gortex query dependents core/parser.go::Parse --depth 2
` + "```" + `
This gives you perfectly accurate AST-level analysis, guaranteeing safe edits.
`
+837
View File
@@ -0,0 +1,837 @@
package claudecode
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/profiles"
)
// Name is the stable identifier for this adapter, matching the
// --agents=<name> CLI flag.
const Name = "claude-code"
// DocsURL is the page we point users at when something about our
// Claude Code integration surprises them. Used in the --json report.
const DocsURL = "https://docs.claude.com/en/docs/claude-code/overview"
// Adapter implements agents.Adapter for Claude Code.
type Adapter struct{}
// New returns the Claude Code adapter. Callers register it via
// `agents.Registry.Register(claudecode.New())`.
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect always returns true. Claude Code is the "home" agent for
// `gortex init` — a project may not be opened in Claude Code today
// but we always want the integration files on disk so the team's
// next contributor is set up. Other adapters gate on detection
// because their artifacts make no sense without the IDE installed.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
return true, nil
}
// Plan reports the full set of files the adapter would touch for
// the given Env. Mode branches between project (`gortex init`) and
// global (`gortex install`) surfaces; InstallHooks elides hook files
// without affecting anything else.
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if env.Mode == agents.ModeGlobal {
// User-level artifacts — machine-wide. Slash commands and
// curated Gortex tool-usage skills both belong here: they're
// codebase-agnostic, so duplicating them into every repo is
// wasted disk and drift risk.
p.Files = append(p.Files, agents.FileAction{Path: userClaudeJSONPath(env.Home), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers"}})
p.Files = append(p.Files, agents.FileAction{Path: userSettingsPath(env.Home), Action: agents.ActionWouldMerge, Keys: []string{"permissions"}})
if env.InstallHooks {
p.Files = append(p.Files, agents.FileAction{Path: userSettingsLocalPath(env.Home), Action: agents.ActionWouldMerge, Keys: []string{"hooks"}})
}
if env.InstallGlobalInstructions {
p.Files = append(p.Files, agents.FileAction{Path: userClaudeMdPath(env.Home), Action: agents.ActionWouldMerge, Keys: []string{"gortex-rules-block"}})
}
configDir := userClaudeConfigDir(env.Home)
if configDir != "" {
for name := range GlobalSkills {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(configDir, "skills", name, "SKILL.md"), Action: agents.ActionWouldCreate})
}
for name := range SlashCommands {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(configDir, "commands", name), Action: agents.ActionWouldCreate})
}
for name := range SubAgents {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(configDir, "agents", name), Action: agents.ActionWouldCreate})
}
}
return p, nil
}
// Project mode — only genuinely repo-specific artifacts. No
// tool-usage duplication: that lives at ~/.claude/skills/
// (installed by `gortex install`). CLAUDE.md gets a
// marker-guarded block only when --analyze or --skills produce
// codebase-derived content.
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".mcp.json"), Action: agents.ActionWouldCreate, Keys: []string{"mcpServers"}})
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".claude", "settings.json"), Action: agents.ActionWouldMerge, Keys: []string{"permissions"}})
if env.InstallHooks {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".claude", "settings.local.json"), Action: agents.ActionWouldMerge, Keys: []string{"hooks"}})
}
if env.AnalyzedOverview != "" || env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, "CLAUDE.md"), Action: agents.ActionWouldMerge, Keys: []string{"communities-block"}})
}
for _, s := range env.GeneratedSkills {
p.Files = append(p.Files, agents.FileAction{Path: filepath.Join(env.Root, ".claude", "skills", "generated", s.DirName, "SKILL.md"), Action: agents.ActionWouldCreate})
}
return p, nil
}
// Apply performs the actual writes. Errors mid-way do not abort the
// whole adapter — we log each failure and continue, matching the
// pre-refactor behaviour where Kiro/Cursor/etc. setup failures only
// emit warnings. Claude Code's .mcp.json, CLAUDE.md, and hook install
// are the exception: they're the core integration and propagate
// failures upward.
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
w := env.Stderr
res := &agents.Result{Name: Name, Detected: true, DocsURL: DocsURL}
if env.Mode == agents.ModeGlobal {
if err := a.applyGlobal(env, opts, res); err != nil {
return res, err
}
res.Configured = true
return res, nil
}
// 1. Project .mcp.json — create if absent, skip otherwise.
//
// If gortex is already registered at user scope (~/.claude.json), a
// project .mcp.json adds a second registration under the same name.
// Claude Code keys OAuth tokens per endpoint and flags this as a
// "conflicting scopes" diagnostic. The user-scope entry already
// serves this repo machine-wide, so skip the project file unless
// --force. (A pre-existing .mcp.json is left in place — we never
// delete it — but we warn so the user can resolve the duplication.)
mcpPath := filepath.Join(env.Root, ".mcp.json")
if !opts.Force && env.Home != "" && userScopeGortexRegistered(env.Home) && !pathExists(mcpPath) {
logWarn(w, "gortex is already registered at user scope (%s); skipping project .mcp.json to avoid a Claude Code \"conflicting scopes\" warning. Re-run with --force to write it anyway (e.g. for teammates without a global install).", userClaudeJSONPath(env.Home))
res.Files = append(res.Files, agents.FileAction{Path: mcpPath, Action: agents.ActionSkip, Reason: "gortex already registered at user scope"})
} else {
if !opts.Force && env.Home != "" && userScopeGortexRegistered(env.Home) && pathExists(mcpPath) {
logWarn(w, "gortex is registered at both user scope (%s) and project scope (%s); Claude Code may warn about conflicting scopes — keep one with `claude mcp remove gortex -s user` or `-s project`.", userClaudeJSONPath(env.Home), mcpPath)
}
mcpAction, err := agents.WriteIfNotExists(w, mcpPath, ProjectMCPJSON, opts)
if err != nil {
return res, fmt.Errorf(".mcp.json: %w", err)
}
res.Files = append(res.Files, mcpAction)
}
// 2. MCP permissions in .claude/settings.json — merge, not create.
permAction, err := installPermissions(w, filepath.Join(env.Root, ".claude", "settings.json"), opts)
if err != nil {
logWarn(w, "could not install permissions: %v", err)
}
res.Files = append(res.Files, permAction)
// 3. Hooks in .claude/settings.local.json — merge with healing.
if env.InstallHooks {
hookAction, err := InstallHookWithMode(w, filepath.Join(env.Root, ".claude", "settings.local.json"), env.HookMode, opts)
if err != nil {
logWarn(w, "could not install hook: %v", err)
}
res.Files = append(res.Files, hookAction)
} else {
logf(w, "[gortex init] skipping hook installation (--no-hooks)")
}
// 4. CLAUDE.md — only written when there's genuinely
// codebase-specific content to place there: either the
// --analyze overview, the --skills community routing, or both.
// Generic tool-usage moved to user-level ~/.claude/skills/
// (installed by `gortex install`).
if env.AnalyzedOverview != "" || env.SkillsRouting != "" {
claudeMdPath := filepath.Join(env.Root, "CLAUDE.md")
var body strings.Builder
if env.AnalyzedOverview != "" {
body.WriteString(env.AnalyzedOverview)
if !strings.HasSuffix(env.AnalyzedOverview, "\n") {
body.WriteString("\n")
}
}
if env.SkillsRouting != "" {
if body.Len() > 0 {
body.WriteString("\n")
}
body.WriteString(env.SkillsRouting)
}
claudeAction, err := agents.UpsertMarkedBlock(w, claudeMdPath, body.String(),
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, fmt.Errorf("CLAUDE.md: %w", err)
}
res.Files = append(res.Files, claudeAction)
}
// 5. Generated community skills — per-community SKILL.md files
// under .claude/skills/generated/. Claude Code auto-discovers
// them next to the repo-local CLAUDE.md. Regenerated each init
// run so they track the current graph.
for _, s := range env.GeneratedSkills {
path := filepath.Join(env.Root, ".claude", "skills", "generated", s.DirName, "SKILL.md")
action, err := agents.WriteOwnedFile(w, path, s.Content, opts)
if err != nil {
logWarn(w, "could not write generated skill %s: %v", s.DirName, err)
continue
}
res.Files = append(res.Files, action)
}
res.Configured = true
return res, nil
}
// applyGlobal handles Mode=ModeGlobal writes (entered via `gortex
// install`). Everything here is codebase-agnostic user-level
// machinery: MCP config pointing at `gortex mcp`, user-level
// hooks, curated Gortex tool-usage skills, and Gortex slash
// commands. No per-repo artifacts.
func (a *Adapter) applyGlobal(env agents.Env, opts agents.ApplyOpts, res *agents.Result) error {
w := env.Stderr
if env.Home == "" {
return fmt.Errorf("global mode requires a resolved home directory")
}
// 1. ~/.claude.json — MCP stanza pointing at `gortex mcp`.
mcpPath := userClaudeJSONPath(env.Home)
action, err := upsertGlobalMCPConfig(w, mcpPath, opts)
if err != nil {
return fmt.Errorf("global MCP config: %w", err)
}
res.Files = append(res.Files, action)
// 2. ~/.claude/settings.json — user-level MCP permission allowlist.
// Mirrors the project-mode call so `mcp__gortex__*` is auto-allowed
// machine-wide; without this every Gortex tool call shows an
// approval prompt until the user adds the rule by hand.
permAction, err := installPermissions(w, userSettingsPath(env.Home), opts)
if err != nil {
logWarn(w, "could not install global permissions: %v", err)
}
res.Files = append(res.Files, permAction)
// 3. ~/.claude/settings.local.json — user-level hooks.
if env.InstallHooks {
hookAction, err := InstallHookWithMode(w, userSettingsLocalPath(env.Home), env.HookMode, opts)
if err != nil {
return fmt.Errorf("global hooks: %w", err)
}
res.Files = append(res.Files, hookAction)
}
// 4. Instruction profiles + ~/.claude/CLAUDE.md pointer block.
// The generated profiles live under the gortex home; CLAUDE.md
// carries only a thin marker-fenced block that @-includes the
// active profile, so `gortex instructions switch` changes
// guidance depth without ever rewriting CLAUDE.md. Without the
// block, the rule only surfaces at deny-time (PreToolUse) which
// is late: the agent has already wasted a turn on a forbidden
// tool.
insDir := instructionsDir(env)
if env.InstallGlobalInstructions {
insAction := agents.FileAction{Path: insDir, Action: agents.ActionMerge, Keys: []string{"instruction-profiles"}}
if opts.DryRun {
insAction.Action = agents.ActionWouldMerge
res.Files = append(res.Files, insAction)
} else if err := profiles.Generate(insDir); err != nil {
logWarn(w, "could not generate instruction profiles: %v", err)
} else {
logf(w, "[gortex install] wrote instruction profiles to %s", insDir)
res.Files = append(res.Files, insAction)
}
claudeMdPath := userClaudeMdPath(env.Home)
mdAction, err := agents.UpsertMarkedBlock(w, claudeMdPath, agents.GlobalPointerBody(insDir),
agents.GlobalRulesStartMarker, agents.GlobalRulesEndMarker, opts)
if err != nil {
logWarn(w, "could not install global CLAUDE.md: %v", err)
} else {
logf(w, "[gortex install] wrote rule block to %s", claudeMdPath)
}
// UpsertMarkedBlock is shared with the per-repo communities
// block, so it labels every action with "communities-block".
// Relabel here so the install report distinguishes the two.
if mdAction.Keys != nil {
mdAction.Keys = []string{"gortex-rules-block"}
}
res.Files = append(res.Files, mdAction)
}
// 3. ~/.claude/skills/gortex-*/SKILL.md — curated tool-usage
// skills, reconciled against the active instruction profile's
// subset (nil = all shipped skills). Existing files are never
// overwritten so user edits survive; out-of-profile skills are
// removed only when they still match a shipped body.
skillActions, err := SyncGlobalSkills(w, env.Home, profiles.Active(insDir).Skills, opts)
if err != nil {
logWarn(w, "could not install user-level skills: %v", err)
}
res.Files = append(res.Files, skillActions...)
// 4. ~/.claude/commands/gortex-*.md — slash commands, also
// codebase-agnostic and user-level. Claude Code discovers
// user-level commands alongside project-level ones.
cmdActions, err := installGlobalSlashCommands(w, env.Home, opts)
if err != nil {
logWarn(w, "could not install user-level slash commands: %v", err)
}
res.Files = append(res.Files, cmdActions...)
// 5. ~/.claude/agents/gortex-*.md — sub-agent definitions. Claude
// Code auto-routes user prompts to sub-agents based on their
// frontmatter description, so shipping these gives gortex a
// delegation surface beyond skills + slash commands. The tool
// allowlist in each file pins sub-agents to gortex graph tools
// only — Bash / Grep / Glob are unavailable by construction.
agentActions, err := installGlobalSubAgents(w, env.Home, opts)
if err != nil {
logWarn(w, "could not install user-level sub-agents: %v", err)
}
res.Files = append(res.Files, agentActions...)
return nil
}
// RemoveGlobal undoes applyGlobal: it strips the Gortex footprint
// from the user-level Claude Code config — the MCP server stanza, the
// permission allow-entry, the hook entries, the CLAUDE.md rule block,
// and the curated skills / commands / sub-agents. Merged files keep
// every non-Gortex key (other MCP servers, the user's own
// permissions / hooks, hand-written CLAUDE.md prose); owned files
// (skills / commands / agents) are deleted outright. The config root
// honors the --claude-config-dir override / $CLAUDE_CONFIG_DIR /
// the ~/.claude default. Returns the number of artifacts
// removed-or-cleaned and any per-artifact failures — a partial clean
// still reports rather than aborting. Invoked by `gortex uninstall
// --global`.
func (a *Adapter) RemoveGlobal(env agents.Env, opts agents.ApplyOpts) (removed int, failures []string) {
w := env.Stderr
if env.Home == "" {
return 0, []string{"global cleanup requires a resolved home directory"}
}
count := func(action agents.FileAction, err error, label string) {
if err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", label, err))
return
}
if action.Action != agents.ActionSkip {
removed++
}
}
// 1. ~/.claude.json — drop the "gortex" MCP server stanza.
mcpPath := userClaudeJSONPath(env.Home)
mcpAction, err := removeGlobalMCPConfig(w, mcpPath, opts)
count(mcpAction, err, mcpPath)
// 2. settings.json — drop the mcp__gortex__* permission entry.
settingsPath := userSettingsPath(env.Home)
permAction, err := removeGlobalPermissions(w, settingsPath, opts)
count(permAction, err, settingsPath)
// 3. settings.local.json — drop the Gortex hook entries.
localPath := userSettingsLocalPath(env.Home)
hookAction, err := removeGlobalHooks(w, localPath, opts)
count(hookAction, err, localPath)
// 4. CLAUDE.md — strip the marker-fenced rule block. An empty
// body makes UpsertMarkedBlock remove the block in place,
// preserving any surrounding user prose.
mdPath := userClaudeMdPath(env.Home)
mdAction, err := agents.UpsertMarkedBlock(w, mdPath, "",
agents.GlobalRulesStartMarker, agents.GlobalRulesEndMarker, opts)
count(mdAction, err, mdPath)
// 5. skills / commands / agents — delete the owned files.
ownedRemoved, ownedFailures := removeGlobalOwnedFiles(w, env.Home, opts)
removed += ownedRemoved
failures = append(failures, ownedFailures...)
// 6. Generated instruction profiles — gortex-owned generated
// files (plus the tiny active-state record), safe to delete.
insDir := instructionsDir(env)
if _, err := os.Stat(insDir); err == nil {
if opts.DryRun {
removed++
} else if err := profiles.Remove(insDir); err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", insDir, err))
} else {
logf(w, "[gortex] removed instruction profiles at %s", insDir)
removed++
}
}
return removed, failures
}
// GlobalArtifacts returns the user-level Claude Code paths gortex
// install manages that currently carry a Gortex footprint — the MCP
// config, settings files, and CLAUDE.md when they contain Gortex
// entries, plus every installed skill / command / sub-agent. Sorted
// for stable output. The config root honors the --claude-config-dir
// override / $CLAUDE_CONFIG_DIR. Used by `gortex uninstall --global`
// to preview the blast radius before deleting.
func GlobalArtifacts(home string) []string {
configDir := userClaudeConfigDir(home)
var present []string
// Merged files: list only when they actually carry a Gortex
// footprint, so the preview (and its count) matches what
// RemoveGlobal will really touch.
if fileContains(userClaudeJSONPath(home), `"gortex"`) {
present = append(present, userClaudeJSONPath(home))
}
if fileContains(userSettingsPath(home), "mcp__gortex__") {
present = append(present, userSettingsPath(home))
}
if fileContains(userSettingsLocalPath(home), "gortex") {
present = append(present, userSettingsLocalPath(home))
}
if fileContains(userClaudeMdPath(home), agents.GlobalRulesStartMarker) {
present = append(present, userClaudeMdPath(home))
}
// Owned files: present on disk == installed by us.
for name := range GlobalSkills {
if p := filepath.Join(configDir, "skills", name); pathExists(p) {
present = append(present, p)
}
}
for name := range SlashCommands {
if p := filepath.Join(configDir, "commands", name); pathExists(p) {
present = append(present, p)
}
}
for name := range SubAgents {
if p := filepath.Join(configDir, "agents", name); pathExists(p) {
present = append(present, p)
}
}
sort.Strings(present)
return present
}
// removeGlobalMCPConfig drops the gortex MCP server from a
// {"mcpServers": {...}} config, leaving the user's other servers
// intact. A missing file or absent stanza is a no-op skip.
func removeGlobalMCPConfig(w io.Writer, path string, opts agents.ApplyOpts) (agents.FileAction, error) {
return agents.MergeJSON(w, path, func(root map[string]any, _ bool) (bool, error) {
return agents.RemoveMCPServer(root, "gortex"), nil
}, opts)
}
// removeGlobalPermissions drops the mcp__gortex__* entry from
// permissions.allow, pruning the allow list / permissions map when
// removal leaves them empty. User-added allow entries survive.
func removeGlobalPermissions(w io.Writer, settingsPath string, opts agents.ApplyOpts) (agents.FileAction, error) {
return agents.MergeJSON(w, settingsPath, func(settings map[string]any, _ bool) (bool, error) {
perms, ok := settings["permissions"].(map[string]any)
if !ok {
return false, nil
}
allow, ok := perms["allow"].([]any)
if !ok {
return false, nil
}
kept := make([]any, 0, len(allow))
removedAny := false
for _, entry := range allow {
if s, ok := entry.(string); ok && strings.Contains(s, "mcp__gortex__") {
removedAny = true
continue
}
kept = append(kept, entry)
}
if !removedAny {
return false, nil
}
if len(kept) == 0 {
delete(perms, "allow")
} else {
perms["allow"] = kept
}
if len(perms) == 0 {
delete(settings, "permissions")
}
return true, nil
}, opts)
}
// removeGlobalHooks drops every Gortex-owned hook entry across the
// events the installer writes, pruning the hooks map when removal
// leaves it empty. Hooks owned by other tools survive.
func removeGlobalHooks(w io.Writer, settingsPath string, opts agents.ApplyOpts) (agents.FileAction, error) {
return agents.MergeJSON(w, settingsPath, func(settings map[string]any, _ bool) (bool, error) {
hooks, ok := settings["hooks"].(map[string]any)
if !ok {
return false, nil
}
removed := 0
for _, event := range []string{"PreToolUse", "PreCompact", "PostToolUse", "Stop", "SessionStart", "UserPromptSubmit"} {
removed += removeGortexHookEntries(hooks, event)
}
if removed == 0 {
return false, nil
}
if len(hooks) == 0 {
delete(settings, "hooks")
}
return true, nil
}, opts)
}
// removeGlobalOwnedFiles deletes the user-level skills / commands /
// sub-agents the installer owns. Skills are directories
// ($DIR/skills/<name>/); commands and agents are single files. A
// not-yet-installed artifact is silently skipped; DryRun counts the
// target without touching disk.
func removeGlobalOwnedFiles(w io.Writer, home string, opts agents.ApplyOpts) (removed int, failures []string) {
configDir := userClaudeConfigDir(home)
type target struct {
path string
dir bool
}
var targets []target
for name := range GlobalSkills {
targets = append(targets, target{filepath.Join(configDir, "skills", name), true})
}
for name := range SlashCommands {
targets = append(targets, target{filepath.Join(configDir, "commands", name), false})
}
for name := range SubAgents {
targets = append(targets, target{filepath.Join(configDir, "agents", name), false})
}
for _, t := range targets {
if !pathExists(t.path) {
continue // not installed — nothing to remove
}
if opts.DryRun {
removed++
continue
}
var err error
if t.dir {
err = os.RemoveAll(t.path)
} else {
err = os.Remove(t.path)
}
if err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", t.path, err))
continue
}
logf(w, "[gortex uninstall] removed %s", t.path)
removed++
}
return removed, failures
}
func fileContains(path, needle string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
return strings.Contains(string(data), needle)
}
func pathExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// installPermissions merges an {"permissions": {"allow":
// ["mcp__gortex__*"]}} stanza into settings.json. Preserves any
// user-added entries; short-circuits when a gortex rule is already
// present.
func installPermissions(w io.Writer, settingsPath string, opts agents.ApplyOpts) (agents.FileAction, error) {
return agents.MergeJSON(w, settingsPath, func(settings map[string]any, _ bool) (bool, error) {
// Bail early if a gortex rule is already present.
if perms, ok := settings["permissions"].(map[string]any); ok {
if allow, ok := perms["allow"].([]any); ok {
for _, entry := range allow {
if s, ok := entry.(string); ok && strings.Contains(s, "mcp__gortex__") {
return false, nil
}
}
}
}
if _, ok := settings["permissions"]; !ok {
settings["permissions"] = make(map[string]any)
}
perms := settings["permissions"].(map[string]any)
if _, ok := perms["allow"]; !ok {
perms["allow"] = []any{}
}
allow := perms["allow"].([]any)
perms["allow"] = append(allow, "mcp__gortex__*")
return true, nil
}, opts)
}
// instructionsDir resolves where the generated instruction profiles
// live for this install run: the Env override (tests) or the machine
// default shared with the daemon and the `gortex instructions` verb.
func instructionsDir(env agents.Env) string {
if env.InstructionsDir != "" {
return env.InstructionsDir
}
return profiles.DefaultDir()
}
// SyncGlobalSkills reconciles ~/.claude/skills/gortex-* with the
// allowed subset (nil = every shipped skill):
//
// - allowed skills are installed when missing; an existing file is
// never overwritten, so user edits survive;
// - shipped skills OUTSIDE the subset are removed only when their
// on-disk SKILL.md is still byte-identical to the shipped body —
// a customised copy is kept (ActionSkip) with a warning, because
// deleting user work to enforce a profile is never worth it.
//
// Also used by `gortex instructions switch` to re-shape the installed
// skill surface when the active profile changes.
func SyncGlobalSkills(w io.Writer, home string, allowed []string, opts agents.ApplyOpts) ([]agents.FileAction, error) {
var allowedSet map[string]bool
if allowed != nil {
allowedSet = make(map[string]bool, len(allowed))
for _, name := range allowed {
allowedSet[name] = true
}
}
out := make([]agents.FileAction, 0, len(GlobalSkills))
skillsDir := filepath.Join(userClaudeConfigDir(home), "skills")
for name, content := range GlobalSkills {
dir := filepath.Join(skillsDir, name)
path := filepath.Join(dir, "SKILL.md")
if allowedSet != nil && !allowedSet[name] {
existing, err := os.ReadFile(path)
if err != nil {
// Not installed (or unreadable): nothing to prune.
out = append(out, agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "outside-profile"})
continue
}
if string(existing) != content {
logWarn(w, "keeping customised skill %s (outside the active profile)", path)
out = append(out, agents.FileAction{Path: path, Action: agents.ActionSkip, Reason: "customised"})
continue
}
if opts.DryRun {
out = append(out, agents.FileAction{Path: path, Action: agents.ActionWouldDelete, Keys: []string{"skill"}})
continue
}
if err := os.RemoveAll(dir); err != nil {
return out, err
}
logf(w, "[gortex] removed skill %s (outside the active profile)", path)
out = append(out, agents.FileAction{Path: path, Action: agents.ActionDelete, Keys: []string{"skill"}})
continue
}
action, err := agents.WriteIfNotExists(w, path, content, opts)
if err != nil {
return out, err
}
out = append(out, action)
}
return out, nil
}
// installGlobalSlashCommands writes ~/.claude/commands/gortex-*.md
// for each entry in SlashCommands. Skips existing files so users
// keep any local tweaks. Mirrors installGlobalSkills — both are
// user-level, codebase-agnostic artifacts installed by
// `gortex install`.
func installGlobalSlashCommands(w io.Writer, home string, opts agents.ApplyOpts) ([]agents.FileAction, error) {
out := make([]agents.FileAction, 0, len(SlashCommands))
dir := filepath.Join(userClaudeConfigDir(home), "commands")
for name, content := range SlashCommands {
path := filepath.Join(dir, name)
action, err := agents.WriteIfNotExists(w, path, content, opts)
if err != nil {
return out, err
}
out = append(out, action)
}
return out, nil
}
// installGlobalSubAgents writes ~/.claude/agents/gortex-*.md for each
// entry in SubAgents. Skips existing files so user tweaks to the
// frontmatter (description tuning, tool allowlist edits) survive
// re-installs. Mirrors installGlobalSkills / installGlobalSlashCommands.
func installGlobalSubAgents(w io.Writer, home string, opts agents.ApplyOpts) ([]agents.FileAction, error) {
out := make([]agents.FileAction, 0, len(SubAgents))
dir := filepath.Join(userClaudeConfigDir(home), "agents")
for name, content := range SubAgents {
path := filepath.Join(dir, name)
action, err := agents.WriteIfNotExists(w, path, content, opts)
if err != nil {
return out, err
}
out = append(out, action)
}
return out, nil
}
// upsertGlobalMCPConfig is the user-level (~/.claude.json) MCP stanza
// installer. Unlike the project-level .mcp.json we never write from
// scratch with a static string here — we always merge so we don't
// clobber a user's other MCP servers or their permissions. If the
// existing file is malformed JSON, it's backed up before we
// overwrite.
func upsertGlobalMCPConfig(w io.Writer, path string, opts agents.ApplyOpts) (agents.FileAction, error) {
// Prefer the bare "gortex" command when it resolves on PATH to the
// binary we're running, so this user-scope entry matches the
// portable project .mcp.json template byte-for-byte. Claude Code
// keys OAuth tokens per endpoint, so a user-scope stanza that
// disagrees with a project-scope one trips its "conflicting scopes"
// diagnostic. Falls back to the absolute path only when gortex is
// not on PATH (e.g. a Windows install whose dir isn't on PATH).
entry := map[string]any{
"command": agents.ResolveGortexCommand(),
"args": []string{"mcp"},
"env": map[string]any{},
}
// Try a direct merge first. MergeJSON handles malformed JSON with a
// timestamped backup already. UpsertMCPServerWithMigration rewrites
// a stale Gortex-authored stanza (including the older absolute-path
// form) in place without clobbering a user's hand-rolled wrapper.
action, err := agents.MergeJSON(w, path, func(root map[string]any, existed bool) (bool, error) {
_ = existed
return agents.UpsertMCPServerWithMigration(root, "gortex", entry, agents.ApplyOpts{Force: opts.Force}), nil
}, opts)
if err != nil {
return agents.FileAction{}, err
}
// Historical behaviour: if the file existed but was malformed,
// rename it to a .bak-<ts> to preserve the original. MergeJSON
// uses a plain ".bak" name; we mirror the timestamp-suffix
// convention for global mode because the user is more likely
// to have edited ~/.claude.json than a project .mcp.json.
if existing, statErr := os.Stat(path + ".bak"); statErr == nil && !existing.IsDir() {
if err := os.Rename(path+".bak", fmt.Sprintf("%s.bak-%d", path, time.Now().Unix())); err != nil {
logWarn(w, "could not timestamp malformed-config backup: %v", err)
}
}
return action, nil
}
// userScopeGortexRegistered reports whether ~/.claude.json already
// registers a "gortex" MCP server at user scope. A project .mcp.json
// written on top of that produces a second registration under the same
// name, which Claude Code flags as a "conflicting scopes" warning
// because it stores OAuth tokens per endpoint. A missing or malformed
// file is treated as "not registered" — we only suppress the project
// write on positive evidence of a user-scope entry.
func userScopeGortexRegistered(home string) bool {
data, err := os.ReadFile(userClaudeJSONPath(home))
if err != nil {
return false
}
var root map[string]any
if err := json.Unmarshal(data, &root); err != nil {
return false
}
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
return false
}
_, ok = servers["gortex"]
return ok
}
// Paths — user-level files.
func userClaudeJSONPath(home string) string {
if dir := claudeConfigDirOverride(); dir != "" {
return filepath.Join(dir, ".claude.json")
}
return filepath.Join(home, ".claude.json")
}
func userSettingsLocalPath(home string) string {
return filepath.Join(userClaudeConfigDir(home), "settings.local.json")
}
// userSettingsPath is the user-level counterpart to
// `.claude/settings.json` in a project. Permissions live here (not
// in settings.local.json) so they survive when the user wipes the
// "local" overrides file.
func userSettingsPath(home string) string {
return filepath.Join(userClaudeConfigDir(home), "settings.json")
}
// userClaudeMdPath is the machine-wide CLAUDE.md Claude Code reads on
// every session, regardless of cwd. We merge a marker-fenced rule
// block into it so the agent sees the Gortex rules from turn one.
func userClaudeMdPath(home string) string {
return filepath.Join(userClaudeConfigDir(home), "CLAUDE.md")
}
// UserClaudeMdPath is the resolved user-level CLAUDE.md path Claude
// Code reads every session, honoring the --claude-config-dir override
// / $CLAUDE_CONFIG_DIR / the ~/.claude default. Exported so the
// installer banner names the real destination instead of a hardcoded
// ~/.claude path.
func UserClaudeMdPath(home string) string { return userClaudeMdPath(home) }
func userClaudeConfigDir(home string) string {
if dir := claudeConfigDirOverride(); dir != "" {
return dir
}
return filepath.Join(home, ".claude")
}
// configDirOverride, when non-empty, pins the Claude Code config root
// for the rest of the process regardless of $CLAUDE_CONFIG_DIR. It is
// set by `gortex install --claude-config-dir` / `gortex uninstall
// --global --claude-config-dir` so an operator can target a
// non-active profile or a CI sandbox without exporting the env var.
// Precedence: flag override > $CLAUDE_CONFIG_DIR > ~/.claude default.
var configDirOverride string
// SetConfigDirOverride pins the Claude Code config root for the rest
// of this process. An empty string clears the override so the env var
// / default resume.
func SetConfigDirOverride(dir string) { configDirOverride = strings.TrimSpace(dir) }
func claudeConfigDirOverride() string {
if configDirOverride != "" {
return configDirOverride
}
return strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR"))
}
func logf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, format+"\n", args...)
}
func logWarn(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, "[gortex init] warning: "+format+"\n", args...)
}
+504
View File
@@ -0,0 +1,504 @@
package claudecode
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestClaudeCodeProjectModeCreatesCanonicalArtifacts is the
// acceptance test for the most important adapter. It asserts that
// a fresh project gets:
// - .mcp.json with our server stanza
// - .claude/settings.json with MCP permissions
// - .claude/settings.local.json with the three hook events
// - CLAUDE.md with the marker-guarded communities block (since
// the test env seeds SkillsRouting)
// - .claude/skills/generated/<DirName>/SKILL.md (one per
// GeneratedSkill)
//
// Slash commands and the curated GlobalSkills are NOT written in
// project mode anymore — they're user-level artifacts installed by
// `gortex install`. TestClaudeCodeGlobalModeWritesUserFiles covers
// those.
//
// Re-running must be a no-op (idempotent contract).
func TestClaudeCodeProjectModeCreatesCanonicalArtifacts(t *testing.T) {
env, _ := agentstest.NewEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true")
}
expected := []string{
filepath.Join(env.Root, ".mcp.json"),
filepath.Join(env.Root, ".claude", "settings.json"),
filepath.Join(env.Root, ".claude", "settings.local.json"),
filepath.Join(env.Root, "CLAUDE.md"),
}
for _, s := range env.GeneratedSkills {
expected = append(expected, filepath.Join(env.Root, ".claude", "skills", "generated", s.DirName, "SKILL.md"))
}
for _, p := range expected {
if _, err := os.Stat(p); err != nil {
t.Errorf("missing artifact %s: %v", p, err)
}
}
// Project-mode must NOT touch the user-level slash commands or
// curated skills — those live in install mode now.
for name := range SlashCommands {
if _, err := os.Stat(filepath.Join(env.Home, ".claude", "commands", name)); err == nil {
t.Errorf("project mode unexpectedly wrote user-level slash command %s", name)
}
if _, err := os.Stat(filepath.Join(env.Root, ".claude", "commands", name)); err == nil {
t.Errorf("project mode unexpectedly wrote project-level slash command %s", name)
}
}
for name := range GlobalSkills {
if _, err := os.Stat(filepath.Join(env.Home, ".claude", "skills", name, "SKILL.md")); err == nil {
t.Errorf("project mode unexpectedly wrote user-level skill %s", name)
}
}
for name := range SubAgents {
if _, err := os.Stat(filepath.Join(env.Home, ".claude", "agents", name)); err == nil {
t.Errorf("project mode unexpectedly wrote user-level sub-agent %s", name)
}
if _, err := os.Stat(filepath.Join(env.Root, ".claude", "agents", name)); err == nil {
t.Errorf("project mode unexpectedly wrote project-level sub-agent %s", name)
}
}
// CLAUDE.md must contain the communities-block markers (since
// the stub SkillsRouting routes through UpsertMarkedBlock).
claudeMd, _ := os.ReadFile(filepath.Join(env.Root, "CLAUDE.md"))
if !strings.Contains(string(claudeMd), agents.CommunitiesStartMarker) {
t.Fatalf("CLAUDE.md missing communities start marker: %s", claudeMd)
}
// Hooks file must reference our test hook command.
hooksFile, _ := os.ReadFile(filepath.Join(env.Root, ".claude", "settings.local.json"))
if !strings.Contains(string(hooksFile), "PreToolUse") {
t.Fatalf("settings.local.json missing PreToolUse: %s", hooksFile)
}
// Idempotent re-run: every file should report skip.
res2, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("second apply: %v", err)
}
for _, f := range res2.Files {
if f.Action != agents.ActionSkip {
t.Errorf("expected skip on re-run for %s, got %s", f.Path, f.Action)
}
}
}
// TestClaudeCodeGlobalModeWritesUserFiles verifies that global mode
// (entered via `gortex install`) writes to ~/.claude.json, user-level
// hooks, and the user-level slash-commands + curated skills trees,
// while leaving the per-repo tree alone.
func TestClaudeCodeGlobalModeWritesUserFiles(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = true
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true in global mode")
}
// User-level files exist.
expected := []string{
filepath.Join(env.Home, ".claude.json"),
filepath.Join(env.Home, ".claude", "settings.json"),
filepath.Join(env.Home, ".claude", "settings.local.json"),
}
for name := range SlashCommands {
expected = append(expected, filepath.Join(env.Home, ".claude", "commands", name))
}
for name := range GlobalSkills {
expected = append(expected, filepath.Join(env.Home, ".claude", "skills", name, "SKILL.md"))
}
for name := range SubAgents {
expected = append(expected, filepath.Join(env.Home, ".claude", "agents", name))
}
for _, p := range expected {
if _, err := os.Stat(p); err != nil {
t.Errorf("missing user-level artifact %s: %v", p, err)
}
}
// settings.json must contain the mcp__gortex__* permission rule
// so MCP tool calls don't prompt for approval each session.
settingsPath := filepath.Join(env.Home, ".claude", "settings.json")
body, err := os.ReadFile(settingsPath)
if err != nil {
t.Fatalf("read %s: %v", settingsPath, err)
}
if !strings.Contains(string(body), "mcp__gortex__*") {
t.Errorf("expected mcp__gortex__* in %s, got:\n%s", settingsPath, body)
}
// CLAUDE.md must contain the marker-fenced rule block when
// InstallGlobalInstructions is true.
claudeMd := filepath.Join(env.Home, ".claude", "CLAUDE.md")
mdBody, err := os.ReadFile(claudeMd)
if err != nil {
t.Fatalf("read %s: %v", claudeMd, err)
}
if !strings.Contains(string(mdBody), agents.GlobalRulesStartMarker) ||
!strings.Contains(string(mdBody), agents.GlobalRulesEndMarker) {
t.Errorf("expected gortex marker block in %s, got:\n%s", claudeMd, mdBody)
}
if !strings.Contains(string(mdBody), "MANDATORY: Use Gortex MCP tools") {
t.Errorf("expected rule body in %s, got:\n%s", claudeMd, mdBody)
}
// Per-repo files should *not* exist under global mode.
for _, p := range []string{
filepath.Join(env.Root, ".mcp.json"),
filepath.Join(env.Root, "CLAUDE.md"),
filepath.Join(env.Root, ".claude", "settings.local.json"),
} {
if _, err := os.Stat(p); err == nil {
t.Errorf("global mode unexpectedly wrote per-repo file %s", p)
}
}
}
func TestClaudeCodeGlobalModeHonorsClaudeConfigDir(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = true
configDir := filepath.Join(t.TempDir(), "work-profile")
t.Setenv("CLAUDE_CONFIG_DIR", configDir)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true in global mode")
}
expected := []string{
filepath.Join(configDir, ".claude.json"),
filepath.Join(configDir, "settings.json"),
filepath.Join(configDir, "settings.local.json"),
filepath.Join(configDir, "CLAUDE.md"),
}
for name := range SlashCommands {
expected = append(expected, filepath.Join(configDir, "commands", name))
}
for name := range GlobalSkills {
expected = append(expected, filepath.Join(configDir, "skills", name, "SKILL.md"))
}
for name := range SubAgents {
expected = append(expected, filepath.Join(configDir, "agents", name))
}
for _, p := range expected {
if _, err := os.Stat(p); err != nil {
t.Errorf("missing CLAUDE_CONFIG_DIR artifact %s: %v", p, err)
}
}
for _, p := range []string{
filepath.Join(env.Home, ".claude.json"),
filepath.Join(env.Home, ".claude"),
} {
if _, err := os.Stat(p); err == nil {
t.Errorf("global mode unexpectedly wrote HOME artifact %s", p)
}
}
plan, err := a.Plan(env)
if err != nil {
t.Fatalf("plan: %v", err)
}
for _, f := range plan.Files {
if !strings.HasPrefix(f.Path, configDir+string(os.PathSeparator)) {
t.Errorf("planned path %s did not use CLAUDE_CONFIG_DIR %s", f.Path, configDir)
}
}
}
// TestClaudeCodeConfigDirOverrideBeatsEnv verifies the explicit
// override (set by `gortex install --claude-config-dir`) wins over
// $CLAUDE_CONFIG_DIR, and that neither the env dir nor HOME is touched
// when the override is active.
func TestClaudeCodeConfigDirOverrideBeatsEnv(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = true
envDir := filepath.Join(t.TempDir(), "env-profile")
overrideDir := filepath.Join(t.TempDir(), "flag-profile")
t.Setenv("CLAUDE_CONFIG_DIR", envDir)
SetConfigDirOverride(overrideDir)
t.Cleanup(func() { SetConfigDirOverride("") })
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
for _, p := range []string{
filepath.Join(overrideDir, ".claude.json"),
filepath.Join(overrideDir, "settings.json"),
filepath.Join(overrideDir, "CLAUDE.md"),
} {
if _, err := os.Stat(p); err != nil {
t.Errorf("missing override artifact %s: %v", p, err)
}
}
if _, err := os.Stat(envDir); err == nil {
t.Errorf("env-profile dir should be untouched when the override is set")
}
if _, err := os.Stat(filepath.Join(env.Home, ".claude")); err == nil {
t.Errorf("HOME .claude should be untouched when the override is set")
}
}
// TestClaudeCodeRemoveGlobal is the round-trip contract for `gortex
// uninstall --global`: after an install, RemoveGlobal must delete the
// owned skills/commands/agents and strip the Gortex portion of every
// merged file while preserving the user's own content (other MCP
// servers, personal CLAUDE.md prose). GlobalArtifacts must report an
// empty footprint afterward.
func TestClaudeCodeRemoveGlobal(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = true
configDir := filepath.Join(env.Home, ".claude")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatal(err)
}
// A non-gortex MCP server and personal CLAUDE.md prose must survive.
if err := os.WriteFile(filepath.Join(env.Home, ".claude.json"),
[]byte(`{"mcpServers":{"other":{"command":"x"}}}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(configDir, "CLAUDE.md"),
[]byte("# My rules\n\nBe terse.\n"), 0o644); err != nil {
t.Fatal(err)
}
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
if got := GlobalArtifacts(env.Home); len(got) == 0 {
t.Fatal("expected a non-empty footprint after install")
}
removed, failures := a.RemoveGlobal(env, agents.ApplyOpts{})
if len(failures) != 0 {
t.Fatalf("RemoveGlobal failures: %v", failures)
}
if removed == 0 {
t.Fatal("expected RemoveGlobal to remove at least one artifact")
}
// Owned files deleted.
for name := range GlobalSkills {
if _, err := os.Stat(filepath.Join(configDir, "skills", name)); err == nil {
t.Errorf("skill %s not removed", name)
}
}
for name := range SlashCommands {
if _, err := os.Stat(filepath.Join(configDir, "commands", name)); err == nil {
t.Errorf("command %s not removed", name)
}
}
for name := range SubAgents {
if _, err := os.Stat(filepath.Join(configDir, "agents", name)); err == nil {
t.Errorf("sub-agent %s not removed", name)
}
}
// CLAUDE.md: gortex block stripped, personal prose preserved.
md, _ := os.ReadFile(filepath.Join(configDir, "CLAUDE.md"))
if strings.Contains(string(md), agents.GlobalRulesStartMarker) {
t.Errorf("CLAUDE.md still carries the gortex marker:\n%s", md)
}
if !strings.Contains(string(md), "Be terse.") {
t.Errorf("CLAUDE.md lost personal content:\n%s", md)
}
// settings.json: gortex permission gone.
if settings, _ := os.ReadFile(filepath.Join(configDir, "settings.json")); strings.Contains(string(settings), "mcp__gortex__") {
t.Errorf("settings.json still allows mcp__gortex__:\n%s", settings)
}
// settings.local.json: gortex hooks gone.
if local, _ := os.ReadFile(filepath.Join(configDir, "settings.local.json")); strings.Contains(string(local), "gortex") {
t.Errorf("settings.local.json still references gortex:\n%s", local)
}
// .claude.json: gortex server gone, the user's other server kept.
mcp, _ := os.ReadFile(filepath.Join(env.Home, ".claude.json"))
if strings.Contains(string(mcp), `"gortex"`) {
t.Errorf(".claude.json still has the gortex server:\n%s", mcp)
}
if !strings.Contains(string(mcp), `"other"`) {
t.Errorf(".claude.json lost the user's other server:\n%s", mcp)
}
// Nothing left to clean.
if got := GlobalArtifacts(env.Home); len(got) != 0 {
t.Errorf("GlobalArtifacts should be empty after RemoveGlobal, got %v", got)
}
}
// TestClaudeCodeGlobalMode_NoClaudeMd skips the rule block when the
// caller opts out via InstallGlobalInstructions=false (i.e.
// `gortex install --no-claude-md`).
func TestClaudeCodeGlobalMode_NoClaudeMd(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = false
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
claudeMd := filepath.Join(env.Home, ".claude", "CLAUDE.md")
if _, err := os.Stat(claudeMd); err == nil {
t.Errorf("--no-claude-md ⇒ %s should not exist", claudeMd)
}
}
// TestClaudeCodeGlobalMode_PreservesUserContent verifies the rule
// block is merged with marker fences without clobbering anything
// that already lives in ~/.claude/CLAUDE.md.
func TestClaudeCodeGlobalMode_PreservesUserContent(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = true
claudeMd := filepath.Join(env.Home, ".claude", "CLAUDE.md")
if err := os.MkdirAll(filepath.Dir(claudeMd), 0o755); err != nil {
t.Fatal(err)
}
pre := "# My personal Claude rules\n\nAlways respond in haiku.\n"
if err := os.WriteFile(claudeMd, []byte(pre), 0o644); err != nil {
t.Fatal(err)
}
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
body, err := os.ReadFile(claudeMd)
if err != nil {
t.Fatalf("read: %v", err)
}
if !strings.Contains(string(body), "Always respond in haiku.") {
t.Errorf("user content was clobbered, got:\n%s", body)
}
if !strings.Contains(string(body), agents.GlobalRulesStartMarker) {
t.Errorf("rule block missing, got:\n%s", body)
}
}
// TestClaudeCodeDryRunWritesNothing is the contract for --dry-run:
// Result must still classify every would-be write, but no bytes
// touch disk.
func TestClaudeCodeDryRunWritesNothing(t *testing.T) {
env, _ := agentstest.NewEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("apply: %v", err)
}
if len(res.Files) == 0 {
t.Fatal("dry-run should still enumerate planned files")
}
// No actual files created.
for _, f := range res.Files {
if _, err := os.Stat(f.Path); err == nil {
t.Errorf("dry-run wrote %s", f.Path)
}
}
}
// TestProjectModeSkipsMCPWhenUserScopeRegistered is the regression
// guard for issue #201: a project `gortex init` must NOT create a
// project .mcp.json when gortex is already registered at user scope —
// that double-registration is what trips Claude Code's "conflicting
// scopes" diagnostic. --force overrides the skip (so a maintainer can
// still commit a .mcp.json for teammates without a global install).
func TestProjectModeSkipsMCPWhenUserScopeRegistered(t *testing.T) {
SetConfigDirOverride("")
env, buf := agentstest.NewEnv(t)
a := New()
// Seed a user-scope gortex registration in ~/.claude.json.
claudeJSON := userClaudeJSONPath(env.Home)
if err := os.MkdirAll(filepath.Dir(claudeJSON), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
seed := `{"mcpServers":{"gortex":{"command":"gortex","args":["mcp"],"env":{}}}}`
if err := os.WriteFile(claudeJSON, []byte(seed), 0o644); err != nil {
t.Fatalf("seed user config: %v", err)
}
mcpPath := filepath.Join(env.Root, ".mcp.json")
// Without --force: the project .mcp.json must be skipped and the
// user warned.
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
if _, err := os.Stat(mcpPath); err == nil {
t.Errorf("project .mcp.json was written despite a user-scope gortex registration")
}
if !strings.Contains(buf.String(), "already registered at user scope") {
t.Errorf("expected a conflicting-scopes warning, got: %q", buf.String())
}
// With --force: the project .mcp.json is written anyway.
if _, err := a.Apply(env, agents.ApplyOpts{Force: true}); err != nil {
t.Fatalf("apply --force: %v", err)
}
if _, err := os.Stat(mcpPath); err != nil {
t.Errorf("--force should still write project .mcp.json: %v", err)
}
}
// TestProjectModeWritesMCPWhenNoUserScope confirms the common path is
// unchanged: with no user-scope gortex registration, the project
// .mcp.json is created as before.
func TestProjectModeWritesMCPWhenNoUserScope(t *testing.T) {
SetConfigDirOverride("")
env, _ := agentstest.NewEnv(t)
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
if _, err := os.Stat(filepath.Join(env.Root, ".mcp.json")); err != nil {
t.Errorf("project .mcp.json should be written when no user-scope entry exists: %v", err)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
package claudecode
import (
"strings"
"testing"
)
// TestCommandPRReviewAgent_ShellsTheReviewVerb asserts the agent-review skill
// instructs a coding agent to shell the review verb in its terse audience mode.
// The whole point of the skill is to replace hand-walking the review gates with
// a single `gortex review --audience agent` call, so that exact invocation must
// appear in the generated content.
func TestCommandPRReviewAgent_ShellsTheReviewVerb(t *testing.T) {
for _, want := range []string{
"gortex review --audience agent",
"--format json",
"VERDICT:",
"file:line",
} {
if !strings.Contains(commandPRReviewAgent, want) {
t.Errorf("commandPRReviewAgent must reference %q so the agent shells the verb and parses its output", want)
}
}
}
// TestCommandPRReviewAgent_Registered asserts the agent-review skill is wired
// into both the slash-command registry and the global-skill registry under
// matching names, so the plugin emitter and every adapter pick it up.
func TestCommandPRReviewAgent_Registered(t *testing.T) {
if got := SlashCommands["gortex-pr-review-agent.md"]; got != commandPRReviewAgent {
t.Error("gortex-pr-review-agent.md must map to commandPRReviewAgent in SlashCommands")
}
skill, ok := GlobalSkills["gortex-pr-review-agent"]
if !ok {
t.Fatal("gortex-pr-review-agent must be registered in GlobalSkills")
}
// The skill body is the frontmatter + the command body; the command body
// must be present verbatim so a drift edit can't silently desync them.
if !strings.Contains(skill, commandPRReviewAgent) {
t.Error("gortex-pr-review-agent skill body must embed commandPRReviewAgent")
}
if !strings.HasPrefix(skill, "---\nname: gortex-pr-review-agent\n") {
t.Error("gortex-pr-review-agent skill must carry matching frontmatter")
}
}
+627
View File
@@ -0,0 +1,627 @@
package claudecode
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"github.com/zzet/gortex/internal/agents"
)
// CurrentPreToolUseMatcher is the canonical matcher pattern we bake
// into Claude Code's PreToolUse hook. Older versions used
// "Read|Grep", "Read|Grep|Glob", "Read|Grep|Glob|Task",
// "Read|Grep|Glob|Task|Bash", or "Read|Grep|Glob|Task|Bash|Edit|Write";
// upgradeGortexMatcher rewrites those in place. Edit and Write are
// included so the hook can redirect whole-file rewrites of indexed
// source to the Gortex MCP edit tools (gated by GORTEX_HOOK_BLOCK_EDIT
// in the hook itself). The two mcp__gortex__ read tools are included so
// the hook can also nudge a full-body read_file / get_editing_context
// toward compress_bodies / search_text — the one gap that fires once
// the agent is already inside a Gortex tool (gated by
// GORTEX_HOOK_FORCE_COMPRESS for the hard-deny posture).
const CurrentPreToolUseMatcher = "Read|Grep|Glob|Task|Bash|Edit|Write|mcp__gortex__read_file|mcp__gortex__get_editing_context"
// CurrentPostToolUseMatcher names the tools whose response the
// PostToolUse hook augments. Only the read-shaped tools have an obvious
// "enrich this output with graph context" payload — Bash / Edit / Write
// don't benefit from a post-call graph snapshot, so they're omitted.
const CurrentPostToolUseMatcher = "Read|Grep|Glob"
// HookModeDeny / HookModeEnrich / HookModeConsultUnlock /
// HookModeAdaptiveNudge are the posture strings the installer accepts.
// They mirror hooks.Mode without importing it (the claudecode package
// is a leaf of the agents adapter tree and must stay import-free of
// hooks).
const (
HookModeDeny = "deny"
HookModeEnrich = "enrich"
HookModeConsultUnlock = "consult-unlock"
HookModeAdaptiveNudge = "nudge"
)
// normalizeHookMode maps user input to a canonical mode. Empty or
// unknown values fall through to deny so existing installs and shell
// typos preserve the original behavior. "adaptive-nudge" is accepted
// as an alias for the canonical "nudge".
func normalizeHookMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case HookModeEnrich:
return HookModeEnrich
case HookModeConsultUnlock:
return HookModeConsultUnlock
case HookModeAdaptiveNudge, "adaptive-nudge":
return HookModeAdaptiveNudge
default:
return HookModeDeny
}
}
// hookCommandWithMode appends `--mode=<mode>` to the base hook command
// when mode is non-default. The deny mode is the historical default —
// emitting it bare keeps existing settings.json diffs minimal during an
// upgrade. Every other posture is emitted explicitly so the installed
// command unambiguously declares itself.
func hookCommandWithMode(base, mode string) string {
switch normalizeHookMode(mode) {
case HookModeEnrich:
return base + " --mode=enrich"
case HookModeConsultUnlock:
return base + " --mode=consult-unlock"
case HookModeAdaptiveNudge:
return base + " --mode=nudge"
default:
return base
}
}
// ResolveHookCommand returns the shell command to bake into Claude
// Code's hook config. It routes through agents.ResolveGortexHookBinary,
// which shares the same same-file decision core as the MCP server
// stanza (agents.ResolveGortexCommand): the hook and the MCP stanza
// must resolve to the same binary file whenever the running binary is
// usable, so a side-by-side install can never point the hook at a
// different daemon than the one serving the session's graph tools. The
// hook keeps an absolute-path preference (it avoids PATH-at-fire-time
// fragility); it falls back to bare "gortex hook" only when no stable
// binary is resolvable at all.
//
// A warning is written to w on the bare fallback because it relies on
// PATH resolution at hook-fire time — fragile when the user's shell
// environment differs between Claude Code and a terminal.
func ResolveHookCommand(w io.Writer) string {
bin := agents.ResolveGortexHookBinary()
if bin == "gortex" && w != nil {
fmt.Fprintln(w,
"[gortex init] warning: `gortex` not found on PATH; "+
"writing bare \"gortex hook\" into settings — install gortex to PATH for a stable hook command")
}
return shellSafeHookBinary(bin) + " hook"
}
// shellSafeHookBinary normalizes a resolved binary path into a form
// safe to embed in a shell-executed hook command. Claude Code runs
// hooks through a shell; on Windows that shell is Git Bash, which
// treats backslashes as escape characters — a native path like
// C:\Users\me\gortex.exe is mangled to C:Usersmegortex.exe (\U, \m, …
// swallowed) and the hook fails with "command not found". Forward
// slashes survive: Git Bash maps C:/Users/... back to a native path
// when it spawns the .exe. The replacement is unconditional rather
// than Windows-guarded — any backslash in an unquoted shell command is
// a bug regardless of OS, and a real Unix binary path never contains
// one as a separator.
func shellSafeHookBinary(path string) string {
return strings.ReplaceAll(path, "\\", "/")
}
// HookCommandPathIsEphemeral reports whether cmd's binary path lives
// in a location that is wiped between sessions (system tmpdirs, the
// macOS go-build cache) or no longer exists on disk. Used by
// healStaleHookCommands to detect settings.json entries that
// outlived their backing binary.
func HookCommandPathIsEphemeral(cmd string) bool {
fields := strings.Fields(cmd)
if len(fields) == 0 {
return false
}
bin := fields[0]
ephemeralPrefixes := []string{"/tmp/", "/var/folders/", "/private/tmp/", "/private/var/folders/"}
for _, p := range ephemeralPrefixes {
if strings.HasPrefix(bin, p) {
return true
}
}
// An absolute path that no longer resolves to a file is also stale.
if filepath.IsAbs(bin) {
if _, err := os.Stat(bin); err != nil {
return true
}
}
return false
}
// healStaleHookCommands rewrites Gortex hook entries whose command
// points at an ephemeral or missing binary path. Returns the number
// of entries rewritten. Non-Gortex entries are left alone; Gortex
// entries whose path is healthy are also left alone.
func healStaleHookCommands(hooks map[string]any, newCommand string) int {
healed := 0
for _, event := range []string{"PreToolUse", "PreCompact", "Stop", "SessionStart", "UserPromptSubmit"} {
list, ok := hooks[event].([]any)
if !ok {
continue
}
for _, h := range list {
hm, ok := h.(map[string]any)
if !ok {
continue
}
inner, ok := hm["hooks"].([]any)
if !ok {
continue
}
for _, e := range inner {
em, ok := e.(map[string]any)
if !ok {
continue
}
cmd, _ := em["command"].(string)
if !commandInvokesGortexHook(cmd) {
continue
}
if !HookCommandPathIsEphemeral(cmd) {
continue
}
em["command"] = newCommand
healed++
}
}
}
return healed
}
func appendHookEntry(hooks map[string]any, event string, entry map[string]any) {
if _, ok := hooks[event]; !ok {
hooks[event] = []any{}
}
list := hooks[event].([]any)
hooks[event] = append(list, entry)
}
// upgradeGortexMatcher rewrites older PreToolUse matchers to the
// current CurrentPreToolUseMatcher. Returns true when a change was
// made. Handles every historical matcher we've shipped; anything
// not in that set is left alone.
func upgradeGortexMatcher(hooks map[string]any) bool {
pre, ok := hooks["PreToolUse"].([]any)
if !ok {
return false
}
legacyMatchers := map[string]bool{
"Read|Grep": true,
"Read|Grep|Glob": true,
"Read|Grep|Glob|Task": true,
"Read|Grep|Glob|Task|Bash": true,
"Read|Grep|Glob|Task|Bash|Edit|Write": true,
}
upgraded := false
for _, h := range pre {
hm, ok := h.(map[string]any)
if !ok {
continue
}
matcher, _ := hm["matcher"].(string)
if !legacyMatchers[matcher] {
continue
}
if !entryInvokesGortexHook(hm) {
continue
}
hm["matcher"] = CurrentPreToolUseMatcher
upgraded = true
}
return upgraded
}
// entryInvokesGortexHook returns true when any hooks[*].command
// looks like a Gortex hook invocation.
func entryInvokesGortexHook(entry map[string]any) bool {
inner, ok := entry["hooks"].([]any)
if !ok {
return false
}
for _, e := range inner {
em, ok := e.(map[string]any)
if !ok {
continue
}
cmd, _ := em["command"].(string)
if commandInvokesGortexHook(cmd) {
return true
}
}
return false
}
// dedupGortexEntries collapses duplicate Gortex hook entries inside
// hooks[event] down to the first one. Non-Gortex entries are
// preserved in order. Returns the number of duplicates removed.
func dedupGortexEntries(hooks map[string]any, event string) int {
list, ok := hooks[event].([]any)
if !ok {
return 0
}
seenGortex := false
kept := make([]any, 0, len(list))
removed := 0
for _, h := range list {
hm, ok := h.(map[string]any)
if !ok {
kept = append(kept, h)
continue
}
if !entryInvokesGortexHook(hm) {
kept = append(kept, h)
continue
}
if seenGortex {
removed++
continue
}
seenGortex = true
kept = append(kept, h)
}
if removed > 0 {
hooks[event] = kept
}
return removed
}
// commandInvokesGortexHook returns true when cmd is a Gortex hook
// invocation. Splits on whitespace and checks that "hook" is a
// standalone token and that "gortex" appears in the binary path
// component.
func commandInvokesGortexHook(cmd string) bool {
fields := strings.Fields(cmd)
if len(fields) < 2 {
return false
}
if !strings.Contains(strings.ToLower(fields[0]), "gortex") {
return false
}
return slices.Contains(fields[1:], "hook")
}
// rewriteGortexHookMode rewrites every Gortex hook entry's command
// across all events so it matches newCommand. Used when the install
// posture changes (deny ↔ enrich) — the existing entries already
// invoke `gortex hook` but with the wrong `--mode=...` suffix; we
// re-stamp them in place instead of removing + re-adding so user-added
// fields (timeout, statusMessage) are preserved. Returns the count of
// rewritten entries.
func rewriteGortexHookMode(hooks map[string]any, newCommand string) int {
rewritten := 0
for _, event := range []string{"PreToolUse", "PostToolUse", "PreCompact", "Stop", "SessionStart", "UserPromptSubmit"} {
list, ok := hooks[event].([]any)
if !ok {
continue
}
for _, h := range list {
hm, ok := h.(map[string]any)
if !ok {
continue
}
inner, ok := hm["hooks"].([]any)
if !ok {
continue
}
for _, e := range inner {
em, ok := e.(map[string]any)
if !ok {
continue
}
cmd, _ := em["command"].(string)
if !commandInvokesGortexHook(cmd) {
continue
}
if cmd == newCommand {
continue
}
em["command"] = newCommand
rewritten++
}
}
}
return rewritten
}
// removeGortexHookEntries drops every entry under hooks[event] that
// invokes `gortex hook`, preserving entries owned by other tools.
// Returns the number of entries removed. Used to clean up PostToolUse
// when the installer switches back from enrich to deny mode.
func removeGortexHookEntries(hooks map[string]any, event string) int {
list, ok := hooks[event].([]any)
if !ok {
return 0
}
removed := 0
kept := make([]any, 0, len(list))
for _, h := range list {
hm, ok := h.(map[string]any)
if !ok {
kept = append(kept, h)
continue
}
if entryInvokesGortexHook(hm) {
removed++
continue
}
kept = append(kept, h)
}
if removed > 0 {
if len(kept) == 0 {
delete(hooks, event)
} else {
hooks[event] = kept
}
}
return removed
}
// hasGortexHookEntry returns true when the given event already has a
// hook entry that invokes `gortex hook`.
func hasGortexHookEntry(hooks map[string]any, event string) bool {
existing, ok := hooks[event].([]any)
if !ok {
return false
}
for _, h := range existing {
hm, ok := h.(map[string]any)
if !ok {
continue
}
if entryInvokesGortexHook(hm) {
return true
}
}
return false
}
// InstallHook is the top-level "make settings.local.json hooks
// match the current Gortex config" operation. It reads the file,
// heals stale commands, upgrades old matchers, dedupes repeat
// entries, then installs any missing Gortex hooks (PreToolUse,
// PreCompact, Stop, SessionStart, and — in enrich mode — PostToolUse).
// Writes back atomically via the shared helper.
//
// This function intentionally accepts a plain filesystem path
// rather than an Env — the same helper is used for project-level
// (.claude/settings.local.json) and user-level (~/.claude/…) files.
//
// Delegates to InstallHookWithMode with the deny posture for callers
// that don't care about hook mode (mostly tests and back-compat paths).
func InstallHook(w io.Writer, settingsPath string, opts agents.ApplyOpts) (agents.FileAction, error) {
return InstallHookWithMode(w, settingsPath, HookModeDeny, opts)
}
// InstallHookWithMode is the mode-aware variant. mode is one of
// HookModeDeny (default — install PreToolUse with deny behavior, no
// PostToolUse) or HookModeEnrich (install PreToolUse in soft-context
// mode plus a PostToolUse entry that augments tool output with graph
// context). Switching modes between installs rewrites the existing
// Gortex hook command in place and adds or removes the PostToolUse
// entry to match.
func InstallHookWithMode(w io.Writer, settingsPath string, mode string, opts agents.ApplyOpts) (agents.FileAction, error) {
mode = normalizeHookMode(mode)
var settings map[string]any
existed := false
if data, err := os.ReadFile(settingsPath); err == nil {
existed = true
if err := json.Unmarshal(data, &settings); err != nil {
settings = make(map[string]any)
}
} else if !errors.Is(err, os.ErrNotExist) {
return agents.FileAction{}, fmt.Errorf("read %s: %w", settingsPath, err)
} else {
settings = make(map[string]any)
}
baseCommand := ResolveHookCommand(w)
hookCommand := hookCommandWithMode(baseCommand, mode)
if _, ok := settings["hooks"]; !ok {
settings["hooks"] = make(map[string]any)
}
hooks := settings["hooks"].(map[string]any)
healedCount := healStaleHookCommands(hooks, hookCommand)
matcherUpgraded := upgradeGortexMatcher(hooks)
modeRewriteCount := rewriteGortexHookMode(hooks, hookCommand)
dedupedCount := dedupGortexEntries(hooks, "PreToolUse") +
dedupGortexEntries(hooks, "PreCompact") +
dedupGortexEntries(hooks, "PostToolUse") +
dedupGortexEntries(hooks, "Stop") +
dedupGortexEntries(hooks, "SessionStart") +
dedupGortexEntries(hooks, "UserPromptSubmit")
// PostToolUse is only present when mode=enrich. Removing it on a
// switch to any other posture keeps settings.json clean — agents
// won't fire a no-op hook on every Read/Grep response.
postToolUseRemoved := 0
if mode != HookModeEnrich {
postToolUseRemoved = removeGortexHookEntries(hooks, "PostToolUse")
}
preToolUseInstalled := hasGortexHookEntry(hooks, "PreToolUse")
preCompactInstalled := hasGortexHookEntry(hooks, "PreCompact")
stopInstalled := hasGortexHookEntry(hooks, "Stop")
sessionStartInstalled := hasGortexHookEntry(hooks, "SessionStart")
userPromptSubmitInstalled := hasGortexHookEntry(hooks, "UserPromptSubmit")
postToolUseInstalled := hasGortexHookEntry(hooks, "PostToolUse")
if !preToolUseInstalled {
appendHookEntry(hooks, "PreToolUse", map[string]any{
"matcher": CurrentPreToolUseMatcher,
"hooks": []any{
map[string]any{
"type": "command",
"command": hookCommand,
"timeout": 3000,
"statusMessage": "Enriching with Gortex graph context...",
},
},
})
}
if !preCompactInstalled {
appendHookEntry(hooks, "PreCompact", map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": hookCommand,
"timeout": 3000,
"statusMessage": "Injecting Gortex orientation snapshot...",
},
},
})
}
if !stopInstalled {
appendHookEntry(hooks, "Stop", map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": hookCommand,
"timeout": 5000,
"statusMessage": "Running Gortex post-task diagnostics...",
},
},
})
}
if !sessionStartInstalled {
// SessionStart fires at the start of a new or resumed session
// — a perfect moment to inject the Gortex orientation snapshot
// so the first turn doesn't have to call graph_stats. It
// complements PreCompact (which fires on summary boundaries).
appendHookEntry(hooks, "SessionStart", map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": hookCommand,
"timeout": 3000,
"statusMessage": "Loading Gortex graph orientation...",
},
},
})
}
if !userPromptSubmitInstalled {
// UserPromptSubmit fires before every user turn — the moment to
// proactively inject graph symbols relevant to the prompt so the
// model reaches for Gortex instead of grepping. It is best-effort
// and time-bounded; a miss is a silent no-op.
appendHookEntry(hooks, "UserPromptSubmit", map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": hookCommand,
"timeout": 3000,
"statusMessage": "Surfacing relevant Gortex symbols...",
},
},
})
}
// PostToolUse is only installed in enrich mode. It augments
// Grep / Glob / Read responses with graph context (enclosing
// symbols, file footprints) so the agent sees the graph value
// adjacent to the raw output instead of via a deny redirect.
postToolUseAdded := false
if mode == HookModeEnrich && !postToolUseInstalled {
appendHookEntry(hooks, "PostToolUse", map[string]any{
"matcher": CurrentPostToolUseMatcher,
"hooks": []any{
map[string]any{
"type": "command",
"command": hookCommand,
"timeout": 3000,
"statusMessage": "Layering Gortex graph context onto tool output...",
},
},
})
postToolUseAdded = true
}
allPresent := preToolUseInstalled && preCompactInstalled && stopInstalled && sessionStartInstalled &&
userPromptSubmitInstalled && (mode != HookModeEnrich || postToolUseInstalled)
noChanges := allPresent && !matcherUpgraded && dedupedCount == 0 && healedCount == 0 &&
modeRewriteCount == 0 && postToolUseRemoved == 0 && !postToolUseAdded
if noChanges {
if w != nil {
fmt.Fprintf(w, "[gortex init] all hooks already present in %s\n", settingsPath)
}
return agents.FileAction{Path: settingsPath, Action: agents.ActionSkip, Reason: "already-configured"}, nil
}
if opts.DryRun {
action := agents.ActionWouldCreate
if existed {
action = agents.ActionWouldMerge
}
return agents.FileAction{Path: settingsPath, Action: action, Keys: []string{"hooks"}}, nil
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return agents.FileAction{}, err
}
if err := agents.AtomicWriteFile(settingsPath, data, 0o644); err != nil {
return agents.FileAction{}, err
}
// Report exactly what changed — helpful for the doctor subcommand
// and for reassuring users during `gortex init` re-runs.
var changes []string
if matcherUpgraded {
changes = append(changes, "upgraded PreToolUse matcher")
}
if dedupedCount > 0 {
changes = append(changes, fmt.Sprintf("removed %d duplicate entries", dedupedCount))
}
if healedCount > 0 {
changes = append(changes, fmt.Sprintf("rewrote %d stale hook path(s)", healedCount))
}
if !preToolUseInstalled {
changes = append(changes, "installed PreToolUse")
}
if !preCompactInstalled {
changes = append(changes, "installed PreCompact")
}
if !stopInstalled {
changes = append(changes, "installed Stop")
}
if !sessionStartInstalled {
changes = append(changes, "installed SessionStart")
}
if !userPromptSubmitInstalled {
changes = append(changes, "installed UserPromptSubmit")
}
if postToolUseAdded {
changes = append(changes, "installed PostToolUse (enrich mode)")
}
if postToolUseRemoved > 0 {
changes = append(changes, fmt.Sprintf("removed PostToolUse (switched to %s mode)", mode))
}
if modeRewriteCount > 0 {
changes = append(changes, fmt.Sprintf("rewrote %d hook command(s) for mode=%s", modeRewriteCount, mode))
}
if w != nil {
fmt.Fprintf(w, "[gortex init] %s in %s\n", strings.Join(changes, ", "), settingsPath)
}
action := agents.ActionCreate
if existed {
action = agents.ActionMerge
}
return agents.FileAction{Path: settingsPath, Action: action, Keys: []string{"hooks"}}, nil
}
+522
View File
@@ -0,0 +1,522 @@
package claudecode
import (
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/agents"
)
// agentsApplyOptsZero is the zero value of agents.ApplyOpts. Helper so
// the mode-switching tests don't drag the agents import into every
// call site.
func agentsApplyOptsZero() agents.ApplyOpts { return agents.ApplyOpts{} }
// readSettingsHooks reads settings.local.json from path and returns
// the hooks map (or fails the test if the file is missing / malformed).
// The mode-switching tests assert on hook structure between InstallHook
// calls, so we centralise the read-and-shape-check here.
func readSettingsHooks(t *testing.T, path string) map[string]any {
t.Helper()
data, err := os.ReadFile(path)
require.NoError(t, err, "read %s", path)
var settings map[string]any
require.NoError(t, json.Unmarshal(data, &settings))
hooks, ok := settings["hooks"].(map[string]any)
require.True(t, ok, "settings.hooks missing or wrong type")
return hooks
}
// TestHookCommandPathIsEphemeral covers every branch of the
// ephemeral-path detector because this function's output directly
// decides whether a user's settings.local.json gets rewritten on
// re-run. A false positive here would thrash the user's hook
// config; a false negative leaves them with stale /tmp paths.
func TestHookCommandPathIsEphemeral(t *testing.T) {
// /bin/sh is a stable POSIX path, not under any ephemeral root.
// os.Executable() would land in /private/var/folders under go
// test, which is itself ephemeral — hence hard-coding.
const existing = "/bin/sh"
if _, err := os.Stat(existing); err != nil {
t.Skipf("test fixture %s not present: %v", existing, err)
}
missing := filepath.Join("/nonexistent-root-for-gortex-test", "ghost-binary")
cases := []struct {
name string
cmd string
want bool
comment string
}{
{"empty", "", false, "no fields to inspect"},
{"bareName", "gortex hook", false, "PATH lookup happens at fire time"},
{"relative", "./gortex hook", false, "relative paths are user choice, not ephemeral"},
{"tmp", "/tmp/gortex-hook-fix hook", true, "/tmp is wiped between sessions"},
{"varFolders", "/var/folders/x/y/z/gortex hook", true, "macOS go-build cache"},
{"privateTmp", "/private/tmp/gortex hook", true, "macOS resolves /tmp via /private/tmp"},
{"privateVarFolders", "/private/var/folders/x/y/z/gortex hook", true, "fully resolved go-build cache"},
{"missingAbsolute", missing + " hook", true, "absolute path that no longer exists"},
{"healthyAbsolute", existing + " hook", false, "absolute path that exists on disk"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := HookCommandPathIsEphemeral(tc.cmd)
assert.Equal(t, tc.want, got, tc.comment)
})
}
}
func TestHealStaleHookCommands(t *testing.T) {
const newCmd = "/opt/homebrew/bin/gortex hook"
t.Run("emptyHooks", func(t *testing.T) {
hooks := map[string]any{}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 0, got)
})
t.Run("noGortexEntries", func(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{
makeHookEntry("Read", "/usr/local/bin/some-other-tool run"),
},
}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 0, got)
entries := hooks["PreToolUse"].([]any)
inner := entries[0].(map[string]any)["hooks"].([]any)
cmd := inner[0].(map[string]any)["command"].(string)
assert.Equal(t, "/usr/local/bin/some-other-tool run", cmd)
})
t.Run("healthyGortexEntryUntouched", func(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Read", "./gortex hook")},
}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 0, got)
assert.Equal(t, "./gortex hook", extractCmd(t, hooks, "PreToolUse", 0))
})
t.Run("staleEntryRewritten", func(t *testing.T) {
hooks := map[string]any{
"Stop": []any{makeHookEntry("", "/tmp/gortex-hook-fix hook")},
}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 1, got)
assert.Equal(t, newCmd, extractCmd(t, hooks, "Stop", 0))
})
t.Run("multipleEventsAndMixed", func(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{
makeHookEntry("Read", "./gortex hook"),
makeHookEntry("Read", "/usr/local/bin/lint --strict"),
},
"PreCompact": []any{makeHookEntry("", "/tmp/gortex-hook-fix hook")},
"Stop": []any{makeHookEntry("", "/tmp/gortex-hook-fix hook")},
}
got := healStaleHookCommands(hooks, newCmd)
assert.Equal(t, 2, got)
assert.Equal(t, "./gortex hook", extractCmd(t, hooks, "PreToolUse", 0))
assert.Equal(t, "/usr/local/bin/lint --strict", extractCmd(t, hooks, "PreToolUse", 1))
assert.Equal(t, newCmd, extractCmd(t, hooks, "PreCompact", 0))
assert.Equal(t, newCmd, extractCmd(t, hooks, "Stop", 0))
})
}
func TestResolveHookCommand(t *testing.T) {
t.Run("foundOnPath", func(t *testing.T) {
dir := t.TempDir()
fake := filepath.Join(dir, "gortex")
require.NoError(t, os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755))
t.Setenv("PATH", dir)
got := ResolveHookCommand(io.Discard)
assert.Equal(t, fake+" hook", got, "should resolve to absolute path on PATH")
})
t.Run("notFoundFallsBackToBare", func(t *testing.T) {
t.Setenv("PATH", t.TempDir())
got := ResolveHookCommand(io.Discard)
assert.Equal(t, "gortex hook", got, "fallback to bare name keeps init working in sandboxes")
})
t.Run("commandNeverContainsBackslash", func(t *testing.T) {
dir := t.TempDir()
fake := filepath.Join(dir, "gortex")
require.NoError(t, os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755))
t.Setenv("PATH", dir)
got := ResolveHookCommand(io.Discard)
assert.NotContains(t, got, "\\",
"baked hook command must use forward slashes — Git Bash on Windows mangles backslash paths")
})
t.Run("agreesWithMCPStanzaBinary", func(t *testing.T) {
// The hook command and the MCP server stanza must launch the same
// gortex binary; a side-by-side install must never split them
// across two daemons. With a gortex on PATH the hook pins the
// absolute path while the stanza collapses to the bare name — both
// resolve to the same file.
dir := t.TempDir()
fake := filepath.Join(dir, "gortex")
require.NoError(t, os.WriteFile(fake, []byte("#!/bin/sh\nexit 0\n"), 0o755))
t.Setenv("PATH", dir)
hookBin := strings.TrimSuffix(ResolveHookCommand(io.Discard), " hook")
mcpCmd := agents.ResolveGortexCommand()
// Map the bare name to the PATH gortex it launches, then compare
// the concrete on-disk binaries.
launched := func(cmd string) string {
if cmd == "gortex" {
return fake
}
return cmd
}
assert.Equal(t, launched(mcpCmd), launched(hookBin),
"hook command and MCP stanza must resolve to the same gortex binary")
})
}
// TestShellSafeHookBinary pins the Windows-path fix: a resolved binary
// path with backslashes (as exec.LookPath returns on Windows) must be
// rewritten to forward slashes so Claude Code's shell-run hook does not
// swallow the separators. Runs on every platform because the helper is
// a pure string transform.
func TestShellSafeHookBinary(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"windowsAbsolute", `C:\Users\Borislav\AppData\Local\Programs\gortex\gortex.exe`, "C:/Users/Borislav/AppData/Local/Programs/gortex/gortex.exe"},
{"windowsMixed", `C:\Users\me/gortex.exe`, "C:/Users/me/gortex.exe"},
{"unixAbsolute", "/opt/homebrew/bin/gortex", "/opt/homebrew/bin/gortex"},
{"bareName", "gortex", "gortex"},
{"forwardSlashesUnchanged", "C:/Users/me/gortex.exe", "C:/Users/me/gortex.exe"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := shellSafeHookBinary(tc.in)
assert.Equal(t, tc.want, got)
assert.NotContains(t, got, "\\", "result must be free of backslashes")
})
}
}
func makeHookEntry(matcher, command string) map[string]any {
entry := map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": command,
"timeout": 3000,
},
},
}
if matcher != "" {
entry["matcher"] = matcher
}
return entry
}
func extractCmd(t *testing.T, hooks map[string]any, event string, idx int) string {
t.Helper()
list, ok := hooks[event].([]any)
require.True(t, ok, "event %q missing", event)
require.Greater(t, len(list), idx, "event %q has fewer than %d entries", event, idx+1)
entry, ok := list[idx].(map[string]any)
require.True(t, ok)
inner, ok := entry["hooks"].([]any)
require.True(t, ok)
require.NotEmpty(t, inner)
em, ok := inner[0].(map[string]any)
require.True(t, ok)
cmd, _ := em["command"].(string)
return cmd
}
// ---------------------------------------------------------------------------
// Mode parsing + command rendering
// ---------------------------------------------------------------------------
func TestNormalizeHookMode(t *testing.T) {
cases := map[string]string{
"": HookModeDeny,
"deny": HookModeDeny,
"DENY": HookModeDeny,
" deny ": HookModeDeny,
"enrich": HookModeEnrich,
"Enrich": HookModeEnrich,
"consult-unlock": HookModeConsultUnlock,
"Consult-Unlock": HookModeConsultUnlock,
"nudge": HookModeAdaptiveNudge,
"NUDGE": HookModeAdaptiveNudge,
"adaptive-nudge": HookModeAdaptiveNudge, // accepted alias
"unknown": HookModeDeny, // safe fallback
"off": HookModeDeny, // install flag handles disable separately
}
for input, want := range cases {
t.Run(input, func(t *testing.T) {
assert.Equal(t, want, normalizeHookMode(input))
})
}
}
func TestHookCommandWithMode(t *testing.T) {
base := "/usr/local/bin/gortex hook"
assert.Equal(t, base, hookCommandWithMode(base, HookModeDeny),
"deny mode must NOT append --mode flag (back-compat with settings written before --hook-mode existed)")
assert.Equal(t, base+" --mode=enrich", hookCommandWithMode(base, HookModeEnrich),
"enrich mode must append --mode=enrich")
assert.Equal(t, base+" --mode=consult-unlock", hookCommandWithMode(base, HookModeConsultUnlock),
"consult-unlock mode must append --mode=consult-unlock")
assert.Equal(t, base+" --mode=nudge", hookCommandWithMode(base, HookModeAdaptiveNudge),
"nudge mode must append --mode=nudge")
assert.Equal(t, base+" --mode=nudge", hookCommandWithMode(base, "adaptive-nudge"),
"adaptive-nudge alias must normalize to --mode=nudge")
assert.Equal(t, base, hookCommandWithMode(base, ""),
"empty mode defaults to deny — no flag")
}
// ---------------------------------------------------------------------------
// removeGortexHookEntries — used when switching enrich → deny
// ---------------------------------------------------------------------------
func TestRemoveGortexHookEntries(t *testing.T) {
hooks := map[string]any{
"PostToolUse": []any{
makeHookEntry("Read|Grep|Glob", "/opt/gortex hook --mode=enrich"),
map[string]any{
"hooks": []any{
map[string]any{"type": "command", "command": "/usr/bin/other-tool"},
},
},
},
}
removed := removeGortexHookEntries(hooks, "PostToolUse")
assert.Equal(t, 1, removed)
list := hooks["PostToolUse"].([]any)
require.Len(t, list, 1, "non-Gortex entry must be preserved")
em := list[0].(map[string]any)
inner := em["hooks"].([]any)
cmd := inner[0].(map[string]any)["command"]
assert.Equal(t, "/usr/bin/other-tool", cmd)
}
func TestRemoveGortexHookEntries_DeletesEmptyEvent(t *testing.T) {
hooks := map[string]any{
"PostToolUse": []any{
makeHookEntry("Read|Grep|Glob", "/opt/gortex hook --mode=enrich"),
},
}
removed := removeGortexHookEntries(hooks, "PostToolUse")
assert.Equal(t, 1, removed)
_, exists := hooks["PostToolUse"]
assert.False(t, exists, "empty event should be deleted, not left as []")
}
func TestRemoveGortexHookEntries_NoOpOnMissingEvent(t *testing.T) {
hooks := map[string]any{}
assert.Equal(t, 0, removeGortexHookEntries(hooks, "PostToolUse"))
}
// ---------------------------------------------------------------------------
// rewriteGortexHookMode — switches mode without touching user fields
// ---------------------------------------------------------------------------
func TestRewriteGortexHookMode_UpdatesCommandPreservingMeta(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{
map[string]any{
"matcher": "Read|Grep",
"hooks": []any{
map[string]any{
"type": "command",
"command": "/opt/gortex hook",
"timeout": 3000,
"statusMessage": "custom user-set message",
},
},
},
},
}
rewritten := rewriteGortexHookMode(hooks, "/opt/gortex hook --mode=enrich")
assert.Equal(t, 1, rewritten)
list := hooks["PreToolUse"].([]any)
em := list[0].(map[string]any)["hooks"].([]any)[0].(map[string]any)
assert.Equal(t, "/opt/gortex hook --mode=enrich", em["command"])
assert.Equal(t, "custom user-set message", em["statusMessage"],
"rewrite must preserve user-added fields like statusMessage")
}
func TestRewriteGortexHookMode_NoOpOnIdenticalCommand(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Read|Grep|Glob", "/opt/gortex hook")},
}
assert.Equal(t, 0, rewriteGortexHookMode(hooks, "/opt/gortex hook"))
}
func TestRewriteGortexHookMode_IgnoresNonGortexEntries(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Bash", "/usr/bin/other --thing")},
}
assert.Equal(t, 0, rewriteGortexHookMode(hooks, "/opt/gortex hook --mode=enrich"))
cmd := extractCmd(t, hooks, "PreToolUse", 0)
assert.Equal(t, "/usr/bin/other --thing", cmd,
"non-Gortex entries must NOT be touched even when we're rewriting")
}
// TestUpgradeGortexMatcher_RewritesPreviousCurrent verifies the
// migration path: an existing install pinned to the previous canonical
// matcher (no MCP read tools) upgrades in place so the read-discipline
// nudge starts firing without the user touching settings.json.
func TestUpgradeGortexMatcher_RewritesPreviousCurrent(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Read|Grep|Glob|Task|Bash|Edit|Write", "/opt/gortex hook")},
}
assert.True(t, upgradeGortexMatcher(hooks), "previous-current matcher should upgrade")
got := hooks["PreToolUse"].([]any)[0].(map[string]any)["matcher"].(string)
assert.Equal(t, CurrentPreToolUseMatcher, got)
assert.Contains(t, got, "mcp__gortex__read_file",
"upgraded matcher must include the gortex read tools")
}
func TestUpgradeGortexMatcher_NoOpOnCurrent(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry(CurrentPreToolUseMatcher, "/opt/gortex hook")},
}
assert.False(t, upgradeGortexMatcher(hooks), "already-current matcher must not be rewritten")
}
func TestUpgradeGortexMatcher_IgnoresNonGortexEntries(t *testing.T) {
hooks := map[string]any{
"PreToolUse": []any{makeHookEntry("Read|Grep|Glob|Task|Bash|Edit|Write", "/usr/bin/other thing")},
}
assert.False(t, upgradeGortexMatcher(hooks), "a non-Gortex entry must not be upgraded")
}
// ---------------------------------------------------------------------------
// InstallHookWithMode — end-to-end: deny → enrich → deny round-trip
// ---------------------------------------------------------------------------
func TestInstallHookWithMode_DenyMode(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir()) // force fallback to bare "gortex hook"
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
assert.NotContains(t, hooks, "PostToolUse",
"deny mode must NOT install a PostToolUse entry")
cmd := extractCmd(t, hooks, "PreToolUse", 0)
assert.Equal(t, "gortex hook", cmd,
"deny mode keeps the bare command — no --mode flag")
}
func TestInstallHook_InstallsUserPromptSubmit(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir()) // force fallback to bare "gortex hook"
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
require.Contains(t, hooks, "UserPromptSubmit",
"UserPromptSubmit hook must be installed for per-turn context injection")
cmd := extractCmd(t, hooks, "UserPromptSubmit", 0)
assert.Equal(t, "gortex hook", cmd)
}
func TestInstallHookWithMode_EnrichMode(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir())
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeEnrich, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
require.Contains(t, hooks, "PostToolUse")
postCmd := extractCmd(t, hooks, "PostToolUse", 0)
assert.Equal(t, "gortex hook --mode=enrich", postCmd)
preCmd := extractCmd(t, hooks, "PreToolUse", 0)
assert.Equal(t, "gortex hook --mode=enrich", preCmd,
"PreToolUse must use the same --mode=enrich command so it falls back to soft-context")
// PostToolUse uses the matcher restricted to read-shaped tools.
post := hooks["PostToolUse"].([]any)[0].(map[string]any)
assert.Equal(t, CurrentPostToolUseMatcher, post["matcher"])
}
func TestInstallHookWithMode_DenyToEnrichRoundTrip(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir())
// Initial deny install.
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
require.NotContains(t, hooks, "PostToolUse")
assert.Equal(t, "gortex hook", extractCmd(t, hooks, "PreToolUse", 0))
// Switch to enrich.
_, err = InstallHookWithMode(io.Discard, settingsPath, HookModeEnrich, agentsApplyOptsZero())
require.NoError(t, err)
hooks = readSettingsHooks(t, settingsPath)
require.Contains(t, hooks, "PostToolUse", "enrich install must add PostToolUse")
assert.Equal(t, "gortex hook --mode=enrich", extractCmd(t, hooks, "PreToolUse", 0),
"PreToolUse command must be rewritten on mode switch")
assert.Equal(t, "gortex hook --mode=enrich", extractCmd(t, hooks, "PostToolUse", 0))
// Switch back to deny — PostToolUse must be removed, PreToolUse rewritten.
_, err = InstallHookWithMode(io.Discard, settingsPath, HookModeDeny, agentsApplyOptsZero())
require.NoError(t, err)
hooks = readSettingsHooks(t, settingsPath)
assert.NotContains(t, hooks, "PostToolUse", "switch back to deny must drop PostToolUse")
assert.Equal(t, "gortex hook", extractCmd(t, hooks, "PreToolUse", 0),
"switch back to deny must restore bare command on PreToolUse")
}
func TestInstallHookWithMode_EnrichIdempotent(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir())
for i := range 3 {
_, err := InstallHookWithMode(io.Discard, settingsPath, HookModeEnrich, agentsApplyOptsZero())
require.NoError(t, err, "iteration %d", i)
}
hooks := readSettingsHooks(t, settingsPath)
// PostToolUse should have exactly one entry — re-running install
// must not append duplicates.
post := hooks["PostToolUse"].([]any)
assert.Len(t, post, 1, "repeated install must not duplicate the PostToolUse entry")
}
// InstallHook (the back-compat wrapper) must default to deny mode.
func TestInstallHook_DefaultsToDeny(t *testing.T) {
dir := t.TempDir()
settingsPath := filepath.Join(dir, "settings.local.json")
t.Setenv("PATH", t.TempDir())
_, err := InstallHook(io.Discard, settingsPath, agentsApplyOptsZero())
require.NoError(t, err)
hooks := readSettingsHooks(t, settingsPath)
assert.NotContains(t, hooks, "PostToolUse",
"InstallHook (no mode) must behave as deny — no PostToolUse entry")
}
@@ -0,0 +1,164 @@
package claudecode
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
"github.com/zzet/gortex/internal/profiles"
)
// Byte ceilings for the installed ambient layer. Approximated at ~4.4 B/token:
// - 6.5 KiB global section (pointer block + the @-included default
// profile body) ≈ 1.5k tokens
// - 2.5 KiB skills-eager total ≈ 0.57k tokens
//
// Blowing either (a future rule-block or description balloon) fails loudly
// instead of silently re-inflating the per-session tax. Per-profile body
// ceilings live in internal/profiles.
const (
globalSectionByteCeiling = 6656 // 6.5 KiB
skillsEagerByteCeiling = 2560 // 2.5 KiB
)
// frontmatterOf returns the YAML frontmatter block (--- … ---) of a skill /
// sub-agent definition — the part a client loads eagerly to route. Empty when
// the body has no leading frontmatter.
func frontmatterOf(s string) string {
s = strings.TrimLeft(s, "\n")
if !strings.HasPrefix(s, "---\n") {
return ""
}
rest := s[len("---\n"):]
end := strings.Index(rest, "\n---")
if end < 0 {
return ""
}
return "---\n" + rest[:end+len("\n---")]
}
// TestInstalledAmbientByteCeilings is the permanent measurement gate: it prints
// the byte cost of the installed ambient layer and asserts the global rule
// block and the skills-eager total stay inside their ceilings.
func TestInstalledAmbientByteCeilings(t *testing.T) {
// What a session actually pays at the machine level: the pointer
// block in ~/.claude/CLAUDE.md plus the @-included active profile
// body (default profile on a fresh install).
def, _ := profiles.ByName(profiles.DefaultName)
global := len(agents.GlobalPointerBody("/home/user/.gortex/instructions")) + len(def.Body())
skillsFM := 0
for _, body := range GlobalSkills {
skillsFM += len(frontmatterOf(body))
}
subFM := 0
for _, body := range SubAgents {
subFM += len(frontmatterOf(body))
}
t.Logf("installed ambient byte cost:")
t.Logf(" global section (pointer+default profile): %d bytes (ceiling %d)", global, globalSectionByteCeiling)
t.Logf(" skills eager frontmatter : %d bytes (ceiling %d, %d skills)", skillsFM, skillsEagerByteCeiling, len(GlobalSkills))
t.Logf(" sub-agent eager frontmatter: %d bytes (%d agents)", subFM, len(SubAgents))
t.Logf(" projected installed eager: %d bytes", global+skillsFM+subFM)
if global > globalSectionByteCeiling {
t.Errorf("global section (pointer + default profile body) is %d bytes, over the %d ceiling", global, globalSectionByteCeiling)
}
if skillsFM > skillsEagerByteCeiling {
t.Errorf("skills eager frontmatter is %d bytes, over the %d ceiling", skillsFM, skillsEagerByteCeiling)
}
}
// TestGlobalInstall_FatToSlimReplacement is the migration gate: a user
// upgrading from a fat pre-diet rule block gets the thin pointer block
// on re-install (the policy body moves to the @-included instruction
// profile), with the user's own prose around the marker block preserved
// and no duplicate block. Exercises the marker-fenced merge on the real
// install path.
func TestGlobalInstall_FatToSlimReplacement(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
env.InstallGlobalInstructions = true
claudeMd := filepath.Join(env.Home, ".claude", "CLAUDE.md")
if err := os.MkdirAll(filepath.Dir(claudeMd), 0o755); err != nil {
t.Fatal(err)
}
// Seed a realistic pre-diet file: user prose, then a FAT marker block
// carrying content the diet relocates (provider matrix, capabilities
// catalog), then more user prose.
const userAbove = "# My machine notes\n\nAlways run tests with -race.\n"
const userBelow = "\n## My other tooling\n\nUse ripgrep for logs.\n"
fatBlock := agents.GlobalRulesStartMarker + "\n" +
"## MANDATORY: Use Gortex MCP tools instead of Read/Grep/Glob\n\n" +
"### LLM provider\n" +
"one of `local` / `anthropic` / `openai` / `azure` / `ollama` / `claudecli` / `codex` / `copilot` / `cursor` / `opencode` / `gemini` / `bedrock` / `deepseek`\n\n" +
"### Non-obvious capabilities worth knowing\n" +
"- analyze is a 61-kind dispatcher: dead_code, hotspots, cycles via Tarjan's SCC…\n" +
"- TOON tabular text is a compact tabular text, lossy fallback\n" +
agents.GlobalRulesEndMarker + "\n"
seed := userAbove + "\n" + fatBlock + userBelow
if err := os.WriteFile(claudeMd, []byte(seed), 0o644); err != nil {
t.Fatal(err)
}
if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
got, err := os.ReadFile(claudeMd)
if err != nil {
t.Fatal(err)
}
text := string(got)
// User prose around the block survives verbatim.
if !strings.Contains(text, userAbove) {
t.Error("user content above the block was clobbered")
}
if !strings.Contains(text, strings.TrimLeft(userBelow, "\n")) {
t.Error("user content below the block was clobbered")
}
// Exactly one marker block — the fat one was replaced in place, not
// appended alongside.
if n := strings.Count(text, agents.GlobalRulesStartMarker); n != 1 {
t.Errorf("expected exactly 1 rule block, found %d", n)
}
// The block is now the thin pointer: it @-includes the active
// profile copy from the hermetic instructions dir…
activePath := filepath.Join(env.InstructionsDir, profiles.ActiveFileName)
if !strings.Contains(text, "@"+activePath) {
t.Errorf("re-installed block does not @-include the active profile (%s)", activePath)
}
if !strings.Contains(text, "gortex instructions switch") {
t.Error("re-installed block lost the switch verb")
}
// …and the slim policy core moved into the profile file itself.
activeBody, err := os.ReadFile(activePath)
if err != nil {
t.Fatalf("active profile copy was not generated: %v", err)
}
for _, token := range []string{"gortex://guide", "distill_session", "surface_memories", "smart_context"} {
if !strings.Contains(string(activeBody), token) {
t.Errorf("active profile body is missing the policy core token %q", token)
}
}
// …and the relocated fat content is gone.
for _, gone := range []string{
"Non-obvious capabilities worth knowing",
"61-kind dispatcher",
"compact tabular text, lossy",
"`local` / `anthropic` / `openai` / `azure` / `ollama` / `claudecli` / `codex` / `copilot` / `cursor` / `opencode` / `gemini` / `bedrock` / `deepseek`",
} {
if strings.Contains(text, gone) {
t.Errorf("re-installed block still carries relocated fat content %q", gone)
}
}
}
+397
View File
@@ -0,0 +1,397 @@
package claudecode
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)
// PluginBundleSpec describes one generation of the marketplace
// plugin bundle. The same emitter drives the Anthropic
// Plugin Marketplace artifact today; the Cursor variant in a follow-up
// will use the same struct with a different LayoutVariant.
type PluginBundleSpec struct {
// TargetDir is the directory to emit into. The directory is created
// if it does not exist; existing files within it are overwritten.
TargetDir string
// Version is the gortex SemVer string to bake into plugin.json
// (e.g. "0.18.2"). Marketplace entries pin a release ref; this
// string identifies what's inside.
Version string
// LayoutVariant chooses the output shape. Today only
// LayoutVariantAnthropic is implemented; the Cursor variant will
// arrive when its marketplace schema settles.
LayoutVariant LayoutVariant
}
// LayoutVariant enumerates the marketplace layouts the emitter knows
// how to write.
type LayoutVariant string
const (
// LayoutVariantAnthropic targets the Anthropic Plugin Marketplace
// schema (https://anthropic.com/claude-code/marketplace.schema.json
// and the per-plugin layout discovered via the example-plugin
// reference shipped in claude-plugins-official).
LayoutVariantAnthropic LayoutVariant = "anthropic"
)
// pluginAuthorName / pluginAuthorEmail / pluginHomepage are the
// fields baked into every plugin.json. They are package-level so the
// marketplace.json submission script can read the same values
// without re-deriving them.
const (
pluginName = "gortex"
pluginDescription = "Your AI's live map of your codebase — every call, dependency, and contract indexed across your repos, shared across every running agent via a local daemon. Gives Claude Code, Cursor, and any MCP-aware client 52 graph-aware tools so they answer \"what calls this?\", \"what breaks if I rename UserStore?\", or \"which services consume this endpoint?\" in one call instead of grepping for minutes. Real AST parsing across 92 languages — deterministic, zero LLM calls during indexing. Useful before refactoring, when tracing bugs across services, exploring unfamiliar code, or any time your agent reaches for Grep or Read. Returns precise answers tagged with confidence tiers (compiler-grade vs heuristic). Local-first, Apache 2.0 licensed."
pluginAuthorName = "Andrey Kumanyaev"
pluginAuthorEmail = "support@gortex.dev"
pluginHomepage = "https://gortex.dev"
)
// pluginMCPJSON is the .mcp.json content for the marketplace plugin.
// Stdio form with `gortex mcp --proxy`: the marketplace user installs
// the plugin, the binary is on PATH (via the get.gortex.dev installer
// or Homebrew), and `--proxy` auto-detects the daemon and falls back
// to spawning a local MCPServer when no daemon is reachable.
//
// Indentation matches Claude Code's reference plugins (2 spaces, no
// trailing newline beyond the final brace's).
const pluginMCPJSON = `{
"gortex": {
"command": "gortex",
"args": ["mcp"]
}
}
`
// pluginHooksJSON is the hooks/hooks.json content for the
// marketplace plugin. Four event bindings (PreToolUse, PreCompact,
// Stop, SessionStart) all dispatch through ${CLAUDE_PLUGIN_ROOT}/hooks-handlers/gortex-hook.sh
// — a thin wrapper that locates the gortex binary and invokes
// `gortex hook`. The wrapper's job is:
//
// - Fail soft when gortex is missing (print to stderr, exit 0) so
// a marketplace user without the binary doesn't see hard errors
// on every Claude Code action.
// - Forward stdin / stderr / argv unchanged so all today's
// `gortex hook` event-dispatcher logic in internal/hooks/dispatch.go
// keeps working byte-for-byte.
//
// The %s is filled with CurrentPreToolUseMatcher at emit time so the
// marketplace plugin and the `gortex init` settings.json never drift.
const pluginHooksJSON = `{
"description": "Gortex graph-aware hooks: PreToolUse routing, PreCompact orientation, post-task diagnostics, SessionStart cold briefing.",
"hooks": {
"PreToolUse": [
{
"matcher": "%s",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/gortex-hook.sh\"",
"timeout": 3000,
"statusMessage": "Enriching with Gortex graph context..."
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/gortex-hook.sh\"",
"timeout": 3000,
"statusMessage": "Injecting Gortex orientation snapshot..."
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/gortex-hook.sh\"",
"timeout": 5000,
"statusMessage": "Running Gortex post-task diagnostics..."
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks-handlers/gortex-hook.sh\"",
"timeout": 3000,
"statusMessage": "Loading Gortex graph orientation..."
}
]
}
]
}
}
`
// pluginHookHandler is the bash wrapper script that every hook event
// invokes. It locates the gortex binary on PATH, runs `gortex hook`
// with stdin/stderr/argv forwarded, and degrades gracefully when the
// binary is missing — print one warning to stderr and exit 0 so a
// missing binary never blocks the user's Claude Code session.
const pluginHookHandler = `#!/usr/bin/env bash
# gortex marketplace-plugin hook wrapper.
# Locates the gortex binary on PATH and forwards the Claude Code hook
# event to ` + "`gortex hook`" + `. Falls back to a soft-fail (warn-and-exit-0)
# when gortex is not installed, so a missing binary never blocks the
# user's session — the marketplace plugin can always be installed in
# advance of the binary.
#
# Install gortex via: curl -fsSL https://get.gortex.dev | sh
set -u
if ! command -v gortex >/dev/null 2>&1; then
echo "gortex binary not found on PATH — install via 'curl -fsSL https://get.gortex.dev | sh' to enable graph-aware hooks." >&2
exit 0
fi
exec gortex hook "$@"
`
const pluginReadmeBody = `# Gortex — Claude Code Plugin
Gortex is a graph-based code intelligence engine. This plugin gives Claude
Code 52 MCP tools for navigation, refactoring, contracts, impact analysis,
and search across 92 languages — backed by a shared-graph daemon that
keeps multiple agents and editor sessions in sync.
## Install
This plugin assumes the ` + "`gortex`" + ` binary is on PATH. Install it via:
` + "```sh\ncurl -fsSL https://get.gortex.dev | sh\n```" + `
The installer fetches a signed release tarball, verifies cosign + SHA256,
installs to ` + "`~/.local/bin`" + ` (or ` + "`/usr/local/bin`" + `), and runs ` + "`gortex install`" + `
+ ` + "`gortex init`" + `. Homebrew users on macOS can also use:
` + "```sh\nbrew install zzet/tap/gortex\n```" + `
## What this plugin adds
| Surface | What you get |
|---------|--------------|
| **MCP server** | 52 tools (` + "`search_symbols`" + `, ` + "`find_usages`" + `, ` + "`get_call_chain`" + `, ` + "`explain_change_impact`" + `, ` + "`rename_symbol`" + `, ` + "`scaffold`" + `, ` + "`contracts`" + `, …) over stdio via ` + "`gortex mcp --proxy`" + ` |
| **Slash commands** | Discovery: ` + "`/gortex-guide`" + `, ` + "`/gortex-explore`" + `, ` + "`/gortex-debug`" + `, ` + "`/gortex-impact`" + `, ` + "`/gortex-dataflow-trace`" + `, ` + "`/gortex-cross-repo-usage`" + `, ` + "`/gortex-co-change`" + `, ` + "`/gortex-onboarding`" + ` · Edit & refactor: ` + "`/gortex-refactor`" + `, ` + "`/gortex-safe-edit`" + `, ` + "`/gortex-rename`" + `, ` + "`/gortex-extract-function`" + `, ` + "`/gortex-fix-all`" + `, ` + "`/gortex-add-test`" + ` · Review & operate: ` + "`/gortex-pr-review`" + `, ` + "`/gortex-pr-review-agent`" + `, ` + "`/gortex-architecture-review`" + `, ` + "`/gortex-quality-audit`" + `, ` + "`/gortex-incident-investigation`" + `, ` + "`/gortex-episode-replay`" + ` |
| **Skills** | Twenty model-invoked skills that activate by task-shape. Discovery (7): architecture exploration, debugging, blast-radius analysis, dataflow tracing, cross-repo usage, co-change analysis, onboarding tour. Edit & refactor (6): general refactor, safe-edit (` + "`preview_edit`" + ` / ` + "`simulate_chain`" + ` before disk), cross-file rename, LSP-driven extract function, LSP ` + "`source.fixAll`" + `, coverage-led add-test. Review & operate (6): PR review, PR review as a sub-agent (shell ` + "`gortex review --audience agent`" + `), architecture review, quality audit, incident investigation, episode replay. Plus the tool-reference guide. The edit skills enforce the speculative-execution + LSP code-actions order so agents do not bypass safety steps; the review & operate skills wrap the discovery + impact + memory surfaces into ordered playbooks so postmortems and reviews are graph-grounded. |
| **Hooks** | PreToolUse routing (Read → ` + "`get_symbol_source`" + `, Grep → ` + "`search_symbols`" + ` / ` + "`find_usages`" + `), PreCompact orientation snapshot, Stop post-task diagnostics, SessionStart cold briefing |
## First run
After install, point Claude Code at any code repository and ask a task that
involves understanding code structure ("how does authentication work?",
"what breaks if I rename ` + "`UserStore`" + `?"). The hooks redirect Read/Grep/Glob
toward graph queries; the slash commands give you guided workflows.
If ` + "`gortex daemon`" + ` is running (` + "`gortex daemon start --detach`" + ` to start it),
all your editor sessions share one in-memory graph. Otherwise this plugin
spawns a one-shot MCP server per session — same tools, slower cold start.
## Links
- Homepage: https://gortex.dev
- Source: https://github.com/zzet/gortex
- License: https://github.com/zzet/gortex/blob/main/LICENSE.md (Apache 2.0)
`
// pluginLicenseBody is the LICENSE shipped inside the plugin
// directory. Plugins under claude-plugins-official tend to ship the
// project's own license rather than a marketplace-imposed one — we
// follow that pattern by emitting a one-line pointer to the canonical
// LICENSE.md in the upstream repo. Keeps the plugin directory small
// and avoids drift from the source-of-truth license.
const pluginLicenseBody = `Gortex is licensed under the Apache License, Version 2.0. The full
license text lives in LICENSE.md at the root of the upstream repository:
https://github.com/zzet/gortex/blob/main/LICENSE.md
Copyright 2024-2026 Andrey Kumanyaev <me@zzet.org>
`
// EmitPluginBundle writes a marketplace plugin layout under
// spec.TargetDir. The directory is created if missing; existing files
// within it are overwritten so re-runs converge on a deterministic
// output. Returns the list of paths written, relative to TargetDir,
// in stable order.
//
// Single source of truth: skills come from GlobalSkills, slash
// commands from SlashCommands. Both are reused from
// content.go without duplication, so a change to a skill's body
// flows through both the user-level install (~/.claude/skills/) and
// the marketplace plugin (claude-plugin/skills/) on the next emit.
func EmitPluginBundle(spec PluginBundleSpec) ([]string, error) {
if spec.LayoutVariant == "" {
spec.LayoutVariant = LayoutVariantAnthropic
}
if spec.LayoutVariant != LayoutVariantAnthropic {
return nil, fmt.Errorf("unsupported plugin layout variant %q (only %q is implemented)", spec.LayoutVariant, LayoutVariantAnthropic)
}
if spec.TargetDir == "" {
return nil, fmt.Errorf("EmitPluginBundle: TargetDir is required")
}
if spec.Version == "" {
return nil, fmt.Errorf("EmitPluginBundle: Version is required (e.g. \"0.18.2\")")
}
if err := os.MkdirAll(spec.TargetDir, 0o755); err != nil {
return nil, fmt.Errorf("create target dir: %w", err)
}
written := make([]string, 0, 16)
write := func(rel string, body []byte, mode os.FileMode) error {
full := filepath.Join(spec.TargetDir, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(rel), err)
}
if err := os.WriteFile(full, body, mode); err != nil {
return fmt.Errorf("write %s: %w", rel, err)
}
written = append(written, rel)
return nil
}
// 1. .claude-plugin/plugin.json
manifest := map[string]any{
"name": pluginName,
"description": pluginDescription,
"version": spec.Version,
"author": map[string]any{
"name": pluginAuthorName,
"email": pluginAuthorEmail,
},
"homepage": pluginHomepage,
}
manifestJSON, err := marshalJSONStable(manifest)
if err != nil {
return nil, fmt.Errorf("marshal plugin.json: %w", err)
}
if err := write(".claude-plugin/plugin.json", manifestJSON, 0o644); err != nil {
return nil, err
}
// 2. README.md
if err := write("README.md", []byte(pluginReadmeBody), 0o644); err != nil {
return nil, err
}
// 3. LICENSE
if err := write("LICENSE", []byte(pluginLicenseBody), 0o644); err != nil {
return nil, err
}
// 4. .mcp.json
if err := write(".mcp.json", []byte(pluginMCPJSON), 0o644); err != nil {
return nil, err
}
// 5. commands/gortex-*.md — sorted for deterministic output.
for _, name := range sortedKeys(SlashCommands) {
body := SlashCommands[name]
if err := write(filepath.Join("commands", name), []byte(body), 0o644); err != nil {
return nil, err
}
}
// 6. skills/<name>/SKILL.md — sorted for deterministic output.
for _, name := range sortedKeys(GlobalSkills) {
body := GlobalSkills[name]
if err := write(filepath.Join("skills", name, "SKILL.md"), []byte(body), 0o644); err != nil {
return nil, err
}
}
// 7. agents/<name> — sub-agent definitions, sorted for
// deterministic output. Mirrors the on-disk path Claude Code
// expects under .claude/agents/ when the plugin is installed.
for _, name := range sortedKeys(SubAgents) {
body := SubAgents[name]
if err := write(filepath.Join("agents", name), []byte(body), 0o644); err != nil {
return nil, err
}
}
// 8. hooks/hooks.json + hooks-handlers/gortex-hook.sh
if err := write("hooks/hooks.json", fmt.Appendf(nil, pluginHooksJSON, CurrentPreToolUseMatcher), 0o644); err != nil {
return nil, err
}
if err := write("hooks-handlers/gortex-hook.sh", []byte(pluginHookHandler), 0o755); err != nil {
return nil, err
}
return written, nil
}
// marshalJSONStable returns indented JSON with sorted keys at every
// level. Two-space indent matches Claude Code's reference plugins;
// trailing newline keeps the file POSIX-text-friendly and avoids
// editor diff noise.
func marshalJSONStable(v any) ([]byte, error) {
out, err := json.MarshalIndent(v, "", " ")
if err != nil {
return nil, err
}
return append(out, '\n'), nil
}
// sortedKeys returns the keys of m in lexicographic order. Used so
// the on-disk emit order is independent of map iteration order, which
// keeps the directory diff-stable across Go versions.
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// PluginManifestPath / PluginMCPPath / PluginHooksPath return the
// in-bundle paths the emitter writes. Exposed so callers (e.g. the
// CI drift guard) can refer to them without hardcoding path strings.
func PluginManifestPath() string { return filepath.Join(".claude-plugin", "plugin.json") }
func PluginMCPPath() string { return ".mcp.json" }
func PluginHooksPath() string { return filepath.Join("hooks", "hooks.json") }
// PluginCommandPaths returns the in-bundle paths of all slash command
// files in stable order.
func PluginCommandPaths() []string {
out := make([]string, 0, len(SlashCommands))
for _, name := range sortedKeys(SlashCommands) {
out = append(out, filepath.Join("commands", name))
}
return out
}
// PluginSkillPaths returns the in-bundle paths of all SKILL.md files
// in stable order.
func PluginSkillPaths() []string {
out := make([]string, 0, len(GlobalSkills))
for _, name := range sortedKeys(GlobalSkills) {
out = append(out, filepath.Join("skills", name, "SKILL.md"))
}
return out
}
// PluginSubAgentPaths returns the in-bundle paths of all sub-agent
// files in stable order.
func PluginSubAgentPaths() []string {
out := make([]string, 0, len(SubAgents))
for _, name := range sortedKeys(SubAgents) {
out = append(out, filepath.Join("agents", name))
}
return out
}
+390
View File
@@ -0,0 +1,390 @@
package claudecode
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
func TestEmitPluginBundle_Layout(t *testing.T) {
dir := t.TempDir()
written, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
})
if err != nil {
t.Fatalf("EmitPluginBundle: %v", err)
}
// The emitter should write exactly: 1 plugin.json + 1 README +
// 1 LICENSE + 1 .mcp.json + N commands + N skills + 1 hooks.json
// + 1 hook handler script.
wantPaths := []string{
".claude-plugin/plugin.json",
".mcp.json",
"LICENSE",
"README.md",
"hooks-handlers/gortex-hook.sh",
"hooks/hooks.json",
}
for _, name := range sortedKeys(SlashCommands) {
wantPaths = append(wantPaths, filepath.Join("commands", name))
}
for _, name := range sortedKeys(GlobalSkills) {
wantPaths = append(wantPaths, filepath.Join("skills", name, "SKILL.md"))
}
for _, name := range sortedKeys(SubAgents) {
wantPaths = append(wantPaths, filepath.Join("agents", name))
}
sort.Strings(wantPaths)
got := append([]string(nil), written...)
sort.Strings(got)
if len(got) != len(wantPaths) {
t.Fatalf("file count mismatch: got %d, want %d.\ngot=%v\nwant=%v", len(got), len(wantPaths), got, wantPaths)
}
for i := range got {
if got[i] != wantPaths[i] {
t.Errorf("path[%d] mismatch: got %q, want %q", i, got[i], wantPaths[i])
}
}
// Every path the emitter claimed to write must exist on disk.
for _, rel := range written {
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
t.Errorf("missing file %s: %v", rel, err)
}
}
}
func TestEmitPluginBundle_PluginManifestShape(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "plugin.json"))
if err != nil {
t.Fatal(err)
}
var manifest map[string]any
if err := json.Unmarshal(body, &manifest); err != nil {
t.Fatalf("plugin.json: invalid JSON: %v", err)
}
// Required keys per the marketplace schema we observed in
// claude-plugins-official: name, description, author. We add
// version + homepage for our own metadata.
for _, key := range []string{"name", "description", "author", "version", "homepage"} {
if _, ok := manifest[key]; !ok {
t.Errorf("plugin.json missing required key %q", key)
}
}
if got := manifest["name"]; got != "gortex" {
t.Errorf("plugin.json name = %v, want gortex", got)
}
if got := manifest["version"]; got != "0.18.2" {
t.Errorf("plugin.json version = %v, want 0.18.2", got)
}
if !strings.HasSuffix(string(body), "\n") {
t.Errorf("plugin.json should end with a newline (got %q)", string(body[len(body)-3:]))
}
}
func TestEmitPluginBundle_MCPJSONShape(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(dir, ".mcp.json"))
if err != nil {
t.Fatal(err)
}
var mcp map[string]any
if err := json.Unmarshal(body, &mcp); err != nil {
t.Fatalf(".mcp.json: invalid JSON: %v", err)
}
gx, ok := mcp["gortex"].(map[string]any)
if !ok {
t.Fatalf(".mcp.json: missing 'gortex' entry, got %v", mcp)
}
if got := gx["command"]; got != "gortex" {
t.Errorf(".mcp.json command = %v, want gortex", got)
}
args, ok := gx["args"].([]any)
if !ok || len(args) != 1 || args[0] != "mcp" {
t.Errorf(".mcp.json args = %v, want [mcp]", args)
}
}
func TestEmitPluginBundle_HooksShape(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(dir, "hooks", "hooks.json"))
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(body, &doc); err != nil {
t.Fatalf("hooks.json: invalid JSON: %v", err)
}
hooks, ok := doc["hooks"].(map[string]any)
if !ok {
t.Fatalf("hooks.json: missing top-level 'hooks' object: %v", doc)
}
// All four event kinds must be present; each must reference the
// hooks-handlers script via ${CLAUDE_PLUGIN_ROOT}.
for _, event := range []string{"PreToolUse", "PreCompact", "Stop", "SessionStart"} {
entries, ok := hooks[event].([]any)
if !ok || len(entries) == 0 {
t.Errorf("hooks.json: event %s missing or empty", event)
continue
}
entry := entries[0].(map[string]any)
inner, ok := entry["hooks"].([]any)
if !ok || len(inner) == 0 {
t.Errorf("hooks.json: %s has no inner hooks", event)
continue
}
first := inner[0].(map[string]any)
cmd, _ := first["command"].(string)
if !strings.Contains(cmd, "${CLAUDE_PLUGIN_ROOT}") {
t.Errorf("hooks.json: %s command does not reference ${CLAUDE_PLUGIN_ROOT}: %q", event, cmd)
}
if !strings.Contains(cmd, "gortex-hook.sh") {
t.Errorf("hooks.json: %s command does not invoke gortex-hook.sh: %q", event, cmd)
}
}
// PreToolUse should carry the canonical matcher.
pre := hooks["PreToolUse"].([]any)
matcher, _ := pre[0].(map[string]any)["matcher"].(string)
if matcher != CurrentPreToolUseMatcher {
t.Errorf("hooks.json: PreToolUse matcher = %q, want %q", matcher, CurrentPreToolUseMatcher)
}
}
func TestEmitPluginBundle_HookHandlerExecBit(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(dir, "hooks-handlers", "gortex-hook.sh"))
if err != nil {
t.Fatal(err)
}
if info.Mode()&0o111 == 0 {
t.Errorf("hooks-handlers/gortex-hook.sh should be executable (mode=%v)", info.Mode())
}
}
func TestEmitPluginBundle_SkillBodiesMatchSource(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
// Single-source-of-truth check: the bytes written under
// skills/<name>/SKILL.md must equal GlobalSkills[<name>] exactly.
// Drift means content.go was edited without re-running the
// emitter; the CI guard catches that case but we double-check
// here in unit tests.
for name, want := range GlobalSkills {
got, err := os.ReadFile(filepath.Join(dir, "skills", name, "SKILL.md"))
if err != nil {
t.Errorf("read skill %s: %v", name, err)
continue
}
if string(got) != want {
t.Errorf("skill %s body drift: bytes do not match GlobalSkills[%q]", name, name)
}
}
}
func TestEmitPluginBundle_CommandBodiesMatchSource(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
for name, want := range SlashCommands {
got, err := os.ReadFile(filepath.Join(dir, "commands", name))
if err != nil {
t.Errorf("read command %s: %v", name, err)
continue
}
if string(got) != want {
t.Errorf("command %s body drift", name)
}
}
}
func TestEmitPluginBundle_Idempotent(t *testing.T) {
dir := t.TempDir()
first, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"})
if err != nil {
t.Fatal(err)
}
second, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"})
if err != nil {
t.Fatalf("second emit: %v", err)
}
if len(first) != len(second) {
t.Fatalf("file count drift between runs: %d vs %d", len(first), len(second))
}
// Every file's bytes must be identical across the two runs.
for _, rel := range first {
path := filepath.Join(dir, rel)
body1, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
// Re-emit and re-read: same path, must equal first body.
body2, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(body1) != string(body2) {
t.Errorf("non-idempotent output at %s", rel)
}
}
}
func TestEmitPluginBundle_RejectsBadInputs(t *testing.T) {
cases := map[string]PluginBundleSpec{
"missing-target": {Version: "0.18.2"},
"missing-version": {TargetDir: t.TempDir()},
"bad-variant": {TargetDir: t.TempDir(), Version: "0.18.2", LayoutVariant: "weird"},
}
for name, spec := range cases {
_, err := EmitPluginBundle(spec)
if err == nil {
t.Errorf("%s: expected error, got nil", name)
}
}
}
func TestEmitPluginBundle_VariantDefaultsToAnthropic(t *testing.T) {
dir := t.TempDir()
// Empty LayoutVariant should fall through to LayoutVariantAnthropic.
_, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"})
if err != nil {
t.Fatalf("empty variant: %v", err)
}
// Verify by checking that the Anthropic-shaped manifest exists.
if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil {
t.Errorf("default variant did not produce .claude-plugin/plugin.json: %v", err)
}
}
func TestEmitPluginBundle_OverwritesExistingFiles(t *testing.T) {
dir := t.TempDir()
// Pre-seed an old README to ensure overwrite works.
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("OLD\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"}); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(dir, "README.md"))
if err != nil {
t.Fatal(err)
}
if string(body) == "OLD\n" {
t.Errorf("emitter did not overwrite existing README.md")
}
}
func TestPluginPathHelpers(t *testing.T) {
if PluginManifestPath() != filepath.Join(".claude-plugin", "plugin.json") {
t.Errorf("PluginManifestPath wrong: %s", PluginManifestPath())
}
if PluginMCPPath() != ".mcp.json" {
t.Errorf("PluginMCPPath wrong: %s", PluginMCPPath())
}
if PluginHooksPath() != filepath.Join("hooks", "hooks.json") {
t.Errorf("PluginHooksPath wrong: %s", PluginHooksPath())
}
cmds := PluginCommandPaths()
if len(cmds) != len(SlashCommands) {
t.Errorf("PluginCommandPaths count: got %d, want %d", len(cmds), len(SlashCommands))
}
skills := PluginSkillPaths()
if len(skills) != len(GlobalSkills) {
t.Errorf("PluginSkillPaths count: got %d, want %d", len(skills), len(GlobalSkills))
}
subagents := PluginSubAgentPaths()
if len(subagents) != len(SubAgents) {
t.Errorf("PluginSubAgentPaths count: got %d, want %d", len(subagents), len(SubAgents))
}
}
func TestEmitPluginBundle_SubAgentBodiesMatchSource(t *testing.T) {
dir := t.TempDir()
if _, err := EmitPluginBundle(PluginBundleSpec{
TargetDir: dir,
Version: "0.18.2",
}); err != nil {
t.Fatal(err)
}
for name, want := range SubAgents {
got, err := os.ReadFile(filepath.Join(dir, "agents", name))
if err != nil {
t.Errorf("read sub-agent %s: %v", name, err)
continue
}
if string(got) != want {
t.Errorf("sub-agent %s body drift: bytes do not match SubAgents[%q]", name, name)
}
}
}
func TestEmitPluginBundle_MissingTargetParent(t *testing.T) {
// MkdirAll handles non-existent parents — verify that.
dir := filepath.Join(t.TempDir(), "deep", "nested", "path")
_, err := EmitPluginBundle(PluginBundleSpec{TargetDir: dir, Version: "0.18.2"})
if err != nil {
t.Errorf("EmitPluginBundle should create parent dirs: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".claude-plugin", "plugin.json")); err != nil {
if errors.Is(err, os.ErrNotExist) {
t.Errorf("emitter did not create plugin.json at deep target")
}
}
}
+104
View File
@@ -0,0 +1,104 @@
package claudecode
import "strings"
// SubAgents maps the filename under .claude/agents/ to the markdown
// body of a Claude Code sub-agent definition.
//
// Sub-agents are a distinct Claude Code ship channel from skills and
// slash commands: the parent agent spawns a sub-agent in a fresh
// context window with a scoped tool allowlist, and the sub-agent
// returns a single summary message. That makes them the right
// container for long-running search and impact-analysis loops — the
// parent's context stays clean and the tool allowlist enforces
// graph-only access by construction.
//
// Like GlobalSkills and SlashCommands, sub-agents are codebase-agnostic
// user-level artifacts: installed by `gortex install` into
// ~/.claude/agents/ and emitted into the marketplace plugin under
// agents/ — never written in project mode.
var SubAgents = map[string]string{
"gortex-search.md": subagentSearch,
"gortex-impact.md": subagentImpact,
}
// SubAgentTools parses the `tools:` allowlist out of a sub-agent definition's
// YAML frontmatter, returning the declared MCP tool names in order (nil when
// the definition declares no tools line). Claude Code spawns each sub-agent in
// a fresh context restricted to exactly this allowlist, so the list is how
// Gortex propagates its MCP tools to sub-agents — and how it guarantees a
// sub-agent stays graph-only (no Bash/Grep/Glob escape).
func SubAgentTools(def string) []string {
for _, line := range strings.Split(def, "\n") {
t := strings.TrimSpace(line)
if !strings.HasPrefix(t, "tools:") {
continue
}
rest := strings.TrimSpace(strings.TrimPrefix(t, "tools:"))
var out []string
for _, name := range strings.Split(rest, ",") {
if n := strings.TrimSpace(name); n != "" {
out = append(out, n)
}
}
return out
}
return nil
}
// subagentSearch handles exploratory codebase questions: locating
// symbols, tracing call paths, mapping architecture. Restricting the
// tool allowlist to gortex graph tools (+ read_file as a fallback for
// non-indexed assets) forces the sub-agent onto the graph — no Bash,
// no Grep, no Glob escape.
const subagentSearch = `---
name: gortex-search
description: "Locate code, trace call paths, or map architecture in a fresh context."
tools: mcp__gortex__smart_context, mcp__gortex__surface_memories, mcp__gortex__search_symbols, mcp__gortex__search_text, mcp__gortex__get_symbol_source, mcp__gortex__get_editing_context, mcp__gortex__get_file_summary, mcp__gortex__find_usages, mcp__gortex__get_callers, mcp__gortex__get_call_chain, mcp__gortex__get_dependencies, mcp__gortex__get_dependents, mcp__gortex__get_repo_outline, mcp__gortex__get_architecture, mcp__gortex__graph_stats, mcp__gortex__get_symbol, mcp__gortex__read_file
---
You are the Gortex search sub-agent. The parent agent delegated a code-locating, exploration, or call-tracing question to you. You return a single summary message — the parent does not see your tool calls.
Your tool surface is graph-only. There is no Bash, Grep, or Glob. Use the graph:
1. Call ` + "`smart_context`" + ` with the task description first. One call replaces 5-10 read-and-search round trips.
2. Call ` + "`surface_memories`" + ` with the symbols smart_context surfaced, to pick up invariants / gotchas the team has recorded.
3. For "where is X" — ` + "`search_symbols`" + ` then ` + "`get_symbol_source`" + `. For a raw string / literal that is not a symbol name — ` + "`search_text`" + ` (trigram-indexed; the grep replacement).
4. For "what calls X" — ` + "`get_callers`" + ` or ` + "`get_call_chain`" + ` (NOT ` + "`find_usages`" + `, which over-returns text matches; callers gives you the precise call graph).
5. For "what does X depend on" — ` + "`get_dependencies`" + ` (out) and ` + "`get_dependents`" + ` (in).
6. For architecture / "how does the system fit together" — ` + "`get_architecture`" + ` (one-shot snapshot; pass ` + "`resolution`" + ` for a hierarchical rollup) or ` + "`get_repo_outline`" + `, then drill into the named communities with ` + "`smart_context`" + `.
Pass ` + "`format: \"gcx\"`" + ` to any list-shaped call (search_symbols, find_usages, smart_context, get_callers, get_dependencies, get_dependents) — round-trippable compact wire format, ~27% fewer tokens.
When you reply to the parent: give the answer first (symbol IDs, file:line, the call chain), then the evidence (one or two key source fragments), then any caveats. Do not dump raw tool output. The parent paid for context isolation — earn it by summarising.
`
// subagentImpact handles "what breaks if I change X" questions.
// Returns a small, high-signal answer (caller count, broken interface
// implementors, suggested tests) from a heavy graph traversal that
// would otherwise blow up the parent's context.
const subagentImpact = `---
name: gortex-impact
description: "Assess a change's blast radius — broken callers, contracts, tests."
tools: mcp__gortex__smart_context, mcp__gortex__surface_memories, mcp__gortex__search_symbols, mcp__gortex__get_symbol_source, mcp__gortex__find_usages, mcp__gortex__get_callers, mcp__gortex__get_call_chain, mcp__gortex__get_dependents, mcp__gortex__verify_change, mcp__gortex__explain_change_impact, mcp__gortex__analyze, mcp__gortex__simulate_chain, mcp__gortex__preview_edit, mcp__gortex__check_guards, mcp__gortex__get_test_targets, mcp__gortex__contracts, mcp__gortex__read_file
---
You are the Gortex impact-analysis sub-agent. The parent delegated a "what will break" question to you. You return a single summary message.
Your job is to convert a proposed change into a small, actionable impact report. The graph already knows callers and contracts — your output should be the conclusion, not the traversal.
Workflow:
1. ` + "`smart_context`" + ` on the affected symbols to get the working set.
2. ` + "`surface_memories`" + ` to pick up invariants on the touched symbols (memories often warn about why a signature is load-bearing).
3. If the change is a signature edit — ` + "`verify_change`" + ` with the proposed new_signature. Returns every broken caller and interface implementor.
4. If the change is more structural (deletion, contract revision) — ` + "`get_callers`" + ` + ` + "`get_dependents`" + ` + ` + "`contracts`" + `.
5. For a speculative WorkspaceEdit — ` + "`preview_edit`" + ` or ` + "`simulate_chain`" + `. The graph diff is computed on disk-untouched state.
6. ` + "`check_guards`" + ` against the changed symbol IDs to surface team rules.
7. ` + "`get_test_targets`" + ` to enumerate tests that exercise the change.
8. When the parent wants a single risk number rather than a caller list — ` + "`analyze`" + ` with ` + "`kind: \"impact\"`" + ` returns a composite 0-100 change-impact score + risk label over five axes (PageRank, reach, complexity, co-change, community span).
Pass ` + "`format: \"gcx\"`" + ` on list-shaped responses for ~27% fewer tokens.
Reply structure: (a) one-line verdict — safe / risky / breaking — (b) the broken callers / contracts / guards if any, with file:line, (c) the test targets the parent should run. Do not paste full source. The parent wants the conclusion plus enough breadcrumbs to verify; the heavy lifting stays in your context.
`
@@ -0,0 +1,50 @@
package claudecode
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestSubAgentToolPropagation locks in the invariant that every Gortex
// sub-agent declares an explicit, graph-only MCP tool allowlist — the
// mechanism by which Gortex propagates its tools to sub-agents and prevents a
// sub-agent from escaping to Bash/Grep/Glob.
func TestSubAgentToolPropagation(t *testing.T) {
require.Contains(t, SubAgents, "gortex-search.md")
require.Contains(t, SubAgents, "gortex-impact.md")
for name, def := range SubAgents {
t.Run(name, func(t *testing.T) {
tools := SubAgentTools(def)
require.NotEmpty(t, tools,
"%s must declare a tools allowlist so the sub-agent inherits Gortex tools", name)
seen := map[string]bool{}
for _, tool := range tools {
require.Truef(t, strings.HasPrefix(tool, "mcp__gortex__"),
"%s lists non-gortex tool %q — the allowlist must be graph-only (no Bash/Grep/Glob escape)", name, tool)
require.Falsef(t, seen[tool], "%s lists duplicate tool %q", name, tool)
seen[tool] = true
}
// smart_context is the shared entry point every sub-agent should hold.
require.Truef(t, seen["mcp__gortex__smart_context"],
"%s should grant smart_context (the one-call working-set entry point)", name)
})
}
}
// TestSubAgentFrontmatterNameMatchesFile guards against a rename drifting the
// frontmatter `name:` away from the installed filename.
func TestSubAgentFrontmatterNameMatchesFile(t *testing.T) {
for name, def := range SubAgents {
base := strings.TrimSuffix(name, ".md")
require.Containsf(t, def, "name: "+base,
"%s frontmatter name must match its filename", name)
}
}
func TestSubAgentToolsParsesEmpty(t *testing.T) {
require.Nil(t, SubAgentTools("---\nname: x\ndescription: no tools line\n---\nbody"))
}
+144
View File
@@ -0,0 +1,144 @@
// Package cline implements the Gortex init integration for Cline
// (formerly Claude Dev — extension ID saoudrizwan.claude-dev). Cline
// stores its MCP config under the host editor's globalStorage
// directory; we probe both the VS Code and Cursor variants on each
// OS. The Step 3 audit will add JetBrains support and verify whether
// Cline has renamed alwaysAllow → autoApprove.
package cline
import (
"os"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "cline"
const DocsURL = "https://docs.cline.bot/mcp/mcp-overview"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// settingsPaths returns candidate cline_mcp_settings.json locations
// across OS/editor combos. Order doesn't matter — we write to every
// path whose parent directory exists.
func settingsPaths(home string) []string {
var paths []string
vscodeBases := []string{
filepath.Join(home, "Library", "Application Support", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings"),
filepath.Join(home, ".config", "Code", "User", "globalStorage", "saoudrizwan.claude-dev", "settings"),
}
cursorBases := []string{
filepath.Join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "saoudrizwan.claude-dev", "settings"),
filepath.Join(home, ".config", "Cursor", "User", "globalStorage", "saoudrizwan.claude-dev", "settings"),
}
for _, dir := range append(vscodeBases, cursorBases...) {
paths = append(paths, filepath.Join(dir, "cline_mcp_settings.json"))
}
return paths
}
// alwaysAllow is Cline's auto-approve allow-list. Matches the
// Gortex tool surface so Cline doesn't ask the user on every query.
var alwaysAllow = []string{
"graph_stats", "search_symbols", "winnow_symbols", "get_symbol", "get_file_summary",
"get_editing_context", "get_dependencies", "get_dependents",
"get_call_chain", "get_callers", "find_implementations", "find_usages",
"get_cluster", "get_symbol_signature", "get_symbol_source", "batch_symbols",
"find_import_path", "explain_change_impact", "get_recent_changes",
"smart_context", "get_edit_plan", "get_test_targets", "suggest_pattern",
"get_communities", "get_community", "get_processes", "get_process",
"detect_changes", "index_repository", "reindex_repository",
"verify_change", "check_guards", "prefetch_context",
"find_dead_code", "find_hotspots", "find_cycles", "would_create_cycle",
"diff_context", "index_health", "get_symbol_history",
"scaffold", "batch_edit",
"flow_between", "taint_paths",
"find_clones",
}
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if env.Home == "" {
return false, nil
}
for _, p := range settingsPaths(env.Home) {
if _, err := os.Stat(filepath.Dir(p)); err == nil {
return true, nil
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if env.Home == "" {
return p, nil
}
for _, path := range settingsPaths(env.Home) {
if _, err := os.Stat(filepath.Dir(path)); err == nil {
p.Files = append(p.Files, agents.FileAction{
Path: path,
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
}
}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".clinerules", "gortex-communities.md"),
Action: agents.ActionWouldCreate,
Keys: []string{"communities-rule"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Cline setup (Cline not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Cline integration...")
for _, path := range settingsPaths(env.Home) {
if _, err := os.Stat(filepath.Dir(path)); err != nil {
continue
}
entry := agents.DefaultGortexMCPEntry()
entry["alwaysAllow"] = alwaysAllow
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", entry, opts), nil
}, opts)
if err != nil {
internalutil.Warnf(env.Stderr, "could not configure Cline at %s: %v", path, err)
continue
}
res.Files = append(res.Files, action)
}
// Cline reads .clinerules/*.md as project-scoped instructions
// on every chat turn. The community-routing file is ours
// end-to-end — regenerated each `gortex init` run so the
// listing tracks the current graph. Skipped in global mode
// (file is per-repo) and when --no-skills / no communities
// qualify.
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
rulesPath := filepath.Join(env.Root, ".clinerules", "gortex-communities.md")
body := agents.CommunitiesStartMarker + "\n" + env.SkillsRouting + "\n" + agents.CommunitiesEndMarker + "\n"
ruleAction, err := agents.WriteOwnedFile(env.Stderr, rulesPath, body, opts)
if err != nil {
internalutil.Warnf(env.Stderr, "could not write Cline community rules at %s: %v", rulesPath, err)
} else {
res.Files = append(res.Files, ruleAction)
}
}
res.Configured = len(res.Files) > 0
return res, nil
}
+453
View File
@@ -0,0 +1,453 @@
// Package codex implements the Gortex init integration for the
// OpenAI Codex CLI. Codex stores MCP server definitions in a TOML
// file — ~/.codex/config.toml for the default scope — under the
// [mcp_servers.<name>] table:
//
// [mcp_servers.gortex]
// command = "gortex"
// args = ["mcp", "--index", ".", "--watch"]
// [mcp_servers.gortex.env]
// GORTEX_INDEX_WORKERS = "8"
//
// Docs: https://github.com/openai/codex/blob/main/docs/config.md
package codex
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "codex"
const DocsURL = "https://developers.openai.com/codex/mcp"
const codexSessionStartMatcher = "startup|resume|clear|compact"
const codexSessionStartMessage = "IMPORTANT: Prefer Gortex MCP tools (search_symbols, get_callers, get_file_summary, edit_file) over Read/Grep/Glob/Edit."
const codexSessionStartCommand = "printf '%s\\n' '" + codexSessionStartMessage + "'"
const codexSessionStartWindowsCommand = "powershell -NoProfile -Command \"Write-Output '" + codexSessionStartMessage + "'\""
const codexPreToolUseMatcher = "^Bash$"
const codexMCPReadPreToolUseMatcher = "^mcp__gortex__(read_file|get_editing_context)$"
const codexPostToolUseMatcher = "^Bash$"
const codexHookTimeoutSeconds = 5
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect checks for the codex CLI on PATH or ~/.codex/.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("codex"); err == nil && p != "" {
return true, nil
}
if env.Home == "" {
return false, nil
}
if _, err := os.Stat(filepath.Join(env.Home, ".codex")); err == nil {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if env.Home != "" {
keys := []string{"mcp_servers"}
if env.InstallHooks {
keys = append(keys, "hooks")
}
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Home, ".codex", "config.toml"),
Action: agents.ActionWouldMerge,
Keys: keys,
})
}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, "AGENTS.md"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Codex setup (codex not detected)")
return res, nil
}
if env.Home == "" {
return res, fmt.Errorf("codex: requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up OpenAI Codex CLI integration...")
path := filepath.Join(env.Home, ".codex", "config.toml")
action, err := agents.MergeTOML(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
changed := false
servers, ok := root["mcp_servers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if _, exists := servers["gortex"]; !exists || opts.Force {
servers["gortex"] = map[string]any{
"command": "gortex",
"args": []string{"mcp"},
"env": map[string]any{
"GORTEX_INDEX_WORKERS": "8",
},
}
root["mcp_servers"] = servers
changed = true
}
if env.InstallHooks {
if upsertCodexHooks(root, env, opts) {
changed = true
}
}
return changed, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// Repo-local community routing → AGENTS.md (also read by
// OpenCode; both adapters upsert the same marker-guarded block,
// so repeat runs converge). Skipped in global mode (AGENTS.md
// is per-repo) and when no communities were generated.
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
agentsMdPath := filepath.Join(env.Root, "AGENTS.md")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, agentsMdPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
func upsertSessionStartHook(root map[string]any, opts agents.ApplyOpts) bool {
return upsertCodexHook(root, "SessionStart", codexHookEntryIsGortexSessionStart, codexSessionStartHookEntry(), opts)
}
func upsertPreToolUseHook(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
desired := []map[string]any{
codexPreToolUseHookEntry(env),
codexMCPReadPreToolUseHookEntry(env),
}
return upsertCodexHookSet(root, "PreToolUse", codexHookEntryIsGortexPreToolUse, desired, opts)
}
func upsertPostToolUseHook(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
return upsertCodexHook(root, "PostToolUse", codexHookEntryIsGortexPostToolUse, codexPostToolUseHookEntry(env), opts)
}
func upsertUserPromptSubmitHook(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
return upsertCodexHook(root, "UserPromptSubmit", codexHookEntryIsGortexUserPromptSubmit, codexUserPromptSubmitHookEntry(env), opts)
}
// InstallHooksOnly refreshes the Codex lifecycle hooks in configPath without
// touching MCP server entries, AGENTS.md, or any other Codex adapter surface.
func InstallHooksOnly(w io.Writer, configPath string, env agents.Env, opts agents.ApplyOpts) (agents.FileAction, error) {
action, err := agents.MergeTOML(w, configPath, func(root map[string]any, _ bool) (bool, error) {
return upsertCodexHooks(root, env, opts), nil
}, opts)
if err != nil {
return agents.FileAction{}, err
}
if action.Action != agents.ActionSkip {
action.Keys = []string{"hooks"}
}
return action, nil
}
func upsertCodexHooks(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
sessionChanged := upsertSessionStartHook(root, opts)
preChanged := upsertPreToolUseHook(root, env, opts)
postChanged := upsertPostToolUseHook(root, env, opts)
promptChanged := upsertUserPromptSubmitHook(root, env, opts)
return sessionChanged || preChanged || postChanged || promptChanged
}
func upsertCodexHook(root map[string]any, event string, isGortex func(any) bool, desired map[string]any, opts agents.ApplyOpts) bool {
hooks, ok := root["hooks"].(map[string]any)
if !ok {
if _, exists := root["hooks"]; exists {
return false
}
hooks = make(map[string]any)
}
entries, ok := codexHookList(hooks[event])
if !ok {
return false
}
found := false
kept := make([]any, 0, len(entries)+1)
for _, entry := range entries {
if isGortex(entry) {
found = true
if opts.Force {
continue
}
}
kept = append(kept, entry)
}
if found && !opts.Force {
return false
}
hooks[event] = append(kept, desired)
root["hooks"] = hooks
return true
}
func upsertCodexHookSet(root map[string]any, event string, isGortex func(any) bool, desired []map[string]any, opts agents.ApplyOpts) bool {
hooks, ok := root["hooks"].(map[string]any)
if !ok {
if _, exists := root["hooks"]; exists {
return false
}
hooks = make(map[string]any)
}
entries, ok := codexHookList(hooks[event])
if !ok {
return false
}
found := make([]bool, len(desired))
kept := make([]any, 0, len(entries)+len(desired))
for _, entry := range entries {
if isGortex(entry) {
if opts.Force {
continue
}
for i, want := range desired {
if !found[i] && codexHookEntryMatchesDesired(entry, want) {
found[i] = true
break
}
}
}
kept = append(kept, entry)
}
changed := false
for i, want := range desired {
if opts.Force || !found[i] {
kept = append(kept, want)
changed = true
}
}
if !changed {
return false
}
hooks[event] = kept
root["hooks"] = hooks
return true
}
func codexHookEntryMatchesDesired(entry any, desired map[string]any) bool {
matcher, _ := desired["matcher"].(string)
return codexHookEntryHasMatcher(entry, matcher) && codexHookEntryInvokesCodexHook(entry)
}
func codexHookEntryHasMatcher(entry any, matcher string) bool {
group, ok := entry.(map[string]any)
if !ok {
return false
}
got, _ := group["matcher"].(string)
return got == matcher
}
func codexHookList(v any) ([]any, bool) {
if v == nil {
return nil, true
}
switch list := v.(type) {
case []any:
return append([]any(nil), list...), true
case []map[string]any:
out := make([]any, 0, len(list))
for _, entry := range list {
out = append(out, entry)
}
return out, true
default:
return nil, false
}
}
func codexHookEntryIsGortexSessionStart(entry any) bool {
group, ok := entry.(map[string]any)
if !ok {
return false
}
handlers, ok := codexHookList(group["hooks"])
if !ok {
return false
}
for _, handler := range handlers {
hm, ok := handler.(map[string]any)
if !ok {
continue
}
if cmd, _ := hm["command"].(string); cmd == codexSessionStartCommand {
return true
}
if cmd, _ := hm["command_windows"].(string); cmd == codexSessionStartWindowsCommand {
return true
}
}
return false
}
func codexHookEntryIsGortexPreToolUse(entry any) bool {
return codexHookEntryInvokesCodexHook(entry)
}
func codexHookEntryIsGortexPostToolUse(entry any) bool {
return codexHookEntryInvokesCodexHook(entry)
}
func codexHookEntryIsGortexUserPromptSubmit(entry any) bool {
return codexHookEntryInvokesCodexHook(entry)
}
func codexHookEntryInvokesCodexHook(entry any) bool {
group, ok := entry.(map[string]any)
if !ok {
return false
}
handlers, ok := codexHookList(group["hooks"])
if !ok {
return false
}
for _, handler := range handlers {
hm, ok := handler.(map[string]any)
if !ok {
continue
}
if cmd, _ := hm["command"].(string); codexCommandInvokesCodexHook(cmd) {
return true
}
}
return false
}
func codexCommandInvokesCodexHook(cmd string) bool {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return false
}
lower := strings.ToLower(cmd)
if !strings.Contains(lower, "gortex") || !strings.Contains(lower, "hook") {
return false
}
return strings.Contains(cmd, "--agent=codex") || strings.Contains(cmd, "--agent codex")
}
func codexSessionStartHookEntry() map[string]any {
return map[string]any{
"matcher": codexSessionStartMatcher,
"hooks": []any{
map[string]any{
"type": "command",
"command": codexSessionStartCommand,
"command_windows": codexSessionStartWindowsCommand,
"timeout": codexHookTimeoutSeconds,
"statusMessage": "Loading Gortex graph orientation...",
},
},
}
}
func codexPreToolUseHookEntry(env agents.Env) map[string]any {
return map[string]any{
"matcher": codexPreToolUseMatcher,
"hooks": []any{
map[string]any{
"type": "command",
"command": codexPreToolUseCommand(env),
"timeout": codexHookTimeoutSeconds,
"statusMessage": "Loading Gortex Bash guidance...",
},
},
}
}
func codexMCPReadPreToolUseHookEntry(env agents.Env) map[string]any {
return map[string]any{
"matcher": codexMCPReadPreToolUseMatcher,
"hooks": []any{
map[string]any{
"type": "command",
"command": codexPreToolUseCommand(env),
"timeout": codexHookTimeoutSeconds,
"statusMessage": "Loading Gortex read guidance...",
},
},
}
}
func codexPostToolUseHookEntry(env agents.Env) map[string]any {
return map[string]any{
"matcher": codexPostToolUseMatcher,
"hooks": []any{
map[string]any{
"type": "command",
"command": codexHookCommand(env),
"timeout": codexHookTimeoutSeconds,
"statusMessage": "Loading Gortex Bash output context...",
},
},
}
}
// codexUserPromptSubmitHookEntry fires on every user turn — Codex's
// UserPromptSubmit event takes no matcher (it can't filter by tool name), so
// the entry omits one. The handler probes the graph for symbols relevant to
// the prompt and injects them as additionalContext, re-surfacing Gortex on
// every turn instead of relying on the SessionStart orientation to persist.
func codexUserPromptSubmitHookEntry(env agents.Env) map[string]any {
return map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": codexHookCommand(env),
"timeout": codexHookTimeoutSeconds,
"statusMessage": "Surfacing Gortex graph context for your prompt...",
},
},
}
}
func codexPreToolUseCommand(env agents.Env) string {
return codexHookCommand(env)
}
func codexHookCommand(env agents.Env) string {
base := strings.TrimSpace(env.HookCommand)
if base == "" {
base = "gortex hook"
}
return base + " --agent=codex --mode=enrich"
}
+820
View File
@@ -0,0 +1,820 @@
package codex
import (
"os"
"path/filepath"
"strings"
"testing"
toml "github.com/pelletier/go-toml/v2"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
const testCodexHookCommand = "/tmp/test-gortex hook --agent=codex --mode=enrich"
// TestCodexWritesMcpServersTOMLTable verifies we produce the
// documented [mcp_servers.gortex] table — not a legacy
// [mcp.gortex] or [mcpServers.gortex].
func TestCodexWritesMcpServersTOMLTable(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Detection sentinel: ~/.codex/ exists.
if err := os.MkdirAll(filepath.Join(env.Home, ".codex"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// Two creates: ~/.codex/config.toml for MCP plus AGENTS.md, the
// per-repo instructions file Codex CLI reads on every task.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
data, err := os.ReadFile(filepath.Join(env.Home, ".codex", "config.toml"))
if err != nil {
t.Fatalf("read config.toml: %v", err)
}
got := string(data)
if !strings.Contains(got, "mcp_servers") {
t.Fatalf("expected mcp_servers table: %s", got)
}
if !strings.Contains(got, "gortex") {
t.Fatalf("expected gortex entry: %s", got)
}
cfg := readCodexConfig(t, env)
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("expected one Gortex SessionStart hook, got %d: %#v", count, cfg["hooks"])
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("expected one Gortex PostToolUse hook, got %d: %#v", count, cfg["hooks"])
}
agentstest.AssertIdempotent(t, a, env)
}
func TestCodexInstallsSessionStartHook(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := readCodexConfig(t, env)
entries := sessionStartEntries(t, cfg)
if len(entries) != 1 {
t.Fatalf("SessionStart entries=%d want 1: %#v", len(entries), entries)
}
entry := entries[0].(map[string]any)
if entry["matcher"] != codexSessionStartMatcher {
t.Fatalf("matcher=%v want %q", entry["matcher"], codexSessionStartMatcher)
}
handlers, ok := codexHookList(entry["hooks"])
if !ok || len(handlers) != 1 {
t.Fatalf("handlers=%#v", entry["hooks"])
}
handler := handlers[0].(map[string]any)
if handler["type"] != "command" {
t.Errorf("hook type=%v want command", handler["type"])
}
if handler["command"] != codexSessionStartCommand {
t.Errorf("command=%v want %q", handler["command"], codexSessionStartCommand)
}
if handler["command_windows"] != codexSessionStartWindowsCommand {
t.Errorf("command_windows=%v want %q", handler["command_windows"], codexSessionStartWindowsCommand)
}
command := handler["command"].(string)
if !strings.Contains(command, "IMPORTANT: Prefer Gortex MCP tools") {
t.Errorf("command should emit the graph-tools orientation: %v", handler["command"])
}
if !strings.Contains(command, "edit_file") {
t.Errorf("command should mention edit_file: %v", handler["command"])
}
if strings.Contains(command, "[Gortex]") {
t.Errorf("command should not duplicate the Gortex label: %v", handler["command"])
}
}
func TestCodexInstallsPreToolUseHook(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := readCodexConfig(t, env)
entries := preToolUseEntries(t, cfg)
if len(entries) != 2 {
t.Fatalf("PreToolUse entries=%d want Bash+MCP read entries: %#v", len(entries), entries)
}
assertGortexPreToolUseHooks(t, cfg)
bashHandler := requireHookEntry(t, cfg, "PreToolUse", codexPreToolUseMatcher, testCodexHookCommand)
mcpHandler := requireHookEntry(t, cfg, "PreToolUse", codexMCPReadPreToolUseMatcher, testCodexHookCommand)
for name, handler := range map[string]map[string]any{"Bash": bashHandler, "MCP read": mcpHandler} {
if handler["type"] != "command" {
t.Errorf("%s hook type=%v want command", name, handler["type"])
}
if handler["timeout"] != int64(codexHookTimeoutSeconds) {
t.Errorf("%s timeout=%v want %d", name, handler["timeout"], codexHookTimeoutSeconds)
}
}
if bashHandler["statusMessage"] != "Loading Gortex Bash guidance..." {
t.Errorf("Bash statusMessage=%v", bashHandler["statusMessage"])
}
if mcpHandler["statusMessage"] != "Loading Gortex read guidance..." {
t.Errorf("MCP read statusMessage=%v", mcpHandler["statusMessage"])
}
}
func TestCodexInstallsPostToolUseHook(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := readCodexConfig(t, env)
entries := postToolUseEntries(t, cfg)
if len(entries) != 1 {
t.Fatalf("PostToolUse entries=%d want 1: %#v", len(entries), entries)
}
entry := entries[0].(map[string]any)
if entry["matcher"] != codexPostToolUseMatcher {
t.Fatalf("matcher=%v want %q", entry["matcher"], codexPostToolUseMatcher)
}
handlers, ok := codexHookList(entry["hooks"])
if !ok || len(handlers) != 1 {
t.Fatalf("handlers=%#v", entry["hooks"])
}
handler := handlers[0].(map[string]any)
if handler["type"] != "command" {
t.Errorf("hook type=%v want command", handler["type"])
}
command := handler["command"].(string)
if command != testCodexHookCommand {
t.Errorf("command=%v want test hook command with --agent=codex --mode=enrich", command)
}
if handler["timeout"] != int64(codexHookTimeoutSeconds) {
t.Errorf("timeout=%v want %d", handler["timeout"], codexHookTimeoutSeconds)
}
}
func TestCodexInstallsUserPromptSubmitHook(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := readCodexConfig(t, env)
entries := userPromptSubmitEntries(t, cfg)
if len(entries) != 1 {
t.Fatalf("UserPromptSubmit entries=%d want 1: %#v", len(entries), entries)
}
entry := entries[0].(map[string]any)
// UserPromptSubmit takes no matcher — Codex can't filter it by tool name.
if _, hasMatcher := entry["matcher"]; hasMatcher {
t.Fatalf("UserPromptSubmit entry should carry no matcher: %#v", entry)
}
handlers, ok := codexHookList(entry["hooks"])
if !ok || len(handlers) != 1 {
t.Fatalf("handlers=%#v", entry["hooks"])
}
handler := handlers[0].(map[string]any)
if handler["type"] != "command" {
t.Errorf("hook type=%v want command", handler["type"])
}
if handler["command"] != testCodexHookCommand {
t.Errorf("command=%v want %q", handler["command"], testCodexHookCommand)
}
if handler["timeout"] != int64(codexHookTimeoutSeconds) {
t.Errorf("timeout=%v want %d", handler["timeout"], codexHookTimeoutSeconds)
}
if count := gortexUserPromptSubmitHookCount(t, cfg); count != 1 {
t.Fatalf("Gortex UserPromptSubmit hooks=%d want 1", count)
}
}
func TestCodexInstallHooksOnlyCreatesOnlyHooks(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
action, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("install hooks only: %v", err)
}
if action.Action != agents.ActionCreate {
t.Fatalf("action=%s want create", action.Action)
}
if len(action.Keys) != 1 || action.Keys[0] != "hooks" {
t.Fatalf("keys=%#v want hooks only", action.Keys)
}
cfg := readCodexConfig(t, env)
if _, ok := cfg["mcp_servers"]; ok {
t.Fatalf("hooks-only should not write mcp_servers: %#v", cfg["mcp_servers"])
}
if _, err := os.Stat(filepath.Join(env.Root, "AGENTS.md")); !os.IsNotExist(err) {
t.Fatalf("hooks-only should not write AGENTS.md, stat err=%v", err)
}
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("SessionStart hooks=%d want 1", count)
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("PostToolUse hooks=%d want 1", count)
}
action, err = InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("second install hooks only: %v", err)
}
if action.Action != agents.ActionSkip {
t.Fatalf("second action=%s want skip", action.Action)
}
}
func TestCodexInstallHooksOnlyPreservesExistingConfig(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
seed := `model = "gpt-5-codex"
[mcp_servers.gortex]
command = "custom-gortex"
args = ["mcp", "--custom"]
[mcp_servers.other]
command = "other"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo user-pretooluse"
statusMessage = "User PreToolUse"
[[hooks.PostToolUse]]
matcher = "^Bash$"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "echo user-posttooluse"
statusMessage = "User PostToolUse"
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
action, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("install hooks only: %v", err)
}
if action.Action != agents.ActionMerge {
t.Fatalf("action=%s want merge", action.Action)
}
if len(action.Keys) != 1 || action.Keys[0] != "hooks" {
t.Fatalf("keys=%#v want hooks only", action.Keys)
}
cfg := readCodexConfig(t, env)
if cfg["model"] != "gpt-5-codex" {
t.Fatalf("unrelated top-level key was clobbered: %#v", cfg)
}
servers := cfg["mcp_servers"].(map[string]any)
gortexServer := servers["gortex"].(map[string]any)
if gortexServer["command"] != "custom-gortex" {
t.Fatalf("hooks-only rewrote mcp_servers.gortex: %#v", gortexServer)
}
if _, ok := servers["other"]; !ok {
t.Fatalf("existing MCP server was clobbered: %#v", servers)
}
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
t.Fatalf("user PreToolUse hook was not preserved: %#v", preToolUseEntries(t, cfg))
}
if !hasHookCommand(t, cfg, "PostToolUse", "echo user-posttooluse") {
t.Fatalf("user PostToolUse hook was not preserved: %#v", postToolUseEntries(t, cfg))
}
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("SessionStart hooks=%d want 1", count)
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("PostToolUse hooks=%d want 1", count)
}
}
func TestCodexInstallHooksOnlyForceReplacesOnlyGortexHooks(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
seed := `[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo user-pretooluse"
statusMessage = "User PreToolUse"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex PreToolUse"
[[hooks.PreToolUse]]
matcher = "^mcp__gortex__(read_file|get_editing_context)$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex MCP Read PreToolUse"
[[hooks.PostToolUse]]
matcher = "^Bash$"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "echo user-posttooluse"
statusMessage = "User PostToolUse"
[[hooks.PostToolUse]]
matcher = "^Bash$"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex PostToolUse"
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
if _, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{Force: true}); err != nil {
t.Fatalf("install hooks only: %v", err)
}
cfg := readCodexConfig(t, env)
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
t.Fatalf("Force removed user PreToolUse hook: %#v", preToolUseEntries(t, cfg))
}
if !hasHookCommand(t, cfg, "PostToolUse", "echo user-posttooluse") {
t.Fatalf("Force removed user PostToolUse hook: %#v", postToolUseEntries(t, cfg))
}
if hasHookCommand(t, cfg, "PreToolUse", "/tmp/old-gortex hook --agent=codex --mode=enrich") {
t.Fatalf("Force kept stale Gortex PreToolUse hook: %#v", preToolUseEntries(t, cfg))
}
if hasHookCommand(t, cfg, "PostToolUse", "/tmp/old-gortex hook --agent=codex --mode=enrich") {
t.Fatalf("Force kept stale Gortex PostToolUse hook: %#v", postToolUseEntries(t, cfg))
}
if !hasHookCommand(t, cfg, "PreToolUse", testCodexHookCommand) {
t.Fatalf("Force did not install current Gortex PreToolUse hook: %#v", preToolUseEntries(t, cfg))
}
if !hasHookCommand(t, cfg, "PostToolUse", testCodexHookCommand) {
t.Fatalf("Force did not install current Gortex PostToolUse hook: %#v", postToolUseEntries(t, cfg))
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("Gortex PostToolUse hooks=%d want 1", count)
}
}
func TestCodexInstallHooksOnlyDryRunDoesNotWrite(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
action, err := InstallHooksOnly(env.Stderr, path, env, agents.ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("install hooks only dry-run: %v", err)
}
if action.Action != agents.ActionWouldCreate {
t.Fatalf("action=%s want would-create", action.Action)
}
if len(action.Keys) != 1 || action.Keys[0] != "hooks" {
t.Fatalf("keys=%#v want hooks only", action.Keys)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("dry-run should not write config.toml, stat err=%v", err)
}
}
func TestCodexPreToolUseCommandFallsBackToGortexHook(t *testing.T) {
command := codexPreToolUseCommand(agents.Env{})
if command != "gortex hook --agent=codex --mode=enrich" {
t.Fatalf("fallback command=%q", command)
}
}
func TestCodexSessionStartHookIdempotent(t *testing.T) {
env := codexGlobalEnv(t)
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("first apply: %v", err)
}
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("second apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionSkip: 1})
cfg := readCodexConfig(t, env)
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("re-run duplicated Gortex SessionStart hook: got %d", count)
}
assertGortexPreToolUseHooks(t, cfg)
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("re-run duplicated Gortex PostToolUse hook: got %d", count)
}
}
func TestCodexSessionStartHookPreservesExistingConfig(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
seed := `model = "gpt-5-codex"
[mcp_servers.other]
command = "other"
[[hooks.SessionStart]]
matcher = "startup"
[[hooks.SessionStart.hooks]]
type = "command"
command = "echo user-session-start"
statusMessage = "User hook"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo user-pretooluse"
statusMessage = "User PreToolUse"
[[hooks.PostToolUse]]
matcher = "^Bash$"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "echo user-posttooluse"
statusMessage = "User PostToolUse"
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionMerge: 1})
cfg := readCodexConfig(t, env)
if cfg["model"] != "gpt-5-codex" {
t.Fatalf("unrelated top-level key was clobbered: %#v", cfg)
}
servers := cfg["mcp_servers"].(map[string]any)
if _, ok := servers["other"]; !ok {
t.Fatalf("existing MCP server was clobbered: %#v", servers)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex MCP server missing after merge: %#v", servers)
}
entries := sessionStartEntries(t, cfg)
if len(entries) != 2 {
t.Fatalf("SessionStart entries=%d want user+gortex entries: %#v", len(entries), entries)
}
if !hasSessionStartCommand(t, cfg, "echo user-session-start") {
t.Fatalf("user SessionStart hook was not preserved: %#v", entries)
}
if count := gortexSessionStartHookCount(t, cfg); count != 1 {
t.Fatalf("Gortex SessionStart hooks=%d want 1", count)
}
preEntries := preToolUseEntries(t, cfg)
if len(preEntries) != 3 {
t.Fatalf("PreToolUse entries=%d want user+Bash+MCP read entries: %#v", len(preEntries), preEntries)
}
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
t.Fatalf("user PreToolUse hook was not preserved: %#v", preEntries)
}
assertGortexPreToolUseHooks(t, cfg)
postEntries := postToolUseEntries(t, cfg)
if len(postEntries) != 2 {
t.Fatalf("PostToolUse entries=%d want user+gortex entries: %#v", len(postEntries), postEntries)
}
if !hasHookCommand(t, cfg, "PostToolUse", "echo user-posttooluse") {
t.Fatalf("user PostToolUse hook was not preserved: %#v", postEntries)
}
if count := gortexPostToolUseHookCount(t, cfg); count != 1 {
t.Fatalf("Gortex PostToolUse hooks=%d want 1", count)
}
}
func TestCodexForceReplacesOnlyGortexPreToolUseHook(t *testing.T) {
env := codexGlobalEnv(t)
path := codexConfigPath(env)
seed := `[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "echo user-pretooluse"
statusMessage = "User PreToolUse"
[[hooks.PreToolUse]]
matcher = "^Bash$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex PreToolUse"
[[hooks.PreToolUse]]
matcher = "^mcp__gortex__(read_file|get_editing_context)$"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/tmp/old-gortex hook --agent=codex --mode=enrich"
statusMessage = "Old Gortex MCP Read PreToolUse"
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{Force: true})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionMerge: 1})
cfg := readCodexConfig(t, env)
preEntries := preToolUseEntries(t, cfg)
if len(preEntries) != 3 {
t.Fatalf("PreToolUse entries=%d want user+Bash+MCP read entries: %#v", len(preEntries), preEntries)
}
if !hasHookCommand(t, cfg, "PreToolUse", "echo user-pretooluse") {
t.Fatalf("Force removed user PreToolUse hook: %#v", preEntries)
}
if hasHookCommand(t, cfg, "PreToolUse", "/tmp/old-gortex hook --agent=codex --mode=enrich") {
t.Fatalf("Force kept stale Gortex PreToolUse hook: %#v", preEntries)
}
if !hasHookCommand(t, cfg, "PreToolUse", testCodexHookCommand) {
t.Fatalf("Force did not install current Gortex PreToolUse hook: %#v", preEntries)
}
assertGortexPreToolUseHooks(t, cfg)
}
func TestCodexNoHooksSkipsSessionStartHook(t *testing.T) {
env := codexGlobalEnv(t)
env.InstallHooks = false
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
cfg := readCodexConfig(t, env)
if _, ok := cfg["hooks"]; ok {
t.Fatalf("--no-hooks should not write Codex hooks: %#v", cfg["hooks"])
}
if _, ok := cfg["mcp_servers"].(map[string]any)["gortex"]; !ok {
t.Fatal("mcp_servers.gortex should still be written under --no-hooks")
}
plan, err := a.Plan(env)
if err != nil {
t.Fatalf("plan: %v", err)
}
if len(plan.Files) != 1 {
t.Fatalf("plan files=%d want 1", len(plan.Files))
}
for _, key := range plan.Files[0].Keys {
if key == "hooks" {
t.Fatalf("Plan should not report hooks under --no-hooks: %#v", plan.Files[0].Keys)
}
}
}
func codexGlobalEnv(t *testing.T) agents.Env {
t.Helper()
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
if err := os.MkdirAll(filepath.Join(env.Home, ".codex"), 0o755); err != nil {
t.Fatal(err)
}
return env
}
func codexConfigPath(env agents.Env) string {
return filepath.Join(env.Home, ".codex", "config.toml")
}
func readCodexConfig(t *testing.T, env agents.Env) map[string]any {
t.Helper()
data, err := os.ReadFile(codexConfigPath(env))
if err != nil {
t.Fatalf("read config.toml: %v", err)
}
var out map[string]any
if err := toml.Unmarshal(data, &out); err != nil {
t.Fatalf("parse config.toml: %v\n%s", err, data)
}
return out
}
func sessionStartEntries(t *testing.T, cfg map[string]any) []any {
t.Helper()
return hookEntries(t, cfg, "SessionStart")
}
func preToolUseEntries(t *testing.T, cfg map[string]any) []any {
t.Helper()
return hookEntries(t, cfg, "PreToolUse")
}
func postToolUseEntries(t *testing.T, cfg map[string]any) []any {
t.Helper()
return hookEntries(t, cfg, "PostToolUse")
}
func userPromptSubmitEntries(t *testing.T, cfg map[string]any) []any {
t.Helper()
return hookEntries(t, cfg, "UserPromptSubmit")
}
func hookEntries(t *testing.T, cfg map[string]any, event string) []any {
t.Helper()
hooks, ok := cfg["hooks"].(map[string]any)
if !ok {
t.Fatalf("missing hooks map: %#v", cfg)
}
entries, ok := codexHookList(hooks[event])
if !ok {
t.Fatalf("hooks.%s has unexpected shape: %#v", event, hooks[event])
}
return entries
}
func gortexSessionStartHookCount(t *testing.T, cfg map[string]any) int {
t.Helper()
count := 0
for _, entry := range sessionStartEntries(t, cfg) {
if codexHookEntryIsGortexSessionStart(entry) {
count++
}
}
return count
}
func gortexPreToolUseHookCount(t *testing.T, cfg map[string]any) int {
t.Helper()
count := 0
for _, entry := range preToolUseEntries(t, cfg) {
if codexHookEntryIsGortexPreToolUse(entry) {
count++
}
}
return count
}
func assertGortexPreToolUseHooks(t *testing.T, cfg map[string]any) {
t.Helper()
if count := gortexPreToolUseHookCount(t, cfg); count != 2 {
t.Fatalf("Gortex PreToolUse hooks=%d want Bash+MCP read hooks: %#v", count, preToolUseEntries(t, cfg))
}
if count := hookMatcherCommandCount(t, cfg, "PreToolUse", codexPreToolUseMatcher, testCodexHookCommand); count != 1 {
t.Fatalf("Bash PreToolUse hook count=%d want 1: %#v", count, preToolUseEntries(t, cfg))
}
if count := hookMatcherCommandCount(t, cfg, "PreToolUse", codexMCPReadPreToolUseMatcher, testCodexHookCommand); count != 1 {
t.Fatalf("MCP read PreToolUse hook count=%d want 1: %#v", count, preToolUseEntries(t, cfg))
}
}
func gortexPostToolUseHookCount(t *testing.T, cfg map[string]any) int {
t.Helper()
count := 0
for _, entry := range postToolUseEntries(t, cfg) {
if codexHookEntryIsGortexPostToolUse(entry) {
count++
}
}
return count
}
func gortexUserPromptSubmitHookCount(t *testing.T, cfg map[string]any) int {
t.Helper()
count := 0
for _, entry := range userPromptSubmitEntries(t, cfg) {
if codexHookEntryIsGortexUserPromptSubmit(entry) {
count++
}
}
return count
}
func requireHookEntry(t *testing.T, cfg map[string]any, event, matcher, command string) map[string]any {
t.Helper()
for _, entry := range hookEntries(t, cfg, event) {
group, ok := entry.(map[string]any)
if !ok {
continue
}
gotMatcher, _ := group["matcher"].(string)
if gotMatcher != matcher {
continue
}
handlers, ok := codexHookList(group["hooks"])
if !ok {
continue
}
for _, handler := range handlers {
hm, ok := handler.(map[string]any)
if !ok {
continue
}
if got, _ := hm["command"].(string); got == command {
return hm
}
}
}
t.Fatalf("missing %s hook matcher=%q command=%q in %#v", event, matcher, command, hookEntries(t, cfg, event))
return nil
}
func hookMatcherCommandCount(t *testing.T, cfg map[string]any, event, matcher, command string) int {
t.Helper()
count := 0
for _, entry := range hookEntries(t, cfg, event) {
group, ok := entry.(map[string]any)
if !ok {
continue
}
gotMatcher, _ := group["matcher"].(string)
if gotMatcher != matcher {
continue
}
handlers, ok := codexHookList(group["hooks"])
if !ok {
continue
}
for _, handler := range handlers {
hm, ok := handler.(map[string]any)
if !ok {
continue
}
if got, _ := hm["command"].(string); got == command {
count++
}
}
}
return count
}
func hasSessionStartCommand(t *testing.T, cfg map[string]any, command string) bool {
t.Helper()
return hasHookCommand(t, cfg, "SessionStart", command)
}
func hasHookCommand(t *testing.T, cfg map[string]any, event string, command string) bool {
t.Helper()
for _, entry := range hookEntries(t, cfg, event) {
group, ok := entry.(map[string]any)
if !ok {
continue
}
handlers, ok := codexHookList(group["hooks"])
if !ok {
continue
}
for _, handler := range handlers {
hm, ok := handler.(map[string]any)
if !ok {
continue
}
if got, _ := hm["command"].(string); got == command {
return true
}
}
}
return false
}
+90
View File
@@ -0,0 +1,90 @@
// Package continuedev implements the Gortex init integration for
// Continue.dev. Today we write .continue/mcpServers/gortex.json
// (the legacy split-file layout); the Step 3 audit revisits whether
// Continue's current canonical form is an inline block inside
// config.yaml.
package continuedev
import (
"os"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "continue"
const DocsURL = "https://docs.continue.dev/customize/deep-dives/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if _, err := os.Stat(filepath.Join(env.Root, ".continue")); err == nil {
return true, nil
}
if env.Home != "" {
if _, err := os.Stat(filepath.Join(env.Home, ".continue")); err == nil {
return true, nil
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{Files: []agents.FileAction{
{Path: filepath.Join(env.Root, ".continue", "mcpServers", "gortex.json"), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers"}},
}}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".continue", "rules", "gortex-communities.md"), Action: agents.ActionWouldCreate,
Keys: []string{"communities-rule"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
// Global mode: Continue.dev's MCP config is project-scoped only;
// nothing to install user-wide today.
if env.Mode == agents.ModeGlobal {
return res, nil
}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Continue.dev setup (Continue not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Continue.dev integration...")
path := filepath.Join(env.Root, ".continue", "mcpServers", "gortex.json")
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// Continue reads .continue/rules/*.md on every chat turn. The
// community-routing file is ours end-to-end — regenerated each
// `gortex init` run so the listing tracks the current graph.
// Skipped when --no-skills / no communities qualify.
if env.SkillsRouting != "" {
rulesPath := filepath.Join(env.Root, ".continue", "rules", "gortex-communities.md")
body := agents.CommunitiesStartMarker + "\n" + env.SkillsRouting + "\n" + agents.CommunitiesEndMarker + "\n"
ruleAction, err := agents.WriteOwnedFile(env.Stderr, rulesPath, body, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, ruleAction)
}
res.Configured = true
return res, nil
}
@@ -0,0 +1,27 @@
package continuedev
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
func TestContinueCreatesAndIsIdempotent(t *testing.T) {
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env.Root, ".continue"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// Two creates: the MCP server JSON plus .continue/rules/gortex.md,
// the per-rule file Continue reads on every chat turn.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
agentstest.AssertIdempotent(t, a, env)
}
+198
View File
@@ -0,0 +1,198 @@
// Package cursor implements the Gortex init integration for
// Cursor. Writes .cursor/mcp.json (project-level) and, when --global
// is effective, ~/.cursor/mcp.json (user-level).
//
// Schema: standard {"mcpServers": {<name>: {command, args, env}}}.
package cursor
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "cursor"
const DocsURL = "https://docs.cursor.com/en/context/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// cursorUserDataDir is the OS-default directory where Cursor stores
// settings when the CLI has never created ~/.cursor/. Detecting it
// avoids false negatives for users who use the app daily but have no
// `cursor` shell helper on PATH yet.
func cursorUserDataDir(home string) string {
if home == "" {
return ""
}
switch runtime.GOOS {
case "darwin":
return filepath.Join(home, "Library", "Application Support", "Cursor")
case "windows":
return filepath.Join(home, "AppData", "Roaming", "Cursor")
default:
// Linux and other Unix targets Cursor ships for.
return filepath.Join(home, ".config", "Cursor")
}
}
// Detect succeeds when any of: project has .cursor/, user has
// ~/.cursor/, Cursor's application data directory exists, or the
// `cursor` CLI is on PATH.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if _, err := os.Stat(filepath.Join(env.Root, ".cursor")); err == nil {
return true, nil
}
if env.Home != "" {
if _, err := os.Stat(filepath.Join(env.Home, ".cursor")); err == nil {
return true, nil
}
if dir := cursorUserDataDir(env.Home); dir != "" {
if _, err := os.Stat(dir); err == nil {
return true, nil
}
}
}
if p, err := exec.LookPath("cursor"); err == nil && p != "" {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{Files: []agents.FileAction{
{Path: mcpConfigPath(env), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers"}},
}}
if env.Mode != agents.ModeGlobal {
p.Files = append(p.Files, agents.FileAction{
Path: workflowRulePath(env), Action: agents.ActionWouldCreate,
Keys: []string{"workflow-rule"},
})
}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: communitiesRulePath(env), Action: agents.ActionWouldCreate,
Keys: []string{"communities-rule"},
})
}
return p, nil
}
// mcpConfigPath returns the mcp.json path for the given mode.
// Project mode: .cursor/mcp.json; global mode: ~/.cursor/mcp.json.
// Cursor reads both and prefers project when a key is defined in
// both.
func mcpConfigPath(env agents.Env) string {
if env.Mode == agents.ModeGlobal && env.Home != "" {
return filepath.Join(env.Home, ".cursor", "mcp.json")
}
return filepath.Join(env.Root, ".cursor", "mcp.json")
}
// communitiesRulePath returns the project-scoped MDC file carrying
// the regenerated community-routing block. Gortex owns this file
// end-to-end; each `gortex init` overwrites it.
//
// Cursor does not support user-level MDC rules (those live in the
// app's Settings UI), so this is always project-scoped.
func communitiesRulePath(env agents.Env) string {
return filepath.Join(env.Root, ".cursor", "rules", "gortex-communities.mdc")
}
// workflowRulePath is the stable Cursor rule that steers the agent
// toward Gortex MCP tools. Regenerated on every `gortex init` so
// wording stays aligned with shipped analyzers.
func workflowRulePath(env agents.Env) string {
return filepath.Join(env.Root, ".cursor", "rules", "gortex-workflow.mdc")
}
// workflowRuleBody is intentionally concise: Cursor injects this on
// every turn (alwaysApply) and we want high signal without drowning
// project-specific rules.
const workflowRuleBody = `## Gortex in Cursor
This repository wires the **gortex** MCP server via .cursor/mcp.json (merge-managed by Gortex).
**MANDATORY: use graph tools, not blind file reads**
You **MUST** prefer Gortex graph queries over text search and whole-file opens on every task. These are not suggestions.
- **Start** a new chat with **index_health** to confirm the daemon/index (cheap); use **graph_stats** only when you need node/edge counts or multi-repo orientation.
- **Use** **search_symbols**, **get_symbol_source**, **get_file_summary**, **get_call_chain**, **find_usages**, and **smart_context** instead of opening whole files or guessing with text search.
- Before any signature or API change, **run** **verify_change**; for test selection **run** **get_test_targets**.
**MANDATORY: session memory**
- **At session start**, call **distill_session** to recover decisions, pinned notes, and recent excerpts saved in prior sessions in this workspace.
- **At every decision point** (picking an approach, rejecting an alternative, spotting a non-obvious constraint), call **save_note** with ` + "`tags:\"decision\"`" + ` and mention affected symbol IDs in the body — they auto-link.
- **Before editing a symbol you've touched before**, call **query_notes** with ` + "`symbol_id:\"<id>\"`" + ` to surface prior warnings and decisions.
**MANDATORY: development memories (cross-session)**
- **Immediately after smart_context** on every task, call **surface_memories** with ` + "`task:\"<task>\"`" + ` and ` + "`symbol_ids:\"<top hits>\"`" + ` — returns memories ranked by anchor overlap, importance, pinning, recency.
- **When you find a durable invariant, gotcha, or decision worth teaching the team**, call **store_memory** with ` + "`kind:\"<invariant|gotcha|convention|decision>\"`" + `, ` + "`symbol_ids:\"<id>\"`" + `, ` + "`importance:5`" + `. Pin load-bearing memories. Use ` + "`supersedes:\"<old-id>\"`" + ` when newer knowledge replaces older.
- Memories are workspace-wide and outlive sessions, agents, and teammates — every future agent inherits them.
`
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Cursor setup (Cursor not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Cursor IDE integration...")
path := mcpConfigPath(env)
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
// Global ~/.cursor/mcp.json is read by Cursor across every
// project, so cwd at MCP-launch time is unrelated to the open
// repo (typically $HOME). Use the daemon-proxy entry; never
// the legacy `--index .` shape that fails the entry-point
// handshake on home. See gortexhq/gortex#19.
if env.Mode == agents.ModeGlobal {
return agents.UpsertMCPServerWithMigration(root, "gortex", agents.GlobalGortexMCPEntry(), opts), nil
}
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// Workflow MDC — project-local only (Cursor has no user-level MDC
// on disk). Tells the agent to reach for MCP graph tools first.
if env.Mode != agents.ModeGlobal {
wfBody := agents.CursorMDCFrontmatter(workflowRuleBody)
wfAction, err := agents.WriteOwnedFile(env.Stderr, workflowRulePath(env), wfBody, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, wfAction)
}
// Community-routing MDC file — always written fresh on init
// so the routing tracks the current graph. Skipped in global
// mode (file is per-repo) and when --no-skills / no
// communities qualify.
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
body := agents.CursorMDCFrontmatter(
agents.CommunitiesStartMarker + "\n" + env.SkillsRouting + "\n" + agents.CommunitiesEndMarker + "\n")
ruleAction, err := agents.WriteOwnedFile(env.Stderr, communitiesRulePath(env), body, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, ruleAction)
}
res.Configured = true
return res, nil
}
+197
View File
@@ -0,0 +1,197 @@
package cursor
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestCursorCreatesMergesAndSkips covers the three behavioural
// phases every adapter must honour:
// - initial create on an empty project (via .cursor/ sentinel)
// - re-run is a no-op (idempotent)
// - merge into an existing mcp.json preserves user keys
func TestCursorCreatesMergesAndSkips(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Sentinel: create .cursor/ so Detect reports true. Without
// this the adapter would skip with "not detected".
if err := os.MkdirAll(filepath.Join(env.Root, ".cursor"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
// Phase 1 — create.
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Detected {
t.Fatal("expected Detected=true after creating .cursor/")
}
if !res.Configured {
t.Fatal("expected Configured=true after first apply")
}
// Three creates: .cursor/mcp.json, gortex-workflow.mdc (always-on MCP
// guidance), and gortex-communities.mdc (routing from stub SkillsRouting).
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 3})
mcp := agentstest.ReadJSON(t, filepath.Join(env.Root, ".cursor", "mcp.json"))
servers := mcp["mcpServers"].(map[string]any)
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex server missing: %v", servers)
}
// Phase 2 — idempotent re-run.
agentstest.AssertIdempotent(t, a, env)
}
// TestCursorGlobalWritesProxyEntry confirms that user-level installs
// (`gortex install`, env.Mode == ModeGlobal) emit the daemon-proxy
// entry — never the legacy `--index . --watch` shape that resolves
// against the launch cwd and fails Cursor's global-config handshake.
// See gortexhq/gortex#19.
func TestCursorGlobalWritesProxyEntry(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
// Sentinel: ~/.cursor exists so Detect succeeds in global mode.
if err := os.MkdirAll(filepath.Join(env.Home, ".cursor"), 0o755); err != nil {
t.Fatal(err)
}
res, err := New().Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true for global install")
}
mcp := agentstest.ReadJSON(t, filepath.Join(env.Home, ".cursor", "mcp.json"))
servers := mcp["mcpServers"].(map[string]any)
entry, ok := servers["gortex"].(map[string]any)
if !ok {
t.Fatalf("gortex entry missing in global mcp.json: %v", servers)
}
args, _ := entry["args"].([]any)
if len(args) == 0 || args[0] != "mcp" {
t.Fatalf("args[0] = %v, want \"mcp\"; full args=%v", args, args)
}
// Canonical shape is bare ["mcp"] — no --index, no --proxy.
if len(args) != 1 {
t.Errorf("global cursor entry must emit canonical [mcp], got %v", args)
}
for _, a := range args {
if s, _ := a.(string); s == "--index" || s == "--proxy" {
t.Errorf("global cursor entry must not pass %q: %v", s, args)
}
}
}
// TestCursorGlobalMigratesLegacyIndexEntry exercises the migration
// path. A user upgrading from a version that wrote the legacy
// `--index . --watch` shape should get their global config rewritten
// to the daemon-proxy form on the next `gortex install`, without
// having to pass --force.
func TestCursorGlobalMigratesLegacyIndexEntry(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
cursorDir := filepath.Join(env.Home, ".cursor")
if err := os.MkdirAll(cursorDir, 0o755); err != nil {
t.Fatal(err)
}
// Seed the legacy entry that issue 19 reported in the wild.
mcpPath := filepath.Join(cursorDir, "mcp.json")
agentstest.WriteJSON(t, mcpPath, map[string]any{
"mcpServers": map[string]any{
"gortex": map[string]any{
"command": "gortex",
"args": []any{"mcp", "--index", ".", "--watch"},
"env": map[string]any{"GORTEX_INDEX_WORKERS": "8"},
},
},
})
res, err := New().Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true after migration")
}
after := agentstest.ReadJSON(t, mcpPath)
servers := after["mcpServers"].(map[string]any)
entry := servers["gortex"].(map[string]any)
args, _ := entry["args"].([]any)
for _, a := range args {
if s, _ := a.(string); s == "--index" {
t.Fatalf("migration left legacy --index in args: %v", args)
}
}
}
// TestCursorGlobalPreservesUserCustomization keeps the migration
// honest: a user who pointed their global gortex entry at a custom
// wrapper script must not have it silently overwritten.
func TestCursorGlobalPreservesUserCustomization(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
cursorDir := filepath.Join(env.Home, ".cursor")
if err := os.MkdirAll(cursorDir, 0o755); err != nil {
t.Fatal(err)
}
mcpPath := filepath.Join(cursorDir, "mcp.json")
agentstest.WriteJSON(t, mcpPath, map[string]any{
"mcpServers": map[string]any{
"gortex": map[string]any{
"command": "/opt/wrappers/my-gortex.sh",
"args": []any{"mcp"},
},
},
})
if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
after := agentstest.ReadJSON(t, mcpPath)
servers := after["mcpServers"].(map[string]any)
entry := servers["gortex"].(map[string]any)
if entry["command"] != "/opt/wrappers/my-gortex.sh" {
t.Fatalf("user-customized command was overwritten: %v", entry)
}
}
// TestCursorMergeIntoExistingPreservesUserKeys confirms that the
// adapter writes alongside — not over — a user's pre-existing MCP
// server entry. This is the load-bearing property of MergeJSON.
func TestCursorMergeIntoExistingPreservesUserKeys(t *testing.T) {
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env.Root, ".cursor"), 0o755); err != nil {
t.Fatal(err)
}
mcpPath := filepath.Join(env.Root, ".cursor", "mcp.json")
agentstest.WriteJSON(t, mcpPath, map[string]any{
"mcpServers": map[string]any{
"user-server": map[string]any{"command": "user-tool"},
},
})
res, err := New().Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// mcp.json pre-exists → merge; workflow + communities rules are new.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionMerge: 1, agents.ActionCreate: 2})
after := agentstest.ReadJSON(t, mcpPath)
servers := after["mcpServers"].(map[string]any)
if _, ok := servers["user-server"]; !ok {
t.Fatalf("user-server was clobbered: %v", servers)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex missing after merge: %v", servers)
}
}
+114
View File
@@ -0,0 +1,114 @@
// Package gemini implements the Gortex init integration for the
// Gemini CLI. Gemini reads ~/.gemini/settings.json (user-level) and
// .gemini/settings.json (project-level); both accept the standard
// {"mcpServers": {...}} shape.
//
// Note: this adapter is distinct from the antigravity adapter —
// Gemini CLI and Google Antigravity are different products that
// happen to share the ~/.gemini/ root directory.
//
// Docs: https://geminicli.com/docs/tools/mcp-server/
package gemini
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "gemini"
const DocsURL = "https://geminicli.com/docs/tools/mcp-server/"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect checks for the gemini CLI on PATH or an existing user-level
// settings.json. We avoid colliding with the antigravity adapter's
// detection by looking at ~/.gemini/settings.json specifically
// rather than the whole ~/.gemini/ tree.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("gemini"); err == nil && p != "" {
return true, nil
}
if env.Home == "" {
return false, nil
}
if _, err := os.Stat(filepath.Join(env.Home, ".gemini", "settings.json")); err == nil {
return true, nil
}
return false, nil
}
// configPath returns the right settings.json for the current Env.
// Project mode writes .gemini/settings.json; global mode writes
// ~/.gemini/settings.json.
func configPath(env agents.Env) string {
if env.Mode == agents.ModeGlobal && env.Home != "" {
return filepath.Join(env.Home, ".gemini", "settings.json")
}
return filepath.Join(env.Root, ".gemini", "settings.json")
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{Files: []agents.FileAction{
{Path: configPath(env), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers", "hooks"}},
}}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, "GEMINI.md"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Gemini CLI setup (gemini not detected)")
return res, nil
}
if env.Home == "" {
return res, fmt.Errorf("gemini: requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Gemini CLI integration...")
action, err := agents.MergeJSON(env.Stderr, configPath(env), func(root map[string]any, _ bool) (bool, error) {
mcp := agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts)
// Install SessionStart + AfterTool lifecycle hooks alongside the
// MCP registration so Gemini gets the same orientation and
// stale-index hints Claude does.
hk := agents.UpsertGeminiHooks(root, Name, opts)
return mcp || hk, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// GEMINI.md gets a marker-guarded community-routing block when
// skills were generated (--skills, default on in `gortex init`).
// Skipped in global mode (the file is per-repo) and when no
// communities met the min-size threshold.
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
geminiMdPath := filepath.Join(env.Root, "GEMINI.md")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, geminiMdPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
+40
View File
@@ -0,0 +1,40 @@
package gemini
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
func TestGeminiGlobalModeWritesUserSettings(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
if err := os.MkdirAll(filepath.Join(env.Home, ".gemini"), 0o755); err != nil {
t.Fatal(err)
}
if f, err := os.Create(filepath.Join(env.Home, ".gemini", "settings.json")); err != nil {
t.Fatal(err)
} else {
_ = f.Close()
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true")
}
cfg := agentstest.ReadJSON(t, filepath.Join(env.Home, ".gemini", "settings.json"))
servers, ok := cfg["mcpServers"].(map[string]any)
if !ok {
t.Fatalf("missing mcpServers: %v", cfg)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex missing: %v", servers)
}
}
+289
View File
@@ -0,0 +1,289 @@
// Package hermes implements the Gortex init/install integration for
// NousResearch Hermes (https://github.com/NousResearch/hermes-agent),
// a CLI agent-orchestrator that consumes MCP servers.
//
// Hermes is a user-level agent, not a repo-scoped IDE: it stores all
// state under ~/.hermes/ and the gortex daemon already resolves the
// active workspace per MCP session, so one global server entry serves
// every repo. We therefore write user-level artifacts in both
// `gortex init` (ModeProject) and `gortex install` (ModeGlobal), the
// same as the openclaw / antigravity adapters — the writes are
// idempotent, so running both is harmless.
//
// Three surfaces are configured:
//
// 1. Global ~/.hermes/config.yaml — upsert a `gortex` stdio server
// under the snake_case `mcp_servers` map, comment-preservingly
// (the config is hand-edited and comment-rich).
// 2. Every existing ~/.hermes/profiles/<name>/config.yaml — Hermes
// profiles can re-declare their own `mcp_servers` block rather
// than inheriting the global one, so we upsert the gortex stanza
// into each profile config that already exists. This guarantees
// every profile resolves the gortex tools regardless of the
// global↔profile merge semantics. (We never create new profiles.)
// 3. A user-level skill at ~/.hermes/skills/gortex/SKILL.md teaching
// the agent to prefer gortex graph tools — Hermes' equivalent of
// the Claude Code / Antigravity user-level instruction surface.
package hermes
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
yaml "gopkg.in/yaml.v3"
)
const Name = "hermes"
const DocsURL = "https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect returns true when Hermes is installed or its home directory
// exists. False means "skip", not an error — a machine without Hermes
// gets no ~/.hermes writes.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("hermes"); err == nil && p != "" {
return true, nil
}
if env.Home == "" {
return false, nil
}
if _, err := os.Stat(hermesDir(env.Home)); err == nil {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
if env.Home == "" {
return &agents.Plan{}, nil
}
// The global config carries mcp_servers always, and the gortex
// pre_tool_call / pre_llm_call hooks when hook installation is on —
// so `init doctor` (which runs Plan) reports the hooks surface too.
globalKeys := []string{"mcp_servers"}
if env.InstallHooks {
globalKeys = append(globalKeys, "hooks")
}
files := []agents.FileAction{
{Path: globalConfigPath(env.Home), Action: agents.ActionWouldMerge, Keys: globalKeys},
}
for _, p := range profileConfigPaths(env.Home) {
files = append(files, agents.FileAction{Path: p, Action: agents.ActionWouldMerge, Keys: []string{"mcp_servers"}})
}
files = append(files, agents.FileAction{Path: skillPath(env.Home, SkillName), Action: agents.ActionWouldCreate})
for _, name := range RoutingSkillNames() {
files = append(files, agents.FileAction{Path: skillPath(env.Home, name), Action: agents.ActionWouldCreate})
}
return &agents.Plan{Files: files}, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Hermes setup (hermes not detected)")
return res, nil
}
if env.Home == "" {
return res, fmt.Errorf("hermes: requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Hermes integration...")
command := resolveGortexCommand()
// 1. Global config — the entry every profile inherits when it
// doesn't re-declare its own server map, plus (when hooks are
// enabled) the gortex pre_tool_call / pre_llm_call hooks. Both
// ride one comment-preserving merge of ~/.hermes/config.yaml.
globalAction, err := upsertGlobalConfig(env.Stderr, globalConfigPath(env.Home), command, env.HookMode, env.InstallHooks, opts)
if err != nil {
return res, fmt.Errorf("hermes global config: %w", err)
}
res.Files = append(res.Files, globalAction)
if env.InstallHooks {
internalutil.Logf(env.Stderr, "[gortex init] wired Hermes pre_tool_call + pre_llm_call hooks (posture: %s)", hermesHookModeLabel(env.HookMode))
} else {
internalutil.Logf(env.Stderr, "[gortex init] skipping Hermes hook installation (--no-hooks)")
}
// 2. Per-profile configs — Hermes profiles may carry their own
// mcp_servers block, so upsert into each existing one too. A
// failure on one profile is a warning, not fatal: the global
// entry still covers profiles that do inherit.
for _, profilePath := range profileConfigPaths(env.Home) {
profileAction, perr := upsertGortexServer(env.Stderr, profilePath, command, opts)
if perr != nil {
// Non-fatal: the global stanza still covers profiles that
// inherit. But this profile does NOT inherit, so record the
// failure on the result — not just stderr — otherwise a
// Configured=true silently masks a profile left unconfigured.
internalutil.Warnf(env.Stderr, "hermes profile %s: %v", profilePath, perr)
res.Warnings = append(res.Warnings, fmt.Sprintf("profile %s not configured: %v", profilePath, perr))
continue
}
res.Files = append(res.Files, profileAction)
}
// 3. User-level skills — the master `gortex` guide plus the
// per-task routing playbooks (explore / impact / refactor / …),
// mirroring the Claude Code user-level skill set. Each is skipped
// when it already exists so user edits survive a re-install.
masterAction, err := agents.WriteIfNotExists(env.Stderr, skillPath(env.Home, SkillName), SkillBody(), opts)
if err != nil {
return res, fmt.Errorf("hermes skill: %w", err)
}
res.Files = append(res.Files, masterAction)
routing := RoutingSkills()
for _, name := range RoutingSkillNames() {
action, rerr := agents.WriteIfNotExists(env.Stderr, skillPath(env.Home, name), routing[name], opts)
if rerr != nil {
internalutil.Warnf(env.Stderr, "hermes skill %s: %v", name, rerr)
continue
}
res.Files = append(res.Files, action)
}
res.Configured = true
return res, nil
}
// upsertGortexServer merges the gortex stdio stanza into the
// `mcp_servers` map of a Hermes YAML config, preserving comments and
// unrelated keys. Used for per-profile configs, which carry only the
// server stanza.
func upsertGortexServer(w io.Writer, path, command string, opts agents.ApplyOpts) (agents.FileAction, error) {
return agents.MergeYAML(w, path, func(root *yaml.Node, _ bool) (bool, error) {
return agents.UpsertYAMLMapEntry(root, "mcp_servers", gortexServerName, gortexMCPEntry(command), opts.Force)
}, opts)
}
// upsertGlobalConfig merges the gortex MCP server stanza — and, when
// hook installation is enabled, the gortex pre_tool_call / pre_llm_call
// hook entries — into the global ~/.hermes/config.yaml in a single
// comment-preserving pass. Hooks are global-only: unlike mcp_servers
// (which a profile can re-declare), Hermes documents shell hooks only at
// global scope, so this is the one place they're written.
func upsertGlobalConfig(w io.Writer, path, command, hookMode string, installHooks bool, opts agents.ApplyOpts) (agents.FileAction, error) {
return agents.MergeYAML(w, path, func(root *yaml.Node, _ bool) (bool, error) {
serverChanged, err := agents.UpsertYAMLMapEntry(root, "mcp_servers", gortexServerName, gortexMCPEntry(command), opts.Force)
if err != nil {
return false, err
}
// Enable the gortex CLI platform toolset so the agent can shell the
// gortex verbs alongside the MCP server.
toolsetChanged, err := enablePlatformToolsetCLI(root, opts.Force)
if err != nil {
return false, err
}
if !installHooks {
return serverChanged || toolsetChanged, nil
}
hooksChanged, err := upsertGortexHooks(root, hookMode, opts.Force)
if err != nil {
return false, err
}
return serverChanged || toolsetChanged || hooksChanged, nil
}, opts)
}
// enablePlatformToolsetCLI ensures Hermes' `platform_toolsets.cli` toolset is
// turned on, so the gortex CLI verbs the agent can shell out to are available
// alongside the MCP server. force overwrites an explicit user value; otherwise
// an existing entry is left untouched.
func enablePlatformToolsetCLI(root *yaml.Node, force bool) (bool, error) {
return agents.UpsertYAMLMapEntry(root, "platform_toolsets", "cli",
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: "true"}, force)
}
// hermesHookModeLabel renders the posture name for the install log,
// normalising the empty / unknown default to "deny".
func hermesHookModeLabel(mode string) string {
m := strings.ToLower(strings.TrimSpace(mode))
switch m {
case "enrich", "consult-unlock", "nudge":
return m
case "adaptive-nudge":
return "nudge"
default:
return "deny"
}
}
// resolveGortexCommand returns the command Hermes should launch for the
// gortex MCP server. It prefers a stable absolute path so the entry
// works regardless of how Hermes' subprocess PATH is set up:
//
// 1. os.Executable() — but only when it actually points at an installed
// `gortex` binary. Under `go run`, os.Executable() is a temp build
// that is deleted on exit (and may even be *named* gortex), so we
// additionally reject any path under the temp dir.
// 2. exec.LookPath("gortex") — a stable PATH install (homebrew / go
// install).
// 3. the bare "gortex" name as a last resort.
func resolveGortexCommand() string {
if exe, err := os.Executable(); err == nil && exe != "" {
base := filepath.Base(exe)
base = strings.TrimSuffix(base, filepath.Ext(base)) // drop .exe on Windows
underTemp := strings.HasPrefix(exe, filepath.Clean(os.TempDir())+string(os.PathSeparator))
if base == "gortex" && !underTemp {
return exe
}
}
if p, err := exec.LookPath("gortex"); err == nil && p != "" {
return p
}
return "gortex"
}
// hermesDir is the ~/.hermes root.
func hermesDir(home string) string { return filepath.Join(home, ".hermes") }
// globalConfigPath is ~/.hermes/config.yaml.
func globalConfigPath(home string) string { return filepath.Join(hermesDir(home), "config.yaml") }
// skillPath is ~/.hermes/skills/<category>/<name>/SKILL.md. Hermes
// discovers SKILL.md files recursively, and its convention is to group
// skills under a category folder rather than at the skills root.
func skillPath(home, name string) string {
return filepath.Join(hermesDir(home), "skills", skillCategory(name), name, "SKILL.md")
}
// skillCategory returns the ~/.hermes/skills subdirectory a gortex skill
// lives under. We reuse the routing-skill taxonomy so each playbook
// lands in its topical folder (navigation / analysis / debugging / …)
// and the master guide under code-intelligence — keeping the skills root
// uncluttered and matching how Hermes' own skills are organised.
func skillCategory(name string) string {
if name == SkillName {
return masterSkillCategory
}
_, category := routingSkillTaxonomy(name)
return category
}
// profileConfigPaths returns the config.yaml of every existing Hermes
// profile under ~/.hermes/profiles/<name>/, sorted for a stable
// install report and deterministic tests. Returns nil when the
// profiles directory is absent.
func profileConfigPaths(home string) []string {
matches, err := filepath.Glob(filepath.Join(hermesDir(home), "profiles", "*", "config.yaml"))
if err != nil || len(matches) == 0 {
return nil
}
sort.Strings(matches)
return matches
}
+385
View File
@@ -0,0 +1,385 @@
package hermes
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
yaml "gopkg.in/yaml.v3"
)
// frontmatterOf returns the YAML frontmatter block of a SKILL.md
// (between the opening `---` and the next `---`), failing the test
// when the body isn't frontmatter-fenced.
func frontmatterOf(t *testing.T, name, body string) string {
t.Helper()
const open = "---\n"
if !strings.HasPrefix(body, open) {
t.Fatalf("%s: SKILL.md does not start with frontmatter:\n%s", name, body)
}
rest := body[len(open):]
fm, _, found := strings.Cut(rest, "\n---\n")
if !found {
t.Fatalf("%s: unterminated frontmatter", name)
}
return fm
}
// seedHermesHome creates an empty ~/.hermes so Detect() passes without
// depending on a `hermes` binary being on the test machine's PATH.
func seedHermesHome(t *testing.T, home string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(home, ".hermes"), 0o755); err != nil {
t.Fatalf("seed ~/.hermes: %v", err)
}
}
// TestHermesApplyWritesGlobalConfigAndSkill is the acceptance test:
// `gortex install` must write the global mcp_servers.gortex stanza and
// a user-level skill, and a re-run must be a pure no-op.
func TestHermesApplyWritesGlobalConfigAndSkill(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true")
}
// Global config: mcp_servers.gortex with command + args:[mcp] + timeouts.
cfg := agentstest.ReadYAML(t, globalConfigPath(env.Home))
servers, ok := cfg["mcp_servers"].(map[string]any)
if !ok {
t.Fatalf("mcp_servers missing or wrong type: %#v", cfg)
}
gortex, ok := servers["gortex"].(map[string]any)
if !ok {
t.Fatalf("gortex server missing: %#v", servers)
}
if cmd, _ := gortex["command"].(string); cmd == "" {
t.Errorf("gortex command empty: %#v", gortex)
}
args, ok := gortex["args"].([]any)
if !ok || len(args) != 1 || args[0] != "mcp" {
t.Errorf("gortex args = %#v, want [mcp]", gortex["args"])
}
if gortex["connect_timeout"] != connectTimeoutSecs {
t.Errorf("connect_timeout = %v, want %d", gortex["connect_timeout"], connectTimeoutSecs)
}
if gortex["timeout"] != requestTimeoutSecs {
t.Errorf("timeout = %v, want %d", gortex["timeout"], requestTimeoutSecs)
}
// Skill present, with Hermes frontmatter.
skill, err := os.ReadFile(skillPath(env.Home, SkillName))
if err != nil {
t.Fatalf("skill missing: %v", err)
}
for _, want := range []string{
"name: gortex", "metadata:", "hermes:", "set_active_project",
"platforms: [linux, macos, windows]", // standard Hermes frontmatter
"related_skills:", // links to the routing playbooks
"## Task playbooks", // slash-command discoverability
"/gortex-explore", // a representative routing command
} {
if !strings.Contains(string(skill), want) {
t.Errorf("skill missing %q", want)
}
}
agentstest.AssertIdempotent(t, a, env)
}
// TestHermesInstallsRoutingSkills covers the Claude Code parity skill
// set: every routing playbook is installed with valid Hermes
// frontmatter, and gortex-guide is excluded in favour of the native
// master `gortex` skill.
func TestHermesInstallsRoutingSkills(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
names := RoutingSkillNames()
if len(names) == 0 {
t.Fatal("no routing skills derived")
}
for _, name := range names {
if name == "gortex-guide" {
t.Errorf("gortex-guide should be excluded (native master covers it)")
}
p := skillPath(env.Home, name)
data, err := os.ReadFile(p)
if err != nil {
t.Errorf("routing skill %s missing: %v", name, err)
continue
}
// The frontmatter block must be valid YAML with the Hermes
// fields Hermes needs to discover and route the skill — Hermes
// silently ignores a skill whose frontmatter won't parse.
fm := frontmatterOf(t, name, string(data))
var meta struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Version string `yaml:"version"`
Metadata struct {
Hermes struct {
Tags []string `yaml:"tags"`
Category string `yaml:"category"`
Platforms []string `yaml:"platforms"`
RelatedSkills []string `yaml:"related_skills"`
} `yaml:"hermes"`
} `yaml:"metadata"`
}
if err := yaml.Unmarshal([]byte(fm), &meta); err != nil {
t.Errorf("%s: frontmatter is not valid YAML: %v\n%s", name, err, fm)
continue
}
if meta.Name != name {
t.Errorf("%s: frontmatter name = %q", name, meta.Name)
}
if meta.Description == "" || meta.Version == "" || meta.Metadata.Hermes.Category == "" || len(meta.Metadata.Hermes.Tags) == 0 {
t.Errorf("%s: incomplete Hermes frontmatter: %+v", name, meta)
}
if len(meta.Metadata.Hermes.Platforms) == 0 || len(meta.Metadata.Hermes.RelatedSkills) == 0 {
t.Errorf("%s: missing platforms/related_skills: %+v", name, meta.Metadata.Hermes)
}
}
// A couple of representative routing skills must be present.
for _, want := range []string{"gortex-explore", "gortex-impact", "gortex-pr-review"} {
if _, err := os.Stat(skillPath(env.Home, want)); err != nil {
t.Errorf("expected routing skill %s: %v", want, err)
}
}
// The excluded guide must not be installed under its own name.
if _, err := os.Stat(skillPath(env.Home, "gortex-guide")); err == nil {
t.Error("gortex-guide should not be installed")
}
}
// TestHermesUpsertsEveryProfileConfig covers the "extra" robustness:
// Hermes profiles can re-declare their own mcp_servers block, so the
// adapter must upsert the gortex stanza into every existing profile
// config, not just the global one.
func TestHermesUpsertsEveryProfileConfig(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
// Two profiles that already declare their own servers.
profiles := []string{"work", "personal"}
for _, name := range profiles {
p := filepath.Join(env.Home, ".hermes", "profiles", name, "config.yaml")
agentstest.WriteYAML(t, p, map[string]any{
"mcp_servers": map[string]any{
"github": map[string]any{"command": "npx", "args": []string{"-y", "server-github"}},
},
})
}
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
for _, name := range profiles {
p := filepath.Join(env.Home, ".hermes", "profiles", name, "config.yaml")
cfg := agentstest.ReadYAML(t, p)
servers, ok := cfg["mcp_servers"].(map[string]any)
if !ok {
t.Fatalf("profile %s: mcp_servers missing: %#v", name, cfg)
}
if _, ok := servers["gortex"]; !ok {
t.Errorf("profile %s: gortex not upserted: %#v", name, servers)
}
if _, ok := servers["github"]; !ok {
t.Errorf("profile %s: pre-existing github server dropped: %#v", name, servers)
}
}
agentstest.AssertIdempotent(t, a, env)
}
// TestHermesProfileFailureSurfacedAsWarning verifies that a profile that
// fails to write is reported on the Result (not just stderr): the global
// stanza covers inheriting profiles, but a non-inheriting one left
// unconfigured must not hide behind Configured=true.
func TestHermesProfileFailureSurfacedAsWarning(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
// A profile whose config.yaml is actually a directory — the merge's
// read/write fails, exercising the per-profile error path.
badProfile := filepath.Join(env.Home, ".hermes", "profiles", "broken", "config.yaml")
if err := os.MkdirAll(badProfile, 0o755); err != nil {
t.Fatalf("seed bad profile: %v", err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if len(res.Warnings) == 0 {
t.Fatal("expected a warning for the failed profile, got none")
}
found := false
for _, w := range res.Warnings {
if strings.Contains(w, "broken") {
found = true
}
}
if !found {
t.Errorf("warning should name the failed profile: %#v", res.Warnings)
}
}
// TestHermesPreservesGlobalConfigComments guards the comment-rich
// merge: a hand-edited config keeps its comments and unrelated keys.
func TestHermesPreservesGlobalConfigComments(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
cfgPath := globalConfigPath(env.Home)
original := `# my hermes config
model: hermes-4 # the good one
mcp_servers:
# filesystem access
filesystem:
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem"]
`
if err := os.WriteFile(cfgPath, []byte(original), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
out, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("read back: %v", err)
}
got := string(out)
for _, want := range []string{"# my hermes config", "# filesystem access", "the good one"} {
if !strings.Contains(got, want) {
t.Errorf("lost comment %q:\n%s", want, got)
}
}
cfg := agentstest.ReadYAML(t, cfgPath)
servers := cfg["mcp_servers"].(map[string]any)
if _, ok := servers["filesystem"]; !ok {
t.Error("pre-existing filesystem server dropped")
}
if _, ok := servers["gortex"]; !ok {
t.Error("gortex server not added")
}
if cfg["model"] != "hermes-4" {
t.Errorf("unrelated key model clobbered: %v", cfg["model"])
}
}
// TestHermesForceOverwritesEntry verifies --force replaces a stale
// gortex entry instead of skipping.
func TestHermesForceOverwritesEntry(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
cfgPath := globalConfigPath(env.Home)
agentstest.WriteYAML(t, cfgPath, map[string]any{
"mcp_servers": map[string]any{
"gortex": map[string]any{"command": "OLD", "args": []string{"stale"}},
},
})
a := New()
res, err := a.Apply(env, agents.ApplyOpts{Force: true})
if err != nil {
t.Fatalf("apply --force: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true")
}
cfg := agentstest.ReadYAML(t, cfgPath)
gortex := cfg["mcp_servers"].(map[string]any)["gortex"].(map[string]any)
if gortex["command"] == "OLD" {
t.Errorf("force did not overwrite stale entry: %#v", gortex)
}
args, _ := gortex["args"].([]any)
if len(args) != 1 || args[0] != "mcp" {
t.Errorf("force did not rewrite args: %#v", gortex["args"])
}
}
// TestHermesDetect checks the home-directory gate. The PATH branch is
// covered implicitly by exec.LookPath and not asserted here so the
// test is independent of what's installed on the runner.
func TestHermesDetect(t *testing.T) {
a := New()
// ~/.hermes present → detect.
home := t.TempDir()
seedHermesHome(t, home)
if detected, _ := a.Detect(agents.Env{Home: home}); !detected {
t.Error("expected detection when ~/.hermes exists")
}
// Empty Home and no detectable hermes → skip (unless the runner
// happens to have hermes on PATH, in which case true is correct).
if detected, _ := a.Detect(agents.Env{Home: ""}); detected {
if _, err := exec.LookPath("hermes"); err != nil {
t.Error("detected Hermes with empty Home and no hermes on PATH")
}
}
}
// TestHermesDryRunWritesNothing verifies the plan-only path.
func TestHermesDryRunWritesNothing(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{DryRun: true}); err != nil {
t.Fatalf("dry-run apply: %v", err)
}
if _, err := os.Stat(globalConfigPath(env.Home)); !os.IsNotExist(err) {
t.Error("dry-run wrote the global config")
}
if _, err := os.Stat(skillPath(env.Home, SkillName)); !os.IsNotExist(err) {
t.Error("dry-run wrote the skill")
}
}
// TestHermesToolsetEnable proves F16's platform_toolsets.cli enablement: the
// gortex CLI toolset is turned on in the Hermes global config.
func TestHermesToolsetEnable(t *testing.T) {
root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
changed, err := enablePlatformToolsetCLI(root, false)
if err != nil {
t.Fatalf("enablePlatformToolsetCLI: %v", err)
}
if !changed {
t.Fatal("enabling the cli toolset on an empty config must report a change")
}
pt := agents.YAMLMapValue(root, "platform_toolsets")
if pt == nil {
t.Fatal("platform_toolsets mapping must be created")
}
cli := agents.YAMLMapValue(pt, "cli")
if cli == nil || cli.Value != "true" {
t.Fatalf("platform_toolsets.cli = %v, want scalar true", cli)
}
}
+341
View File
@@ -0,0 +1,341 @@
package hermes
import (
"sort"
"strconv"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/claudecode"
yaml "gopkg.in/yaml.v3"
)
// gortexServerName is the key the gortex stanza lives under in the
// Hermes `mcp_servers` map. Stable across releases so re-installs
// upsert in place rather than duplicating.
const gortexServerName = "gortex"
// connectTimeoutSecs / requestTimeoutSecs match a real-world working
// Hermes ↔ gortex setup: the daemon-backed MCP server can take a
// moment to hand off on first connect and graph-heavy tools (smart_
// context, analyze) occasionally run longer than Hermes' tight
// defaults, so we give both a generous ceiling out of the box.
const (
connectTimeoutSecs = 60
requestTimeoutSecs = 120
)
// gortexMCPEntry builds the stdio MCP stanza Hermes expects under
// `mcp_servers.gortex`. It mirrors the shape Hermes uses for every
// other stdio server (command + args + the two timeout knobs):
//
// gortex:
// command: /abs/path/to/gortex
// args: [mcp]
// connect_timeout: 60
// timeout: 120
//
// `gortex mcp` (no flags) connects to a running daemon and resolves
// the active workspace per MCP session, so one global stanza serves
// every repo Hermes is pointed at — no cwd-relative state to trip on.
func gortexMCPEntry(command string) *yaml.Node {
entry := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
agents.YAMLSetMapValue(entry, "command", agents.YAMLScalar(command))
// Flow style — `args: [mcp]` — to match the canonical Hermes
// example and keep the inserted block as compact as the rest of
// a hand-written config.
args := &yaml.Node{
Kind: yaml.SequenceNode,
Tag: "!!seq",
Style: yaml.FlowStyle,
Content: []*yaml.Node{agents.YAMLScalar("mcp")},
}
agents.YAMLSetMapValue(entry, "args", args)
agents.YAMLSetMapValue(entry, "connect_timeout", yamlInt(connectTimeoutSecs))
agents.YAMLSetMapValue(entry, "timeout", yamlInt(requestTimeoutSecs))
return entry
}
// yamlInt builds an integer scalar node. Kept here rather than in the
// agents package because the generic YAMLScalar helper only covers
// strings and Hermes is the only adapter that needs typed YAML
// scalars today.
func yamlInt(n int) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(n)}
}
// SkillName is the directory under ~/.hermes/skills/ that holds the
// gortex skill. Hermes discovers SKILL.md files recursively under the
// skills root, so a single `gortex/SKILL.md` is picked up regardless
// of the per-version layout.
const SkillName = "gortex"
// masterSkillCategory is the Hermes skills category the master gortex
// guide is filed under (skills/<category>/gortex/SKILL.md). Shared with
// skillCategory so the on-disk folder and the frontmatter agree.
const masterSkillCategory = "code-intelligence"
// masterSkillRaw is the static body of the user-level Hermes master
// skill: it teaches the agent to prefer gortex graph tools over raw file
// reads / text search, mirrors the Claude Code / Antigravity user-level
// instruction surface, and documents multi-repo scoping. It follows the
// Hermes SKILL.md frontmatter schema (name / description / version /
// metadata.hermes) and the documented section order. SkillBody() wraps
// it with the dynamic frontmatter fields (platforms, related_skills) and
// the slash-command index, both derived from RoutingSkillNames() so they
// never drift from the installed routing set.
const masterSkillRaw = `---
name: gortex
description: "Use for any task on a codebase indexed by the gortex daemon — searching symbols, finding usages/callers, reading code, tracing impact, refactoring, and multi-repo navigation. Prefer these graph tools over raw file reads or text search."
version: 1.0.0
metadata:
hermes:
tags: [code-intelligence, code-search, navigation, refactoring, mcp]
category: code-intelligence
---
# Gortex Code Intelligence
Gortex indexes repositories into an in-memory knowledge graph and serves it over MCP. On any indexed codebase its graph tools are faster, cheaper, and more accurate than reading whole files or grepping — they return exactly the symbol, caller set, or blast radius you asked for, with zero false positives.
## When to Use
- Searching for a symbol, function, type, or where something is referenced.
- Reading a single function/method without pulling its whole file.
- Understanding architecture, tracing call chains, or checking what a change breaks.
- Refactoring: renames, extractions, and multi-file edits that must stay consistent.
- Working across more than one repository from a single session.
## Prerequisites
- The ` + "`gortex`" + ` MCP server is registered in ` + "`~/.hermes/config.yaml`" + ` under ` + "`mcp_servers.gortex`" + ` (gortex's installer wires this for you).
- The gortex daemon is running and tracking the repo: check with ` + "`gortex daemon status`" + ` in a terminal, start it with ` + "`gortex daemon start --detach`" + `, and track a repo with ` + "`gortex init`" + ` (or ` + "`gortex track <path>`" + `).
- Confirm the graph is live at the start of a task by calling the ` + "`graph_stats`" + ` tool. If ` + "`total_nodes`" + ` is 0, call ` + "`index_repository`" + ` with ` + "`path: \".\"`" + ` first.
## How to Run
Call the gortex MCP tools directly. Translate the instinct to read or grep into the matching graph query. If you only have the terminal (no MCP tools), every tool below is reachable as ` + "`gortex call <tool> --arg k=v`" + ` (e.g. ` + "`gortex call read_file --arg path=<file>`" + `) — there is no bare ` + "`gortex <tool>`" + ` verb.
### Search and navigation
| Instead of... | Use the gortex tool... |
|------------------------------------------|----------------------------------------------|
| Grepping for a symbol | ` + "`search_symbols`" + ` (BM25 + camelCase-aware) |
| Grepping for references | ` + "`find_usages`" + ` (zero false positives) |
| Hunting for callers | ` + "`get_callers`" + ` / ` + "`get_call_chain`" + ` |
| Globbing source files (` + "`**/*.go`" + `) | ` + "`get_repo_outline`" + ` / ` + "`search_symbols`" + ` |
| Many file reads to orient on a task | ` + "`smart_context`" + ` (one call assembles the working set) |
| Literal / regex text the symbol index misses | ` + "`search_text`" + ` (trigram-accelerated grep) |
### Reading source
| Instead of... | Use the gortex tool... |
|------------------------------------------|----------------------------------------------|
| Reading a whole file for one function | ` + "`get_symbol_source`" + ` (≈80% fewer tokens) |
| Reading a file to understand it | ` + "`get_file_summary`" + ` / ` + "`get_editing_context`" + ` |
| Reading a file to check a signature | ` + "`get_symbol`" + ` (signature in ` + "`meta.signature`" + `) |
| Reading a non-indexed / raw file | ` + "`read_file`" + ` (atomic, overlay-aware) |
### Editing and refactoring
| Instead of... | Use the gortex tool... |
|------------------------------------------|----------------------------------------------|
| A whole-file string-match edit | ` + "`edit_file`" + ` (no pre-read; atomic; auto-reindex) |
| A read→edit roundtrip for one symbol | ` + "`edit_symbol`" + ` (edit by ID) |
| Manual find-and-replace for a rename | ` + "`rename_symbol`" + ` (updates cross-file references) |
| Sequencing multi-file edits by hand | ` + "`batch_edit`" + ` (dependency-ordered, atomic) |
| Guessing what a change breaks | ` + "`verify_change`" + ` / ` + "`get_dependents`" + ` (blast radius) |
### Analysis
` + "`analyze`" + ` is a unified dispatcher — pass ` + "`kind`" + ` for one of ` + "`dead_code`" + `, ` + "`hotspots`" + `, ` + "`cycles`" + `, ` + "`coverage_gaps`" + `, ` + "`todos`" + `, ` + "`sast`" + `, ` + "`impact`" + `, ` + "`cross_repo`" + `, and ~50 more. ` + "`get_architecture`" + ` gives a one-call architectural snapshot.
## Multi-repo scoping
The daemon can track several repositories at once. Scope your queries so results come from the right project:
- Call ` + "`get_active_project`" + ` to see the current scope and ` + "`set_active_project`" + ` to switch the session default.
- Most list/search tools accept a ` + "`repo`" + ` or ` + "`project`" + ` argument to target one repository for a single call without changing the session default.
- ` + "`list_repos`" + ` enumerates everything the daemon tracks; ` + "`track_repository`" + ` adds a new one.
- ` + "`analyze kind: \"cross_repo\"`" + ` and a ` + "`find_usages`" + ` partitioned by repo answer "who consumes this across all our services?".
## Quick Reference
1. ` + "`index_health`" + ` — confirm the daemon is up and oriented (` + "`graph_stats`" + ` for node/edge counts).
2. ` + "`smart_context`" + ` with the task description — assemble the minimal working set.
3. ` + "`search_symbols`" + ` / ` + "`find_usages`" + ` / ` + "`get_symbol_source`" + ` — navigate and read.
4. ` + "`get_editing_context`" + ` then ` + "`edit_symbol`" + ` / ` + "`edit_file`" + ` / ` + "`rename_symbol`" + ` / ` + "`batch_edit`" + ` — edit safely.
5. ` + "`verify_change`" + ` / ` + "`get_test_targets`" + ` — check the blast radius before and after.
## Token economy
For list-shaped responses (` + "`search_symbols`" + `, ` + "`find_usages`" + `, ` + "`analyze`" + `, ` + "`get_callers`" + `, ` + "`get_editing_context`" + `, ` + "`smart_context`" + `, …) pass ` + "`format: \"gcx\"`" + ` for the GCX1 compact wire format — round-trippable, ~27% fewer tokens. For reading source, pass ` + "`compress_bodies: true`" + ` to ` + "`read_file`" + ` / ` + "`get_symbol_source`" + ` / ` + "`get_editing_context`" + ` to elide function bodies to signatures (~3040% of original tokens).
## Pitfalls
- Don't fall back to raw file reads / shell grep on an indexed repo "just to be quick" — the graph tools are both faster and more precise, and they keep your context budget intact.
- An empty result from ` + "`search_symbols`" + ` usually means the daemon hasn't finished warming or isn't tracking this repo — check ` + "`graph_stats`" + ` / ` + "`index_health`" + ` rather than assuming the symbol is absent.
- In a multi-repo session, an unexpected result set is often a scoping issue — verify ` + "`get_active_project`" + ` or pass an explicit ` + "`repo`" + ` argument.
## Verification
After edits, call ` + "`verify_change`" + ` (broken callers + interface implementors, cross-repo) and ` + "`get_test_targets`" + ` (the tests that cover what you touched) before declaring the task done.
`
// SkillBody renders the master gortex skill: the static guide
// (masterSkillRaw) with the dynamic frontmatter fields (platforms,
// related_skills) injected and the slash-command index appended. The
// dynamic parts derive from RoutingSkillNames() so they stay in sync
// with the routing skills actually installed.
func SkillBody() string {
const fmClose = " category: " + masterSkillCategory + "\n---\n"
inject := " category: " + masterSkillCategory + "\n" +
" platforms: [linux, macos, windows]\n" +
" related_skills: [" + strings.Join(RoutingSkillNames(), ", ") + "]\n---\n"
return strings.Replace(masterSkillRaw, fmClose, inject, 1) + masterSkillCommands()
}
// masterSkillCommands renders a discoverability section listing the
// /gortex-* slash commands that `gortex install` registers (Hermes turns
// every installed skill into a /<name> command). Derived from
// RoutingSkillNames() so the list can't drift from what is installed.
func masterSkillCommands() string {
names := RoutingSkillNames()
if len(names) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n## Task playbooks (slash commands)\n\n")
b.WriteString("`gortex install` also registers these per-task playbooks as Hermes slash commands. Reach for the one that matches your task:\n\n")
for _, n := range names {
b.WriteString("- `/" + n + "`\n")
}
return b.String()
}
// RoutingSkills returns the per-task routing skills, keyed by the
// directory under ~/.hermes/skills/. They mirror Claude Code's curated
// user-level skill set so a Hermes user gets the same task-routing
// surface — explore / impact / debug / refactor / rename / safe-edit /
// add-test / pr-review / … — that `gortex install` gives Claude Code.
//
// The bodies are the single source of truth in
// internal/agents/claudecode (reused verbatim so the two agents never
// drift) re-wrapped with Hermes frontmatter. Hermes also turns every
// installed skill into a `/skill-name` slash command, so the
// `/gortex-explore`-style cross-references in the bodies resolve to the
// sibling skills installed here.
//
// gortex-guide is excluded: the native master `gortex` skill
// (SkillBody) already fills the guide role, and shipping both would be
// a redundant entry in Hermes' skill picker.
func RoutingSkills() map[string]string {
out := make(map[string]string, len(claudecode.GlobalSkills))
for name, claudeBody := range claudecode.GlobalSkills {
if name == "gortex-guide" {
continue
}
out[name] = hermesSkillFromClaude(name, claudeBody)
}
return out
}
// RoutingSkillNames returns the routing skill directory names, sorted,
// for a stable Plan / install report and deterministic tests.
func RoutingSkillNames() []string {
skills := RoutingSkills()
names := make([]string, 0, len(skills))
for name := range skills {
names = append(names, name)
}
sort.Strings(names)
return names
}
// hermesSkillFromClaude re-frames one Claude Code skill as a Hermes
// skill: it keeps the body verbatim and swaps the Claude frontmatter
// (name + description) for Hermes frontmatter (name + description +
// version + metadata.hermes.{tags,category}).
func hermesSkillFromClaude(name, claudeContent string) string {
desc := claudeFrontmatterField(claudeContent, "description")
body := stripClaudeFrontmatter(claudeContent)
tags, category := routingSkillTaxonomy(name)
var b strings.Builder
b.WriteString("---\n")
b.WriteString("name: " + name + "\n")
if desc != "" {
b.WriteString("description: " + desc + "\n")
}
b.WriteString("version: 1.0.0\n")
b.WriteString("metadata:\n")
b.WriteString(" hermes:\n")
b.WriteString(" tags: [" + strings.Join(tags, ", ") + "]\n")
b.WriteString(" category: " + category + "\n")
b.WriteString(" platforms: [linux, macos, windows]\n")
b.WriteString(" related_skills: [" + SkillName + "]\n")
b.WriteString("---\n")
b.WriteString(body)
return b.String()
}
// stripClaudeFrontmatter drops the leading `---`-fenced YAML block from
// a Claude skill body, returning just the markdown. The skill bodies
// always start with `---\n`; the first `\n---\n` after it closes the
// frontmatter.
func stripClaudeFrontmatter(s string) string {
const open = "---\n"
if !strings.HasPrefix(s, open) {
return s
}
rest := s[len(open):]
if _, after, found := strings.Cut(rest, "\n---\n"); found {
return after
}
return s
}
// claudeFrontmatterField extracts a single-line scalar field from the
// leading frontmatter block, value verbatim (quotes and inner escapes
// preserved — the Claude descriptions are already valid YAML double-
// quoted scalars). Returns "" when the field is absent.
func claudeFrontmatterField(s, field string) string {
const open = "---\n"
if !strings.HasPrefix(s, open) {
return ""
}
block := s[len(open):]
if before, _, found := strings.Cut(block, "\n---\n"); found {
block = before
}
prefix := field + ":"
for line := range strings.SplitSeq(block, "\n") {
if rest, ok := strings.CutPrefix(line, prefix); ok {
return strings.TrimSpace(rest)
}
}
return ""
}
// routingSkillTaxonomy assigns Hermes discovery tags + a category to a
// routing skill from its name. The per-skill topic tag (the name minus
// the `gortex-` prefix) plus a broad category make the skill findable
// in Hermes' skills_list without hand-maintaining a table per skill.
func routingSkillTaxonomy(name string) (tags []string, category string) {
topic := strings.TrimPrefix(name, "gortex-")
category = "code-intelligence"
switch topic {
case "explore", "onboarding", "cross-repo-usage", "dataflow-trace":
category = "navigation"
case "impact", "co-change", "architecture-review", "quality-audit", "pr-review", "pr-review-agent", "episode-replay":
category = "analysis"
case "debug", "incident-investigation":
category = "debugging"
case "refactor", "rename", "extract-function", "safe-edit", "fix-all":
category = "refactoring"
case "add-test":
category = "testing"
}
return []string{"code-intelligence", "mcp", topic}, category
}
+243
View File
@@ -0,0 +1,243 @@
package hermes
import (
"strconv"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/claudecode"
yaml "gopkg.in/yaml.v3"
)
// Hermes shell-hook wiring. The Claude Code adapter installs PreToolUse
// (+ UserPromptSubmit / SessionStart) hooks that redirect Grep / Glob /
// Read of indexed source to graph tools; Hermes exposes the equivalent
// primitives under the `hooks:` block of ~/.hermes/config.yaml, so this
// file gives Hermes the same graph-first enforcement.
//
// Two events are wired (the only two that can affect a turn — Hermes'
// on_session_start is observer-only and post_tool_call is
// fire-and-forget, so neither can inject context or block):
//
// - pre_tool_call (matcher "read_file|terminal"): the block/redirect
// hook. read_file covers whole-file reads of indexed source;
// terminal covers shell grep / find / cat (Hermes has no separate
// Grep / Glob tool — every shell command rides the one terminal
// tool). Honours the posture flag the same way Claude Code does.
// - pre_llm_call (no matcher): context injection. It does double duty
// — the Gortex orientation briefing on the first turn (Claude's
// SessionStart equivalent, which Hermes lacks) and relevant-symbol
// surfacing on every later turn (Claude's UserPromptSubmit).
//
// Both events are written only to the GLOBAL ~/.hermes/config.yaml.
// Unlike `mcp_servers` (which a profile can re-declare, so we upsert
// per-profile too), Hermes documents shell hooks only at global scope.
const (
// hermesPreToolEvent / hermesPreLLMEvent are the snake_case event
// keys under the config's `hooks:` map.
hermesPreToolEvent = "pre_tool_call"
hermesPreLLMEvent = "pre_llm_call"
// hermesToolMatcher is the regex Hermes matches against tool_name
// for the pre_tool_call hook. It must agree with the tool names the
// hooks.RunHermes dispatcher inspects (read_file + terminal).
hermesToolMatcher = "read_file|terminal"
// hermesHookTimeoutSecs bounds each hook invocation. Hermes hook
// timeouts are in SECONDS (default 60, max 300) — unlike Claude
// Code's millisecond timeouts. 5s is generous for a localhost daemon
// probe yet well under the ceiling, so a wedged daemon can't stall a
// turn for long.
hermesHookTimeoutSecs = 5
)
// upsertGortexHooks merges the gortex pre_tool_call + pre_llm_call
// entries into the `hooks:` block of an already-open config root,
// preserving comments and unrelated hooks. It is called from inside the
// global-config MergeYAML mutate so the whole config.yaml (mcp_servers +
// hooks) is written in a single comment-preserving pass. Returns whether
// it changed anything.
func upsertGortexHooks(root *yaml.Node, mode string, force bool) (bool, error) {
hooksNode, err := ensureHooksMapping(root)
if err != nil {
return false, err
}
binary := hermesHookBinary()
preToolCmd := binary + " hook --agent hermes" + hermesModeSuffix(mode)
preLLMCmd := binary + " hook --agent hermes"
// pre_tool_call carries the matcher + posture; pre_llm_call is the
// matcher-less injection hook (its posture is irrelevant — it never
// blocks).
changedTool := upsertHookEvent(hooksNode, hermesPreToolEvent, hermesToolMatcher, preToolCmd, force)
changedLLM := upsertHookEvent(hooksNode, hermesPreLLMEvent, "", preLLMCmd, force)
return changedTool || changedLLM, nil
}
// ensureHooksMapping returns the `hooks:` mapping node, creating it when
// absent or replacing an explicit-null value (`hooks:` with nothing, or
// `hooks: {}` decoded to null) in place so its leading comment survives.
// A non-mapping `hooks:` value is refused rather than clobbered.
func ensureHooksMapping(root *yaml.Node) (*yaml.Node, error) {
node := agents.YAMLMapValue(root, "hooks")
switch {
case node == nil, yamlIsNull(node):
node = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
agents.YAMLSetMapValue(root, "hooks", node)
case node.Kind == yaml.MappingNode:
// Reuse in place.
default:
return nil, &hooksShapeError{}
}
return node, nil
}
// upsertHookEvent ensures hooks[event] is a sequence holding exactly one
// gortex entry with the desired matcher + command + timeout. An existing
// gortex entry is re-stamped in place when it drifts (binary path moved,
// posture switched) so user-added fields and comments survive; a missing
// one is appended; an up-to-date one is left untouched unless force.
// Non-gortex entries in the same sequence are never touched.
func upsertHookEvent(hooksNode *yaml.Node, event, matcher, command string, force bool) bool {
seq := agents.YAMLMapValue(hooksNode, event)
if seq == nil || yamlIsNull(seq) {
seq = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
agents.YAMLSetMapValue(hooksNode, event, seq)
} else if seq.Kind != yaml.SequenceNode {
// `event:` holds a scalar/mapping rather than a list — not a
// shape we can splice an entry into. Leave it alone.
return false
}
existing := findGortexHookEntry(seq)
if existing == nil {
seq.Content = append(seq.Content, hermesHookEntryNode(matcher, command))
return true
}
if hookEntryMatches(existing, matcher, command) && !force {
return false
}
// Re-stamp the existing entry's contents in place (keeps node
// identity, so any HeadComment the user attached to it survives).
desired := hermesHookEntryNode(matcher, command)
existing.Content = desired.Content
return true
}
// findGortexHookEntry returns the first sequence item that is a gortex
// hook entry (its `command` invokes `gortex hook --agent hermes`), or
// nil. Identifies our entry by the command string so a re-install
// updates it in place rather than appending a duplicate.
func findGortexHookEntry(seq *yaml.Node) *yaml.Node {
if seq == nil || seq.Kind != yaml.SequenceNode {
return nil
}
for _, item := range seq.Content {
if item.Kind != yaml.MappingNode {
continue
}
cmd := agents.YAMLMapValue(item, "command")
if cmd != nil && cmd.Kind == yaml.ScalarNode && commandIsGortexHermesHook(cmd.Value) {
return item
}
}
return nil
}
// commandIsGortexHermesHook reports whether a hook command string is a
// gortex Hermes hook invocation: it runs the `hook` subcommand of a
// gortex binary with the hermes agent protocol selected. Robust to the
// binary being an absolute path and to `--agent hermes` vs
// `--agent=hermes`.
func commandIsGortexHermesHook(cmd string) bool {
lower := strings.ToLower(cmd)
if !strings.Contains(lower, "gortex") || !strings.Contains(lower, "hook") {
return false
}
return strings.Contains(cmd, "--agent hermes") || strings.Contains(cmd, "--agent=hermes")
}
// hookEntryMatches reports whether an existing hook entry already has
// the desired matcher + command + timeout, so an idempotent re-run is a
// no-op. A missing matcher (pre_llm_call) must mean no matcher key.
func hookEntryMatches(entry *yaml.Node, matcher, command string) bool {
if scalarValue(agents.YAMLMapValue(entry, "command")) != command {
return false
}
if scalarValue(agents.YAMLMapValue(entry, "matcher")) != matcher {
return false
}
return scalarValue(agents.YAMLMapValue(entry, "timeout")) == strconv.Itoa(hermesHookTimeoutSecs)
}
// hermesHookEntryNode builds one `hooks:` list entry: an optional
// matcher (omitted when empty — pre_llm_call takes no matcher), the
// command, and the timeout. matcher + command are double-quoted to
// match the documented Hermes example shape and to survive any future
// special characters in a binary path.
func hermesHookEntryNode(matcher, command string) *yaml.Node {
entry := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
if matcher != "" {
agents.YAMLSetMapValue(entry, "matcher", yamlQuoted(matcher))
}
agents.YAMLSetMapValue(entry, "command", yamlQuoted(command))
agents.YAMLSetMapValue(entry, "timeout", yamlInt(hermesHookTimeoutSecs))
return entry
}
// hermesHookBinary resolves the gortex binary path for the hook command
// and normalises backslashes to forward slashes. Hermes runs hook
// commands through a shell; on Windows that shell is Git Bash, which
// mangles backslash path separators — forward slashes survive there and
// are correct everywhere else. Same treatment the Claude Code adapter
// applies to its hook command.
func hermesHookBinary() string {
return strings.ReplaceAll(resolveGortexCommand(), "\\", "/")
}
// hermesModeSuffix renders the `--mode=<mode>` suffix for the
// pre_tool_call command. deny is the historical default and emitted bare
// so a deny install carries no suffix; every other posture is explicit.
// Reuses the Claude Code adapter's canonical mode strings so the two
// adapters can't drift on what a posture is called.
func hermesModeSuffix(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case claudecode.HookModeEnrich:
return " --mode=enrich"
case claudecode.HookModeConsultUnlock:
return " --mode=consult-unlock"
case claudecode.HookModeAdaptiveNudge, "adaptive-nudge":
return " --mode=nudge"
default:
return ""
}
}
// yamlIsNull reports whether n is a YAML null — an explicit null scalar
// (`~`, `null`) or an empty value (`key:` with nothing after it). A
// local copy of the agents-package predicate, which is unexported.
func yamlIsNull(n *yaml.Node) bool {
return n != nil && n.Kind == yaml.ScalarNode && (n.Tag == "!!null" || n.Value == "")
}
// yamlQuoted builds a double-quoted string scalar node.
func yamlQuoted(s string) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle, Value: s}
}
// scalarValue returns a scalar node's value, or "" for a nil / non-scalar.
func scalarValue(n *yaml.Node) string {
if n == nil || n.Kind != yaml.ScalarNode {
return ""
}
return n.Value
}
// hooksShapeError is returned when `hooks:` holds a non-mapping value we
// refuse to overwrite.
type hooksShapeError struct{}
func (*hooksShapeError) Error() string {
return "hermes: `hooks` is not a mapping; refusing to overwrite"
}
+267
View File
@@ -0,0 +1,267 @@
package hermes
import (
"os"
"slices"
"strings"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
yaml "gopkg.in/yaml.v3"
)
// hookEntries returns the list of hook entries under hooks[event] in a
// parsed Hermes config, or nil. Each entry is a map[string]any.
func hookEntries(t *testing.T, cfg map[string]any, event string) []map[string]any {
t.Helper()
hooks, ok := cfg["hooks"].(map[string]any)
if !ok {
return nil
}
raw, ok := hooks[event].([]any)
if !ok {
return nil
}
out := make([]map[string]any, 0, len(raw))
for _, e := range raw {
if m, ok := e.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// gortexHookEntry returns the single gortex hook entry under
// hooks[event], failing if absent or duplicated.
func gortexHookEntry(t *testing.T, cfg map[string]any, event string) map[string]any {
t.Helper()
var found map[string]any
count := 0
for _, e := range hookEntries(t, cfg, event) {
if cmd, _ := e["command"].(string); strings.Contains(cmd, "--agent hermes") {
found = e
count++
}
}
if count != 1 {
t.Fatalf("hooks[%s]: expected exactly 1 gortex entry, got %d", event, count)
}
return found
}
// hookEnv returns a global-mode env with hook installation enabled at
// the given posture.
func hookEnv(t *testing.T, mode string) agents.Env {
t.Helper()
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
env.InstallHooks = true
env.HookMode = mode
return env
}
// TestHermesInstallsHooks is the acceptance test for issue #51:
// `gortex install --agents=hermes --hooks` writes a valid pre_tool_call
// (matcher read_file|terminal) and pre_llm_call (no matcher) entry, and
// a re-run is a pure no-op.
func TestHermesInstallsHooks(t *testing.T) {
env := hookEnv(t, "deny")
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
cfg := agentstest.ReadYAML(t, globalConfigPath(env.Home))
// pre_tool_call: matcher + gortex hook command + a seconds timeout.
pt := gortexHookEntry(t, cfg, hermesPreToolEvent)
if pt["matcher"] != hermesToolMatcher {
t.Errorf("pre_tool_call matcher = %v, want %q", pt["matcher"], hermesToolMatcher)
}
cmd, _ := pt["command"].(string)
if !strings.Contains(cmd, "hook --agent hermes") {
t.Errorf("pre_tool_call command should invoke the hermes hook: %q", cmd)
}
if strings.Contains(cmd, "--mode") {
t.Errorf("deny posture should be emitted bare (no --mode): %q", cmd)
}
if pt["timeout"] != hermesHookTimeoutSecs {
t.Errorf("pre_tool_call timeout = %v, want %d", pt["timeout"], hermesHookTimeoutSecs)
}
// pre_llm_call: NO matcher (Hermes rejects matchers on non-tool events).
pl := gortexHookEntry(t, cfg, hermesPreLLMEvent)
if _, hasMatcher := pl["matcher"]; hasMatcher {
t.Errorf("pre_llm_call must not carry a matcher: %#v", pl)
}
if cmd, _ := pl["command"].(string); !strings.Contains(cmd, "hook --agent hermes") {
t.Errorf("pre_llm_call command should invoke the hermes hook: %q", cmd)
}
// The MCP server stanza must still be present — hooks ride the same merge.
if _, ok := cfg["mcp_servers"].(map[string]any)["gortex"]; !ok {
t.Error("mcp_servers.gortex lost when hooks were added")
}
agentstest.AssertIdempotent(t, a, env)
}
// TestHermesNoHooksSkips verifies --no-hooks leaves the hooks block
// untouched while still writing the MCP server stanza.
func TestHermesNoHooksSkips(t *testing.T) {
env, _ := agentstest.NewEnv(t)
seedHermesHome(t, env.Home)
env.InstallHooks = false
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
cfg := agentstest.ReadYAML(t, globalConfigPath(env.Home))
if _, ok := cfg["hooks"]; ok {
t.Errorf("--no-hooks should not write a hooks block: %#v", cfg["hooks"])
}
if _, ok := cfg["mcp_servers"].(map[string]any)["gortex"]; !ok {
t.Error("mcp_servers.gortex should still be written under --no-hooks")
}
}
// TestHermesHookModeSwitch verifies switching posture re-stamps the
// pre_tool_call command in place (deny → enrich adds --mode=enrich)
// without duplicating the entry.
func TestHermesHookModeSwitch(t *testing.T) {
env := hookEnv(t, "deny")
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply deny: %v", err)
}
env.HookMode = "enrich"
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply enrich: %v", err)
}
cfg := agentstest.ReadYAML(t, globalConfigPath(env.Home))
pt := gortexHookEntry(t, cfg, hermesPreToolEvent) // also asserts no duplicate
cmd, _ := pt["command"].(string)
if !strings.Contains(cmd, "--mode=enrich") {
t.Errorf("posture switch should re-stamp command with --mode=enrich: %q", cmd)
}
}
// TestHermesHooksPreserveExistingEntriesAndComments guards the
// comment-rich merge: a hand-edited config keeps its comments, its
// unrelated keys, and any non-gortex hook entries.
func TestHermesHooksPreserveExistingEntriesAndComments(t *testing.T) {
env := hookEnv(t, "deny")
cfgPath := globalConfigPath(env.Home)
original := `# my hermes config
model: hermes-4 # the good one
hooks:
pre_tool_call:
# block dangerous shell commands
- matcher: terminal
command: ~/.hermes/agent-hooks/guard.sh
timeout: 10
hooks_auto_accept: true
`
if err := os.WriteFile(cfgPath, []byte(original), 0o644); err != nil {
t.Fatalf("seed config: %v", err)
}
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
out, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("read back: %v", err)
}
got := string(out)
for _, want := range []string{"# my hermes config", "the good one", "block dangerous shell commands"} {
if !strings.Contains(got, want) {
t.Errorf("lost comment %q:\n%s", want, got)
}
}
cfg := agentstest.ReadYAML(t, cfgPath)
if cfg["hooks_auto_accept"] != true {
t.Errorf("unrelated key hooks_auto_accept clobbered: %v", cfg["hooks_auto_accept"])
}
// The pre-existing guard.sh hook survives alongside the gortex one.
entries := hookEntries(t, cfg, hermesPreToolEvent)
var hasGuard, hasGortex bool
for _, e := range entries {
cmd, _ := e["command"].(string)
if strings.Contains(cmd, "guard.sh") {
hasGuard = true
}
if strings.Contains(cmd, "--agent hermes") {
hasGortex = true
}
}
if !hasGuard {
t.Error("pre-existing guard.sh hook was dropped")
}
if !hasGortex {
t.Error("gortex hook was not added alongside the existing one")
}
}
// TestHermesPlanReportsHooks verifies Plan lists the hooks key when hook
// installation is enabled (so `init doctor` reports it) and omits it
// otherwise.
func TestHermesPlanReportsHooks(t *testing.T) {
a := New()
withHooks := hookEnv(t, "deny")
plan, err := a.Plan(withHooks)
if err != nil {
t.Fatalf("plan: %v", err)
}
if !planGlobalHasKey(plan, withHooks.Home, "hooks") {
t.Error("Plan should report the hooks key when InstallHooks is set")
}
noHooks, _ := agentstest.NewEnv(t)
seedHermesHome(t, noHooks.Home)
noHooks.InstallHooks = false
plan, err = a.Plan(noHooks)
if err != nil {
t.Fatalf("plan: %v", err)
}
if planGlobalHasKey(plan, noHooks.Home, "hooks") {
t.Error("Plan should not report the hooks key under --no-hooks")
}
}
// planGlobalHasKey reports whether the global-config FileAction in plan
// lists key among its merge keys.
func planGlobalHasKey(plan *agents.Plan, home, key string) bool {
target := globalConfigPath(home)
for _, f := range plan.Files {
if f.Path != target {
continue
}
if slices.Contains(f.Keys, key) {
return true
}
}
return false
}
// TestUpsertHookEvent_RefusesNonSequence verifies that a malformed
// `hooks.pre_tool_call` scalar is left untouched rather than clobbered.
func TestUpsertHookEvent_RefusesNonSequence(t *testing.T) {
var doc yaml.Node
if err := yaml.Unmarshal([]byte("pre_tool_call: nonsense\n"), &doc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
hooksNode := doc.Content[0]
if changed := upsertHookEvent(hooksNode, "pre_tool_call", "m", "cmd", false); changed {
t.Error("non-sequence event value should be left untouched (no change)")
}
}
+115
View File
@@ -0,0 +1,115 @@
package agents
import (
"slices"
"strings"
)
// hooks.go installs Gortex lifecycle hooks into the Gemini-CLI /
// Antigravity settings.json. These agents share Claude Code's
// hookSpecificOutput.additionalContext wire shape, so a gortex hook can
// inject the same session orientation (SessionStart) and stale-index
// hints (AfterTool) it gives Claude — closing the gap where the
// non-Claude adapters wrote MCP config but no lifecycle hooks.
// GortexHookName is the identifier stamped on every hook entry Gortex
// installs, so a re-run detects and skips (or, with Force, replaces)
// its own entries without disturbing the user's other hooks.
const GortexHookName = "gortex"
// geminiHookTimeoutMS is the per-hook timeout. Gemini CLI measures hook
// timeouts in milliseconds (Claude Code uses seconds), so this is 10s.
const geminiHookTimeoutMS = 10000
// UpsertGeminiHooks installs Gortex SessionStart + AfterTool lifecycle
// hooks into a Gemini-CLI / Antigravity settings.json root, politely
// merging with any existing user hooks (existing entries are preserved;
// gortex's own entry is added once and is idempotent on re-run).
// agentFlag is the value passed to `gortex hook --agent <agentFlag>`.
// Returns true when the root was modified.
func UpsertGeminiHooks(root map[string]any, agentFlag string, opts ApplyOpts) (changed bool) {
hooks, ok := root["hooks"].(map[string]any)
if !ok {
hooks = map[string]any{}
}
cmd := "gortex hook --agent " + agentFlag
// SessionStart: full session orientation (no tool matcher).
if upsertHookEvent(hooks, "SessionStart", "", cmd, "Gortex session orientation", opts) {
changed = true
}
// AfterTool: a concise stale-index hint after the tools where the
// index can drift (shell, search, glob).
if upsertHookEvent(hooks, "AfterTool", "run_shell_command|search_file_content|glob", cmd, "Gortex graph context + stale-index hint", opts) {
changed = true
}
if changed {
root["hooks"] = hooks
}
return changed
}
// upsertHookEvent appends a gortex hook group to one event's array,
// preserving existing user entries. When a gortex entry is already
// present it is a no-op (idempotent) unless opts.Force, which replaces
// it. Returns true when the array was modified.
func upsertHookEvent(hooks map[string]any, event, matcher, cmd, desc string, opts ApplyOpts) bool {
arr, _ := hooks[event].([]any)
hasGortex := slices.ContainsFunc(arr, hookGroupIsGortex)
if hasGortex && !opts.Force {
return false
}
if hasGortex && opts.Force {
// Drop existing gortex groups; keep the user's.
kept := make([]any, 0, len(arr))
for _, e := range arr {
if !hookGroupIsGortex(e) {
kept = append(kept, e)
}
}
arr = kept
}
inner := map[string]any{
"type": "command",
"command": cmd,
"name": GortexHookName,
"timeout": geminiHookTimeoutMS,
"description": desc,
}
group := map[string]any{"hooks": []any{inner}}
if matcher != "" {
group["matcher"] = matcher
}
hooks[event] = append(arr, group)
return true
}
// hookGroupIsGortex reports whether a hook group was installed by
// Gortex — either tagged with GortexHookName or carrying a
// `gortex hook` command.
func hookGroupIsGortex(e any) bool {
g, ok := e.(map[string]any)
if !ok {
return false
}
inner, ok := g["hooks"].([]any)
if !ok {
return false
}
for _, h := range inner {
hm, ok := h.(map[string]any)
if !ok {
continue
}
if name, _ := hm["name"].(string); name == GortexHookName {
return true
}
if cmd, _ := hm["command"].(string); strings.Contains(cmd, "gortex hook") {
return true
}
}
return false
}
+108
View File
@@ -0,0 +1,108 @@
package agents
import "testing"
// gortexGroupCount counts the gortex-authored hook groups under one
// event in a settings root.
func gortexGroupCount(root map[string]any, event string) int {
hooks, _ := root["hooks"].(map[string]any)
arr, _ := hooks[event].([]any)
n := 0
for _, e := range arr {
if hookGroupIsGortex(e) {
n++
}
}
return n
}
func TestUpsertGeminiHooks_InstallsSessionStartAndAfterTool(t *testing.T) {
root := map[string]any{}
if !UpsertGeminiHooks(root, "gemini", ApplyOpts{}) {
t.Fatal("expected the first install to report a change")
}
hooks, ok := root["hooks"].(map[string]any)
if !ok {
t.Fatal("hooks key not written")
}
for _, event := range []string{"SessionStart", "AfterTool"} {
arr, ok := hooks[event].([]any)
if !ok || len(arr) != 1 {
t.Fatalf("%s: want one group, got %v", event, hooks[event])
}
group := arr[0].(map[string]any)
inner := group["hooks"].([]any)[0].(map[string]any)
if inner["command"] != "gortex hook --agent gemini" {
t.Errorf("%s command=%v", event, inner["command"])
}
if inner["timeout"] != geminiHookTimeoutMS {
t.Errorf("%s timeout=%v want %d (milliseconds)", event, inner["timeout"], geminiHookTimeoutMS)
}
}
// AfterTool carries a tool matcher; SessionStart does not.
if _, ok := hooks["AfterTool"].([]any)[0].(map[string]any)["matcher"]; !ok {
t.Error("AfterTool group should carry a matcher")
}
if _, ok := hooks["SessionStart"].([]any)[0].(map[string]any)["matcher"]; ok {
t.Error("SessionStart group should not carry a matcher")
}
}
func TestUpsertGeminiHooks_Idempotent(t *testing.T) {
root := map[string]any{}
UpsertGeminiHooks(root, "gemini", ApplyOpts{})
if UpsertGeminiHooks(root, "gemini", ApplyOpts{}) {
t.Error("a second install without Force should be a no-op")
}
if got := gortexGroupCount(root, "SessionStart"); got != 1 {
t.Errorf("SessionStart gortex groups=%d want 1 (no duplicate)", got)
}
}
func TestUpsertGeminiHooks_PreservesExistingUserHooks(t *testing.T) {
root := map[string]any{
"hooks": map[string]any{
"AfterTool": []any{
map[string]any{"hooks": []any{map[string]any{"type": "command", "command": "my-own-linter", "name": "user"}}},
},
},
}
UpsertGeminiHooks(root, "gemini", ApplyOpts{})
arr := root["hooks"].(map[string]any)["AfterTool"].([]any)
if len(arr) != 2 {
t.Fatalf("expected the user's hook to be preserved alongside gortex's, got %d groups", len(arr))
}
// The user's entry must survive untouched.
first := arr[0].(map[string]any)["hooks"].([]any)[0].(map[string]any)
if first["command"] != "my-own-linter" {
t.Errorf("user hook clobbered: %v", first)
}
}
func TestUpsertGeminiHooks_ForceReplacesGortexOnly(t *testing.T) {
root := map[string]any{}
UpsertGeminiHooks(root, "gemini", ApplyOpts{})
// Add a user hook to AfterTool, then force-reinstall.
hooks := root["hooks"].(map[string]any)
hooks["AfterTool"] = append(hooks["AfterTool"].([]any),
map[string]any{"hooks": []any{map[string]any{"type": "command", "command": "user-linter", "name": "user"}}})
if !UpsertGeminiHooks(root, "gemini", ApplyOpts{Force: true}) {
t.Fatal("Force reinstall should report a change")
}
if got := gortexGroupCount(root, "AfterTool"); got != 1 {
t.Errorf("Force should leave exactly one gortex group, got %d", got)
}
// The user's hook still survives.
arr := hooks["AfterTool"].([]any)
foundUser := false
for _, e := range arr {
inner := e.(map[string]any)["hooks"].([]any)[0].(map[string]any)
if inner["command"] == "user-linter" {
foundUser = true
}
}
if !foundUser {
t.Error("Force must not remove the user's own hook")
}
}
+273
View File
@@ -0,0 +1,273 @@
// Instructions shared across every doc-aware adapter. Centralising the
// body here avoids per-adapter drift: Cursor's .cursor/rules file,
// Copilot's .github/copilot-instructions.md, Codex's AGENTS.md, and
// Claude Code's CLAUDE.md all read from the same constant, so when the
// "prefer Gortex over Read/Grep" story evolves we update it once and
// every agent sees the change on the next `gortex init`.
//
// The claudecode adapter extends this body with its own slash-commands
// appendix — that part is Claude-Code-specific and lives in
// claudecode/content.go, keyed off the same sentinel so idempotency
// checks line up across adapters.
package agents
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/zzet/gortex/internal/profiles"
)
// InstructionsSentinel is the substring every doc-aware adapter checks
// for when deciding whether to append the instructions block. If it's
// already present (wherever it came from — a prior `gortex init`, a
// user-copied block, another adapter writing to a shared rules file
// like AGENTS.md) we skip to stay idempotent.
const InstructionsSentinel = "## MANDATORY: Use Gortex MCP tools"
// CommunitiesStartMarker / CommunitiesEndMarker fence the generated
// community-routing block that `gortex init` writes into per-repo
// instructions files. Fenced (not just start-only) because this block
// is regenerated on every `init` re-run as the codebase evolves, so
// we need to identify and overwrite it precisely without clobbering
// user edits around it.
const (
CommunitiesStartMarker = "<!-- gortex:communities:start -->"
CommunitiesEndMarker = "<!-- gortex:communities:end -->"
)
// GlobalRulesStartMarker / GlobalRulesEndMarker fence the rule block
// that `gortex install` merges into ~/.claude/CLAUDE.md. The block is
// idempotent (re-running install replaces it in place) and removable
// (user can delete the marked region by hand without other side
// effects). Distinct from the communities markers above because this
// block lives at user level and survives every project init.
const (
GlobalRulesStartMarker = "<!-- gortex:rules:start -->"
GlobalRulesEndMarker = "<!-- gortex:rules:end -->"
)
// GlobalPointerBody renders the thin machine-level rule block
// `gortex install` merges into ~/.claude/CLAUDE.md. The rule content
// itself lives in <instructionsDir>/active.md — an atomic byte copy of
// the selected instruction profile (internal/profiles) — and is pulled
// in through an @-include, so switching guidance depth never rewrites
// CLAUDE.md. The heading stays here as the idempotency sentinel and as
// a functional minimum for readers that do not expand @-includes.
func GlobalPointerBody(instructionsDir string) string {
active := filepath.Join(instructionsDir, profiles.ActiveFileName)
return "## MANDATORY: Use Gortex MCP tools instead of Read/Grep/Glob\n\n" +
"The machine-wide Gortex rules load from the active instruction profile, imported below:\n\n" +
"@" + active + "\n\n" +
"Switch guidance depth with `gortex instructions switch <core|localization|full>` (`list` shows all) — applies to NEW sessions only.\n"
}
// InstructionsBody is the shared rule block every adapter writes to
// its agent's instructions file. Tool names in the tables (Read, Grep)
// are Claude-Code-specific flavour; models outside Claude Code read
// them as "any file-reading tool" — the principle stays the same so
// we keep one body rather than branch by agent.
const InstructionsBody = `## MANDATORY: Use Gortex MCP tools instead of Read/Grep
Gortex runs as an MCP server for this repository. You MUST prefer graph queries over file reads on every task — PreToolUse hooks deny ` + "`" + `Read` + "`" + ` / ` + "`" + `Grep` + "`" + ` / ` + "`" + `Glob` + "`" + ` against indexed source, and the deny message names the right tool.
**Start every task with ` + "`" + `explore` + "`" + `.** Describe the request in plain words (paste the whole issue — it is distilled server-side; you do not have to hand-craft a query) and it returns the ranked localization neighborhood — the likely-involved symbols with their source, call paths, and the files to change — in ONE call. Answer or start editing from its output; the source it returned is already in context, so do not re-open those files with ` + "`" + `Read` + "`" + ` / ` + "`" + `Glob` + "`" + ` — read more of a listed symbol with ` + "`" + `get_symbol_source` + "`" + ` / ` + "`" + `batch_symbols` + "`" + ` on its ` + "`" + `id:` + "`" + `.
| Instead of... | Use... |
|-------------------------------------|------------------------------------------|
| Localizing a task / bug / "where is X" | ` + "`" + `explore` + "`" + ` (one call: ranked neighborhood + source + call paths) |
| ` + "`" + `Grep` + "`" + ` for a symbol | ` + "`" + `search_symbols` + "`" + ` (BM25 + camelCase-aware) |
| ` + "`" + `Grep` + "`" + ` for references | ` + "`" + `find_usages` + "`" + ` (zero false positives) |
| Reading / grepping to find callers | ` + "`" + `get_callers` + "`" + ` / ` + "`" + `get_call_chain` + "`" + ` |
| ` + "`" + `Read` + "`" + ` a file for one symbol | ` + "`" + `get_symbol_source` + "`" + ` (` + "`" + `compress_bodies:true` + "`" + ` for the signature only) |
| ` + "`" + `Read` + "`" + ` to understand a file | ` + "`" + `get_file_summary` + "`" + ` / ` + "`" + `get_editing_context` + "`" + ` |
| ` + "`" + `Read` + "`" + ` a non-indexed / raw file | ` + "`" + `read_file` + "`" + ` |
| ` + "`" + `Read` + "`" + ` several symbols' bodies at once | ` + "`" + `batch_symbols` + "`" + ` (one call, many bodies) |
| ` + "`" + `Edit` + "`" + ` / ` + "`" + `Write` + "`" + ` source | ` + "`" + `edit_file` + "`" + ` / ` + "`" + `write_file` + "`" + ` / ` + "`" + `edit_symbol` + "`" + ` / ` + "`" + `rename_symbol` + "`" + ` / ` + "`" + `batch_edit` + "`" + ` |
**CLI fallback (no MCP):** every tool above is reachable from a shell as ` + "`" + `gortex call <tool> --arg k=v` + "`" + ` (e.g. ` + "`" + `gortex call read_file --arg path=<file>` + "`" + `) — there is no bare ` + "`" + `gortex <tool>` + "`" + ` verb.
The graph narrows scope; read the real body with ` + "`" + `get_symbol_source` + "`" + ` before you change or depend on a symbol — especially behavior-critical code (migrations, retry / fallback, concurrency), where ` + "`" + `compress_bodies:true` + "`" + ` would elide the risky branches. ` + "`" + `format:"gcx"` + "`" + ` and ` + "`" + `compress_bodies:true` + "`" + ` exist on the read / list tools — the parameter legend is in the MCP server instructions.
**Memory workflow** (behavior-critical; the full triggers live in the global ` + "`" + `~/.claude/CLAUDE.md` + "`" + ` policy and in ` + "`" + `gortex://guide` + "`" + `): ` + "`" + `distill_session` + "`" + ` at session start; ` + "`" + `surface_memories` + "`" + ` right after ` + "`" + `smart_context` + "`" + `; ` + "`" + `save_note tags:"decision"` + "`" + ` at each decision; ` + "`" + `store_memory` + "`" + ` for durable invariants / gotchas the team should inherit; ` + "`" + `query_notes` + "`" + ` / ` + "`" + `query_memories` + "`" + ` before re-touching a symbol.
**Reference:** ` + "`" + `gortex://guide` + "`" + ` (or ` + "`" + `gortex guide [topic]` + "`" + `) carries the full detail — provider matrix, capabilities, analyze / search_ast catalogs, token-economy, MCP resources. The server publishes a lean tool preset eagerly; call ` + "`" + `tools_search` + "`" + ` to discover and load any other tool by keyword.
`
// AppendInstructions appends body to path, creating the file if
// missing. Idempotent: when `sentinel` is already present anywhere in
// the file we skip with ActionSkip and log the reason. Callers pass
// the adapter's ApplyOpts through so --dry-run / --global / --force
// all flow to the right FileAction status.
//
// Not atomic. Rules files are plaintext a human edits, matching the
// historical CLAUDE.md append behaviour — a concurrent external writer
// during init is extraordinarily unlikely and atomic rename of a file
// a human is editing would fight their editor.
func AppendInstructions(w io.Writer, path, body, sentinel string, opts ApplyOpts) (FileAction, error) {
existing, readErr := os.ReadFile(path)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, readErr)
}
existed := readErr == nil
if existed && strings.Contains(string(existing), sentinel) {
if w != nil {
fmt.Fprintf(w, "[gortex init] skip %s (Gortex block already present)\n", path)
}
return FileAction{Path: path, Action: ActionSkip, Reason: "block-present"}, nil
}
if opts.DryRun {
action := ActionWouldMerge
if !existed {
action = ActionWouldCreate
}
return FileAction{Path: path, Action: action, Keys: []string{"gortex-block"}}, nil
}
// Two blank lines between existing content and the block so the
// appended section reads as a separate document and doesn't glue
// onto the last paragraph the user wrote.
prefix := ""
if existed && len(existing) > 0 {
prefix = "\n\n"
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return FileAction{}, err
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return FileAction{}, err
}
defer f.Close()
if _, err := f.WriteString(prefix + body); err != nil {
return FileAction{}, err
}
if w != nil {
fmt.Fprintf(w, "[gortex init] appended Gortex block to %s\n", path)
}
action := ActionMerge
if !existed {
action = ActionCreate
}
return FileAction{Path: path, Action: action, Keys: []string{"gortex-block"}}, nil
}
// CursorMDCFrontmatter wraps the instructions body in the YAML
// frontmatter Cursor expects for MDC rules files. Cursor reads
// `alwaysApply: true` rules on every chat turn — which is what we
// want for the MANDATORY-prefer-Gortex block.
//
// Kept separate from AppendInstructions because MDC files are
// one-rule-per-file (Cursor owns the filename, not the content), so
// they use WriteIfNotExists semantics, not append.
func CursorMDCFrontmatter(body string) string {
return `---
description: Gortex code intelligence — prefer graph tools over file reads
alwaysApply: true
---
` + body
}
// UpsertMarkedBlock writes `body` into `path` between `startMarker`
// and `endMarker`. Unlike AppendInstructions, this is idempotent AND
// regeneratable: if the markers already exist the block between them
// is replaced; otherwise the block is appended with a blank-line gap
// to existing content. If `body` is empty and the markers exist, the
// block is removed (migration use case). Creates the file if missing.
//
// Designed for the per-repo community-routing block which regenerates
// on every `gortex init` run as the graph evolves.
func UpsertMarkedBlock(w io.Writer, path, body, startMarker, endMarker string, opts ApplyOpts) (FileAction, error) {
existing, readErr := os.ReadFile(path)
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, readErr)
}
existed := readErr == nil
text := ""
if existed {
text = string(existing)
}
hasBlock := existed && strings.Contains(text, startMarker) && strings.Contains(text, endMarker)
empty := strings.TrimSpace(body) == ""
// Nothing to do: empty body and no existing block.
if empty && !hasBlock {
return FileAction{Path: path, Action: ActionSkip, Reason: "no-communities"}, nil
}
fenced := startMarker + "\n" + body + "\n" + endMarker + "\n"
var next string
switch {
case hasBlock:
start := strings.Index(text, startMarker)
end := strings.Index(text, endMarker) + len(endMarker)
// Trim trailing newline after the end marker so we don't
// accumulate blank lines on repeated re-runs.
if end < len(text) && text[end] == '\n' {
end++
}
if empty {
next = text[:start] + text[end:]
} else {
next = text[:start] + fenced + text[end:]
}
case !existed:
next = fenced
default:
prefix := ""
if len(text) > 0 {
if !strings.HasSuffix(text, "\n") {
prefix = "\n\n"
} else if !strings.HasSuffix(text, "\n\n") {
prefix = "\n"
}
}
next = text + prefix + fenced
}
// Skip when the file would end up byte-identical to what's
// already there — important for AssertIdempotent semantics and
// for avoiding spurious mtime bumps on `gortex init` re-runs
// when the graph hasn't changed.
if existed && next == text {
return FileAction{Path: path, Action: ActionSkip, Reason: "unchanged"}, nil
}
if opts.DryRun {
switch {
case !existed:
return FileAction{Path: path, Action: ActionWouldCreate, Keys: []string{"communities-block"}}, nil
case hasBlock:
return FileAction{Path: path, Action: ActionWouldMerge, Keys: []string{"communities-block"}}, nil
default:
return FileAction{Path: path, Action: ActionWouldMerge, Keys: []string{"communities-block"}}, nil
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return FileAction{}, err
}
if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
return FileAction{}, err
}
if w != nil {
verb := "updated"
if !existed {
verb = "wrote"
}
fmt.Fprintf(w, "[gortex init] %s %s (communities block)\n", verb, path)
}
action := ActionMerge
if !existed {
action = ActionCreate
}
return FileAction{Path: path, Action: action, Keys: []string{"communities-block"}}, nil
}
+196
View File
@@ -0,0 +1,196 @@
package agents
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestAppendInstructions_CreateThenSkip pins the behaviour every
// doc-aware adapter depends on: first call creates the file, second
// call with the same body is a no-op ActionSkip. If this regresses,
// running `gortex init` twice would append the block twice to every
// agent's rules file — the user-visible pain we extracted this helper
// to eliminate.
func TestAppendInstructions_CreateThenSkip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nested", "rules.md")
var buf bytes.Buffer
action, err := AppendInstructions(&buf, path, InstructionsBody, InstructionsSentinel, ApplyOpts{})
require.NoError(t, err)
assert.Equal(t, ActionCreate, action.Action)
contents, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(contents), InstructionsSentinel,
"first write must land the full sentinel-bearing block")
// Second call is idempotent — no duplicate append.
action, err = AppendInstructions(&buf, path, InstructionsBody, InstructionsSentinel, ApplyOpts{})
require.NoError(t, err)
assert.Equal(t, ActionSkip, action.Action)
assert.Equal(t, "block-present", action.Reason)
after, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, len(contents), len(after),
"second call must not grow the file")
}
// TestAppendInstructions_PreservesExistingContent guards the merge
// path — the helper must not clobber a hand-written file, it must
// append the block after the user's content with a blank-line gap.
func TestAppendInstructions_PreservesExistingContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "AGENTS.md")
existing := "# Team conventions\n\nUse tabs, not spaces.\n"
require.NoError(t, os.WriteFile(path, []byte(existing), 0o644))
action, err := AppendInstructions(nil, path, InstructionsBody, InstructionsSentinel, ApplyOpts{})
require.NoError(t, err)
assert.Equal(t, ActionMerge, action.Action)
data, err := os.ReadFile(path)
require.NoError(t, err)
text := string(data)
assert.True(t, strings.HasPrefix(text, existing),
"user content must remain at the top of the file")
assert.Contains(t, text, InstructionsSentinel,
"block must be appended below the user's content")
}
// TestAppendInstructions_SharedSentinelAcrossAdapters is the scenario
// that matters when two adapters target the same file (Codex and
// Opencode both write AGENTS.md). The second adapter must detect the
// first adapter's write via the shared InstructionsSentinel and skip,
// rather than duplicating the block. This is why the sentinel lives
// in the shared package, not each adapter.
func TestAppendInstructions_SharedSentinelAcrossAdapters(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "AGENTS.md")
// Simulate Codex writing first.
_, err := AppendInstructions(nil, path, InstructionsBody, InstructionsSentinel, ApplyOpts{})
require.NoError(t, err)
// Simulate Opencode running afterwards against the same repo.
action, err := AppendInstructions(nil, path, InstructionsBody, InstructionsSentinel, ApplyOpts{})
require.NoError(t, err)
assert.Equal(t, ActionSkip, action.Action,
"second adapter targeting the same file must skip, not append again")
}
// TestAppendInstructions_DryRunReportsAction verifies --dry-run never
// touches the filesystem and reports ActionWouldCreate / ActionWouldMerge
// correctly. Users rely on the planning output to preview what init
// will do; a silent write during dry-run would be a real footgun.
func TestAppendInstructions_DryRunReportsAction(t *testing.T) {
dir := t.TempDir()
newPath := filepath.Join(dir, "NEW.md")
existingPath := filepath.Join(dir, "EXISTING.md")
require.NoError(t, os.WriteFile(existingPath, []byte("preexisting\n"), 0o644))
action, err := AppendInstructions(nil, newPath, InstructionsBody, InstructionsSentinel, ApplyOpts{DryRun: true})
require.NoError(t, err)
assert.Equal(t, ActionWouldCreate, action.Action)
_, err = os.Stat(newPath)
assert.True(t, os.IsNotExist(err), "dry-run must not create the file")
action, err = AppendInstructions(nil, existingPath, InstructionsBody, InstructionsSentinel, ApplyOpts{DryRun: true})
require.NoError(t, err)
assert.Equal(t, ActionWouldMerge, action.Action)
data, _ := os.ReadFile(existingPath)
assert.Equal(t, "preexisting\n", string(data),
"dry-run must not mutate an existing file")
}
// TestCursorMDCFrontmatter proves the MDC wrapper emits the two keys
// Cursor needs — `description` so users can see the rule in the UI
// and `alwaysApply: true` so it attaches to every chat turn. Without
// alwaysApply Cursor gates rules on keyword heuristics and the
// Gortex-preference block would fire only sporadically.
func TestCursorMDCFrontmatter(t *testing.T) {
out := CursorMDCFrontmatter("BODY")
assert.True(t, strings.HasPrefix(out, "---\n"),
"MDC file must start with YAML frontmatter fence")
assert.Contains(t, out, "alwaysApply: true",
"MDC block must opt into always-apply so Cursor attaches it on every turn")
assert.Contains(t, out, "description:")
assert.Contains(t, out, "BODY")
}
// Single-home markers: content that must live in exactly one place. These
// strings anchor the reference blocks that were relocated OUT of the CLAUDE.md
// sections into the guide (provider matrix, analyze catalog) or the server
// instructions (wire-format deep-dive). Their absence from the slim bodies is
// the enforcement side of the single-home principle; their presence in the
// guide / server-instructions is asserted in the mcp package.
const (
providerMatrixMarker = "`local` / `anthropic` / `openai` / `azure` / `ollama` / `claudecli` / `codex` / `copilot` / `cursor` / `opencode` / `gemini` / `bedrock` / `deepseek`"
analyzeCatalogMarker = "Tarjan's SCC" // from the analyze `cycles` kind doc
formatDeepDiveMarker = "compact tabular text, lossy"
)
// TestInstructionsBody_PolicyCoreAndSingleHome smoke-tests the slim project
// rule block: it keeps the mandatory graph-tools mapping + the memory-workflow
// pointers, and it does NOT re-carry the relocated reference content.
func TestInstructionsBody_PolicyCoreAndSingleHome(t *testing.T) {
for _, token := range []string{
// Graph-tools policy core.
"search_symbols", "find_usages", "get_callers",
"get_symbol_source", "get_editing_context", "get_file_summary",
"read_file", "smart_context", "edit_file", "compress_bodies",
// Memory workflow (pointer form).
"distill_session", "surface_memories", "save_note", "store_memory",
"query_notes", "query_memories",
// Discovery pointers.
"tools_search", "gortex://guide",
} {
if !strings.Contains(InstructionsBody, token) {
t.Errorf("InstructionsBody no longer mentions %q — policy core regression", token)
}
}
for _, banned := range []string{providerMatrixMarker, analyzeCatalogMarker, formatDeepDiveMarker} {
if strings.Contains(InstructionsBody, banned) {
t.Errorf("InstructionsBody re-carries relocated content %q — single-home violation", banned)
}
}
}
// TestGlobalPointerBody_ShapeAndSentinel locks in the thin pointer
// block `gortex install` writes into ~/.claude/CLAUDE.md: it must keep
// the mandatory-rule heading (the cross-adapter idempotency sentinel),
// @-include the active profile copy from the given directory, name the
// switch verb with its next-session caveat, and stay tiny — the full
// policy body lives in the profile file, not here.
func TestGlobalPointerBody_ShapeAndSentinel(t *testing.T) {
const dir = "/home/user/.gortex/instructions"
body := GlobalPointerBody(dir)
if !strings.Contains(body, InstructionsSentinel) {
t.Error("pointer block lost the idempotency sentinel heading")
}
if !strings.Contains(body, "@"+dir+"/active.md") {
t.Errorf("pointer block does not @-include the active profile: %q", body)
}
if !strings.Contains(body, "gortex instructions switch") {
t.Error("pointer block lost the switch verb")
}
if !strings.Contains(body, "NEW sessions only") {
t.Error("pointer block lost the next-session caveat")
}
if strings.Contains(body, "@~") {
t.Error("pointer block must embed a resolved path, not a ~ shorthand")
}
const pointerByteCeiling = 512
if len(body) > pointerByteCeiling {
t.Errorf("pointer block is %d bytes, over the %d ceiling — the body belongs in the profile file", len(body), pointerByteCeiling)
}
}
+28
View File
@@ -0,0 +1,28 @@
// Package internalutil holds helpers shared across agent adapters.
// Kept separate from `agents` itself so adapters don't create
// import cycles by depending on logging helpers that in turn import
// something agent-specific.
package internalutil
import (
"fmt"
"io"
)
// Logf writes a progress line to w when w is non-nil. Adapter
// packages use this for consistency — every "[gortex init] …"
// message goes through the same helper.
func Logf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, format+"\n", args...)
}
// Warnf writes a "[gortex init] warning: …" prefixed line.
func Warnf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, "[gortex init] warning: "+format+"\n", args...)
}
+176
View File
@@ -0,0 +1,176 @@
// Package kilocode implements the Gortex init integration for the
// Kilo Code VS Code extension. Kilo Code stores MCP servers at the
// VS Code extension's globalStorage directory:
//
// macOS/Linux: ~/.config/Code/User/globalStorage/kilocode.kilo/mcp_settings.json
// (variants exist under Library/Application Support on macOS)
//
// The schema is the canonical {"mcpServers": {<name>: {...}}}
// plus an `alwaysAllow` list that mirrors the Cline pattern.
// Project-level config is also supported at .kilocode/mcp.json.
//
// Docs: https://kilo.ai/docs/features/mcp/using-mcp-in-kilo-code
package kilocode
import (
"os"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "kilocode"
const DocsURL = "https://kilo.ai/docs/features/mcp/using-mcp-in-kilo-code"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// alwaysAllow is Kilo Code's auto-approve list, matching the Cline
// equivalent — Kilo Code is a Cline fork and uses the same field.
var alwaysAllow = []string{
"graph_stats", "search_symbols", "winnow_symbols", "get_symbol", "get_file_summary",
"get_editing_context", "get_dependencies", "get_dependents",
"get_call_chain", "get_callers", "find_implementations", "find_usages",
"get_cluster", "get_symbol_source", "batch_symbols",
"find_import_path", "explain_change_impact", "get_recent_changes",
"smart_context", "get_edit_plan", "get_test_targets", "suggest_pattern",
"get_communities", "get_processes",
"detect_changes", "index_repository", "reindex_repository",
"verify_change", "check_guards", "prefetch_context",
"analyze", "diff_context", "index_health", "get_symbol_history",
"scaffold", "batch_edit", "contracts", "feedback",
"flow_between", "taint_paths",
}
// globalStoragePaths returns candidate paths for Kilo Code's MCP
// settings across VS Code install flavours (VS Code stable,
// VS Code Insiders, VSCodium, Cursor). We write to every candidate
// whose parent globalStorage directory exists.
func globalStoragePaths(home string) []string {
bases := []string{
filepath.Join(home, "Library", "Application Support", "Code", "User", "globalStorage", "kilocode.kilo", "settings"),
filepath.Join(home, ".config", "Code", "User", "globalStorage", "kilocode.kilo", "settings"),
filepath.Join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "kilocode.kilo", "settings"),
filepath.Join(home, ".config", "Cursor", "User", "globalStorage", "kilocode.kilo", "settings"),
}
paths := make([]string, 0, len(bases)+1)
for _, b := range bases {
paths = append(paths, filepath.Join(b, "mcp_settings.json"))
}
return paths
}
func (a *Adapter) Detect(env agents.Env) (bool, error) {
// Project-level hint: .kilocode/ in the repo.
if _, err := os.Stat(filepath.Join(env.Root, ".kilocode")); err == nil {
return true, nil
}
if env.Home == "" {
return false, nil
}
for _, p := range globalStoragePaths(env.Home) {
if _, err := os.Stat(filepath.Dir(p)); err == nil {
return true, nil
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
// Project-level .kilocode/mcp.json when we're in project mode.
if env.Mode != agents.ModeGlobal {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".kilocode", "mcp.json"),
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
if env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".kilocoderules"),
Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
}
if env.Home != "" {
for _, path := range globalStoragePaths(env.Home) {
if _, err := os.Stat(filepath.Dir(path)); err == nil {
p.Files = append(p.Files, agents.FileAction{
Path: path,
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
}
}
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Kilo Code setup (kilo-code not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Kilo Code integration...")
entry := agents.DefaultGortexMCPEntry()
entry["alwaysAllow"] = alwaysAllow
merge := func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", entry, opts), nil
}
// Project-level first, if a .kilocode/ dir already exists.
if env.Mode != agents.ModeGlobal {
projectPath := filepath.Join(env.Root, ".kilocode", "mcp.json")
if _, err := os.Stat(filepath.Join(env.Root, ".kilocode")); err == nil {
action, err := agents.MergeJSON(env.Stderr, projectPath, merge, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
}
}
// Global-storage paths — write to every one whose parent exists.
if env.Home != "" {
for _, path := range globalStoragePaths(env.Home) {
if _, err := os.Stat(filepath.Dir(path)); err != nil {
continue
}
action, err := agents.MergeJSON(env.Stderr, path, merge, opts)
if err != nil {
internalutil.Warnf(env.Stderr, "could not configure Kilo Code at %s: %v", path, err)
continue
}
res.Files = append(res.Files, action)
}
}
// .kilocoderules is the project-scoped instructions file Kilo
// Code reads on every task (inherited from the Cline lineage).
// Write a marker-guarded community-routing block when skills
// were generated. Skipped in global mode and when --no-skills /
// no communities qualify.
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
rulesPath := filepath.Join(env.Root, ".kilocoderules")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, rulesPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
internalutil.Warnf(env.Stderr, "could not update %s: %v", rulesPath, err)
} else {
res.Files = append(res.Files, routingAction)
}
}
res.Configured = len(res.Files) > 0
return res, nil
}
+318
View File
@@ -0,0 +1,318 @@
// Package kimi implements the Gortex init/install integration for Kimi Code
// CLI. Kimi keeps MCP server declarations in mcp.json and user-level lifecycle
// hooks in config.toml:
//
// - project MCP: .kimi-code/mcp.json
// - user MCP: $KIMI_CODE_HOME/mcp.json, defaulting to ~/.kimi-code/mcp.json
// - user hooks: $KIMI_CODE_HOME/config.toml, defaulting to ~/.kimi-code/config.toml
//
// Kimi's hooks are user-level today, so project-mode `gortex init` only writes
// the repo-local MCP file. Machine-wide `gortex install` writes user MCP and,
// when hooks are enabled, UserPromptSubmit / PreToolUse / Stop / SubagentStart
// hooks that shell `gortex hook --agent=kimi` for pre-turn context injection,
// graph-aware tool redirects, post-turn self-correction, and subagent briefing.
//
// Docs: https://www.kimi.com/code/docs/en/kimi-code-cli/customization/hooks.html
package kimi
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "kimi"
const DocsURL = "https://www.kimi.com/code/docs/en/kimi-code-cli/customization/hooks.html"
// Per-event hook timeouts (Kimi allows 1600s). UserPromptSubmit and
// PreToolUse sit on the turn's critical path, so they stay snappy; the Stop and
// SubagentStart briefings fan out to several graph tools and run off the hot
// path, so they get more headroom before Kimi's fail-open kill.
const (
kimiHookTimeoutSeconds = 5
kimiStopHookTimeoutSeconds = 30
kimiSubagentHookTimeoutSeconds = 15
)
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect checks for the kimi CLI on PATH or an existing Kimi Code home.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("kimi"); err == nil && p != "" {
return true, nil
}
if home := kimiConfigRoot(env); home != "" {
if _, err := os.Stat(home); err == nil {
return true, nil
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if env.Mode == agents.ModeGlobal {
home := kimiConfigRoot(env)
if home == "" {
return p, nil
}
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(home, "mcp.json"),
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
if env.InstallHooks {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(home, "config.toml"),
Action: agents.ActionWouldMerge,
Keys: []string{"hooks"},
})
}
return p, nil
}
p.Files = append(p.Files, agents.FileAction{
Path: projectMCPPath(env),
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Kimi Code CLI setup (kimi not detected)")
return res, nil
}
if env.Mode == agents.ModeGlobal {
return a.applyGlobal(env, opts, res)
}
return a.applyProject(env, opts, res)
}
func (a *Adapter) applyProject(env agents.Env, opts agents.ApplyOpts, res *agents.Result) (*agents.Result, error) {
if env.Root == "" {
return res, fmt.Errorf("kimi: requires a repository root")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Kimi Code CLI project MCP integration...")
action, err := agents.MergeJSON(env.Stderr, projectMCPPath(env), func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
res.Configured = true
return res, nil
}
func (a *Adapter) applyGlobal(env agents.Env, opts agents.ApplyOpts, res *agents.Result) (*agents.Result, error) {
home := kimiConfigRoot(env)
if home == "" {
return res, fmt.Errorf("kimi: requires KIMI_CODE_HOME or a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex install] setting up Kimi Code CLI user integration...")
mcpAction, err := agents.MergeJSON(env.Stderr, filepath.Join(home, "mcp.json"), func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, mcpAction)
if env.InstallHooks {
hookAction, err := agents.MergeTOML(env.Stderr, filepath.Join(home, "config.toml"), func(root map[string]any, _ bool) (bool, error) {
return upsertKimiHooks(root, env, opts), nil
}, opts)
if err != nil {
return res, err
}
if hookAction.Action != agents.ActionSkip {
hookAction.Keys = []string{"hooks"}
}
res.Files = append(res.Files, hookAction)
}
res.Configured = true
return res, nil
}
func projectMCPPath(env agents.Env) string {
return filepath.Join(env.Root, ".kimi-code", "mcp.json")
}
func kimiConfigRoot(env agents.Env) string {
if home := strings.TrimSpace(os.Getenv("KIMI_CODE_HOME")); home != "" {
return home
}
if env.Home == "" {
return ""
}
return filepath.Join(env.Home, ".kimi-code")
}
func upsertKimiHooks(root map[string]any, env agents.Env, opts agents.ApplyOpts) bool {
existing, ok := kimiHookList(root["hooks"])
if !ok {
return false
}
found := make(map[string]bool)
kept := make([]any, 0, len(existing)+2)
for _, entry := range existing {
if event, ok := kimiHookEntryInvokesKimi(entry); ok {
found[event] = true
if opts.Force {
continue
}
}
kept = append(kept, entry)
}
changed := false
for _, hook := range kimiHooks(env) {
event, _ := hook["event"].(string)
if found[event] && !opts.Force {
continue
}
kept = append(kept, hook)
changed = true
}
if !changed {
return false
}
root["hooks"] = kept
return true
}
func kimiHookList(v any) ([]any, bool) {
if v == nil {
return nil, true
}
switch list := v.(type) {
case []any:
return append([]any(nil), list...), true
case []map[string]any:
out := make([]any, 0, len(list))
for _, entry := range list {
out = append(out, entry)
}
return out, true
default:
return nil, false
}
}
func kimiHooks(env agents.Env) []map[string]any {
return []map[string]any{
kimiUserPromptSubmitHook(env),
kimiPreToolUseHook(env),
kimiStopHook(env),
kimiSubagentStartHook(env),
}
}
func kimiUserPromptSubmitHook(env agents.Env) map[string]any {
return map[string]any{
"event": "UserPromptSubmit",
"command": kimiHookCommand(env),
"timeout": kimiHookTimeoutSeconds,
}
}
func kimiPreToolUseHook(env agents.Env) map[string]any {
// Do not set matcher here: Kimi currently exposes MCP tool names as the
// underlying tool name without a documented server namespace, and the
// native Read/Grep/Glob redirects want to see every tool call. The
// dispatcher does the filtering and silently ignores anything it doesn't
// enrich.
return map[string]any{
"event": "PreToolUse",
"command": kimiHookCommand(env),
"timeout": kimiHookTimeoutSeconds,
}
}
// kimiStopHook runs the post-turn diagnostics (changed symbols → test targets,
// guards, dead code, coverage, contracts) and feeds them back so the agent can
// self-correct before handoff.
func kimiStopHook(env agents.Env) map[string]any {
return map[string]any{
"event": "Stop",
"command": kimiHookCommand(env),
"timeout": kimiStopHookTimeoutSeconds,
}
}
// kimiSubagentStartHook briefs a spawned subagent with task-scoped graph
// context and the tool-swap table so it doesn't default to raw Read/Grep.
func kimiSubagentStartHook(env agents.Env) map[string]any {
return map[string]any{
"event": "SubagentStart",
"command": kimiHookCommand(env),
"timeout": kimiSubagentHookTimeoutSeconds,
}
}
func kimiHookCommand(env agents.Env) string {
base := strings.TrimSpace(env.HookCommand)
if base == "" {
base = "gortex hook"
}
return base + " --agent=kimi"
}
func kimiHookEntryInvokesKimi(entry any) (string, bool) {
m, ok := entry.(map[string]any)
if !ok {
return "", false
}
event, _ := m["event"].(string)
switch event {
case "UserPromptSubmit", "PreToolUse", "Stop", "SubagentStart":
default:
return "", false
}
cmd, _ := m["command"].(string)
return event, kimiCommandInvokesKimiHook(cmd)
}
func kimiCommandInvokesKimiHook(cmd string) bool {
fields := strings.Fields(cmd)
if len(fields) < 3 {
return false
}
if !strings.Contains(strings.ToLower(fields[0]), "gortex") {
return false
}
hasHook := false
hasAgent := false
for i, f := range fields[1:] {
if f == "hook" {
hasHook = true
}
if f == "--agent=kimi" {
hasAgent = true
}
if f == "--agent" && i+2 < len(fields) && fields[i+2] == "kimi" {
hasAgent = true
}
}
return hasHook && hasAgent
}
+225
View File
@@ -0,0 +1,225 @@
package kimi
import (
"os"
"path/filepath"
"testing"
toml "github.com/pelletier/go-toml/v2"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
const testKimiHookCommand = "/tmp/test-gortex hook --agent=kimi"
func TestKimiProjectWritesOnlyProjectMCP(t *testing.T) {
env := kimiTestEnv(t)
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
cfg := agentstest.ReadJSON(t, projectMCPPath(env))
if _, ok := cfg["mcpServers"].(map[string]any)["gortex"]; !ok {
t.Fatalf("missing gortex MCP entry: %#v", cfg)
}
if _, err := os.Stat(filepath.Join(kimiConfigRoot(env), "config.toml")); !os.IsNotExist(err) {
t.Fatalf("project mode should not write user config.toml, stat err=%v", err)
}
agentstest.AssertIdempotent(t, a, env)
}
func TestKimiGlobalWritesMCPAndHooks(t *testing.T) {
env := kimiTestEnv(t)
env.Mode = agents.ModeGlobal
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
mcp := agentstest.ReadJSON(t, filepath.Join(kimiConfigRoot(env), "mcp.json"))
if _, ok := mcp["mcpServers"].(map[string]any)["gortex"]; !ok {
t.Fatalf("missing global gortex MCP entry: %#v", mcp)
}
hooks := readKimiHooks(t, env)
if len(hooks) != 4 {
t.Fatalf("hooks=%#v want 4", hooks)
}
assertKimiGortexHooks(t, hooks, 0)
agentstest.AssertIdempotent(t, a, env)
}
func TestKimiGlobalNoHooksWritesOnlyMCP(t *testing.T) {
env := kimiTestEnv(t)
env.Mode = agents.ModeGlobal
env.InstallHooks = false
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
if _, err := os.Stat(filepath.Join(kimiConfigRoot(env), "config.toml")); !os.IsNotExist(err) {
t.Fatalf("--no-hooks should not write config.toml, stat err=%v", err)
}
}
func TestKimiHookMergePreservesExistingHooks(t *testing.T) {
env := kimiTestEnv(t)
env.Mode = agents.ModeGlobal
path := filepath.Join(kimiConfigRoot(env), "config.toml")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
seed := `default_model = "kimi-code/kimi-for-coding"
[[hooks]]
event = "Notification"
matcher = "task\\.completed"
command = "echo user"
timeout = 5
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatal(err)
}
res, err := New().Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1, agents.ActionMerge: 1})
cfg := readKimiConfig(t, env)
if cfg["default_model"] != "kimi-code/kimi-for-coding" {
t.Fatalf("default_model was not preserved: %#v", cfg)
}
hooks := readKimiHooks(t, env)
if len(hooks) != 5 {
t.Fatalf("hooks=%#v want user+gortex hooks", hooks)
}
if hooks[0]["event"] != "Notification" {
t.Fatalf("user hook was not preserved first: %#v", hooks)
}
assertKimiGortexHooks(t, hooks, 1)
}
func TestKimiForceReplacesOnlyGortexHook(t *testing.T) {
env := kimiTestEnv(t)
env.Mode = agents.ModeGlobal
path := filepath.Join(kimiConfigRoot(env), "config.toml")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
seed := `[[hooks]]
event = "Notification"
command = "echo user"
[[hooks]]
event = "UserPromptSubmit"
command = "/tmp/old-gortex hook --agent=kimi"
timeout = 30
[[hooks]]
event = "PreToolUse"
command = "/tmp/old-gortex hook --agent=kimi"
timeout = 30
`
if err := os.WriteFile(path, []byte(seed), 0o644); err != nil {
t.Fatal(err)
}
if _, err := New().Apply(env, agents.ApplyOpts{Force: true}); err != nil {
t.Fatalf("apply force: %v", err)
}
hooks := readKimiHooks(t, env)
if len(hooks) != 5 {
t.Fatalf("hooks=%#v want user+current gortex hooks", hooks)
}
if hooks[0]["command"] != "echo user" {
t.Fatalf("user hook was not preserved: %#v", hooks)
}
assertKimiGortexHooks(t, hooks, 1)
}
func kimiTestEnv(t *testing.T) agents.Env {
t.Helper()
t.Setenv("KIMI_CODE_HOME", "")
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(kimiConfigRoot(env), 0o755); err != nil {
t.Fatal(err)
}
return env
}
func readKimiConfig(t *testing.T, env agents.Env) map[string]any {
t.Helper()
data, err := os.ReadFile(filepath.Join(kimiConfigRoot(env), "config.toml"))
if err != nil {
t.Fatalf("read config.toml: %v", err)
}
var cfg map[string]any
if err := toml.Unmarshal(data, &cfg); err != nil {
t.Fatalf("parse config.toml: %v", err)
}
return cfg
}
func readKimiHooks(t *testing.T, env agents.Env) []map[string]any {
t.Helper()
cfg := readKimiConfig(t, env)
raw, ok := cfg["hooks"].([]map[string]any)
if ok {
return raw
}
anyList, ok := cfg["hooks"].([]any)
if !ok {
t.Fatalf("hooks has unexpected shape: %#v", cfg["hooks"])
}
out := make([]map[string]any, 0, len(anyList))
for _, item := range anyList {
m, ok := item.(map[string]any)
if !ok {
t.Fatalf("hook has unexpected shape: %#v", item)
}
out = append(out, m)
}
return out
}
func assertKimiHook(t *testing.T, hook map[string]any, event string, timeout int) {
t.Helper()
if hook["event"] != event {
t.Errorf("event=%v want %s", hook["event"], event)
}
if hook["command"] != testKimiHookCommand {
t.Errorf("command=%v want %q", hook["command"], testKimiHookCommand)
}
if hook["timeout"] != int64(timeout) {
t.Errorf("timeout=%v want %d", hook["timeout"], timeout)
}
if _, ok := hook["matcher"]; ok {
t.Errorf("%s hook should not write matcher: %#v", event, hook)
}
}
// assertKimiGortexHooks checks the four gortex-managed hooks appear, in order,
// starting at hooks[offset].
func assertKimiGortexHooks(t *testing.T, hooks []map[string]any, offset int) {
t.Helper()
assertKimiHook(t, hooks[offset+0], "UserPromptSubmit", kimiHookTimeoutSeconds)
assertKimiHook(t, hooks[offset+1], "PreToolUse", kimiHookTimeoutSeconds)
assertKimiHook(t, hooks[offset+2], "Stop", kimiStopHookTimeoutSeconds)
assertKimiHook(t, hooks[offset+3], "SubagentStart", kimiSubagentHookTimeoutSeconds)
}
+141
View File
@@ -0,0 +1,141 @@
package kiro
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
)
const Name = "kiro"
const DocsURL = "https://kiro.dev/docs/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect reports true when any of: the project already has .kiro/,
// the user has ~/.kiro, or "kiro" is on PATH. A single hit is
// enough — we'd rather over-provision than silently skip a user who
// happens to open the repo in Kiro later.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if _, err := os.Stat(filepath.Join(env.Root, ".kiro")); err == nil {
return true, nil
}
if env.Home != "" {
if _, err := os.Stat(filepath.Join(env.Home, ".kiro")); err == nil {
return true, nil
}
}
if p, err := exec.LookPath("kiro"); err == nil && p != "" {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
p.Files = append(p.Files, agents.FileAction{
Path: mcpConfigPath(env),
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
// Steering docs and hooks only make sense per-project — Kiro's
// agent-hook engine only fires in the workspace that owns them.
if env.Mode == agents.ModeGlobal {
return p, nil
}
for name := range SteeringFiles {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".kiro", "steering", name),
Action: agents.ActionWouldCreate,
})
}
for name := range HookFiles {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".kiro", "hooks", name),
Action: agents.ActionWouldCreate,
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
logf(env.Stderr, "[gortex init] skip Kiro setup (Kiro not detected)")
return res, nil
}
logf(env.Stderr, "[gortex init] setting up Kiro IDE integration...")
// 1. MCP config with Kiro-specific extras (autoApprove list and
// an explicit disabled:false flag Kiro's UI expects). Kiro
// supports both workspace (.kiro/settings/mcp.json) and user
// (~/.kiro/settings/mcp.json) paths — the global mode writes
// to the user-level one so every project the user opens
// picks up Gortex automatically.
mcpPath := mcpConfigPath(env)
action, err := agents.MergeJSON(env.Stderr, mcpPath, func(root map[string]any, _ bool) (bool, error) {
entry := agents.DefaultGortexMCPEntry()
entry["disabled"] = false
entry["autoApprove"] = AutoApproveTools
return agents.UpsertMCPServer(root, "gortex", entry, opts), nil
}, opts)
if err != nil {
return res, fmt.Errorf("kiro mcp.json: %w", err)
}
res.Files = append(res.Files, action)
if env.Mode == agents.ModeGlobal {
// Steering docs and agent hooks are project-scoped and
// irrelevant at the user level. Stop here.
res.Configured = true
return res, nil
}
// 2. Steering docs — static, created if absent.
for name, content := range SteeringFiles {
action, err := agents.WriteIfNotExists(env.Stderr, filepath.Join(env.Root, ".kiro", "steering", name), content, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
}
// 3. Agent hooks — static JSON, created if absent.
for name, content := range HookFiles {
action, err := agents.WriteIfNotExists(env.Stderr, filepath.Join(env.Root, ".kiro", "hooks", name), content, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
}
res.Configured = true
return res, nil
}
// mcpConfigPath returns the mcp.json path for the given Env's
// mode. Workspace mode writes .kiro/settings/mcp.json; global mode
// writes ~/.kiro/settings/mcp.json. Kiro merges the two with
// workspace taking precedence.
func mcpConfigPath(env agents.Env) string {
if env.Mode == agents.ModeGlobal && env.Home != "" {
return filepath.Join(env.Home, ".kiro", "settings", "mcp.json")
}
return filepath.Join(env.Root, ".kiro", "settings", "mcp.json")
}
func logf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, format+"\n", args...)
}
+49
View File
@@ -0,0 +1,49 @@
package kiro
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
func TestKiroCreatesAllArtifactsAndIsIdempotent(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Sentinel: create .kiro/ so Detect returns true.
if err := os.MkdirAll(filepath.Join(env.Root, ".kiro"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// 1 mcp.json + len(SteeringFiles) + len(HookFiles).
want := 1 + len(SteeringFiles) + len(HookFiles)
got := 0
for _, f := range res.Files {
if f.Action == agents.ActionCreate {
got++
}
}
if got != want {
t.Fatalf("expected %d creates, got %d (%v)", want, got, res.Files)
}
// autoApprove list is baked into the mcp.json entry — verify.
mcp := agentstest.ReadJSON(t, filepath.Join(env.Root, ".kiro", "settings", "mcp.json"))
servers := mcp["mcpServers"].(map[string]any)
gortex := servers["gortex"].(map[string]any)
approvals, ok := gortex["autoApprove"].([]any)
if !ok || len(approvals) == 0 {
t.Fatalf("autoApprove missing or empty: %v", gortex)
}
if gortex["disabled"] != false {
t.Fatalf("disabled should be false: %v", gortex)
}
agentstest.AssertIdempotent(t, a, env)
}
+359
View File
@@ -0,0 +1,359 @@
// Package kiro implements the Gortex init integration for Kiro
// IDE. Writes:
//
// - .kiro/settings/mcp.json (MCP server stanza, merged)
// - .kiro/steering/gortex-*.md (steering docs, static)
// - .kiro/hooks/gortex-*.json (agent hooks, static)
package kiro
// SteeringFiles maps filename → content under .kiro/steering/. The
// "workflow" doc has inclusion: always; the others are manual
// (surfaced only when the user asks for them).
var SteeringFiles = map[string]string{
"gortex-workflow.md": steeringWorkflow,
"gortex-explore.md": steeringExplore,
"gortex-debug.md": steeringDebug,
"gortex-impact.md": steeringImpact,
"gortex-refactor.md": steeringRefactor,
}
// HookFiles maps filename → content under .kiro/hooks/. Each file
// is a Kiro agent-hook definition (when+then JSON).
var HookFiles = map[string]string{
"gortex-smart-context.json": hookSmartContext,
"gortex-post-edit.json": hookPostEdit,
"gortex-pre-read.json": hookPreRead,
}
const steeringWorkflow = `---
inclusion: always
---
# Gortex Code Intelligence
Gortex is running as an MCP server. It indexes this repository into an in-memory knowledge graph and exposes tools for code navigation, impact analysis, and refactoring.
## MANDATORY: Use Gortex tools instead of file reads
You **MUST** prefer Gortex graph queries over file reads on every task in this repo. These are not suggestions — the tools below replace the corresponding read/grep flows. From a shell (no MCP tools), every tool below is reachable as ` + "`gortex call <tool> --arg k=v`" + ` (e.g. ` + "`gortex call read_file --arg path=<file>`" + `) — there is no bare ` + "`gortex <tool>`" + ` verb.
### Navigation and Reading
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Reading a whole file for one function | ` + "`get_symbol_source`" + ` with ` + "`id: \"path/to/file.go::SymbolName\"`" + ` (methods carry the receiver: ` + "`\"path/to/file.go::Recv.Name\"`" + `; 80% fewer tokens) — use ` + "`get_file_summary`" + ` first if you don't know the symbol name |
| Reading to find a function | ` + "`get_symbol`" + ` or ` + "`get_editing_context`" + ` |
| Multiple ` + "`get_symbol`" + ` calls | ` + "`batch_symbols`" + ` (one call for N symbols) |
| Searching for references | ` + "`find_usages`" + ` (zero false positives) |
| Searching to find a symbol by name | ` + "`search_symbols`" + ` (BM25 + camelCase) |
| Filtering ` + "`search_symbols`" + ` by hand | ` + "`winnow_symbols`" + ` — structured constraint chain (kind, language, community, path_prefix, min_fan_in, min_churn) with per-axis score contributions |
| Reading to understand a file | ` + "`get_file_summary`" + ` or ` + "`get_editing_context`" + ` |
| Reading multiple files to trace calls | ` + "`get_call_chain`" + ` / ` + "`get_callers`" + ` |
| Guessing an import path | ` + "`find_import_path`" + ` |
| Reading to check a function signature | ` + "`get_symbol_signature`" + ` |
| 5-10 calls to explore for a task | ` + "`smart_context`" + ` (one call) |
### Impact Analysis and Safety
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Reading files to assess change scope | ` + "`explain_change_impact`" + ` (includes cross-community warnings) |
| Guessing which tests to run | ` + "`get_test_targets`" + ` |
| Manual dependency ordering | ` + "`get_edit_plan`" + ` |
| Hoping signature changes are safe | ` + "`verify_change`" + ` — checks callers and interface implementors |
| Manually checking team conventions | ` + "`check_guards`" + ` — evaluates guard rules from .gortex.yaml |
| Wondering if a new dep creates a cycle| ` + "`analyze`" + ` with ` + "`kind: \"would_create_cycle\"`" + ` — checks before you add it |
### Structural Code Search
| Instead of... | Use... |
|------------------------------------------|------------------------------------------|
| Grep for an anti-pattern in this repo | ` + "`search_ast`" + ` with ` + "`detector: \"<name>\"`" + ` (` + "`error-not-wrapped`" + ` / ` + "`sql-string-concat`" + ` / ` + "`weak-crypto`" + ` / ` + "`panic-in-library`" + ` / ` + "`goroutine-without-recover`" + ` / ` + "`http-client-no-timeout`" + ` / ` + "`hardcoded-secret`" + ` / ` + "`empty-catch`" + ` / ` + "`java-string-equality`" + ` / ` + "`python-mutable-default-arg`" + `). Cross-language; matches enriched with enclosing ` + "`symbol_id`" + `. |
| Grep for a code shape | ` + "`search_ast`" + ` with ` + "`pattern: \"...\"`" + ` + ` + "`language`" + ` (tree-sitter S-expression; capture with ` + "`@name`" + `, anchor with ` + "`@match`" + `). |
| Scoping audit to important code | Pass ` + "`min_fan_in_of_enclosing_func: <N>`" + ` — drops matches in functions with fewer than N callers. |
### Diagnostics and Code Actions
| Instead of... | Use... |
|------------------------------------------|------------------------------------------|
| Polling for diagnostics after every edit | ` + "`subscribe_diagnostics`" + ` — opt into push ` + "`notifications/diagnostics`" + `. Initial state replays as ` + "`initial_replay: true`" + `; thereafter delta-changed files only. ` + "`min_severity`" + ` / ` + "`path_prefix`" + ` filters scope the stream. |
| Manual diagnostics fetch | ` + "`get_diagnostics`" + ` — last stored ` + "`publishDiagnostics`" + ` for a file; ` + "`wait`" + ` + ` + "`timeout_ms`" + ` block until the first publish. |
| Forgetting to opt out | ` + "`unsubscribe_diagnostics`" + ` — idempotent; auto-fires on session disconnect. |
| Hand-applying compiler suggestions | ` + "`get_code_actions`" + ` then ` + "`apply_code_action`" + ` (atomic temp+rename, both ` + "`changes`" + ` and ` + "`documentChanges`" + `). |
| Walking a file to apply every fix | ` + "`fix_all_in_file`" + ` — one-shot ` + "`source.fixAll`" + ` for the whole file. |
### Code Quality and Analysis
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Manually hunting unused code | ` + "`analyze`" + ` with ` + "`kind: \"dead_code\"`" + ` — zero incoming edges (excludes entry points, tests, exports) |
| Guessing which symbols are over-coupled| ` + "`analyze`" + ` with ` + "`kind: \"hotspots\"`" + ` — ranks by fan-in, fan-out, community crossings |
| Manually scanning for circular deps | ` + "`analyze`" + ` with ` + "`kind: \"cycles\"`" + ` — Tarjan's SCC with severity classification |
| Surveying K8s manifests in the repo | ` + "`analyze`" + ` with ` + "`kind: \"k8s_resources\"`" + ` — KindResource fan-out (depends_on / configures / mounts / exposes / uses_env); ` + "`k8s_kind`" + ` / ` + "`namespace`" + ` / ` + "`name`" + ` filters |
| Listing container images in use | ` + "`analyze`" + ` with ` + "`kind: \"images\"`" + ` — KindImage with consumer count (Dockerfile FROM + K8s ` + "`container.image`" + `); ` + "`role`" + ` / ` + "`ref`" + ` / ` + "`tag`" + ` filters |
| Mapping the Kustomize overlay tree | ` + "`analyze`" + ` with ` + "`kind: \"kustomize\"`" + ` — KindKustomization with base / resource fan-out; ` + "`dir`" + ` filter |
| Auditing what crosses repo boundaries | ` + "`analyze`" + ` with ` + "`kind: \"cross_repo\"`" + ` — calls / implements / extends edges crossing repo boundaries, grouped by source → target repo; ` + "`repo`" + ` / ` + "`base_kind`" + ` / ` + "`path_prefix`" + ` filters |
| Surveying dbt / SQLMesh models | ` + "`analyze`" + ` with ` + "`kind: \"dbt_models\"`" + ` — dbt / SQLMesh models, seeds, snapshots, sources with column count + lineage fan-in/out; ` + "`framework`" + ` / ` + "`type`" + ` / ` + "`materialized`" + ` / ` + "`name`" + ` filters |
| Checking if the index is stale | ` + "`index_health`" + ` — health score, parse failures, stale files |
| Wondering what changed this session | ` + "`get_symbol_history`" + ` — modification counts, flags churning (3+ edits) |
### Code Generation and Editing
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Reading files to learn a pattern | ` + "`suggest_pattern`" + ` |
| Manually scaffolding from a pattern | ` + "`scaffold`" + ` — generates code, wiring, and test stubs from an example |
| Sequencing multi-file edits yourself | ` + "`batch_edit`" + ` — applies edits in dependency order, re-indexes between steps |
| Reading a diff without graph context | ` + "`diff_context`" + ` — enriches git diff with callers, callees, community, risk |
| Guessing what context you need next | ` + "`prefetch_context`" + ` — predicts needed symbols from task + recent activity |
### Dataflow (CPG-lite)
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Hand-tracing a value through helpers | ` + "`flow_between(source_id, sink_id, max_depth=8)`" + ` — ranked dataflow paths over ` + "`value_flow`" + ` ` + "`arg_of`" + ` ` + "`returns_to`" + ` |
| Grepping for sources / sinks | ` + "`taint_paths(source_pattern, sink_pattern)`" + ` — pattern sweep. Patterns: bare = name substring; ` + "`exact:Foo`" + `; ` + "`path:dir/`" + `; ` + "`kind:method`" + `. Sinks auto-expand functions to params. |
### Clone Detection
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Eyeballing the repo for copy-paste | ` + "`find_clones`" + ` — near-duplicate function/method clusters from the ` + "`similar_to`" + ` graph layer (MinHash + LSH; catches renamed-variable clones) |
| Finding safe-to-delete duplicates | ` + "`find_clones`" + ` with ` + "`dead_only: true`" + ` — clusters containing a dead-code symbol ("dead duplicates of live code") |
### Multi-Repo Management
| Instead of... | Use... |
|---------------------------------------|------------------------------------------|
| Manually adding a repo to config | ` + "`track_repository`" + ` — indexes immediately, persists to config |
| Manually removing a repo from config | ` + "`untrack_repository`" + ` — evicts nodes/edges, persists to config |
| Refreshing the graph after edits | ` + "`reindex_repository`" + ` — incremental re-index of changed files only; pass ` + "`paths`" + ` to scope |
| Wondering which project is active | ` + "`get_active_project`" + ` — returns project name and repo list |
| Switching project context | ` + "`set_active_project`" + ` — re-scopes all subsequent queries |
| Scoping a query to one repo | Pass ` + "`repo`" + ` param to ` + "`search_symbols`" + `, ` + "`find_usages`" + `, etc. |
| Scoping a query to a project | Pass ` + "`project`" + ` param to any query tool |
| Filtering by reference tag | Pass ` + "`ref`" + ` param to any query tool |
### Session Memory (save_note / query_notes / distill_session)
Gortex remembers code; this triplet remembers **why you made a call**. Notes persist per-repo across daemon restarts and context compactions, are scoped to the session's workspace, and are auto-linked to symbols mentioned in the body.
| Trigger | Use |
|----------------------------------------------------------|--------------------------------------------------------------------------------|
| Session start in a touched repo (after a compaction or on a fresh run) | ` + "`distill_session`" + ` — returns top symbols, pinned notes, decisions, recent excerpts. Seed your mental model before reading any file. |
| Making a decision, rejecting an alternative, hitting a non-obvious constraint, committing to an invariant | ` + "`save_note tags:\"decision\" body:\"<what+why>\"`" + ` — mention symbol IDs (` + "`pkg/foo.go::Bar`" + `) in the body for auto-linking; pin (` + "`pinned:true`" + `) anything load-bearing. |
| Before editing a symbol you've touched before | ` + "`query_notes symbol_id:\"<id>\"`" + ` — surfaces prior decisions and warnings attached to that symbol. |
Save: decisions, non-obvious constraints, follow-ups, bug reproductions, surprising findings, partial-progress hand-offs. Skip: play-by-play of what you just did (the diff says it), patterns derivable from the graph, anything already in the steering docs. Canonical tags: ` + "`decision`" + `, ` + "`bug`" + `, ` + "`follow-up`" + `, ` + "`gotcha`" + `, ` + "`invariant`" + `.
### Development Memories (store_memory / query_memories / surface_memories)
` + "`save_note`" + ` is a **per-session scratchpad**; ` + "`store_memory`" + ` is the **workspace-wide durable knowledge base** — entries outlive sessions, agents, and teammates so every future agent in the workspace inherits them.
| Trigger | Use |
|----------------------------------------------------------|--------------------------------------------------------------------------------|
| Immediately after ` + "`smart_context`" + ` (every new task) | ` + "`surface_memories task:\"<task>\" symbol_ids:\"<top hits>\"`" + ` — memories ranked by anchor overlap, importance, pinning, recency. Each hit carries ` + "`match_reasons`" + `. |
| You discover a durable invariant / gotcha / decision worth teaching the team | ` + "`store_memory kind:\"<invariant|gotcha|convention|decision>\" body:\"<what+why>\" symbol_ids:\"<id>\" importance:5`" + ` — pin load-bearing memories. |
| A memory is no longer true | ` + "`store_memory body:\"<corrected>\" supersedes:\"<old-id>\"`" + ` — preserves audit trail; old memory hidden by default. |
Store: invariants, conventions, incident learnings, API contracts not enforced by types, debugging traps, cross-cutting decisions. Skip: anything derivable from code, session-local play-by-play (use ` + "`save_note`" + `), steering-doc content.
## Session workflow
1. Call ` + "`index_health`" + ` to confirm Gortex is running and indexed (` + "`graph_stats`" + ` for counts). If ` + "`total_nodes`" + ` is 0, call ` + "`index_repository`" + ` with path ` + "`\".\"`" + `.
2. Call ` + "`distill_session`" + ` to recover prior session memory for this workspace.
3. In multi-repo mode, call ` + "`get_active_project`" + ` to check scope. Use ` + "`set_active_project`" + ` to switch if needed.
4. For a new task, call ` + "`smart_context`" + ` with the task description. Immediately after, call ` + "`surface_memories`" + ` with the same task description and the top symbol hits.
5. Before editing any file, call ` + "`get_editing_context`" + ` first. If you've touched the symbol before, also call ` + "`query_notes symbol_id:\"<id>\"`" + ` and ` + "`query_memories symbol_id:\"<id>\"`" + `.
6. Before changing a function signature, call ` + "`verify_change`" + ` to catch contract violations — checks callers across all repos.
7. Before any refactor, call ` + "`get_edit_plan`" + ` for dependency-ordered file list. Use ` + "`batch_edit`" + ` to apply atomically.
8. Verify with the project's real build/test. Reserve ` + "`check_guards`" + ` for guard-relevant changes and ` + "`get_test_targets`" + ` to find the tests covering a substantive change (includes cross-repo test files).
9. After making a meaningful decision or hitting a non-obvious constraint, call ` + "`save_note`" + ` so the next session can recover it. If the discovery is workspace-wide and worth teaching the team, call ` + "`store_memory`" + ` instead — that compounds across sessions.
10. Before committing, call ` + "`detect_changes`" + ` to verify scope. Use ` + "`diff_context`" + ` for graph-enriched review.
`
const steeringExplore = `---
inclusion: manual
---
# Exploring Codebases with Gortex
## Workflow
1. ` + "`index_health`" + ` — confirm the index is ready (` + "`graph_stats`" + ` for node/edge counts)
2. ` + "`get_communities`" + ` — see functional clusters (architecture overview)
3. ` + "`search_symbols({query: \"<concept>\"})`" + ` — find symbols related to a concept
4. ` + "`get_processes`" + ` — discover execution flows
5. ` + "`get_process({id: \"<process-id>\"})`" + ` — trace a specific flow step by step
6. ` + "`get_editing_context({path: \"<file>\"})`" + ` — deep dive on a specific file
## When to use
- "How does authentication work?"
- "What's the project structure?"
- "Show me the main components"
- Understanding code you haven't seen before
## Key tools
- ` + "`get_communities`" + ` for architectural overview (functional clusters with cohesion scores)
- ` + "`get_processes`" + ` for execution flow discovery (entry points to call chains)
- ` + "`search_symbols`" + ` for concept-based symbol search (BM25 + camelCase-aware)
- ` + "`get_editing_context`" + ` for 360-degree file view (symbols, callers, callees, imports)
`
const steeringDebug = `---
inclusion: manual
---
# Debugging with Gortex
## Workflow
1. ` + "`search_symbols({query: \"<error or suspect>\"})`" + ` — find related symbols
2. ` + "`get_callers({id: \"<suspect>\"})`" + ` — who calls it?
3. ` + "`get_call_chain({id: \"<suspect>\"})`" + ` — what does it call?
4. ` + "`get_editing_context({path: \"<file>\"})`" + ` — full file context
5. ` + "`get_process({id: \"<process>\"})`" + ` — trace execution flow
## Debugging patterns
| Symptom | Gortex Approach |
| -------------------- | --------------- |
| Error message | ` + "`search_symbols`" + ` for error-related names, then ` + "`get_callers`" + ` on throw sites |
| Wrong return value | ` + "`get_call_chain`" + ` on the function, trace callees for data flow |
| Intermittent failure | ` + "`get_editing_context`" + `, look for external calls and async deps |
| Performance issue | ` + "`find_usages`" + `, find symbols with many callers (hot paths) |
| Recent regression | ` + "`detect_changes`" + `, see what your changes affect |
`
const steeringImpact = `---
inclusion: manual
---
# Impact Analysis with Gortex
## Workflow
1. ` + "`search_symbols({query: \"X\"})`" + ` — find the symbol ID
2. ` + "`explain_change_impact({ids: \"<id1>, <id2>\"})`" + ` — risk-tiered blast radius
3. ` + "`get_dependents({id: \"<symbol-id>\", depth: 3})`" + ` — detailed dependent tree
4. ` + "`detect_changes({scope: \"staged\"})`" + ` — pre-commit check
## Risk tiers
| Depth | Risk Level | Meaning |
| ----- | -------------- | ------------------------ |
| d=1 | WILL BREAK | Direct callers/importers |
| d=2 | LIKELY AFFECTED| Indirect dependencies |
| d=3 | MAY NEED TESTING| Transitive effects |
## Before any non-trivial change
- Call ` + "`explain_change_impact`" + ` with all symbols you plan to modify
- Review the risk level (LOW/MEDIUM/HIGH/CRITICAL)
- Check ` + "`by_depth`" + `: d=1 items WILL BREAK
- Note ` + "`affected_processes`" + ` and ` + "`affected_communities`" + `
- Check ` + "`test_files`" + ` that need re-running
- Before commit: ` + "`detect_changes`" + ` to verify scope
`
const steeringRefactor = `---
inclusion: manual
---
# Refactoring with Gortex
## Workflow
1. ` + "`search_symbols({query: \"X\"})`" + ` — find the symbol ID
2. ` + "`explain_change_impact({ids: \"<id>\"})`" + ` — map blast radius
3. ` + "`get_editing_context({path: \"<file>\"})`" + ` — see all symbols and relationships
4. ` + "`find_usages({id: \"<id>\"})`" + ` — every reference to change
5. ` + "`get_edit_plan({ids: \"<ids>\"})`" + ` — dependency-ordered edit sequence
6. Edit in order: interfaces -> implementations -> callers -> tests
7. ` + "`detect_changes({scope: \"all\"})`" + ` — verify after changes
## Rename symbol
- ` + "`find_usages`" + ` to get every reference location
- ` + "`explain_change_impact`" + ` to assess blast radius
- Edit in dependency order: definition, then callers, then tests
## Extract module
- ` + "`get_editing_context`" + ` on the source file to see all symbols
- ` + "`get_dependents`" + ` on symbols to extract to find external callers
- ` + "`find_import_path`" + ` for correct import paths in the new location
## Split function/service
- ` + "`get_call_chain`" + ` to understand all callees
- ` + "`get_callers`" + ` to map all call sites that need updating
- ` + "`explain_change_impact`" + ` for full blast radius
`
const hookSmartContext = `{
"name": "Gortex: Smart Context on Prompt",
"version": "1.0.0",
"description": "On each new prompt, calls smart_context to assemble task-relevant code context from the knowledge graph in one shot.",
"when": {
"type": "promptSubmit"
},
"then": {
"type": "askAgent",
"prompt": "If the user's message describes a coding task (adding a feature, fixing a bug, refactoring, understanding code), call Gortex's smart_context tool with the task description to get relevant symbols, source code, relationships, and an edit plan in one call. Skip this for non-coding questions or simple chat."
}
}
`
const hookPostEdit = `{
"name": "Gortex: Post-Edit Impact Check",
"version": "1.0.0",
"description": "After saving a source file, runs detect_changes and get_test_targets to show blast radius and which tests to run.",
"when": {
"type": "fileEdited",
"patterns": ["**/*.go", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.py", "**/*.rs", "**/*.java", "**/*.kt", "**/*.scala", "**/*.swift", "**/*.rb", "**/*.cs", "**/*.php"]
},
"then": {
"type": "askAgent",
"prompt": "A source file was just edited. Call Gortex detect_changes with scope unstaged to see which symbols were affected and the risk level. If symbols were changed, also call get_test_targets with those symbol IDs to identify which tests should be run. Briefly report the risk level and test commands."
}
}
`
const hookPreRead = `{
"name": "Gortex: Enrich File Reads",
"version": "1.0.0",
"description": "Before reading a source file, calls get_editing_context to inject symbol context, callers, callees, and imports.",
"when": {
"type": "preToolUse",
"toolTypes": ["read"]
},
"then": {
"type": "askAgent",
"prompt": "SKIP this hook entirely (do nothing, proceed with the read) if ANY of these are true: (1) the file path contains .kiro/, .claude/, .github/, .vscode/, or node_modules/, (2) the file extension is .md, .json, .yaml, .yml, .toml, .txt, .lock, .sum, .mod, .env, .gitignore, .html, .css, or .svg, (3) the file is not a source code file. ONLY for source code files (.go, .ts, .tsx, .js, .jsx, .py, .rs, .java, .kt, .cs, .rb, .php, .swift, .scala, .c, .cpp, .h): call get_editing_context or get_file_summary for the file to get symbol context before reading it."
}
}
`
// AutoApproveTools is the list of Gortex MCP tools Kiro should
// auto-approve without prompting. Baked into the mcp.json entry so
// the user isn't interrupted for every query call.
var AutoApproveTools = []string{
"graph_stats", "search_symbols", "winnow_symbols", "get_symbol", "get_file_summary",
"get_editing_context", "get_dependencies", "get_dependents",
"get_call_chain", "get_callers", "find_implementations", "find_usages",
"get_cluster", "get_symbol_source", "batch_symbols",
"find_import_path", "explain_change_impact", "get_recent_changes",
"smart_context", "get_edit_plan", "get_test_targets", "suggest_pattern",
"get_communities", "get_processes",
"detect_changes", "index_repository", "reindex_repository",
"verify_change", "check_guards", "prefetch_context",
"analyze",
"diff_context", "index_health", "get_symbol_history",
"scaffold", "batch_edit",
"contracts", "feedback",
"save_note", "query_notes", "distill_session",
"store_memory", "query_memories", "surface_memories",
}
+319
View File
@@ -0,0 +1,319 @@
package agents
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
)
// UpsertMCPServer merges a gortex-flavored MCP server stanza into a
// map that follows the standard {"mcpServers": {<name>: {...}}}
// shape used by Claude Code, Cursor, VS Code, Continue.dev, Cline,
// and Kiro. Returns true when the map was modified (false when a
// gortex stanza was already present and opts.Force is off).
//
// serverName is the key under mcpServers (canonically "gortex").
// entry is the stanza value — adapters produce their own variant
// when the target client uses a different shape (e.g. Cline's
// alwaysAllow list, Kiro's autoApprove list).
func UpsertMCPServer(root map[string]any, serverName string, entry map[string]any, opts ApplyOpts) (changed bool) {
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if _, exists := servers[serverName]; exists && !opts.Force {
return false
}
servers[serverName] = entry
root["mcpServers"] = servers
return true
}
// RemoveMCPServer deletes serverName from the {"mcpServers": {...}}
// map, pruning the parent key when removal leaves it empty. Returns
// true when the map was modified. Counterpart to UpsertMCPServer for
// uninstall paths — all other servers (and the user's other config)
// are left untouched.
func RemoveMCPServer(root map[string]any, serverName string) (changed bool) {
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
return false
}
if _, exists := servers[serverName]; !exists {
return false
}
delete(servers, serverName)
if len(servers) == 0 {
delete(root, "mcpServers")
} else {
root["mcpServers"] = servers
}
return true
}
// UpsertMCPServerWithMigration is like UpsertMCPServer but also
// rewrites entries that look Gortex-authored (any `gortex mcp ...`
// stanza) even without opts.Force. This lets `gortex install` swap
// the legacy `mcp --index . --watch` shape — which fails Cursor's
// global-cwd handshake — for the daemon-proxy shape on next install.
//
// If the existing entry is already byte-identical to `entry`, returns
// false so the caller reports "already-configured" instead of a noisy
// rewrite. User-customized entries (anything that doesn't look like
// Gortex authored it) are left untouched unless opts.Force is set.
func UpsertMCPServerWithMigration(root map[string]any, serverName string, entry map[string]any, opts ApplyOpts) (changed bool) {
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if existing, exists := servers[serverName]; exists {
if MCPEntriesEqual(existing, entry) {
return false
}
if !opts.Force && !IsGortexAuthoredMCPEntry(existing) {
return false
}
}
servers[serverName] = entry
root["mcpServers"] = servers
return true
}
// IsGortexAuthoredMCPEntry returns true for MCP server stanzas that
// look like Gortex wrote them — a command naming the gortex binary
// (bare "gortex" or an absolute path whose basename is gortex) and an
// args list starting with the `mcp` subcommand. Used by global-mode
// installers to migrate their own legacy stanzas — including the older
// absolute-path form — without clobbering user-customized servers.
func IsGortexAuthoredMCPEntry(entry any) bool {
m, ok := entry.(map[string]any)
if !ok {
return false
}
cmd, _ := m["command"].(string)
if !commandIsGortex(cmd) {
return false
}
args, ok := m["args"].([]any)
if !ok || len(args) == 0 {
return false
}
first, _ := args[0].(string)
return first == "mcp"
}
// commandIsGortex reports whether an MCP stanza's command string names
// the gortex binary — either the bare "gortex"/"gortex.exe" name or an
// absolute path whose basename is gortex (the legacy os.Executable()
// form a pre-fix `gortex install` baked into ~/.claude.json). Lets the
// global installer recognize and migrate its own older stanza in place
// instead of leaving a stale absolute path that disagrees with the
// bare-`gortex` project .mcp.json template.
func commandIsGortex(cmd string) bool {
return gortexCommandBase(cmd) == "gortex"
}
// gortexCommandBase extracts the binary base name from a command
// string, tolerating both / and \ separators so a path written on one
// OS is still recognized when parsed on another (matters for
// cross-platform tests; in production the path is always native). The
// trailing extension (.exe on Windows) is stripped.
func gortexCommandBase(cmd string) string {
if cmd == "" {
return ""
}
base := cmd
if i := strings.LastIndexAny(base, `/\`); i >= 0 {
base = base[i+1:]
}
return strings.TrimSuffix(base, filepath.Ext(base))
}
// ResolveGortexCommand returns the command string an installer should
// bake into a gortex MCP server stanza. It prefers the bare "gortex"
// name — portable across machines and byte-identical to the project
// .mcp.json template — but only when "gortex" on PATH resolves to the
// same binary that is currently running. Matching the project template
// matters for Claude Code specifically: it stores OAuth tokens per
// endpoint (command + args), so a user-scope entry that disagrees with
// a project-scope entry trips its "conflicting scopes" diagnostic.
//
// When the running binary is not reachable on PATH (e.g. a Windows
// install whose program directory is not on PATH) it falls back to the
// absolute os.Executable() path so the entry still launches. Under
// `go run` (a transient temp build) or any other ambiguity it falls
// back to the bare name rather than bake a path that won't exist later.
func ResolveGortexCommand() string {
exe, exeErr := os.Executable()
lp, lpErr := exec.LookPath("gortex")
return resolveGortexCommandFrom(exe, exeErr, lp, lpErr, sameFile)
}
// resolveGortexCommandFrom is the pure decision core of
// ResolveGortexCommand, split out so the PATH/executable inputs can be
// injected in tests.
func resolveGortexCommandFrom(exe string, exeErr error, lookPath string, lookErr error, same func(a, b string) bool) string {
exeUsable := exeErr == nil && exe != "" && gortexCommandBase(exe) == "gortex" && !isUnderTempDir(exe)
if lookErr == nil && lookPath != "" {
// On PATH: collapse to the bare name when it points at the
// binary we are running (or we cannot trust os.Executable),
// so the entry matches the portable project template.
if !exeUsable || same(lookPath, exe) {
return "gortex"
}
}
if exeUsable {
// Not on PATH, but we know exactly where we live: pin the
// absolute path so the entry launches.
return exe
}
return "gortex"
}
// ResolveGortexHookBinary returns the gortex binary reference a Claude
// Code hook command should embed. It shares ResolveGortexCommand's
// same-file decision core so the hook process and the MCP server/daemon
// never resolve to different binaries — a side-by-side install (CI
// sandbox, a downloaded release run before linking, a dev build) would
// otherwise bake the running binary into the MCP stanza but a stray
// PATH gortex into the hook, and the hook could then consult a
// different daemon than the one serving the session's graph tools.
//
// It differs from ResolveGortexCommand only in output preference: a
// hook fires in a shell whose PATH may not match install time, so it
// pins an absolute path whenever the running binary is usable rather
// than the bare name. The returned value is a bare binary reference
// with no subcommand — callers append " hook" (and any --mode suffix).
func ResolveGortexHookBinary() string {
exe, exeErr := os.Executable()
lp, lpErr := exec.LookPath("gortex")
return resolveGortexHookBinaryFrom(exe, exeErr, lp, lpErr, sameFile)
}
// resolveGortexHookBinaryFrom is the pure decision core of
// ResolveGortexHookBinary, split out so the PATH/executable inputs can
// be injected in tests. It resolves to the same on-disk binary as
// resolveGortexCommandFrom in every case where the running binary is
// usable (both pin the running exe, or the bare name that PATH resolves
// back to it); it only prefers an absolute path over the bare name.
//
// The ephemeral guard is deliberately on the running binary alone
// (exeUsable's !isUnderTempDir(exe)): a `go run` transient build must
// never be baked into a long-lived hook, so we fall back to the PATH
// gortex — the same file the MCP stanza collapses to — instead. A
// PATH lookup that itself points into a temp dir is the user's own
// PATH and is left to resolve, matching resolveGortexCommandFrom.
func resolveGortexHookBinaryFrom(exe string, exeErr error, lookPath string, lookErr error, same func(a, b string) bool) string {
exeUsable := exeErr == nil && exe != "" && gortexCommandBase(exe) == "gortex" && !isUnderTempDir(exe)
if exeUsable {
// The running binary is a stable, non-ephemeral gortex: pin it.
// This is the same file the MCP stanza launches — whether that
// stanza used the bare name (PATH gortex IS this binary) or the
// absolute exe (off-PATH, or a different gortex on PATH).
return exe
}
if lookErr == nil && lookPath != "" {
// Running binary isn't a bakeable path (go run temp build,
// unstattable, or not named gortex). Fall back to PATH's gortex
// — the file the MCP stanza collapses to — as an absolute path.
return lookPath
}
return "gortex"
}
// isUnderTempDir reports whether p lives under the OS temp directory —
// the tell-tale of a `go run` / `go test` transient build that must not
// be baked into a long-lived config.
func isUnderTempDir(p string) bool {
return strings.HasPrefix(p, filepath.Clean(os.TempDir())+string(os.PathSeparator))
}
// sameFile reports whether two paths reference the same on-disk binary,
// resolving symlinks and falling back to os.SameFile. A pure string
// match short-circuits the stat calls.
func sameFile(a, b string) bool {
if a == b {
return true
}
if ra, err := filepath.EvalSymlinks(a); err == nil {
if rb, err := filepath.EvalSymlinks(b); err == nil && ra == rb {
return true
}
}
fa, e1 := os.Stat(a)
fb, e2 := os.Stat(b)
return e1 == nil && e2 == nil && os.SameFile(fa, fb)
}
// MCPEntriesEqual compares two MCP stanzas by their JSON-marshaled
// form. Round-tripping is the simplest way to handle the []string vs
// []any drift between freshly-built entries and entries decoded from
// an existing mcp.json on disk. Exported so the doctor can compare an
// on-disk stanza against DefaultGortexMCPEntry() to flag a stale entry.
func MCPEntriesEqual(a, b any) bool {
aj, err := json.Marshal(a)
if err != nil {
return false
}
bj, err := json.Marshal(b)
if err != nil {
return false
}
var av, bv any
if err := json.Unmarshal(aj, &av); err != nil {
return false
}
if err := json.Unmarshal(bj, &bv); err != nil {
return false
}
return reflect.DeepEqual(av, bv)
}
// DefaultGortexMCPEntry returns the shared {command, args, env}
// stanza most clients accept for project-local MCP configs (where
// the editor launches the process with cwd set to the project root).
// Adapters that want extra keys wrap this and add them (e.g. Cline's
// alwaysAllow, Kiro's autoApprove).
//
// The command intentionally points at the bare "gortex" binary on
// PATH rather than os.Executable() — users who installed via
// Homebrew or `go install` get a stable path, and installers that
// run `go build -o /tmp/...` don't bake the transient path into
// long-lived configs.
// gortexMCPArgs is the one canonical args list every adapter emits.
// Both project and global configs use it: the daemon resolves the active
// workspace per session from the request handshake, so no cwd-relative
// flag (--index/--watch) and no proxy flag (--proxy) is needed. `gortex
// mcp` proxies to (and auto-starts) the daemon and falls back to an
// embedded server on its own.
func gortexMCPArgs() []string { return []string{"mcp"} }
func DefaultGortexMCPEntry() map[string]any {
return map[string]any{
"command": "gortex",
"args": gortexMCPArgs(),
"env": map[string]string{"GORTEX_INDEX_WORKERS": "8"},
}
}
// GlobalGortexMCPEntry returns the daemon-proxy MCP entry suitable
// for user-level (global) configs, where the editor may launch the
// MCP process with cwd set to the user's home directory rather than
// any open project — Cursor's global-mcp behaviour reported in
// gortexhq/gortex#19.
//
// The proxy shape carries no cwd-relative state: the daemon resolves
// the active workspace per session from the request handshake, so
// the global config never trips the strict entry-point check on the
// home directory. If no daemon is running, `gortex mcp --proxy` exits
// with a clear "no daemon" error rather than silently falling back
// to a broken indexer.
// GlobalGortexMCPEntry is now identical to DefaultGortexMCPEntry — the
// canonical `["mcp"]` shape carries no cwd-relative or proxy flag. Kept
// as a named function so existing call sites compile.
func GlobalGortexMCPEntry() map[string]any {
return DefaultGortexMCPEntry()
}
+47
View File
@@ -0,0 +1,47 @@
package agents
import "testing"
// TestMCPArgsConvergeOnMcp asserts the shared MCP entries emit the single
// canonical ["mcp"] args shape (no --index/--watch/--proxy).
func TestMCPArgsConvergeOnMcp(t *testing.T) {
for name, entry := range map[string]map[string]any{
"default": DefaultGortexMCPEntry(),
"global": GlobalGortexMCPEntry(),
} {
args, _ := entry["args"].([]string)
if len(args) != 1 || args[0] != "mcp" {
t.Errorf("%s entry args = %v, want [mcp]", name, args)
}
}
}
// TestUpsertMigratesLegacyShapes asserts the upsert rewrites every legacy
// on-disk args shape to the canonical ["mcp"] on first run, and is a no-op
// on the second run (idempotent, self-healing).
func TestUpsertMigratesLegacyShapes(t *testing.T) {
legacy := map[string][]any{
"index-watch": {"mcp", "--index", ".", "--watch"},
"proxy": {"mcp", "--proxy"},
}
for name, args := range legacy {
t.Run(name, func(t *testing.T) {
root := map[string]any{
"mcpServers": map[string]any{
"gortex": map[string]any{"command": "gortex", "args": args},
},
}
if changed := UpsertMCPServerWithMigration(root, "gortex", DefaultGortexMCPEntry(), ApplyOpts{}); !changed {
t.Fatal("first upsert should migrate a legacy stanza")
}
got := root["mcpServers"].(map[string]any)["gortex"].(map[string]any)["args"]
gotArgs, _ := got.([]string)
if len(gotArgs) != 1 || gotArgs[0] != "mcp" {
t.Fatalf("migrated args = %v, want [mcp]", got)
}
if changed := UpsertMCPServerWithMigration(root, "gortex", DefaultGortexMCPEntry(), ApplyOpts{}); changed {
t.Fatal("second upsert must be a no-op (already canonical)")
}
})
}
}
+358
View File
@@ -0,0 +1,358 @@
package agents
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
)
// TestGlobalGortexMCPEntryUsesProxy verifies that the user-level MCP
// entry is daemon-only — no cwd-relative `--index .` arg. This is the
// load-bearing property for gortexhq/gortex#19: Cursor launches the
// global config with cwd=$HOME, so any cwd-relative indexing would
// fail the entry-point handshake.
func TestGlobalGortexMCPEntryUsesProxy(t *testing.T) {
entry := GlobalGortexMCPEntry()
cmd, _ := entry["command"].(string)
if cmd != "gortex" {
t.Fatalf("command = %q, want %q", cmd, "gortex")
}
args, _ := entry["args"].([]string)
if len(args) == 0 || args[0] != "mcp" {
t.Fatalf("args[0] = %v, want mcp; full args=%v", args, args)
}
// The canonical shape is bare ["mcp"]: no cwd-relative --index/--watch
// (the daemon resolves the workspace per session) and no --proxy
// (gortex mcp proxies to / auto-starts the daemon on its own).
if len(args) != 1 || args[0] != "mcp" {
t.Errorf("global entry must emit canonical [mcp], got %v", args)
}
}
// TestIsGortexAuthoredMCPEntry checks the heuristic used by
// UpsertMCPServerWithMigration to decide which existing stanzas are
// safe to overwrite during a global-install migration.
func TestIsGortexAuthoredMCPEntry(t *testing.T) {
cases := []struct {
name string
raw string
want bool
}{
{
name: "legacy --index . shape (the bug from issue 19)",
raw: `{"command":"gortex","args":["mcp","--index",".","--watch"]}`,
want: true,
},
{
name: "current --proxy shape",
raw: `{"command":"gortex","args":["mcp","--proxy"]}`,
want: true,
},
{
name: "user wrapper that shells gortex through a script",
raw: `{"command":"/usr/local/bin/wrap-gortex.sh","args":["mcp"]}`,
want: false,
},
{
name: "user's own MCP server, not gortex at all",
raw: `{"command":"other-tool","args":["serve"]}`,
want: false,
},
{
name: "gortex command but a different subcommand",
raw: `{"command":"gortex","args":["daemon","start"]}`,
want: false,
},
{
name: "empty args list",
raw: `{"command":"gortex","args":[]}`,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var v any
if err := json.Unmarshal([]byte(tc.raw), &v); err != nil {
t.Fatalf("unmarshal: %v", err)
}
got := IsGortexAuthoredMCPEntry(v)
if got != tc.want {
t.Errorf("IsGortexAuthoredMCPEntry(%s) = %v, want %v", tc.raw, got, tc.want)
}
})
}
}
// TestUpsertMCPServerWithMigrationReplacesLegacy exercises the
// migration path: a user with an old global config containing
// `--index . --watch` should get rewritten to the proxy entry on next
// `gortex install`, even though opts.Force is off.
func TestUpsertMCPServerWithMigrationReplacesLegacy(t *testing.T) {
root := map[string]any{
"mcpServers": map[string]any{
"gortex": map[string]any{
"command": "gortex",
"args": []any{"mcp", "--index", ".", "--watch"},
"env": map[string]any{"GORTEX_INDEX_WORKERS": "8"},
},
},
}
want := GlobalGortexMCPEntry()
changed := UpsertMCPServerWithMigration(root, "gortex", want, ApplyOpts{})
if !changed {
t.Fatalf("expected migration to rewrite legacy entry, got changed=false")
}
servers, _ := root["mcpServers"].(map[string]any)
got, _ := servers["gortex"].(map[string]any)
if got["command"] != "gortex" {
t.Fatalf("command lost during migration: %v", got)
}
args, _ := got["args"].([]string)
for _, a := range args {
if a == "--index" {
t.Fatalf("migration left legacy --index in args: %v", got)
}
}
}
// TestUpsertMCPServerWithMigrationIdempotent verifies the second run
// is a no-op once the config is already in the desired shape — no
// spurious rewrite, no noisy "modified" log.
func TestUpsertMCPServerWithMigrationIdempotent(t *testing.T) {
// Use the on-disk shape (args as []any) to mirror what comes back
// out of json.Unmarshal on an existing mcp.json.
root := map[string]any{
"mcpServers": map[string]any{
"gortex": map[string]any{
"command": "gortex",
"args": []any{"mcp"},
"env": map[string]any{"GORTEX_INDEX_WORKERS": "8"},
},
},
}
if changed := UpsertMCPServerWithMigration(root, "gortex", GlobalGortexMCPEntry(), ApplyOpts{}); changed {
t.Errorf("expected no rewrite when entry already matches; got changed=true")
}
}
// TestUpsertMCPServerWithMigrationPreservesUserCustomization confirms
// the migration is fingerprint-gated: a user who hand-rolled their
// own gortex wrapper (non-`gortex` command, or a different subcommand)
// should not have their config silently overwritten.
func TestUpsertMCPServerWithMigrationPreservesUserCustomization(t *testing.T) {
root := map[string]any{
"mcpServers": map[string]any{
"gortex": map[string]any{
"command": "/opt/scripts/my-gortex.sh",
"args": []any{"mcp"},
},
},
}
if changed := UpsertMCPServerWithMigration(root, "gortex", GlobalGortexMCPEntry(), ApplyOpts{}); changed {
t.Errorf("user-customized entry was overwritten without --force")
}
// And with --force the same call should overwrite — that's the
// escape hatch users have when they want Gortex to take over.
if changed := UpsertMCPServerWithMigration(root, "gortex", GlobalGortexMCPEntry(), ApplyOpts{Force: true}); !changed {
t.Errorf("opts.Force must overwrite user-customized entry")
}
}
// TestIsGortexAuthoredMCPEntryAbsolutePath verifies the broadened
// detection: the legacy absolute-path command form a pre-fix `gortex
// install` baked into ~/.claude.json (via os.Executable()) is now
// recognized as Gortex-authored, so the next install can migrate it to
// the canonical bare-`gortex` shape. A user's own wrapper script is
// still left alone. Both / and \ separators are handled regardless of
// the host OS.
func TestIsGortexAuthoredMCPEntryAbsolutePath(t *testing.T) {
cases := []struct {
name string
raw string
want bool
}{
{
name: "unix absolute path to gortex (legacy os.Executable form)",
raw: `{"command":"/usr/local/bin/gortex","args":["mcp"]}`,
want: true,
},
{
name: "windows absolute path to gortex.exe (the issue #201 form)",
raw: `{"command":"C:\\Users\\daoti\\AppData\\Local\\Programs\\gortex\\gortex.exe","args":["mcp"]}`,
want: true,
},
{
name: "absolute path to a non-gortex binary",
raw: `{"command":"/usr/local/bin/something-else","args":["mcp"]}`,
want: false,
},
{
name: "user wrapper whose basename merely contains gortex",
raw: `{"command":"/opt/scripts/my-gortex.sh","args":["mcp"]}`,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var v any
if err := json.Unmarshal([]byte(tc.raw), &v); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got := IsGortexAuthoredMCPEntry(v); got != tc.want {
t.Errorf("IsGortexAuthoredMCPEntry(%s) = %v, want %v", tc.raw, got, tc.want)
}
})
}
}
// TestResolveGortexCommandFrom pins the command-resolution policy that
// keeps the user-scope ~/.claude.json entry in agreement with the
// project .mcp.json template (and so avoids Claude Code's "conflicting
// scopes" diagnostic) while staying robust when gortex isn't on PATH.
func TestResolveGortexCommandFrom(t *testing.T) {
sameTrue := func(a, b string) bool { return true }
sameFalse := func(a, b string) bool { return false }
notFound := errors.New("executable file not found in $PATH")
cases := []struct {
name string
exe string
exeErr error
lookPath string
lookErr error
same func(a, b string) bool
want string
}{
{
name: "on PATH and same binary -> bare gortex (matches project template)",
exe: "/usr/local/bin/gortex",
lookPath: "/usr/local/bin/gortex",
same: sameTrue,
want: "gortex",
},
{
name: "installed but not on PATH -> pin absolute path",
exe: `C:\Users\daoti\AppData\Local\Programs\gortex\gortex.exe`,
lookPath: "",
lookErr: notFound,
same: sameFalse,
want: `C:\Users\daoti\AppData\Local\Programs\gortex\gortex.exe`,
},
{
name: "on PATH but a different gortex -> pin the running binary, not the PATH one",
exe: "/opt/build/gortex",
lookPath: "/usr/local/bin/gortex",
same: sameFalse,
want: "/opt/build/gortex",
},
{
name: "go run transient temp build + gortex on PATH -> bare gortex",
exe: filepath.Join(os.TempDir(), "go-build1234", "agents.test"),
lookPath: "/usr/local/bin/gortex",
same: sameFalse,
want: "gortex",
},
{
name: "nothing resolvable -> bare gortex last resort",
exe: "",
exeErr: errors.New("os.Executable failed"),
lookErr: notFound,
same: sameFalse,
want: "gortex",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := resolveGortexCommandFrom(tc.exe, tc.exeErr, tc.lookPath, tc.lookErr, tc.same)
if got != tc.want {
t.Errorf("resolveGortexCommandFrom() = %q, want %q", got, tc.want)
}
})
}
}
// TestResolveGortexHookBinaryFrom mirrors TestResolveGortexCommandFrom's
// table for the hook resolver, and additionally asserts the load-bearing
// invariant: the hook binary and the MCP stanza launch the SAME on-disk
// file in every case. The hook only prefers an absolute path over the
// bare name (it fires in a shell whose PATH may differ from install
// time); it must never point at a different gortex than the MCP server.
func TestResolveGortexHookBinaryFrom(t *testing.T) {
sameTrue := func(a, b string) bool { return true }
sameFalse := func(a, b string) bool { return false }
notFound := errors.New("executable file not found in $PATH")
cases := []struct {
name string
exe string
exeErr error
lookPath string
lookErr error
same func(a, b string) bool
want string
}{
{
name: "on PATH and same binary -> pin absolute (hook avoids PATH at fire time)",
exe: "/usr/local/bin/gortex",
lookPath: "/usr/local/bin/gortex",
same: sameTrue,
want: "/usr/local/bin/gortex",
},
{
name: "installed but not on PATH -> pin absolute path",
exe: `C:\Users\daoti\AppData\Local\Programs\gortex\gortex.exe`,
lookPath: "",
lookErr: notFound,
same: sameFalse,
want: `C:\Users\daoti\AppData\Local\Programs\gortex\gortex.exe`,
},
{
name: "on PATH but a different gortex -> pin the running binary, not the PATH one",
exe: "/opt/build/gortex",
lookPath: "/usr/local/bin/gortex",
same: sameFalse,
want: "/opt/build/gortex",
},
{
name: "go run transient temp build + gortex on PATH -> PATH gortex, never the temp build",
exe: filepath.Join(os.TempDir(), "go-build1234", "agents.test"),
lookPath: "/usr/local/bin/gortex",
same: sameFalse,
want: "/usr/local/bin/gortex",
},
{
name: "nothing resolvable -> bare gortex last resort",
exe: "",
exeErr: errors.New("os.Executable failed"),
lookErr: notFound,
same: sameFalse,
want: "gortex",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := resolveGortexHookBinaryFrom(tc.exe, tc.exeErr, tc.lookPath, tc.lookErr, tc.same)
if got != tc.want {
t.Errorf("resolveGortexHookBinaryFrom() = %q, want %q", got, tc.want)
}
// Map each resolver output to the concrete file it launches:
// an absolute path is itself; the bare name resolves via PATH
// to lookPath. The two must never disagree.
launched := func(resolved string) string {
if resolved == "gortex" {
return tc.lookPath
}
return resolved
}
mcp := resolveGortexCommandFrom(tc.exe, tc.exeErr, tc.lookPath, tc.lookErr, tc.same)
if hf, mf := launched(got), launched(mcp); hf != mf {
t.Errorf("hook/MCP disagree on binary file: hook %q -> %q, mcp %q -> %q", got, hf, mcp, mf)
}
})
}
}
+96
View File
@@ -0,0 +1,96 @@
// Package ohmypi implements the Gortex init integration for
// Oh My Pi (omp). Oh My Pi stores project-level MCP servers
// at .omp/mcp.json using the canonical mcpServers schema:
//
// {
// "mcpServers": {
// "gortex": {
// "command": "gortex",
// "args": ["mcp"],
// "env": {"GORTEX_INDEX_WORKERS": "8"}
// }
// }
// }
//
// The MCP client name sent during the initialize handshake is
// "omp-coding-agent"; Gortex uses this to default the response
// format to GCX1.
package ohmypi
import (
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "oh-my-pi"
const DocsURL = "https://github.com/can1357/oh-my-pi/blob/main/docs/mcp-config.md"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if env.Mode == agents.ModeGlobal {
return false, nil
}
if _, err := os.Stat(filepath.Join(env.Root, ".omp")); err == nil {
return true, nil
}
if p, err := exec.LookPath("omp"); err == nil && p != "" {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
if env.Mode == agents.ModeGlobal {
return &agents.Plan{}, nil
}
return &agents.Plan{Files: []agents.FileAction{
{Path: filepath.Join(env.Root, ".omp", "mcp.json"), Action: agents.ActionWouldMerge, Keys: []string{"mcpServers"}},
}}, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
if env.Mode == agents.ModeGlobal {
return res, nil
}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Oh My Pi setup (oh-my-pi not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Oh My Pi integration...")
path := filepath.Join(env.Root, ".omp", "mcp.json")
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
servers, ok := root["mcpServers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if _, exists := servers["gortex"]; exists && !opts.Force {
return false, nil
}
servers["gortex"] = map[string]any{
"command": "gortex",
"args": []string{"mcp"},
"env": map[string]string{"GORTEX_INDEX_WORKERS": "8"},
}
root["mcpServers"] = servers
return true, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
res.Configured = true
return res, nil
}
+84
View File
@@ -0,0 +1,84 @@
package ohmypi
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
func TestOhMyPiCreatesAndMerges(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Create sentinel .omp directory so Detect returns true.
if err := os.MkdirAll(filepath.Join(env.Root, ".omp"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
// Apply should write the MCP config to .omp/mcp.json.
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Detected {
t.Fatal("expected Detected=true after creating .omp/")
}
if !res.Configured {
t.Fatal("expected Configured=true after apply")
}
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 1})
mcp := agentstest.ReadJSON(t, filepath.Join(env.Root, ".omp", "mcp.json"))
servers := mcp["mcpServers"].(map[string]any)
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex server missing: %v", servers)
}
// Assert idempotence on re-run.
agentstest.AssertIdempotent(t, a, env)
}
func TestOhMyPiGlobalIsNoOp(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
// Even if .omp directory exists, Detect should return false in ModeGlobal.
if err := os.MkdirAll(filepath.Join(env.Root, ".omp"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
detected, err := a.Detect(env)
if err != nil {
t.Fatalf("detect: %v", err)
}
if detected {
t.Fatal("expected Detect to return false in ModeGlobal")
}
// Plan should be empty.
plan, err := a.Plan(env)
if err != nil {
t.Fatalf("plan: %v", err)
}
if len(plan.Files) > 0 {
t.Fatalf("expected empty plan in ModeGlobal, got: %v", plan.Files)
}
// Apply should return early and not write files.
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if res.Configured {
t.Fatal("expected Configured=false in ModeGlobal")
}
if len(res.Files) > 0 {
t.Fatalf("expected no files touched in ModeGlobal, got: %v", res.Files)
}
}
+107
View File
@@ -0,0 +1,107 @@
// Package openclaw implements the Gortex init integration for
// OpenClaw. OpenClaw's config lives at ~/.openclaw/openclaw.json
// in JSON5 format; MCP servers go under the nested "mcp.servers"
// table:
//
// {
// "mcp": {
// "servers": {
// "gortex": {"command": "gortex", "args": [...], "env": {...}}
// }
// }
// }
//
// We emit plain JSON — OpenClaw's JSON5 parser accepts vanilla
// JSON, and writing strict JSON sidesteps dependency on a JSON5
// encoder. Users can hand-edit the file afterwards and JSON5
// features (comments, trailing commas) won't be stripped because
// we never read back; we always overwrite the gortex entry only.
//
// Docs: https://docs.openclaw.ai/gateway/configuration-reference.md
package openclaw
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "openclaw"
const DocsURL = "https://docs.openclaw.ai/cli/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("openclaw"); err == nil && p != "" {
return true, nil
}
if env.Home == "" {
return false, nil
}
if _, err := os.Stat(filepath.Join(env.Home, ".openclaw")); err == nil {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
if env.Home == "" {
return &agents.Plan{}, nil
}
return &agents.Plan{Files: []agents.FileAction{{
Path: filepath.Join(env.Home, ".openclaw", "openclaw.json"),
Action: agents.ActionWouldMerge,
Keys: []string{"mcp"},
}}}, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip OpenClaw setup (openclaw not detected)")
return res, nil
}
if env.Home == "" {
return res, fmt.Errorf("openclaw: requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up OpenClaw integration...")
path := filepath.Join(env.Home, ".openclaw", "openclaw.json")
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
mcp, ok := root["mcp"].(map[string]any)
if !ok {
mcp = make(map[string]any)
}
servers, ok := mcp["servers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if _, exists := servers["gortex"]; exists && !opts.Force {
return false, nil
}
servers["gortex"] = map[string]any{
"command": "gortex",
"args": []string{"mcp"},
"env": map[string]string{"GORTEX_INDEX_WORKERS": "8"},
}
mcp["servers"] = servers
root["mcp"] = mcp
return true, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
res.Configured = true
return res, nil
}
+155
View File
@@ -0,0 +1,155 @@
// Package opencode implements the Gortex init integration for
// OpenCode. OpenCode reads its project config from `opencode.json` or
// `opencode.jsonc` at the repo root (and `~/.config/opencode/` for the
// global config); it does NOT read `.opencode/config.json` — Gortex
// wrote there historically and OpenCode silently ignored it, so the MCP
// server was never registered. We now write the MCP stanza to a root
// `opencode.json` (or merge into an existing `opencode.json` /
// `opencode.jsonc`).
//
// OpenCode uses a different MCP schema than the canonical
// Claude / Cursor shape:
//
// {
// "$schema": "https://opencode.ai/config.json",
// "mcp": {
// "gortex": {
// "type": "local",
// "command": ["gortex", "mcp", ...],
// "environment": {...},
// "enabled": true
// }
// }
// }
//
// We preserve any existing $schema reference and add one if absent.
package opencode
import (
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "opencode"
const DocsURL = "https://opencode.ai/docs/mcp"
const SchemaURL = "https://opencode.ai/config.json"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if _, err := os.Stat(filepath.Join(env.Root, ".opencode")); err == nil {
return true, nil
}
if p, err := exec.LookPath("opencode"); err == nil && p != "" {
return true, nil
}
if env.Home != "" {
if _, err := os.Stat(filepath.Join(env.Home, ".config", "opencode")); err == nil {
return true, nil
}
}
return false, nil
}
// projectConfigPath resolves the file the MCP stanza should be written
// into. OpenCode reads `opencode.json` / `opencode.jsonc` from the repo
// root, so we merge into an existing one (preferring `.jsonc`, since a
// hand-authored, comment-bearing config keeps its extension) and
// otherwise default to a fresh `opencode.json`.
func projectConfigPath(root string) string {
for _, name := range []string{"opencode.jsonc", "opencode.json"} {
p := filepath.Join(root, name)
if _, err := os.Stat(p); err == nil {
return p
}
}
return filepath.Join(root, "opencode.json")
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{Files: []agents.FileAction{
{Path: projectConfigPath(env.Root), Action: agents.ActionWouldMerge, Keys: []string{"mcp"}},
}}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, "AGENTS.md"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
if env.Mode == agents.ModeGlobal {
return res, nil
}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip OpenCode setup (OpenCode not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up OpenCode integration...")
path := projectConfigPath(env.Root)
// Gortex used to write `.opencode/config.json`, which OpenCode does
// not read. If that stale file is still around, point the user at
// the config we're actually writing now.
if legacy := filepath.Join(env.Root, ".opencode", "config.json"); legacy != path {
if _, err := os.Stat(legacy); err == nil {
internalutil.Logf(env.Stderr, "[gortex init] note: %s is no longer read by OpenCode; writing the MCP config to %s instead", legacy, filepath.Base(path))
}
}
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
mcpSection, ok := root["mcp"].(map[string]any)
if !ok {
mcpSection = make(map[string]any)
}
if _, exists := mcpSection["gortex"]; exists && !opts.Force {
return false, nil
}
mcpSection["gortex"] = map[string]any{
"type": "local",
"command": []string{"gortex", "mcp"},
"environment": map[string]string{
"GORTEX_INDEX_WORKERS": "8",
},
"enabled": true,
}
root["mcp"] = mcpSection
if _, hasSchema := root["$schema"]; !hasSchema {
root["$schema"] = SchemaURL
}
return true, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// AGENTS.md gets a marker-guarded community-routing block when
// skills were generated (--skills, default on in `gortex init`).
// The codex adapter targets the same file with the same markers
// so a repo running both adapters converges on one block.
if env.SkillsRouting != "" {
agentsMdPath := filepath.Join(env.Root, "AGENTS.md")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, agentsMdPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
+152
View File
@@ -0,0 +1,152 @@
package opencode
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestOpenCodeUsesMCPSectionKey verifies we write under "mcp",
// not "mcpServers" — OpenCode's schema differs from the canonical
// Claude / Cursor shape — and that the config lands in a root
// `opencode.json`, the file OpenCode actually reads (not the legacy
// `.opencode/config.json`).
func TestOpenCodeUsesMCPSectionKey(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// `.opencode/` is OpenCode's detection sentinel (it holds agents,
// commands, skills) — present, but the MCP config does not live in it.
if err := os.MkdirAll(filepath.Join(env.Root, ".opencode"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// Two creates: opencode.json for MCP plus AGENTS.md for the
// instructions block OpenCode reads on every task.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
// The MCP config must be the root opencode.json, not the ignored
// .opencode/config.json.
if _, err := os.Stat(filepath.Join(env.Root, ".opencode", "config.json")); err == nil {
t.Fatalf("must not write the legacy .opencode/config.json (OpenCode ignores it)")
}
cfg := agentstest.ReadJSON(t, filepath.Join(env.Root, "opencode.json"))
if _, ok := cfg["mcpServers"]; ok {
t.Fatalf("should not write mcpServers (that's Claude/Cursor shape): %v", cfg)
}
mcp, ok := cfg["mcp"].(map[string]any)
if !ok {
t.Fatalf("missing 'mcp' section: %v", cfg)
}
gortex, ok := mcp["gortex"].(map[string]any)
if !ok {
t.Fatalf("missing 'mcp.gortex': %v", mcp)
}
// command must be an array (OpenCode-specific)
if _, ok := gortex["command"].([]any); !ok {
t.Fatalf("command should be an array: %v", gortex)
}
if cfg["$schema"] != SchemaURL {
t.Fatalf("expected $schema=%q, got %v", SchemaURL, cfg["$schema"])
}
agentstest.AssertIdempotent(t, a, env)
}
// TestOpenCodeMergesIntoExistingJSON verifies that when a project
// already has an opencode.json, we merge into it without clobbering
// the user's own keys, rather than creating a competing file.
func TestOpenCodeMergesIntoExistingJSON(t *testing.T) {
env, _ := agentstest.NewEnv(t)
cfgPath := filepath.Join(env.Root, "opencode.json")
agentstest.WriteJSON(t, cfgPath, map[string]any{
"$schema": SchemaURL,
"theme": "tokyonight",
"mcp": map[string]any{
"other": map[string]any{
"type": "local",
"command": []string{"other-server"},
"enabled": true,
},
},
})
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil {
t.Fatalf("apply: %v", err)
}
cfg := agentstest.ReadJSON(t, cfgPath)
if cfg["theme"] != "tokyonight" {
t.Fatalf("merge clobbered user's top-level key: %v", cfg)
}
mcp, ok := cfg["mcp"].(map[string]any)
if !ok {
t.Fatalf("missing 'mcp' section: %v", cfg)
}
if _, ok := mcp["other"]; !ok {
t.Fatalf("merge clobbered existing 'other' server: %v", mcp)
}
if _, ok := mcp["gortex"]; !ok {
t.Fatalf("merge didn't add 'gortex': %v", mcp)
}
agentstest.AssertIdempotent(t, a, env)
}
// TestOpenCodeMergesIntoExistingJSONC verifies that an existing
// opencode.jsonc — comments and trailing commas included — is the file
// we merge into, the user's data survives, and no competing
// opencode.json is created.
func TestOpenCodeMergesIntoExistingJSONC(t *testing.T) {
env, _ := agentstest.NewEnv(t)
jsoncPath := filepath.Join(env.Root, "opencode.jsonc")
const jsonc = `{
// OpenCode project config
"$schema": "https://opencode.ai/config.json",
"theme": "tokyonight", /* dark theme */
"mcp": {
"other": { "type": "local", "command": ["other-server"], "enabled": true },
},
}`
if err := os.WriteFile(jsoncPath, []byte(jsonc), 0o644); err != nil {
t.Fatal(err)
}
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil {
t.Fatalf("apply: %v", err)
}
// We must have merged into the .jsonc, not created a sibling .json.
if _, err := os.Stat(filepath.Join(env.Root, "opencode.json")); err == nil {
t.Fatalf("must not create opencode.json when opencode.jsonc already exists")
}
// A commented config is valid JSONC, not malformed — no backup.
if _, err := os.Stat(jsoncPath + ".bak"); err == nil {
t.Fatalf("a valid commented .jsonc must not be backed up as malformed")
}
cfg := agentstest.ReadJSON(t, jsoncPath)
if cfg["theme"] != "tokyonight" {
t.Fatalf("merge dropped user's data keys: %v", cfg)
}
mcp, ok := cfg["mcp"].(map[string]any)
if !ok {
t.Fatalf("missing 'mcp' section: %v", cfg)
}
if _, ok := mcp["other"]; !ok {
t.Fatalf("merge clobbered existing 'other' server: %v", mcp)
}
if _, ok := mcp["gortex"]; !ok {
t.Fatalf("merge didn't add 'gortex': %v", mcp)
}
agentstest.AssertIdempotent(t, a, env)
}
+223
View File
@@ -0,0 +1,223 @@
// Package pi implements the Gortex init integration for the Pi coding
// agent (earendil-works/pi, a.k.a. pi-mono).
//
// Pi is unlike every other adapter in this tree for two reasons:
//
// 1. Pi has no MCP support — by design. Instead we ship a TypeScript
// extension that registers Gortex's graph tools natively (each
// shelling `gortex call <tool>`) and re-creates the Claude-Code
// read-discipline enforcement by bridging Pi lifecycle events to
// `gortex hook --agent=pi`.
//
// 2. It is therefore has to ship executable code (the embedded
// `extension/index.ts`) and use `go:embed` in the agents tree.
// The extension carries four templated sentinels the adapter
// fills in at write time — see renderExtension.
//
// Read-discipline rules are injected by the extension at runtime, not
// written to an instructions file. AGENTS.md is touched only for the
// community-routing block, and only with --skills.
//
// File layout written by the adapter:
//
// ModeProject: <root>/.pi/extensions/gortex/index.ts (+ AGENTS.md routing block when --skills)
// ModeGlobal: <home>/.pi/agent/extensions/gortex/index.ts
package pi
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
_ "embed"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "pi"
const DocsURL = "https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md"
//go:embed extension/index.ts
var extensionSource string
// Templated sentinels in extension/index.ts. Each is replaced with a JSON
// literal so values containing spaces or backslashes survive intact.
const (
sentinelBin = "{{GORTEX_BIN}}"
sentinelArgv = "{{GORTEX_HOOK_ARGV}}"
sentinelEnforce = "{{GORTEX_ENFORCE}}"
sentinelToolsPreset = "{{GORTEX_TOOLS_PRESET}}"
)
// defaultToolsPreset is the eager tool preset baked into the extension. It
// mirrors the daemon's own default surface (corePresetTools, in defer
// mode); GORTEX_TOOLS in the environment overrides it at runtime, the same
// override the daemon honours. Kept a constant (not read from the
// environment at render time) so the rendered extension is deterministic.
const defaultToolsPreset = "core"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// Detect reports whether Pi is in use: a project-local `.pi/` dir, a
// user-level `~/.pi/`, or the `pi` CLI on PATH.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if env.Mode == agents.ModeProject && env.Root != "" {
if _, err := os.Stat(filepath.Join(env.Root, ".pi")); err == nil {
return true, nil
}
}
if env.Home != "" {
if _, err := os.Stat(filepath.Join(env.Home, ".pi")); err == nil {
return true, nil
}
}
for _, bin := range []string{"pi", "pi-coding-agent"} {
if p, err := exec.LookPath(bin); err == nil && p != "" {
return true, nil
}
}
return false, nil
}
// extensionPath returns where the extension file lands for the given mode.
func extensionPath(env agents.Env) string {
if env.Mode == agents.ModeGlobal {
return filepath.Join(env.Home, ".pi", "agent", "extensions", "gortex", "index.ts")
}
return filepath.Join(env.Root, ".pi", "extensions", "gortex", "index.ts")
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
p.Files = append(p.Files, agents.FileAction{
Path: extensionPath(env),
Action: agents.ActionWouldCreate,
})
// AGENTS.md gets the community-routing block, and only with skills
// (project mode). Read-discipline rules ride the extension, not this file.
if env.Mode != agents.ModeGlobal && env.Root != "" && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, "AGENTS.md"),
Action: agents.ActionWouldMerge, Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Pi setup (pi not detected)")
return res, nil
}
if env.Mode == agents.ModeGlobal && env.Home == "" {
return res, fmt.Errorf("pi: global mode requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Pi integration...")
// 1. The extension (Gortex owns it end-to-end → overwrite on re-run).
extAction, err := agents.WriteOwnedFile(env.Stderr, extensionPath(env), renderExtension(env), opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, extAction)
// 2. Community routing → AGENTS.md (project mode, skills enabled only).
// Read-discipline rules ride the extension, so AGENTS.md is left alone
// unless there's a routing block to merge.
if env.Mode != agents.ModeGlobal && env.Root != "" && env.SkillsRouting != "" {
agentsMd := filepath.Join(env.Root, "AGENTS.md")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, agentsMd, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
// renderExtension fills the embedded TypeScript template with the resolved
// gortex binary path, the hook argv, and the enforcement flag.
func renderExtension(env agents.Env) string {
bin := resolveGortexBin(env)
argv := []string{bin, "hook", "--agent=pi"}
if mode := normalizeMode(env.HookMode); mode != "" && mode != "deny" {
argv = append(argv, "--mode="+mode)
}
src := extensionSource
src = substituteSentinel(src, sentinelBin, jsonString(bin))
src = substituteSentinel(src, sentinelArgv, jsonValue(argv))
src = substituteSentinel(src, sentinelEnforce, jsonValue(env.InstallHooks))
src = substituteSentinel(src, sentinelToolsPreset, jsonString(defaultToolsPreset))
return src
}
// resolveGortexBin prefers an explicit HookCommand binary, then a `gortex`
// on PATH, then the bare name. We only need the binary path here — the
// hook subcommand/args are appended by renderExtension.
func resolveGortexBin(env agents.Env) string {
if env.HookCommand != "" {
if fields := strings.Fields(env.HookCommand); len(fields) > 0 {
return fields[0]
}
}
if p, err := exec.LookPath("gortex"); err == nil && p != "" {
return p
}
return "gortex"
}
// normalizeMode mirrors the hook posture strings the daemon accepts; any
// unknown value (including empty) collapses to the deny default.
func normalizeMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "enrich":
return "enrich"
case "consult-unlock":
return "consult-unlock"
case "nudge", "adaptive-nudge":
return "nudge"
default:
return "deny"
}
}
// substituteSentinel replaces a {{NAME}} placeholder with value, tolerant
// of inner whitespace. The embedded extension is plain TypeScript and may
// be run through a formatter (Prettier reflows `{{NAME}}` to `{{ NAME }}`),
// so an exact-string match would silently miss a formatted sentinel and
// ship an un-substituted template. The replacement is literal so a value
// containing `$` is not treated as a regexp expansion.
func substituteSentinel(src, sentinel, value string) string {
name := strings.TrimSuffix(strings.TrimPrefix(sentinel, "{{"), "}}")
re := regexp.MustCompile(`\{\{\s*` + regexp.QuoteMeta(name) + `\s*\}\}`)
return re.ReplaceAllLiteralString(src, value)
}
// jsonString / jsonValue emit JSON literals for templating into the TS
// source. jsonString is for a bare string; jsonValue for any value.
func jsonString(s string) string { return jsonValue(s) }
func jsonValue(v any) string {
b, err := json.Marshal(v)
if err != nil {
return "null"
}
return string(b)
}
+345
View File
@@ -0,0 +1,345 @@
package pi
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// readExtension returns the rendered extension file content for the env.
func writeAndRead(t *testing.T, env agents.Env) string {
t.Helper()
a := New()
if _, err := a.Apply(env, agents.ApplyOpts{ForceDetect: true}); err != nil {
t.Fatalf("apply: %v", err)
}
data, err := os.ReadFile(extensionPath(env))
if err != nil {
t.Fatalf("read extension: %v", err)
}
return string(data)
}
func TestPiDetect(t *testing.T) {
t.Run("project .pi dir", func(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// NewEnv's Home is a fresh temp dir with no .pi, so detection
// hinges on the project marker (PATH may or may not have pi).
if err := os.MkdirAll(filepath.Join(env.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
ok, err := New().Detect(env)
if err != nil || !ok {
t.Fatalf("expected detect=true with .pi/, got %v (err %v)", ok, err)
}
})
t.Run("home .pi dir", func(t *testing.T) {
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env.Home, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
ok, err := New().Detect(env)
if err != nil || !ok {
t.Fatalf("expected detect=true with ~/.pi/, got %v (err %v)", ok, err)
}
})
}
func TestPiApplyWritesExtensionAndRouting(t *testing.T) {
env, _ := agentstest.NewEnv(t) // NewEnv seeds SkillsRouting → routing block written.
if err := os.MkdirAll(filepath.Join(env.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Detected || !res.Configured {
t.Fatalf("expected detected+configured, got %+v", res)
}
// Extension written.
ext := filepath.Join(env.Root, ".pi", "extensions", "gortex", "index.ts")
data, err := os.ReadFile(ext)
if err != nil {
t.Fatalf("extension not written: %v", err)
}
src := string(data)
// Sentinels must be fully substituted — no template left behind.
for _, sentinel := range []string{sentinelBin, sentinelArgv, sentinelEnforce, sentinelToolsPreset} {
if strings.Contains(src, sentinel) {
t.Errorf("unsubstituted sentinel %q remains in extension", sentinel)
}
}
// NewEnv sets InstallHooks=true → ENFORCE true.
if !strings.Contains(src, "const ENFORCE: boolean = true;") {
t.Errorf("expected ENFORCE true with InstallHooks=true")
}
// argv must carry the pi agent flag.
if !strings.Contains(src, `"--agent=pi"`) {
t.Errorf("expected --agent=pi in hook argv; got source without it")
}
// AGENTS.md carries the community-routing block — but NOT the
// read-discipline rules: those are injected by the extension at runtime,
// not persisted to an instructions file (mirrors opencode).
agentsMd, err := os.ReadFile(filepath.Join(env.Root, "AGENTS.md"))
if err != nil {
t.Fatalf("AGENTS.md not written: %v", err)
}
if !strings.Contains(string(agentsMd), agents.CommunitiesStartMarker) {
t.Errorf("AGENTS.md missing communities block")
}
if strings.Contains(string(agentsMd), agents.InstructionsSentinel) {
t.Errorf("AGENTS.md must NOT carry the read-discipline rules block — the extension injects them at runtime")
}
// Idempotent re-run.
agentstest.AssertIdempotent(t, a, env)
}
// Without skills routing, the adapter writes the extension only and never
// touches AGENTS.md — the read-discipline rules ride the extension's
// `context` hook, so there's nothing to persist to an instructions file.
func TestPiApplyNoSkillsLeavesAgentsMdUntouched(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.SkillsRouting = ""
if err := os.MkdirAll(filepath.Join(env.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
if _, err := New().Apply(env, agents.ApplyOpts{}); err != nil {
t.Fatalf("apply: %v", err)
}
if _, err := os.Stat(filepath.Join(env.Root, "AGENTS.md")); err == nil {
t.Errorf("AGENTS.md should not be written when skills routing is empty")
}
}
func TestPiNoHooksDisablesEnforcement(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.InstallHooks = false
if err := os.MkdirAll(filepath.Join(env.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
src := writeAndRead(t, env)
if !strings.Contains(src, "const ENFORCE: boolean = false;") {
t.Errorf("expected ENFORCE false with InstallHooks=false")
}
// Tools are still registered regardless of enforcement.
if !strings.Contains(src, "registerGortexTools") {
t.Errorf("tool registration should remain present with --no-hooks")
}
}
func TestPiEnrichModeAppendsModeFlag(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.HookMode = "enrich"
if err := os.MkdirAll(filepath.Join(env.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
var sawEnrich bool
for _, arg := range parseArgv(t, writeAndRead(t, env)) {
if arg == "--mode=enrich" {
sawEnrich = true
}
}
if !sawEnrich {
t.Errorf("expected --mode=enrich in hook argv for enrich posture")
}
// Default (deny) posture must NOT append a --mode flag to the argv.
// (Check the parsed argv, not the raw source — the template's doc
// comment legitimately mentions "--mode=<mode>".)
env2, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env2.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
for _, arg := range parseArgv(t, writeAndRead(t, env2)) {
if strings.HasPrefix(arg, "--mode=") {
t.Errorf("deny posture should not append a --mode flag, got argv arg %q", arg)
}
}
}
func TestPiGlobalMode(t *testing.T) {
env, _ := agentstest.NewEnv(t)
env.Mode = agents.ModeGlobal
if err := os.MkdirAll(filepath.Join(env.Home, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected configured in global mode")
}
// Global extension lands under ~/.pi/agent/extensions; no AGENTS.md.
if _, err := os.ReadFile(filepath.Join(env.Home, ".pi", "agent", "extensions", "gortex", "index.ts")); err != nil {
t.Fatalf("global extension not written: %v", err)
}
if _, err := os.Stat(filepath.Join(env.Root, "AGENTS.md")); err == nil {
t.Errorf("global mode should not write repo AGENTS.md")
}
}
func TestPiApplyDryRun(t *testing.T) {
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env.Root, ".pi"), 0o755); err != nil {
t.Fatal(err)
}
res, err := New().Apply(env, agents.ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("apply dry-run: %v", err)
}
// Nothing written to disk under dry-run.
if _, err := os.Stat(extensionPath(env)); err == nil {
t.Errorf("dry-run must not write the extension file")
}
if len(res.Files) == 0 {
t.Errorf("dry-run should still report planned actions")
}
}
// parseArgv extracts and JSON-decodes the HOOK_ARGV literal templated into
// the rendered extension. It doubles as a guard that the argv sentinel
// round-trips into a valid string array (so the TS `HOOK_ARGV[0]` access
// is sound).
func parseArgv(t *testing.T, src string) []string {
t.Helper()
const marker = "const HOOK_ARGV: string[] = "
i := strings.Index(src, marker)
if i < 0 {
t.Fatal("HOOK_ARGV line not found")
}
rest := src[i+len(marker):]
end := strings.Index(rest, ";")
if end < 0 {
t.Fatal("HOOK_ARGV line not terminated")
}
var argv []string
if err := json.Unmarshal([]byte(rest[:end]), &argv); err != nil {
t.Fatalf("HOOK_ARGV is not valid JSON array: %v", err)
}
return argv
}
func TestPiRendersSearchToolAndPreset(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Render-time default must not depend on the caller's environment.
t.Setenv("GORTEX_TOOLS", "")
src := renderExtension(env)
// B: the on-demand discovery meta-tool that mirrors tools_search.
if !strings.Contains(src, "registerSearchTool(pi)") {
t.Error("expected registerSearchTool to be wired into the entry point")
}
if !strings.Contains(src, `"tools", "search", query`) {
t.Error("expected the meta-tool to shell `gortex tools search`")
}
// Every Gortex tool (incl. the meta-tool) is namespaced under gortex_ so
// it can't silently clobber a user's own tool of the same name.
if !strings.Contains(src, `const TOOL_PREFIX = "gortex_"`) {
t.Error("expected a gortex_ tool-name prefix")
}
if !strings.Contains(src, `piToolName("tools_search")`) {
t.Error("expected the search meta-tool name to be derived via the gortex_ prefix")
}
// C: the eager preset is configurable; the baked default is core and
// the runtime honours GORTEX_TOOLS.
if !strings.Contains(src, `const TOOLS_PRESET: string = ((process.env && process.env.GORTEX_TOOLS) || "core").trim();`) {
t.Error("expected TOOLS_PRESET to default to \"core\" and honour GORTEX_TOOLS at runtime")
}
if strings.Contains(src, `"--preset", "core", "--format"`) {
t.Error("discovery must no longer hardcode --preset core; it should route through presetListArgs()")
}
}
// TestPiRegistersToolsPerSession guards the /new regression: Pi resets the
// session tool registry on every session_start, so tools must be
// (re)registered there — registering only at factory load loses them after
// the first session. The persistent name guard must be cleared first or
// re-registration is suppressed into the new (empty) registry.
func TestPiRegistersToolsPerSession(t *testing.T) {
env, _ := agentstest.NewEnv(t)
src := renderExtension(env)
// Registration happens inside a session_start handler.
sIdx := strings.Index(src, `pi.on("session_start"`)
if sIdx < 0 {
t.Fatal("expected a session_start handler")
}
// Bound the search to the handler body so we assert these calls live
// *inside* session_start, not merely somewhere in the file.
body := src[sIdx:]
if end := strings.Index(body, "pi.on(\"before_agent_start\""); end > 0 {
body = body[:end]
}
for _, want := range []string{"gortexToolNames.clear()", "registerGortexTools(pi)", "registerSearchTool(pi)", "ensureDaemon()"} {
if !strings.Contains(body, want) {
t.Errorf("expected %q inside the session_start handler (per-session re-registration)", want)
}
}
}
// TestPiInjectsOrientationViaContextHook guards the cache-safe injection
// contract: the orientation rides a `context`-hook tail user message, never
// a systemPrompt mutation (which would bust prefix prompt caching).
func TestPiInjectsOrientationViaContextHook(t *testing.T) {
env, _ := agentstest.NewEnv(t)
src := renderExtension(env)
// A `context` hook must exist and push a user message.
if !strings.Contains(src, `pi.on("context"`) {
t.Error("expected a context hook to inject the orientation")
}
if !strings.Contains(src, `event.messages.push`) {
t.Error("expected the context hook to append a message to event.messages")
}
// The decision field is `orientation`, matching the Go PiDecision.
if !strings.Contains(src, "decision.orientation") {
t.Error("expected the extension to read decision.orientation")
}
// before_agent_start must NOT fold the orientation into systemPrompt —
// that path is what we removed for cache safety.
if strings.Contains(src, "systemPrompt: (event") {
t.Error("orientation must not be appended to systemPrompt (breaks prompt caching)")
}
// Orientation injection is unconditional; only tool_call enforcement is
// gated by ENFORCE. Assert the `if (!ENFORCE) return;` guard sits AFTER
// the before_agent_start + context registrations and BEFORE tool_call —
// so --no-hooks still teaches the model how to use Gortex.
guard := strings.Index(src, "if (!ENFORCE) return;")
beforeAgent := strings.Index(src, `pi.on("before_agent_start"`)
context := strings.Index(src, `pi.on("context"`)
toolCall := strings.Index(src, `pi.on("tool_call"`)
if guard < 0 || beforeAgent < 0 || context < 0 || toolCall < 0 {
t.Fatalf("missing a required handler/guard (guard=%d before_agent_start=%d context=%d tool_call=%d)",
guard, beforeAgent, context, toolCall)
}
if beforeAgent >= guard || context >= guard || guard >= toolCall {
t.Errorf("ENFORCE guard misplaced: orientation hooks must precede it and tool_call must follow it "+
"(before_agent_start=%d context=%d guard=%d tool_call=%d)", beforeAgent, context, guard, toolCall)
}
}
func TestRenderedExtensionParsesArgv(t *testing.T) {
env, _ := agentstest.NewEnv(t)
argv := parseArgv(t, renderExtension(env))
if len(argv) < 3 || argv[1] != "hook" || argv[2] != "--agent=pi" {
t.Errorf("unexpected argv: %v", argv)
}
}
+485
View File
@@ -0,0 +1,485 @@
// Gortex extension for the Pi coding agent (earendil-works/pi).
//
// Pi has no MCP support by design — the extension API's registerTool
// covers it. This extension does two things:
//
// 1. Exposes Gortex's graph tools as native Pi tools. On every
// session_start the daemon is brought up (ensureDaemon) and the tools
// are (re)registered — Pi has no MCP, so the extension does what an MCP
// client's `gortex mcp` proxy does for it, and Pi resets the session's
// tool registry on each /new, so registration must happen per session.
// Each registered tool then shells `gortex call <name>`, which
// relays to the daemon. This mirrors the daemon's own core/defer tool
// surface: the configured eager preset (GORTEX_TOOLS, default `core`)
// is discovered from `gortex tools list` and registered up front, and
// the deferred remainder of the full (~175-tool) catalogue is reached
// on demand via the `gortex_tools_search` meta-tool, which runs the
// BM25 catalogue search and dynamically registers the matches — the
// analogue of MCP's tools_search + server-side promotion. If the
// daemon is unreachable at session_start no graph tools are
// registered; discovery re-runs on the next session.
//
// 2. Re-creates the Claude-Code "prefer graph tools over raw file
// reads" enforcement. Rather than re-implementing the deny logic in
// TypeScript, every relevant lifecycle event is marshalled into a
// normalized envelope and handed to `gortex hook --agent=pi`, whose
// decision is applied here. The Go side owns the deny / enrich /
// consult-unlock / nudge postures, identical to every other agent.
//
// This file is written verbatim by the `pi` agent adapter, except for four
// templated sentinels the adapter replaces at install time:
//
// {{GORTEX_BIN}} -> a JSON string: the resolved `gortex` binary path
// {{GORTEX_HOOK_ARGV}} -> a JSON array: the full hook argv, e.g.
// ["/usr/local/bin/gortex","hook","--agent=pi"]
// (with a trailing "--mode=<mode>" when non-deny)
// {{GORTEX_ENFORCE}} -> a JSON boolean: whether to wire the read-discipline
// enforcement (false when installed with --no-hooks;
// the graph tools + orientation are still wired)
// {{GORTEX_TOOLS_PRESET}} -> a JSON string: the default eager tool preset
// (core|edit|nav|readonly|full); GORTEX_TOOLS in the
// environment overrides it at runtime.
//
// Each is emitted as a JSON literal so values containing spaces survive.
//
// NOTE: Pi is pre-1.0. The ExtensionAPI surface, event names, and the Pi
// built-in tool names mapped in normalizeToolCall() below are pinned to
// Pi's documented API (packages/coding-agent/docs/extensions.md). If a Pi
// upgrade renames an event or a built-in tool, adjust the small maps here
// — the Go side (the wire contract) stays unchanged.
// Zero runtime dependencies: only Node built-ins. The tool `parameters`
// below are plain JSON Schema objects rather than TypeBox schemas — Pi's
// tool layer accepts a plain JSON-schema object. This is deliberate:
// a single-file auto-discovered extension has no package.json / node_modules,
// so importing `typebox` would throw at load and take the whole extension
// (tools AND enforcement) down with it.
import { execFileSync, spawn } from "node:child_process";
const GORTEX_BIN: string = {{ GORTEX_BIN }};
const HOOK_ARGV: string[] = {{ GORTEX_HOOK_ARGV }};
const ENFORCE: boolean = {{ GORTEX_ENFORCE }};
// Which eager preset to expose as native Pi tools, mirroring the Gortex
// daemon's own tool surface. The daemon defaults to the `core` preset in
// `defer` mode and lets GORTEX_TOOLS override it — so we honour the same
// env at runtime, falling back to the value baked at install time.
// Recognised: core | edit | nav | readonly | full (full = the whole
// catalogue eagerly). The deferred remainder is reachable via
// `gortex_tools_search` below.
const TOOLS_PRESET: string = ((process.env && process.env.GORTEX_TOOLS) || {{ GORTEX_TOOLS_PRESET }}).trim();
// Names (all gortex_-prefixed) of the Gortex tools registered into Pi.
const gortexToolNames = new Set<string>();
// Every Gortex tool is registered under a `gortex_` prefix so it can never
// silently clobber a Pi built-in or another extension's tool of the same
// name.
// The execute() closure still calls the daemon with the bare name.
const TOOL_PREFIX = "gortex_";
function piToolName(bare: string): string {
return bare.startsWith(TOOL_PREFIX) ? bare : TOOL_PREFIX + bare;
}
// ---------------------------------------------------------------------------
// Subprocess helpers
// ---------------------------------------------------------------------------
function runGortex(args: string[], input?: string): string {
return execFileSync(GORTEX_BIN, args, {
input,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
timeout: 30_000,
}).trim();
}
// ensureDaemon brings the shared Gortex daemon up, the way an MCP host's
// `gortex mcp` does for other agents.
// Idempotent, fire-and-forget (we don't block on the launcher's ≤60s
// readiness poll), and never throws — a missing binary or spawn failure
// must not take the extension down. The first tool calls either find it
// ready or degrade gracefully.
function ensureDaemon(): void {
try {
const child = spawn(GORTEX_BIN, ["daemon", "start", "--detach"], {
stdio: "ignore",
detached: true,
});
child.on("error", () => { }); // binary missing / spawn failure — swallow
// No teardown counterpart, by design: the daemon is shared, long-lived
// infrastructure that outlives the session — like `gortex mcp`.
child.unref();
} catch {
}
}
interface PiDecision {
block?: boolean;
reason?: string;
additional_context?: string;
orientation?: string;
}
// callHook sends a normalized event envelope to `gortex hook --agent=pi`
// and parses the PiDecision it writes back. Fail-open: any error returns
// an empty decision so the extension never blocks Pi's flow on a hook
// hiccup (daemon down, parse error, timeout).
function callHook(envelope: Record<string, unknown>): PiDecision {
try {
const out = execFileSync(HOOK_ARGV[0], HOOK_ARGV.slice(1), {
input: JSON.stringify(envelope),
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
timeout: 5_000,
}).trim();
if (!out) return {};
return JSON.parse(out) as PiDecision;
} catch {
return {};
}
}
// ---------------------------------------------------------------------------
// Tool-call normalization (Pi vocabulary -> canonical Claude-Code vocabulary)
// ---------------------------------------------------------------------------
//
// The Go enrich() classifier switches on Claude-Code tool names
// ("Read"/"Grep"/"Glob"/"Bash"/"Edit"/"Write") and reads canonical input
// keys ("file_path"/"pattern"/"command"). Pi uses its own lowercase names
// and input shapes, so we translate here — keeping all Pi-specific
// knowledge in this Pi-specific file.
const toolNameMap: Record<string, string> = {
read: "Read",
grep: "Grep",
find: "Glob",
ls: "Glob",
bash: "Bash",
edit: "Edit",
write: "Write",
};
function firstString(obj: Record<string, unknown>, keys: string[]): string | undefined {
for (const k of keys) {
const v = obj[k];
if (typeof v === "string" && v !== "") return v;
}
return undefined;
}
function normalizeToolCall(
piName: string,
piInput: Record<string, unknown>,
): { tool_name: string; tool_input: Record<string, unknown> } {
const canonical = toolNameMap[piName.toLowerCase()] ?? piName;
const out: Record<string, unknown> = { ...piInput };
const path = firstString(piInput, ["file_path", "path", "file", "filepath", "absolute_path"]);
if (path !== undefined) out.file_path = path;
let pattern = firstString(piInput, ["pattern", "glob", "query", "regex", "name"]);
if (pattern === undefined && canonical === "Glob") pattern = path;
if (pattern !== undefined) out.pattern = pattern;
const command = firstString(piInput, ["command", "cmd", "script"]);
if (command !== undefined) out.command = command;
return { tool_name: canonical, tool_input: out };
}
// ---------------------------------------------------------------------------
// Dynamic tool registration
// ---------------------------------------------------------------------------
interface ToolDescriptor {
name: string;
summary?: string;
description?: string;
}
// presetListArgs maps the configured preset to `gortex tools list` flags.
// `full`/`all`/empty list the whole catalogue (no --preset filter); any
// other value is passed straight through so a preset added upstream works
// with no change here — the CLI is the source of truth for valid names. An
// unrecognised name simply matches nothing, and discoverTools falls back to
// the static set; the full catalogue stays reachable via search regardless.
function presetListArgs(): string[] {
const base = ["tools", "list", "--format", "json"];
const p = TOOLS_PRESET.toLowerCase();
if (p === "" || p === "full" || p === "all") return base;
return [...base, "--preset", p];
}
// discoverTools asks the daemon for the eager tool roster. Returns [] when
// the daemon is unreachable.
// TODO: poll for the daemon coming online mid-session and register then.
function discoverTools(): ToolDescriptor[] {
try {
const raw = runGortex(presetListArgs());
const parsed = JSON.parse(raw) as ToolDescriptor[];
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
} catch {
// daemon down / not tracking — no tools registered this session.
}
return [];
}
// safeRegister registers a Pi tool, tolerating a redundant registration —
// a config reload can re-fire session_start without resetting the session's
// tool registry, in which case the name is already live and Pi may throw.
function safeRegister(pi: any, def: any): void {
try {
pi.registerTool(def);
} catch {
// already live this session — the existing registration stands.
}
}
// registerOneTool registers a single Gortex tool as a native Pi tool named
// `gortex_<bare>`, whose execute() shells `gortex call <bare>`. Idempotent —
// a name already registered is skipped. Adds the prefixed name to
// gortexToolNames so the read-discipline postures treat a call to it as a
// graph query, not a raw file read. Used both for the eager preset and for
// tools promoted later by gortex_tools_search.
function registerOneTool(pi: any, desc: ToolDescriptor): void {
const bare = desc.name;
if (!bare) return;
const name = piToolName(bare);
if (gortexToolNames.has(name)) return;
gortexToolNames.add(name);
safeRegister(pi, {
name,
label: name,
description: (desc.description || desc.summary || name).trim(),
// Pass-through arguments: the model supplies whatever the underlying
// Gortex tool accepts; `gortex call` validates names + args server
// side and suggests near-matches on a typo.
parameters: { type: "object", additionalProperties: true, properties: {} },
async execute(_id: string, params: Record<string, unknown>) {
try {
const out = runGortex([
"call",
bare,
"--json",
JSON.stringify(params ?? {}),
"--format",
"gcx",
]);
return { content: [{ type: "text", text: out }], details: {} };
} catch (err: any) {
const msg = err?.stderr?.toString?.() || err?.message || String(err);
throw new Error(`gortex call ${bare} failed: ${msg}`);
}
},
});
}
function registerGortexTools(pi: any): void {
for (const desc of discoverTools()) registerOneTool(pi, desc);
}
// ---------------------------------------------------------------------------
// On-demand tool discovery (mirrors MCP tools_search + promotion)
// ---------------------------------------------------------------------------
//
// MCP clients on the default core/defer surface see only the eager preset;
// the rest of the catalogue is deferred behind the tools_search meta-tool,
// which returns a match's schema and PROMOTES it into the live server
// (firing notifications/tools/list_changed) so it becomes callable. Pi has
// no server-side promotion, so we replicate it: a single
// `gortex_tools_search` meta-tool runs the BM25 catalogue search and
// dynamically registers each match as a native Pi tool — Pi supports
// registering tools after session init.
const SEARCH_TOOL_NAME = piToolName("tools_search");
// firstSentence returns the leading sentence of a description, up to and
// including the first ". " boundary.
function firstSentence(desc: string): string {
const t = desc.trim();
if (!t) return "";
const i = t.indexOf(". ");
return i >= 0 ? t.slice(0, i + 1).trim() : t;
}
// parseToolSearchJSON parses `gortex tools search --format json` output —
// an array of {name, description}.
function parseToolSearchJSON(raw: string): ToolDescriptor[] {
let parsed: Array<{ name: string; description?: string }>;
try {
parsed = JSON.parse(raw);
} catch {
return [];
}
if (!Array.isArray(parsed)) return [];
return parsed
.filter((e) => e && typeof e.name === "string" && e.name)
.map((e) => ({
name: e.name,
description: e.description ?? "",
summary: firstSentence(e.description ?? ""),
}));
}
function searchTools(query: string, limit: number): ToolDescriptor[] {
return parseToolSearchJSON(
runGortex(["tools", "search", query, "--limit", String(limit), "--format", "json"]),
);
}
function registerSearchTool(pi: any): void {
if (gortexToolNames.has(SEARCH_TOOL_NAME)) return;
gortexToolNames.add(SEARCH_TOOL_NAME);
safeRegister(pi, {
name: SEARCH_TOOL_NAME,
label: SEARCH_TOOL_NAME,
description:
"Search the full Gortex tool catalogue and register the matches as callable tools. " +
"Use this when the eagerly-loaded set doesn't cover what you need — e.g. taint_paths, " +
"flow_between, contracts, find_clones, pr_risk, overlay/simulation tools. Returns the " +
"names + summaries now available; call them directly on the next turn. (Gortex graph tool.)",
parameters: {
type: "object",
additionalProperties: false,
properties: {
query: {
type: "string",
description: "What you want to do, e.g. 'trace taint to a sink' or 'detect duplicate code'.",
},
limit: {
type: "number",
description: "Maximum number of tools to register (default 5).",
},
},
required: ["query"],
},
async execute(_id: string, params: Record<string, unknown>) {
const query = typeof params?.query === "string" ? params.query.trim() : "";
if (!query) {
return {
content: [{ type: "text", text: "Provide a 'query' describing the tool you need." }],
details: {},
};
}
const rawLimit = typeof params?.limit === "number" ? Math.floor(params.limit) : 5;
const limit = rawLimit > 0 ? rawLimit : 5;
let matches: ToolDescriptor[];
try {
matches = searchTools(query, limit);
} catch (err: any) {
const msg = err?.stderr?.toString?.() || err?.message || String(err);
throw new Error(`gortex tools search failed: ${msg}`);
}
const newlyAvailable: string[] = [];
for (const desc of matches) {
const name = desc.name ? piToolName(desc.name) : "";
if (!name || name === SEARCH_TOOL_NAME) continue;
if (!gortexToolNames.has(name)) newlyAvailable.push(name);
registerOneTool(pi, desc); // idempotent
}
// Report the prefixed names — that's what the model calls them by.
const lines = matches
.filter((d) => d.name && piToolName(d.name) !== SEARCH_TOOL_NAME)
.map((d) => `- ${piToolName(d.name)}${d.summary ? `${d.summary}` : ""}`);
const header =
matches.length === 0
? `No tools matched "${query}".`
: newlyAvailable.length > 0
? `Registered ${newlyAvailable.length} tool(s) — now callable: ${newlyAvailable.join(", ")}.`
: "Matching tools are already registered and callable.";
return { content: [{ type: "text", text: [header, ...lines].join("\n") }], details: {} };
},
});
}
// ---------------------------------------------------------------------------
// Extension entry point
// ---------------------------------------------------------------------------
export default function (pi: any) {
let orientationInjected = false;
// Orientation awaiting injection into the next LLM call. The `context`
// hook appends it as a tail user message rather than mutating
// systemPrompt: a systemPrompt change sits at messages[0] and invalidates
// prefix prompt caching. Computed once per session.
let pendingOrientation = "";
// Pi resets the session's tool registry on every session_start, so
// (re)register here. Clear the name guard first — it persists across
// sessions and would otherwise suppress re-registration.
pi.on("session_start", () => {
orientationInjected = false;
pendingOrientation = "";
ensureDaemon();
gortexToolNames.clear();
registerGortexTools(pi);
registerSearchTool(pi);
});
// Fires before the agent loop's first LLM call. It can't mutate messages
// itself, so it just parks the orientation for the `context` hook.
pi.on("before_agent_start", () => {
if (orientationInjected) return;
const decision = callHook({ event: "session_start", cwd: pi?.cwd ?? process.cwd() });
if (decision.orientation) {
pendingOrientation = decision.orientation;
orientationInjected = true;
}
return;
});
// Fires before each LLM call with a mutable message array. Append the
// parked orientation once, then clear it so it isn't repeated each call.
pi.on("context", (event: any) => {
if (!pendingOrientation) return;
const text = pendingOrientation;
pendingOrientation = "";
try {
if (event && Array.isArray(event.messages)) {
event.messages.push({ role: "user", content: text });
return { messages: event.messages };
}
} catch {
// best effort — never break context assembly.
}
return;
});
if (!ENFORCE) return;
// Enforcement: every non-Gortex tool call is checked against the Go hook.
pi.on("tool_call", async (event: any, ctx: any) => {
const piName: string = event?.toolName ?? "";
const piInput: Record<string, unknown> = event?.input ?? {};
const isGortexTool = gortexToolNames.has(piName);
const norm = normalizeToolCall(piName, piInput);
const decision = callHook({
event: "tool_call",
tool_name: norm.tool_name,
tool_input: norm.tool_input,
cwd: ctx?.cwd ?? pi?.cwd ?? process.cwd(),
session_id: ctx?.sessionManager?.sessionId ?? "",
is_gortex_tool: isGortexTool,
});
if (decision.block) {
return { block: true, reason: decision.reason ?? "[Gortex] blocked — prefer graph tools." };
}
if (decision.additional_context) {
// Soft guidance: surface it without blocking the call.
try {
pi.sendMessage(
{ customType: "gortex", content: decision.additional_context, display: true },
{ deliverAs: "steer" },
);
} catch {
// sendMessage shape can vary across Pi versions; never fatal.
}
}
return;
});
}
+44
View File
@@ -0,0 +1,44 @@
package agents
import (
"encoding/json"
"fmt"
"io"
"strings"
)
// PrintConfig writes a dry-run view of the configuration a single named adapter
// would install — the files it plans to touch, their action, and the keys it
// sets — as indented JSON, without writing anything to disk. It is the
// machine-readable companion to `init doctor`, scoped to one agent. Returns an
// error naming the registered agents when `name` matches none.
func PrintConfig(w io.Writer, reg *Registry, name string, env Env) error {
var found Adapter
var available []string
for _, a := range reg.All() {
available = append(available, a.Name())
if a.Name() == name {
found = a
}
}
if found == nil {
return fmt.Errorf("unknown agent %q; available: %s", name, strings.Join(available, ", "))
}
plan, err := found.Plan(env)
if err != nil {
return fmt.Errorf("plan %s: %w", found.Name(), err)
}
files := []FileAction{}
if plan != nil && plan.Files != nil {
files = plan.Files
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(map[string]any{
"agent": found.Name(),
"docs_url": found.DocsURL(),
"files": files,
})
}
+61
View File
@@ -0,0 +1,61 @@
package agents
import (
"bytes"
"encoding/json"
"testing"
)
// planStubAdapter is a stub adapter whose Plan returns a known file set so the
// PrintConfig output can be asserted.
type planStubAdapter struct{ name string }
func (p planStubAdapter) Name() string { return p.name }
func (p planStubAdapter) DocsURL() string { return "https://example.test/" + p.name }
func (p planStubAdapter) Detect(Env) (bool, error) {
return true, nil
}
func (p planStubAdapter) Plan(Env) (*Plan, error) {
return &Plan{Files: []FileAction{
{Path: ".cursor/mcp.json", Action: ActionMerge, Keys: []string{"mcpServers.gortex"}},
}}, nil
}
func (p planStubAdapter) Apply(Env, ApplyOpts) (*Result, error) {
return &Result{Name: p.name}, nil
}
// TestPrintConfig proves the --print-config core: a known agent's planned files
// are rendered as JSON, and an unknown agent errors with the available list.
func TestPrintConfig(t *testing.T) {
reg := NewRegistry()
reg.Register(planStubAdapter{name: "cursor"})
var buf bytes.Buffer
if err := PrintConfig(&buf, reg, "cursor", Env{}); err != nil {
t.Fatalf("PrintConfig(cursor): %v", err)
}
var out struct {
Agent string `json:"agent"`
DocsURL string `json:"docs_url"`
Files []FileAction `json:"files"`
}
if err := json.Unmarshal(buf.Bytes(), &out); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, buf.String())
}
if out.Agent != "cursor" {
t.Errorf("agent = %q, want cursor", out.Agent)
}
if len(out.Files) != 1 || out.Files[0].Path != ".cursor/mcp.json" {
t.Errorf("files = %+v, want the planned mcp.json", out.Files)
}
// An unknown agent errors and names the registered agents.
err := PrintConfig(&bytes.Buffer{}, reg, "nonexistent", Env{})
if err == nil {
t.Fatal("an unknown agent must error")
}
if !bytes.Contains([]byte(err.Error()), []byte("unknown agent")) ||
!bytes.Contains([]byte(err.Error()), []byte("cursor")) {
t.Errorf("error should name the unknown agent and the available list; got %v", err)
}
}
+178
View File
@@ -0,0 +1,178 @@
package agents
import (
"fmt"
"sort"
"strings"
"sync"
)
// Registry holds the set of adapters `gortex init` will run. Callers
// Register each adapter explicitly (from cmd/gortex/init.go) rather
// than via sub-package init() hooks so that unit tests can build a
// registry with a controlled subset without a global side-effect.
//
// Methods are safe for concurrent reads; Register should only be
// called during startup.
type Registry struct {
mu sync.RWMutex
adapters []Adapter
byName map[string]Adapter
}
// NewRegistry returns an empty Registry.
func NewRegistry() *Registry {
return &Registry{byName: make(map[string]Adapter)}
}
// Register adds an adapter. Panics on duplicate names — duplicates
// indicate a programming error (two packages registering the same
// name), not a user mistake, so a panic is appropriate.
func (r *Registry) Register(a Adapter) {
r.mu.Lock()
defer r.mu.Unlock()
name := a.Name()
if _, dup := r.byName[name]; dup {
panic(fmt.Sprintf("agents.Registry: duplicate adapter name %q", name))
}
r.byName[name] = a
r.adapters = append(r.adapters, a)
}
// All returns adapters in registration order. Callers should not
// mutate the returned slice.
func (r *Registry) All() []Adapter {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Adapter, len(r.adapters))
copy(out, r.adapters)
return out
}
// Names returns the sorted list of registered adapter names. Used by
// the --agents flag's help text and the unknown-name error message.
func (r *Registry) Names() []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.byName))
for name := range r.byName {
out = append(out, name)
}
sort.Strings(out)
return out
}
// Lookup returns the adapter with the given name, or nil when no
// adapter is registered under that name.
func (r *Registry) Lookup(name string) Adapter {
r.mu.RLock()
defer r.mu.RUnlock()
return r.byName[name]
}
// Filter returns the subset of adapters selected by allow/skip lists.
// allowCSV:
// - "" (empty) or "auto" selects every registered adapter (the
// default: let Detect decide which ones run)
// - comma-separated list of names selects only those
// - any unknown name produces an error
//
// skipCSV removes names from the selection after allowCSV applies.
// Unknown skip names are also hard errors — silent skips mask typos.
//
// The returned slice preserves registration order so the user sees a
// stable run sequence.
func (r *Registry) Filter(allowCSV, skipCSV string) ([]Adapter, error) {
r.mu.RLock()
defer r.mu.RUnlock()
allow := parseCSV(allowCSV)
skip := parseCSV(skipCSV)
// Validate names up front; we'd rather report all typos at once.
var unknown []string
for _, n := range allow {
if n == "auto" {
continue
}
if _, ok := r.byName[n]; !ok {
unknown = append(unknown, n)
}
}
for _, n := range skip {
if _, ok := r.byName[n]; !ok {
unknown = append(unknown, n)
}
}
if len(unknown) > 0 {
sort.Strings(unknown)
return nil, fmt.Errorf("unknown agent name(s): %s; available: %s",
strings.Join(unknown, ", "),
strings.Join(r.sortedNamesLocked(), ", "))
}
// Build the allowed set. Empty allowCSV or the "auto" sentinel
// means "every registered adapter" — Detect() then decides which
// ones actually run.
var allowSet map[string]struct{}
autoMode := len(allow) == 0
for _, n := range allow {
if n == "auto" {
autoMode = true
continue
}
}
if !autoMode {
allowSet = make(map[string]struct{}, len(allow))
for _, n := range allow {
allowSet[n] = struct{}{}
}
}
skipSet := make(map[string]struct{}, len(skip))
for _, n := range skip {
skipSet[n] = struct{}{}
}
out := make([]Adapter, 0, len(r.adapters))
for _, a := range r.adapters {
name := a.Name()
if allowSet != nil {
if _, ok := allowSet[name]; !ok {
continue
}
}
if _, skipped := skipSet[name]; skipped {
continue
}
out = append(out, a)
}
return out, nil
}
func (r *Registry) sortedNamesLocked() []string {
out := make([]string, 0, len(r.byName))
for name := range r.byName {
out = append(out, name)
}
sort.Strings(out)
return out
}
// parseCSV splits a comma-separated list and trims whitespace. Empty
// input and all-whitespace input yield a nil slice.
func parseCSV(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
+182
View File
@@ -0,0 +1,182 @@
package agents
import (
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
)
// render.go is the engine behind the skill-render drift fence. It runs
// every adapter against an isolated sandbox (a throwaway HOME + repo
// root) with ForceDetect on — so the adapter renders regardless of which
// tools are installed — then serialises the produced file tree into a
// stable, machine-independent text manifest. The manifest is
// byte-compared against committed goldens by the drift test and the
// `gortex agents render` command, so any change to an adapter's
// generated MCP config, instructions, hooks, or skills surfaces as a
// reviewable diff across every platform, not just Claude.
// RenderManifest renders each adapter into its own sandbox and returns a
// normalised manifest per adapter, keyed by adapter name.
func RenderManifest(adapters []Adapter) (map[string]string, error) {
out := make(map[string]string, len(adapters))
for _, a := range adapters {
m, err := renderOne(a)
if err != nil {
return nil, fmt.Errorf("render %s: %w", a.Name(), err)
}
out[a.Name()] = m
}
return out, nil
}
// renderOne applies a single adapter in a fresh sandbox and returns its
// manifest. The HOME and repo-root temp dirs are removed afterwards.
func renderOne(a Adapter) (string, error) {
home, err := os.MkdirTemp("", "gortex-render-home-")
if err != nil {
return "", err
}
defer func() { _ = os.RemoveAll(home) }()
root, err := os.MkdirTemp("", "gortex-render-root-")
if err != nil {
return "", err
}
defer func() { _ = os.RemoveAll(root) }()
// Project mode is the default `gortex init` path and the one that
// renders the per-repo skill / instruction surfaces (where content
// drift lives). A fixed SkillsRouting payload makes the
// community-routing blocks render deterministically.
env := Env{
Root: root,
Home: home,
Mode: ModeProject,
HookCommand: "gortex hook",
SkillsRouting: "- [example-community](.claude/skills/example/SKILL.md) — example routing block\n",
Stderr: io.Discard,
}
if _, err := a.Apply(env, ApplyOpts{ForceDetect: true}); err != nil {
return "", err
}
return manifestForDirs(home, root)
}
// manifestForDirs walks the sandbox HOME and repo root and builds a
// sorted, normalised manifest. Each file becomes a `=== <key> ===`
// header followed by its (sandbox-path-stripped) content; entries are
// sorted by key so the manifest is deterministic.
func manifestForDirs(home, root string) (string, error) {
type entry struct{ key, content string }
var entries []entry
collect := func(base, prefix string) error {
return filepath.WalkDir(base, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
rel, err := filepath.Rel(base, path)
if err != nil {
return err
}
entries = append(entries, entry{
key: canonicalManifestKey(prefix + filepath.ToSlash(rel)),
content: normalizeRender(string(data), home, root),
})
return nil
})
}
if err := collect(home, "home/"); err != nil {
return "", err
}
if err := collect(root, "root/"); err != nil {
return "", err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].key < entries[j].key })
var b strings.Builder
for _, e := range entries {
fmt.Fprintf(&b, "=== %s ===\n%s\n", e.key, strings.TrimRight(e.content, "\n"))
}
return b.String(), nil
}
// gortexBinaryPaths memoizes the absolute paths an adapter might bake
// into its config for the gortex binary — the running process
// (os.Executable, as the hermes adapter uses) and a PATH lookup. They
// are normalised to bare "gortex" so the manifest doesn't depend on
// where this build happens to live (dev box vs CI vs `go test` binary).
var gortexBinaryPaths = sync.OnceValue(func() []string {
seen := map[string]struct{}{}
var paths []string
add := func(p string) {
if p == "" || p == "gortex" {
return
}
if _, dup := seen[p]; dup {
return
}
seen[p] = struct{}{}
paths = append(paths, p)
}
if exe, err := os.Executable(); err == nil {
add(exe)
if abs, e := filepath.Abs(exe); e == nil {
add(abs)
}
}
if p, err := exec.LookPath("gortex"); err == nil {
add(p)
}
return paths
})
// canonicalManifestKey rewrites the OS-specific user-config locations
// in a manifest path to a single canonical (Linux/XDG) form, so the
// manifest is identical regardless of the OS the render runs on.
// Without this an adapter that uses OS-specific config dirs makes the
// golden non-portable (e.g. zed writes ~/Library/Application Support/Zed
// on macOS, ~/AppData/Roaming/Zed on Windows, and ~/.config/zed on
// Linux). Only manifest keys are folded; file contents are compared
// verbatim.
func canonicalManifestKey(key string) string {
key = strings.ReplaceAll(key, "Library/Application Support/", ".config/")
key = strings.ReplaceAll(key, "AppData/Roaming/", ".config/")
// zed capitalises its directory on macOS/Windows but not on Linux.
key = strings.ReplaceAll(key, ".config/Zed/", ".config/zed/")
return key
}
// normalizeRender replaces machine-specific absolutes — the sandbox
// HOME / repo root and the resolved gortex binary path — with stable
// placeholders so the manifest is identical on every machine.
func normalizeRender(s, home, root string) string {
s = strings.ReplaceAll(s, home, "$HOME")
s = strings.ReplaceAll(s, root, "$ROOT")
for _, p := range gortexBinaryPaths() {
s = strings.ReplaceAll(s, p, "gortex")
}
return s
}
// RenderContainsRegistration reports whether a rendered manifest still
// wires Gortex in — an MCP server stanza, a gortex hook, a community
// routing block, or instruction prose all reference "gortex". The drift
// CLI uses it as a structural sanity check (independent of the byte
// golden) that an adapter didn't silently stop emitting gortex content.
func RenderContainsRegistration(manifest string) bool {
return strings.Contains(strings.ToLower(manifest), "gortex")
}
+71
View File
@@ -0,0 +1,71 @@
package agents
import (
"errors"
"fmt"
"io"
"os"
toml "github.com/pelletier/go-toml/v2"
)
// MergeTOML is the TOML cousin of MergeJSON: read an existing
// TOML file (if any), pass the decoded map to mutate, and write
// back atomically when mutate reports a change. Used by the Codex
// CLI adapter — Codex config lives in ~/.codex/config.toml and no
// other adapter currently needs TOML support.
//
// Malformed TOML is preserved as a .bak sibling before we
// overwrite, same policy as MergeJSON.
func MergeTOML(w io.Writer, path string, mutate func(root map[string]any, existed bool) (changed bool, err error), opts ApplyOpts) (FileAction, error) {
existed := false
root := make(map[string]any)
var backupPath string
if data, err := os.ReadFile(path); err == nil {
existed = true
if err := toml.Unmarshal(data, &root); err != nil {
backupPath = path + ".bak"
_ = os.WriteFile(backupPath, data, 0o644)
root = make(map[string]any)
}
} else if !errors.Is(err, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, err)
}
changed, err := mutate(root, existed)
if err != nil {
return FileAction{}, err
}
if !changed {
return FileAction{Path: path, Action: ActionSkip, Reason: "already-configured"}, nil
}
keys := sortedMapKeys(root)
if opts.DryRun {
action := ActionWouldCreate
if existed {
action = ActionWouldMerge
}
return FileAction{Path: path, Action: action, Keys: keys}, nil
}
out, err := toml.Marshal(root)
if err != nil {
return FileAction{}, fmt.Errorf("marshal %s: %w", path, err)
}
if err := AtomicWriteFile(path, out, 0o644); err != nil {
return FileAction{}, err
}
action := ActionCreate
if existed {
action = ActionMerge
}
if backupPath != "" {
logf(w, "[gortex init] %s was malformed TOML; backup saved to %s", path, backupPath)
}
logf(w, "[gortex init] %s %s", actionVerb(action), path)
return FileAction{Path: path, Action: action, Keys: keys}, nil
}
+129
View File
@@ -0,0 +1,129 @@
// Package vscode implements the Gortex init integration for
// Visual Studio Code's native MCP runtime (1.102+). VS Code's
// schema differs from the Claude / Cursor canonical shape in two
// ways:
//
// - top-level key is "servers" (not "mcpServers")
// - stdio is the inferred default; no "type" field is required
//
// The VS Code docs document both project-level (.vscode/mcp.json)
// and user-level configurations; the user-level file lives under
// the platform-specific VS Code profile directory and is accessed
// via the "MCP: Open User Configuration" command. We write the
// project-level file today and leave user-level to a follow-up once
// the official per-OS paths are confirmed.
//
// Docs: https://code.visualstudio.com/docs/copilot/chat/mcp-servers
package vscode
import (
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "vscode"
const DocsURL = "https://code.visualstudio.com/docs/copilot/chat/mcp-servers"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if _, err := os.Stat(filepath.Join(env.Root, ".vscode")); err == nil {
return true, nil
}
if p, err := exec.LookPath("code"); err == nil && p != "" {
return true, nil
}
if env.Home != "" {
candidates := []string{
filepath.Join(env.Home, "Library", "Application Support", "Code"),
filepath.Join(env.Home, ".config", "Code"),
filepath.Join(env.Home, ".vscode"),
}
for _, dir := range candidates {
if _, err := os.Stat(dir); err == nil {
return true, nil
}
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{Files: []agents.FileAction{
{Path: filepath.Join(env.Root, ".vscode", "mcp.json"), Action: agents.ActionWouldMerge, Keys: []string{"servers"}},
}}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".github", "copilot-instructions.md"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
// Global mode skips this adapter — user-level VS Code MCP
// config lives at a platform-specific path (via the "MCP: Open
// User Configuration" command) which we don't resolve today.
if env.Mode == agents.ModeGlobal {
return res, nil
}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip VS Code / Copilot setup (VS Code not detected)")
return res, nil
}
internalutil.Logf(env.Stderr, "[gortex init] setting up VS Code / GitHub Copilot integration...")
path := filepath.Join(env.Root, ".vscode", "mcp.json")
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
servers, ok := root["servers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if _, exists := servers["gortex"]; exists && !opts.Force {
return false, nil
}
// VS Code's native MCP runtime (1.102+) infers stdio from
// command/args presence, so no "type" field is needed. Env
// is optional — we set it for parity with other adapters.
servers["gortex"] = map[string]any{
"command": "gortex",
"args": []string{"mcp"},
"env": map[string]string{"GORTEX_INDEX_WORKERS": "8"},
}
root["servers"] = servers
return true, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// Copilot reads .github/copilot-instructions.md on every chat
// turn. Write a marker-guarded community-routing block there
// when skills were generated — codebase-specific navigation
// that MCP tool descriptions can't carry.
if env.SkillsRouting != "" {
copilotPath := filepath.Join(env.Root, ".github", "copilot-instructions.md")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, copilotPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
+78
View File
@@ -0,0 +1,78 @@
package vscode
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestVSCodeUsesServersKeyNotMcpServers locks in the VS Code
// 1.102+ schema: top-level key is "servers", not the legacy
// "mcpServers". A regression here would be silently invisible to
// users — VS Code would just not load the server.
func TestVSCodeUsesServersKeyNotMcpServers(t *testing.T) {
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env.Root, ".vscode"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// Two creates: .vscode/mcp.json for MCP plus
// .github/copilot-instructions.md for the Copilot rules block
// that tells Copilot Chat to prefer Gortex over file reads.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
cfg := agentstest.ReadJSON(t, filepath.Join(env.Root, ".vscode", "mcp.json"))
if _, ok := cfg["mcpServers"]; ok {
t.Fatalf("wrote legacy 'mcpServers' key; VS Code 1.102+ expects 'servers': %v", cfg)
}
servers, ok := cfg["servers"].(map[string]any)
if !ok {
t.Fatalf("missing 'servers' key: %v", cfg)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("servers.gortex missing: %v", servers)
}
agentstest.AssertIdempotent(t, a, env)
}
// TestVSCodeMergePreservesUserServers confirms we don't clobber a
// user's existing "servers" entry during the merge.
func TestVSCodeMergePreservesUserServers(t *testing.T) {
env, _ := agentstest.NewEnv(t)
if err := os.MkdirAll(filepath.Join(env.Root, ".vscode"), 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(env.Root, ".vscode", "mcp.json")
agentstest.WriteJSON(t, path, map[string]any{
"servers": map[string]any{
"playwright": map[string]any{"command": "npx"},
},
})
res, err := New().Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// mcp.json pre-exists → merge. The copilot-instructions.md file
// does not exist in this fixture, so the instructions helper
// creates it.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionMerge: 1, agents.ActionCreate: 1})
cfg := agentstest.ReadJSON(t, path)
servers := cfg["servers"].(map[string]any)
if _, ok := servers["playwright"]; !ok {
t.Fatalf("playwright was clobbered: %v", servers)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("gortex missing: %v", servers)
}
}
+144
View File
@@ -0,0 +1,144 @@
// Package windsurf implements the Gortex init integration for
// Codeium's Windsurf editor. The canonical config path is
// ~/.codeium/mcp_config.json (documented 2026-04). Earlier Windsurf
// releases used ~/.codeium/windsurf/mcp_config.json; we detect both
// and prefer the new path, leaving the old file alone so users can
// uninstall a stale install cleanly. --force upgrades by removing
// the legacy file.
//
// Docs: https://docs.windsurf.com/plugins/cascade/mcp
package windsurf
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "windsurf"
const DocsURL = "https://docs.windsurf.com/plugins/cascade/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// currentConfigPath is the path Windsurf reads today.
func currentConfigPath(home string) string {
return filepath.Join(home, ".codeium", "mcp_config.json")
}
// legacyConfigPath is the pre-2026 path Windsurf used.
func legacyConfigPath(home string) string {
return filepath.Join(home, ".codeium", "windsurf", "mcp_config.json")
}
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("windsurf"); err == nil && p != "" {
return true, nil
}
if env.Home == "" {
return false, nil
}
for _, p := range []string{
filepath.Join(env.Home, ".codeium"),
filepath.Join(env.Home, ".codeium", "windsurf"),
} {
if _, err := os.Stat(p); err == nil {
return true, nil
}
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if env.Home != "" {
p.Files = append(p.Files, agents.FileAction{
Path: currentConfigPath(env.Home),
Action: agents.ActionWouldMerge,
Keys: []string{"mcpServers"},
})
}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".windsurfrules"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Windsurf setup (Windsurf not detected)")
return res, nil
}
if env.Home == "" {
return res, fmt.Errorf("windsurf: requires a resolved home directory")
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Windsurf integration...")
// Migrate: if the legacy path has an entry but the current
// path doesn't, move the gortex stanza over. Users who've
// hand-edited the legacy file shouldn't lose their changes;
// we leave the legacy file on disk unless --force.
if err := migrateLegacyWindsurfConfig(env, opts); err != nil {
internalutil.Warnf(env.Stderr, "windsurf: legacy-config migration failed: %v", err)
}
action, err := agents.MergeJSON(env.Stderr, currentConfigPath(env.Home), func(root map[string]any, _ bool) (bool, error) {
return agents.UpsertMCPServer(root, "gortex", agents.DefaultGortexMCPEntry(), opts), nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// .windsurfrules gets a marker-guarded community-routing block
// when skills were generated. Skipped in global mode (the file
// is per-repo) and when --no-skills / no communities qualify.
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
rulesPath := filepath.Join(env.Root, ".windsurfrules")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, rulesPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
// migrateLegacyWindsurfConfig copies a pre-existing gortex stanza
// from the legacy ~/.codeium/windsurf/mcp_config.json to the
// current path. Non-gortex servers the user added are not moved —
// they may be intentionally Windsurf-specific.
func migrateLegacyWindsurfConfig(env agents.Env, opts agents.ApplyOpts) error {
legacy := legacyConfigPath(env.Home)
if _, err := os.Stat(legacy); errors.Is(err, os.ErrNotExist) {
return nil
}
internalutil.Logf(env.Stderr, "[gortex init] migrating Windsurf config from %s to %s", legacy, currentConfigPath(env.Home))
// No-op migration: we never overwrite the legacy file. The
// merge below handles inserting the gortex stanza into the
// current path. With --force we remove the legacy file.
if opts.Force && !opts.DryRun {
if err := os.Remove(legacy); err != nil {
return err
}
internalutil.Logf(env.Stderr, "[gortex init] --force: removed legacy Windsurf config %s", legacy)
}
return nil
}
+58
View File
@@ -0,0 +1,58 @@
package windsurf
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestWindsurfUsesCurrentPath verifies we write to
// ~/.codeium/mcp_config.json (the 2026+ canonical path) rather than
// the legacy ~/.codeium/windsurf/mcp_config.json.
func TestWindsurfUsesCurrentPath(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Detection sentinel: ~/.codeium exists.
if err := os.MkdirAll(filepath.Join(env.Home, ".codeium"), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// Two creates: ~/.codeium/mcp_config.json for MCP plus the
// project-scoped .windsurfrules file the Cascade agent reads.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
if _, err := os.Stat(filepath.Join(env.Home, ".codeium", "mcp_config.json")); err != nil {
t.Fatalf("current config path not written: %v", err)
}
if _, err := os.Stat(filepath.Join(env.Home, ".codeium", "windsurf", "mcp_config.json")); err == nil {
t.Fatal("legacy path should not be written by default")
}
agentstest.AssertIdempotent(t, a, env)
}
// TestWindsurfForceRemovesLegacyFile confirms --force removes the
// stale pre-2026 config file so users don't get confused state.
func TestWindsurfForceRemovesLegacyFile(t *testing.T) {
env, _ := agentstest.NewEnv(t)
legacy := filepath.Join(env.Home, ".codeium", "windsurf", "mcp_config.json")
agentstest.WriteJSON(t, legacy, map[string]any{"mcpServers": map[string]any{}})
res, err := New().Apply(env, agents.ApplyOpts{Force: true})
if err != nil {
t.Fatalf("apply: %v", err)
}
if !res.Configured {
t.Fatal("expected Configured=true")
}
if _, err := os.Stat(legacy); err == nil {
t.Fatal("--force should have removed legacy file")
}
}
+418
View File
@@ -0,0 +1,418 @@
package agents
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// writer.go centralises every write `gortex init` performs. Going
// through one helper lets us:
//
// 1. Make writes atomic — temp file + rename. Partial failures
// can't leave a half-written MCP config that breaks the editor.
// 2. Respect ApplyOpts.DryRun uniformly. No adapter needs its own
// "would this run?" branch.
// 3. Report what happened in a structured FileAction so `--json`
// and the doctor subcommand speak the same vocabulary.
// 4. Power golden-fixture tests — the test harness points the
// "root" at a temp dir, runs Apply, and diffs the written tree
// against testdata/ golden files.
// WriteIfNotExists writes content to path when it doesn't exist.
// Used for static artifacts (slash-command markdown, Kiro steering,
// KI metadata) where merging isn't meaningful. When the file is
// already present we emit ActionSkip with Reason="exists" rather
// than silently overwriting.
//
// Under DryRun: no disk write. Returns ActionWouldCreate for a
// missing file, ActionSkip for an existing one.
//
// Directories are created as needed with 0o755.
func WriteIfNotExists(w io.Writer, path, content string, opts ApplyOpts) (FileAction, error) {
if _, err := os.Stat(path); err == nil {
logf(w, "[gortex init] skip %s (already exists)", path)
return FileAction{Path: path, Action: ActionSkip, Reason: "exists"}, nil
} else if !errors.Is(err, os.ErrNotExist) {
return FileAction{Path: path, Action: ActionSkip, Reason: err.Error()}, fmt.Errorf("stat %s: %w", path, err)
}
if opts.DryRun {
return FileAction{Path: path, Action: ActionWouldCreate}, nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return FileAction{}, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
}
if err := AtomicWriteFile(path, []byte(content), 0o644); err != nil {
return FileAction{}, err
}
logf(w, "[gortex init] created %s", path)
return FileAction{Path: path, Action: ActionCreate}, nil
}
// WriteOwnedFile writes content to path unconditionally, overwriting
// whatever is there. Meant for files Gortex owns end-to-end (e.g.
// generated community-routing files under .cursor/rules/,
// .continue/rules/, .clinerules/) so each `gortex init` run
// regenerates them from the current graph. Returns ActionCreate when
// the file was absent and ActionMerge when it already existed, so the
// summary line reads naturally.
//
// Under DryRun: no disk write. Returns ActionWould* mirroring the
// same existed/absent split.
func WriteOwnedFile(w io.Writer, path, content string, opts ApplyOpts) (FileAction, error) {
existing, readErr := os.ReadFile(path)
existed := readErr == nil
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, readErr)
}
// Skip when the target is already byte-identical — keeps
// AssertIdempotent valid and avoids mtime bumps on no-op
// re-runs.
if existed && string(existing) == content {
return FileAction{Path: path, Action: ActionSkip, Reason: "unchanged"}, nil
}
if opts.DryRun {
action := ActionWouldCreate
if existed {
action = ActionWouldMerge
}
return FileAction{Path: path, Action: action}, nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return FileAction{}, fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
}
if err := AtomicWriteFile(path, []byte(content), 0o644); err != nil {
return FileAction{}, err
}
verb := "wrote"
if existed {
verb = "regenerated"
}
logf(w, "[gortex init] %s %s", verb, path)
action := ActionCreate
if existed {
action = ActionMerge
}
return FileAction{Path: path, Action: action}, nil
}
// AtomicWriteFile writes data to path via a temp file in the same
// directory followed by a rename. Guarantees that a concurrent reader
// either sees the old file or the fully-written new file — never a
// half-written state.
//
// The temp file uses a deterministic prefix so a crash leaves
// "<name>.gortex.tmp-<pid>.<rand>" files that are easy to identify
// and clean up manually.
func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
f, err := os.CreateTemp(dir, filepath.Base(path)+".gortex.tmp-*")
if err != nil {
return fmt.Errorf("create temp in %s: %w", dir, err)
}
tmpPath := f.Name()
// Best-effort cleanup on failure. We deliberately ignore the
// error: if the rename succeeds the file no longer exists, and
// if something else goes wrong the user can remove the temp by
// hand.
cleanup := func() { _ = os.Remove(tmpPath) }
if _, err := f.Write(data); err != nil {
_ = f.Close()
cleanup()
return fmt.Errorf("write %s: %w", tmpPath, err)
}
if err := f.Chmod(perm); err != nil {
_ = f.Close()
cleanup()
return fmt.Errorf("chmod %s: %w", tmpPath, err)
}
if err := f.Close(); err != nil {
cleanup()
return fmt.Errorf("close %s: %w", tmpPath, err)
}
if err := renameWithRetry(tmpPath, path); err != nil {
cleanup()
return fmt.Errorf("rename %s -> %s: %w", tmpPath, path, err)
}
return nil
}
// renameWithRetry renames oldPath onto newPath, retrying briefly when the
// failure is a transient, Windows-specific sharing violation (see
// isRetryableRenameErr). On POSIX the predicate is always false, so this
// collapses to a single os.Rename with no added latency.
//
// os.Rename maps to MoveFileEx(MOVEFILE_REPLACE_EXISTING) on Windows,
// which fails with ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED when
// another process still holds the destination open without
// FILE_SHARE_DELETE — an editor's language server, antivirus, a search
// indexer, or Gortex's own file watcher re-indexing the file we just
// wrote. Those holders release the handle within milliseconds, so a
// short bounded retry turns a spurious "file is being used by another
// process" error into the atomic replace the caller asked for. Worst
// case is ~225ms of backoff, imperceptible for an interactive write.
func renameWithRetry(oldPath, newPath string) error {
const attempts = 10
var err error
for attempt := range attempts {
if err = os.Rename(oldPath, newPath); err == nil {
return nil
}
if !isRetryableRenameErr(err) {
return err
}
if attempt < attempts-1 {
time.Sleep(time.Duration(attempt+1) * 5 * time.Millisecond)
}
}
return err
}
// MergeJSON reads path (if present), parses it as a JSON object,
// passes the parsed map to mutate, and writes the result back
// atomically when mutate reports a change. A nil or malformed file
// is treated as empty — a backup is written alongside the original
// before we overwrite garbage, so a user with a broken config doesn't
// lose their edits.
//
// mutate returns:
// - changed=true if the map was modified and should be written
// - changed=false if no change is needed (idempotent re-run); we
// still return a FileAction describing the skip for --json
//
// Keys is collected from the top-level map keys after mutation —
// useful for the --json report but not semantically load-bearing.
func MergeJSON(w io.Writer, path string, mutate func(root map[string]any, existed bool) (changed bool, err error), opts ApplyOpts) (FileAction, error) {
existed := false
root := make(map[string]any)
var backupPath string
var backupData []byte
if data, err := os.ReadFile(path); err == nil {
existed = true
switch {
case len(strings.TrimSpace(string(data))) == 0:
// An empty (or whitespace-only) file is an empty object, not
// malformed — no backup, nothing to preserve.
default:
// `.jsonc` / `.json5` configs (e.g. OpenCode's
// opencode.jsonc) may carry comments and trailing commas
// that encoding/json rejects. Sanitize those before the
// parse so a commented config merges instead of being
// treated as malformed and clobbered. Comments are not
// round-tripped through the re-marshal — same policy as
// MergeTOML.
parse := data
if isJSONCPath(path) {
parse = stripJSONComments(data)
}
if err := json.Unmarshal(parse, &root); err != nil {
// Don't silently overwrite the user's file even if it's
// malformed — keep a backup for recovery. The backup is
// written only on the real write path below, so a DryRun
// never touches disk.
backupPath, backupData = path+".bak", data
root = make(map[string]any)
}
}
} else if !errors.Is(err, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, err)
}
changed, err := mutate(root, existed)
if err != nil {
return FileAction{}, err
}
if !changed {
return FileAction{Path: path, Action: ActionSkip, Reason: "already-configured"}, nil
}
keys := sortedMapKeys(root)
if opts.DryRun {
action := ActionWouldCreate
if existed {
action = ActionWouldMerge
}
return FileAction{Path: path, Action: action, Keys: keys}, nil
}
out, err := json.MarshalIndent(root, "", " ")
if err != nil {
return FileAction{}, fmt.Errorf("marshal %s: %w", path, err)
}
if backupPath != "" {
// Best-effort backup of the malformed original, just before we
// overwrite it (never under DryRun — that returned above).
_ = os.WriteFile(backupPath, backupData, 0o644)
}
if err := AtomicWriteFile(path, out, 0o644); err != nil {
return FileAction{}, err
}
action := ActionCreate
if existed {
action = ActionMerge
}
if backupPath != "" {
logf(w, "[gortex init] %s was malformed; backup saved to %s", path, backupPath)
}
logf(w, "[gortex init] %s %s", actionVerb(action), path)
return FileAction{Path: path, Action: action, Keys: keys}, nil
}
// isJSONCPath reports whether path uses a JSON-with-comments extension
// (`.jsonc` / `.json5`) whose contents may need sanitising before they
// can be handed to encoding/json.
func isJSONCPath(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".jsonc", ".json5":
return true
default:
return false
}
}
// stripJSONComments rewrites JSONC / JSON5-style input into strict JSON
// that encoding/json can parse: it drops `//` line comments, `/* */`
// block comments, and trailing commas before `}` / `]`. String literals
// (and their escape sequences) are copied through untouched, so a `//`
// or comma inside a quoted value is preserved. The result is only used
// for parsing — the merged map is re-marshalled fresh, so the original
// comments and formatting are not carried over (matching MergeTOML).
func stripJSONComments(b []byte) []byte {
out := make([]byte, 0, len(b))
inString, escaped := false, false
for i := 0; i < len(b); i++ {
c := b[i]
if inString {
out = append(out, c)
switch {
case escaped:
escaped = false
case c == '\\':
escaped = true
case c == '"':
inString = false
}
continue
}
switch {
case c == '"':
inString = true
out = append(out, c)
case c == '/' && i+1 < len(b) && b[i+1] == '/':
// Line comment: skip to (but keep) the newline.
for i+1 < len(b) && b[i+1] != '\n' {
i++
}
case c == '/' && i+1 < len(b) && b[i+1] == '*':
// Block comment: skip through the closing */.
i += 2
for i+1 < len(b) && (b[i] != '*' || b[i+1] != '/') {
i++
}
i++ // step onto '/', loop's i++ moves past it
default:
out = append(out, c)
}
}
return stripTrailingCommas(out)
}
// stripTrailingCommas removes a comma that is followed (ignoring
// whitespace) by a `}` or `]` — JSONC / JSON5 permit it, strict JSON
// does not. Commas inside string literals are left alone.
func stripTrailingCommas(b []byte) []byte {
out := make([]byte, 0, len(b))
inString, escaped := false, false
for i := range len(b) {
c := b[i]
if inString {
out = append(out, c)
switch {
case escaped:
escaped = false
case c == '\\':
escaped = true
case c == '"':
inString = false
}
continue
}
if c == '"' {
inString = true
out = append(out, c)
continue
}
if c == ',' {
j := i + 1
for j < len(b) {
switch b[j] {
case ' ', '\t', '\n', '\r':
j++
continue
}
break
}
if j < len(b) && (b[j] == '}' || b[j] == ']') {
continue // drop the trailing comma
}
}
out = append(out, c)
}
return out
}
// actionVerb renders an ActionKind for human-readable log lines.
// Kept distinct from the on-the-wire string so we can tweak messaging
// without breaking JSON consumers.
func actionVerb(a ActionKind) string {
switch a {
case ActionCreate:
return "created"
case ActionMerge:
return "merged into"
case ActionSkip:
return "skipped"
case ActionWouldCreate:
return "would create"
case ActionWouldMerge:
return "would merge into"
}
return string(a)
}
func sortedMapKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// logf writes a newline-terminated message when w is non-nil. Shared
// helper so adapters don't each need to guard for a nil stderr in
// tests.
func logf(w io.Writer, format string, args ...any) {
if w == nil {
return
}
fmt.Fprintf(w, format+"\n", args...)
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package agents
// isRetryableRenameErr is always false off Windows: a POSIX rename
// atomically replaces the destination even while another process holds it
// open, so a failed rename there is a genuine, non-transient error (e.g.
// an EXDEV cross-device link) that retrying would not fix.
func isRetryableRenameErr(error) bool { return false }
+330
View File
@@ -0,0 +1,330 @@
package agents
import (
"bytes"
"encoding/json"
"io"
"os"
"path/filepath"
"testing"
)
// TestWriteIfNotExistsCreatesAndSkips covers both the create and
// skip branches of the helper plus the DryRun prediction. Golden
// fixtures don't exercise DryRun, so we test it explicitly here.
func TestWriteIfNotExistsCreatesAndSkips(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nested", "file.txt")
var buf bytes.Buffer
// 1. First call creates the file.
a, err := WriteIfNotExists(&buf, path, "hello", ApplyOpts{})
if err != nil {
t.Fatalf("create: %v", err)
}
if a.Action != ActionCreate {
t.Fatalf("expected create, got %q", a.Action)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(got) != "hello" {
t.Fatalf("content: got %q want %q", got, "hello")
}
// 2. Second call finds the file and skips.
a, err = WriteIfNotExists(&buf, path, "different", ApplyOpts{})
if err != nil {
t.Fatalf("skip: %v", err)
}
if a.Action != ActionSkip {
t.Fatalf("expected skip, got %q", a.Action)
}
// Content must be unchanged — skip is never overwrite.
got, _ = os.ReadFile(path)
if string(got) != "hello" {
t.Fatalf("skip must not overwrite: got %q", got)
}
// 3. DryRun on a missing file reports would-create, doesn't write.
missing := filepath.Join(dir, "new.txt")
a, err = WriteIfNotExists(&buf, missing, "x", ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("dry-run: %v", err)
}
if a.Action != ActionWouldCreate {
t.Fatalf("expected would-create, got %q", a.Action)
}
if _, err := os.Stat(missing); !os.IsNotExist(err) {
t.Fatalf("dry-run wrote file: %v", err)
}
}
// TestMergeJSONCreatesMergesAndSkipsIdempotent covers the three
// transitions the MCP installer relies on: fresh file, merge into
// existing, and no-op on re-run. This is the behavioural contract
// golden tests will compare against byte-for-byte.
// TestMergeJSON_DryRunNeverWritesBackup guards the regression where a
// dry-run over a malformed (or empty) existing file still dropped a
// .bak sibling on disk — dry-run must touch nothing.
func TestMergeJSON_DryRunNeverWritesBackup(t *testing.T) {
dir := t.TempDir()
add := func(root map[string]any, _ bool) (bool, error) {
return UpsertMCPServer(root, "gortex", DefaultGortexMCPEntry(), ApplyOpts{}), nil
}
// A malformed existing file under dry-run.
mal := filepath.Join(dir, "malformed.json")
if err := os.WriteFile(mal, []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := MergeJSON(io.Discard, mal, add, ApplyOpts{DryRun: true}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(mal + ".bak"); !os.IsNotExist(err) {
t.Errorf("dry-run must not write a .bak (err=%v)", err)
}
// An empty file is treated as an empty object, not malformed: no
// backup even on a real write.
empty := filepath.Join(dir, "empty.json")
if err := os.WriteFile(empty, []byte(""), 0o644); err != nil {
t.Fatal(err)
}
if _, err := MergeJSON(io.Discard, empty, add, ApplyOpts{}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(empty + ".bak"); !os.IsNotExist(err) {
t.Errorf("an empty file must not be treated as malformed / backed up (err=%v)", err)
}
// A genuinely malformed file on the real write path still gets a .bak.
mal2 := filepath.Join(dir, "malformed2.json")
if err := os.WriteFile(mal2, []byte("garbage{"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := MergeJSON(io.Discard, mal2, add, ApplyOpts{}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(mal2 + ".bak"); err != nil {
t.Errorf("a real merge over a malformed file should keep a .bak: %v", err)
}
}
func TestMergeJSONCreatesMergesAndSkipsIdempotent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mcp.json")
var buf bytes.Buffer
addGortex := func(root map[string]any, existed bool) (bool, error) {
return UpsertMCPServer(root, "gortex", DefaultGortexMCPEntry(), ApplyOpts{}), nil
}
// 1. Missing file -> create.
a, err := MergeJSON(&buf, path, addGortex, ApplyOpts{})
if err != nil {
t.Fatalf("create: %v", err)
}
if a.Action != ActionCreate {
t.Fatalf("expected create, got %q", a.Action)
}
// 2. Re-run -> skip (idempotent).
a, err = MergeJSON(&buf, path, addGortex, ApplyOpts{})
if err != nil {
t.Fatalf("skip: %v", err)
}
if a.Action != ActionSkip {
t.Fatalf("expected skip, got %q", a.Action)
}
// 3. Pre-populate with an unrelated MCP server, merge adds ours
// without clobbering theirs.
existing := map[string]any{
"mcpServers": map[string]any{
"other": map[string]any{"command": "other"},
},
}
data, _ := json.MarshalIndent(existing, "", " ")
fresh := filepath.Join(dir, "mcp-pre.json")
if err := os.WriteFile(fresh, data, 0o644); err != nil {
t.Fatal(err)
}
a, err = MergeJSON(&buf, fresh, addGortex, ApplyOpts{})
if err != nil {
t.Fatalf("merge: %v", err)
}
if a.Action != ActionMerge {
t.Fatalf("expected merge, got %q", a.Action)
}
content, _ := os.ReadFile(fresh)
var out map[string]any
if err := json.Unmarshal(content, &out); err != nil {
t.Fatal(err)
}
servers := out["mcpServers"].(map[string]any)
if _, ok := servers["other"]; !ok {
t.Fatalf("merge clobbered existing 'other' server: %v", servers)
}
if _, ok := servers["gortex"]; !ok {
t.Fatalf("merge didn't add 'gortex': %v", servers)
}
}
// TestStripJSONComments covers the JSONC sanitiser: comments and
// trailing commas are removed, but `//`, `/* */`, and commas inside
// string literals are preserved.
func TestStripJSONComments(t *testing.T) {
in := `{
// a line comment
"url": "https://example.com/path", /* block */
"note": "a, b, c // not a comment",
"list": [1, 2, 3,],
"obj": { "k": "v", },
}`
got := stripJSONComments([]byte(in))
var out map[string]any
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("sanitised JSONC did not parse: %v\n---\n%s", err, got)
}
if out["url"] != "https://example.com/path" {
t.Fatalf("`//` inside a string was stripped: %v", out["url"])
}
if out["note"] != "a, b, c // not a comment" {
t.Fatalf("comment/comma inside a string was altered: %v", out["note"])
}
if list, ok := out["list"].([]any); !ok || len(list) != 3 {
t.Fatalf("trailing comma handling broke the array: %v", out["list"])
}
}
// TestMergeJSON_JSONCMergesInsteadOfClobbering guards the OpenCode
// path: merging into an existing `.jsonc` with comments must preserve
// the user's data keys (not back the file up as malformed and start
// fresh).
func TestMergeJSON_JSONCMergesInsteadOfClobbering(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "opencode.jsonc")
if err := os.WriteFile(path, []byte(`{
// user config
"theme": "dark",
}`), 0o644); err != nil {
t.Fatal(err)
}
add := func(root map[string]any, _ bool) (bool, error) {
root["added"] = true
return true, nil
}
a, err := MergeJSON(io.Discard, path, add, ApplyOpts{})
if err != nil {
t.Fatalf("merge: %v", err)
}
if a.Action != ActionMerge {
t.Fatalf("expected merge, got %q", a.Action)
}
if _, err := os.Stat(path + ".bak"); err == nil {
t.Fatalf("a valid commented .jsonc must not be backed up as malformed")
}
var out map[string]any
data, _ := os.ReadFile(path)
if err := json.Unmarshal(data, &out); err != nil {
t.Fatalf("re-marshalled output not valid JSON: %v", err)
}
if out["theme"] != "dark" {
t.Fatalf("merge dropped the user's data key: %v", out)
}
if out["added"] != true {
t.Fatalf("merge didn't apply the mutation: %v", out)
}
}
// TestRegistryFilterValidatesNames ensures we hard-error on typos
// rather than silently dropping them — a key UX requirement from the
// init plan.
func TestRegistryFilterValidatesNames(t *testing.T) {
r := NewRegistry()
r.Register(stubAdapter{name: "alpha"})
r.Register(stubAdapter{name: "beta"})
if _, err := r.Filter("alpha,gamma", ""); err == nil {
t.Fatal("expected error on unknown 'gamma', got nil")
}
got, err := r.Filter("alpha", "beta")
if err != nil {
t.Fatalf("filter: %v", err)
}
if len(got) != 1 || got[0].Name() != "alpha" {
t.Fatalf("filter returned %v, want [alpha]", names(got))
}
// auto + skip should yield everything minus the skipped.
got, err = r.Filter("auto", "alpha")
if err != nil {
t.Fatalf("auto+skip: %v", err)
}
if len(got) != 1 || got[0].Name() != "beta" {
t.Fatalf("auto+skip returned %v, want [beta]", names(got))
}
}
type stubAdapter struct{ name string }
func (s stubAdapter) Name() string { return s.name }
func (s stubAdapter) DocsURL() string { return "" }
func (s stubAdapter) Detect(Env) (bool, error) { return true, nil }
func (s stubAdapter) Plan(Env) (*Plan, error) { return &Plan{}, nil }
func (s stubAdapter) Apply(Env, ApplyOpts) (*Result, error) { return &Result{Name: s.name}, nil }
func names(as []Adapter) []string {
out := make([]string, 0, len(as))
for _, a := range as {
out = append(out, a.Name())
}
return out
}
// TestAtomicWriteFileCreatesAndOverwrites guards the core write path every
// MCP file tool funnels through (write_file, edit_file, move, ...): a
// fresh write lands the content and a second write atomically replaces it,
// leaving no stray *.gortex.tmp-* file behind. This is also the
// no-contention happy path for renameWithRetry — its retry loop must be
// transparent when the rename succeeds first try.
func TestAtomicWriteFileCreatesAndOverwrites(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sub", "file.txt")
if err := AtomicWriteFile(path, []byte("first"), 0o644); err != nil {
t.Fatalf("create: %v", err)
}
if got, _ := os.ReadFile(path); string(got) != "first" {
t.Fatalf("content: got %q want %q", got, "first")
}
// Overwrite must replace, not append or corrupt.
if err := AtomicWriteFile(path, []byte("second"), 0o644); err != nil {
t.Fatalf("overwrite: %v", err)
}
if got, _ := os.ReadFile(path); string(got) != "second" {
t.Fatalf("overwrite content: got %q want %q", got, "second")
}
// Success must not leave the sibling temp file behind.
if leftovers, _ := filepath.Glob(filepath.Join(filepath.Dir(path), "*.gortex.tmp-*")); len(leftovers) != 0 {
t.Fatalf("leftover temp files: %v", leftovers)
}
}
// TestRenameWithRetryReturnsNonRetryableErr checks that a rename failure
// which isn't a transient sharing violation (here: a missing source) is
// surfaced immediately rather than retried — the retry budget is reserved
// for the Windows lock race, not for masking genuine errors. On every
// platform ERROR_FILE_NOT_FOUND / ENOENT is non-retryable, so this holds
// cross-platform.
func TestRenameWithRetryReturnsNonRetryableErr(t *testing.T) {
dir := t.TempDir()
if err := renameWithRetry(filepath.Join(dir, "does-not-exist"), filepath.Join(dir, "dest")); err == nil {
t.Fatal("expected an error renaming a missing source, got nil")
}
}
+30
View File
@@ -0,0 +1,30 @@
//go:build windows
package agents
import (
"errors"
"syscall"
)
// Windows system error codes returned by MoveFileEx (which os.Rename
// wraps) when the destination path is transiently held open by another
// process without FILE_SHARE_DELETE — an editor's language server,
// antivirus, a search indexer, or Gortex's own file watcher re-indexing
// the file we just wrote. Unlike POSIX, Windows refuses to replace a file
// while such a handle is open. The holder releases within milliseconds,
// so these are safe to retry.
const (
errSharingViolation syscall.Errno = 32 // ERROR_SHARING_VIOLATION
errLockViolation syscall.Errno = 33 // ERROR_LOCK_VIOLATION
errAccessDenied syscall.Errno = 5 // ERROR_ACCESS_DENIED
)
// isRetryableRenameErr reports whether a failed os.Rename is a transient
// Windows sharing/lock violation worth retrying. os.Rename returns a
// *os.LinkError whose Err unwraps to a syscall.Errno.
func isRetryableRenameErr(err error) bool {
return errors.Is(err, errSharingViolation) ||
errors.Is(err, errLockViolation) ||
errors.Is(err, errAccessDenied)
}
+96
View File
@@ -0,0 +1,96 @@
//go:build windows
package agents
import (
"os"
"path/filepath"
"sync"
"syscall"
"testing"
"time"
)
// openExclusiveNoDelete opens path with a share mode that omits
// FILE_SHARE_DELETE, reproducing the handle an editor's language server,
// antivirus, a search indexer, or Gortex's own file watcher holds while
// touching a file. MoveFileEx — and therefore os.Rename — cannot replace
// a destination held this way and fails with ERROR_SHARING_VIOLATION.
func openExclusiveNoDelete(t *testing.T, path string) syscall.Handle {
t.Helper()
p, err := syscall.UTF16PtrFromString(path)
if err != nil {
t.Fatalf("utf16 %s: %v", path, err)
}
h, err := syscall.CreateFile(p,
syscall.GENERIC_READ,
syscall.FILE_SHARE_READ, // deliberately no FILE_SHARE_DELETE / WRITE
nil,
syscall.OPEN_EXISTING,
syscall.FILE_ATTRIBUTE_NORMAL,
0)
if err != nil {
t.Fatalf("CreateFile %s: %v", path, err)
}
return h
}
// TestAtomicWriteFileRetriesPastSharingViolation is the Windows regression
// test for the "The process cannot access the file because it is being
// used by another process" failures: when the destination is transiently
// held open without FILE_SHARE_DELETE, AtomicWriteFile must retry the
// rename and succeed once the holder releases the handle, instead of
// surfacing the spurious error.
//
// CI's Windows job is build-only (see .github/workflows/ci.yml), so this
// runs locally / on demand, not in the cross-platform test matrix.
func TestAtomicWriteFileRetriesPastSharingViolation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "held.txt")
if err := os.WriteFile(path, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
h := openExclusiveNoDelete(t, path)
// Release the handle within the retry budget (~225ms) so a rename that
// first fails with a sharing violation later succeeds.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(40 * time.Millisecond)
_ = syscall.CloseHandle(h)
}()
if err := AtomicWriteFile(path, []byte("new"), 0o644); err != nil {
t.Fatalf("AtomicWriteFile should retry past a transient hold, got: %v", err)
}
wg.Wait()
if got, _ := os.ReadFile(path); string(got) != "new" {
t.Fatalf("content after retry: got %q want %q", got, "new")
}
}
// TestAtomicWriteFileFailsWhenHeldThroughout bounds the retry: a
// destination held open for longer than the whole retry budget must make
// AtomicWriteFile give up and return an error — not hang forever — and
// must leave the original file intact.
func TestAtomicWriteFileFailsWhenHeldThroughout(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "locked.txt")
if err := os.WriteFile(path, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
h := openExclusiveNoDelete(t, path)
defer syscall.CloseHandle(h)
if err := AtomicWriteFile(path, []byte("new"), 0o644); err == nil {
t.Fatal("expected AtomicWriteFile to fail while the file is held open throughout")
}
if got, _ := os.ReadFile(path); string(got) != "old" {
t.Fatalf("a failed write must not corrupt the destination: got %q", got)
}
}
+297
View File
@@ -0,0 +1,297 @@
package agents
import (
"bytes"
"errors"
"fmt"
"io"
"os"
yaml "gopkg.in/yaml.v3"
)
// MergeYAML is the YAML cousin of MergeJSON / MergeTOML, with one
// important difference: it preserves comments and structure. Hermes
// config (~/.hermes/config.yaml) is comment-rich and hand-edited, so
// a round-trip through map[string]any — which silently drops every
// comment and reorders keys — would mangle the user's file. Instead
// we decode into a *yaml.Node tree (which carries HeadComment /
// LineComment / FootComment on every node) and re-encode it, so an
// idempotent re-run is a no-op and a real merge touches only the keys
// we add.
//
// mutate receives the top-level mapping node (created empty for a new
// or empty file) and reports whether it changed anything. The agents
// package ships node helpers — YAMLMapValue / YAMLSetMapValue /
// YAMLScalar / UpsertYAMLMapEntry — so callers don't hand-walk the
// Content slice.
//
// Malformed YAML is preserved as a .bak sibling before we overwrite,
// same policy as MergeJSON / MergeTOML. A valid-but-non-mapping
// top-level document (a bare scalar or sequence — never a real config
// file) is also backed up before we replace it with a fresh mapping.
func MergeYAML(w io.Writer, path string, mutate func(root *yaml.Node, existed bool) (changed bool, err error), opts ApplyOpts) (FileAction, error) {
existed := false
var backupPath string
var backupData []byte
indent := defaultYAMLIndent
var doc yaml.Node
if data, err := os.ReadFile(path); err == nil {
existed = true
if len(bytes.TrimSpace(data)) > 0 {
indent = detectYAMLIndent(data)
if err := yaml.Unmarshal(data, &doc); err != nil {
// Malformed YAML — capture it for a .bak (written only in
// the real-apply branch below, never under --dry-run) and
// start from an empty document.
backupPath, backupData = path+".bak", data
doc = yaml.Node{}
} else if !documentHasMappingRoot(&doc) {
// Valid YAML but not a top-level mapping (a bare list
// or scalar). We can't safely splice mcp_servers into
// that, so preserve it and start fresh.
backupPath, backupData = path+".bak", data
doc = yaml.Node{}
}
}
} else if !errors.Is(err, os.ErrNotExist) {
return FileAction{}, fmt.Errorf("read %s: %w", path, err)
}
root := documentRoot(&doc)
changed, err := mutate(root, existed)
if err != nil {
return FileAction{}, err
}
if !changed {
return FileAction{Path: path, Action: ActionSkip, Reason: "already-configured"}, nil
}
keys := yamlTopLevelKeys(root)
if opts.DryRun {
action := ActionWouldCreate
if existed {
action = ActionWouldMerge
}
return FileAction{Path: path, Action: action, Keys: keys}, nil
}
out, err := marshalYAMLDocument(&doc, indent)
if err != nil {
return FileAction{}, fmt.Errorf("marshal %s: %w", path, err)
}
// Preserve the unparseable / non-mapping original as a .bak sibling
// before we overwrite it. Deferred to here, the real-apply branch,
// so a --dry-run on a malformed file never mutates disk.
if backupPath != "" {
if err := os.WriteFile(backupPath, backupData, 0o644); err != nil {
return FileAction{}, fmt.Errorf("write backup %s: %w", backupPath, err)
}
}
if err := AtomicWriteFile(path, out, 0o644); err != nil {
return FileAction{}, err
}
action := ActionCreate
if existed {
action = ActionMerge
}
if backupPath != "" {
logf(w, "[gortex init] %s was malformed YAML; backup saved to %s", path, backupPath)
}
logf(w, "[gortex init] %s %s", actionVerb(action), path)
return FileAction{Path: path, Action: action, Keys: keys}, nil
}
// documentHasMappingRoot reports whether a freshly-unmarshaled
// document node carries a mapping at its root — the only shape we can
// safely merge into.
func documentHasMappingRoot(doc *yaml.Node) bool {
return doc.Kind == yaml.DocumentNode &&
len(doc.Content) > 0 &&
doc.Content[0].Kind == yaml.MappingNode
}
// documentRoot normalises doc so it is a DocumentNode wrapping a
// MappingNode and returns that mapping. A zero-value node (empty /
// absent file) is turned into an empty document so the caller always
// gets a writable mapping back.
func documentRoot(doc *yaml.Node) *yaml.Node {
if documentHasMappingRoot(doc) {
return doc.Content[0]
}
mapping := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
doc.Kind = yaml.DocumentNode
doc.Content = []*yaml.Node{mapping}
return mapping
}
// marshalYAMLDocument renders a document node back to bytes at the given
// indent width — matched to the file's own (see detectYAMLIndent) so a
// merge re-emits 4-space configs as 4-space rather than forcing 2 —
// preserving the comments captured during Unmarshal.
func marshalYAMLDocument(doc *yaml.Node, indent int) ([]byte, error) {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(indent)
if err := enc.Encode(doc); err != nil {
_ = enc.Close()
return nil, err
}
if err := enc.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// defaultYAMLIndent is the indentation width MergeYAML falls back to
// when a file's own indent can't be inferred (a new or single-level
// file). 2 is what Hermes and most hand-written YAML use.
const defaultYAMLIndent = 2
// detectYAMLIndent infers the indentation step (in spaces) a YAML file
// already uses, so a comment-preserving merge re-emits the file at the
// width its author chose. It returns the smallest positive leading-space
// count across non-comment lines — for well-formed YAML that is the
// per-level step — clamped to a sane range, else defaultYAMLIndent.
//
// This matches the indent *width* only. yaml.v3's encoder cannot emit
// indentless block sequences (a key whose `- item` children sit at the
// key's own column); those are always re-indented one level on a merge.
// That is a known go-yaml limitation, not something this can undo — a
// fully diff-free merge would require a surgical byte-splice instead of
// a whole-document re-encode.
func detectYAMLIndent(data []byte) int {
best := 0
for line := range bytes.SplitSeq(data, []byte("\n")) {
n := 0
for n < len(line) && line[n] == ' ' {
n++
}
if n == 0 || n >= len(line) { // top-level, blank, or all spaces
continue
}
if line[n] == '#' { // comments may be aligned arbitrarily
continue
}
if best == 0 || n < best {
best = n
}
}
if best < 1 || best > 8 {
return defaultYAMLIndent
}
return best
}
// yamlTopLevelKeys returns the key names of a mapping node, in file
// order. Used to populate FileAction.Keys for the --json report.
func yamlTopLevelKeys(m *yaml.Node) []string {
if m == nil || m.Kind != yaml.MappingNode {
return nil
}
out := make([]string, 0, len(m.Content)/2)
for i := 0; i+1 < len(m.Content); i += 2 {
out = append(out, m.Content[i].Value)
}
return out
}
// YAMLMapValue returns the value node stored under key in a mapping
// node, or nil when the key is absent (or m isn't a mapping). YAML
// mappings are stored as a flat [k0, v0, k1, v1, …] Content slice;
// this hides that layout from callers.
func YAMLMapValue(m *yaml.Node, key string) *yaml.Node {
if m == nil || m.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
return m.Content[i+1]
}
}
return nil
}
// YAMLSetMapValue sets key=val in a mapping node, replacing an
// existing value in place (so its leading comment survives) or
// appending a new key/value pair.
func YAMLSetMapValue(m *yaml.Node, key string, val *yaml.Node) {
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value == key {
m.Content[i+1] = val
return
}
}
m.Content = append(m.Content, YAMLScalar(key), val)
}
// YAMLScalar builds a plain string scalar node. Callers that need
// non-string scalars (ints, bools) construct yaml.Node literals with
// the matching tag directly.
func YAMLScalar(value string) *yaml.Node {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}
}
// UpsertYAMLMapEntry ensures root[outerKey][innerKey] = entry, where
// outerKey's value is a nested mapping (created when absent). Returns
// true when the tree was modified, false when innerKey already exists
// and force is off — the idempotent-re-run signal MergeYAML turns
// into an "already-configured" skip.
//
// This is the YAML analogue of UpsertMCPServer: Hermes stores MCP
// servers under the snake_case `mcp_servers` map rather than the
// camelCase `mcpServers` every JSON client uses.
func UpsertYAMLMapEntry(root *yaml.Node, outerKey, innerKey string, entry *yaml.Node, force bool) (changed bool, err error) {
outer := YAMLMapValue(root, outerKey)
switch {
case outer == nil:
// Key absent — create the nested mapping.
outer = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
YAMLSetMapValue(root, outerKey, outer)
case outer.Kind == yaml.MappingNode:
// Reuse the existing mapping in place.
case isNullYAMLNode(outer):
// `outerKey:` with an empty / null value — e.g. every server
// commented out so the map decodes to null. Safe to populate:
// replacing the null value in place keeps the key node (and its
// leading comment) intact.
outer = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
YAMLSetMapValue(root, outerKey, outer)
default:
// A non-null scalar or a sequence under outerKey is not a shape
// we can splice a child into. Refuse rather than silently drop
// the user's data — the caller leaves the file untouched.
return false, fmt.Errorf("agents: %q is %s, not a mapping; refusing to overwrite", outerKey, yamlKindName(outer.Kind))
}
if existing := YAMLMapValue(outer, innerKey); existing != nil && !force {
return false, nil
}
YAMLSetMapValue(outer, innerKey, entry)
return true, nil
}
// isNullYAMLNode reports whether n is a YAML null — an explicitly null
// scalar (`~`, `null`) or an empty value (`key:` with nothing after it).
func isNullYAMLNode(n *yaml.Node) bool {
return n != nil && n.Kind == yaml.ScalarNode && (n.Tag == "!!null" || n.Value == "")
}
// yamlKindName renders a node kind for a human-readable error message.
func yamlKindName(k yaml.Kind) string {
switch k {
case yaml.SequenceNode:
return "a sequence"
case yaml.ScalarNode:
return "a scalar"
case yaml.MappingNode:
return "a mapping"
case yaml.AliasNode:
return "an alias"
default:
return "a non-mapping value"
}
}
+305
View File
@@ -0,0 +1,305 @@
package agents
import (
"os"
"path/filepath"
"strings"
"testing"
yaml "gopkg.in/yaml.v3"
)
// gortexEntryNode builds the stdio MCP stanza Hermes expects, used
// across the YAML merge tests.
func gortexEntryNode() *yaml.Node {
entry := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
YAMLSetMapValue(entry, "command", YAMLScalar("gortex"))
args := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq", Content: []*yaml.Node{YAMLScalar("mcp")}}
YAMLSetMapValue(entry, "args", args)
return entry
}
func upsertGortex(force bool) func(root *yaml.Node, existed bool) (bool, error) {
return func(root *yaml.Node, _ bool) (bool, error) {
return UpsertYAMLMapEntry(root, "mcp_servers", "gortex", gortexEntryNode(), force)
}
}
func TestMergeYAML_CreatesFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{})
if err != nil {
t.Fatalf("merge: %v", err)
}
if action.Action != ActionCreate {
t.Fatalf("want create, got %s", action.Action)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
var root map[string]any
if err := yaml.Unmarshal(data, &root); err != nil {
t.Fatalf("written file is not valid YAML: %v\n%s", err, data)
}
servers, ok := root["mcp_servers"].(map[string]any)
if !ok {
t.Fatalf("mcp_servers missing or wrong type: %#v", root)
}
gortex, ok := servers["gortex"].(map[string]any)
if !ok {
t.Fatalf("gortex server missing: %#v", servers)
}
if gortex["command"] != "gortex" {
t.Errorf("command = %v, want gortex", gortex["command"])
}
}
func TestMergeYAML_Idempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if _, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{}); err != nil {
t.Fatalf("first merge: %v", err)
}
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{})
if err != nil {
t.Fatalf("second merge: %v", err)
}
if action.Action != ActionSkip {
t.Fatalf("re-run want skip, got %s", action.Action)
}
}
func TestMergeYAML_PreservesCommentsAndKeys(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
original := `# Hermes configuration — edit with care
model: hermes-4
# remote tool servers
mcp_servers:
# GitHub MCP — issues and PRs
github:
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
# delegation knobs the user tuned by hand
delegation:
max_depth: 3 # do not raise without measuring
`
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{})
if err != nil {
t.Fatalf("merge: %v", err)
}
if action.Action != ActionMerge {
t.Fatalf("want merge, got %s", action.Action)
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
got := string(out)
// Comments must survive the round-trip.
for _, want := range []string{
"# Hermes configuration — edit with care",
"# remote tool servers",
"# GitHub MCP — issues and PRs",
"# delegation knobs the user tuned by hand",
"do not raise without measuring",
} {
if !strings.Contains(got, want) {
t.Errorf("lost comment %q after merge:\n%s", want, got)
}
}
// Existing servers + unrelated keys must survive, gortex must be added.
var root map[string]any
if err := yaml.Unmarshal(out, &root); err != nil {
t.Fatalf("merged file is not valid YAML: %v", err)
}
if root["model"] != "hermes-4" {
t.Errorf("unrelated key model clobbered: %v", root["model"])
}
servers := root["mcp_servers"].(map[string]any)
if _, ok := servers["github"]; !ok {
t.Errorf("pre-existing github server dropped: %#v", servers)
}
if _, ok := servers["gortex"]; !ok {
t.Errorf("gortex server not added: %#v", servers)
}
}
func TestMergeYAML_MalformedBacksUp(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
// Tab indentation is a hard YAML parse error.
bad := "mcp_servers:\n\t\tgithub: bad\n : : :\n"
if err := os.WriteFile(path, []byte(bad), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{})
if err != nil {
t.Fatalf("merge: %v", err)
}
if action.Action != ActionMerge {
t.Fatalf("want merge (file existed), got %s", action.Action)
}
if _, err := os.Stat(path + ".bak"); err != nil {
t.Errorf("expected .bak of malformed input: %v", err)
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
var root map[string]any
if err := yaml.Unmarshal(out, &root); err != nil {
t.Fatalf("recovered file is not valid YAML: %v\n%s", err, out)
}
if _, ok := root["mcp_servers"].(map[string]any)["gortex"]; !ok {
t.Errorf("gortex not written into recovered file: %#v", root)
}
}
func TestMergeYAML_DryRunNoWrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("merge: %v", err)
}
if action.Action != ActionWouldCreate {
t.Fatalf("want would-create, got %s", action.Action)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("dry-run must not write the file")
}
}
// TestMergeYAML_DryRunMalformedNoBackup guards that --dry-run never
// mutates disk even when the existing file is malformed: the .bak of a
// malformed input is deferred to a real apply, so a plan-only run leaves
// both the file and its sibling untouched.
func TestMergeYAML_DryRunMalformedNoBackup(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
bad := "mcp_servers:\n\t\tgithub: bad\n : : :\n"
if err := os.WriteFile(path, []byte(bad), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{DryRun: true})
if err != nil {
t.Fatalf("merge: %v", err)
}
if action.Action != ActionWouldMerge {
t.Fatalf("want would-merge, got %s", action.Action)
}
if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) {
t.Errorf("dry-run must not write a .bak (stat err = %v)", err)
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(out) != bad {
t.Errorf("dry-run mutated the original file:\n%q", out)
}
}
// TestMergeYAML_NonMapOuterErrors guards that a non-null, non-mapping
// value under the outer key (a stray scalar or sequence) is treated as
// schema-invalid and refused — never silently swapped for a fresh map
// that drops the user's data.
func TestMergeYAML_NonMapOuterErrors(t *testing.T) {
for _, tc := range []struct {
name string
body string
}{
{"scalar", "mcp_servers: nonsense\n"},
{"sequence", "mcp_servers:\n - a\n - b\n"},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(tc.body), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{}); err == nil {
t.Fatal("expected an error for non-mapping mcp_servers, got nil")
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if string(out) != tc.body {
t.Errorf("file mutated despite refusal:\n%q", out)
}
if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) {
t.Errorf("refusal must not write a .bak")
}
})
}
}
// TestMergeYAML_NullOuterPopulated covers the benign case: an empty /
// null `mcp_servers:` (e.g. every server commented out) is populated in
// place rather than erroring, and the key's leading comment survives.
func TestMergeYAML_NullOuterPopulated(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
original := "# servers (all disabled for now)\nmcp_servers:\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
action, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{})
if err != nil {
t.Fatalf("merge: %v", err)
}
if action.Action != ActionMerge {
t.Fatalf("want merge, got %s", action.Action)
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
if !strings.Contains(string(out), "# servers (all disabled for now)") {
t.Errorf("lost leading comment on null key:\n%s", out)
}
var root map[string]any
if err := yaml.Unmarshal(out, &root); err != nil {
t.Fatalf("not valid YAML: %v\n%s", err, out)
}
servers, ok := root["mcp_servers"].(map[string]any)
if !ok {
t.Fatalf("mcp_servers not populated: %#v", root)
}
if _, ok := servers["gortex"]; !ok {
t.Errorf("gortex not added: %#v", servers)
}
}
// TestMergeYAML_PreservesIndentWidth verifies a 4-space config is
// re-emitted at 4 spaces rather than forced to 2 (detectYAMLIndent).
func TestMergeYAML_PreservesIndentWidth(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
original := "mcp_servers:\n github:\n command: npx\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := MergeYAML(nil, path, upsertGortex(false), ApplyOpts{}); err != nil {
t.Fatalf("merge: %v", err)
}
out, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read back: %v", err)
}
got := string(out)
if !strings.Contains(got, "\n github:") {
t.Errorf("4-space indent not preserved:\n%s", got)
}
if strings.Contains(got, "\n github:") {
t.Errorf("indent collapsed to 2 spaces:\n%s", got)
}
}
+150
View File
@@ -0,0 +1,150 @@
// Package zed implements the Gortex init integration for the Zed
// editor. Zed calls its MCP registry `context_servers` (not
// `mcpServers`) and stores it in a platform-specific settings.json:
//
// macOS: ~/Library/Application Support/Zed/settings.json
// Linux: ~/.config/zed/settings.json
// Windows: %APPDATA%\Zed\settings.json
//
// The server entry shape:
//
// {
// "context_servers": {
// "gortex": {
// "source": "custom",
// "command": "gortex",
// "args": ["mcp", "--index", ".", "--watch"],
// "env": {"GORTEX_INDEX_WORKERS": "8"}
// }
// }
// }
//
// Docs: https://zed.dev/docs/ai/mcp
package zed
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/internalutil"
)
const Name = "zed"
const DocsURL = "https://zed.dev/docs/ai/mcp"
type Adapter struct{}
func New() *Adapter { return &Adapter{} }
func (a *Adapter) Name() string { return Name }
func (a *Adapter) DocsURL() string { return DocsURL }
// userSettingsPath returns the platform-specific Zed settings file.
// Returns "" when Home is unset or the OS is unsupported.
func userSettingsPath(home string) string {
if home == "" {
return ""
}
switch runtime.GOOS {
case "darwin":
return filepath.Join(home, "Library", "Application Support", "Zed", "settings.json")
case "linux":
return filepath.Join(home, ".config", "zed", "settings.json")
case "windows":
// %APPDATA% is usually ~\AppData\Roaming on Windows.
return filepath.Join(home, "AppData", "Roaming", "Zed", "settings.json")
default:
return ""
}
}
// Detect checks for the zed CLI on PATH or the platform-specific
// settings.json directory.
func (a *Adapter) Detect(env agents.Env) (bool, error) {
if p, err := exec.LookPath("zed"); err == nil && p != "" {
return true, nil
}
path := userSettingsPath(env.Home)
if path == "" {
return false, nil
}
if _, err := os.Stat(filepath.Dir(path)); err == nil {
return true, nil
}
return false, nil
}
func (a *Adapter) Plan(env agents.Env) (*agents.Plan, error) {
p := &agents.Plan{}
if settings := userSettingsPath(env.Home); settings != "" {
p.Files = append(p.Files, agents.FileAction{
Path: settings,
Action: agents.ActionWouldMerge,
Keys: []string{"context_servers"},
})
}
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
p.Files = append(p.Files, agents.FileAction{
Path: filepath.Join(env.Root, ".rules"), Action: agents.ActionWouldMerge,
Keys: []string{"communities-block"},
})
}
return p, nil
}
func (a *Adapter) Apply(env agents.Env, opts agents.ApplyOpts) (*agents.Result, error) {
res := &agents.Result{Name: Name, DocsURL: DocsURL}
detected, _ := a.Detect(env)
res.Detected = detected
if !detected && !opts.ForceDetect {
internalutil.Logf(env.Stderr, "[gortex init] skip Zed setup (zed not detected)")
return res, nil
}
path := userSettingsPath(env.Home)
if path == "" {
return res, fmt.Errorf("zed: no user settings path known for %s", runtime.GOOS)
}
internalutil.Logf(env.Stderr, "[gortex init] setting up Zed integration...")
action, err := agents.MergeJSON(env.Stderr, path, func(root map[string]any, _ bool) (bool, error) {
servers, ok := root["context_servers"].(map[string]any)
if !ok {
servers = make(map[string]any)
}
if _, exists := servers["gortex"]; exists && !opts.Force {
return false, nil
}
servers["gortex"] = map[string]any{
"source": "custom",
"command": "gortex",
"args": []string{"mcp"},
"env": map[string]string{"GORTEX_INDEX_WORKERS": "8"},
}
root["context_servers"] = servers
return true, nil
}, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, action)
// Zed's Agent panel reads `.rules` at the project root on every
// turn. Write a marker-guarded community-routing block there
// when skills were generated. Skipped in global mode (the file
// is per-repo).
if env.Mode != agents.ModeGlobal && env.SkillsRouting != "" {
rulesPath := filepath.Join(env.Root, ".rules")
routingAction, err := agents.UpsertMarkedBlock(env.Stderr, rulesPath, env.SkillsRouting,
agents.CommunitiesStartMarker, agents.CommunitiesEndMarker, opts)
if err != nil {
return res, err
}
res.Files = append(res.Files, routingAction)
}
res.Configured = true
return res, nil
}
+51
View File
@@ -0,0 +1,51 @@
package zed
import (
"os"
"path/filepath"
"testing"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/agents/agentstest"
)
// TestZedUsesContextServersKey verifies Zed's idiosyncratic schema:
// top-level key is "context_servers" (not mcpServers or servers).
// A regression here would silently break the integration.
func TestZedUsesContextServersKey(t *testing.T) {
env, _ := agentstest.NewEnv(t)
// Detection: create the per-OS settings dir so Detect() succeeds.
p := userSettingsPath(env.Home)
if p == "" {
t.Skip("unsupported OS for zed test")
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
a := New()
res, err := a.Apply(env, agents.ApplyOpts{})
if err != nil {
t.Fatalf("apply: %v", err)
}
// Two creates: Zed's settings.json for the context_servers stanza
// plus the project-root .rules file the Agent panel reads on
// every turn.
agentstest.AssertCountsByAction(t, res, map[agents.ActionKind]int{agents.ActionCreate: 2})
cfg := agentstest.ReadJSON(t, p)
if _, ok := cfg["mcpServers"]; ok {
t.Fatalf("should not write mcpServers — Zed uses context_servers: %v", cfg)
}
servers, ok := cfg["context_servers"].(map[string]any)
if !ok {
t.Fatalf("missing context_servers key: %v", cfg)
}
gortex, ok := servers["gortex"].(map[string]any)
if !ok {
t.Fatalf("gortex entry missing: %v", servers)
}
if gortex["source"] != "custom" {
t.Fatalf("expected source=custom, got %v", gortex["source"])
}
}