diff --git a/README.md b/README.md
index 5a766b4..30590c0 100644
--- a/README.md
+++ b/README.md
@@ -75,6 +75,42 @@ Repeat `--root` and `--project` as needed; they can be mixed:
}
```
+### Central store
+
+`--store
` re-roots every knowledge store under one central directory,
+keeping repos clean of `.agents/`:
+
+```jsonc
+// opencode.jsonc
+{
+ "mcp": {
+ "knowledge-mcp": {
+ "command": ["knowledge-mcp", "--root", "/path/to/org", "--store", "/path/to/knowledge"],
+ "type": "local"
+ }
+ }
+}
+```
+
+Layout:
+
+```
+/
+ .index/ # bleve index (default; --index wins)
+ /.agents/ # per-project store (qualified names nest:
+ # / -> ///.agents)
+ /.agents/knowledge/ # org-level markdown
+```
+
+- In-tree `.agents/` dirs are ignored (startup warnings list them). Migrate
+ by moving them: `mkdir -p / && mv /.agents //.agents`.
+- Only `--global` stays where it is; it merges into listings as usual.
+- `init_knowledge` initializes the central store for resolvable paths and
+ errors for others (add them via `--project`/`--root` first).
+- The store directory itself must exist and is never created for you.
+- The store is single-writer: its search index and files do not support
+ concurrent access, so point only one server at a store.
+
### Shared global store
`--global ` adds one knowledge store shared across every project. Querying or listing any project automatically merges the global store's entries alongside the project's own.
@@ -102,14 +138,14 @@ General notes:
- Each `--root` discovers its immediate children as projects (one level deep — pass deeper directories as additional flags).
- Duplicate project basenames across roots are addressed as `/` (e.g. `work/api`); `list_projects` shows which names need qualification.
- `query_knowledge` accepts org root names to search that root's `.agents/knowledge/` files. Org-level documents are indexed **per `##` section**, so a query returns the matching section(s), not the entire file.
-- Multi-entry configurations store the search index under `$XDG_STATE_HOME/knowledge-mcp/` (default `~/.local/state/knowledge-mcp/`). Single-flag configurations keep the index inside their own `.agents/`.
+- Multi-entry configurations store the search index under `$XDG_STATE_HOME/knowledge-mcp/` (default `~/.local/state/knowledge-mcp/`). Single-flag configurations keep the index inside their own `.agents/` — unless `--store` is set, in which case the index lives at `/.index`.
- `--index ` overrides the index location in all modes.
## MCP Tools
| Tool | Description |
|------|-------------|
-| `init_knowledge` | Create `.agents/` directory structure for a project |
+| `init_knowledge` | Create a project's knowledge directory structure (central store under `--store`) |
| `write_knowledge` | Add a new knowledge entry |
| `query_knowledge` | Full-text search with category filters |
| `list_knowledge` | List all entries; entries with rules shown first as constraints |
diff --git a/main.go b/main.go
index aef2ec9..feb9e7f 100644
--- a/main.go
+++ b/main.go
@@ -44,6 +44,7 @@ func main() {
showVersion := flag.Bool("version", false, "Print version and exit")
globalPath := flag.String("global", "", "Path to a global knowledge store shared across all projects")
indexOverride := flag.String("index", "", "Override the search index location")
+ storeDir := flag.String("store", "", "Central directory for all knowledge stores; in-tree .agents/ is ignored when set")
flag.Var(&roots, "root", "Org root whose immediate children are projects (repeatable)")
flag.Var(&projs, "project", "Single project root (repeatable)")
flag.Parse()
@@ -58,7 +59,7 @@ func main() {
os.Exit(1)
}
- resolver, warnings, err := projects.Build([]string(roots), []string(projs), *globalPath)
+ resolver, warnings, err := projects.BuildWithStore([]string(roots), []string(projs), *globalPath, *storeDir)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
@@ -67,7 +68,7 @@ func main() {
log.Printf("warning: %s", w)
}
- indexBasePath, err := indexLocation(*indexOverride, roots, projs, resolver.Entries())
+ indexBasePath, err := indexLocation(*indexOverride, *storeDir, roots, projs, resolver.Entries())
if err != nil {
log.Fatalf("determine index location: %v", err)
}
@@ -226,19 +227,18 @@ func indexAll(res *projects.Resolver, idx *search.Index) {
for _, ref := range res.Snapshot() {
switch ref.Kind {
case projects.KindProject:
- indexProjectKnowledge(ref.Path, ref.Address, idx)
+ indexProjectKnowledge(res.AgentsDir(ref), ref.Address, idx)
case projects.KindOrg:
- indexOrgKnowledge(ref.Path, ref.Name, idx)
+ indexOrgKnowledge(res.OrgKnowledgeDir(ref), ref.Name, idx)
case projects.KindGlobal:
- indexProjectKnowledge(ref.Path, ref.Address, idx)
+ indexProjectKnowledge(res.AgentsDir(ref), ref.Address, idx)
}
}
}
-// indexProjectKnowledge indexes all knowledge files in a single project
+// indexProjectKnowledge indexes all knowledge files under an agents dir
// under the given addressing name.
-func indexProjectKnowledge(projectPath, projectName string, idx *search.Index) {
- agentsDir := filepath.Join(projectPath, ".agents")
+func indexProjectKnowledge(agentsDir, projectName string, idx *search.Index) {
for _, cat := range knowledge.ValidCategories() {
catPath := knowledge.CategoryFilePath(agentsDir, cat)
kf, err := knowledge.Load(catPath)
@@ -260,13 +260,12 @@ func indexProjectKnowledge(projectPath, projectName string, idx *search.Index) {
}
}
-// indexOrgKnowledge indexes an org root's .agents/knowledge/ files as
-// individual markdown sections, so queries return only the relevant
-// section instead of the entire document.
-func indexOrgKnowledge(root, orgName string, idx *search.Index) {
- orgAgentsDir := filepath.Join(root, ".agents", "knowledge")
+// indexOrgKnowledge indexes an org's knowledge files as individual markdown
+// sections, so queries return only the relevant section instead of the
+// entire document.
+func indexOrgKnowledge(knowledgeDir, orgName string, idx *search.Index) {
for _, catFile := range []string{"architecture.md", "review.md"} {
- filePath := filepath.Join(orgAgentsDir, catFile)
+ filePath := filepath.Join(knowledgeDir, catFile)
data, err := os.ReadFile(filePath)
if err != nil {
continue
@@ -325,13 +324,16 @@ func splitSections(data string) [][2]string {
return sections
}
-// indexLocation picks the bleve index path: explicit override, legacy
-// per-config locations for single-entry configs, or a hashed XDG state
-// dir for multi-entry configs.
-func indexLocation(override string, roots, projs pathList, canonicalEntries []string) (string, error) {
+// 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.
+func indexLocation(override, store string, roots, projs pathList, canonicalEntries []string) (string, error) {
if override != "" {
return filepath.Abs(override)
}
+ if store != "" {
+ return filepath.Join(store, ".index"), nil
+ }
if len(roots)+len(projs) == 1 {
single := roots
if len(single) == 0 {
diff --git a/main_test.go b/main_test.go
index 3dc11c1..d1a54ad 100644
--- a/main_test.go
+++ b/main_test.go
@@ -57,7 +57,7 @@ func TestPathListSet(t *testing.T) {
}
func TestIndexLocationOverride(t *testing.T) {
- p, err := indexLocation("/custom/index", pathList{}, pathList{}, nil)
+ p, err := indexLocation("/custom/index", "", pathList{}, pathList{}, nil)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
@@ -67,7 +67,7 @@ func TestIndexLocationOverride(t *testing.T) {
}
func TestIndexLocationLegacySingleProject(t *testing.T) {
- p, err := indexLocation("", pathList{}, pathList{"/proj"}, nil)
+ p, err := indexLocation("", "", pathList{}, pathList{"/proj"}, nil)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
@@ -77,7 +77,7 @@ func TestIndexLocationLegacySingleProject(t *testing.T) {
}
func TestIndexLocationLegacySingleRoot(t *testing.T) {
- p, err := indexLocation("", pathList{"/root"}, pathList{}, nil)
+ p, err := indexLocation("", "", pathList{"/root"}, pathList{}, nil)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
@@ -90,7 +90,7 @@ func TestIndexLocationMultiEntryStateDir(t *testing.T) {
t.Setenv("XDG_STATE_HOME", t.TempDir())
entries := []string{"/rootB", "/rootA", "/projC"}
- p, err := indexLocation("", pathList{"/rootB", "/rootA"}, pathList{"/projC"}, entries)
+ p, err := indexLocation("", "", pathList{"/rootB", "/rootA"}, pathList{"/projC"}, entries)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
@@ -99,7 +99,7 @@ func TestIndexLocationMultiEntryStateDir(t *testing.T) {
}
// Same entries -> same location (deterministic).
- p2, err := indexLocation("", pathList{"/rootA", "/rootB"}, pathList{"/projC"}, entries)
+ p2, err := indexLocation("", "", pathList{"/rootA", "/rootB"}, pathList{"/projC"}, entries)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
@@ -109,7 +109,7 @@ func TestIndexLocationMultiEntryStateDir(t *testing.T) {
// Different entries -> different location.
entries2 := []string{"/rootB", "/rootA", "/projD"}
- p3, err := indexLocation("", pathList{"/rootB", "/rootA"}, pathList{"/projD"}, entries2)
+ p3, err := indexLocation("", "", pathList{"/rootB", "/rootA"}, pathList{"/projD"}, entries2)
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
@@ -124,7 +124,7 @@ func TestIndexLocationXDGDefault(t *testing.T) {
if err != nil {
t.Skip("no home dir")
}
- p, err := indexLocation("", pathList{"/a", "/b"}, pathList{}, []string{"/a", "/b"})
+ p, err := indexLocation("", "", pathList{"/a", "/b"}, pathList{}, []string{"/a", "/b"})
if err != nil {
t.Fatalf("indexLocation() error: %v", err)
}
@@ -132,3 +132,33 @@ func TestIndexLocationXDGDefault(t *testing.T) {
t.Errorf("location = %q, want under %q", p, want)
}
}
+
+func TestIndexLocationStoreDefault(t *testing.T) {
+ p, err := indexLocation("", "/store", pathList{"/root"}, pathList{}, nil)
+ if err != nil {
+ t.Fatalf("indexLocation() error: %v", err)
+ }
+ if want := filepath.Join("/store", ".index"); p != want {
+ t.Errorf("location = %q, want %q", p, want)
+ }
+}
+
+func TestIndexLocationStoreMultiEntry(t *testing.T) {
+ p, err := indexLocation("", "/store", pathList{"/a", "/b"}, pathList{"/c"}, []string{"/a", "/b", "/c"})
+ if err != nil {
+ t.Fatalf("indexLocation() error: %v", err)
+ }
+ if want := filepath.Join("/store", ".index"); p != want {
+ t.Errorf("location = %q, want %q", p, want)
+ }
+}
+
+func TestIndexLocationOverrideBeatsStore(t *testing.T) {
+ p, err := indexLocation("/custom/index", "/store", pathList{}, pathList{}, nil)
+ if err != nil {
+ t.Fatalf("indexLocation() error: %v", err)
+ }
+ if p != "/custom/index" {
+ t.Errorf("location = %q, want %q", p, "/custom/index")
+ }
+}
diff --git a/projects/resolver.go b/projects/resolver.go
index c76acc7..964b1d3 100644
--- a/projects/resolver.go
+++ b/projects/resolver.go
@@ -50,13 +50,16 @@ type Resolver struct {
roots []string
explicit []string
global string // path to global knowledge store (empty if none)
+ store string // central store dir (empty if disabled)
}
-// Build constructs a resolver from org roots, explicit project paths, and an
-// optional global knowledge path. It returns startup warnings for ambiguous
-// names and path overlaps. It returns an error for unresolvable configurations:
-// nonexistent or non-directory paths, empty values, and duplicate root basenames.
-func Build(roots, projects []string, global string) (*Resolver, []string, error) {
+// BuildWithStore constructs a resolver from org roots, explicit project
+// paths, an optional global knowledge path, and an optional central store
+// directory. When store is non-empty, project and org knowledge dirs are
+// rooted under it (see AgentsDir/OrgKnowledgeDir), in-tree .agents/ dirs
+// are ignored with warnings, and discovered refs inside the store dir are
+// excluded from discovery.
+func BuildWithStore(roots, projects []string, global, store string) (*Resolver, []string, error) {
var warnings []string
rootPaths, err := canonicalAll(roots, "root")
@@ -71,6 +74,15 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error)
rootPaths = dedupePaths(rootPaths, &warnings, "root")
projPaths = dedupePaths(projPaths, &warnings, "project")
+ storePath := ""
+ if store != "" {
+ stored, err := canonicalAll([]string{store}, "store")
+ if err != nil {
+ return nil, nil, err
+ }
+ storePath = stored[0]
+ }
+
// Root basenames must be unique: they qualify project names.
seenRoots := map[string]string{}
for _, rp := range rootPaths {
@@ -96,6 +108,10 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error)
var refs []Ref
for _, pp := range projPaths {
+ if storePath != "" && pathWithin(pp, storePath) {
+ warnings = append(warnings, fmt.Sprintf(
+ "explicit project %s is inside the --store directory", pp))
+ }
refs = append(refs, Ref{
Name: filepath.Base(pp),
Qualifier: filepath.Base(filepath.Dir(pp)),
@@ -109,13 +125,30 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error)
return nil, nil, fmt.Errorf("scan root %q: %w", rp, err)
}
warnings = append(warnings, childWarnings...)
- refs = append(refs, children...)
- if hasOrgKnowledge(rp) {
+ for _, child := range children {
+ if storePath != "" && pathWithin(child.Path, storePath) {
+ warnings = append(warnings, fmt.Sprintf(
+ "skipping %s under root %s: inside the --store directory",
+ child.Name, rp))
+ continue
+ }
+ if storePath != "" && child.Name == ".index" {
+ warnings = append(warnings, fmt.Sprintf(
+ "skipping %s under root %s: name collides with the --store index directory",
+ child.Name, rp))
+ continue
+ }
+ refs = append(refs, child)
+ }
+ if hasOrgKnowledge(rp, storePath) {
refs = append(refs, Ref{
Name: filepath.Base(rp),
Path: rp,
Kind: KindOrg,
})
+ } else if storePath != "" && hasOrgKnowledge(rp, "") {
+ warnings = append(warnings, fmt.Sprintf(
+ "ignoring in-tree org knowledge at %s (--store is set)", rp))
}
}
@@ -152,6 +185,7 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error)
roots: rootPaths,
explicit: projPaths,
global: global,
+ store: storePath,
}
// Dedupe by path; project kind wins over org kind.
@@ -201,6 +235,7 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error)
ref.Address = ref.Qualifier + "/" + ref.Name
}
r.byBare[ref.Name] = append(r.byBare[ref.Name], *ref)
+ r.byPath[ref.Path] = *ref
}
// Address collisions should be impossible after the checks above;
@@ -212,6 +247,33 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error)
r.byAddress[ref.Address] = ref
}
+ if storePath != "" {
+ for _, ref := range r.refs {
+ switch ref.Kind {
+ case KindProject:
+ inTree := filepath.Join(ref.Path, ".agents")
+ if r.AgentsDir(ref) == inTree {
+ continue
+ }
+ info, err := os.Stat(inTree)
+ if err == nil && info.IsDir() {
+ warnings = append(warnings, fmt.Sprintf(
+ "ignoring in-tree .agents at %s (--store is set)", ref.Path))
+ }
+ case KindOrg:
+ inTree := filepath.Join(ref.Path, ".agents", "knowledge")
+ if r.OrgKnowledgeDir(ref) == inTree {
+ continue
+ }
+ info, err := os.Stat(inTree)
+ if err == nil && info.IsDir() {
+ warnings = append(warnings, fmt.Sprintf(
+ "ignoring in-tree org knowledge at %s (--store is set)", ref.Path))
+ }
+ }
+ }
+ }
+
// Warn about ambiguous bare names.
for name, refs := range r.byBare {
if len(refs) > 1 && !hasOrgRef(refs) {
@@ -224,6 +286,12 @@ func Build(roots, projects []string, global string) (*Resolver, []string, error)
return r, warnings, nil
}
+// Build constructs a resolver without a central store (in-tree .agents/
+// stores, the original behavior).
+func Build(roots, projects []string, global string) (*Resolver, []string, error) {
+ return BuildWithStore(roots, projects, global, "")
+}
+
// Resolve maps a bare or qualified name to a Ref. Unknown names and
// ambiguous bare names return errors listing known names or candidates.
func (r *Resolver) Resolve(name string) (Ref, error) {
@@ -321,10 +389,59 @@ func (r *Resolver) Covers(path string) bool {
return false
}
+// underStore reports whether a path is at or under r.store.
+func (r *Resolver) underStore(path string) bool {
+ if r.store == "" {
+ return false
+ }
+ return pathWithin(path, r.store)
+}
+
+// StoreEnabled reports whether a central store is configured.
+func (r *Resolver) StoreEnabled() bool {
+ return r.store != ""
+}
+
+// AgentsDir returns the knowledge directory for a ref: its in-tree
+// .agents/ when no store is configured, or its slot in the central store.
+// The global store is never re-rooted.
+func (r *Resolver) AgentsDir(ref Ref) string {
+ if r.store != "" && ref.Kind != KindGlobal {
+ return filepath.Join(r.store, ref.Address, ".agents")
+ }
+ return filepath.Join(ref.Path, ".agents")
+}
+
+// OrgKnowledgeDir returns the org-level knowledge directory for an org ref.
+func (r *Resolver) OrgKnowledgeDir(ref Ref) string {
+ return filepath.Join(r.AgentsDir(ref), "knowledge")
+}
+
+// RefForPath returns the ref registered for an exact canonical path.
+func (r *Resolver) RefForPath(path string) (Ref, bool) {
+ resolved, ok := canonicalize(path)
+ if !ok {
+ return Ref{}, false
+ }
+ ref, ok := r.byPath[resolved]
+ return ref, ok
+}
+
+// CanonicalPath returns the canonical absolute form of path when it names
+// an existing directory (symlinks resolved); otherwise ok is false.
+func CanonicalPath(path string) (string, bool) {
+ return canonicalize(path)
+}
+
+// pathWithin reports whether path equals dir or lies under it.
+func pathWithin(path, dir string) bool {
+ return path == dir || strings.HasPrefix(path, dir+string(filepath.Separator))
+}
+
func (r *Resolver) resolveDynamicBare(name string) (Ref, bool) {
for _, root := range r.roots {
path := filepath.Join(root, name)
- if info, err := os.Stat(path); err == nil && info.IsDir() {
+ if info, err := os.Stat(path); err == nil && info.IsDir() && r.dynamicPathOK(path, name) {
return Ref{
Name: name, Qualifier: filepath.Base(root),
Path: path, Kind: KindProject, Address: name,
@@ -342,7 +459,7 @@ func (r *Resolver) resolveDynamicQualified(name string) (Ref, bool) {
continue
}
path := filepath.Join(root, base)
- if info, err := os.Stat(path); err == nil && info.IsDir() {
+ if info, err := os.Stat(path); err == nil && info.IsDir() && r.dynamicPathOK(path, base) {
return Ref{
Name: base, Qualifier: qualifier,
Path: path, Kind: KindProject, Address: name,
@@ -352,6 +469,20 @@ func (r *Resolver) resolveDynamicQualified(name string) (Ref, bool) {
return Ref{}, false
}
+// dynamicPathOK reports whether a path found on disk after startup is
+// eligible for dynamic resolution: paths under the central store are
+// never resurrected, and a child named .index is excluded in store mode
+// because its slot would live inside the removable index directory.
+func (r *Resolver) dynamicPathOK(path, name string) bool {
+ if r.underStore(path) {
+ return false
+ }
+ if r.store != "" && name == ".index" {
+ return false
+ }
+ return true
+}
+
// ScanRoot returns refs for the immediate child directories of root,
// skipping .agents and .git and following symlinks. It returns warnings
// for unreadable entries (e.g. permission errors, dangling symlinks).
@@ -387,8 +518,12 @@ func ScanRoot(root string) ([]Ref, []string, error) {
return refs, warnings, nil
}
-func hasOrgKnowledge(root string) bool {
- info, err := os.Stat(filepath.Join(root, ".agents", "knowledge"))
+func hasOrgKnowledge(root, store string) bool {
+ knowledgePath := filepath.Join(root, ".agents", "knowledge")
+ if store != "" {
+ knowledgePath = filepath.Join(store, filepath.Base(root), ".agents", "knowledge")
+ }
+ info, err := os.Stat(knowledgePath)
return err == nil && info.IsDir()
}
diff --git a/projects/resolver_test.go b/projects/resolver_test.go
index 40d0988..3f707a1 100644
--- a/projects/resolver_test.go
+++ b/projects/resolver_test.go
@@ -378,3 +378,272 @@ func TestGlobalRefNone(t *testing.T) {
t.Error("GlobalRef() should return false when no global is configured")
}
}
+
+func TestBuildWithStoreInvalid(t *testing.T) {
+ tmp := t.TempDir()
+
+ if _, _, err := BuildWithStore(nil, nil, "", filepath.Join(tmp, "missing")); err == nil {
+ t.Error("nonexistent store path should error")
+ }
+ file := filepath.Join(tmp, "afile")
+ os.WriteFile(file, []byte("x"), 0644)
+ if _, _, err := BuildWithStore(nil, nil, "", file); err == nil {
+ t.Error("file store path should error")
+ }
+}
+
+func TestAgentsDirWithoutStore(t *testing.T) {
+ dir := mkdir(t, t.TempDir(), "myproj")
+
+ res, _, err := Build(nil, []string{dir}, "")
+ if err != nil {
+ t.Fatalf("Build() error: %v", err)
+ }
+ ref, err := res.Resolve("myproj")
+ if err != nil {
+ t.Fatalf("Resolve() error: %v", err)
+ }
+ if want := filepath.Join(dir, ".agents"); res.AgentsDir(ref) != want {
+ t.Errorf("AgentsDir = %q, want %q", res.AgentsDir(ref), want)
+ }
+ if want := filepath.Join(dir, ".agents", "knowledge"); res.OrgKnowledgeDir(ref) != want {
+ t.Errorf("OrgKnowledgeDir = %q, want %q", res.OrgKnowledgeDir(ref), want)
+ }
+}
+
+func TestAgentsDirWithStore(t *testing.T) {
+ r1 := mkdir(t, t.TempDir(), "r1")
+ r2 := mkdir(t, t.TempDir(), "r2")
+ mkdir(t, r1, "api")
+ mkdir(t, r2, "api")
+ globalDir := mkdir(t, t.TempDir(), "global")
+ store := mkdir(t, t.TempDir(), "store")
+
+ res, _, err := BuildWithStore([]string{r1, r2}, nil, globalDir, store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ qualified, err := res.Resolve("r1/api")
+ if err != nil {
+ t.Fatalf("Resolve(r1/api) error: %v", err)
+ }
+ if want := filepath.Join(store, "r1", "api", ".agents"); res.AgentsDir(qualified) != want {
+ t.Errorf("AgentsDir(qualified) = %q, want %q", res.AgentsDir(qualified), want)
+ }
+
+ gref, err := res.Resolve("_global")
+ if err != nil {
+ t.Fatalf("Resolve(_global) error: %v", err)
+ }
+ if want := filepath.Join(globalDir, ".agents"); res.AgentsDir(gref) != want {
+ t.Errorf("AgentsDir(global) = %q, want %q (must never re-root)", res.AgentsDir(gref), want)
+ }
+}
+
+func TestBuildWithStoreExcludesStoreUnderRoot(t *testing.T) {
+ root := t.TempDir()
+ mkdir(t, root, "alpha")
+ store := mkdir(t, root, ".knowledge")
+ mkdir(t, store, "alpha") // store child mirrors a project name
+
+ res, warnings, err := BuildWithStore([]string{root}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ if _, err := res.Resolve(".knowledge"); err == nil {
+ t.Error("store dir itself must not be discovered as a project")
+ }
+ warned := false
+ for _, w := range warnings {
+ if strings.Contains(w, "inside the --store directory") {
+ warned = true
+ }
+ }
+ if !warned {
+ t.Errorf("expected exclusion warning, got %v", warnings)
+ }
+
+ // Dynamic resolution must not resurrect the store dir either.
+ if _, err := res.Resolve(".knowledge"); err == nil {
+ t.Error("dynamic resolution must exclude the store dir")
+ }
+}
+
+func TestBuildWithStoreInTreeWarnings(t *testing.T) {
+ root := t.TempDir()
+ proj := mkdir(t, root, "proj")
+ mkdir(t, proj, ".agents")
+ org := mkdir(t, t.TempDir(), "org")
+ mkdir(t, org, ".agents", "knowledge")
+ store := mkdir(t, t.TempDir(), "store")
+
+ _, warnings, err := BuildWithStore([]string{root, org}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ var projWarn, orgWarn int
+ for _, w := range warnings {
+ if strings.Contains(w, "ignoring in-tree .agents at") {
+ projWarn++
+ }
+ if strings.Contains(w, "ignoring in-tree org knowledge at") {
+ orgWarn++
+ }
+ }
+ if projWarn != 1 {
+ t.Errorf("want 1 in-tree project warning, got %d (%v)", projWarn, warnings)
+ }
+ if orgWarn != 1 {
+ t.Errorf("want 1 in-tree org warning, got %d (%v)", orgWarn, warnings)
+ }
+}
+
+func TestBuildWithStoreOrgKnowledgeInStore(t *testing.T) {
+ root := mkdir(t, t.TempDir(), "org")
+ store := mkdir(t, t.TempDir(), "store")
+ mkdir(t, store, "org", ".agents", "knowledge")
+
+ res, _, err := BuildWithStore([]string{root}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ ref, err := res.Resolve("org")
+ if err != nil {
+ t.Fatalf("Resolve(org) error: %v", err)
+ }
+ if ref.Kind != KindOrg {
+ t.Errorf("store-hosted org knowledge should create an org ref, got kind %v", ref.Kind)
+ }
+ if want := filepath.Join(store, "org", ".agents", "knowledge"); res.OrgKnowledgeDir(ref) != want {
+ t.Errorf("OrgKnowledgeDir = %q, want %q", res.OrgKnowledgeDir(ref), want)
+ }
+}
+
+func TestBuildWithStoreNoOrgKnowledgeInTree(t *testing.T) {
+ root := mkdir(t, t.TempDir(), "org")
+ mkdir(t, root, ".agents", "knowledge") // in-tree only
+ store := mkdir(t, t.TempDir(), "store")
+
+ res, _, err := BuildWithStore([]string{root}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ if _, err := res.Resolve("org"); err == nil {
+ t.Error("in-tree org knowledge must not create an org ref when --store is set")
+ }
+}
+
+func TestRefForPath(t *testing.T) {
+ dir := mkdir(t, t.TempDir(), "myproj")
+
+ res, _, err := Build(nil, []string{dir}, "")
+ if err != nil {
+ t.Fatalf("Build() error: %v", err)
+ }
+
+ ref, ok := res.RefForPath(dir)
+ if !ok || ref.Address != "myproj" {
+ t.Errorf("RefForPath(%q) = %#v, %v; want myproj", dir, ref, ok)
+ }
+ if _, ok := res.RefForPath(filepath.Join(dir, "nonexistent")); ok {
+ t.Error("RefForPath should return false for unknown paths")
+ }
+}
+
+func TestExplicitProjectInsideStoreKept(t *testing.T) {
+ store := mkdir(t, t.TempDir(), "store")
+ inner := mkdir(t, store, "inner")
+
+ res, warnings, err := BuildWithStore(nil, []string{inner}, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+ if _, err := res.Resolve("inner"); err != nil {
+ t.Errorf("explicit project inside store should stay resolvable: %v", err)
+ }
+ found := false
+ for _, w := range warnings {
+ if strings.Contains(w, "explicit project") {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("expected explicit-project warning, got %v", warnings)
+ }
+}
+
+func TestBuildWithStoreNoInTreeWarningForStoreSlot(t *testing.T) {
+ store := mkdir(t, t.TempDir(), "store")
+
+ // Explicit project inside the store whose slot IS its in-tree dir.
+ proj := mkdir(t, store, "foo")
+ mkdir(t, proj, ".agents")
+
+ res, warnings, err := BuildWithStore(nil, []string{proj}, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+ for _, w := range warnings {
+ if strings.Contains(w, "ignoring in-tree .agents") {
+ t.Errorf("must not warn when the store slot is the in-tree dir: %q", w)
+ }
+ }
+ ref, err := res.Resolve("foo")
+ if err != nil {
+ t.Fatalf("Resolve(foo) error: %v", err)
+ }
+ if want := filepath.Join(proj, ".agents"); res.AgentsDir(ref) != want {
+ t.Errorf("AgentsDir = %q, want %q", res.AgentsDir(ref), want)
+ }
+
+ // Org root inside the store whose slot IS its in-tree knowledge dir.
+ orgRoot := mkdir(t, store, "org")
+ mkdir(t, orgRoot, ".agents", "knowledge")
+ _, warnings, err = BuildWithStore([]string{orgRoot}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore(org) error: %v", err)
+ }
+ for _, w := range warnings {
+ if strings.Contains(w, "ignoring in-tree org knowledge") {
+ t.Errorf("must not warn when the org slot is the in-tree dir: %q", w)
+ }
+ }
+}
+
+func TestBuildWithStoreSkipsIndexChild(t *testing.T) {
+ root := t.TempDir()
+ mkdir(t, root, "proj")
+ mkdir(t, root, ".index")
+ store := mkdir(t, t.TempDir(), "store")
+
+ res, warnings, err := BuildWithStore([]string{root}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+ if _, err := res.Resolve(".index"); err == nil {
+ t.Error("a child named .index must not become a project in store mode: its slot would live inside the removable index directory")
+ }
+ warned := false
+ for _, w := range warnings {
+ if strings.Contains(w, ".index") {
+ warned = true
+ }
+ }
+ if !warned {
+ t.Errorf("expected a .index collision warning, got %v", warnings)
+ }
+
+ // Without a store, a real project named .index stays resolvable.
+ plain, _, err := Build([]string{root}, nil, "")
+ if err != nil {
+ t.Fatalf("Build() error: %v", err)
+ }
+ if _, err := plain.Resolve(".index"); err != nil {
+ t.Errorf("non-store mode must keep .index as a project: %v", err)
+ }
+}
diff --git a/tools/init.go b/tools/init.go
index 0e10f64..f70bcb5 100644
--- a/tools/init.go
+++ b/tools/init.go
@@ -26,6 +26,28 @@ func InitHandler(res *projects.Resolver) func(context.Context, mcp.CallToolReque
}
agentsDir := filepath.Join(projectPath, ".agents")
+ resolvable := res != nil && res.Covers(projectPath)
+
+ if res != nil && res.StoreEnabled() {
+ ref, ok := res.RefForPath(projectPath)
+ if !ok && res.Covers(projectPath) {
+ if resolved, resolveErr := res.Resolve(filepath.Base(projectPath)); resolveErr == nil &&
+ resolved.Kind == projects.KindProject {
+ if want, wantOk := projects.CanonicalPath(projectPath); wantOk && resolved.Path == want {
+ ref, ok = resolved, true
+ }
+ }
+ }
+ if !ok {
+ return mcp.NewToolResultError(
+ "init_knowledge targets the central store under --store; add this project via --project or --root and restart"), nil
+ }
+ if ref.Kind == projects.KindOrg {
+ return mcp.NewToolResultError("org roots have no per-project store"), nil
+ }
+ agentsDir = res.AgentsDir(ref)
+ resolvable = true
+ }
if err := knowledge.EnsureDir(agentsDir); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("create directory: %v", err)), nil
@@ -69,19 +91,17 @@ func InitHandler(res *projects.Resolver) func(context.Context, mcp.CallToolReque
}
}
- resolvable := res != nil && res.Covers(projectPath)
-
if len(created) == 0 {
if resolvable {
- return mcp.NewToolResultText("already initialized — .agents/ exists with all files"), nil
+ return mcp.NewToolResultText(fmt.Sprintf("already initialized — %s exists with all files", agentsDir)), nil
}
- return mcp.NewToolResultText("already initialized — .agents/ exists with all files\nwarning: this path is not under any configured root/project; add it via --project or --root and restart to make it queryable"), nil
+ return mcp.NewToolResultText(fmt.Sprintf("already initialized — %s exists with all files\nwarning: this path is not under any configured root/project; add it via --project or --root and restart to make it queryable", agentsDir)), nil
}
if resolvable {
- return mcp.NewToolResultText(fmt.Sprintf("initialized .agents/ with: %v", created)), nil
+ return mcp.NewToolResultText(fmt.Sprintf("initialized %s with: %v", agentsDir, created)), nil
}
return mcp.NewToolResultText(fmt.Sprintf(
- "initialized .agents/ with: %v\nwarning: this path is not under any configured root/project; add it via --project or --root and restart to make it queryable", created)), nil
+ "initialized %s with: %v\nwarning: this path is not under any configured root/project; add it via --project or --root and restart to make it queryable", agentsDir, created)), nil
}
}
diff --git a/tools/integration_test.go b/tools/integration_test.go
index cc8d481..9e2315c 100644
--- a/tools/integration_test.go
+++ b/tools/integration_test.go
@@ -354,6 +354,107 @@ func TestFullWorkflow(t *testing.T) {
}
}
+func TestStoreModeEndToEnd(t *testing.T) {
+ root := t.TempDir()
+ proj := filepath.Join(root, "proj")
+ os.MkdirAll(proj, 0755)
+
+ // Seed an in-tree store that must be ignored under --store.
+ agentsDir := filepath.Join(proj, ".agents")
+ os.MkdirAll(agentsDir, 0755)
+ inTree := &knowledge.KnowledgeFile{
+ Project: "proj", Version: 1,
+ Entries: []knowledge.Entry{
+ {ID: "conv-001", Summary: "stale in-tree entry", Source: "test", Date: knowledge.Today()},
+ },
+ }
+ if err := knowledge.Save(knowledge.CategoryFilePath(agentsDir, "conventions"), inTree); err != nil {
+ t.Fatalf("seed in-tree store: %v", err)
+ }
+
+ store := t.TempDir()
+ resolver, warnings, err := projects.BuildWithStore([]string{root}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ warned := false
+ for _, w := range warnings {
+ if strings.Contains(w, "ignoring in-tree .agents") {
+ warned = true
+ }
+ }
+ if !warned {
+ t.Errorf("expected in-tree ignore warning, got %v", warnings)
+ }
+
+ idx, err := search.NewIndex(filepath.Join(store, ".index"), []string{"proj"})
+ if err != nil {
+ t.Fatalf("NewIndex() error: %v", err)
+ }
+ defer idx.Close()
+
+ // Init writes into the store.
+ initReq := mcp.CallToolRequest{}
+ initReq.Params.Arguments = map[string]interface{}{"project_path": proj}
+ initResult, err := InitHandler(resolver)(context.Background(), initReq)
+ if err != nil {
+ t.Fatalf("init error: %v", err)
+ }
+ if initResult.IsError {
+ t.Fatalf("init returned error: %v", extractTextContent(t, initResult))
+ }
+ if !knowledge.FileExists(filepath.Join(store, "proj", ".agents", "conventions.yaml")) {
+ t.Fatal("init did not create the central store")
+ }
+
+ // Write goes to the store.
+ writeHandler := WriteHandler(resolver, idx)
+ writeReq := mcp.CallToolRequest{}
+ writeReq.Params.Arguments = map[string]interface{}{
+ "project": "proj",
+ "category": "conventions",
+ "summary": "fresh central entry",
+ "detail": "centralized reverb parameters",
+ "source": "test",
+ }
+ if _, err := writeHandler(context.Background(), writeReq); err != nil {
+ t.Fatalf("write error: %v", err)
+ }
+
+ // Query hits the store entry, not the in-tree one.
+ queryHandler := QueryHandler(resolver, idx)
+ queryReq := mcp.CallToolRequest{}
+ queryReq.Params.Arguments = map[string]interface{}{"project": "proj", "query": "reverb"}
+ queryResult, err := queryHandler(context.Background(), queryReq)
+ if err != nil {
+ t.Fatalf("query error: %v", err)
+ }
+ queryContent := extractTextContent(t, queryResult)
+ if !strings.Contains(queryContent, "fresh central entry") {
+ t.Errorf("query should find the central entry, got: %s", queryContent)
+ }
+ if strings.Contains(queryContent, "stale in-tree entry") {
+ t.Error("query must not find in-tree entries under --store")
+ }
+
+ // List shows the store entry only.
+ listHandler := ListHandler(resolver, idx)
+ listReq := mcp.CallToolRequest{}
+ listReq.Params.Arguments = map[string]interface{}{"project": "proj"}
+ listResult, err := listHandler(context.Background(), listReq)
+ if err != nil {
+ t.Fatalf("list error: %v", err)
+ }
+ listContent := extractTextContent(t, listResult)
+ if !strings.Contains(listContent, "fresh central entry") {
+ t.Errorf("list should show the central entry, got: %s", listContent)
+ }
+ if strings.Contains(listContent, "stale in-tree entry") {
+ t.Error("list must not show in-tree entries under --store")
+ }
+}
+
// extractTextContent extracts the text content from an MCP tool result.
func extractTextContent(t *testing.T, result *mcp.CallToolResult) string {
t.Helper()
diff --git a/tools/list.go b/tools/list.go
index b1f6948..da4a2fb 100644
--- a/tools/list.go
+++ b/tools/list.go
@@ -3,7 +3,6 @@ package tools
import (
"context"
"fmt"
- "path/filepath"
"strings"
"github.com/mark3labs/mcp-go/mcp"
@@ -37,7 +36,7 @@ func ListHandler(res *projects.Resolver, idx *search.Index) func(context.Context
// the project's own.
if ref.Address != projects.GlobalAddress {
if globalRef, ok := res.GlobalRef(); ok {
- output += listProjectEntries(globalRef.Path, projects.GlobalAddress, filterCategory, " (global)")
+ output += listProjectEntries(res.AgentsDir(globalRef), projects.GlobalAddress, filterCategory, " (global)")
}
}
@@ -49,8 +48,7 @@ func ListHandler(res *projects.Resolver, idx *search.Index) func(context.Context
return mcp.NewToolResultText(output), nil
}
- projectPath := ref.Path
- local := listProjectEntries(projectPath, project, filterCategory, "")
+ local := listProjectEntries(res.AgentsDir(ref), project, filterCategory, "")
if local == "" {
if strings.TrimSpace(output) == "" {
@@ -108,8 +106,7 @@ func listOrgEntries(idx *search.Index, orgAddress, filterCategory string) string
return output
}
-func listProjectEntries(projectPath, projectName, filterCategory, headerSuffix string) string {
- agentsDir := filepath.Join(projectPath, ".agents")
+func listProjectEntries(agentsDir, projectName, filterCategory, headerSuffix string) string {
var constraints []knowledge.Entry
type catEntries struct {
diff --git a/tools/tools_test.go b/tools/tools_test.go
index 22c5ff7..feec35c 100644
--- a/tools/tools_test.go
+++ b/tools/tools_test.go
@@ -1181,3 +1181,363 @@ func TestListConstraintsSeparation(t *testing.T) {
t.Errorf("regular entry should appear in regular entries section: %s", content[regularIdx:])
}
}
+
+func TestWriteToCentralStore(t *testing.T) {
+ root := t.TempDir()
+ proj := filepath.Join(root, "proj")
+ knowledge.EnsureDir(proj)
+ store := filepath.Join(t.TempDir(), "store")
+ knowledge.EnsureDir(store)
+
+ resolver, _, err := projects.BuildWithStore([]string{root}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ handler := WriteHandler(resolver, nil)
+ req := mcp.CallToolRequest{}
+ req.Params.Arguments = map[string]interface{}{
+ "project": "proj",
+ "category": "conventions",
+ "summary": "central",
+ "detail": "stored centrally",
+ "source": "test",
+ }
+ result, err := handler(context.Background(), req)
+ if err != nil {
+ t.Fatalf("write error: %v", err)
+ }
+ if result.IsError {
+ t.Fatalf("write returned error: %v", extractTextContent(t, result))
+ }
+
+ want := filepath.Join(store, "proj", ".agents", "conventions.yaml")
+ if !knowledge.FileExists(want) {
+ t.Errorf("store file missing: %s", want)
+ }
+ if knowledge.FileExists(filepath.Join(proj, ".agents", "conventions.yaml")) {
+ t.Error("in-tree .agents must not be created under --store")
+ }
+}
+
+func TestListIgnoresInTreeUnderStore(t *testing.T) {
+ root := t.TempDir()
+ proj := filepath.Join(root, "proj")
+ knowledge.EnsureDir(filepath.Join(proj, ".agents"))
+ kf := &knowledge.KnowledgeFile{
+ Project: "proj", Version: 1,
+ Entries: []knowledge.Entry{
+ {ID: "conv-001", Summary: "stale in-tree entry", Source: "test", Date: knowledge.Today()},
+ },
+ }
+ if err := knowledge.Save(knowledge.CategoryFilePath(filepath.Join(proj, ".agents"), "conventions"), kf); err != nil {
+ t.Fatalf("seed in-tree: %v", err)
+ }
+
+ store := filepath.Join(t.TempDir(), "store")
+ knowledge.EnsureDir(store)
+ resolver, _, err := projects.BuildWithStore([]string{root}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ handler := ListHandler(resolver, nil)
+ req := mcp.CallToolRequest{}
+ req.Params.Arguments = map[string]interface{}{"project": "proj"}
+ result, err := handler(context.Background(), req)
+ if err != nil {
+ t.Fatalf("list error: %v", err)
+ }
+ content := extractTextContent(t, result)
+ if strings.Contains(content, "stale in-tree entry") {
+ t.Errorf("list must not surface in-tree entries under --store: %s", content)
+ }
+}
+
+func TestUpdateInCentralStore(t *testing.T) {
+ root := t.TempDir()
+ proj := filepath.Join(root, "proj")
+ knowledge.EnsureDir(proj)
+ store := filepath.Join(t.TempDir(), "store")
+ knowledge.EnsureDir(store)
+
+ resolver, _, err := projects.BuildWithStore([]string{root}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ want := filepath.Join(store, "proj", ".agents", "conventions.yaml")
+ writeHandler := WriteHandler(resolver, nil)
+ writeReq := mcp.CallToolRequest{}
+ writeReq.Params.Arguments = map[string]interface{}{
+ "project": "proj", "category": "conventions",
+ "summary": "before", "detail": "before detail", "source": "test",
+ }
+ if _, err := writeHandler(context.Background(), writeReq); err != nil {
+ t.Fatalf("write error: %v", err)
+ }
+
+ handler := UpdateHandler(resolver, nil)
+ req := mcp.CallToolRequest{}
+ req.Params.Arguments = map[string]interface{}{
+ "project": "proj", "category": "conventions", "id": "conv-001", "summary": "after",
+ }
+ result, err := handler(context.Background(), req)
+ if err != nil {
+ t.Fatalf("update error: %v", err)
+ }
+ if result.IsError {
+ t.Fatalf("update returned error: %v", extractTextContent(t, result))
+ }
+
+ loaded, loadErr := knowledge.Load(want)
+ if loadErr != nil {
+ t.Fatalf("load store file: %v", loadErr)
+ }
+ if loaded.Entries[0].Summary != "after" {
+ t.Errorf("summary = %q, want %q", loaded.Entries[0].Summary, "after")
+ }
+}
+
+func TestInitTargetsCentralStore(t *testing.T) {
+ root := t.TempDir()
+ proj := filepath.Join(root, "proj")
+ knowledge.EnsureDir(proj)
+ store := filepath.Join(t.TempDir(), "store")
+ knowledge.EnsureDir(store)
+
+ resolver, _, err := projects.BuildWithStore([]string{root}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ req := mcp.CallToolRequest{}
+ req.Params.Arguments = map[string]interface{}{"project_path": proj}
+ result, err := InitHandler(resolver)(context.Background(), req)
+ if err != nil {
+ t.Fatalf("init error: %v", err)
+ }
+ if result.IsError {
+ t.Fatalf("init returned error: %v", extractTextContent(t, result))
+ }
+
+ want := filepath.Join(store, "proj", ".agents")
+ if !knowledge.FileExists(knowledge.CategoryFilePath(want, "conventions")) {
+ t.Errorf("store not initialized at %s", want)
+ }
+ if knowledge.FileExists(filepath.Join(proj, ".agents", "conventions.yaml")) {
+ t.Error("init must not create in-tree .agents under --store")
+ }
+ content := extractTextContent(t, result)
+ if !strings.Contains(content, want) {
+ t.Errorf("result should report the store path %q, got: %s", want, content)
+ }
+}
+
+func TestGlobalOpsUnderStore(t *testing.T) {
+ root := t.TempDir()
+ proj := filepath.Join(root, "proj")
+ knowledge.EnsureDir(filepath.Join(proj, ".agents"))
+ globalDir := t.TempDir()
+ knowledge.EnsureDir(filepath.Join(globalDir, ".agents"))
+ store := filepath.Join(t.TempDir(), "store")
+ knowledge.EnsureDir(store)
+
+ resolver, _, err := projects.BuildWithStore([]string{root}, nil, globalDir, store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ write := WriteHandler(resolver, nil)
+ req := mcp.CallToolRequest{}
+ req.Params.Arguments = map[string]interface{}{
+ "project": "_global", "category": "conventions",
+ "summary": "global under store", "detail": "global detail", "source": "test",
+ }
+ result, err := write(context.Background(), req)
+ if err != nil {
+ t.Fatalf("write error: %v", err)
+ }
+ if result.IsError {
+ t.Fatalf("write error result: %v", extractTextContent(t, result))
+ }
+
+ globalCat := knowledge.CategoryFilePath(filepath.Join(globalDir, ".agents"), "conventions")
+ if !knowledge.FileExists(globalCat) {
+ t.Fatal("write to _global must land in the global store")
+ }
+ if knowledge.FileExists(filepath.Join(store, "_global", ".agents", "conventions.yaml")) {
+ t.Error("write to _global must not create a _global slot in the central store")
+ }
+
+ update := UpdateHandler(resolver, nil)
+ uReq := mcp.CallToolRequest{}
+ uReq.Params.Arguments = map[string]interface{}{
+ "project": "_global", "category": "conventions", "id": "conv-001", "summary": "updated global under store",
+ }
+ uResult, err := update(context.Background(), uReq)
+ if err != nil {
+ t.Fatalf("update error: %v", err)
+ }
+ if uResult.IsError {
+ t.Fatalf("update error result: %v", extractTextContent(t, uResult))
+ }
+ loaded, loadErr := knowledge.Load(globalCat)
+ if loadErr != nil {
+ t.Fatalf("load global file: %v", loadErr)
+ }
+ if loaded.Entries[0].Summary != "updated global under store" {
+ t.Errorf("summary = %q, want %q", loaded.Entries[0].Summary, "updated global under store")
+ }
+
+ list := ListHandler(resolver, nil)
+ lReq := mcp.CallToolRequest{}
+ lReq.Params.Arguments = map[string]interface{}{"project": "_global"}
+ lResult, err := list(context.Background(), lReq)
+ if err != nil {
+ t.Fatalf("list error: %v", err)
+ }
+ content := extractTextContent(t, lResult)
+ if !strings.Contains(content, "updated global under store") {
+ t.Errorf("list _global should show the global entry: %s", content)
+ }
+}
+
+func TestInitUnresolvableUnderStore(t *testing.T) {
+ root := t.TempDir()
+ other := filepath.Join(t.TempDir(), "elsewhere")
+ knowledge.EnsureDir(other)
+ store := filepath.Join(t.TempDir(), "store")
+ knowledge.EnsureDir(store)
+
+ resolver, _, err := projects.BuildWithStore([]string{root}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ req := mcp.CallToolRequest{}
+ req.Params.Arguments = map[string]interface{}{"project_path": other}
+ result, err := InitHandler(resolver)(context.Background(), req)
+ if err != nil {
+ t.Fatalf("init error: %v", err)
+ }
+ if !result.IsError {
+ t.Fatal("unresolvable init under --store must return an error")
+ }
+ content := extractTextContent(t, result)
+ if !strings.Contains(content, "--store") {
+ t.Errorf("error should explain --store, got: %s", content)
+ }
+ if knowledge.FileExists(filepath.Join(other, ".agents")) {
+ t.Error("unresolvable init must not write in-tree .agents under --store")
+ }
+}
+
+func TestInitStoreFallbackPathVerified(t *testing.T) {
+ t.Run("same name in another root must not bind", func(t *testing.T) {
+ r1 := t.TempDir()
+ r2 := t.TempDir()
+ existing := filepath.Join(r2, "api")
+ knowledge.EnsureDir(existing) // registered at build with unique bare name "api"
+
+ store := filepath.Join(t.TempDir(), "store")
+ knowledge.EnsureDir(store)
+
+ resolver, _, err := projects.BuildWithStore([]string{r1, r2}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ created := filepath.Join(r1, "api")
+ knowledge.EnsureDir(created) // created after startup, covered by r1
+
+ req := mcp.CallToolRequest{}
+ req.Params.Arguments = map[string]interface{}{"project_path": created}
+ result, err := InitHandler(resolver)(context.Background(), req)
+ if err != nil {
+ t.Fatalf("init error: %v", err)
+ }
+ if !result.IsError {
+ t.Fatal("init for a path covered but not resolvable must reject, not write another root's slot")
+ }
+ content := extractTextContent(t, result)
+ if !strings.Contains(content, "--store") {
+ t.Errorf("error should explain --store, got: %s", content)
+ }
+ if knowledge.FileExists(filepath.Join(store, "api", ".agents")) {
+ t.Error("init must not write into the other root's store slot")
+ }
+ if knowledge.FileExists(filepath.Join(created, ".agents")) {
+ t.Error("init must not fall back to in-tree .agents under --store")
+ }
+ })
+
+ t.Run("org ref must not hijack init", func(t *testing.T) {
+ orgRoot := filepath.Join(t.TempDir(), "api")
+ knowledge.EnsureDir(orgRoot)
+
+ store := filepath.Join(t.TempDir(), "store")
+ knowledge.EnsureDir(store)
+ // Org ref for "api" exists on the store side.
+ knowledge.EnsureDir(filepath.Join(store, "api", ".agents", "knowledge"))
+
+ resolver, _, err := projects.BuildWithStore([]string{orgRoot}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ created := filepath.Join(orgRoot, "api")
+ knowledge.EnsureDir(created) // a project path under the root named like the org
+
+ req := mcp.CallToolRequest{}
+ req.Params.Arguments = map[string]interface{}{"project_path": created}
+ result, err := InitHandler(resolver)(context.Background(), req)
+ if err != nil {
+ t.Fatalf("init error: %v", err)
+ }
+ if !result.IsError {
+ t.Fatal("init must reject when bare-name resolution selects an unrelated org ref")
+ }
+ content := extractTextContent(t, result)
+ if strings.Contains(content, "org roots have no per-project store") {
+ t.Errorf("error must not blame the unrelated org ref, got: %s", content)
+ }
+ if !strings.Contains(content, "--store") {
+ t.Errorf("error should explain --store, got: %s", content)
+ }
+ if knowledge.FileExists(filepath.Join(created, ".agents")) {
+ t.Error("init must not write in-tree .agents under --store")
+ }
+ })
+
+ t.Run("new project under a root still initializes", func(t *testing.T) {
+ root := t.TempDir()
+ store := filepath.Join(t.TempDir(), "store")
+ knowledge.EnsureDir(store)
+
+ resolver, _, err := projects.BuildWithStore([]string{root}, nil, "", store)
+ if err != nil {
+ t.Fatalf("BuildWithStore() error: %v", err)
+ }
+
+ created := filepath.Join(root, "api")
+ knowledge.EnsureDir(created) // created after startup
+
+ req := mcp.CallToolRequest{}
+ req.Params.Arguments = map[string]interface{}{"project_path": created}
+ result, err := InitHandler(resolver)(context.Background(), req)
+ if err != nil {
+ t.Fatalf("init error: %v", err)
+ }
+ if result.IsError {
+ t.Fatalf("init returned error: %v", extractTextContent(t, result))
+ }
+ want := filepath.Join(store, "api", ".agents")
+ if !knowledge.FileExists(knowledge.CategoryFilePath(want, "conventions")) {
+ t.Errorf("store not initialized at %s", want)
+ }
+ if knowledge.FileExists(filepath.Join(created, ".agents", "conventions.yaml")) {
+ t.Error("init must not create in-tree .agents under --store")
+ }
+ })
+}
diff --git a/tools/update.go b/tools/update.go
index e65fd1e..1a04607 100644
--- a/tools/update.go
+++ b/tools/update.go
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log"
- "path/filepath"
"github.com/mark3labs/mcp-go/mcp"
"github.com/renderorange/knowledge-mcp/knowledge"
@@ -39,12 +38,11 @@ func UpdateHandler(res *projects.Resolver, idx *search.Index) func(context.Conte
}
if ref.Kind == projects.KindOrg {
return mcp.NewToolResultError(fmt.Sprintf(
- "%q is an org root; org-level knowledge is file-based — edit %s/.agents/knowledge/ directly",
- project, ref.Path)), nil
+ "%q is an org root; org-level knowledge is file-based — edit %s directly",
+ project, res.OrgKnowledgeDir(ref))), nil
}
- projectPath := ref.Path
- agentsDir := filepath.Join(projectPath, ".agents")
+ agentsDir := res.AgentsDir(ref)
catPath := knowledge.CategoryFilePath(agentsDir, category)
mu := fileLocks.Get(catPath)
diff --git a/tools/write.go b/tools/write.go
index c7e6175..695bbb8 100644
--- a/tools/write.go
+++ b/tools/write.go
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log"
- "path/filepath"
"github.com/mark3labs/mcp-go/mcp"
"github.com/renderorange/knowledge-mcp/knowledge"
@@ -64,12 +63,14 @@ func WriteHandler(res *projects.Resolver, idx *search.Index) func(context.Contex
}
if ref.Kind == projects.KindOrg {
return mcp.NewToolResultError(fmt.Sprintf(
- "%q is an org root; org-level knowledge is file-based — edit %s/.agents/knowledge/ directly",
- project, ref.Path)), nil
+ "%q is an org root; org-level knowledge is file-based — edit %s directly",
+ project, res.OrgKnowledgeDir(ref))), nil
}
- projectPath := ref.Path
- agentsDir := filepath.Join(projectPath, ".agents")
+ agentsDir := res.AgentsDir(ref)
+ if err := knowledge.EnsureDir(agentsDir); err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("create agents directory: %v", err)), nil
+ }
catPath := knowledge.CategoryFilePath(agentsDir, category)
mu := fileLocks.Get(catPath)