From f84308208414b3b357bfe20a7952f49860d3f640 Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Fri, 11 Sep 2026 14:04:41 -0400 Subject: [PATCH 1/9] GH-20: extract tool registry shared by server and install --- main.go | 145 +++++++++-------------------------------- main_test.go | 25 +++++++ tools/registry.go | 106 ++++++++++++++++++++++++++++++ tools/registry_test.go | 28 ++++++++ 4 files changed, 188 insertions(+), 116 deletions(-) create mode 100644 tools/registry.go create mode 100644 tools/registry_test.go diff --git a/main.go b/main.go index feb9e7f..de50f34 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,6 @@ import ( "strings" "syscall" - "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/renderorange/knowledge-mcp/knowledge" @@ -89,121 +88,7 @@ func main() { ) // Register tools - s.AddTool( - mcp.NewTool("init_knowledge", - mcp.WithDescription("Create .agents/ knowledge directory structure for a project"), - mcp.WithString("project_path", - mcp.Required(), - mcp.Description("Absolute path to the project root"), - ), - ), - tools.InitHandler(resolver), - ) - - s.AddTool( - mcp.NewTool("write_knowledge", - mcp.WithDescription("Add a new knowledge entry to a project's .agents/ store"), - mcp.WithString("project", - mcp.Required(), - mcp.Description("Project name (bare if unique, else root/project)"), - ), - mcp.WithString("category", - mcp.Required(), - mcp.Description("Category: conventions, subsystems, or decisions"), - ), - mcp.WithString("summary", - mcp.Required(), - mcp.Description("One-line description (max 100 chars)"), - ), - mcp.WithString("detail", - mcp.Required(), - mcp.Description("Concise knowledge (10-20 lines). Summarize key facts, don't copy source files. Include only query-able information."), - ), - mcp.WithString("source", - mcp.Required(), - mcp.Description("How this was learned (provenance)"), - ), - mcp.WithString("rule", - mcp.Description("Imperative constraint this entry enforces (e.g. \"Never create files outside ./tmp\"). Shown prominently in list output. Max 200 chars."), - ), - ), - tools.WriteHandler(resolver, idx), - ) - - s.AddTool( - mcp.NewTool("query_knowledge", - mcp.WithDescription("Search knowledge entries by text and category"), - mcp.WithString("project", - mcp.Required(), - mcp.Description("Project name (bare if unique, else root/project)"), - ), - mcp.WithString("query", - mcp.Description("Full-text search query"), - ), - mcp.WithString("category", - mcp.Description("Filter by category: conventions, subsystems, or decisions"), - ), - mcp.WithNumber("limit", - mcp.Description("Max results (default 10)"), - ), - ), - tools.QueryHandler(resolver, idx), - ) - - s.AddTool( - mcp.NewTool("list_knowledge", - mcp.WithDescription("List all knowledge entries for a project. Entries with a rule are shown first under constraints."), - mcp.WithString("project", - mcp.Required(), - mcp.Description("Project name (bare if unique, else root/project)"), - ), - mcp.WithString("category", - mcp.Description("Filter by category: conventions, subsystems, or decisions"), - ), - ), - tools.ListHandler(resolver, idx), - ) - - s.AddTool( - mcp.NewTool("update_knowledge", - mcp.WithDescription("Update an existing knowledge entry by ID"), - mcp.WithString("project", - mcp.Required(), - mcp.Description("Project name (bare if unique, else root/project)"), - ), - mcp.WithString("category", - mcp.Required(), - mcp.Description("Category: conventions, subsystems, or decisions"), - ), - mcp.WithString("id", - mcp.Required(), - mcp.Description("Entry ID to update (e.g., conv-001)"), - ), - mcp.WithString("summary", - mcp.Description("New summary (optional)"), - ), - mcp.WithString("detail", - mcp.Description("New detail (optional)"), - ), - mcp.WithString("rule", - mcp.Description("New imperative rule (optional, max 200 chars)"), - ), - mcp.WithString("supersedes", - mcp.Description("ID of entry this supersedes (optional)"), - ), - ), - tools.UpdateHandler(resolver, idx), - ) - - // Register list_projects tool (org-wide mode only) - if len(roots) > 0 { - s.AddTool( - mcp.NewTool("list_projects", - mcp.WithDescription("List all discovered projects across configured roots"), - ), - tools.ListProjectsHandler(resolver), - ) - } + registerTools(s, buildHandlers(resolver, idx, len(roots) > 0)) // Graceful shutdown on SIGTERM/SIGINT ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) @@ -324,6 +209,34 @@ func splitSections(data string) [][2]string { return sections } +// registerTools adds every registry tool with an available handler. +// list_projects is only served in org-wide mode (its handler is absent +// otherwise) — the loop does the gating by design. +func registerTools(s *server.MCPServer, handlerMap map[string]server.ToolHandlerFunc) { + for _, spec := range tools.Registry { + handler, ok := handlerMap[spec.Name] + if !ok { + continue // not served in this mode + } + s.AddTool(spec.MCPTool(), handler) + } +} + +// buildHandlers wires the tools package handlers to the resolver/index. +func buildHandlers(res *projects.Resolver, idx *search.Index, orgMode bool) map[string]server.ToolHandlerFunc { + h := map[string]server.ToolHandlerFunc{ + tools.ToolInitKnowledge: tools.InitHandler(res), + tools.ToolWriteKnowledge: tools.WriteHandler(res, idx), + tools.ToolQueryKnowledge: tools.QueryHandler(res, idx), + tools.ToolListKnowledge: tools.ListHandler(res, idx), + tools.ToolUpdateKnowledge: tools.UpdateHandler(res, idx), + } + if orgMode { + h[tools.ToolListProjects] = tools.ListProjectsHandler(res) + } + return h +} + // indexLocation picks the bleve index path: explicit override, a --store // default (/.index), legacy per-config locations for single-entry // configs, or a hashed XDG state dir for multi-entry configs. diff --git a/main_test.go b/main_test.go index d1a54ad..c7bcc0c 100644 --- a/main_test.go +++ b/main_test.go @@ -5,6 +5,9 @@ import ( "path/filepath" "strings" "testing" + + "github.com/renderorange/knowledge-mcp/projects" + "github.com/renderorange/knowledge-mcp/tools" ) func TestSplitSections(t *testing.T) { @@ -162,3 +165,25 @@ func TestIndexLocationOverrideBeatsStore(t *testing.T) { t.Errorf("location = %q, want %q", p, "/custom/index") } } + +func TestBuildHandlersMatchesRegistry(t *testing.T) { + resolver, _, err := projects.Build(nil, []string{t.TempDir()}, "") + if err != nil { + t.Fatalf("projects.Build() error: %v", err) + } + for _, orgMode := range []bool{false, true} { + h := buildHandlers(resolver, nil, orgMode) + for _, spec := range tools.Registry { + _, ok := h[spec.Name] + if spec.Name == tools.ToolListProjects && !orgMode { + if ok { + t.Errorf("orgMode=false: list_projects must not be registered") + } + continue + } + if !ok { + t.Errorf("orgMode=%v: missing handler for %q", orgMode, spec.Name) + } + } + } +} diff --git a/tools/registry.go b/tools/registry.go new file mode 100644 index 0000000..11c8393 --- /dev/null +++ b/tools/registry.go @@ -0,0 +1,106 @@ +package tools + +import "github.com/mark3labs/mcp-go/mcp" + +// Tool name constants keep main.go, the registry, and generated +// instructions in sync. +const ( + ToolInitKnowledge = "init_knowledge" + ToolWriteKnowledge = "write_knowledge" + ToolQueryKnowledge = "query_knowledge" + ToolListKnowledge = "list_knowledge" + ToolUpdateKnowledge = "update_knowledge" + ToolListProjects = "list_projects" +) + +// ParamSpec describes one tool parameter. +type ParamSpec struct { + Name string + Description string + Type string // "string" or "number" + Required bool +} + +// ToolSpec is the metadata install templates and MCP registration share. +type ToolSpec struct { + Name string + Purpose string // one line used by generated agent instructions + Description string // full MCP tool description + Params []ParamSpec +} + +// Registry lists every MCP tool the server serves, in registration order. +var Registry = []ToolSpec{ + { + Name: ToolInitKnowledge, Purpose: "Create a project's .agents/ knowledge directory structure", + Description: "Create .agents/ knowledge directory structure for a project", + Params: []ParamSpec{ + {Name: "project_path", Description: "Absolute path to the project root", Type: "string", Required: true}, + }, + }, + { + Name: ToolWriteKnowledge, Purpose: "Add a new knowledge entry to a project's store", + Description: "Add a new knowledge entry to a project's .agents/ store", + Params: []ParamSpec{ + {Name: "project", Description: "Project name (bare if unique, else root/project)", Type: "string", Required: true}, + {Name: "category", Description: "Category: conventions, subsystems, or decisions", Type: "string", Required: true}, + {Name: "summary", Description: "One-line description (max 100 chars)", Type: "string", Required: true}, + {Name: "detail", Description: "Concise knowledge (10-20 lines). Summarize key facts, don't copy source files. Include only query-able information.", Type: "string", Required: true}, + {Name: "source", Description: "How this was learned (provenance)", Type: "string", Required: true}, + {Name: "rule", Description: "Imperative constraint this entry enforces (e.g. \"Never create files outside ./tmp\"). Shown prominently in list output. Max 200 chars.", Type: "string"}, + }, + }, + { + Name: ToolQueryKnowledge, Purpose: "Full-text search across knowledge entries", + Description: "Search knowledge entries by text and category", + Params: []ParamSpec{ + {Name: "project", Description: "Project name (bare if unique, else root/project)", Type: "string", Required: true}, + {Name: "query", Description: "Full-text search query", Type: "string"}, + {Name: "category", Description: "Filter by category: conventions, subsystems, or decisions", Type: "string"}, + {Name: "limit", Description: "Max results (default 10)", Type: "number"}, + }, + }, + { + Name: ToolListKnowledge, Purpose: "List all entries; rules shown first as constraints", + Description: "List all knowledge entries for a project. Entries with a rule are shown first under constraints.", + Params: []ParamSpec{ + {Name: "project", Description: "Project name (bare if unique, else root/project)", Type: "string", Required: true}, + {Name: "category", Description: "Filter by category: conventions, subsystems, or decisions", Type: "string"}, + }, + }, + { + Name: ToolUpdateKnowledge, Purpose: "Update an existing knowledge entry by ID", + Description: "Update an existing knowledge entry by ID", + Params: []ParamSpec{ + {Name: "project", Description: "Project name (bare if unique, else root/project)", Type: "string", Required: true}, + {Name: "category", Description: "Category: conventions, subsystems, or decisions", Type: "string", Required: true}, + {Name: "id", Description: "Entry ID to update (e.g., conv-001)", Type: "string", Required: true}, + {Name: "summary", Description: "New summary (optional)", Type: "string"}, + {Name: "detail", Description: "New detail (optional)", Type: "string"}, + {Name: "rule", Description: "New imperative rule (optional, max 200 chars)", Type: "string"}, + {Name: "supersedes", Description: "ID of entry this supersedes (optional)", Type: "string"}, + }, + }, + { + Name: ToolListProjects, Purpose: "List all discovered projects (org-wide mode)", + Description: "List all discovered projects across configured roots", + }, +} + +// MCPTool converts the spec into an mcp.Tool for server registration. +func (t ToolSpec) MCPTool() mcp.Tool { + opts := []mcp.ToolOption{mcp.WithDescription(t.Description)} + for _, p := range t.Params { + propOpts := []mcp.PropertyOption{mcp.Description(p.Description)} + if p.Required { + propOpts = append(propOpts, mcp.Required()) + } + switch p.Type { + case "string": + opts = append(opts, mcp.WithString(p.Name, propOpts...)) + case "number": + opts = append(opts, mcp.WithNumber(p.Name, propOpts...)) + } + } + return mcp.NewTool(t.Name, opts...) +} diff --git a/tools/registry_test.go b/tools/registry_test.go new file mode 100644 index 0000000..ad8ed41 --- /dev/null +++ b/tools/registry_test.go @@ -0,0 +1,28 @@ +package tools + +import "testing" + +func TestRegistryComplete(t *testing.T) { + want := map[string]bool{ + "init_knowledge": true, "write_knowledge": true, "query_knowledge": true, + "list_knowledge": true, "update_knowledge": true, "list_projects": true, + } + seen := map[string]bool{} + for _, spec := range Registry { + if seen[spec.Name] { + t.Fatalf("duplicate tool %q", spec.Name) + } + seen[spec.Name] = true + if spec.Description == "" || spec.Purpose == "" { + t.Errorf("tool %q missing description/purpose", spec.Name) + } + if !want[spec.Name] { + t.Errorf("unexpected tool %q", spec.Name) + } + } + for name := range want { + if !seen[name] { + t.Errorf("missing tool %q", name) + } + } +} From 60236eb7b3af1121d3c0d74d46ce565c9f65f2ca Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Fri, 11 Sep 2026 14:10:25 -0400 Subject: [PATCH 2/9] GH-20: move section splitting into knowledge package --- knowledge/sections_test.go | 33 +++++++++++++++++++++++++++++++ knowledge/store.go | 38 ++++++++++++++++++++++++++++++++++++ main.go | 40 +------------------------------------- main_test.go | 30 ---------------------------- 4 files changed, 72 insertions(+), 69 deletions(-) create mode 100644 knowledge/sections_test.go diff --git a/knowledge/sections_test.go b/knowledge/sections_test.go new file mode 100644 index 0000000..4601274 --- /dev/null +++ b/knowledge/sections_test.go @@ -0,0 +1,33 @@ +package knowledge + +import "testing" + +func TestSplitSections(t *testing.T) { + data := "# Title\n\nintro text\n\n## First Section\nbody one\n\n### Sub Heading\nsub body\n\n## Second Section\nbody two\n" + sections := SplitSections(data) + + want := [][2]string{ + {"Overview", "# Title\n\nintro text"}, + {"First Section", "## First Section\nbody one\n\n### Sub Heading\nsub body"}, + {"Second Section", "## Second Section\nbody two"}, + } + if len(sections) != len(want) { + t.Fatalf("len(sections) = %d, want %d (%#v)", len(sections), len(want), sections) + } + for i, w := range want { + if sections[i][0] != w[0] || sections[i][1] != w[1] { + t.Errorf("section[%d] = %q/%q, want %q/%q", i, sections[i][0], sections[i][1], w[0], w[1]) + } + } +} + +func TestSplitSectionsNoPreamble(t *testing.T) { + data := "## Only Heading\nbody\n" + sections := SplitSections(data) + if len(sections) != 1 { + t.Fatalf("len(sections) = %d, want 1", len(sections)) + } + if sections[0][0] != "Only Heading" || sections[0][1] != "## Only Heading\nbody" { + t.Errorf("got %#v", sections) + } +} diff --git a/knowledge/store.go b/knowledge/store.go index 8d49cf5..133ac6f 100644 --- a/knowledge/store.go +++ b/knowledge/store.go @@ -117,3 +117,41 @@ func LoadOrCreate(filePath, project string) (*KnowledgeFile, error) { } return kf, nil } + +// SplitSections splits a knowledge markdown document on lines starting +// with "## ". Content before the first section heading is kept under +// "Overview". Subsection headings (### ...) stay part of their parent +// section body. +func SplitSections(data string) [][2]string { + var sections [][2]string + var currentTitle string + var current strings.Builder + + flush := func() { + title := currentTitle + if title == "" { + title = "Overview" + } + body := strings.TrimSpace(current.String()) + if title == "Overview" && body == "" { + current.Reset() + return + } + sections = append(sections, [2]string{title, body}) + current.Reset() + } + + for _, line := range strings.Split(data, "\n") { + if rest, ok := strings.CutPrefix(line, "## "); ok { + flush() + currentTitle = strings.TrimSpace(rest) + current.WriteString(line) + current.WriteString("\n") + continue + } + current.WriteString(line) + current.WriteString("\n") + } + flush() + return sections +} diff --git a/main.go b/main.go index de50f34..2891941 100644 --- a/main.go +++ b/main.go @@ -155,7 +155,7 @@ func indexOrgKnowledge(knowledgeDir, orgName string, idx *search.Index) { if err != nil { continue } - for _, sec := range splitSections(string(data)) { + for _, sec := range knowledge.SplitSections(string(data)) { heading, body := sec[0], sec[1] doc := search.SearchDocument{ Summary: fmt.Sprintf("%s: %s", catFile, heading), @@ -171,44 +171,6 @@ func indexOrgKnowledge(knowledgeDir, orgName string, idx *search.Index) { } } -// splitSections splits a knowledge markdown document on lines starting -// with "## ". Content before the first section heading is kept under -// "Overview". Subsection headings (### ...) stay part of their parent -// section body. -func splitSections(data string) [][2]string { - var sections [][2]string - var currentTitle string - var current strings.Builder - - flush := func() { - title := currentTitle - if title == "" { - title = "Overview" - } - body := strings.TrimSpace(current.String()) - if title == "Overview" && body == "" { - current.Reset() - return - } - sections = append(sections, [2]string{title, body}) - current.Reset() - } - - for _, line := range strings.Split(data, "\n") { - if rest, ok := strings.CutPrefix(line, "## "); ok { - flush() - currentTitle = strings.TrimSpace(rest) - current.WriteString(line) - current.WriteString("\n") - continue - } - current.WriteString(line) - current.WriteString("\n") - } - flush() - return sections -} - // registerTools adds every registry tool with an available handler. // list_projects is only served in org-wide mode (its handler is absent // otherwise) — the loop does the gating by design. diff --git a/main_test.go b/main_test.go index c7bcc0c..71e82c5 100644 --- a/main_test.go +++ b/main_test.go @@ -10,36 +10,6 @@ import ( "github.com/renderorange/knowledge-mcp/tools" ) -func TestSplitSections(t *testing.T) { - data := "# Title\n\nintro text\n\n## First Section\nbody one\n\n### Sub Heading\nsub body\n\n## Second Section\nbody two\n" - sections := splitSections(data) - - want := [][2]string{ - {"Overview", "# Title\n\nintro text"}, - {"First Section", "## First Section\nbody one\n\n### Sub Heading\nsub body"}, - {"Second Section", "## Second Section\nbody two"}, - } - if len(sections) != len(want) { - t.Fatalf("len(sections) = %d, want %d (%#v)", len(sections), len(want), sections) - } - for i, w := range want { - if sections[i][0] != w[0] || sections[i][1] != w[1] { - t.Errorf("section[%d] = %q/%q, want %q/%q", i, sections[i][0], sections[i][1], w[0], w[1]) - } - } -} - -func TestSplitSectionsNoPreamble(t *testing.T) { - data := "## Only Heading\nbody\n" - sections := splitSections(data) - if len(sections) != 1 { - t.Fatalf("len(sections) = %d, want 1", len(sections)) - } - if sections[0][0] != "Only Heading" || sections[0][1] != "## Only Heading\nbody" { - t.Errorf("got %#v", sections) - } -} - func TestPathListSet(t *testing.T) { var p pathList if err := p.Set("/a"); err != nil { From e29a4b7c3a3c90c47d1a85edc6866644927fa714 Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Fri, 11 Sep 2026 14:15:59 -0400 Subject: [PATCH 3/9] GH-20: add install package config dir and record --- install/paths.go | 54 +++++++++++++++++++++++++ install/paths_test.go | 91 +++++++++++++++++++++++++++++++++++++++++++ install/record.go | 58 +++++++++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 install/paths.go create mode 100644 install/paths_test.go create mode 100644 install/record.go diff --git a/install/paths.go b/install/paths.go new file mode 100644 index 0000000..9900788 --- /dev/null +++ b/install/paths.go @@ -0,0 +1,54 @@ +package install + +import ( + "os" + "path/filepath" +) + +// ConfigDir returns the knowledge-mcp install/config directory. +// KNM_CONFIG_DIR overrides it (tests, containers); otherwise it follows +// XDG (or the OS user-config dir). +func ConfigDir() string { + if d := os.Getenv("KNM_CONFIG_DIR"); d != "" { + return d + } + base, err := os.UserConfigDir() + if err != nil { + if home, herr := os.UserHomeDir(); herr == nil { + base = filepath.Join(home, ".config") + } else { + base = "." + } + } + return filepath.Join(base, "knowledge-mcp") +} + +// OpencodeDir returns the opencode config directory (sibling of ConfigDir). +// opencode reads its config from /opencode on Linux/macOS. +func OpencodeDir() string { + return filepath.Join(filepath.Dir(ConfigDir()), "opencode") +} + +// OpencodeConfigPath picks the existing opencode config file (jsonc +// preferred), or proposes a new opencode.jsonc path when neither exists. +func OpencodeConfigPath() (string, error) { + dir := OpencodeDir() + jsonc := filepath.Join(dir, "opencode.jsonc") + json := filepath.Join(dir, "opencode.json") + switch { + case fileExists(jsonc): + return jsonc, nil + case fileExists(json): + return json, nil + default: + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + return jsonc, nil + } +} + +func fileExists(p string) bool { + info, err := os.Stat(p) + return err == nil && !info.IsDir() +} \ No newline at end of file diff --git a/install/paths_test.go b/install/paths_test.go new file mode 100644 index 0000000..56aa7ff --- /dev/null +++ b/install/paths_test.go @@ -0,0 +1,91 @@ +package install + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConfigDirEnvOverride(t *testing.T) { + dir := filepath.Join(t.TempDir(), "cfg") + t.Setenv("KNM_CONFIG_DIR", dir) + if got := ConfigDir(); got != dir { + t.Fatalf("ConfigDir() = %q, want %q", got, dir) + } + if got := OpencodeDir(); got != filepath.Join(filepath.Dir(dir), "opencode") { + t.Fatalf("OpencodeDir() = %q, want %q", got, filepath.Join(filepath.Dir(dir), "opencode")) + } +} + +func TestConfigDirDefault(t *testing.T) { + t.Setenv("KNM_CONFIG_DIR", "") + t.Setenv("XDG_CONFIG_HOME", "/xdg") + t.Setenv("HOME", "/home/u") + if got := ConfigDir(); got != filepath.Join("/xdg", "knowledge-mcp") { + t.Fatalf("ConfigDir() = %q, want %q", got, filepath.Join("/xdg", "knowledge-mcp")) + } + if got := OpencodeDir(); got != filepath.Join("/xdg", "opencode") { + t.Fatalf("OpencodeDir() = %q, want %q", got, filepath.Join("/xdg", "opencode")) + } +} + +func TestOpencodeConfigPathPreference(t *testing.T) { + cfg := t.TempDir() + t.Setenv("KNM_CONFIG_DIR", filepath.Join(cfg, "knowledge-mcp")) + opencodeDir := OpencodeDir() + + got, err := OpencodeConfigPath() + if err != nil { + t.Fatalf("OpencodeConfigPath() error: %v", err) + } + if got != filepath.Join(opencodeDir, "opencode.jsonc") { + t.Fatalf("OpencodeConfigPath() = %q, want jsonc proposal", got) + } + + if err := os.MkdirAll(opencodeDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(opencodeDir, "opencode.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + got, err = OpencodeConfigPath() + if err != nil { + t.Fatalf("OpencodeConfigPath() error: %v", err) + } + if got != filepath.Join(opencodeDir, "opencode.json") { + t.Fatalf("OpencodeConfigPath() = %q, want json", got) + } + + if err := os.WriteFile(filepath.Join(opencodeDir, "opencode.jsonc"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + got, _ = OpencodeConfigPath() + if got != filepath.Join(opencodeDir, "opencode.jsonc") { + t.Fatalf("OpencodeConfigPath() = %q, want jsonc", got) + } +} + +func TestRecordRoundTrip(t *testing.T) { + cfg := t.TempDir() + t.Setenv("KNM_CONFIG_DIR", filepath.Join(cfg, "knowledge-mcp")) + rec := Record{ + Version: "v1.0.0", BinPath: "/bin/k", Roots: []string{"/r1", "/r2"}, + Projects: []string{"/p"}, Global: "/g", Index: "/i", + Artifacts: []Artifact{{Path: "/a", Kind: "skill"}}, + } + if err := SaveRecord(rec); err != nil { + t.Fatalf("SaveRecord() error: %v", err) + } + loaded, ok := LoadRecord() + if !ok { + t.Fatal("LoadRecord() not ok") + } + if loaded.Version != rec.Version || len(loaded.Roots) != 2 || loaded.Artifacts[0].Kind != "skill" { + t.Fatalf("round trip mismatch: %+v", loaded) + } + + t.Setenv("KNM_CONFIG_DIR", filepath.Join(t.TempDir(), "none")) + if _, ok := LoadRecord(); ok { + t.Fatal("LoadRecord() ok for missing record") + } +} \ No newline at end of file diff --git a/install/record.go b/install/record.go new file mode 100644 index 0000000..c4edbcb --- /dev/null +++ b/install/record.go @@ -0,0 +1,58 @@ +package install + +import ( + "encoding/json" + "os" + "path/filepath" +) + +type Record struct { + Version string `json:"version"` + BinPath string `json:"bin_path"` + Roots []string `json:"roots"` + Projects []string `json:"projects"` + Global string `json:"global"` + Store string `json:"store"` + Index string `json:"index"` + Artifacts []Artifact `json:"artifacts"` +} + +type Artifact struct { + Path string `json:"path"` + Kind string `json:"kind"` // "agents_md_block" | "mcp_entry_block" | "skill" | "plugin" +} + +func recordPath() string { + return filepath.Join(ConfigDir(), "install.json") +} + +// SaveRecord writes the record via temp + rename so a crashed install never +// leaves a half-written JSON file behind. +func SaveRecord(record Record) error { + if err := os.MkdirAll(ConfigDir(), 0755); err != nil { + return err + } + data, err := json.MarshalIndent(record, "", " ") + if err != nil { + return err + } + tmp := recordPath() + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return err + } + return os.Rename(tmp, recordPath()) +} + +// LoadRecord reads the record; ok is false when absent or unparseable, in +// which case callers treat the install as absent. +func LoadRecord() (Record, bool) { + data, err := os.ReadFile(recordPath()) + if err != nil { + return Record{}, false + } + var rec Record + if err := json.Unmarshal(data, &rec); err != nil { + return Record{}, false + } + return rec, true +} \ No newline at end of file From 67a09b5b8dc56bac42f76e45e256b658c31d5eb8 Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Fri, 11 Sep 2026 14:47:12 -0400 Subject: [PATCH 4/9] GH-20: add marker-based opencode.jsonc mcp merge --- install/opencode.go | 153 +++++++++++++++++++++++++++++++++++++++ install/opencode_test.go | 101 ++++++++++++++++++++++++++ install/paths.go | 2 +- install/paths_test.go | 2 +- install/record.go | 2 +- 5 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 install/opencode.go create mode 100644 install/opencode_test.go diff --git a/install/opencode.go b/install/opencode.go new file mode 100644 index 0000000..86565c4 --- /dev/null +++ b/install/opencode.go @@ -0,0 +1,153 @@ +package install + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" +) + +const ( + mcpMarkerStart = "// knowledge-mcp:start" + mcpMarkerEnd = "// knowledge-mcp:end" +) + +var errHandWrittenEntry = errors.New("hand-written knowledge-mcp entry without knowledge-mcp markers") + +// MergeMCPEntry adds (or refreshes) the knowledge-mcp MCP entry in opencode +// config text, preserving jsonc comments and unrelated keys. +func MergeMCPEntry(existing []byte, binPath string, flags []string) ([]byte, bool, error) { + content := string(existing) + + if strings.Contains(content, mcpMarkerStart) && strings.Contains(content, mcpMarkerEnd) { + return replaceMarkedRegion(content, binPath, flags) + } + + if handWritten(content) { + return nil, false, errHandWrittenEntry + } + + trimmed := strings.TrimSpace(content) + if trimmed == "" { + entry, _ := renderEntry(binPath, flags, " ") + out := "{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"mcp\": {\n" + entry + " },\n}\n" + return []byte(out), true, nil + } + + indent := detectIndent(content) + entry, _ := renderEntry(binPath, flags, indent) + + mcpRE := regexp.MustCompile(`(^|\n)([ \t]*)"mcp"[ \t]*:[ \t]*\{`) + if loc := mcpRE.FindStringSubmatchIndex(content); loc != nil { + // loc is for the whole match; the `{` sits at the end. Append the + // entry right after the opening brace. + insertAt := loc[1] + out := content[:insertAt] + "\n" + entry + content[insertAt:] + if err := sanityCheck(out); err != nil { + return nil, false, err + } + return []byte(out), true, nil + } + + // No mcp key: append one before the final closing brace. The preceding + // key/value needs a comma unless the file ends inside an empty object. + idx := strings.LastIndex(content, "}") + if idx < 0 { + return nil, false, errors.New("no closing brace found and no mcp key; cannot merge") + } + before := strings.TrimSpace(content[:idx]) + pfx := "" + if !strings.HasSuffix(before, ",") && !strings.HasSuffix(before, "{") { + pfx = "," + } + out := strings.TrimRight(content[:idx], " \t\n") + pfx + "\n" + indent + "\"mcp\": {\n" + entry + indent + "},\n" + content[idx:] + if err := sanityCheck(out); err != nil { + return nil, false, err + } + return []byte(out), true, nil +} + +func replaceMarkedRegion(content, binPath string, flags []string) ([]byte, bool, error) { + startIdx := strings.Index(content, mcpMarkerStart) + endIdx := strings.Index(content, mcpMarkerEnd) + after := content[endIdx:] + newline := strings.Index(after, "\n") + if newline < 0 { + newline = len(after) + } + replaceEnd := endIdx + newline + + lineStart := strings.LastIndex(content[:startIdx], "\n") + 1 + indent := content[lineStart:startIdx] + + entry, _ := renderEntry(binPath, flags, indent) + out := content[:lineStart] + entry + content[replaceEnd:] + if err := sanityCheck(out); err != nil { + return nil, false, err + } + return []byte(out), true, nil +} + +func handWritten(content string) bool { + return regexp.MustCompile(`(?m)^[ \t]*"knowledge-mcp"[ \t]*:`).MatchString(content) +} + +// renderEntry returns the marker-delimited entry block, indented with +// `space`, plus the indent string itself. +func renderEntry(binPath string, flags []string, space string) (string, string) { + cmd := append([]string{binPath}, flags...) + var parts []string + for _, c := range cmd { + b, _ := json.Marshal(c) + parts = append(parts, string(b)) + } + cmdJSON := "[" + strings.Join(parts, ", ") + "]" + inner := space + " " + lines := []string{ + space + mcpMarkerStart, + space + "\"knowledge-mcp\": {", + inner + "\"command\": " + string(cmdJSON) + ",", + inner + "\"type\": \"local\"", + space + "},", + space + mcpMarkerEnd + "\n", + } + return strings.Join(lines, "\n"), space +} + +func detectIndent(content string) string { + if m := regexp.MustCompile(`(?m)^([ \t]*)"mcp"`).FindStringSubmatch(content); m != nil { + return m[1] + " " + } + if m := regexp.MustCompile(`(?m)^([ \t]*)"[^"]+"[ \t]*:`).FindStringSubmatch(content); m != nil { + return m[1] + } + return " " +} + +// sanityCheck rejects edits that would produce unbalanced braces outside +// strings — a proxy for "jsonc still plausibly parses". +func sanityCheck(content string) error { + inStr, esc := false, false + depth := 0 + for _, r := range content { + switch { + case inStr && esc: + esc = false + case inStr && r == '\\': + esc = true + case inStr && r == '"': + inStr = false + case !inStr && r == '"': + inStr = true + case !inStr && r == '{': + depth++ + case !inStr && r == '}': + depth-- + } + } + if depth != 0 { + return fmt.Errorf("refusing edit: braces unbalanced (depth %d)", depth) + } + return nil +} diff --git a/install/opencode_test.go b/install/opencode_test.go new file mode 100644 index 0000000..c9cca52 --- /dev/null +++ b/install/opencode_test.go @@ -0,0 +1,101 @@ +package install + +import ( + "strings" + "testing" +) + +const testBin = "/usr/local/bin/knowledge-mcp" + +var testFlags = []string{"--root", "/org", "--global", "/global"} + +func TestMergeMCPEntryIntoExistingMCP(t *testing.T) { + existing := []byte("{\n \"$schema\": \"https://opencode.ai/config.json\",\n // a comment\n \"mcp\": {\n \"codebase-memory-mcp\": { \"command\": [\"/x\"], \"type\": \"local\" }\n },\n \"model\": \"a/b\"\n}\n") + out, changed, err := MergeMCPEntry(existing, testBin, testFlags) + if err != nil { + t.Fatalf("MergeMCPEntry() error: %v", err) + } + if !changed { + t.Fatal("expected changed=true") + } + s := string(out) + for _, want := range []string{ + "$schema", "// a comment", "codebase-memory-mcp", "\"model\": \"a/b\"", + "// knowledge-mcp:start", "// knowledge-mcp:end", + "\"command\": [\"/usr/local/bin/knowledge-mcp\", \"--root\", \"/org\", \"--global\", \"/global\"]", + "\"type\": \"local\"", + } { + if !strings.Contains(s, want) { + t.Errorf("output missing %q:\n%s", want, s) + } + } +} + +func TestMergeMCPEntryNoMCPKey(t *testing.T) { + existing := []byte("{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"model\": \"a/b\"\n}\n") + out, _, err := MergeMCPEntry(existing, testBin, testFlags) + if err != nil { + t.Fatalf("MergeMCPEntry() error: %v", err) + } + s := string(out) + if !strings.Contains(s, "\"mcp\": {") { + t.Errorf("mcp object not appended:\n%s", s) + } + if strings.LastIndex(s, "\"mcp\"") < strings.LastIndex(s, "\"model\"") { + t.Errorf("mcp should come after existing keys:\n%s", s) + } +} + +func TestMergeMCPEntryReplacesMarkedRegion(t *testing.T) { + existing := []byte("{\n \"mcp\": {\n // knowledge-mcp:start\n \"knowledge-mcp\": { \"command\": [\"/old/bin\"], \"type\": \"local\" },\n // knowledge-mcp:end\n \"other\": { \"command\": [\"/o\"], \"type\": \"local\" }\n }\n}\n") + out, changed, err := MergeMCPEntry(existing, testBin, testFlags) + if err != nil { + t.Fatalf("MergeMCPEntry() error: %v", err) + } + if !changed { + t.Fatal("expected changed=true") + } + s := string(out) + if strings.Contains(s, "/old/bin") { + t.Errorf("old entry not replaced:\n%s", s) + } + if strings.Count(s, "knowledge-mcp:start") != 1 || strings.Count(s, "knowledge-mcp:end") != 1 { + t.Errorf("markers duplicated:\n%s", s) + } + if !strings.Contains(s, "\"other\"") { + t.Errorf("sibling entry lost:\n%s", s) + } +} + +func TestMergeMCPEntryHandWrittenEntryBlocks(t *testing.T) { + existing := []byte("{\n \"mcp\": {\n \"knowledge-mcp\": { \"command\": [\"/manual\"], \"type\": \"local\" }\n }\n}\n") + _, _, err := MergeMCPEntry(existing, testBin, testFlags) + if err == nil { + t.Fatal("expected error for unmarked hand-written entry") + } + if !strings.Contains(err.Error(), "without knowledge-mcp markers") { + t.Fatalf("error should explain the ownership conflict: %v", err) + } +} + +func TestMergeMCPEntryUnbalancedBraces(t *testing.T) { + existing := []byte("{ \"mcp\": { }") + _, _, err := MergeMCPEntry(existing, testBin, testFlags) + if err == nil { + t.Fatal("expected sanity error for unbalanced braces") + } + if strings.Contains(err.Error(), "without knowledge-mcp markers") { + t.Fatalf("wrong error: %v", err) + } +} + +func TestMergeMCPEntryEmptyFile(t *testing.T) { + out, _, err := MergeMCPEntry(nil, testBin, testFlags) + if err != nil { + t.Fatalf("MergeMCPEntry() error: %v", err) + } + s := string(out) + if !strings.Contains(s, "\"$schema\"") || !strings.Contains(s, "\"mcp\"") { + t.Fatalf("new file should be a full config with mcp:\n%s", s) + } +} diff --git a/install/paths.go b/install/paths.go index 9900788..ca660c3 100644 --- a/install/paths.go +++ b/install/paths.go @@ -51,4 +51,4 @@ func OpencodeConfigPath() (string, error) { func fileExists(p string) bool { info, err := os.Stat(p) return err == nil && !info.IsDir() -} \ No newline at end of file +} diff --git a/install/paths_test.go b/install/paths_test.go index 56aa7ff..f57d352 100644 --- a/install/paths_test.go +++ b/install/paths_test.go @@ -88,4 +88,4 @@ func TestRecordRoundTrip(t *testing.T) { if _, ok := LoadRecord(); ok { t.Fatal("LoadRecord() ok for missing record") } -} \ No newline at end of file +} diff --git a/install/record.go b/install/record.go index c4edbcb..2a68513 100644 --- a/install/record.go +++ b/install/record.go @@ -55,4 +55,4 @@ func LoadRecord() (Record, bool) { return Record{}, false } return rec, true -} \ No newline at end of file +} From 2f785b23c288bb84afba03461cbfb0973bfe3dca Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Fri, 11 Sep 2026 14:48:44 -0400 Subject: [PATCH 5/9] GH-20: render agent instructions and skill and plugin templates --- install/blocks.go | 70 ++++++++++++++++++++++++++++++++++++++++++ install/blocks_test.go | 55 +++++++++++++++++++++++++++++++++ install/plugin.go | 52 +++++++++++++++++++++++++++++++ install/plugin_test.go | 19 ++++++++++++ install/skill.go | 55 +++++++++++++++++++++++++++++++++ install/skill_test.go | 26 ++++++++++++++++ 6 files changed, 277 insertions(+) create mode 100644 install/blocks.go create mode 100644 install/blocks_test.go create mode 100644 install/plugin.go create mode 100644 install/plugin_test.go create mode 100644 install/skill.go create mode 100644 install/skill_test.go diff --git a/install/blocks.go b/install/blocks.go new file mode 100644 index 0000000..5a51170 --- /dev/null +++ b/install/blocks.go @@ -0,0 +1,70 @@ +package install + +import ( + "strings" + + "github.com/renderorange/knowledge-mcp/tools" +) + +const ( + agentsStartMark = "" + agentsEndMark = "" +) + +// UpsertBlock replaces the marker-delimited region of content (markers +// inclusive) when present, otherwise appends the block at the end. +func UpsertBlock(existing, block []byte, startMark, endMark string) []byte { + content := string(existing) + start := strings.Index(content, startMark) + end := strings.Index(content, endMark) + if start >= 0 && end > start { + endLine := strings.Index(content[end:], "\n") + if endLine < 0 { + endLine = len(content) - end + } + head := content[:start] + tail := content[end+endLine:] + if strings.HasSuffix(head, "\n\n") { + head = strings.TrimRight(head, "\n") + "\n\n" + } + return []byte(head + string(block) + tail) + } + sep := "\n" + trimmed := strings.TrimRight(content, "\n") + if trimmed != "" && !strings.HasSuffix(trimmed, "\n\n") { + sep = "\n\n" + } + return []byte(trimmed + sep + string(block) + "\n") +} + +// RenderAgentsBlock renders the minimal always-loaded instruction block. +func RenderAgentsBlock(registry []tools.ToolSpec) []byte { + var rows strings.Builder + for _, spec := range registry { + rows.WriteString("| `" + spec.Name + "` | " + spec.Purpose + " |\n") + } + block := agentsStartMark + "\n" + + "## Persistent Knowledge Store (knowledge-mcp)\n" + + "\n" + + "Projects keep distilled knowledge (conventions, subsystem notes, decisions) in\n" + + ".agents/ stores queried through MCP tools. The store is the source of truth;\n" + + "this block is regenerated by `knowledge-mcp install`.\n" + + "\n" + + "### Tool use\n" + + "\n" + + "| Tool | When |\n" + + "|---|---|\n" + + rows.String() + + "### Hard rules\n" + + "\n" + + "- **Summaries are not rules.** list_knowledge summaries OMIT enforcement\n" + + " details. Before acting in a domain, run query_knowledge and read the FULL\n" + + " detail of matching entries.\n" + + "- **No duplication.** Never store rules that already live in an always-loaded\n" + + " instructions file; never copy AGENTS.md rules into entries intentlessly.\n" + + "- **Detail is a distilled summary** (10-20 lines of query-able facts), never\n" + + " a copy of source files.\n" + + "- **Provenance.** Every entry records where it was learned (source field).\n" + + agentsEndMark + "\n" + return []byte(block) +} diff --git a/install/blocks_test.go b/install/blocks_test.go new file mode 100644 index 0000000..236ee90 --- /dev/null +++ b/install/blocks_test.go @@ -0,0 +1,55 @@ +package install + +import ( + "strings" + "testing" + + "github.com/renderorange/knowledge-mcp/tools" +) + +const agentsStart = "" +const agentsEnd = "" + +func TestUpsertBlockAppends(t *testing.T) { + existing := []byte("# My Instructions\n\nSome user content.\n") + block := []byte(agentsStart + "\nNEW BLOCK\n" + agentsEnd) + out := UpsertBlock(existing, block, agentsStart, agentsEnd) + s := string(out) + if !strings.HasPrefix(s, "# My Instructions\n") { + t.Fatalf("user content lost:\n%s", s) + } + if !strings.HasSuffix(strings.TrimSpace(s), agentsEnd) { + t.Fatalf("block not appended:\n%s", s) + } +} + +func TestUpsertBlockReplaces(t *testing.T) { + existing := []byte("start\n" + agentsStart + "\nOLD\n" + agentsEnd + "\nend\n") + block := []byte(agentsStart + "\nNEW\n" + agentsEnd) + out := UpsertBlock(existing, block, agentsStart, agentsEnd) + s := string(out) + if !strings.Contains(s, "NEW") || strings.Contains(s, "OLD") { + t.Fatalf("replace failed:\n%s", s) + } + if !strings.Contains(s, "start\n") || !strings.Contains(s, "end\n") { + t.Fatalf("surrounding content lost:\n%s", s) + } + if strings.Count(s, agentsStart) != 1 { + t.Fatalf("duplicate start marker:\n%s", s) + } +} + +func TestRenderAgentsBlockListsEveryTool(t *testing.T) { + block := string(RenderAgentsBlock(tools.Registry)) + for _, spec := range tools.Registry { + if !strings.Contains(block, "`"+spec.Name+"`") { + t.Errorf("block missing tool %q", spec.Name) + } + } + if !strings.Contains(block, "Summaries are not rules") { + t.Errorf("block missing the summaries-are-not-rules rule") + } + if !strings.Contains(block, agentsStart) || !strings.Contains(block, agentsEnd) { + t.Error("block missing markers") + } +} diff --git a/install/plugin.go b/install/plugin.go new file mode 100644 index 0000000..75d3168 --- /dev/null +++ b/install/plugin.go @@ -0,0 +1,52 @@ +package install + +// RenderPlugin renders the opencode augment plugin (tool.execute.after on +// Grep/Glob). It is a full-file artifact: overwritten on reinstall. +func RenderPlugin(binPath string) []byte { + src := "// knowledge-mcp:start\n" + + "// Generated by knowledge-mcp install. Overwritten on reinstall;\n" + + "// this file is fully generated.\n" + + "import { spawn } from 'node:child_process';\n" + + "\n" + + "const BIN = '" + binPath + "';\n" + + "\n" + + "function augment(tool, args) {\n" + + " return new Promise((resolve) => {\n" + + " let done = false;\n" + + " const finish = (out) => { if (!done) { done = true; resolve(out); } };\n" + + " let child;\n" + + " try {\n" + + " child = spawn(BIN, ['hook-augment'], {\n" + + " stdio: ['pipe', 'pipe', 'ignore'],\n" + + " env: { ...process.env, KNM_LOG_LEVEL: 'error' },\n" + + " });\n" + + " } catch (err) {\n" + + " return finish('');\n" + + " }\n" + + " const timer = setTimeout(() => { try { child.kill(); } catch (err) {} finish(''); }, 3000);\n" + + " let out = '';\n" + + " child.stdout.on('data', (d) => (out += d.toString()));\n" + + " child.on('error', () => finish(''));\n" + + " child.on('close', () => { clearTimeout(timer); finish(out); });\n" + + " child.stdin.on('error', () => finish(''));\n" + + " child.stdin.end(JSON.stringify({\n" + + " hook_event_name: 'PostToolUse',\n" + + " tool_name: tool,\n" + + " tool_input: args ?? {},\n" + + " }));\n" + + " });\n" + + "}\n" + + "\n" + + "export const KnowledgeMcp = async () => ({\n" + + " 'tool.execute.after': async (input, output) => {\n" + + " const tool = input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;\n" + + " if (!tool) return;\n" + + " const extra = await augment(tool, output?.args);\n" + + " if (extra && typeof output?.output === 'string') {\n" + + " output.output += '\\n' + extra;\n" + + " }\n" + + " },\n" + + "});\n" + + "// knowledge-mcp:end\n" + return []byte(src) +} diff --git a/install/plugin_test.go b/install/plugin_test.go new file mode 100644 index 0000000..349f5d5 --- /dev/null +++ b/install/plugin_test.go @@ -0,0 +1,19 @@ +package install + +import ( + "strings" + "testing" +) + +func TestRenderPluginContent(t *testing.T) { + ts := string(RenderPlugin("/usr/local/bin/knowledge-mcp")) + for _, want := range []string{ + "// knowledge-mcp:start", "// knowledge-mcp:end", + "'hook-augment'", "/usr/local/bin/knowledge-mcp", + "tool.execute.after", "KNM_LOG_LEVEL", + } { + if !strings.Contains(ts, want) { + t.Errorf("plugin missing %q", want) + } + } +} diff --git a/install/skill.go b/install/skill.go new file mode 100644 index 0000000..38744f1 --- /dev/null +++ b/install/skill.go @@ -0,0 +1,55 @@ +package install + +import ( + "strings" + + "github.com/renderorange/knowledge-mcp/tools" +) + +// RenderSkillMD renders the knowledge-mcp skill. +func RenderSkillMD(registry []tools.ToolSpec) []byte { + var rows strings.Builder + for _, spec := range registry { + rows.WriteString("| " + spec.Name + " | " + spec.Purpose + " |\n") + } + md := "---\n" + + "name: knowledge-mcp\n" + + "description: \"Use the persistent knowledge store for project conventions, subsystems, and decisions. Triggers on: what are our conventions, does this project have rules about, how does this subsystem work, why was this decision made, capture this decision, write that down as knowledge, update that entry, knowledge store, .agents, list_knowledge, query_knowledge, write_knowledge, update_knowledge, init_knowledge.\"\n" + + "---\n" + + "\n" + + "# Knowledge Store (knowledge-mcp)\n" + + "\n" + + "Distilled project knowledge lives in .agents/.yaml files and is served\n" + + "by the knowledge-mcp MCP server. Query the store before re-deriving facts from\n" + + "source files.\n" + + "\n" + + "## Tool decision matrix\n" + + "\n" + + "| Tool | Purpose |\n" + + "|---|---|\n" + + rows.String() + + "## Session start\n" + + "\n" + + "1. list_knowledge(project=\"\") — constraints are shown first; read them.\n" + + "2. For the task's domain, query_knowledge(project, query) and read the FULL\n" + + " detail of matching entries before acting.\n" + + "\n" + + "## Capture workflow\n" + + "\n" + + "- write_knowledge when a convention, subsystem fact, or decision is established.\n" + + "- detail: 10-20 lines of query-able facts; never copy source files.\n" + + "- source: record how the knowledge was learned.\n" + + "- rule field: an imperative prohibition/requirement (surfaced first in lists).\n" + + "- update_knowledge to change or supersede existing entries.\n" + + "\n" + + "## Gotchas\n" + + "\n" + + "- summaries are not rules: list summaries omit enforcement details — query\n" + + " before acting.\n" + + "- Never duplicate always-loaded AGENTS.md instructions into the store.\n" + + "- Org-level queries return markdown SECTIONS, not whole documents.\n" + + "- Merged global entries are labeled (global) in listings.\n" + + "- Under --store (central store mode), the store is single-writer: only one\n" + + " server may run against it.\n" + return []byte(md) +} diff --git a/install/skill_test.go b/install/skill_test.go new file mode 100644 index 0000000..fe54be0 --- /dev/null +++ b/install/skill_test.go @@ -0,0 +1,26 @@ +package install + +import ( + "strings" + "testing" + + "github.com/renderorange/knowledge-mcp/tools" +) + +func TestRenderSkillMDContent(t *testing.T) { + md := string(RenderSkillMD(tools.Registry)) + for _, want := range []string{ + "---\n", "name: knowledge-mcp", "description:", + "list_knowledge", "query_knowledge", "write_knowledge", + "## Gotchas", "summaries are not rules", + } { + if !strings.Contains(md, want) { + t.Errorf("skill missing %q", want) + } + } + for _, spec := range tools.Registry { + if !strings.Contains(md, spec.Name) { + t.Errorf("skill missing tool %q", spec.Name) + } + } +} From ed22aeaa0132fb7ba615e22db849da0120476cf9 Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Fri, 11 Sep 2026 14:53:43 -0400 Subject: [PATCH 6/9] GH-20: orchestrate install and dispatch subcommands --- install/blocks.go | 13 +++- install/install.go | 168 ++++++++++++++++++++++++++++++++++++++++ install/install_test.go | 134 ++++++++++++++++++++++++++++++++ install/opencode.go | 6 +- install/uninstall.go | 8 ++ main.go | 27 +++++++ 6 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 install/install.go create mode 100644 install/install_test.go create mode 100644 install/uninstall.go diff --git a/install/blocks.go b/install/blocks.go index 5a51170..fd7ca3d 100644 --- a/install/blocks.go +++ b/install/blocks.go @@ -19,11 +19,12 @@ func UpsertBlock(existing, block []byte, startMark, endMark string) []byte { end := strings.Index(content, endMark) if start >= 0 && end > start { endLine := strings.Index(content[end:], "\n") - if endLine < 0 { - endLine = len(content) - end + endCut := len(content) + if endLine >= 0 { + endCut = end + endLine + 1 } head := content[:start] - tail := content[end+endLine:] + tail := content[endCut:] if strings.HasSuffix(head, "\n\n") { head = strings.TrimRight(head, "\n") + "\n\n" } @@ -34,7 +35,11 @@ func UpsertBlock(existing, block []byte, startMark, endMark string) []byte { if trimmed != "" && !strings.HasSuffix(trimmed, "\n\n") { sep = "\n\n" } - return []byte(trimmed + sep + string(block) + "\n") + out := trimmed + sep + string(block) + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + return []byte(out) } // RenderAgentsBlock renders the minimal always-loaded instruction block. diff --git a/install/install.go b/install/install.go new file mode 100644 index 0000000..ebe9530 --- /dev/null +++ b/install/install.go @@ -0,0 +1,168 @@ +package install + +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/renderorange/knowledge-mcp/projects" + "github.com/renderorange/knowledge-mcp/tools" +) + +type strSlice []string + +func (s *strSlice) String() string { return fmt.Sprintf("%v", []string(*s)) } +func (s *strSlice) Set(v string) error { + if v == "" { + return errors.New("empty path") + } + *s = append(*s, v) + return nil +} + +// installConfig is the all-flags input to an install run. runInstall is the +// pure core; Run parses argv and fills it in. +type installConfig struct { + Roots []string + Projects []string + Global string + Store string + Index string + Version string + BinPath string +} + +// Run parses install flags and performs an install. +func Run(args []string, version string) error { + var roots, projects strSlice + var globalPath, storeDir, indexOverride string + fs := flag.NewFlagSet("install", flag.ExitOnError) + fs.Var(&roots, "root", "Org root whose immediate children are projects (repeatable)") + fs.Var(&projects, "project", "Single project root (repeatable)") + fs.StringVar(&globalPath, "global", "", "Path to a global knowledge store shared across all projects") + fs.StringVar(&storeDir, "store", "", "Central directory for all knowledge stores; in-tree .agents/ is ignored when set") + fs.StringVar(&indexOverride, "index", "", "Override the search index location") + _ = fs.Parse(args) + + binPath, err := os.Executable() + if err != nil { + return fmt.Errorf("resolve executable: %w", err) + } + + reports, err := runInstall(installConfig{ + Roots: roots, Projects: projects, Global: globalPath, + Store: storeDir, Index: indexOverride, Version: version, BinPath: binPath, + }) + for _, r := range reports { + fmt.Println(r) + } + return err +} + +func runInstall(cfg installConfig) ([]string, error) { + if len(cfg.Roots) == 0 && len(cfg.Projects) == 0 && cfg.Global == "" { + return nil, errors.New("error: at least one --project, --root, or --global is required") + } + + // Same resolution semantics as the server. + if _, _, err := projects.BuildWithStore(cfg.Roots, cfg.Projects, cfg.Global, cfg.Store); err != nil { + return nil, fmt.Errorf("error: %w", err) + } + + flags := commandFlags(cfg) + + // Merge the opencode config first — the riskiest step. On failure print + // the paste-able snippet and abort before writing anything else. + configPath, err := OpencodeConfigPath() + if err != nil { + return nil, fmt.Errorf("opencode config: %w", err) + } + var existingCfg []byte + if b, rerr := os.ReadFile(configPath); rerr == nil { + existingCfg = b + } + merged, changed, mergeErr := MergeMCPEntry(existingCfg, cfg.BinPath, flags) + if mergeErr != nil { + entry, _ := renderEntry(cfg.BinPath, flags, " ") + fmt.Fprintf(os.Stderr, "error: could not merge the MCP entry into %s: %v\n", configPath, mergeErr) + fmt.Fprintf(os.Stderr, "Add the following to the \"mcp\" section of %s manually:\n%s", configPath, entry) + return nil, mergeErr + } + if changed { + if err := os.WriteFile(configPath, merged, 0644); err != nil { + return nil, fmt.Errorf("write %s: %w", configPath, err) + } + } + + artifacts := []Artifact{{Path: configPath, Kind: "mcp_entry_block"}} + var reports []string + + skillDir := filepath.Join(OpencodeDir(), "skills", "knowledge-mcp") + if err := os.MkdirAll(skillDir, 0755); err != nil { + return nil, fmt.Errorf("skill dir: %w", err) + } + skillPath := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(skillPath, RenderSkillMD(tools.Registry), 0644); err != nil { + return nil, fmt.Errorf("skill: %w", err) + } + reports = append(reports, "installed skill "+skillPath) + artifacts = append(artifacts, Artifact{Path: skillPath, Kind: "skill"}) + + pluginPath := filepath.Join(OpencodeDir(), "plugins", "knowledge-mcp.ts") + if err := os.MkdirAll(filepath.Dir(pluginPath), 0755); err != nil { + return nil, fmt.Errorf("plugin dir: %w", err) + } + if err := os.WriteFile(pluginPath, RenderPlugin(cfg.BinPath), 0644); err != nil { + return nil, fmt.Errorf("plugin: %w", err) + } + reports = append(reports, "installed plugin "+pluginPath) + artifacts = append(artifacts, Artifact{Path: pluginPath, Kind: "plugin"}) + + agentsPath := filepath.Join(OpencodeDir(), "AGENTS.md") + var agentsContent []byte + if b, rerr := os.ReadFile(agentsPath); rerr == nil { + agentsContent = b + } + upserted := UpsertBlock(agentsContent, RenderAgentsBlock(tools.Registry), agentsStartMark, agentsEndMark) + if err := os.WriteFile(agentsPath, upserted, 0644); err != nil { + return nil, fmt.Errorf("AGENTS.md: %w", err) + } + reports = append(reports, "installed AGENTS.md block "+agentsPath) + artifacts = append(artifacts, Artifact{Path: agentsPath, Kind: "agents_md_block"}) + + rec := Record{ + Version: cfg.Version, BinPath: cfg.BinPath, + Roots: cfg.Roots, Projects: cfg.Projects, Global: cfg.Global, + Store: cfg.Store, Index: cfg.Index, Artifacts: artifacts, + } + if err := SaveRecord(rec); err != nil { + return nil, fmt.Errorf("record: %w", err) + } + reports = append(reports, "installed record "+recordPath()) + reports = append(reports, "note: restart opencode to load the new MCP server and plugin") + return reports, nil +} + +// commandFlags expands cfg into the server command array recorded in the +// opencode mcp entry. +func commandFlags(cfg installConfig) []string { + var flags []string + for _, r := range cfg.Roots { + flags = append(flags, "--root", r) + } + for _, p := range cfg.Projects { + flags = append(flags, "--project", p) + } + if cfg.Global != "" { + flags = append(flags, "--global", cfg.Global) + } + if cfg.Store != "" { + flags = append(flags, "--store", cfg.Store) + } + if cfg.Index != "" { + flags = append(flags, "--index", cfg.Index) + } + return flags +} diff --git a/install/install_test.go b/install/install_test.go new file mode 100644 index 0000000..95a1492 --- /dev/null +++ b/install/install_test.go @@ -0,0 +1,134 @@ +package install + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func setupConfig(t *testing.T) (string, string) { + t.Helper() + cfgRoot := t.TempDir() + opencodeDir := filepath.Join(cfgRoot, "opencode") + t.Setenv("KNM_CONFIG_DIR", filepath.Join(cfgRoot, "knowledge-mcp")) + return cfgRoot, opencodeDir +} + +func writeAgents(t *testing.T, opencodeDir, content string) { + t.Helper() + if err := os.MkdirAll(opencodeDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(opencodeDir, "AGENTS.md"), []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func TestRunInstallWritesAllArtifacts(t *testing.T) { + _, opencodeDir := setupConfig(t) + writeAgents(t, opencodeDir, "# Existing\n\nuser rules\n") + + root := t.TempDir() + global := t.TempDir() + cfg := installConfig{ + Roots: []string{root}, Global: global, + Version: "v1.2.3", BinPath: filepath.Join(t.TempDir(), "knowledge-mcp"), + } + reports, err := runInstall(cfg) + if err != nil { + t.Fatalf("runInstall() error: %v", err) + } + if len(reports) == 0 { + t.Fatal("no reports") + } + + for _, want := range []string{ + filepath.Join(opencodeDir, "skills", "knowledge-mcp", "SKILL.md"), + filepath.Join(opencodeDir, "plugins", "knowledge-mcp.ts"), + } { + if _, statErr := os.Stat(want); statErr != nil { + t.Errorf("artifact missing: %s (%v)", want, statErr) + } + } + agents, _ := os.ReadFile(filepath.Join(opencodeDir, "AGENTS.md")) + if !strings.Contains(string(agents), "knowledge-mcp:start") { + t.Error("AGENTS.md block not upserted") + } + if !strings.HasPrefix(string(agents), "# Existing") { + t.Error("user AGENTS.md content lost") + } + cfgPath, _ := OpencodeConfigPath() + cfgData, _ := os.ReadFile(cfgPath) + if !strings.Contains(string(cfgData), "\"type\": \"local\"") { + t.Errorf("opencode config entry missing:\n%s", cfgData) + } + if !strings.Contains(string(cfgData), "--root") { + t.Errorf("flags missing from config entry:\n%s", cfgData) + } + rec, ok := LoadRecord() + if !ok { + t.Fatal("record not saved") + } + if rec.Version != "v1.2.3" || len(rec.Artifacts) != 4 { + t.Errorf("record mismatch: %+v", rec) + } + if len(rec.Roots) != 1 || rec.Roots[0] != root { + t.Errorf("record roots mismatch: %+v", rec) + } +} + +func TestRunInstallIdempotent(t *testing.T) { + _, opencodeDir := setupConfig(t) + writeAgents(t, opencodeDir, "# Existing\n") + root := t.TempDir() + + run2 := func() { + t.Helper() + if _, err := runInstall(installConfig{ + Roots: []string{root}, Version: "v1", BinPath: "/bin/k", + }); err != nil { + t.Fatalf("runInstall() error: %v", err) + } + } + run2() + + snapshot := func() map[string]string { + out := map[string]string{} + cfgPath, _ := OpencodeConfigPath() + for _, p := range []string{ + cfgPath, + filepath.Join(opencodeDir, "AGENTS.md"), + filepath.Join(ConfigDir(), "install.json"), + filepath.Join(opencodeDir, "skills", "knowledge-mcp", "SKILL.md"), + filepath.Join(opencodeDir, "plugins", "knowledge-mcp.ts"), + } { + b, _ := os.ReadFile(p) + out[p] = string(b) + } + return out + } + before := snapshot() + run2() + after := snapshot() + for p, want := range before { + if after[p] != want { + t.Errorf("non-idempotent artifact %s\n--- before ---\n%s\n--- after ---\n%s", p, want, after[p]) + } + } +} + +func TestRunInstallRejectsMissingRoot(t *testing.T) { + setupConfig(t) + cfg := installConfig{Roots: []string{filepath.Join(t.TempDir(), "does-not-exist")}, Version: "v", BinPath: "/bin/k"} + if _, err := runInstall(cfg); err == nil { + t.Fatal("expected error for nonexistent root") + } +} + +func TestRunInstallRequiresFlags(t *testing.T) { + setupConfig(t) + if _, err := runInstall(installConfig{Version: "v", BinPath: "/bin/k"}); err == nil { + t.Fatal("expected error when no roots/projects/global given") + } +} diff --git a/install/opencode.go b/install/opencode.go index 86565c4..c027cff 100644 --- a/install/opencode.go +++ b/install/opencode.go @@ -73,10 +73,10 @@ func replaceMarkedRegion(content, binPath string, flags []string) ([]byte, bool, endIdx := strings.Index(content, mcpMarkerEnd) after := content[endIdx:] newline := strings.Index(after, "\n") - if newline < 0 { - newline = len(after) + replaceEnd := len(content) + if newline >= 0 { + replaceEnd = endIdx + newline + 1 } - replaceEnd := endIdx + newline lineStart := strings.LastIndex(content[:startIdx], "\n") + 1 indent := content[lineStart:startIdx] diff --git a/install/uninstall.go b/install/uninstall.go new file mode 100644 index 0000000..c28eb2f --- /dev/null +++ b/install/uninstall.go @@ -0,0 +1,8 @@ +package install + +import "fmt" + +// RunUninstall is implemented in Task 7. +func RunUninstall(args []string) error { + return fmt.Errorf("uninstall not implemented yet") +} diff --git a/main.go b/main.go index 2891941..b3f9091 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "github.com/mark3labs/mcp-go/server" + "github.com/renderorange/knowledge-mcp/install" "github.com/renderorange/knowledge-mcp/knowledge" "github.com/renderorange/knowledge-mcp/projects" "github.com/renderorange/knowledge-mcp/search" @@ -38,6 +39,26 @@ func (p *pathList) Set(v string) error { } func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "install": + if err := install.Run(os.Args[2:], version); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + return + case "uninstall": + if err := install.RunUninstall(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + return + case "hook-augment": + runHook() + return + } + } + var roots pathList var projs pathList showVersion := flag.Bool("version", false, "Print version and exit") @@ -171,6 +192,12 @@ func indexOrgKnowledge(knowledgeDir, orgName string, idx *search.Index) { } } +// runHook is implemented with the hook package in Task 8. +func runHook() { + fmt.Fprintln(os.Stderr, "hook-augment not implemented yet") + os.Exit(0) +} + // registerTools adds every registry tool with an available handler. // list_projects is only served in org-wide mode (its handler is absent // otherwise) — the loop does the gating by design. From 355649c2b19b1d870205a6ba9dca7e1bab2674a0 Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Fri, 11 Sep 2026 14:55:01 -0400 Subject: [PATCH 7/9] GH-20: implement data-safe uninstall --- install/uninstall.go | 109 ++++++++++++++++++++++++++++++++++++-- install/uninstall_test.go | 104 ++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 install/uninstall_test.go diff --git a/install/uninstall.go b/install/uninstall.go index c28eb2f..eabcc05 100644 --- a/install/uninstall.go +++ b/install/uninstall.go @@ -1,8 +1,111 @@ package install -import "fmt" +import ( + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "strings" +) -// RunUninstall is implemented in Task 7. +// RunUninstall parses uninstall flags and detaches the client integration. func RunUninstall(args []string) error { - return fmt.Errorf("uninstall not implemented yet") + fs := flag.NewFlagSet("uninstall", flag.ExitOnError) + removeBinary := fs.Bool("remove-binary", false, "also delete the recorded knowledge-mcp binary") + _ = fs.Parse(args) + + reports, err := runUninstall(*removeBinary) + for _, r := range reports { + fmt.Println(r) + } + return err +} + +// runUninstall removes exactly what install wrote. It never touches +// .agents/ dirs, stores, or indexes. +func runUninstall(removeBinary bool) ([]string, error) { + rec, ok := LoadRecord() + if !ok { + return nil, errors.New("no install record found at " + recordPath() + "; nothing to uninstall (knowledge data is never touched)") + } + + var reports []string + + // Strip the AGENTS.md block (marker-delimited only). + if err := stripFileBlock(filepath.Join(OpencodeDir(), "AGENTS.md"), agentsStartMark, agentsEndMark, &reports); err != nil { + return reports, err + } + + // Strip the opencode config marker block. + if cfgPath, err := OpencodeConfigPath(); err == nil { + if serr := stripFileBlock(cfgPath, mcpMarkerStart, mcpMarkerEnd, &reports); serr != nil { + return reports, serr + } + } + + // Delete generated full-file artifacts. + for _, art := range rec.Artifacts { + switch art.Kind { + case "skill", "plugin": + if err := os.Remove(art.Path); err != nil && !os.IsNotExist(err) { + reports = append(reports, "skip "+art.Path+": "+err.Error()) + continue + } + reports = append(reports, "removed "+art.Path) + } + } + // Remove the skill dir if we made it and it is now empty. + skillDir := filepath.Join(OpencodeDir(), "skills", "knowledge-mcp") + _ = os.Remove(skillDir) // rmdir semantics: fails silently if non-empty + reports = append(reports, "removed "+skillDir) + + if err := os.Remove(recordPath()); err != nil && !os.IsNotExist(err) { + return reports, fmt.Errorf("remove record: %w", err) + } + reports = append(reports, "removed "+recordPath()) + + if removeBinary && rec.BinPath != "" { + if err := os.Remove(rec.BinPath); err != nil && !os.IsNotExist(err) { + reports = append(reports, "skip binary "+rec.BinPath+": "+err.Error()) + } else { + reports = append(reports, "removed binary "+rec.BinPath) + } + } + + return reports, nil +} + +// stripFileBlock strips a marker-delimited region (markers inclusive) from +// file at path. Missing file or missing markers -> skip report, no error. +func stripFileBlock(path, startMark, endMark string, reports *[]string) error { + b, err := os.ReadFile(path) + if err != nil { + *reports = append(*reports, "skip "+path+": not found") + return nil + } + stripped := stripBlock(string(b), startMark, endMark) + if stripped == string(b) { + *reports = append(*reports, "skip "+path+": markers not found; left untouched") + return nil + } + if err := os.WriteFile(path, []byte(stripped), 0644); err != nil { + return fmt.Errorf("strip %s: %w", path, err) + } + *reports = append(*reports, "stripped "+path) + return nil +} + +// stripBlock removes a marker-delimited region (markers inclusive). +func stripBlock(content, startMark, endMark string) string { + start := strings.Index(content, startMark) + end := strings.Index(content, endMark) + if start < 0 || end <= start { + return content + } + endLine := strings.Index(content[end:], "\n") + if endLine < 0 { + return content[:start] + content[len(content):] + } + return content[:start] + content[end+endLine+1:] } diff --git a/install/uninstall_test.go b/install/uninstall_test.go new file mode 100644 index 0000000..22353e8 --- /dev/null +++ b/install/uninstall_test.go @@ -0,0 +1,104 @@ +package install + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunUninstallRemovesEverything(t *testing.T) { + _, opencodeDir := setupConfig(t) + agentsPath := filepath.Join(opencodeDir, "AGENTS.md") + writeAgents(t, opencodeDir, "# Keep me\n\nsome rules\n") + + root := t.TempDir() + binPath := filepath.Join(t.TempDir(), "bin", "knowledge-mcp") + if err := os.MkdirAll(filepath.Dir(binPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(binPath, []byte("fake binary"), 0755); err != nil { + t.Fatal(err) + } + if _, err := runInstall(installConfig{Roots: []string{root}, Version: "v1", BinPath: binPath}); err != nil { + t.Fatalf("setup install error: %v", err) + } + + reports, err := runUninstall(true) + if err != nil { + t.Fatalf("runUninstall() error: %v", err) + } + if len(reports) == 0 { + t.Fatal("no reports") + } + + for _, gone := range []string{ + filepath.Join(opencodeDir, "skills", "knowledge-mcp", "SKILL.md"), + filepath.Join(opencodeDir, "skills", "knowledge-mcp"), + filepath.Join(opencodeDir, "plugins", "knowledge-mcp.ts"), + filepath.Join(ConfigDir(), "install.json"), + binPath, + } { + if _, statErr := os.Stat(gone); statErr == nil { + t.Errorf("artifact still present: %s", gone) + } + } + + agents, _ := os.ReadFile(agentsPath) + agentsStr := string(agents) + if strings.Contains(agentsStr, "knowledge-mcp:start") { + t.Error("AGENTS.md markers not stripped") + } + if !strings.Contains(agentsStr, "# Keep me") { + t.Error("user AGENTS.md content lost on uninstall") + } + + cfgPath, _ := OpencodeConfigPath() + cfgData, _ := os.ReadFile(cfgPath) + if strings.Contains(string(cfgData), "knowledge-mcp:start") { + t.Error("opencode config markers not stripped") + } +} + +func TestRunUninstallSkipsMissingMarkers(t *testing.T) { + _, opencodeDir := setupConfig(t) + agentsPath := filepath.Join(opencodeDir, "AGENTS.md") + writeAgents(t, opencodeDir, "# plain\n") + + root := t.TempDir() + if _, err := runInstall(installConfig{Roots: []string{root}, Version: "v", BinPath: "/bin/k"}); err != nil { + t.Fatalf("setup install error: %v", err) + } + // Simulate the user deleting the block themselves. + if err := os.WriteFile(agentsPath, []byte("# plain\n"), 0644); err != nil { + t.Fatal(err) + } + + reports, err := runUninstall(false) + if err != nil { + t.Fatalf("runUninstall() error: %v", err) + } + found := false + for _, r := range reports { + if strings.Contains(r, "skip") && strings.Contains(r, "AGENTS.md") { + found = true + } + } + if !found { + t.Errorf("expected skip report for AGENTS.md, got %v", reports) + } + agents, _ := os.ReadFile(agentsPath) + if string(agents) != "# plain\n" { + t.Errorf("AGENTS.md altered without markers: %q", string(agents)) + } +} + +func TestRunUninstallNoRecord(t *testing.T) { + cfg := t.TempDir() + t.Setenv("KNM_CONFIG_DIR", filepath.Join(cfg, "knowledge-mcp")) + if _, err := runUninstall(false); err == nil { + t.Fatal("expected error when no record exists") + } else if !strings.Contains(err.Error(), "knowledge-mcp") { + t.Fatalf("error should name the missing record: %v", err) + } +} From 097ecab34043aff7a09469f0e922c6e900dd0661 Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Fri, 11 Sep 2026 14:58:00 -0400 Subject: [PATCH 8/9] GH-20: add hook-augment subcommand backed by install record --- hook/hook.go | 65 ++++++++++++++++ hook/hook_test.go | 67 +++++++++++++++++ hook/match_test.go | 179 +++++++++++++++++++++++++++++++++++++++++++++ hook/search.go | 158 +++++++++++++++++++++++++++++++++++++++ main.go | 9 ++- 5 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 hook/hook.go create mode 100644 hook/hook_test.go create mode 100644 hook/match_test.go create mode 100644 hook/search.go diff --git a/hook/hook.go b/hook/hook.go new file mode 100644 index 0000000..83700c3 --- /dev/null +++ b/hook/hook.go @@ -0,0 +1,65 @@ +package hook + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/renderorange/knowledge-mcp/install" + "github.com/renderorange/knowledge-mcp/projects" +) + +// pluginEvent is the JSON the opencode plugin sends on stdin. +type pluginEvent struct { + HookEventName string `json:"hook_event_name"` + ToolName string `json:"tool_name"` + ToolInput map[string]any `json:"tool_input"` +} + +// Run executes hook-augment: resolve the cwd project from the recorded +// install config and print matching knowledge entries for the tool's +// pattern. Any failure leaves output empty — a hook must never break the +// client tool it augments. +func Run(in io.Reader, out io.Writer) error { + data, err := io.ReadAll(io.LimitReader(in, 1<<20)) + if err != nil { + return err + } + var ev pluginEvent + if err := json.Unmarshal(data, &ev); err != nil { + return err + } + pattern, ok := ev.ToolInput["pattern"].(string) + if !ok || strings.TrimSpace(pattern) == "" { + return nil + } + + rec, ok := install.LoadRecord() + if !ok { + return nil + } + res, _, err := projects.BuildWithStore(rec.Roots, rec.Projects, rec.Global, rec.Store) + if err != nil { + return nil + } + + cwd := os.Getenv("KNM_HOOK_CWD") + if cwd == "" { + cwd, _ = os.Getwd() + } + proj, org := resolveCwd(res, cwd) + + hits := Search(res, proj, org, pattern, 3) + if len(hits) == 0 { + return nil + } + + fmt.Fprintf(out, "Knowledge store hits for %q:\n", pattern) + for _, h := range hits { + fmt.Fprintf(out, "- %s/%s: %s — query_knowledge(%q, project=%q) for detail\n", + h.Address, h.ID, h.Summary, pattern, h.Address) + } + return nil +} diff --git a/hook/hook_test.go b/hook/hook_test.go new file mode 100644 index 0000000..f232f80 --- /dev/null +++ b/hook/hook_test.go @@ -0,0 +1,67 @@ +package hook + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/renderorange/knowledge-mcp/install" + "github.com/renderorange/knowledge-mcp/knowledge" +) + +func TestRunGolden(t *testing.T) { + root := t.TempDir() + proj := filepath.Join(root, "proj") + if err := knowledge.EnsureDir(filepath.Join(proj, ".agents")); err != nil { + t.Fatal(err) + } + kf := &knowledge.KnowledgeFile{Project: "proj", Version: 1, Entries: []knowledge.Entry{ + {ID: "conv-001", Summary: "Tmp directory rules", Detail: "docs in ./tmp/docs", Source: "u", Date: knowledge.Today()}, + }} + if err := knowledge.Save(knowledge.CategoryFilePath(filepath.Join(proj, ".agents"), "conventions"), kf); err != nil { + t.Fatal(err) + } + + cfgRoot := t.TempDir() + t.Setenv("KNM_CONFIG_DIR", filepath.Join(cfgRoot, "knowledge-mcp")) + t.Setenv("KNM_HOOK_CWD", proj) + if err := install.SaveRecord(install.Record{Roots: []string{root}, Version: "v", BinPath: "/x"}); err != nil { + t.Fatal(err) + } + + in := bytes.NewBufferString(`{"hook_event_name":"PostToolUse","tool_name":"Grep","tool_input":{"pattern":"tmp directory"}}`) + var out bytes.Buffer + if err := Run(in, &out); err != nil { + t.Fatalf("Run() error: %v", err) + } + s := out.String() + if !strings.Contains(s, "conv-001") || !strings.Contains(s, "query_knowledge") { + t.Fatalf("output missing hit:\n%s", s) + } +} + +func TestRunNoRecordSilent(t *testing.T) { + t.Setenv("KNM_CONFIG_DIR", filepath.Join(t.TempDir(), "knowledge-mcp")) + t.Setenv("KNM_HOOK_CWD", t.TempDir()) + in := bytes.NewBufferString(`{"hook_event_name":"PostToolUse","tool_name":"Grep","tool_input":{"pattern":"x"}}`) + var out bytes.Buffer + if err := Run(in, &out); err != nil { + t.Fatalf("Run() error: %v", err) + } + if out.Len() != 0 { + t.Fatalf("expected empty output, got:\n%s", out.String()) + } +} + +func TestRunNoPatternSilent(t *testing.T) { + t.Setenv("KNM_CONFIG_DIR", filepath.Join(t.TempDir(), "knowledge-mcp")) + in := bytes.NewBufferString(`{"hook_event_name":"PostToolUse","tool_name":"Grep","tool_input":{}}`) + var out bytes.Buffer + if err := Run(in, &out); err != nil { + t.Fatalf("Run() error: %v", err) + } + if out.Len() != 0 { + t.Fatalf("expected empty output, got %q", out.String()) + } +} diff --git a/hook/match_test.go b/hook/match_test.go new file mode 100644 index 0000000..cec9025 --- /dev/null +++ b/hook/match_test.go @@ -0,0 +1,179 @@ +package hook + +import ( + "os" + "path/filepath" + "testing" + + "github.com/renderorange/knowledge-mcp/knowledge" + "github.com/renderorange/knowledge-mcp/projects" +) + +func seedEntry(t *testing.T, dir, cat string, entries ...knowledge.Entry) { + t.Helper() + if err := knowledge.EnsureDir(dir); err != nil { + t.Fatal(err) + } + kf := &knowledge.KnowledgeFile{Project: "p", Version: 1, Entries: entries} + if err := knowledge.Save(knowledge.CategoryFilePath(dir, cat), kf); err != nil { + t.Fatal(err) + } +} + +func lookupRef(t *testing.T, res *projects.Resolver, path string) *projects.Ref { + t.Helper() + ref, ok := res.RefForPath(path) + if !ok { + t.Fatalf("RefForPath(%s) not ok", path) + } + return &ref +} + +func TestSearchProjectHits(t *testing.T) { + root := t.TempDir() + proj := filepath.Join(root, "proj") + seedEntry(t, filepath.Join(proj, ".agents"), "conventions", + knowledge.Entry{ID: "conv-001", Summary: "Tmp directory rules", Detail: "All project docs go in ./tmp/docs/ only", Source: "user", Date: knowledge.Today()}, + knowledge.Entry{ID: "conv-002", Summary: "Unrelated", Detail: "nothing here", Source: "user", Date: knowledge.Today()}, + ) + res, _, err := projects.Build([]string{root}, nil, "") + if err != nil { + t.Fatal(err) + } + hits := Search(res, lookupRef(t, res, proj), nil, "tmp directory", 3) + if len(hits) != 1 { + t.Fatalf("len(hits) = %d, want 1 (%+v)", len(hits), hits) + } + if hits[0].ID != "conv-001" || hits[0].Address != "proj" { + t.Errorf("hit = %+v", hits[0]) + } +} + +func TestSearchWholePatternSubstring(t *testing.T) { + root := t.TempDir() + proj := filepath.Join(root, "proj") + seedEntry(t, filepath.Join(proj, ".agents"), "decisions", + knowledge.Entry{ID: "dec-001", Summary: "Session compression", Detail: "Use sessioncompress for compaction", Source: "test", Date: knowledge.Today()}, + ) + res, _, err := projects.Build([]string{root}, nil, "") + if err != nil { + t.Fatal(err) + } + hits := Search(res, lookupRef(t, res, proj), nil, "sessioncompress", 3) + if len(hits) != 1 || hits[0].ID != "dec-001" { + t.Fatalf("hits = %+v", hits) + } +} + +func TestSearchIncludesOrgAndGlobal(t *testing.T) { + root := t.TempDir() + proj := filepath.Join(root, "proj") + seedEntry(t, filepath.Join(proj, ".agents"), "conventions", + knowledge.Entry{ID: "conv-001", Summary: "Project one", Detail: "compression project", Source: "t", Date: knowledge.Today()}, + ) + orgKnowledge := filepath.Join(root, ".agents", "knowledge") + if err := os.MkdirAll(orgKnowledge, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(orgKnowledge, "architecture.md"), + []byte("## Compression\norg-level compression notes\n"), 0644); err != nil { + t.Fatal(err) + } + globalDir := filepath.Join(t.TempDir(), "global") + seedEntry(t, filepath.Join(globalDir, ".agents"), "conventions", + knowledge.Entry{ID: "conv-009", Summary: "Global compression rule", Detail: "handle compression globally", Source: "t", Date: knowledge.Today()}, + ) + res, _, err := projects.Build([]string{root}, nil, globalDir) + if err != nil { + t.Fatal(err) + } + hits := Search(res, lookupRef(t, res, proj), orgRef(t, res), "compression", 5) + var addresses []string + for _, h := range hits { + addresses = append(addresses, h.Address) + } + found := func(addr string) bool { + for _, a := range addresses { + if a == addr { + return true + } + } + return false + } + if !found("proj") || !found("_global") { + t.Errorf("hits missing sources: %+v", hits) + } +} + +func orgRef(t *testing.T, res *projects.Resolver) *projects.Ref { + t.Helper() + for _, ref := range res.Snapshot() { + if ref.Kind == projects.KindOrg { + return &ref + } + } + t.Fatal("no org ref") + return nil +} + +func TestSearchRankingAndLimit(t *testing.T) { + root := t.TempDir() + proj := filepath.Join(root, "proj") + seedEntry(t, filepath.Join(proj, ".agents"), "conventions", + knowledge.Entry{ID: "conv-001", Summary: "Alpha", Detail: "zilch zilch zilch", Source: "t", Date: knowledge.Today()}, + knowledge.Entry{ID: "conv-002", Summary: "Beta", Detail: "zilch zilch", Source: "t", Date: knowledge.Today()}, + knowledge.Entry{ID: "conv-003", Summary: "Gamma", Detail: "zilch", Source: "t", Date: knowledge.Today()}, + knowledge.Entry{ID: "conv-004", Summary: "Delta", Detail: "unrelated", Source: "t", Date: knowledge.Today()}, + ) + res, _, err := projects.Build([]string{root}, nil, "") + if err != nil { + t.Fatal(err) + } + hits := Search(res, lookupRef(t, res, proj), nil, "zilch", 2) + if len(hits) != 2 { + t.Fatalf("len(hits) = %d, want 2", len(hits)) + } + if hits[0].ID != "conv-001" || hits[1].ID != "conv-002" { + t.Errorf("ranking wrong: %+v", hits) + } +} + +func TestResolveCwdMatrix(t *testing.T) { + root := t.TempDir() + proj := filepath.Join(root, "proj") + sub := filepath.Join(proj, "deep", "dir") + other := t.TempDir() + globalDir := filepath.Join(t.TempDir(), "global") + for _, d := range []string{sub, other, filepath.Join(root, ".agents", "knowledge"), filepath.Join(globalDir, ".agents")} { + if err := os.MkdirAll(d, 0755); err != nil { + t.Fatal(err) + } + } + res, _, err := projects.Build([]string{root}, nil, globalDir) + if err != nil { + t.Fatal(err) + } + + pRef, oRef := resolveCwd(res, proj) + if pRef == nil || pRef.Address != "proj" { + t.Errorf("exact project: got %+v", pRef) + } + if oRef == nil || oRef.Kind != projects.KindOrg { + t.Errorf("exact project: expected containing org ref, got %+v", oRef) + } + + pRef, oRef = resolveCwd(res, sub) + if pRef == nil || pRef.Address != "proj" { + t.Errorf("nested under project: got %+v", pRef) + } + + pRef, _ = resolveCwd(res, other) + if pRef != nil { + t.Errorf("outside roots: got %+v", pRef) + } + + pRef, oRef = resolveCwd(res, root) + if pRef != nil || oRef == nil || oRef.Kind != projects.KindOrg { + t.Errorf("org root cwd: got pRef=%+v oRef=%+v", pRef, oRef) + } +} diff --git a/hook/search.go b/hook/search.go new file mode 100644 index 0000000..e25da5a --- /dev/null +++ b/hook/search.go @@ -0,0 +1,158 @@ +package hook + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/renderorange/knowledge-mcp/knowledge" + "github.com/renderorange/knowledge-mcp/projects" +) + +// Hit is one knowledge-store match for the augment output. +type Hit struct { + Address string + ID string + Summary string +} + +// Search scans, in precedence order: the project store (proj), the org +// knowledge files (org), then the global store. Never the bleve index. +func Search(res *projects.Resolver, proj, org *projects.Ref, pattern string, limit int) []Hit { + type cand struct { + addr, id, summary string + score int + } + var cands []cand + addEntry := func(addr, id, summary, detail, rule string) { + cands = append(cands, cand{addr, id, summary, scoreEntry(pattern, summary, detail, rule)}) + } + if proj != nil && proj.Kind == projects.KindProject { + for _, cat := range knowledge.ValidCategories() { + kf, err := knowledge.Load(knowledge.CategoryFilePath(res.AgentsDir(*proj), cat)) + if err != nil { + continue + } + for _, e := range kf.Entries { + addEntry(proj.Address, e.ID, e.Summary, e.Detail, e.Rule) + } + } + } + if org != nil { + for _, file := range []string{"architecture.md", "review.md"} { + data, err := os.ReadFile(filepath.Join(res.OrgKnowledgeDir(*org), file)) + if err != nil { + continue + } + for _, sec := range knowledge.SplitSections(string(data)) { + addEntry(org.Address, "org-"+file+"::"+sec[0], file+": "+sec[0], sec[1], "") + } + } + } + if gref, ok := res.GlobalRef(); ok { + for _, cat := range knowledge.ValidCategories() { + kf, err := knowledge.Load(knowledge.CategoryFilePath(res.AgentsDir(gref), cat)) + if err != nil { + continue + } + for _, e := range kf.Entries { + addEntry(gref.Address, e.ID, e.Summary, e.Detail, e.Rule) + } + } + } + + sort.SliceStable(cands, func(i, j int) bool { return cands[i].score > cands[j].score }) + if limit > len(cands) { + limit = len(cands) + } + var hits []Hit + for _, c := range cands[:limit] { + if c.score <= 0 { + continue + } + hits = append(hits, Hit{Address: c.addr, ID: c.id, Summary: truncate(c.summary, 90)}) + } + return hits +} + +// scoreEntry: whole-pattern substring beats word overlap; zero when no +// overlap at all. +func scoreEntry(pattern, summary, detail, rule string) int { + p := strings.ToLower(strings.TrimSpace(pattern)) + if p == "" { + return 0 + } + text := strings.ToLower(summary + "\n" + detail + "\n" + rule) + if strings.Contains(text, p) { + return 100 + strings.Count(text, p) + } + n := 0 + for _, tok := range strings.Fields(p) { + if strings.Contains(text, tok) { + n++ + } + } + return n +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return strings.TrimSpace(s[:max-3]) + "..." +} + +// resolveCwd maps a cwd to its project ref (longest matching known project +// path) and its containing org ref (longest org-root prefix). Both may be +// nil. +func resolveCwd(res *projects.Resolver, cwd string) (*projects.Ref, *projects.Ref) { + canon, ok := projects.CanonicalPath(cwd) + if !ok { + return nil, nil + } + if ref, ok := res.RefForPath(canon); ok { + switch ref.Kind { + case projects.KindProject: + return &ref, orgOf(res, canon) + case projects.KindOrg: + return nil, &ref + } + } + var bestProj *projects.Ref + for _, ref := range res.Snapshot() { + if ref.Kind != projects.KindProject { + continue + } + if pathWithin(canon, ref.Path) { + if bestProj == nil || len(ref.Path) > len(bestProj.Path) { + copyRef := ref + bestProj = ©Ref + } + } + } + if bestProj == nil { + return nil, nil + } + return bestProj, orgOf(res, canon) +} + +func orgOf(res *projects.Resolver, canon string) *projects.Ref { + var best *projects.Ref + for _, ref := range res.Snapshot() { + if ref.Kind != projects.KindOrg { + continue + } + if pathWithin(canon, ref.Path) { + if best == nil || len(ref.Path) > len(best.Path) { + copyRef := ref + best = ©Ref + } + } + } + return best +} + +func pathWithin(path, dir string) bool { + return path == dir || strings.HasPrefix(path, dir+string(filepath.Separator)) +} diff --git a/main.go b/main.go index b3f9091..b05124e 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "github.com/mark3labs/mcp-go/server" + "github.com/renderorange/knowledge-mcp/hook" "github.com/renderorange/knowledge-mcp/install" "github.com/renderorange/knowledge-mcp/knowledge" "github.com/renderorange/knowledge-mcp/projects" @@ -192,9 +193,13 @@ func indexOrgKnowledge(knowledgeDir, orgName string, idx *search.Index) { } } -// runHook is implemented with the hook package in Task 8. +// runHook executes hook-augment against the recorded install config. func runHook() { - fmt.Fprintln(os.Stderr, "hook-augment not implemented yet") + if err := hook.Run(os.Stdin, os.Stdout); err != nil { + if os.Getenv("KNM_LOG_LEVEL") != "" { + fmt.Fprintf(os.Stderr, "hook-augment: %v\n", err) + } + } os.Exit(0) } From b18577c3f2b3e992f5c28c08ab664eb6d9d2ad8d Mon Sep 17 00:00:00 2001 From: Blaine Motsinger Date: Fri, 11 Sep 2026 14:58:39 -0400 Subject: [PATCH 9/9] GH-20: add fetch-style installer script and README install docs --- README.md | 37 ++++++++++++++++++-- install.sh | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) create mode 100755 install.sh diff --git a/README.md b/README.md index 30590c0..6893f61 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,41 @@ An MCP server for persistent agent knowledge. Agents query and write structured ## Quick Start +One command downloads the binary for your platform and wires everything up +(OpenCode MCP entry, agent instructions block, skill, and the Grep/Glob +augment hook): + +```bash +curl -fsSL https://raw.githubusercontent.com/renderorange/knowledge-mcp/main/install.sh -o install.sh +chmod +x install.sh +./install.sh --root /path/to/org --global /path/to/global-store +``` + +Flags passed to `install.sh` become the server's flags (`--root`, +`--project`, `--global`, `--store`, `--index`). `--version ` pins a +specific release; `--dry-run` previews what would happen. + +What install writes: + +- the MCP server entry in `~/.config/opencode/opencode.jsonc` (marker-delimited) +- an instruction block in `~/.config/opencode/AGENTS.md` +- the `knowledge-mcp` skill at `~/.config/opencode/skills/knowledge-mcp/SKILL.md` +- the augment plugin at `~/.config/opencode/plugins/knowledge-mcp.ts` +- an install record at `~/.config/knowledge-mcp/install.json` + +The plugin intercepts Grep/Glob and appends matching knowledge-store entries +to the results, so agent searches surface stored conventions automatically. + +Remove the integration (data-safe — store files are never touched): + +```bash +knowledge-mcp uninstall [--remove-binary] +``` + +The client manages the server process — you don't need to run it manually. + +### Build From Source + ```bash make build ``` @@ -14,8 +49,6 @@ Or with a specific version: make build/1.2.3 ``` -Then configure your MCP client (see [Agent Configuration](#agent-configuration) below). The client manages the server process — you don't need to run it manually. - Check the version: ```bash diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..941dbc2 --- /dev/null +++ b/install.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +OWNER="renderorange" +REPO="knowledge-mcp" +BINARY="knowledge-mcp" + +VERSION="" +DRY_RUN=0 +PASSTHRU=() + +usage() { + cat <<'EOF' +Usage: install.sh [--version ] [--dry-run] + [--root ] [--project ] [--global ] + [--store ] [--index ] + +Fetches the knowledge-mcp binary for this platform from GitHub releases, +installs it, and runs `knowledge-mcp install` with the given flags. + + --version install a specific release instead of the latest + --dry-run print actions without performing them + +All other flags are passed to `knowledge-mcp install` unchanged. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage; exit 0 ;; + --dry-run) DRY_RUN=1; shift ;; + --version) + [[ $# -ge 2 ]] || { echo "error: --version requires a value" >&2; exit 1; } + VERSION="$2"; shift 2 ;; + --version=*) VERSION="${1#*=}"; shift ;; + --) shift; PASSTHRU+=("$@"); break ;; + *) PASSTHRU+=("$1"); shift ;; + esac +done + +case "$(uname -s)" in + Linux) OS="linux" ;; + Darwin) OS="darwin" ;; + *) echo "error: unsupported OS '$(uname -s)'; supported: linux, darwin" >&2; exit 1 ;; +esac +case "$(uname -m)" in + x86_64|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) echo "error: unsupported architecture '$(uname -m)'; supported: amd64, arm64" >&2; exit 1 ;; +esac + +if [[ -n "$VERSION" ]]; then + URL="https://github.com/${OWNER}/${REPO}/releases/download/${VERSION}/${BINARY}-${OS}-${ARCH}" +else + URL="https://github.com/${OWNER}/${REPO}/releases/latest/download/${BINARY}-${OS}-${ARCH}" +fi + +install_dir() { + local dir + local saved_ifs="$IFS" + IFS=: read -r -a path_entries <<< "${PATH:-}" + IFS="$saved_ifs" + for dir in "${path_entries[@]}"; do + [[ -n "$dir" && -w "$dir" ]] || continue + case "$dir" in + "$HOME"/*|/usr/local/bin) + printf '%s' "$dir" + return 0 + ;; + esac + done + local candidate="${HOME}/.local/bin" + mkdir -p "$candidate" + printf '%s' "$candidate" +} + +DEST="$(install_dir)/${BINARY}" + +if [[ "$DRY_RUN" == "1" ]]; then + cat <&2 + exit 1 +fi +chmod +x "${TMPDIR_BIN}/${BINARY}" +mv "${TMPDIR_BIN}/${BINARY}" "$DEST" +echo "installed $DEST" + +exec "$DEST" install "${PASSTHRU[@]}" \ No newline at end of file