lorekeep 0.1.13__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. {lorekeep-0.1.13 → lorekeep-0.2.0}/.github/workflows/release-please.yml +9 -2
  2. lorekeep-0.2.0/.release-please-manifest.json +3 -0
  3. {lorekeep-0.1.13 → lorekeep-0.2.0}/CHANGELOG.md +7 -0
  4. {lorekeep-0.1.13 → lorekeep-0.2.0}/PKG-INFO +1 -1
  5. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/guides/getting-started.md +16 -5
  6. {lorekeep-0.1.13 → lorekeep-0.2.0}/pyproject.toml +1 -1
  7. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/__init__.py +1 -1
  8. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/cli.py +51 -5
  9. lorekeep-0.2.0/src/lorekeep/integrations/detect.py +82 -0
  10. lorekeep-0.2.0/src/lorekeep/integrations/opencode.py +43 -0
  11. lorekeep-0.2.0/tests/test_agent_detect.py +71 -0
  12. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_init_cli.py +64 -0
  13. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_integrations.py +37 -1
  14. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_mcp_add_cli.py +14 -0
  15. lorekeep-0.1.13/.release-please-manifest.json +0 -3
  16. {lorekeep-0.1.13 → lorekeep-0.2.0}/.commitlintrc.json +0 -0
  17. {lorekeep-0.1.13 → lorekeep-0.2.0}/.github/workflows/auto-merge-release-please.yml +0 -0
  18. {lorekeep-0.1.13 → lorekeep-0.2.0}/.github/workflows/ci.yml +0 -0
  19. {lorekeep-0.1.13 → lorekeep-0.2.0}/.github/workflows/lint-commits.yml +0 -0
  20. {lorekeep-0.1.13 → lorekeep-0.2.0}/.gitignore +0 -0
  21. {lorekeep-0.1.13 → lorekeep-0.2.0}/.lorekeep/config.yaml.example +0 -0
  22. {lorekeep-0.1.13 → lorekeep-0.2.0}/.lorekeep/schema.json +0 -0
  23. {lorekeep-0.1.13 → lorekeep-0.2.0}/.pre-commit-config.yaml +0 -0
  24. {lorekeep-0.1.13 → lorekeep-0.2.0}/AGENTS.md +0 -0
  25. {lorekeep-0.1.13 → lorekeep-0.2.0}/CLAUDE.md +0 -0
  26. {lorekeep-0.1.13 → lorekeep-0.2.0}/LICENSE +0 -0
  27. {lorekeep-0.1.13 → lorekeep-0.2.0}/README.md +0 -0
  28. {lorekeep-0.1.13 → lorekeep-0.2.0}/SECURITY.md +0 -0
  29. {lorekeep-0.1.13 → lorekeep-0.2.0}/cover.jpeg +0 -0
  30. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/README.md +0 -0
  31. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/architecture/agent.md +0 -0
  32. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/architecture/data-model.md +0 -0
  33. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/architecture/evaluation.md +0 -0
  34. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/architecture/journal.md +0 -0
  35. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/architecture/overview.md +0 -0
  36. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/architecture/permission.md +0 -0
  37. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/architecture/pipeline.md +0 -0
  38. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/architecture/serve-mcp.md +0 -0
  39. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/architecture/temporal.md +0 -0
  40. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/guides/backup.md +0 -0
  41. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/guides/compile.md +0 -0
  42. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/guides/data-home.md +0 -0
  43. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/guides/import.md +0 -0
  44. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/guides/serve.md +0 -0
  45. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/superpowers/plans/2026-06-26-lorekeep-dotdir-backup.md +0 -0
  46. {lorekeep-0.1.13 → lorekeep-0.2.0}/docs/superpowers/specs/2026-06-26-lorekeep-dotdir-layout-design.md +0 -0
  47. {lorekeep-0.1.13 → lorekeep-0.2.0}/release-please-config.json +0 -0
  48. {lorekeep-0.1.13 → lorekeep-0.2.0}/scripts/check-conventional-commit.py +0 -0
  49. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/agent.py +0 -0
  50. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/backup.py +0 -0
  51. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/compile/__init__.py +0 -0
  52. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/compile/extract.py +0 -0
  53. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/compile/ingest.py +0 -0
  54. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/compile/providers.py +0 -0
  55. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/compile/resolve.py +0 -0
  56. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/compile/writer.py +0 -0
  57. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/config.py +0 -0
  58. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/defaults.py +0 -0
  59. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/eval/__init__.py +0 -0
  60. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/eval/construction.py +0 -0
  61. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/eval/gold.py +0 -0
  62. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/eval/retrieval.py +0 -0
  63. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/facts_io.py +0 -0
  64. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/importer/__init__.py +0 -0
  65. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/importer/claude.py +0 -0
  66. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/importer/cursor.py +0 -0
  67. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/integrations/__init__.py +0 -0
  68. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/integrations/claude_code.py +0 -0
  69. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/integrations/codex.py +0 -0
  70. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/integrations/common.py +0 -0
  71. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/integrations/cursor.py +0 -0
  72. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/journal.py +0 -0
  73. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/mcp_server.py +0 -0
  74. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/models.py +0 -0
  75. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/paths.py +0 -0
  76. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/perm/__init__.py +0 -0
  77. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/perm/ns.py +0 -0
  78. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/pipeline.py +0 -0
  79. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/schema_io.py +0 -0
  80. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/store/__init__.py +0 -0
  81. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/store/fts.py +0 -0
  82. {lorekeep-0.1.13 → lorekeep-0.2.0}/src/lorekeep/store/graph.py +0 -0
  83. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/conftest.py +0 -0
  84. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/fixtures/claude-session/memory/MEMORY.md +0 -0
  85. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/fixtures/claude-session/memory/lorekeep-design.md +0 -0
  86. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/fixtures/claude-session/memory/lorekeep-project-state.md +0 -0
  87. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/fixtures/claude-session/test.jsonl +0 -0
  88. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/fixtures/gold/payments.facts.jsonl +0 -0
  89. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/fixtures/raw/backend/payments.md +0 -0
  90. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/fixtures/retrieval/questions.json +0 -0
  91. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/fixtures/schema.json +0 -0
  92. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_agent_cli.py +0 -0
  93. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_backup.py +0 -0
  94. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_backup_cli.py +0 -0
  95. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_compile_cli.py +0 -0
  96. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_config.py +0 -0
  97. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_construction_eval.py +0 -0
  98. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_defaults.py +0 -0
  99. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_determinism.py +0 -0
  100. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_doctor_cli.py +0 -0
  101. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_eval_cli.py +0 -0
  102. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_extract.py +0 -0
  103. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_fts.py +0 -0
  104. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_graph_store.py +0 -0
  105. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_import_claude.py +0 -0
  106. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_import_cursor.py +0 -0
  107. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_ingest.py +0 -0
  108. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_mcp_reload.py +0 -0
  109. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_mcp_server.py +0 -0
  110. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_models.py +0 -0
  111. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_paths.py +0 -0
  112. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_perm.py +0 -0
  113. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_pipeline.py +0 -0
  114. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_providers.py +0 -0
  115. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_resolve.py +0 -0
  116. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_retrieval_eval.py +0 -0
  117. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_schema_io.py +0 -0
  118. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_serve_cli.py +0 -0
  119. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_smoke.py +0 -0
  120. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_watch_sessions.py +0 -0
  121. {lorekeep-0.1.13 → lorekeep-0.2.0}/tests/test_writer.py +0 -0
  122. {lorekeep-0.1.13 → lorekeep-0.2.0}/uv.lock +0 -0
@@ -13,6 +13,12 @@ name: release-please
13
13
  on:
14
14
  push:
15
15
  branches: [main]
16
+ workflow_dispatch:
17
+ inputs:
18
+ tag_name:
19
+ description: "Tag to publish (e.g. v0.1.13)"
20
+ required: true
21
+ type: string
16
22
 
17
23
  permissions:
18
24
  contents: write
@@ -21,6 +27,7 @@ permissions:
21
27
  jobs:
22
28
  release-please:
23
29
  runs-on: ubuntu-latest
30
+ if: github.event_name == 'push'
24
31
  outputs:
25
32
  release_created: ${{ steps.rp.outputs.release_created }}
26
33
  tag_name: ${{ steps.rp.outputs.tag_name }}
@@ -32,7 +39,7 @@ jobs:
32
39
 
33
40
  publish:
34
41
  needs: release-please
35
- if: needs.release-please.outputs.release_created == 'true'
42
+ if: always() && (needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch')
36
43
  runs-on: ubuntu-latest
37
44
  environment: pypi
38
45
  permissions:
@@ -41,7 +48,7 @@ jobs:
41
48
  steps:
42
49
  - uses: actions/checkout@v5
43
50
  with:
44
- ref: ${{ needs.release-please.outputs.tag_name }}
51
+ ref: ${{ github.event.inputs.tag_name || needs.release-please.outputs.tag_name }}
45
52
 
46
53
  - uses: astral-sh/setup-uv@v7
47
54
  with:
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.2.0"
3
+ }
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0](https://github.com/manhhailua/lorekeep/compare/v0.1.13...v0.2.0) (2026-06-28)
4
+
5
+
6
+ ### Features
7
+
8
+ * auto-detect coding agents during init and wire MCP automatically ([#46](https://github.com/manhhailua/lorekeep/issues/46)) ([c529974](https://github.com/manhhailua/lorekeep/commit/c5299746ce12d5d12a3511a990d9711d357b0277))
9
+
3
10
  ## [0.1.13](https://github.com/manhhailua/lorekeep/compare/v0.1.12...v0.1.13) (2026-06-27)
4
11
 
5
12
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lorekeep
3
- Version: 0.1.13
3
+ Version: 0.2.0
4
4
  Summary: Temporal knowledge graph for AI agents via MCP
5
5
  Project-URL: Homepage, https://github.com/manhhailua/lorekeep
6
6
  Project-URL: Repository, https://github.com/manhhailua/lorekeep
@@ -9,7 +9,7 @@ graph — including the backup setup. For the terse 5-step version, see the
9
9
  - A **data home** (`.lorekeep/` in a source checkout, or an XDG dir for an
10
10
  installed copy) holding your config, schema, raw docs, and compiled graph.
11
11
  - A compiled `facts.jsonl` graph built from your markdown.
12
- - A coding agent (Claude Code / Cursor / Codex) reading the graph over MCP,
12
+ - A coding agent (Claude Code / Cursor / Codex / opencode) reading the graph over MCP,
13
13
  scoped to a namespace.
14
14
  - A private git backup of the data home, so the graph syncs to your other
15
15
  machines.
@@ -49,7 +49,16 @@ uvx lorekeep init
49
49
  `raw/<ns>/about.md`, so the compiled graph starts with a fact about you.
50
50
 
51
51
  It then writes `config.yaml` + `schema.json`, creates `raw/` + `graph/` dirs,
52
- and writes `raw/<ns>/about.md`.
52
+ writes `raw/<ns>/about.md`, and **auto-detects coding agents** to wire:
53
+
54
+ - If you're running `init` **inside** a coding agent (e.g. from opencode's or
55
+ Claude Code's shell), it wires only that agent.
56
+ - If you're in a **plain shell**, it scans for all installed agents
57
+ (`~/.claude`, `~/.cursor`, `~/.codex`, `~/.config/opencode`) and wires each.
58
+
59
+ Each detected agent gets its MCP config written automatically (`.mcp.json`,
60
+ `.cursor/mcp.json`, `config.toml`, or `opencode.json` — depending on the agent).
61
+ Restart the agent to pick up the new tools.
53
62
 
54
63
  Non-interactive (CI, scripts): `uvx lorekeep init --yes`. From a source
55
64
  checkout it uses the repo's own `.lorekeep/`; for an installed copy it uses
@@ -113,14 +122,16 @@ Recompiling unchanged input is **byte-identical** (determinism is a hard
113
122
  requirement) — unchanged chunks return cached LLM output, so only edited docs
114
123
  cost tokens on recompile.
115
124
 
116
- ## 6. Wire a coding agent
125
+ ## 6. Wire additional coding agents
117
126
 
118
- Write a portable `.mcp.json` scoped to a namespace:
127
+ `init` already wired agents it detected. To add more, or to re-scope one:
119
128
 
120
129
  ```bash
121
- uvx lorekeep mcp add --agent claude --ns backend
130
+ uvx lorekeep mcp add --agent opencode --ns backend
122
131
  ```
123
132
 
133
+ Supported agents: `claude`, `cursor`, `codex`, `opencode`.
134
+
124
135
  Restart the agent → the 8 read-only Lorekeep tools (`search`, `get_node`,
125
136
  `neighbors`, `at_time`, `history`, `changes`, `list_namespaces`, `schema`) are
126
137
  available, scoped to `backend` (+ `public`). See
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lorekeep"
3
- version = "0.1.13"
3
+ version = "0.2.0"
4
4
  description = "Temporal knowledge graph for AI agents via MCP"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -1,3 +1,3 @@
1
1
  """Lorekeep — temporal knowledge graph for AI agents via MCP."""
2
2
 
3
- __version__ = "0.1.13"
3
+ __version__ = "0.2.0"
@@ -227,12 +227,11 @@ app.add_typer(mcp_app, name="mcp")
227
227
 
228
228
  @mcp_app.command("add")
229
229
  def mcp_add(
230
- agent: str = typer.Option(..., "--agent", help="claude | cursor | codex"),
230
+ agent: str = typer.Option(..., "--agent", help="claude | cursor | codex | opencode"),
231
231
  scope: str = typer.Option("project", "--scope", help="project | user"),
232
232
  ns: str = typer.Option(None, "--ns", help="namespace to scope the agent to"),
233
233
  ) -> None:
234
234
  """Write the agent's MCP config + print an agent-memory snippet."""
235
- from lorekeep.integrations import claude_code, codex, cursor
236
235
  from lorekeep.integrations.common import agent_memory_snippet, resolve_command
237
236
 
238
237
  p = resolve_paths()
@@ -240,9 +239,9 @@ def mcp_add(
240
239
  command, args = resolve_command(config.install_source)
241
240
 
242
241
  target = Path.cwd() if scope == "project" else Path.home()
243
- writers = {"claude": claude_code, "cursor": cursor, "codex": codex}
242
+ writers = _agent_writers()
244
243
  if agent not in writers:
245
- typer.echo(f"unknown agent: {agent} (choose claude|cursor|codex)")
244
+ typer.echo(f"unknown agent: {agent} (choose claude|cursor|codex|opencode)")
246
245
  raise typer.Exit(code=1)
247
246
  written = writers[agent].write_config(target, command, args, ns)
248
247
  typer.echo(f"wrote {agent} config -> {written}")
@@ -357,10 +356,12 @@ def init(
357
356
  (ns_dir / "about.md").write_text(about_md)
358
357
  typer.echo(f" wrote: {ns_dir / 'about.md'}")
359
358
 
359
+ # Auto-detect and wire coding agents (active session or installed).
360
360
  if not config_existed:
361
+ _auto_wire_agents(p, ns)
361
362
  typer.echo("\nNext steps:")
362
363
  typer.echo(" 1. Compile: uvx lorekeep compile (or LOREKEEP_PROVIDER=fake)")
363
- typer.echo(f" 2. Wire an agent: uvx lorekeep mcp add --agent claude --ns {ns}")
364
+ typer.echo(" 2. More agents: uvx lorekeep mcp add --agent <claude|cursor|codex|opencode>")
364
365
 
365
366
 
366
367
  def _interactive_init(p: dict) -> tuple[str, str, str]:
@@ -420,6 +421,51 @@ def _interactive_init(p: dict) -> tuple[str, str, str]:
420
421
  return ns, name, bio
421
422
 
422
423
 
424
+ def _auto_wire_agents(p: dict, ns: str) -> None:
425
+ """Detect coding agents and write their MCP configs automatically.
426
+
427
+ If running inside a coding agent (env var set), wires only that agent.
428
+ Otherwise scans the filesystem for all installed agents and wires each.
429
+ """
430
+ from lorekeep.integrations.detect import detect_agents
431
+ from lorekeep.integrations.common import agent_memory_snippet, resolve_command
432
+
433
+ detected = detect_agents()
434
+ if not detected:
435
+ typer.echo("\n No coding agents detected — run `lorekeep mcp add --agent <name>` after install.")
436
+ return
437
+
438
+ config = load_config(p["config"])
439
+ command, args = resolve_command(config.install_source)
440
+
441
+ writers = _agent_writers()
442
+ target = Path.cwd()
443
+
444
+ typer.echo(f"\n Detected agents: {', '.join(detected)}")
445
+ for agent_name in detected:
446
+ writer = writers.get(agent_name)
447
+ if not writer:
448
+ continue
449
+ try:
450
+ written = writer.write_config(target, command, args, ns)
451
+ typer.echo(f" wired {agent_name} -> {written}")
452
+ except Exception as exc:
453
+ typer.echo(f" {agent_name}: failed ({exc})")
454
+
455
+ typer.echo("\n " + agent_memory_snippet().replace("\n", "\n ").strip())
456
+
457
+
458
+ def _agent_writers() -> dict:
459
+ """Return the agent-name → writer-module mapping (lazy import)."""
460
+ from lorekeep.integrations import claude_code, codex, cursor, opencode
461
+ return {
462
+ "claude": claude_code,
463
+ "cursor": cursor,
464
+ "codex": codex,
465
+ "opencode": opencode,
466
+ }
467
+
468
+
423
469
  @app.command()
424
470
  def backup(
425
471
  init_remote: str = typer.Option(
@@ -0,0 +1,82 @@
1
+ """Detect coding agents: active session (env vars) + installed (filesystem markers).
2
+
3
+ Two-layer detection:
4
+ 1. ``detect_active_agent`` — which agent shell are we running inside right now?
5
+ Uses well-known env vars. Returns at most one agent name (or ``None``).
6
+ 2. ``detect_installed_agents`` — which agents are installed on this machine?
7
+ Checks for config directories / binaries. Returns a list (may be empty).
8
+
9
+ ``detect_agents`` combines both: if an active session is detected, return just
10
+ that agent. Otherwise, return all installed agents found on the filesystem.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import shutil
16
+ from pathlib import Path
17
+
18
+ # Env var that each agent sets when it launches a subprocess shell.
19
+ # The value is checked for truthiness (any non-empty string counts).
20
+ _ACTIVE_ENV: dict[str, list[str]] = {
21
+ "claude": ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"],
22
+ "opencode": ["OPENCODE"],
23
+ "cursor": ["CURSOR_DEBUG"],
24
+ }
25
+
26
+ # Filesystem markers that indicate an agent has been installed / used.
27
+ # Each entry is a list of candidate paths (``~`` expanded relative to home).
28
+ _INSTALLED_MARKERS: dict[str, list[str]] = {
29
+ "claude": ["~/.claude"],
30
+ "cursor": ["~/.cursor"],
31
+ "codex": ["~/.codex"],
32
+ "opencode": ["~/.config/opencode", "~/.opencode"],
33
+ }
34
+
35
+ # Binary names to look for in PATH (fallback when dir markers are missing).
36
+ _BINARIES: dict[str, list[str]] = {
37
+ "claude": ["claude"],
38
+ "cursor": ["cursor"],
39
+ "codex": ["codex"],
40
+ "opencode": ["opencode"],
41
+ }
42
+
43
+ SUPPORTED_AGENTS = sorted(_ACTIVE_ENV.keys() | _INSTALLED_MARKERS.keys())
44
+
45
+
46
+ def _env_truthy(name: str) -> bool:
47
+ val = os.environ.get(name, "")
48
+ return val not in ("", "0", "false", "False")
49
+
50
+
51
+ def detect_active_agent() -> str | None:
52
+ """Return the agent whose shell we are running inside, or ``None``."""
53
+ for agent, env_vars in _ACTIVE_ENV.items():
54
+ if any(_env_truthy(v) for v in env_vars):
55
+ return agent
56
+ return None
57
+
58
+
59
+ def detect_installed_agents() -> list[str]:
60
+ """Return all agents detected on this machine (filesystem + PATH)."""
61
+ found: list[str] = []
62
+ home = Path.home()
63
+ for agent, markers in _INSTALLED_MARKERS.items():
64
+ if any(Path(m).expanduser().exists() for m in markers):
65
+ found.append(agent)
66
+ continue
67
+ if any(shutil.which(b) for b in _BINARIES.get(agent, [])):
68
+ found.append(agent)
69
+ return found
70
+
71
+
72
+ def detect_agents() -> list[str]:
73
+ """Detect agents to wire during ``init``.
74
+
75
+ - If running inside a coding agent, return **only** that agent (the user
76
+ is already in it; wiring others is noise).
77
+ - Otherwise, return all installed agents found on the filesystem.
78
+ """
79
+ active = detect_active_agent()
80
+ if active:
81
+ return [active]
82
+ return detect_installed_agents()
@@ -0,0 +1,43 @@
1
+ """opencode MCP config writer (opencode.json ``mcp`` section).
2
+
3
+ opencode uses a different MCP schema than Claude/Cursor:
4
+ - Config file: ``opencode.json`` (project root) or ``~/.config/opencode/opencode.json`` (global)
5
+ - Key: ``mcp`` (not ``mcpServers``)
6
+ - Entry shape: ``{"type": "local", "command": [cmd, ...args], "enabled": true, "environment": {...}}``
7
+ - ``command`` is a single array (command + args combined)
8
+
9
+ Idempotent: re-running merges into the existing ``mcp`` section, replacing only
10
+ the ``lorekeep`` entry.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+
18
+ def write_config(target_dir: Path, command: str, args: list[str], ns: str | None) -> Path:
19
+ target_dir = Path(target_dir)
20
+ target_dir.mkdir(parents=True, exist_ok=True)
21
+ path = target_dir / "opencode.json"
22
+
23
+ existing: dict = {}
24
+ if path.exists():
25
+ try:
26
+ existing = json.loads(path.read_text())
27
+ except (json.JSONDecodeError, ValueError):
28
+ existing = {}
29
+
30
+ entry: dict = {
31
+ "type": "local",
32
+ "command": [command, *args],
33
+ "enabled": True,
34
+ }
35
+ if ns:
36
+ entry["environment"] = {"LOREKEEP_NS": ns}
37
+
38
+ mcp = existing.get("mcp", {})
39
+ mcp["lorekeep"] = entry
40
+ existing["mcp"] = mcp
41
+
42
+ path.write_text(json.dumps(existing, indent=2) + "\n")
43
+ return path
@@ -0,0 +1,71 @@
1
+ """Tests for agent auto-detection (env vars + filesystem markers)."""
2
+ from pathlib import Path
3
+ from lorekeep.integrations.detect import detect_active_agent, detect_installed_agents, detect_agents
4
+
5
+
6
+ def _isolate(monkeypatch, tmp_path):
7
+ """Block all real env vars + filesystem markers + PATH lookups."""
8
+ for v in ("CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "OPENCODE", "CURSOR_DEBUG"):
9
+ monkeypatch.delenv(v, raising=False)
10
+ monkeypatch.setenv("HOME", str(tmp_path))
11
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
12
+ monkeypatch.setattr("lorekeep.integrations.detect.shutil.which", lambda _: None)
13
+
14
+
15
+ def test_detect_active_opencode(monkeypatch):
16
+ _isolate(monkeypatch, Path("/tmp"))
17
+ monkeypatch.setenv("OPENCODE", "1")
18
+ assert detect_active_agent() == "opencode"
19
+
20
+
21
+ def test_detect_active_claude(monkeypatch):
22
+ _isolate(monkeypatch, Path("/tmp"))
23
+ monkeypatch.setenv("CLAUDECODE", "1")
24
+ assert detect_active_agent() == "claude"
25
+
26
+
27
+ def test_detect_active_none(monkeypatch, tmp_path):
28
+ _isolate(monkeypatch, tmp_path)
29
+ assert detect_active_agent() is None
30
+
31
+
32
+ def test_detect_active_falsy_env_ignored(monkeypatch, tmp_path):
33
+ _isolate(monkeypatch, tmp_path)
34
+ monkeypatch.setenv("OPENCODE", "0")
35
+ assert detect_active_agent() is None
36
+
37
+
38
+ def test_detect_installed_finds_markers(monkeypatch, tmp_path):
39
+ _isolate(monkeypatch, tmp_path)
40
+ (tmp_path / ".claude").mkdir()
41
+ (tmp_path / ".config" / "opencode").mkdir(parents=True)
42
+ found = detect_installed_agents()
43
+ assert "claude" in found
44
+ assert "opencode" in found
45
+ assert "codex" not in found
46
+
47
+
48
+ def test_detect_installed_empty(monkeypatch, tmp_path):
49
+ _isolate(monkeypatch, tmp_path)
50
+ assert detect_installed_agents() == []
51
+
52
+
53
+ def test_detect_agents_active_overrides_installed(monkeypatch, tmp_path):
54
+ """When inside an agent session, return only that agent."""
55
+ _isolate(monkeypatch, tmp_path)
56
+ monkeypatch.setenv("OPENCODE", "1")
57
+ (tmp_path / ".claude").mkdir()
58
+ (tmp_path / ".config" / "opencode").mkdir(parents=True)
59
+ result = detect_agents()
60
+ assert result == ["opencode"]
61
+
62
+
63
+ def test_detect_agents_no_active_returns_all_installed(monkeypatch, tmp_path):
64
+ _isolate(monkeypatch, tmp_path)
65
+ (tmp_path / ".claude").mkdir()
66
+ (tmp_path / ".cursor").mkdir()
67
+ result = detect_agents()
68
+ assert set(result) == {"claude", "cursor"}
69
+
70
+
71
+ from pathlib import Path
@@ -117,3 +117,67 @@ def test_init_no_about_when_raw_has_files(tmp_path: Path, monkeypatch):
117
117
  result = runner.invoke(app, ["init"])
118
118
  assert result.exit_code == 0, result.stdout
119
119
  assert not (home / "raw" / "public" / "about.md").exists()
120
+
121
+
122
+ def test_init_auto_wires_detected_agent(tmp_path: Path, monkeypatch):
123
+ """init detects active agent from env and writes its MCP config."""
124
+ home = tmp_path / "home"
125
+ project = tmp_path / "project"
126
+ project.mkdir()
127
+ fake_home = tmp_path / "fakehome"
128
+ fake_home.mkdir()
129
+ monkeypatch.setenv("LOREKEEP_HOME", str(home))
130
+ monkeypatch.setenv("HOME", str(fake_home))
131
+ monkeypatch.chdir(project)
132
+ monkeypatch.setenv("OPENCODE", "1")
133
+ monkeypatch.delenv("CLAUDECODE", raising=False)
134
+ monkeypatch.setattr("pathlib.Path.home", lambda: fake_home)
135
+ monkeypatch.setattr("lorekeep.integrations.detect.shutil.which", lambda _: None)
136
+ result = runner.invoke(app, ["init", "--yes"])
137
+ assert result.exit_code == 0, result.stdout
138
+ import json
139
+ mcp_path = project / "opencode.json"
140
+ assert mcp_path.exists(), f"opencode.json not written: {result.stdout}"
141
+ data = json.loads(mcp_path.read_text())
142
+ assert data["mcp"]["lorekeep"]["type"] == "local"
143
+
144
+
145
+ def test_init_auto_wires_installed_agents(tmp_path: Path, monkeypatch):
146
+ """init in shell mode scans filesystem and wires all installed agents."""
147
+ home = tmp_path / "home"
148
+ fake_home = tmp_path / "fakehome"
149
+ project = tmp_path / "project"
150
+ project.mkdir()
151
+ (fake_home / ".claude").mkdir(parents=True)
152
+ (fake_home / ".config" / "opencode").mkdir(parents=True)
153
+
154
+ monkeypatch.setenv("LOREKEEP_HOME", str(home))
155
+ monkeypatch.setenv("HOME", str(fake_home))
156
+ monkeypatch.chdir(project)
157
+ monkeypatch.delenv("OPENCODE", raising=False)
158
+ monkeypatch.delenv("CLAUDECODE", raising=False)
159
+ monkeypatch.setattr("pathlib.Path.home", lambda: fake_home)
160
+ monkeypatch.setattr("lorekeep.integrations.detect.shutil.which", lambda _: None)
161
+
162
+ result = runner.invoke(app, ["init", "--yes"])
163
+ assert result.exit_code == 0, result.stdout
164
+ assert (project / ".mcp.json").exists()
165
+ assert (project / "opencode.json").exists()
166
+
167
+
168
+ def test_init_no_agents_detected_message(tmp_path: Path, monkeypatch):
169
+ """init reports when no agents are detected."""
170
+ home = tmp_path / "home"
171
+ fake_home = tmp_path / "fakehome"
172
+ fake_home.mkdir()
173
+ monkeypatch.setenv("LOREKEEP_HOME", str(home))
174
+ monkeypatch.setenv("HOME", str(fake_home))
175
+ monkeypatch.chdir(tmp_path)
176
+ monkeypatch.delenv("OPENCODE", raising=False)
177
+ monkeypatch.delenv("CLAUDECODE", raising=False)
178
+ monkeypatch.setattr("pathlib.Path.home", lambda: fake_home)
179
+ monkeypatch.setattr("lorekeep.integrations.detect.shutil.which", lambda _: None)
180
+
181
+ result = runner.invoke(app, ["init", "--yes"])
182
+ assert result.exit_code == 0, result.stdout
183
+ assert "no coding agents detected" in result.stdout.lower()
@@ -1,7 +1,7 @@
1
1
  import json
2
2
  from pathlib import Path
3
3
  from lorekeep.integrations.common import resolve_command, agent_memory_snippet
4
- from lorekeep.integrations import claude_code, cursor, codex
4
+ from lorekeep.integrations import claude_code, cursor, codex, opencode
5
5
 
6
6
 
7
7
  def test_resolve_command_pypi():
@@ -66,3 +66,39 @@ def test_codex_rejects_newline_in_ns(tmp_path: Path):
66
66
  import pytest
67
67
  with pytest.raises(ValueError):
68
68
  codex.write_config(tmp_path, "uvx", ["lorekeep"], ns="team\n[malicious]")
69
+
70
+
71
+ def test_opencode_writes_json(tmp_path: Path):
72
+ opencode.write_config(tmp_path, "uvx", ["lorekeep", "serve", "--transport", "stdio"],
73
+ ns="teams/backend")
74
+ data = json.loads((tmp_path / "opencode.json").read_text())
75
+ entry = data["mcp"]["lorekeep"]
76
+ assert entry["type"] == "local"
77
+ assert entry["command"] == ["uvx", "lorekeep", "serve", "--transport", "stdio"]
78
+ assert entry["enabled"] is True
79
+ assert entry["environment"]["LOREKEEP_NS"] == "teams/backend"
80
+
81
+
82
+ def test_opencode_no_ns(tmp_path: Path):
83
+ opencode.write_config(tmp_path, "uvx", ["lorekeep", "serve", "--transport", "stdio"], ns=None)
84
+ data = json.loads((tmp_path / "opencode.json").read_text())
85
+ entry = data["mcp"]["lorekeep"]
86
+ assert "environment" not in entry
87
+
88
+
89
+ def test_opencode_idempotent(tmp_path: Path):
90
+ opencode.write_config(tmp_path, "uvx", ["lorekeep"], ns="team/a")
91
+ opencode.write_config(tmp_path, "uvx", ["lorekeep"], ns="team/b")
92
+ data = json.loads((tmp_path / "opencode.json").read_text())
93
+ assert data["mcp"]["lorekeep"]["environment"]["LOREKEEP_NS"] == "team/b"
94
+
95
+
96
+ def test_opencode_preserves_existing_keys(tmp_path: Path):
97
+ existing = {"$schema": "https://opencode.ai/config.json", "model": "anthropic/claude-sonnet-4-5", "mcp": {"other": {"type": "local", "command": ["foo"]}}}
98
+ (tmp_path / "opencode.json").write_text(json.dumps(existing))
99
+ opencode.write_config(tmp_path, "uvx", ["lorekeep", "serve", "--transport", "stdio"], ns=None)
100
+ data = json.loads((tmp_path / "opencode.json").read_text())
101
+ assert data["$schema"] == "https://opencode.ai/config.json"
102
+ assert data["model"] == "anthropic/claude-sonnet-4-5"
103
+ assert "other" in data["mcp"]
104
+ assert "lorekeep" in data["mcp"]
@@ -26,3 +26,17 @@ def test_mcp_add_codex_user(tmp_path: Path, monkeypatch):
26
26
  assert result.exit_code == 0, result.stdout
27
27
  text = (tmp_path / "config.toml").read_text()
28
28
  assert "--from" in text and "git+https://github.com/x/lorekeep.git" in text
29
+
30
+
31
+ def test_mcp_add_opencode_project(tmp_path: Path, monkeypatch):
32
+ monkeypatch.chdir(tmp_path)
33
+ monkeypatch.setenv("LOREKEEP_CONFIG", str(tmp_path / "config.yaml"))
34
+ (tmp_path / "config.yaml").write_text("install_source: local\n")
35
+ result = runner.invoke(app, ["mcp", "add", "--agent", "opencode", "--ns", "teams/backend"])
36
+ assert result.exit_code == 0, result.stdout
37
+ import json
38
+ data = json.loads((tmp_path / "opencode.json").read_text())
39
+ entry = data["mcp"]["lorekeep"]
40
+ assert entry["type"] == "local"
41
+ assert entry["command"] == ["lorekeep", "serve", "--transport", "stdio"]
42
+ assert entry["environment"]["LOREKEEP_NS"] == "teams/backend"
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.1.13"
3
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes