From 74770d9f2cf88be232570e624d3b986f6769b532 Mon Sep 17 00:00:00 2001 From: andev0x Date: Sun, 21 Jun 2026 23:47:13 +0700 Subject: [PATCH 1/6] docs: update documentation and configuration - apply semantic repository updates --- .opencode/AGENTS.md | 71 ++++++++++++ internal/cli/commands/mcp.go | 10 +- internal/mcp/install/install.go | 185 ++++++++++++++++++-------------- 3 files changed, 180 insertions(+), 86 deletions(-) create mode 100644 .opencode/AGENTS.md diff --git a/.opencode/AGENTS.md b/.opencode/AGENTS.md new file mode 100644 index 0000000..59b858c --- /dev/null +++ b/.opencode/AGENTS.md @@ -0,0 +1,71 @@ +# AGENTS.md + +> **CRITICAL:** THIS REPOSITORY IS STRICTLY GOVERNED BY THE PIZENLABS DUAL-ENGINE SECOND BRAIN PROTOCOL. +> YOU ARE ABSOLUTELY FORBIDDEN FROM MAKING ANY BLIND SEARCHES (`GLOB`, `GREP`), RAW FILE READS, OR CODE EDITS UNTIL YOU COMPLY WITH THE ECOSYSTEM LIFECYCLE BELOW. + +--- + +## INITIALIZATION LIFECYCLE (MANDATORY FIRST ACTIONS) + +Before executing any business logic or making any architectural assumptions, you **MUST** sequentially call your native read tool to parse the following core state configurations inside `.lea/`: + +1. **`.lea/protocol.json`** + + * Ingest the `"strict_runtime_rules"` and `"tool_execution_adapter"`. + * Treat them as unchangeable runtime constants. + * **CRITICAL BINDING:** You **MUST** execute all ecosystem subcommands through your native `bash` tool using the exact format: `bash -c ''`. + +2. **`.lea/workspace.json`** + + * Ingest the immutable repository facts, languages, frameworks, and graph stats to understand the project scale. + +3. **`.lea/intent.json` & `.lea/limitations.json`** + + * Ingest current human architectural boundaries, unsupported scopes, and confidence limitations (especially regarding dynamic dispatch). + +4. **`.lea/memory.json`** + + * Inherit historical failures, hotspots, and successful patterns to avoid repeating past development bugs. + +--- + +## MANDATORY EXECUTION PIPELINE (NEVER BYPASS) + +Every single task requiring file exploration or modification **MUST** navigate through the strict multi-phase pipeline defined in `protocol.json`: + +### PHASE 1: DISCOVER (via `pizen-lynx` engine) + +You **MUST** invoke discovery commands via bash first to retrieve coordinate states: + +* `bash -c 'lx search '` — Discover candidate symbols/files by intent. +* `bash -c 'lx resolve '` — Resolve a symbol to stable file coordinates. +* *Zero modifications or raw file reads are allowed before this phase provides valid coordinates.* + +### PHASE 2: REASON (via `pizen-lea` engine) + +Once coordinates are resolved, you **MUST** pipe them into the graph reasoning layer via bash to map structural impact and execution flows: + +* `bash -c 'lea context '` — Compile token-budgeted context blocks for the target symbol. +* `bash -c 'lea flow '` — Generate ordered control-flow trace from the target symbol. +* `bash -c 'lea impact '` — Execute recursive blast-radius impact analysis. + +### PHASE 3: ARCHITECTURE GUARD + +Before completing any task or declaring success, you **MUST** validate project boundaries: + +* `bash -c 'lea violations'` +* Ensure that all your changes comply strictly with the boundary constraints and return zero violations. + +--- + +## HARD BOUNDARIES & ABORT CONDITIONS + +* **Abort Trigger:** If you hit **3 consecutive failed edits**, you **MUST** immediately halt all file mutations. + +* **Recovery Procedure:** Run the fallback sequence through your native bash tool: + + 1. `bash -c 'lx resolve '` + 2. `bash -c 'lea impact '` + 3. **Request human intervention** as specified in `protocol.json`. + +> **CONSEQUENCE:** Failure to execute this exact setup loop and orchestration pipeline as your **FIRST** tool actions will break repository integrity and result in immediate session rejection. diff --git a/internal/cli/commands/mcp.go b/internal/cli/commands/mcp.go index fe8b338..c32f61b 100644 --- a/internal/cli/commands/mcp.go +++ b/internal/cli/commands/mcp.go @@ -61,10 +61,14 @@ var mcpInstallCmd = &cobra.Command{ Use: "install", Short: "Configure MCP entries for lea and lx across AI tools", Long: `Installs MCP server entries (pizen-lea and pizen-lynx) into the configuration -files of supported AI coding agents: Claude Code, VS Code (Cline/Roo Code/Codex CLI), -OpenCode, Pi, Zed, Gemini CLI, OpenClaw, Aider, Antigravity, Kiro, and KiloCode.`, +files of supported AI coding agents: Claude Code, Codex CLI, Gemini CLI, Zed, +OpenCode, Antigravity, Aider, KiloCode, VS Code, OpenClaw, Kiro, and Pi.`, RunE: func(_ *cobra.Command, _ []string) error { - return install.Run() + projectDir, err := os.Getwd() + if err != nil { + return err + } + return install.Run(projectDir) }, } diff --git a/internal/mcp/install/install.go b/internal/mcp/install/install.go index 779b604..946816d 100644 --- a/internal/mcp/install/install.go +++ b/internal/mcp/install/install.go @@ -10,7 +10,6 @@ import ( "os/exec" "path/filepath" "runtime" - "strings" "github.com/pelletier/go-toml/v2" "gopkg.in/yaml.v3" @@ -30,25 +29,18 @@ type target struct { } // installTargets returns the full list of MCP configuration targets. -func installTargets() []target { - home := homeDir() - configDir := filepath.Join(home, ".config") - - vscodeBase := vscodeGlobalStorageDir(home) - +func installTargets(home, projectDir, vscodeUserDir string) []target { return []target{ - {Name: "Claude Code", Path: filepath.Join(home, ".claude", ".mcp.json"), Format: "json"}, - {Name: "VS Code (Cline/Roo Code/Codex CLI)", Path: filepath.Join(vscodeBase, "saoudrizwan.claude-dev", "settings", "mcp_settings.json"), Format: "json"}, - {Name: "OpenCode", Path: filepath.Join(configDir, "opencode", "opencode.json"), Format: "opencode"}, - {Name: "Pi Coding Agents", Path: filepath.Join(home, ".pi", "agent", "mcp.json"), Format: "json"}, - {Name: "PizenLabs Shared MCP", Path: filepath.Join(configDir, "mcp", "mcp.json"), Format: "json"}, - {Name: "Zed IDE", Path: filepath.Join(home, ".zed", "settings.json"), Format: "json"}, - {Name: "Gemini CLI", Path: filepath.Join(configDir, "gemini-cli", "mcp.json"), Format: "json"}, - {Name: "OpenClaw", Path: filepath.Join(configDir, "openclaw", "mcp.json"), Format: "json"}, - {Name: "Aider", Path: filepath.Join(home, ".aider.conf.yml"), Format: "yaml"}, - {Name: "Antigravity", Path: filepath.Join(configDir, "antigravity", "mcp_manifest.json"), Format: "json"}, - {Name: "Kiro Agent", Path: filepath.Join(configDir, "kiro", "config.toml"), Format: "toml"}, - {Name: "KiloCode", Path: filepath.Join(home, ".kilocode", "config.json"), Format: "json"}, + {Name: "Claude Code", Path: filepath.Join(projectDir, ".claude", ".mcp.json"), Format: "json"}, + {Name: "Codex CLI", Path: filepath.Join(projectDir, ".codex", "config.toml"), Format: "codex_toml"}, + {Name: "Gemini CLI", Path: filepath.Join(projectDir, ".gemini", "settings.json"), Format: "json"}, + {Name: "Zed", Path: filepath.Join(projectDir, "settings.json"), Format: "zed"}, + {Name: "OpenCode", Path: filepath.Join(projectDir, "opencode.json"), Format: "opencode"}, + {Name: "Antigravity", Path: filepath.Join(home, ".gemini", "config", "mcp_config.json"), Format: "json"}, + {Name: "KiloCode", Path: filepath.Join(projectDir, "mcp_settings.json"), Format: "json"}, + {Name: "VS Code", Path: filepath.Join(vscodeUserDir, "mcp.json"), Format: "json"}, + {Name: "OpenClaw", Path: filepath.Join(projectDir, "openclaw.json"), Format: "json"}, + {Name: "Kiro", Path: filepath.Join(projectDir, ".kiro", "settings", "mcp.json"), Format: "json"}, } } @@ -79,8 +71,27 @@ func vscodeGlobalStorageDir(home string) string { } } +// vscodeUserDir returns the VS Code User directory for the current OS. +func vscodeUserDir(home string) string { + switch runtime.GOOS { + case "darwin": + return filepath.Join(home, "Library", "Application Support", "Code", "User") + case "linux": + return filepath.Join(home, ".config", "Code", "User") + case "windows": + appData := os.Getenv("APPDATA") + if appData == "" { + appData = filepath.Join(home, "AppData", "Roaming") + } + return filepath.Join(appData, "Code", "User") + default: + return filepath.Join(home, ".config", "Code", "User") + } +} + // Run configures all MCP targets with pizen-lea and pizen-lynx entries. -func Run() error { +// projectDir is the project root for resolving relative (project-scoped) config paths. +func Run(projectDir string) error { // Resolve lea binary path leaPath, err := os.Executable() if err != nil { @@ -91,20 +102,18 @@ func Run() error { return fmt.Errorf("cannot resolve absolute lea path: %w", err) } - // Resolve lx binary path: ~/.cargo/bin/lx home := homeDir() lxPath := filepath.Join(home, ".cargo", "bin", "lx") - // Verify lx exists (optional, don't fail) if _, err := os.Stat(lxPath); err != nil { lxPath = resolveLXFallback() } + vscodeUserDir := vscodeUserDir(home) successCount := 0 - for _, t := range installTargets() { + for _, t := range installTargets(home, projectDir, vscodeUserDir) { if err := configureTarget(t, leaPath, lxPath); err != nil { - // Silently skip — log to stderr for debugging but don't fail log.Printf("[skip] %s: %v", t.Name, err) continue } @@ -112,8 +121,7 @@ func Run() error { successCount++ } - // Generate system instruction file - if err := generateInstructions(home); err != nil { + if err := generateInstructions(home, projectDir); err != nil { log.Printf("[skip] instructions: %v", err) } fmt.Printf(" ✓ System Instructions\n") @@ -134,15 +142,8 @@ func resolveLXFallback() string { // configureTarget injects MCP entries into a single target configuration file. func configureTarget(t target, leaPath, lxPath string) error { parent := filepath.Dir(t.Path) - if _, err := os.Stat(parent); os.IsNotExist(err) { - // Create parent directory for PizenLabs Shared MCP and other writable targets - if t.Name == "PizenLabs Shared MCP" { - if err := os.MkdirAll(parent, 0755); err != nil { - return fmt.Errorf("cannot create parent directory %q: %w", parent, err) - } - } else { - return fmt.Errorf("parent directory %q does not exist", parent) - } + if err := os.MkdirAll(parent, 0755); err != nil { + return fmt.Errorf("cannot create parent directory %q: %w", parent, err) } switch t.Format { @@ -150,10 +151,14 @@ func configureTarget(t target, leaPath, lxPath string) error { return injectJSON(t.Path, leaPath, lxPath) case "opencode": return injectOpenCodeJSON(t.Path, leaPath, lxPath) + case "zed": + return injectZedJSON(t.Path, leaPath, lxPath) case "yaml": return injectYAML(t.Path, leaPath, lxPath) case "toml": return injectTOML(t.Path, leaPath, lxPath) + case "codex_toml": + return injectCodexTOML(t.Path, leaPath, lxPath) default: return fmt.Errorf("unsupported format: %s", t.Format) } @@ -172,11 +177,6 @@ func injectJSON(path, leaPath, lxPath string) error { raw = make(map[string]any) } - // Handle Zed IDE format (mcp at root level, not mcpServers) - if strings.Contains(path, ".zed") || strings.Contains(path, "zed") { - return injectZedJSON(raw, path, leaPath, lxPath) - } - // Standard mcpServers injection servers, ok := raw["mcpServers"].(map[string]any) if !ok || servers == nil { @@ -196,7 +196,18 @@ func injectJSON(path, leaPath, lxPath string) error { } // injectZedJSON handles Zed IDE's mcp config format under root "mcp" key. -func injectZedJSON(raw map[string]any, path, leaPath, lxPath string) error { +func injectZedJSON(path, leaPath, lxPath string) error { + data := readOrEmpty(path) + + var raw map[string]any + if len(data) > 0 { + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("unmarshal error: %w", err) + } + } else { + raw = make(map[string]any) + } + mcp, ok := raw["mcp"].(map[string]any) if !ok || mcp == nil { mcp = make(map[string]any) @@ -207,7 +218,6 @@ func injectZedJSON(raw map[string]any, path, leaPath, lxPath string) error { "HOME": os.Getenv("HOME"), } - // Zed format: "pizen-lea": { "command": "...", "args": ["mcp"] } mcp["pizen-lea"] = MCPEntry{Command: leaPath, Args: []string{"mcp"}, Env: env} mcp["pizen-lynx"] = MCPEntry{Command: lxPath, Args: []string{"mcp"}, Env: env} raw["mcp"] = mcp @@ -350,6 +360,36 @@ func upsertTOMLTool(list []any, name, command string, args []string) []any { return list } +// injectCodexTOML reads or creates a TOML file and injects pizen entries +// using the Codex CLI format: [mcpServers] key with inline table values. +func injectCodexTOML(path, leaPath, lxPath string) error { + data := readOrEmpty(path) + + var raw map[string]any + if len(data) > 0 { + if err := toml.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("unmarshal error: %w", err) + } + } else { + raw = make(map[string]any) + } + + servers, ok := raw["mcpServers"].(map[string]any) + if !ok || servers == nil { + servers = make(map[string]any) + } + + env := map[string]string{ + "PATH": os.Getenv("PATH"), + "HOME": os.Getenv("HOME"), + } + servers["pizen-lea"] = MCPEntry{Command: leaPath, Args: []string{"mcp"}, Env: env} + servers["pizen-lynx"] = MCPEntry{Command: lxPath, Args: []string{"mcp"}, Env: env} + raw["mcpServers"] = servers + + return writeTOML(path, raw) +} + // readOrEmpty reads a file, returning nil if it doesn't exist. func readOrEmpty(path string) []byte { data, err := os.ReadFile(path) @@ -386,13 +426,8 @@ func writeTOML(path string, v map[string]any) error { return os.WriteFile(path, data, 0644) } -// generateInstructions creates the shared instruction file at ~/.config/pizen/instructions.md. -func generateInstructions(home string) error { - pizenDir := filepath.Join(home, ".config", "pizen") - if err := os.MkdirAll(pizenDir, 0755); err != nil { - return fmt.Errorf("cannot create pizen config dir: %w", err) - } - +// generateInstructions creates per-agent instruction files in the project directory. +func generateInstructions(home, projectDir string) error { content := `# PizenLabs Ecosystem — Dual-Tool Orchestration CRITICAL: For code-related queries, ALWAYS run pizen-lynx (via search or resolve) @@ -401,46 +436,30 @@ first to discover the exact Symbol ID. DO NOT guess the code structure. Once the Symbol ID is retrieved, immediately pass it to pizen-lea (via impact, flow, or neighbors) to map structural reasoning and blast radius. ` - path := filepath.Join(pizenDir, "instructions.md") - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - return fmt.Errorf("cannot write instructions: %w", err) - } - - // Also try to inject into .clinerules / .cursorrules in common project directories. - // This is best-effort; failures are silently ignored. - _ = injectClinerules(home) - - return nil -} -// injectClinerules tries to add a reference to the instructions file in detected -// .clinerules or .cursorrules files under common project directories. -func injectClinerules(home string) error { - rulesContent := "\n# PizenLabs Ecosystem\nSee ~/.config/pizen/instructions.md for dual-tool orchestration instructions.\n" + type instructionTarget struct { + path string + wrap func(string) string + } - candidates := []string{ - filepath.Join(home, ".clinerules"), - filepath.Join(home, ".cursorrules"), - filepath.Join(home, ".codex", "rules.md"), + targets := []instructionTarget{ + {path: filepath.Join(projectDir, ".codex", "AGENTS.md"), wrap: func(s string) string { return "# Codex CLI — Lea Instructions\n\n" + s + "\n" }}, + {path: filepath.Join(projectDir, ".gemini", "GEMINI.md"), wrap: func(s string) string { return "# Gemini CLI — Lea Instructions\n\n## BeforeTool Hook\nAlways use grep before reading files.\n\n## SessionStart Reminder\n" + s + "\n" }}, + {path: filepath.Join(projectDir, "AGENTS.md"), wrap: func(s string) string { return "# OpenCode — Lea Instructions\n\n" + s + "\n" }}, + {path: filepath.Join(projectDir, "antigravity-cli", "AGENTS.md"), wrap: func(s string) string { return "# Antigravity — Lea Instructions\n\n## SessionStart Reminder\n" + s + "\n" }}, + {path: filepath.Join(projectDir, "AIDER.md"), wrap: func(s string) string { return "# Aider — Lea Instructions\n\n" + s + "\n" }}, + {path: filepath.Join(home, ".kilocode", "rules", "lea.md"), wrap: func(s string) string { return "# KiloCode — Lea Instructions\n\n" + s + "\n" }}, + {path: filepath.Join(projectDir, ".pi", "AGENTS.md"), wrap: func(s string) string { return "# Pi — Lea Instructions\n\n## SessionStart Reminder\n" + s + "\n" }}, } - for _, path := range candidates { - if _, err := os.Stat(path); os.IsNotExist(err) { - continue + for _, t := range targets { + dir := filepath.Dir(t.path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("cannot create directory %q: %w", dir, err) } - data, err := os.ReadFile(path) - if err != nil { - continue - } - if strings.Contains(string(data), "pizen-lynx") || strings.Contains(string(data), "pizen-lea") { - continue // already injected - } - f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - continue + if err := os.WriteFile(t.path, []byte(t.wrap(content)), 0644); err != nil { + return fmt.Errorf("cannot write %q: %w", t.path, err) } - _, _ = f.WriteString(rulesContent) - _ = f.Close() } return nil From 2565e5ea5a8025cfe06a79bbfe228b10d84d82a4 Mon Sep 17 00:00:00 2001 From: andev0x Date: Mon, 22 Jun 2026 00:26:33 +0700 Subject: [PATCH 2/6] fix(cli): resolve interface implementations - remove unnecessary reverse map - batch save new edges for efficiency - properly handle method-to-interface edges - add proper resource cleanup with defer statements --- internal/cli/commands/index.go | 26 +++++++++++--------------- internal/parser/treesitter/parser.go | 4 +++- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/internal/cli/commands/index.go b/internal/cli/commands/index.go index c862099..e37c7d7 100644 --- a/internal/cli/commands/index.go +++ b/internal/cli/commands/index.go @@ -418,15 +418,12 @@ func resolveInterfaceImplementations(ctx context.Context, store contracts.Store) // Build map: typeID -> set of methodIDs belonging to it typeMethods := make(map[string]map[string]bool) - // Build reverse map: methodID -> belongs to typeID - methodOwner := make(map[string]string) for _, e := range belongsToEdges { // BELONGS_TO goes from method -> type (or type -> package, struct -> package) // We need edges from method to type // Check if FromID looks like a method ID (starts with "method:") if strings.HasPrefix(e.FromID, "method:") { - methodOwner[e.FromID] = e.ToID if typeMethods[e.ToID] == nil { typeMethods[e.ToID] = make(map[string]bool) } @@ -472,7 +469,8 @@ func resolveInterfaceImplementations(ctx context.Context, store contracts.Store) } // Count new edges for reporting - newEdges := 0 + var newEdges []*graph.Edge + newMethodEdges := 0 // For each interface, find structs whose method set is a superset of the interface method set for ifaceID, ifaceMethods := range interfaceMethods { @@ -492,11 +490,7 @@ func resolveInterfaceImplementations(ctx context.Context, store contracts.Store) ToID: ifaceID, Type: graph.EdgeImplements, } - if err := store.SaveEdge(ctx, edge); err != nil { - return fmt.Errorf("failed to save IMPLEMENTS edge from %s to %s: %w", structID, ifaceID, err) - } - newEdges++ - fmt.Printf(" IMPLEMENTS: %s -> %s\n", structID, ifaceID) + newEdges = append(newEdges, edge) // Pass 2: Explicit method-to-interface linking (Issue 1 fix) // Create IMPLEMENTS_METHOD edges at the method granularity @@ -523,18 +517,20 @@ func resolveInterfaceImplementations(ctx context.Context, store contracts.Store) ToID: ifaceMethodID, Type: graph.EdgeImplementsMethod, } - if err := store.SaveEdge(ctx, methodEdge); err != nil { - return fmt.Errorf("failed to save IMPLEMENTS_METHOD edge from %s to %s: %w", concreteMethodID, ifaceMethodID, err) - } - fmt.Printf(" IMPLEMENTS_METHOD: %s -> %s\n", concreteMethodID, ifaceMethodID) + newEdges = append(newEdges, methodEdge) + newMethodEdges++ } } } } } - if newEdges > 0 { - fmt.Printf("Resolved %d interface implementation(s).\n", newEdges) + if len(newEdges) > 0 { + // Batch all new edges in a single transaction + if err := store.SaveGraph(ctx, nil, newEdges); err != nil { + return fmt.Errorf("failed to save interface implementation edges: %w", err) + } + fmt.Printf("Resolved %d interface implementation(s) and %d method edge(s).\n", len(newEdges)-newMethodEdges, newMethodEdges) } return nil diff --git a/internal/parser/treesitter/parser.go b/internal/parser/treesitter/parser.go index ac66fbe..5704b87 100644 --- a/internal/parser/treesitter/parser.go +++ b/internal/parser/treesitter/parser.go @@ -50,6 +50,7 @@ func (p *Parser) ParseFile(_ context.Context, path string) ([]*graph.Node, []*gr } parser := sitter.NewParser() + defer parser.Close() if err := parser.SetLanguage(lang); err != nil { return nil, nil, fmt.Errorf("failed to set language: %w", err) } @@ -58,6 +59,7 @@ func (p *Parser) ParseFile(_ context.Context, path string) ([]*graph.Node, []*gr if tree == nil { return nil, nil, fmt.Errorf("failed to parse %s", path) } + defer tree.Close() var nodes []*graph.Node var edges []*graph.Edge @@ -76,11 +78,11 @@ func (p *Parser) ParseFile(_ context.Context, path string) ([]*graph.Node, []*gr return nodes, edges, nil } - fmt.Printf("Creating query for %s with lang %p and query:\n%s\n", ext, lang, queryStr) query, qErr := sitter.NewQuery(lang, queryStr) if qErr != nil { return nil, nil, fmt.Errorf("failed to create query: %w", qErr) } + defer query.Close() cursor := sitter.NewQueryCursor() captures := cursor.Captures(query, tree.RootNode(), content) From f963f999492e5fe6647ecf8dd483e531a7854f6b Mon Sep 17 00:00:00 2001 From: andev0x Date: Mon, 22 Jun 2026 02:39:00 +0700 Subject: [PATCH 3/6] feat(cli): add yes and all flags - add logic to detect and configure detected AI agent config directories --- internal/cli/commands/mcp.go | 19 +- internal/mcp/install/install.go | 300 +++++++++++++++++++++++++------- 2 files changed, 250 insertions(+), 69 deletions(-) diff --git a/internal/cli/commands/mcp.go b/internal/cli/commands/mcp.go index c32f61b..f4e0f6a 100644 --- a/internal/cli/commands/mcp.go +++ b/internal/cli/commands/mcp.go @@ -57,22 +57,29 @@ var mcpCmd = &cobra.Command{ }, } +var ( + mcpInstallYes bool + mcpInstallAll bool +) + var mcpInstallCmd = &cobra.Command{ Use: "install", Short: "Configure MCP entries for lea and lx across AI tools", Long: `Installs MCP server entries (pizen-lea and pizen-lynx) into the configuration files of supported AI coding agents: Claude Code, Codex CLI, Gemini CLI, Zed, -OpenCode, Antigravity, Aider, KiloCode, VS Code, OpenClaw, Kiro, and Pi.`, +OpenCode, Antigravity, Aider, KiloCode, VS Code, OpenClaw, Kiro, and Pi. + +Only agents whose global/home configuration directory exists on the system are +detected. Use --yes or --all to configure all detected agents without prompting.`, RunE: func(_ *cobra.Command, _ []string) error { - projectDir, err := os.Getwd() - if err != nil { - return err - } - return install.Run(projectDir) + auto := mcpInstallYes || mcpInstallAll + return install.Run(install.Options{AutoSelectAll: auto}) }, } func init() { rootCmd.AddCommand(mcpCmd) mcpCmd.AddCommand(mcpInstallCmd) + mcpInstallCmd.Flags().BoolVarP(&mcpInstallYes, "yes", "y", false, "configure all detected agents without prompting") + mcpInstallCmd.Flags().BoolVar(&mcpInstallAll, "all", false, "configure all detected agents without prompting") } diff --git a/internal/mcp/install/install.go b/internal/mcp/install/install.go index 946816d..a3fe428 100644 --- a/internal/mcp/install/install.go +++ b/internal/mcp/install/install.go @@ -11,10 +11,17 @@ import ( "path/filepath" "runtime" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" "github.com/pelletier/go-toml/v2" "gopkg.in/yaml.v3" ) +// Options controls automated behavior of the install command. +type Options struct { + AutoSelectAll bool +} + // MCPEntry represents a single MCP tool entry in the JSON config schema. type MCPEntry struct { Command string `json:"command" yaml:"cmd" toml:"command"` @@ -23,24 +30,28 @@ type MCPEntry struct { } type target struct { - Name string - Path string // after tilde expansion - Format string // json, yaml, toml + Name string + Path string + Format string + ConfigDir string } -// installTargets returns the full list of MCP configuration targets. -func installTargets(home, projectDir, vscodeUserDir string) []target { +// installTargets returns the full list of MCP configuration targets using +// global/home configuration directories. No project-scoped directories are used. +func installTargets(home, vscodeUserDir string) []target { + zedDir := zedConfigDir(home) return []target{ - {Name: "Claude Code", Path: filepath.Join(projectDir, ".claude", ".mcp.json"), Format: "json"}, - {Name: "Codex CLI", Path: filepath.Join(projectDir, ".codex", "config.toml"), Format: "codex_toml"}, - {Name: "Gemini CLI", Path: filepath.Join(projectDir, ".gemini", "settings.json"), Format: "json"}, - {Name: "Zed", Path: filepath.Join(projectDir, "settings.json"), Format: "zed"}, - {Name: "OpenCode", Path: filepath.Join(projectDir, "opencode.json"), Format: "opencode"}, - {Name: "Antigravity", Path: filepath.Join(home, ".gemini", "config", "mcp_config.json"), Format: "json"}, - {Name: "KiloCode", Path: filepath.Join(projectDir, "mcp_settings.json"), Format: "json"}, - {Name: "VS Code", Path: filepath.Join(vscodeUserDir, "mcp.json"), Format: "json"}, - {Name: "OpenClaw", Path: filepath.Join(projectDir, "openclaw.json"), Format: "json"}, - {Name: "Kiro", Path: filepath.Join(projectDir, ".kiro", "settings", "mcp.json"), Format: "json"}, + {Name: "Claude Code", Path: filepath.Join(home, ".claude", "settings.json"), Format: "json", ConfigDir: filepath.Join(home, ".claude")}, + {Name: "Codex CLI", Path: filepath.Join(home, ".codex", "config.toml"), Format: "codex_toml", ConfigDir: filepath.Join(home, ".codex")}, + {Name: "Gemini CLI", Path: filepath.Join(home, ".gemini", "settings.json"), Format: "json", ConfigDir: filepath.Join(home, ".gemini")}, + {Name: "Zed", Path: filepath.Join(zedDir, "settings.json"), Format: "zed", ConfigDir: zedDir}, + {Name: "OpenCode", Path: filepath.Join(home, ".opencode", "settings.json"), Format: "opencode", ConfigDir: filepath.Join(home, ".opencode")}, + {Name: "Antigravity", Path: filepath.Join(home, ".gemini", "config", "mcp_config.json"), Format: "json", ConfigDir: filepath.Join(home, ".gemini", "config")}, + {Name: "KiloCode", Path: filepath.Join(home, ".kilocode", "settings.json"), Format: "json", ConfigDir: filepath.Join(home, ".kilocode")}, + {Name: "VS Code", Path: filepath.Join(vscodeUserDir, "globalStorage", "mcp.json"), Format: "json", ConfigDir: filepath.Join(vscodeUserDir, "globalStorage")}, + {Name: "OpenClaw", Path: filepath.Join(home, ".openclaw", "config.json"), Format: "json", ConfigDir: filepath.Join(home, ".openclaw")}, + {Name: "Kiro", Path: filepath.Join(home, ".kiro", "settings", "mcp.json"), Format: "json", ConfigDir: filepath.Join(home, ".kiro", "settings")}, + {Name: "System Instructions", Path: filepath.Join(home, ".config", "pizen", "instructions.md"), Format: "instructions", ConfigDir: filepath.Join(home, ".config", "pizen")}, } } @@ -53,21 +64,21 @@ func homeDir() string { return h } -// vscodeGlobalStorageDir returns the VS Code globalStorage path for the current OS. -func vscodeGlobalStorageDir(home string) string { +// zedConfigDir returns the Zed configuration directory for the current OS. +func zedConfigDir(home string) string { switch runtime.GOOS { case "darwin": - return filepath.Join(home, "Library", "Application Support", "Code", "User", "globalStorage") + return filepath.Join(home, "Library", "Application Support", "Zed") case "linux": - return filepath.Join(home, ".config", "Code", "User", "globalStorage") + return filepath.Join(home, ".config", "zed") case "windows": appData := os.Getenv("APPDATA") if appData == "" { appData = filepath.Join(home, "AppData", "Roaming") } - return filepath.Join(appData, "Code", "User", "globalStorage") + return filepath.Join(appData, "Zed", "User") default: - return filepath.Join(home, ".config", "Code", "User", "globalStorage") + return filepath.Join(home, ".config", "zed") } } @@ -89,10 +100,15 @@ func vscodeUserDir(home string) string { } } -// Run configures all MCP targets with pizen-lea and pizen-lynx entries. -// projectDir is the project root for resolving relative (project-scoped) config paths. -func Run(projectDir string) error { - // Resolve lea binary path +// Run configures MCP targets for detected AI coding agents. +// If opts includes AutoSelectAll=true, all detected targets are configured +// without user interaction. Otherwise an interactive multi-select prompt is shown. +func Run(opts ...Options) error { + option := Options{} + if len(opts) > 0 { + option = opts[0] + } + leaPath, err := os.Executable() if err != nil { return fmt.Errorf("cannot resolve lea binary path: %w", err) @@ -110,9 +126,34 @@ func Run(projectDir string) error { } vscodeUserDir := vscodeUserDir(home) - successCount := 0 + allTargets := installTargets(home, vscodeUserDir) + detected := detectTargets(allTargets) + + if len(detected) == 0 { + fmt.Println("No existing AI agent config directories found; nothing to configure.") + return nil + } + + fmt.Printf("Detected %d existing AI agent config directories.\n\n", len(detected)) - for _, t := range installTargets(home, projectDir, vscodeUserDir) { + var selected []target + if option.AutoSelectAll { + selected = detected + fmt.Println("Configuring all detected targets (--yes/--all).") + } else { + selected, err = selectTargets(detected) + if err != nil { + return fmt.Errorf("target selection failed: %w", err) + } + } + + if len(selected) == 0 { + fmt.Println("No MCP targets selected.") + return nil + } + + successCount := 0 + for _, t := range selected { if err := configureTarget(t, leaPath, lxPath); err != nil { log.Printf("[skip] %s: %v", t.Name, err) continue @@ -121,15 +162,173 @@ func Run(projectDir string) error { successCount++ } - if err := generateInstructions(home, projectDir); err != nil { - log.Printf("[skip] instructions: %v", err) + fmt.Printf("\nConfigured %d MCP targets successfully.\n", successCount) + return nil +} + +// detectTargets returns only targets whose global configuration directory exists. +func detectTargets(targets []target) []target { + detected := make([]target, 0, len(targets)) + for _, t := range targets { + info, err := os.Stat(t.ConfigDir) + if err == nil && info.IsDir() { + detected = append(detected, t) + } + } + return detected +} + +// validateConfigDir returns an error if the given path does not exist or is not a directory. +func validateConfigDir(path string) error { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("config directory %q does not exist", path) + } + return fmt.Errorf("cannot stat config directory %q: %w", path, err) + } + if !info.IsDir() { + return fmt.Errorf("config path %q is not a directory", path) } - fmt.Printf(" ✓ System Instructions\n") + return nil +} - fmt.Printf("\nConfigured %d MCP targets successfully.\n", successCount) +// selectionItem implements list.Item for the interactive multi-select prompt. +type selectionItem struct { + target target + selected bool +} + +func (i selectionItem) Title() string { + state := "[ ]" + if i.selected { + state = "[x]" + } + return fmt.Sprintf("%s %s", state, i.target.Name) +} + +func (i selectionItem) Description() string { + return i.target.Path +} + +func (i selectionItem) FilterValue() string { + return i.target.Name +} + +type selectionModel struct { + list list.Model +} + +func newSelectionModel(targets []target) selectionModel { + items := make([]list.Item, len(targets)) + for i, t := range targets { + items[i] = selectionItem{target: t} + } + l := list.New(items, list.NewDefaultDelegate(), 0, 0) + l.Title = "" + l.SetFilteringEnabled(false) + l.SetShowHelp(false) + l.SetShowPagination(false) + return selectionModel{list: l} +} + +func (m selectionModel) Init() tea.Cmd { return nil } +func (m selectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + case "enter": + return m, tea.Quit + case " ", "x": + m.toggle() + return m, nil + case "a": + m.selectAll() + return m, nil + case "i": + m.clearAll() + return m, nil + } + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m *selectionModel) toggle() { + items := m.list.Items() + if len(items) == 0 { + return + } + idx := m.list.GlobalIndex() + if idx < 0 || idx >= len(items) { + return + } + item, ok := items[idx].(selectionItem) + if !ok { + return + } + item.selected = !item.selected + m.list.SetItem(idx, item) +} + +func (m *selectionModel) selectAll() { + for i, item := range m.list.Items() { + it, ok := item.(selectionItem) + if !ok { + continue + } + it.selected = true + m.list.SetItem(i, it) + } +} + +func (m *selectionModel) clearAll() { + for i, item := range m.list.Items() { + it, ok := item.(selectionItem) + if !ok { + continue + } + it.selected = false + m.list.SetItem(i, it) + } +} + +func (m selectionModel) selectedTargets() []target { + var selected []target + for _, item := range m.list.Items() { + it, ok := item.(selectionItem) + if !ok || !it.selected { + continue + } + selected = append(selected, it.target) + } + return selected +} + +func (m selectionModel) View() string { + return fmt.Sprintf("Select targets to configure:\n%s\n\n space/x toggle · a select all · i clear all · enter confirm · ctrl+c cancel", m.list.View()) +} + +// selectTargets presents an interactive multi-selection prompt and returns chosen targets. +func selectTargets(targets []target) ([]target, error) { + p := tea.NewProgram(newSelectionModel(targets), tea.WithAltScreen()) + final, err := p.Run() + if err != nil { + return nil, err + } + model, ok := final.(selectionModel) + if !ok { + return nil, fmt.Errorf("unexpected prompt result type") + } + return model.selectedTargets(), nil +} + // resolveLXFallback tries to find lx via PATH as a fallback. func resolveLXFallback() string { p, err := exec.LookPath("lx") @@ -141,9 +340,8 @@ func resolveLXFallback() string { // configureTarget injects MCP entries into a single target configuration file. func configureTarget(t target, leaPath, lxPath string) error { - parent := filepath.Dir(t.Path) - if err := os.MkdirAll(parent, 0755); err != nil { - return fmt.Errorf("cannot create parent directory %q: %w", parent, err) + if err := validateConfigDir(t.ConfigDir); err != nil { + return err } switch t.Format { @@ -159,6 +357,8 @@ func configureTarget(t target, leaPath, lxPath string) error { return injectTOML(t.Path, leaPath, lxPath) case "codex_toml": return injectCodexTOML(t.Path, leaPath, lxPath) + case "instructions": + return writeInstructions(t.Path) default: return fmt.Errorf("unsupported format: %s", t.Format) } @@ -426,8 +626,8 @@ func writeTOML(path string, v map[string]any) error { return os.WriteFile(path, data, 0644) } -// generateInstructions creates per-agent instruction files in the project directory. -func generateInstructions(home, projectDir string) error { +// writeInstructions writes the global Pizen ecosystem instructions file. +func writeInstructions(path string) error { content := `# PizenLabs Ecosystem — Dual-Tool Orchestration CRITICAL: For code-related queries, ALWAYS run pizen-lynx (via search or resolve) @@ -436,31 +636,5 @@ first to discover the exact Symbol ID. DO NOT guess the code structure. Once the Symbol ID is retrieved, immediately pass it to pizen-lea (via impact, flow, or neighbors) to map structural reasoning and blast radius. ` - - type instructionTarget struct { - path string - wrap func(string) string - } - - targets := []instructionTarget{ - {path: filepath.Join(projectDir, ".codex", "AGENTS.md"), wrap: func(s string) string { return "# Codex CLI — Lea Instructions\n\n" + s + "\n" }}, - {path: filepath.Join(projectDir, ".gemini", "GEMINI.md"), wrap: func(s string) string { return "# Gemini CLI — Lea Instructions\n\n## BeforeTool Hook\nAlways use grep before reading files.\n\n## SessionStart Reminder\n" + s + "\n" }}, - {path: filepath.Join(projectDir, "AGENTS.md"), wrap: func(s string) string { return "# OpenCode — Lea Instructions\n\n" + s + "\n" }}, - {path: filepath.Join(projectDir, "antigravity-cli", "AGENTS.md"), wrap: func(s string) string { return "# Antigravity — Lea Instructions\n\n## SessionStart Reminder\n" + s + "\n" }}, - {path: filepath.Join(projectDir, "AIDER.md"), wrap: func(s string) string { return "# Aider — Lea Instructions\n\n" + s + "\n" }}, - {path: filepath.Join(home, ".kilocode", "rules", "lea.md"), wrap: func(s string) string { return "# KiloCode — Lea Instructions\n\n" + s + "\n" }}, - {path: filepath.Join(projectDir, ".pi", "AGENTS.md"), wrap: func(s string) string { return "# Pi — Lea Instructions\n\n## SessionStart Reminder\n" + s + "\n" }}, - } - - for _, t := range targets { - dir := filepath.Dir(t.path) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("cannot create directory %q: %w", dir, err) - } - if err := os.WriteFile(t.path, []byte(t.wrap(content)), 0644); err != nil { - return fmt.Errorf("cannot write %q: %w", t.path, err) - } - } - - return nil + return os.WriteFile(path, []byte(content), 0644) } From ee08140dd76d15da61289ba65075911c1cab7c7f Mon Sep 17 00:00:00 2001 From: andev0x Date: Mon, 22 Jun 2026 12:51:29 +0700 Subject: [PATCH 4/6] feat(install): handle window size changes - add height calculation for list - ensure minimum height constraint --- internal/mcp/install/install.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/mcp/install/install.go b/internal/mcp/install/install.go index a3fe428..f2c6576 100644 --- a/internal/mcp/install/install.go +++ b/internal/mcp/install/install.go @@ -254,6 +254,12 @@ func (m selectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.clearAll() return m, nil } + case tea.WindowSizeMsg: + h := msg.Height - 4 + if h < 0 { + h = 0 + } + m.list.SetSize(msg.Width, h) } var cmd tea.Cmd m.list, cmd = m.list.Update(msg) From d3867077a3092b936f9e168a3dae0664d6c73285 Mon Sep 17 00:00:00 2001 From: andev0x Date: Mon, 22 Jun 2026 15:42:32 +0700 Subject: [PATCH 5/6] feat(mcp): add graceful shutdown handling - introduce signal monitoring for SIGTERM and INT - clean up store connections on shutdown - block main goroutine to wait for signals --- internal/mcp/server.go | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 91159bc..1d30100 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -4,9 +4,12 @@ package mcp import ( "context" "fmt" + "os" + "os/signal" "path/filepath" "sort" "strings" + "syscall" aictx "github.com/PizenLabs/lea/internal/ai/context" "github.com/PizenLabs/lea/internal/architecture" @@ -85,9 +88,21 @@ func (s *Server) Start() error { return err } - // mcp-golang v0.16.1 Serve() is non-blocking — the read-loop goroutine - // has already started. Block forever so the process stays alive until - // opencode kills the subprocess. + // Set up monitored signal channel for graceful shutdown. + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGTERM, os.Interrupt) + + // Launch background goroutine to intercept the signal. + go func() { + <-sigChan + // Cleanly close the store connections. + if s.store != nil { + _ = s.store.Close() + } + os.Exit(0) + }() + + // Block until the process receives a termination signal. select {} } From 6589442bc9f0006783532af91f4585e1f5e830c6 Mon Sep 17 00:00:00 2001 From: andev0x Date: Mon, 22 Jun 2026 16:41:46 +0700 Subject: [PATCH 6/6] feat(commands): add hook command for AI coding - introduce pre-tool subcommand to intercept and validate tool calls - parse JSON input from stdin - ensure pizen-lea tools are validated against known symbols - add test case for invalid symbol scenario --- internal/cli/commands/hook.go | 140 ++++++++++++++++ internal/cli/commands/hook_test.go | 48 ++++++ internal/mcp/install/install.go | 259 ++++++++++++++++++++++++++--- 3 files changed, 426 insertions(+), 21 deletions(-) create mode 100644 internal/cli/commands/hook.go create mode 100644 internal/cli/commands/hook_test.go diff --git a/internal/cli/commands/hook.go b/internal/cli/commands/hook.go new file mode 100644 index 0000000..f0a5b37 --- /dev/null +++ b/internal/cli/commands/hook.go @@ -0,0 +1,140 @@ +package commands + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/PizenLabs/lea/internal/storage/sqlite" + "github.com/spf13/cobra" +) + +// HookInput represents the JSON payload passed to PreToolUse hooks by Claude Code and other agents. +type HookInput struct { + ToolName string `json:"tool_name"` + ToolNameCamel string `json:"toolName"` + ToolInput map[string]any `json:"tool_input"` + ToolInputCamel map[string]any `json:"toolInput"` +} + +var hookCmd = &cobra.Command{ + Use: "hook", + Short: "Run tool execution hooks for AI coding agents", + Long: `The hook command intercepts and validates agent tool calls in the lifecycle.`, +} + +var hookPreToolCmd = &cobra.Command{ + Use: "pre-tool", + Short: "Execute pre-tool lifecycle checks", + Run: func(_ *cobra.Command, _ []string) { + // Read hook input from stdin + stdinData, err := io.ReadAll(os.Stdin) + if err != nil { + // Fail open on error reading stdin to avoid blocking developer flow + os.Exit(0) + } + + if len(stdinData) == 0 { + os.Exit(0) + } + + var input HookInput + if err := json.Unmarshal(stdinData, &input); err != nil { + // Fail open on invalid JSON + os.Exit(0) + } + + toolName := input.ToolName + if toolName == "" { + toolName = input.ToolNameCamel + } + + // Clean up toolName + toolName = strings.TrimSpace(toolName) + if toolName == "" { + os.Exit(0) + } + + // Check if it's a pizen-lea tool. + // pizen-lea tools typically include: impact, flow, neighbors, violations, symbols + // They can be named like: "pizen-lea__impact", "mcp__pizen-lea__impact", "pizen-lea/impact", "impact" + isLeaTool := false + lowerName := strings.ToLower(toolName) + if strings.Contains(lowerName, "pizen-lea") { + isLeaTool = true + } else { + // Fallback: check if the tool name matches one of our MCP tools + leaTools := []string{"impact", "flow", "neighbors", "violations", "symbols"} + for _, t := range leaTools { + if lowerName == t || strings.HasSuffix(lowerName, "__"+t) || strings.HasSuffix(lowerName, "/"+t) { + isLeaTool = true + break + } + } + } + + if !isLeaTool { + os.Exit(0) + } + + // It's a pizen-lea tool. Get tool input. + toolInput := input.ToolInput + if len(toolInput) == 0 { + toolInput = input.ToolInputCamel + } + + // Try to extract the symbol name. + // The tool arguments might be: symbol, symbol_id, target, name, qualified_name + var symbolName string + symbolKeys := []string{"symbol", "symbol_id", "target", "name", "qualified_name", "symbolName", "symbolId"} + for _, k := range symbolKeys { + if val, ok := toolInput[k]; ok { + if s, ok := val.(string); ok && s != "" { + symbolName = s + break + } + } + } + + if symbolName == "" { + // If no symbol name is passed, let it pass to standard validation/execution + os.Exit(0) + } + + // Find .lea directory starting from current working directory + leaDir, err := findLeaDir(".") + if err != nil { + // If .lea dir doesn't exist, we can't validate, so fail open + os.Exit(0) + } + + dbPath := filepath.Join(leaDir, "graph.db") + store, err := sqlite.NewStore(dbPath) + if err != nil { + os.Exit(0) + } + defer func() { _ = store.Close() }() + + ctx := context.Background() + _, err = resolveSymbolID(ctx, store, symbolName) + if err != nil { + // Symbol resolution failed! Block the tool execution (exit code 2) + // and print the warning message to stderr. + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + fmt.Fprintln(os.Stderr, "CRITICAL: You MUST first search for the symbol using pizen-lynx (via search or resolve) to find the correct Symbol ID before calling pizen-lea tools.") + os.Exit(2) + } + + // Success, allow tool execution + os.Exit(0) + }, +} + +func init() { + rootCmd.AddCommand(hookCmd) + hookCmd.AddCommand(hookPreToolCmd) +} diff --git a/internal/cli/commands/hook_test.go b/internal/cli/commands/hook_test.go new file mode 100644 index 0000000..c7d4601 --- /dev/null +++ b/internal/cli/commands/hook_test.go @@ -0,0 +1,48 @@ +package commands + +import ( + "bytes" + "io" + "os" + "testing" +) + +// Mockable os.Exit for testing +var osExitOriginal = os.Exit +var osExit = os.Exit + +func TestHookPreToolInvalidSymbol(t *testing.T) { + // Prepare JSON input with a non-existent symbol + jsonInput := []byte(`{"tool_name":"pizen-lea__impact","tool_input":{"symbol":"nonexistent_symbol"}}`) + // Replace stdin with pipe containing JSON input + oldStdin := os.Stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe error: %v", err) + } + _, err = w.Write(jsonInput) + if err != nil { + t.Fatalf("write error: %v", err) + } + w.Close() + os.Stdin = r + // Capture exit code via mocking os.Exit + exitCode := 0 + osExit = func(code int) { + exitCode = code + panic("os.Exit called") + } + defer func() { + // Restore globals + os.Stdin = oldStdin + os.Exit = osExitOriginal + if r := recover(); r != nil { + // expected panic from os.Exit + } + if exitCode != 2 { + t.Fatalf("expected exit code 2, got %d", exitCode) + } + }() + // Run the pre-tool hook command + hookPreToolCmd.Run(nil, []string{}) +} diff --git a/internal/mcp/install/install.go b/internal/mcp/install/install.go index f2c6576..9ddb7af 100644 --- a/internal/mcp/install/install.go +++ b/internal/mcp/install/install.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" @@ -30,10 +31,11 @@ type MCPEntry struct { } type target struct { - Name string - Path string - Format string - ConfigDir string + Name string + Path string + Format string + ConfigDir string + InstructionFile string } // installTargets returns the full list of MCP configuration targets using @@ -41,16 +43,18 @@ type target struct { func installTargets(home, vscodeUserDir string) []target { zedDir := zedConfigDir(home) return []target{ - {Name: "Claude Code", Path: filepath.Join(home, ".claude", "settings.json"), Format: "json", ConfigDir: filepath.Join(home, ".claude")}, - {Name: "Codex CLI", Path: filepath.Join(home, ".codex", "config.toml"), Format: "codex_toml", ConfigDir: filepath.Join(home, ".codex")}, - {Name: "Gemini CLI", Path: filepath.Join(home, ".gemini", "settings.json"), Format: "json", ConfigDir: filepath.Join(home, ".gemini")}, - {Name: "Zed", Path: filepath.Join(zedDir, "settings.json"), Format: "zed", ConfigDir: zedDir}, - {Name: "OpenCode", Path: filepath.Join(home, ".opencode", "settings.json"), Format: "opencode", ConfigDir: filepath.Join(home, ".opencode")}, - {Name: "Antigravity", Path: filepath.Join(home, ".gemini", "config", "mcp_config.json"), Format: "json", ConfigDir: filepath.Join(home, ".gemini", "config")}, - {Name: "KiloCode", Path: filepath.Join(home, ".kilocode", "settings.json"), Format: "json", ConfigDir: filepath.Join(home, ".kilocode")}, - {Name: "VS Code", Path: filepath.Join(vscodeUserDir, "globalStorage", "mcp.json"), Format: "json", ConfigDir: filepath.Join(vscodeUserDir, "globalStorage")}, - {Name: "OpenClaw", Path: filepath.Join(home, ".openclaw", "config.json"), Format: "json", ConfigDir: filepath.Join(home, ".openclaw")}, - {Name: "Kiro", Path: filepath.Join(home, ".kiro", "settings", "mcp.json"), Format: "json", ConfigDir: filepath.Join(home, ".kiro", "settings")}, + {Name: "Claude Code", Path: filepath.Join(home, ".claude", "settings.json"), Format: "json", ConfigDir: filepath.Join(home, ".claude"), InstructionFile: "CLAUDE.md"}, + {Name: "Codex CLI", Path: filepath.Join(home, ".codex", "config.toml"), Format: "codex_toml", ConfigDir: filepath.Join(home, ".codex"), InstructionFile: "AGENTS.md"}, + {Name: "Pi Coding Agents", Path: filepath.Join(home, ".pi", "agent", "mcp.json"), Format: "json", ConfigDir: filepath.Join(home, ".pi", "agent"), InstructionFile: "AGENTS.md"}, + {Name: "Gemini CLI", Path: filepath.Join(home, ".gemini", "settings.json"), Format: "json", ConfigDir: filepath.Join(home, ".gemini"), InstructionFile: "GEMINI.md"}, + {Name: "Zed", Path: filepath.Join(zedDir, "settings.json"), Format: "zed", ConfigDir: zedDir, InstructionFile: "AGENTS.md"}, + {Name: "OpenCode", Path: filepath.Join(home, ".opencode", "settings.json"), Format: "opencode", ConfigDir: filepath.Join(home, ".opencode"), InstructionFile: "AGENTS.md"}, + {Name: "Antigravity", Path: filepath.Join(home, ".gemini", "config", "mcp_config.json"), Format: "json", ConfigDir: filepath.Join(home, ".gemini", "config"), InstructionFile: "AGENTS.md"}, + {Name: "Aider", Path: filepath.Join(home, ".aider.conf.yml"), Format: "yaml", ConfigDir: filepath.Join(home, ".aider"), InstructionFile: "AIDER.md"}, + {Name: "KiloCode", Path: filepath.Join(home, ".kilocode", "settings.json"), Format: "json", ConfigDir: filepath.Join(home, ".kilocode"), InstructionFile: "AGENTS.md"}, + {Name: "VS Code", Path: filepath.Join(vscodeUserDir, "globalStorage", "mcp.json"), Format: "json", ConfigDir: filepath.Join(vscodeUserDir, "globalStorage"), InstructionFile: "instructions.md"}, + {Name: "OpenClaw", Path: filepath.Join(home, ".openclaw", "config.json"), Format: "json", ConfigDir: filepath.Join(home, ".openclaw"), InstructionFile: "AGENTS.md"}, + {Name: "Kiro", Path: filepath.Join(home, ".kiro", "settings", "mcp.json"), Format: "json", ConfigDir: filepath.Join(home, ".kiro", "settings"), InstructionFile: "AGENTS.md"}, {Name: "System Instructions", Path: filepath.Join(home, ".config", "pizen", "instructions.md"), Format: "instructions", ConfigDir: filepath.Join(home, ".config", "pizen")}, } } @@ -350,24 +354,37 @@ func configureTarget(t target, leaPath, lxPath string) error { return err } + var err error switch t.Format { case "json": - return injectJSON(t.Path, leaPath, lxPath) + err = injectJSON(t.Path, leaPath, lxPath) case "opencode": - return injectOpenCodeJSON(t.Path, leaPath, lxPath) + err = injectOpenCodeJSON(t.Path, leaPath, lxPath) case "zed": - return injectZedJSON(t.Path, leaPath, lxPath) + err = injectZedJSON(t.Path, leaPath, lxPath) case "yaml": - return injectYAML(t.Path, leaPath, lxPath) + err = injectYAML(t.Path, leaPath, lxPath) case "toml": - return injectTOML(t.Path, leaPath, lxPath) + err = injectTOML(t.Path, leaPath, lxPath) case "codex_toml": - return injectCodexTOML(t.Path, leaPath, lxPath) + err = injectCodexTOML(t.Path, leaPath, lxPath) case "instructions": - return writeInstructions(t.Path) + err = writeInstructions(t.Path) default: return fmt.Errorf("unsupported format: %s", t.Format) } + + if err != nil { + return err + } + + if t.InstructionFile != "" { + if err := writeInstructionsFile(t); err != nil { + log.Printf("[warning] failed to write instructions for %s: %v", t.Name, err) + } + } + + return nil } // injectJSON reads or creates a JSON file and injects pizen entries under mcpServers. @@ -398,6 +415,8 @@ func injectJSON(path, leaPath, lxPath string) error { servers["pizen-lynx"] = lxEntry raw["mcpServers"] = servers + injectHooksJSON(raw, leaPath) + return writeJSON(path, raw) } @@ -428,6 +447,8 @@ func injectZedJSON(path, leaPath, lxPath string) error { mcp["pizen-lynx"] = MCPEntry{Command: lxPath, Args: []string{"mcp"}, Env: env} raw["mcp"] = mcp + injectHooksJSON(raw, leaPath) + return writeJSON(path, raw) } @@ -462,6 +483,8 @@ func injectOpenCodeJSON(path, leaPath, lxPath string) error { } raw["mcp"] = mcp + injectHooksJSON(raw, leaPath) + return writeJSON(path, raw) } @@ -493,6 +516,8 @@ func injectYAML(path, leaPath, lxPath string) error { mcpList = upsertYAMLList(mcpList, "pizen-lynx", lxPath) raw["mcp"] = mcpList + injectHooksYAML(raw, leaPath) + return writeYAML(path, raw) } @@ -541,6 +566,8 @@ func injectTOML(path, leaPath, lxPath string) error { tools = upsertTOMLTool(tools, "pizen-lynx", lxPath, []string{"mcp"}) raw["external_tools"] = tools + injectHooksTOML(raw, leaPath) + return writeTOML(path, raw) } @@ -593,6 +620,8 @@ func injectCodexTOML(path, leaPath, lxPath string) error { servers["pizen-lynx"] = MCPEntry{Command: lxPath, Args: []string{"mcp"}, Env: env} raw["mcpServers"] = servers + injectHooksTOML(raw, leaPath) + return writeTOML(path, raw) } @@ -644,3 +673,191 @@ flow, or neighbors) to map structural reasoning and blast radius. ` return os.WriteFile(path, []byte(content), 0644) } + +func writeInstructionsFile(t target) error { + if t.InstructionFile == "" { + return nil + } + // Make sure config directory exists + if err := os.MkdirAll(t.ConfigDir, 0755); err != nil { + return err + } + path := filepath.Join(t.ConfigDir, t.InstructionFile) + content := `# PizenLabs Ecosystem — Dual-Tool Orchestration + +CRITICAL: For code-related queries, ALWAYS run pizen-lynx (via search or resolve) +first to discover the exact Symbol ID. DO NOT guess the code structure. + +Once the Symbol ID is retrieved, immediately pass it to pizen-lea (via impact, +flow, or neighbors) to map structural reasoning and blast radius. +` + return os.WriteFile(path, []byte(content), 0644) +} + +func injectHooksJSON(raw map[string]any, leaPath string) { + hooks, ok := raw["hooks"].(map[string]any) + if !ok || hooks == nil { + hooks = make(map[string]any) + } + + preToolUse, ok := hooks["PreToolUse"].([]any) + if !ok { + preToolUse = []any{} + } + + hookCmd := leaPath + " hook pre-tool" + found := false + for _, item := range preToolUse { + entry, ok := item.(map[string]any) + if !ok { + continue + } + matcher, _ := entry["matcher"].(string) + if matcher == "*" { + subHooks, ok := entry["hooks"].([]any) + if ok { + for _, sh := range subHooks { + shMap, ok := sh.(map[string]any) + if ok { + cmd, _ := shMap["command"].(string) + if strings.Contains(cmd, "lea hook") { + shMap["command"] = hookCmd + found = true + break + } + } + } + } + } + } + + if !found { + newHook := map[string]any{ + "matcher": "*", + "hooks": []any{ + map[string]any{ + "type": "command", + "command": hookCmd, + }, + }, + } + preToolUse = append(preToolUse, newHook) + } + + hooks["PreToolUse"] = preToolUse + raw["hooks"] = hooks +} + +func injectHooksTOML(raw map[string]any, leaPath string) { + hookCmd := leaPath + " hook pre-tool" + hooks, ok := raw["hooks"].(map[string]any) + if !ok || hooks == nil { + hooks = make(map[string]any) + } + + preToolUse, ok := hooks["PreToolUse"].([]any) + if !ok { + preToolUse = []any{} + } + + found := false + for i, item := range preToolUse { + entry, ok := item.(map[string]any) + if !ok { + continue + } + matcher, _ := entry["matcher"].(string) + if matcher == "*" { + subHooks, ok := entry["hooks"].([]any) + if ok { + for j, sh := range subHooks { + shMap, ok := sh.(map[string]any) + if ok { + cmd, _ := shMap["command"].(string) + if strings.Contains(cmd, "lea hook") { + shMap["command"] = hookCmd + subHooks[j] = shMap + found = true + break + } + } + } + entry["hooks"] = subHooks + preToolUse[i] = entry + } + } + } + + if !found { + newHook := map[string]any{ + "matcher": "*", + "hooks": []any{ + map[string]any{ + "type": "command", + "command": hookCmd, + }, + }, + } + preToolUse = append(preToolUse, newHook) + } + + hooks["PreToolUse"] = preToolUse + raw["hooks"] = hooks +} + +func injectHooksYAML(raw map[string]any, leaPath string) { + hookCmd := leaPath + " hook pre-tool" + hooks, ok := raw["hooks"].(map[string]any) + if !ok || hooks == nil { + hooks = make(map[string]any) + } + + preToolUse, ok := hooks["PreToolUse"].([]any) + if !ok { + preToolUse = []any{} + } + + found := false + for i, item := range preToolUse { + entry, ok := item.(map[string]any) + if !ok { + continue + } + matcher, _ := entry["matcher"].(string) + if matcher == "*" { + subHooks, ok := entry["hooks"].([]any) + if ok { + for j, sh := range subHooks { + shMap, ok := sh.(map[string]any) + if ok { + cmd, _ := shMap["command"].(string) + if strings.Contains(cmd, "lea hook") { + shMap["command"] = hookCmd + subHooks[j] = shMap + found = true + break + } + } + } + entry["hooks"] = subHooks + preToolUse[i] = entry + } + } + } + + if !found { + newHook := map[string]any{ + "matcher": "*", + "hooks": []any{ + map[string]any{ + "type": "command", + "command": hookCmd, + }, + }, + } + preToolUse = append(preToolUse, newHook) + } + + hooks["PreToolUse"] = preToolUse + raw["hooks"] = hooks +}