From a4ceac86b28b378d716fd13d3bcae2a6e4882963 Mon Sep 17 00:00:00 2001 From: Mayank Jain Date: Tue, 2 Jun 2026 22:31:24 +0530 Subject: [PATCH 1/3] docs: update reddit-post install instructions and timing context --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 080f22e1..8e7c883b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Nixis - AI Agent Firewall +# Nixis [![CI](https://github.com/mayankjain0141/nixis/actions/workflows/ci.yml/badge.svg)](https://github.com/mayankjain0141/nixis/actions/workflows/ci.yml) [![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white)](https://go.dev) From 0d4a1b23a8fe7b29be7021412f9ef89ef12a7652 Mon Sep 17 00:00:00 2001 From: Mayank Jain Date: Wed, 3 Jun 2026 02:01:45 +0530 Subject: [PATCH 2/3] feat(ws-adapters): add hermes and opencode hook adapters - HermesAdapter: detects hermes payloads by cwd+hook_event_name presence, returns {} on allow and {"decision":"block","reason":"..."} on deny, always exit 0 (hermes reads JSON body, not exit code) - init() in adapter_hermes.go prepends HermesAdapter before ClaudeCodeAdapter so the more-specific format is detected first (both carry hook_event_name) - integrations/hermes/plugin.yaml: hermes plugin manifest - integrations/hermes/__init__.py: stdlib-only Python plugin calling HTTP /v1/check - integrations/opencode/package.json: npm package manifest - integrations/opencode/src/index.ts: TypeScript plugin with onToolCalled hook --- cmd/nixis-hook/adapter_hermes.go | 97 +++++++++++++ cmd/nixis-hook/adapter_hermes_test.go | 189 ++++++++++++++++++++++++++ integrations/hermes/__init__.py | 116 ++++++++++++++++ integrations/hermes/plugin.yaml | 9 ++ integrations/opencode/package.json | 10 ++ integrations/opencode/src/index.ts | 140 +++++++++++++++++++ 6 files changed, 561 insertions(+) create mode 100644 cmd/nixis-hook/adapter_hermes.go create mode 100644 cmd/nixis-hook/adapter_hermes_test.go create mode 100644 integrations/hermes/__init__.py create mode 100644 integrations/hermes/plugin.yaml create mode 100644 integrations/opencode/package.json create mode 100644 integrations/opencode/src/index.ts diff --git a/cmd/nixis-hook/adapter_hermes.go b/cmd/nixis-hook/adapter_hermes.go new file mode 100644 index 00000000..10ca35c3 --- /dev/null +++ b/cmd/nixis-hook/adapter_hermes.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/mayankjain0141/nixis/pkg/nixis" +) + +// HermesAdapter handles the hermes-agent shell hook protocol. +// Detection: presence of both "hook_event_name" and "cwd" fields. +// Hermes always receives exit code 0; the decision is conveyed via the JSON body. +type HermesAdapter struct{} + +// hermesInput is the JSON shape sent by hermes-agent shell hooks. +type hermesInput struct { + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + ToolCallID string `json:"tool_call_id"` + Cwd string `json:"cwd"` +} + +// hermesBlockOutput is the body written when nixis blocks a tool call. +type hermesBlockOutput struct { + Decision string `json:"decision"` + Reason string `json:"reason"` +} + +func init() { + // Prepend HermesAdapter so it is evaluated before ClaudeCodeAdapter. + // Both formats carry "hook_event_name"; hermes is distinguished by also + // carrying "cwd". First-match-wins requires hermes to come first. + adapters = append([]IDEAdapter{&HermesAdapter{}}, adapters...) +} + +func (a *HermesAdapter) Name() string { return "hermes" } + +func (a *HermesAdapter) Detect(raw json.RawMessage) bool { + var probe struct { + HookEventName string `json:"hook_event_name"` + Cwd string `json:"cwd"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return false + } + // Hermes payloads carry both hook_event_name and cwd. + // Claude Code payloads carry hook_event_name but not cwd. + return probe.HookEventName != "" && probe.Cwd != "" +} + +func (a *HermesAdapter) ParseInput(raw json.RawMessage) (nixis.CheckRequest, error) { + var inp hermesInput + if err := json.Unmarshal(raw, &inp); err != nil { + return nixis.CheckRequest{}, fmt.Errorf("parse hermes input: %w", err) + } + args := inp.ToolInput + if len(args) == 0 || string(args) == "null" { + args = json.RawMessage("{}") + } + return nixis.CheckRequest{ + Tool: inp.ToolName, + Args: args, + SessionID: inp.SessionID, + SpawnToken: os.Getenv("NIXIS_SPAWN_TOKEN"), + ParentSessionID: os.Getenv("NIXIS_PARENT_SESSION_ID"), + ProjectRoot: os.Getenv("NIXIS_PROJECT_ROOT"), + }, nil +} + +func (a *HermesAdapter) FormatOutput(resp nixis.CheckResponse, _ json.RawMessage) ([]byte, int) { + switch resp.Decision.Action { + case nixis.ActionDeny: + out := hermesBlockOutput{ + Decision: "block", + Reason: resp.Decision.Reason, + } + b, err := json.Marshal(out) + if err != nil { + return []byte(`{"decision":"block","reason":"policy violation"}` + "\n"), 0 + } + return append(b, '\n'), 0 + default: + // ActionAllow, ActionLog/ActionAudit, ActionRequireApproval — all allow; hermes + // reads a non-empty "decision" field to block. Empty object = allow. + return []byte("{}\n"), 0 + } +} + +func (a *HermesAdapter) FormatFailOpen(_ string, _ json.RawMessage) ([]byte, int) { + // Fail-open: daemon unreachable → do not block. + return []byte("{}\n"), 0 +} diff --git a/cmd/nixis-hook/adapter_hermes_test.go b/cmd/nixis-hook/adapter_hermes_test.go new file mode 100644 index 00000000..e2bdf0cc --- /dev/null +++ b/cmd/nixis-hook/adapter_hermes_test.go @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: MIT +package main + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/mayankjain0141/nixis/pkg/nixis" +) + +func TestHermesAdapter_Detect_True(t *testing.T) { + raw := json.RawMessage(`{ + "hook_event_name": "pre_tool_call", + "tool_name": "terminal", + "tool_input": {"command": "ls"}, + "session_id": "sess-123", + "cwd": "/home/user" + }`) + + a := &HermesAdapter{} + if !a.Detect(raw) { + t.Error("Detect() = false, want true for payload with hook_event_name and cwd") + } +} + +func TestHermesAdapter_Detect_False_NoCwd(t *testing.T) { + // Payload has hook_event_name but no cwd — this is Claude Code, not Hermes. + raw := json.RawMessage(`{ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "ls"}, + "session_id": "sess-cc-001" + }`) + + a := &HermesAdapter{} + if a.Detect(raw) { + t.Error("Detect() = true, want false for payload with hook_event_name but no cwd") + } +} + +func TestHermesAdapter_Detect_False_NoHookEvent(t *testing.T) { + // Payload has cwd but no hook_event_name — generic/unknown, not hermes. + raw := json.RawMessage(`{ + "tool_name": "terminal", + "cwd": "/home/user" + }`) + + a := &HermesAdapter{} + if a.Detect(raw) { + t.Error("Detect() = true, want false for payload missing hook_event_name") + } +} + +func TestHermesAdapter_ParseInput(t *testing.T) { + raw := json.RawMessage(`{ + "hook_event_name": "pre_tool_call", + "tool_name": "terminal", + "tool_input": {"command": "rm -rf /"}, + "session_id": "sess-123", + "task_id": "task-456", + "tool_call_id": "call-789", + "cwd": "/home/user" + }`) + + a := &HermesAdapter{} + req, err := a.ParseInput(raw) + if err != nil { + t.Fatalf("ParseInput() error = %v", err) + } + + if req.Tool != "terminal" { + t.Errorf("Tool = %q, want %q", req.Tool, "terminal") + } + if req.SessionID != "sess-123" { + t.Errorf("SessionID = %q, want %q", req.SessionID, "sess-123") + } + + // Args must be a JSON object containing "command". + var args map[string]string + if err := json.Unmarshal(req.Args, &args); err != nil { + t.Fatalf("Args is not valid JSON object: %v", err) + } + if args["command"] != "rm -rf /" { + t.Errorf("Args.command = %q, want %q", args["command"], "rm -rf /") + } +} + +func TestHermesAdapter_ParseInput_NullToolInput(t *testing.T) { + // When tool_input is absent or null, Args should fall back to "{}". + raw := json.RawMessage(`{ + "hook_event_name": "pre_tool_call", + "tool_name": "terminal", + "session_id": "sess-999", + "cwd": "/tmp" + }`) + + a := &HermesAdapter{} + req, err := a.ParseInput(raw) + if err != nil { + t.Fatalf("ParseInput() error = %v", err) + } + if string(req.Args) != "{}" { + t.Errorf("Args = %s, want {}", req.Args) + } +} + +func TestHermesAdapter_FormatOutput_Allow(t *testing.T) { + resp := nixis.CheckResponse{} + resp.Decision.Action = nixis.ActionAllow + + a := &HermesAdapter{} + out, exitCode := a.FormatOutput(resp, nil) + + if exitCode != 0 { + t.Errorf("exitCode = %d, want 0", exitCode) + } + // Allow response must be empty JSON object (possibly with trailing newline). + trimmed := bytes.TrimSpace(out) + if string(trimmed) != "{}" { + t.Errorf("FormatOutput allow = %q, want {}", string(trimmed)) + } +} + +func TestHermesAdapter_FormatOutput_Deny(t *testing.T) { + resp := nixis.CheckResponse{} + resp.Decision.Action = nixis.ActionDeny + resp.Decision.Reason = "Policy violation: destructive command" + + a := &HermesAdapter{} + out, exitCode := a.FormatOutput(resp, nil) + + if exitCode != 0 { + t.Errorf("exitCode = %d, want 0 (hermes reads JSON, not exit code)", exitCode) + } + + var m map[string]string + if err := json.Unmarshal(bytes.TrimSpace(out), &m); err != nil { + t.Fatalf("output is not valid JSON: %v (got: %s)", err, out) + } + if m["decision"] != "block" { + t.Errorf("decision = %q, want %q", m["decision"], "block") + } + if m["reason"] != "Policy violation: destructive command" { + t.Errorf("reason = %q, want %q", m["reason"], "Policy violation: destructive command") + } +} + +func TestHermesAdapter_FormatOutput_Audit(t *testing.T) { + // ActionAudit (ActionLog) should not block — returns "{}". + resp := nixis.CheckResponse{} + resp.Decision.Action = nixis.ActionAudit + + a := &HermesAdapter{} + out, exitCode := a.FormatOutput(resp, nil) + + if exitCode != 0 { + t.Errorf("exitCode = %d, want 0", exitCode) + } + trimmed := bytes.TrimSpace(out) + if string(trimmed) != "{}" { + t.Errorf("FormatOutput audit = %q, want {}", string(trimmed)) + } +} + +func TestHermesAdapter_FormatFailOpen(t *testing.T) { + a := &HermesAdapter{} + out, exitCode := a.FormatFailOpen("daemon_unreachable", nil) + + if exitCode != 0 { + t.Errorf("exitCode = %d, want 0", exitCode) + } + trimmed := bytes.TrimSpace(out) + if string(trimmed) != "{}" { + t.Errorf("FormatFailOpen = %q, want {}", string(trimmed)) + } +} + +func TestHermesAdapter_InitRegistered(t *testing.T) { + // Verify that the init() function registered HermesAdapter as the first entry + // in the global adapters slice, before ClaudeCodeAdapter. + if len(adapters) == 0 { + t.Fatal("adapters slice is empty") + } + first, ok := adapters[0].(*HermesAdapter) + if !ok || first == nil { + t.Errorf("adapters[0] = %T, want *HermesAdapter", adapters[0]) + } +} diff --git a/integrations/hermes/__init__.py b/integrations/hermes/__init__.py new file mode 100644 index 00000000..dcc28b04 --- /dev/null +++ b/integrations/hermes/__init__.py @@ -0,0 +1,116 @@ +"""nixis hermes plugin — governance hook for hermes-agent. + +Wires nixis-daemon into hermes via its hook protocol. The plugin calls the +nixis HTTP API at http://127.0.0.1:9091/v1/check for each pre_tool_call event. +If the daemon is unreachable the hook fails open (does not block). + +Socket path priority (for reference — HTTP is used here for simplicity): + 1. $NIXIS_SOCKET_PATH + 2. $XDG_RUNTIME_DIR/nixis/nixis.sock + 3. /tmp/nixis.sock + +HTTP endpoints used: + GET http://127.0.0.1:9091/healthz — liveness check + POST http://127.0.0.1:9091/v1/check — tool-call classification +""" + +import json +import os +import urllib.error +import urllib.request +from typing import Any + +_NIXIS_HTTP_BASE = "http://127.0.0.1:9091" +_CHECK_URL = _NIXIS_HTTP_BASE + "/v1/check" +_TIMEOUT_S = 0.2 # 200 ms — matches nixis-hook total budget + + +def _post_check(tool_name: str, args: Any, session_id: str) -> dict: + """Call POST /v1/check and return the parsed response dict. + + Returns an empty dict on any error so the caller can fail open. + """ + payload = json.dumps( + { + "tool": tool_name, + "args": args if args is not None else {}, + "session_id": session_id, + } + ).encode() + + req = urllib.request.Request( + _CHECK_URL, + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as resp: + return json.loads(resp.read()) + except (urllib.error.URLError, OSError, json.JSONDecodeError): + # Daemon unreachable or response unreadable — fail open. + return {} + + +def pre_tool_call( + tool_name: str, + args: Any = None, + session_id: str = "", + **kwargs: Any, +) -> dict | None: + """Hook invoked before every tool call. + + Returns a block decision dict when nixis denies the call, otherwise + returns None to allow hermes to proceed. + """ + resp = _post_check(tool_name, args, session_id) + + decision = resp.get("decision", {}) + action = decision.get("action", "allow") + + if action == "deny": + reason = decision.get("reason", "nixis policy violation") + return {"decision": "block", "reason": reason} + + # allow / audit / require_approval all let the tool run. + return None + + +def post_tool_call( + tool_name: str, + args: Any = None, + result: Any = None, + session_id: str = "", + **kwargs: Any, +) -> None: + """Hook invoked after every tool call — fire-and-forget audit log. + + Does not block; any error is silently discarded. + """ + payload = json.dumps( + { + "tool": tool_name, + "args": args if args is not None else {}, + "session_id": session_id, + "event": "post_tool_call", + } + ).encode() + + req = urllib.request.Request( + _NIXIS_HTTP_BASE + "/v1/audit", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + urllib.request.urlopen(req, timeout=_TIMEOUT_S) + except (urllib.error.URLError, OSError): + pass + + +def on_session_start(session_id: str = "", **kwargs: Any) -> None: + """Hook invoked at session start — placeholder for future telemetry.""" + + +def on_session_end(session_id: str = "", **kwargs: Any) -> None: + """Hook invoked at session end — placeholder for future telemetry.""" diff --git a/integrations/hermes/plugin.yaml b/integrations/hermes/plugin.yaml new file mode 100644 index 00000000..a8924a33 --- /dev/null +++ b/integrations/hermes/plugin.yaml @@ -0,0 +1,9 @@ +name: nixis +version: 1.0.0 +description: Governance daemon for tool-call classification and policy enforcement +kind: standalone +provides_hooks: + - pre_tool_call + - post_tool_call + - on_session_start + - on_session_end diff --git a/integrations/opencode/package.json b/integrations/opencode/package.json new file mode 100644 index 00000000..2aeef050 --- /dev/null +++ b/integrations/opencode/package.json @@ -0,0 +1,10 @@ +{ + "name": "nixis-opencode", + "version": "1.0.0", + "description": "Nixis governance plugin for OpenCode", + "type": "module", + "main": "src/index.ts", + "peerDependencies": { + "@opencode-ai/core": "*" + } +} diff --git a/integrations/opencode/src/index.ts b/integrations/opencode/src/index.ts new file mode 100644 index 00000000..b7cdac32 --- /dev/null +++ b/integrations/opencode/src/index.ts @@ -0,0 +1,140 @@ +/** + * nixis-opencode — governance plugin for OpenCode. + * + * Subscribes to tool-call events on OpenCode's event bus and calls the + * nixis-daemon HTTP API to classify and audit each invocation. + * + * Wiring into OpenCode: + * import { NixisPlugin } from "nixis-opencode" + * // Register with OpenCode's plugin registry (exact API depends on OpenCode version): + * opencode.plugins.register(NixisPlugin) + * + * The plugin calls POST http://127.0.0.1:9091/v1/check for each tool call. + * If the daemon is unreachable it fails open — the tool call is NOT blocked. + * + * Event shape (from OpenCode's session/event.ts): + * session.next.tool.called → ToolCalledEvent + */ + +/** Payload emitted by OpenCode for each tool invocation. */ +export interface ToolCalledEvent { + callID: string + tool: { name: string } + input: Record + sessionID: string + timestamp: number +} + +/** Wire request sent to nixis-daemon /v1/check. */ +interface NixisCheckRequest { + tool: string + args: Record + session_id: string +} + +/** Wire response from nixis-daemon /v1/check. */ +interface NixisCheckResponse { + decision?: { + action?: string + reason?: string + policy_id?: string + } + latency_ns?: number +} + +const NIXIS_CHECK_URL = "http://127.0.0.1:9091/v1/check" +/** 200 ms — matches the nixis-hook total budget. */ +const TIMEOUT_MS = 200 + +/** + * Call nixis-daemon to classify a tool invocation. + * + * Returns the parsed response, or null if the daemon is unreachable. + */ +async function callNixis( + toolName: string, + args: Record, + sessionID: string, +): Promise { + const body: NixisCheckRequest = { tool: toolName, args, session_id: sessionID } + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS) + + try { + const resp = await fetch(NIXIS_CHECK_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }) + if (!resp.ok) { + return null + } + return (await resp.json()) as NixisCheckResponse + } catch { + // Daemon unreachable, timeout, or parse error — fail open. + return null + } finally { + clearTimeout(timer) + } +} + +/** + * Handle a tool-call event from OpenCode. + * + * Returns a block decision object when nixis denies the call, or undefined + * to allow the tool to proceed. + * + * @example + * // Wire into OpenCode's event bus (pseudo-code): + * eventBus.on("session.next.tool.called", async (event: ToolCalledEvent) => { + * const block = await onToolCalled(event) + * if (block) throw new Error(block.reason) + * }) + */ +export async function onToolCalled( + event: ToolCalledEvent, +): Promise<{ decision: "block"; reason: string } | undefined> { + const resp = await callNixis(event.tool.name, event.input, event.sessionID) + + if (resp === null) { + // Daemon unavailable — fail open. + return undefined + } + + const action = resp.decision?.action ?? "allow" + if (action === "deny") { + return { + decision: "block", + reason: resp.decision?.reason ?? "nixis policy violation", + } + } + + // allow / audit / require_approval — let the tool run. + return undefined +} + +/** + * NixisPlugin is the OpenCode plugin export. + * + * The exact plugin registration API varies by OpenCode version. This object + * provides the canonical hook handlers; wire them into your OpenCode setup + * using the registration method available in your version. + * + * @example + * // OpenCode PluginV2.define() pattern (if available): + * // import { PluginV2 } from "@opencode-ai/core" + * // export default PluginV2.define({ name: "nixis", hooks: NixisPlugin.hooks }) + */ +export const NixisPlugin = { + name: "nixis" as const, + version: "1.0.0" as const, + hooks: { + /** + * Invoked before each tool call. Return a block object to prevent execution. + * Return undefined to allow. + */ + "session.next.tool.called": onToolCalled, + }, +} as const From b7e3b4e22acfea5f5ea22080d4d493fdbafd7d15 Mon Sep 17 00:00:00 2001 From: Mayank Jain Date: Wed, 3 Jun 2026 02:02:28 +0530 Subject: [PATCH 3/3] feat(setup): auto-configure hermes and opencode integrations Add steps 6b and 6c to nixis setup that detect and patch hermes-agent config.yaml (shell hook registration) and opencode opencode.json (instructions entry). Both steps are optional and skip gracefully when the tool is not installed. Uninstall steps 2b/2c clean up the entries. --- cmd/nixis/setup.go | 34 ++++ cmd/nixis/setup_hermes.go | 147 +++++++++++++++++ cmd/nixis/setup_hermes_test.go | 266 +++++++++++++++++++++++++++++++ cmd/nixis/setup_opencode.go | 148 +++++++++++++++++ cmd/nixis/setup_opencode_test.go | 258 ++++++++++++++++++++++++++++++ 5 files changed, 853 insertions(+) create mode 100644 cmd/nixis/setup_hermes.go create mode 100644 cmd/nixis/setup_hermes_test.go create mode 100644 cmd/nixis/setup_opencode.go create mode 100644 cmd/nixis/setup_opencode_test.go diff --git a/cmd/nixis/setup.go b/cmd/nixis/setup.go index 20fbc440..15a901fc 100644 --- a/cmd/nixis/setup.go +++ b/cmd/nixis/setup.go @@ -177,6 +177,26 @@ func runInstall(cmd *cobra.Command, homeDir, nixisDir string) error { return fmt.Errorf("patch settings.json: %w", err) } + // Step 6b: Configure hermes integration (optional) + fmt.Fprintln(w) + fmt.Fprintln(w, "[6b] Configuring hermes-agent integration (optional)...") + if detectHermes(homeDir) != "" { + if err := patchHermesConfig(w, homeDir, hookPath); err != nil { + fmt.Fprintf(w, " Warning: could not configure hermes: %v\n", err) + } + } else { + fmt.Fprintln(w, " hermes-agent not detected, skipping") + } + + // Step 6c: Configure opencode integration (optional) + fmt.Fprintln(w) + fmt.Fprintln(w, "[6c] Configuring opencode integration (optional)...") + if err := patchOpenCodeConfig(w, homeDir, nixisDir); err != nil { + fmt.Fprintf(w, " Warning: could not configure opencode: %v\n", err) + } else { + fmt.Fprintln(w, " opencode instructions configured") + } + // Step 7: Smoke test fmt.Fprintln(w) fmt.Fprintln(w, "[7/8] Running smoke test...") @@ -252,6 +272,20 @@ func runUninstall(cmd *cobra.Command, homeDir, nixisDir string) error { fmt.Fprintf(w, " Warning: %v\n", err) } + // Step 2b: Remove hermes integration + fmt.Fprintln(w) + fmt.Fprintln(w, "[2b] Removing hermes-agent integration...") + if err := unpatchHermesConfig(w, homeDir); err != nil { + fmt.Fprintf(w, " Warning: %v\n", err) + } + + // Step 2c: Remove opencode integration + fmt.Fprintln(w) + fmt.Fprintln(w, "[2c] Removing opencode integration...") + if err := unpatchOpenCodeConfig(w, homeDir); err != nil { + fmt.Fprintf(w, " Warning: %v\n", err) + } + // Step 3: Remove ~/.nixis directory fmt.Fprintln(w) fmt.Fprintln(w, "[3/4] Removing", nixisDir) diff --git a/cmd/nixis/setup_hermes.go b/cmd/nixis/setup_hermes.go new file mode 100644 index 00000000..c19271a8 --- /dev/null +++ b/cmd/nixis/setup_hermes.go @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const ( + hermesBeginMarker = " # nixis-begin" + hermesEndMarker = " # nixis-end" +) + +// detectHermes returns the hermes config path if hermes is installed, "" otherwise. +func detectHermes(homeDir string) string { + path := filepath.Join(homeDir, ".hermes", "config.yaml") + if _, err := os.Stat(path); err != nil { + return "" + } + return path +} + +// patchHermesConfig adds nixis-hook to hermes shell hooks. Idempotent. +func patchHermesConfig(w io.Writer, homeDir, hookPath string) error { + cfgPath := filepath.Join(homeDir, ".hermes", "config.yaml") + fmt.Fprintf(w, " Hermes config: %s\n", cfgPath) + + data, err := os.ReadFile(cfgPath) + if err != nil { + if os.IsNotExist(err) { + fmt.Fprintln(w, " hermes config not found, skipping") + return nil + } + return fmt.Errorf("read hermes config: %w", err) + } + + content := string(data) + + // Idempotent: already registered. + if strings.Contains(content, "nixis-hook") { + fmt.Fprintln(w, " nixis-hook already registered in hermes config") + return nil + } + + // The block we insert, indented to match YAML structure under pre_tool_call. + hookBlock := fmt.Sprintf("%s\n - command: %s\n timeout: 5\n%s", + hermesBeginMarker, hookPath, hermesEndMarker) + + var newContent string + switch { + case strings.Contains(content, "pre_tool_call:"): + // Append our entry after the pre_tool_call: key line. + newContent = insertAfterLine(content, "pre_tool_call:", "\n"+hookBlock) + case strings.Contains(content, "hooks:"): + // Add pre_tool_call section under hooks:. + preToolCallSection := fmt.Sprintf("\n pre_tool_call:\n%s", hookBlock) + newContent = insertAfterLine(content, "hooks:", preToolCallSection) + default: + // Append entire hooks block at end of file. + newContent = strings.TrimRight(content, "\n") + + fmt.Sprintf("\n\nhooks:\n pre_tool_call:\n%s\n", hookBlock) + } + + if setupDryRun { + fmt.Fprintln(w, " (dry-run) Would patch hermes config") + return nil + } + + if err := os.WriteFile(cfgPath, []byte(newContent), 0o644); err != nil { + return fmt.Errorf("write hermes config: %w", err) + } + fmt.Fprintln(w, " hermes config patched") + return nil +} + +// unpatchHermesConfig removes nixis-hook from hermes shell hooks. +func unpatchHermesConfig(w io.Writer, homeDir string) error { + cfgPath := filepath.Join(homeDir, ".hermes", "config.yaml") + data, err := os.ReadFile(cfgPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read hermes config: %w", err) + } + + content := string(data) + if !strings.Contains(content, hermesBeginMarker) { + fmt.Fprintln(w, " No nixis-begin marker found in hermes config, skipping") + return nil + } + + // Remove the lines between nixis-begin and nixis-end inclusive. + newContent := removeMarkedBlock(content, hermesBeginMarker, hermesEndMarker) + + if setupDryRun { + fmt.Fprintln(w, " (dry-run) Would unpatch hermes config") + return nil + } + + if err := os.WriteFile(cfgPath, []byte(newContent), 0o644); err != nil { + return fmt.Errorf("write hermes config: %w", err) + } + fmt.Fprintln(w, " nixis-hook removed from hermes config") + return nil +} + +// insertAfterLine finds the first line in content that starts with (or equals) marker +// and appends addition immediately after it (before the next newline if any). +func insertAfterLine(content, marker, addition string) string { + lines := strings.Split(content, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == strings.TrimSpace(marker) { + // Rebuild: everything up to and including this line, then addition, then rest. + before := strings.Join(lines[:i+1], "\n") + after := strings.Join(lines[i+1:], "\n") + return before + addition + "\n" + after + } + } + // marker not found — append at end. + return strings.TrimRight(content, "\n") + addition + "\n" +} + +// removeMarkedBlock removes all lines from the begin marker to the end marker, inclusive. +func removeMarkedBlock(content, beginMarker, endMarker string) string { + lines := strings.Split(content, "\n") + out := make([]string, 0, len(lines)) + inBlock := false + for _, line := range lines { + if strings.TrimSpace(line) == strings.TrimSpace(beginMarker) { + inBlock = true + continue + } + if inBlock { + if strings.TrimSpace(line) == strings.TrimSpace(endMarker) { + inBlock = false + } + continue + } + out = append(out, line) + } + return strings.Join(out, "\n") +} diff --git a/cmd/nixis/setup_hermes_test.go b/cmd/nixis/setup_hermes_test.go new file mode 100644 index 00000000..f48dacd7 --- /dev/null +++ b/cmd/nixis/setup_hermes_test.go @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: MIT +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDetectHermes_NotInstalled(t *testing.T) { + dir := t.TempDir() + got := detectHermes(dir) + if got != "" { + t.Fatalf("detectHermes on empty dir = %q, want empty", got) + } +} + +func TestDetectHermes_Installed(t *testing.T) { + dir := t.TempDir() + hermesDir := filepath.Join(dir, ".hermes") + if err := os.MkdirAll(hermesDir, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(hermesDir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte("model:\n default: claude\n"), 0o644); err != nil { + t.Fatal(err) + } + got := detectHermes(dir) + if got != cfgPath { + t.Fatalf("detectHermes = %q, want %q", got, cfgPath) + } +} + +func TestPatchHermesConfig_NoHooksSection(t *testing.T) { + dir := t.TempDir() + hermesDir := filepath.Join(dir, ".hermes") + if err := os.MkdirAll(hermesDir, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(hermesDir, "config.yaml") + initial := "model:\n default: claude\n" + if err := os.WriteFile(cfgPath, []byte(initial), 0o644); err != nil { + t.Fatal(err) + } + + hookPath := "/home/user/.nixis/nixis-hook" + var w bytes.Buffer + if err := patchHermesConfig(&w, dir, hookPath); err != nil { + t.Fatalf("patchHermesConfig: %v", err) + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + content := string(data) + if !strings.Contains(content, "nixis-hook") { + t.Fatalf("nixis-hook not found in patched config:\n%s", content) + } + if !strings.Contains(content, "hooks:") { + t.Fatalf("hooks: section not present:\n%s", content) + } + if !strings.Contains(content, "pre_tool_call:") { + t.Fatalf("pre_tool_call: not present:\n%s", content) + } + if !strings.Contains(content, hermesBeginMarker) { + t.Fatalf("nixis-begin marker not present:\n%s", content) + } +} + +func TestPatchHermesConfig_WithHooksButNoPreToolCall(t *testing.T) { + dir := t.TempDir() + hermesDir := filepath.Join(dir, ".hermes") + if err := os.MkdirAll(hermesDir, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(hermesDir, "config.yaml") + initial := "hooks:\n post_tool_call: []\n" + if err := os.WriteFile(cfgPath, []byte(initial), 0o644); err != nil { + t.Fatal(err) + } + + hookPath := "/home/user/.nixis/nixis-hook" + var w bytes.Buffer + if err := patchHermesConfig(&w, dir, hookPath); err != nil { + t.Fatalf("patchHermesConfig: %v", err) + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + content := string(data) + if !strings.Contains(content, "pre_tool_call:") { + t.Fatalf("pre_tool_call: not inserted:\n%s", content) + } + if !strings.Contains(content, "nixis-hook") { + t.Fatalf("nixis-hook not present:\n%s", content) + } +} + +func TestPatchHermesConfig_WithPreToolCall(t *testing.T) { + dir := t.TempDir() + hermesDir := filepath.Join(dir, ".hermes") + if err := os.MkdirAll(hermesDir, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(hermesDir, "config.yaml") + initial := "hooks:\n pre_tool_call:\n - command: /other/tool\n timeout: 30\n" + if err := os.WriteFile(cfgPath, []byte(initial), 0o644); err != nil { + t.Fatal(err) + } + + hookPath := "/home/user/.nixis/nixis-hook" + var w bytes.Buffer + if err := patchHermesConfig(&w, dir, hookPath); err != nil { + t.Fatalf("patchHermesConfig: %v", err) + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + content := string(data) + if !strings.Contains(content, "/other/tool") { + t.Fatalf("existing hook removed:\n%s", content) + } + if !strings.Contains(content, "nixis-hook") { + t.Fatalf("nixis-hook not added:\n%s", content) + } +} + +func TestPatchHermesConfig_Idempotent(t *testing.T) { + dir := t.TempDir() + hermesDir := filepath.Join(dir, ".hermes") + if err := os.MkdirAll(hermesDir, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(hermesDir, "config.yaml") + // File already has nixis-hook. + initial := "hooks:\n pre_tool_call:\n - command: /home/user/.nixis/nixis-hook\n timeout: 5\n" + if err := os.WriteFile(cfgPath, []byte(initial), 0o644); err != nil { + t.Fatal(err) + } + + hookPath := "/home/user/.nixis/nixis-hook" + var w bytes.Buffer + if err := patchHermesConfig(&w, dir, hookPath); err != nil { + t.Fatalf("patchHermesConfig: %v", err) + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + // Content must be identical — no duplicate entries. + if string(data) != initial { + t.Fatalf("idempotent run changed file:\ngot:\n%s\nwant:\n%s", data, initial) + } + if strings.Contains(w.String(), "patched") { + t.Fatalf("expected 'already registered' message, got: %s", w.String()) + } +} + +func TestPatchHermesConfig_NotExist_Skip(t *testing.T) { + dir := t.TempDir() + var w bytes.Buffer + // No .hermes directory or config.yaml. + if err := patchHermesConfig(&w, dir, "/path/nixis-hook"); err != nil { + t.Fatalf("patchHermesConfig on missing config: %v", err) + } + if !strings.Contains(w.String(), "not found") { + t.Fatalf("expected 'not found' message, got: %s", w.String()) + } +} + +func TestUnpatchHermesConfig_RemovesBlock(t *testing.T) { + dir := t.TempDir() + hermesDir := filepath.Join(dir, ".hermes") + if err := os.MkdirAll(hermesDir, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(hermesDir, "config.yaml") + initial := "hooks:\n pre_tool_call:\n # nixis-begin\n - command: /home/user/.nixis/nixis-hook\n timeout: 5\n # nixis-end\n" + if err := os.WriteFile(cfgPath, []byte(initial), 0o644); err != nil { + t.Fatal(err) + } + + var w bytes.Buffer + if err := unpatchHermesConfig(&w, dir); err != nil { + t.Fatalf("unpatchHermesConfig: %v", err) + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + content := string(data) + if strings.Contains(content, "nixis-hook") { + t.Fatalf("nixis-hook still present after unpatching:\n%s", content) + } + if strings.Contains(content, hermesBeginMarker) { + t.Fatalf("nixis-begin marker still present:\n%s", content) + } +} + +func TestUnpatchHermesConfig_NoMarker_Noop(t *testing.T) { + dir := t.TempDir() + hermesDir := filepath.Join(dir, ".hermes") + if err := os.MkdirAll(hermesDir, 0o755); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(hermesDir, "config.yaml") + initial := "hooks:\n pre_tool_call: []\n" + if err := os.WriteFile(cfgPath, []byte(initial), 0o644); err != nil { + t.Fatal(err) + } + + var w bytes.Buffer + if err := unpatchHermesConfig(&w, dir); err != nil { + t.Fatalf("unpatchHermesConfig: %v", err) + } + // File should be unchanged. + data, _ := os.ReadFile(cfgPath) + if string(data) != initial { + t.Fatalf("file changed unexpectedly:\n%s", data) + } +} + +func TestUnpatchHermesConfig_NotExist_Noop(t *testing.T) { + dir := t.TempDir() + var w bytes.Buffer + if err := unpatchHermesConfig(&w, dir); err != nil { + t.Fatalf("unpatchHermesConfig on missing file: %v", err) + } +} + +func TestInsertAfterLine(t *testing.T) { + content := "hooks:\n post_tool_call: []\n" + result := insertAfterLine(content, "hooks:", "\n pre_tool_call:\n - command: /hook\n") + if !strings.Contains(result, "pre_tool_call:") { + t.Fatalf("pre_tool_call not inserted: %q", result) + } + if !strings.HasPrefix(result, "hooks:") { + t.Fatalf("hooks: should be first line: %q", result) + } +} + +func TestRemoveMarkedBlock(t *testing.T) { + content := "before\n # nixis-begin\n - command: hook\n # nixis-end\nafter\n" + result := removeMarkedBlock(content, " # nixis-begin", " # nixis-end") + if strings.Contains(result, "nixis-begin") { + t.Fatalf("begin marker still present: %q", result) + } + if strings.Contains(result, "nixis-end") { + t.Fatalf("end marker still present: %q", result) + } + if strings.Contains(result, "command: hook") { + t.Fatalf("hook command still present: %q", result) + } + if !strings.Contains(result, "before") || !strings.Contains(result, "after") { + t.Fatalf("surrounding content removed: %q", result) + } +} diff --git a/cmd/nixis/setup_opencode.go b/cmd/nixis/setup_opencode.go new file mode 100644 index 00000000..b7a2f423 --- /dev/null +++ b/cmd/nixis/setup_opencode.go @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +const openCodeInstructionsTemplate = `# Nixis Governance Policy + +Nixis is enforcing governance policies on tool calls in this session. +Policy directory: ~/.nixis/policies/ + +When a tool call is blocked, respect the decision and do not attempt to circumvent it. +` + +// detectOpenCode returns the opencode config path to patch, "" if not applicable. +// For global setup we target ~/.config/opencode/opencode.json. +func detectOpenCode(homeDir string) string { + return filepath.Join(homeDir, ".config", "opencode", "opencode.json") +} + +// patchOpenCodeConfig adds nixis instructions to opencode config. Idempotent. +func patchOpenCodeConfig(w io.Writer, homeDir, nixisDir string) error { + cfgPath := detectOpenCode(homeDir) + fmt.Fprintf(w, " OpenCode config: %s\n", cfgPath) + + instructionsPath := filepath.Join(nixisDir, "opencode-instructions.md") + + // Write (or overwrite) the instructions file. + if !setupDryRun { + if err := os.MkdirAll(nixisDir, 0o755); err != nil { + return fmt.Errorf("create nixis dir: %w", err) + } + if err := os.WriteFile(instructionsPath, []byte(openCodeInstructionsTemplate), 0o644); err != nil { + return fmt.Errorf("write opencode instructions: %w", err) + } + } + + // instructionsRef is what we store in the JSON (home-dir-relative tilde path). + instructionsRef := "~/.nixis/opencode-instructions.md" + + // Read existing config or start fresh. + var config map[string]any + data, err := os.ReadFile(cfgPath) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("read opencode config: %w", err) + } + // File doesn't exist — start with a minimal config. + config = map[string]any{ + "$schema": "https://opencode.ai/config.json", + } + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil && !setupDryRun { + return fmt.Errorf("create opencode config dir: %w", err) + } + } else { + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("parse opencode config: %w", err) + } + } + + // Check / update the instructions array. + existing, _ := config["instructions"].([]any) + for _, v := range existing { + if s, ok := v.(string); ok && s == instructionsRef { + fmt.Fprintln(w, " nixis instructions already registered in opencode config") + return nil + } + } + config["instructions"] = append(existing, instructionsRef) + + newData, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("marshal opencode config: %w", err) + } + + if setupDryRun { + fmt.Fprintln(w, " (dry-run) Would patch opencode config") + return nil + } + + if err := os.WriteFile(cfgPath, append(newData, '\n'), 0o644); err != nil { + return fmt.Errorf("write opencode config: %w", err) + } + fmt.Fprintln(w, " opencode config patched") + return nil +} + +// unpatchOpenCodeConfig removes nixis instructions from opencode config. +func unpatchOpenCodeConfig(w io.Writer, homeDir string) error { + cfgPath := detectOpenCode(homeDir) + data, err := os.ReadFile(cfgPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read opencode config: %w", err) + } + + var config map[string]any + if err := json.Unmarshal(data, &config); err != nil { + return fmt.Errorf("parse opencode config: %w", err) + } + + const instructionsRef = "~/.nixis/opencode-instructions.md" + + existing, _ := config["instructions"].([]any) + filtered := existing[:0] + removed := false + for _, v := range existing { + if s, ok := v.(string); ok && s == instructionsRef { + removed = true + continue + } + filtered = append(filtered, v) + } + + if !removed { + fmt.Fprintln(w, " nixis instructions not found in opencode config, skipping") + return nil + } + + if len(filtered) == 0 { + delete(config, "instructions") + } else { + config["instructions"] = filtered + } + + newData, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("marshal opencode config: %w", err) + } + + if setupDryRun { + fmt.Fprintln(w, " (dry-run) Would unpatch opencode config") + return nil + } + + if err := os.WriteFile(cfgPath, append(newData, '\n'), 0o644); err != nil { + return fmt.Errorf("write opencode config: %w", err) + } + fmt.Fprintln(w, " nixis instructions removed from opencode config") + return nil +} diff --git a/cmd/nixis/setup_opencode_test.go b/cmd/nixis/setup_opencode_test.go new file mode 100644 index 00000000..078aadc5 --- /dev/null +++ b/cmd/nixis/setup_opencode_test.go @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: MIT +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestDetectOpenCode(t *testing.T) { + homeDir := "/home/testuser" + got := detectOpenCode(homeDir) + want := filepath.Join(homeDir, ".config", "opencode", "opencode.json") + if got != want { + t.Fatalf("detectOpenCode = %q, want %q", got, want) + } +} + +func TestPatchOpenCodeConfig_CreatesNewConfig(t *testing.T) { + dir := t.TempDir() + nixisDir := filepath.Join(dir, ".nixis") + + var w bytes.Buffer + if err := patchOpenCodeConfig(&w, dir, nixisDir); err != nil { + t.Fatalf("patchOpenCodeConfig: %v", err) + } + + cfgPath := detectOpenCode(dir) + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("config not created: %v", err) + } + + var config map[string]any + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("config not valid JSON: %v", err) + } + + instructions, _ := config["instructions"].([]any) + if len(instructions) == 0 { + t.Fatal("instructions array is empty") + } + found := false + for _, v := range instructions { + if s, ok := v.(string); ok && s == "~/.nixis/opencode-instructions.md" { + found = true + } + } + if !found { + t.Fatalf("nixis instructions ref not in config: %v", instructions) + } +} + +func TestPatchOpenCodeConfig_AppendsToExistingConfig(t *testing.T) { + dir := t.TempDir() + nixisDir := filepath.Join(dir, ".nixis") + cfgPath := detectOpenCode(dir) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"$schema": "https://opencode.ai/config.json", "instructions": ["~/.other/instructions.md"]}` + "\n" + if err := os.WriteFile(cfgPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + var w bytes.Buffer + if err := patchOpenCodeConfig(&w, dir, nixisDir); err != nil { + t.Fatalf("patchOpenCodeConfig: %v", err) + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("invalid JSON after patch: %v", err) + } + instructions, _ := config["instructions"].([]any) + if len(instructions) < 2 { + t.Fatalf("expected at least 2 instructions, got %d: %v", len(instructions), instructions) + } + // Original must be preserved. + foundOther := false + foundNixis := false + for _, v := range instructions { + if s, ok := v.(string); ok { + if s == "~/.other/instructions.md" { + foundOther = true + } + if s == "~/.nixis/opencode-instructions.md" { + foundNixis = true + } + } + } + if !foundOther { + t.Fatal("existing instructions entry was removed") + } + if !foundNixis { + t.Fatal("nixis instructions entry not added") + } +} + +func TestPatchOpenCodeConfig_Idempotent(t *testing.T) { + dir := t.TempDir() + nixisDir := filepath.Join(dir, ".nixis") + cfgPath := detectOpenCode(dir) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"instructions": ["~/.nixis/opencode-instructions.md"]}` + "\n" + if err := os.WriteFile(cfgPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + var w bytes.Buffer + if err := patchOpenCodeConfig(&w, dir, nixisDir); err != nil { + t.Fatalf("patchOpenCodeConfig: %v", err) + } + + data, _ := os.ReadFile(cfgPath) + var config map[string]any + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + instructions, _ := config["instructions"].([]any) + count := 0 + for _, v := range instructions { + if s, ok := v.(string); ok && s == "~/.nixis/opencode-instructions.md" { + count++ + } + } + if count != 1 { + t.Fatalf("expected exactly 1 nixis instructions entry, got %d", count) + } + if !strings.Contains(w.String(), "already registered") { + t.Fatalf("expected 'already registered' message, got: %s", w.String()) + } +} + +func TestPatchOpenCodeConfig_WritesInstructionsFile(t *testing.T) { + dir := t.TempDir() + nixisDir := filepath.Join(dir, ".nixis") + + var w bytes.Buffer + if err := patchOpenCodeConfig(&w, dir, nixisDir); err != nil { + t.Fatalf("patchOpenCodeConfig: %v", err) + } + + instrPath := filepath.Join(nixisDir, "opencode-instructions.md") + data, err := os.ReadFile(instrPath) + if err != nil { + t.Fatalf("instructions file not created: %v", err) + } + if !strings.Contains(string(data), "Nixis Governance Policy") { + t.Fatalf("instructions file missing expected content: %s", data) + } +} + +func TestUnpatchOpenCodeConfig_RemovesEntry(t *testing.T) { + dir := t.TempDir() + cfgPath := detectOpenCode(dir) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"instructions": ["~/.nixis/opencode-instructions.md", "~/.other/instr.md"]}` + "\n" + if err := os.WriteFile(cfgPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + var w bytes.Buffer + if err := unpatchOpenCodeConfig(&w, dir); err != nil { + t.Fatalf("unpatchOpenCodeConfig: %v", err) + } + + data, _ := os.ReadFile(cfgPath) + var config map[string]any + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + instructions, _ := config["instructions"].([]any) + for _, v := range instructions { + if s, ok := v.(string); ok && s == "~/.nixis/opencode-instructions.md" { + t.Fatal("nixis instructions entry not removed") + } + } + // Other entry must remain. + found := false + for _, v := range instructions { + if s, ok := v.(string); ok && s == "~/.other/instr.md" { + found = true + } + } + if !found { + t.Fatal("other instructions entry was removed") + } +} + +func TestUnpatchOpenCodeConfig_EmptyInstructions_DeletesKey(t *testing.T) { + dir := t.TempDir() + cfgPath := detectOpenCode(dir) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"$schema": "https://opencode.ai/config.json", "instructions": ["~/.nixis/opencode-instructions.md"]}` + "\n" + if err := os.WriteFile(cfgPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + var w bytes.Buffer + if err := unpatchOpenCodeConfig(&w, dir); err != nil { + t.Fatalf("unpatchOpenCodeConfig: %v", err) + } + + data, _ := os.ReadFile(cfgPath) + var config map[string]any + if err := json.Unmarshal(data, &config); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if _, ok := config["instructions"]; ok { + t.Fatal("instructions key should be deleted when array becomes empty") + } +} + +func TestUnpatchOpenCodeConfig_NotExist_Noop(t *testing.T) { + dir := t.TempDir() + var w bytes.Buffer + if err := unpatchOpenCodeConfig(&w, dir); err != nil { + t.Fatalf("unpatchOpenCodeConfig on missing file: %v", err) + } +} + +func TestUnpatchOpenCodeConfig_NotRegistered_Noop(t *testing.T) { + dir := t.TempDir() + cfgPath := detectOpenCode(dir) + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatal(err) + } + existing := `{"instructions": ["~/.other/instr.md"]}` + "\n" + if err := os.WriteFile(cfgPath, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + + var w bytes.Buffer + if err := unpatchOpenCodeConfig(&w, dir); err != nil { + t.Fatalf("unpatchOpenCodeConfig: %v", err) + } + data, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(data), "~/.other/instr.md") { + t.Fatal("other entry was removed") + } + if !strings.Contains(w.String(), "not found") { + t.Fatalf("expected 'not found' message, got: %s", w.String()) + } +}