From c816c0de2cca6511eb1e0a87e0fa423ae51e3f43 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 11 Jun 2026 01:04:40 -0500 Subject: [PATCH 1/7] fix(mcp): substitute OSS discount code in cloud_info and release_notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled markdown for both tools carries a {{OSS_DISCOUNT_CODE}} placeholder, and the tools returned it verbatim — users saw the literal template string. The prior test asserted the placeholder, pinning the bug. Substitute the code from cli.promo (single source of truth) on read, and assert that no template braces ever reach tool output. Fixes #958. Found during live verification for #952. Co-Authored-By: Claude Fable 5 Signed-off-by: phernandez --- src/basic_memory/mcp/tools/cloud_info.py | 9 ++++++++- src/basic_memory/mcp/tools/release_notes.py | 9 ++++++++- tests/mcp/test_tool_cloud_discovery.py | 9 +++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/basic_memory/mcp/tools/cloud_info.py b/src/basic_memory/mcp/tools/cloud_info.py index 891018ef1..864ad3a16 100644 --- a/src/basic_memory/mcp/tools/cloud_info.py +++ b/src/basic_memory/mcp/tools/cloud_info.py @@ -11,5 +11,12 @@ ) def cloud_info() -> str: """Return optional Basic Memory Cloud information and setup guidance.""" + # Import here to avoid pulling CLI promo machinery (analytics, rich, config) + # into the MCP server import graph at module load. + from basic_memory.cli.promo import OSS_DISCOUNT_CODE + content_path = Path(__file__).parent.parent / "resources" / "cloud_info.md" - return content_path.read_text(encoding="utf-8") + content = content_path.read_text(encoding="utf-8") + # The bundled markdown carries a template placeholder so the promo code has + # one source of truth (cli.promo); substitute before it reaches users. + return content.replace("{{OSS_DISCOUNT_CODE}}", OSS_DISCOUNT_CODE) diff --git a/src/basic_memory/mcp/tools/release_notes.py b/src/basic_memory/mcp/tools/release_notes.py index f325c47ac..c437a69fc 100644 --- a/src/basic_memory/mcp/tools/release_notes.py +++ b/src/basic_memory/mcp/tools/release_notes.py @@ -11,5 +11,12 @@ ) def release_notes() -> str: """Return the latest product release notes for optional user review.""" + # Import here to avoid pulling CLI promo machinery (analytics, rich, config) + # into the MCP server import graph at module load. + from basic_memory.cli.promo import OSS_DISCOUNT_CODE + content_path = Path(__file__).parent.parent / "resources" / "release_notes.md" - return content_path.read_text(encoding="utf-8") + content = content_path.read_text(encoding="utf-8") + # The bundled markdown carries a template placeholder so the promo code has + # one source of truth (cli.promo); substitute before it reaches users. + return content.replace("{{OSS_DISCOUNT_CODE}}", OSS_DISCOUNT_CODE) diff --git a/tests/mcp/test_tool_cloud_discovery.py b/tests/mcp/test_tool_cloud_discovery.py index 7b7b3288f..d9d8041d7 100644 --- a/tests/mcp/test_tool_cloud_discovery.py +++ b/tests/mcp/test_tool_cloud_discovery.py @@ -1,5 +1,6 @@ """Tests for cloud discovery MCP tools.""" +from basic_memory.cli.promo import OSS_DISCOUNT_CODE from basic_memory.mcp.tools import cloud_info, release_notes @@ -7,8 +8,10 @@ def test_cloud_info_tool_returns_expected_copy(): result = cloud_info() assert "# Basic Memory Cloud (optional)" in result - assert "{{OSS_DISCOUNT_CODE}}" in result + assert OSS_DISCOUNT_CODE in result assert "bm cloud login" in result + # Regression (#958): the template placeholder must never reach users. + assert "{{" not in result def test_release_notes_tool_returns_expected_copy(): @@ -16,5 +19,7 @@ def test_release_notes_tool_returns_expected_copy(): assert "# Release Notes" in result assert "2026-02-06" in result - assert "{{OSS_DISCOUNT_CODE}}" in result + assert OSS_DISCOUNT_CODE in result assert "bm cloud login" in result + # Regression (#958): the template placeholder must never reach users. + assert "{{" not in result From 1557798dc63fea8157033a93eb41308062dcd0d7 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 11 Jun 2026 01:04:40 -0500 Subject: [PATCH 2/7] docs(mcp): fix stale 'basic-memory sync' references and document search total semantics - README, Docker guide, and character-handling guide referenced 'basic-memory sync', which no longer exists; point them at 'basic-memory reindex' (the incremental filesystem scan) instead. Part of #959. - Document in the search_notes docstring that vector/hybrid searches report total: 0 by design (no second counting pass) and that has_more is the pagination signal in those modes. Found during live verification for #952. Co-Authored-By: Claude Fable 5 Signed-off-by: phernandez --- README.md | 2 +- docs/Docker.md | 6 +++--- docs/character-handling.md | 4 ++-- src/basic_memory/mcp/tools/search.py | 5 +++++ 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ca6367151..159cb952d 100644 --- a/README.md +++ b/README.md @@ -590,7 +590,7 @@ retention). | `BASIC_MEMORY_IMPORT_UPLOAD_MAX_BYTES` | `104857600` | Max uploaded import size | ```bash -BASIC_MEMORY_LOG_LEVEL=DEBUG basic-memory sync +BASIC_MEMORY_LOG_LEVEL=DEBUG basic-memory reindex tail -f ~/.basic-memory/basic-memory.log ``` diff --git a/docs/Docker.md b/docs/Docker.md index 249d02769..8b8b4b926 100644 --- a/docs/Docker.md +++ b/docs/Docker.md @@ -111,7 +111,7 @@ You can run Basic Memory CLI commands inside the container using `docker exec`: docker exec basic-memory-server basic-memory status # Sync files -docker exec basic-memory-server basic-memory sync +docker exec basic-memory-server basic-memory reindex # Show help docker exec basic-memory-server basic-memory --help @@ -137,7 +137,7 @@ When using Docker volumes, you'll need to configure projects to point to your mo 3. **Sync the new project:** ```bash - docker exec basic-memory-server basic-memory sync + docker exec basic-memory-server basic-memory reindex ``` ### Example: Setting up an Obsidian Vault @@ -157,7 +157,7 @@ docker exec basic-memory-server basic-memory project create obsidian /app/data docker exec basic-memory-server basic-memory project set-default obsidian # Sync to index all files -docker exec basic-memory-server basic-memory sync +docker exec basic-memory-server basic-memory reindex ``` ### Environment Variables diff --git a/docs/character-handling.md b/docs/character-handling.md index f23742b73..86a478d2d 100644 --- a/docs/character-handling.md +++ b/docs/character-handling.md @@ -184,8 +184,8 @@ finance/ (lowercase f) Use Basic Memory's built-in conflict detection: ```bash -# Sync will report conflicts -basic-memory sync +# Index local file changes (reports conflicts) +basic-memory reindex # Check sync status for warnings basic-memory status diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 08f87bc14..72091abad 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -820,6 +820,11 @@ async def search_notes( Formatted markdown text (output_format="text"), dict (output_format="json"), or helpful error guidance string if search fails + Pagination note: `total` is exact only for text/title/permalink searches. + Vector and hybrid searches skip the count query (it would cost a second + semantic retrieval pass) and report `total: 0` even when results are + returned — use `has_more` for pagination in those modes. + Examples: # Basic text search results = await search_notes("project planning") From e57895dbc1f6d11b8fdafbd270ad009f0fab9164 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 11 Jun 2026 01:04:40 -0500 Subject: [PATCH 3/7] feat(plugins): add manpage seed schema for documentation projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picoschema for Unix-style manual pages as Basic Memory notes (#952): section/name/summary/verified frontmatter contracts, gotcha/bug/pattern observation categories, see_also relations. Opt-in seed — not wired into bm-setup's default seeding; documentation projects adopt it explicitly. Validated by just package-check-claude-code. The first consumer is the 'manual' project in the Basic Memory team workspace: 38 pages validating 38/38 against this schema. Co-Authored-By: Claude Fable 5 Signed-off-by: phernandez --- plugins/claude-code/schemas/manpage.md | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 plugins/claude-code/schemas/manpage.md diff --git a/plugins/claude-code/schemas/manpage.md b/plugins/claude-code/schemas/manpage.md new file mode 100644 index 000000000..103758c36 --- /dev/null +++ b/plugins/claude-code/schemas/manpage.md @@ -0,0 +1,55 @@ +--- +title: Manpage +type: schema +entity: Manpage +version: 1 +schema: + gotcha?(array): string, sharp edges and surprising behavior learned from live verification + example?(array): string, worked examples beyond the generated synopsis + pattern?(array): string, recommended idioms and usage patterns + bug?(array): string, known defects affecting this surface, with issue links + see_also?(array): Entity, related manual pages — the SEE ALSO graph +settings: + validation: warn + frontmatter: + section(enum, Unix manual section number): [1, 3, 5, 7, 8] + name: string, page name without section suffix (e.g. write-note) + summary: string, one-line NAME description + generated?(enum, who owns the mechanical sections): [registry, typer, hand] + tool?: string, MCP tool this page documents (section 3 pages) + command?: string, CLI command this page documents (section 1 pages) + verified?: string, version and path that verified this page (e.g. 0.21.6 mcp+cli) + since?: string, version this surface first appeared +--- + +# Manpage + +A **ManpageNote** is one page of a Unix-style manual implemented as Basic +Memory notes (issue #952): commands in section 1, MCP tools in section 3, +file formats in section 5, concepts in section 7, admin in section 8. The +manual becomes a knowledge graph — `SEE ALSO` entries are typed relations, +and pages are found by structured recall: +`search_notes(metadata_filters={"type": "manpage", "section": 3})`. + +This schema is an opt-in seed for documentation projects; the canonical +manual lives in the Basic Memory team workspace `manual` project. + +## What makes a good ManpageNote + +- **NAME / SYNOPSIS / DESCRIPTION** — classic man-page structure, with + PARAMETERS, MCP USAGE, CLI EQUIVALENT, EXAMPLES, GOTCHAS, SEE ALSO where + applicable. +- **Verified examples** — EXAMPLES contain only commands that actually ran; + the `verified` field records the version and path (mcp, cli, or both). +- **generated** — declares regeneration ownership: `registry` (from the MCP + tool registry) and `typer` (from CLI help) pages get mechanical sections + rewritten; curated sections (EXAMPLES, GOTCHAS, SEE ALSO, observations) + are never overwritten. +- **gotcha / bug observations** — field knowledge accumulates on pages + without being clobbered by regeneration; bugs link their tracking issues. + +## Frontmatter + +`type: manpage` plus `section` makes the manual queryable like `man -k`: +by section, by `tool`, by `command`, or by missing/stale `verified` stamps. +Validation is `warn`, never blocking. From 5258038c143bed70436d33ad6eddf8ae1748bbd0 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 11 Jun 2026 09:49:49 -0500 Subject: [PATCH 4/7] docs(core): document the manual-pages flow What the manual is (notes conforming to the Manpage schema, SEE ALSO as relations), where it lives (team-workspace 'manual' project; opt-in seed schema for any project), the verification discipline (examples must run, playground rule, verified stamps, schema-validate as linter), how to add pages, and the generator/bm-man roadmap. Part of #952. Co-Authored-By: Claude Fable 5 Signed-off-by: phernandez --- docs/manual-pages.md | 135 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/manual-pages.md diff --git a/docs/manual-pages.md b/docs/manual-pages.md new file mode 100644 index 000000000..74a3ec698 --- /dev/null +++ b/docs/manual-pages.md @@ -0,0 +1,135 @@ +# Manual Pages + +Basic Memory's manual is written in the style of Unix man pages — and +implemented as Basic Memory notes ([#952](https://github.com/basicmachines-co/basic-memory/issues/952)). +Every page is a markdown note conforming to the `Manpage` schema, `SEE ALSO` +entries are real knowledge-graph relations, and every example on every page +was executed against a live project before the page shipped. The manual +documents the tools; the tools verify the manual. + +## Where it lives + +The canonical manual is the **`manual` project in the Basic Memory team +workspace** (cloud, shared). Anyone can build their own: the schema ships as +an opt-in seed at `plugins/claude-code/schemas/manpage.md` — copy it into any +project's folder and start writing pages against it. + +Layout: + +``` +manual/ +├── schemas/Manpage.md # the manpage schema (type: schema) +├── man1/ # CLI commands bm(1), bm-status(1), ... +├── man3/ # MCP tools write-note(3), search-notes(3), ... +├── man5/ # file formats bm-note(5), bm-observation(5), ... +├── man7/ # concepts basic-memory(7), semantic-memory(7), ... +├── playground/ # scratch notes for destructive examples +└── diagrams/ # canvas visualizations of the manual graph +``` + +Sections follow Unix numbering: `1` CLI commands, `3` MCP tools, `5` file +formats and schemas, `7` concepts, `8` admin/cloud operations. + +## Page anatomy + +Pages use the classic headers where applicable: `NAME`, `SYNOPSIS`, +`DESCRIPTION`, `PARAMETERS`, `MCP USAGE`, `CLI EQUIVALENT`, `EXAMPLES`, +`GOTCHAS`, `SEE ALSO`. Frontmatter (validated by the schema): + +```yaml +type: manpage +section: 3 # 1 | 3 | 5 | 7 | 8 +name: write-note # page name without section suffix +summary: create or overwrite a markdown note in the knowledge base +generated: hand # hand | registry | typer (regeneration ownership) +tool: write_note # section-3 pages: the MCP tool documented +command: basic-memory status # section-1 pages: the CLI command documented +verified: 0.21.6 mcp+cli # version + path(s) that proved the page +``` + +Field knowledge accumulates as observations — `[gotcha]`, `[bug]` (with issue +links), `[pattern]` — and `SEE ALSO` entries are `see_also` relations, so the +manual is a navigable graph, not a folder of files. + +## How to use it + +Man-style reads (any MCP client or the CLI): + +```bash +# read a page +bm tool read-note "man3/write-note-3" --project manual + +# apropos — find pages by section, tool, or text +bm tool search-notes --project manual # then filter, or via MCP: +# search_notes(project="manual", metadata_filters={"type": "manpage", "section": 3}) +# search_notes(project="manual", metadata_filters={"type": "manpage", "tool": "write_note"}) + +# traverse SEE ALSO from any page +# build_context(url="man3/write-note-3", project="manual") +``` + +A future `bm man ` command is thin sugar over exactly these calls. + +## The verification discipline + +Two rules make the manual trustworthy: + +1. **Examples must have run.** An `EXAMPLES` (or `MCP USAGE` / `CLI + EQUIVALENT`) block contains only commands that actually executed against + the manual project. Destructive operations (`delete_note`, `move_note`, + destructive `edit_note`) run only against `playground/` notes — never + against pages. The `verified:` field records the version and which path + proved the page: `mcp` (live service), `cli` (dev checkout), or both. + +2. **The schema is the linter.** Validate the whole manual any time: + + ```bash + bm tool schema-validate manpage --project manual + # → {"total_notes": 38, "valid_count": 38, "warning_count": 0, ...} + ``` + + `bm orphans --project manual` confirms every page is connected to the + graph, and `schema_diff`/`schema_infer` report drift between the schema + and how pages are actually written. + +Because verification exercises real tool calls against the live service, +building the manual doubles as an end-to-end smoke test. The initial build +found six bugs in one pass (#954–#959) — including the verification rule +catching a test that asserted a bug as expected output (#958). + +## Adding or updating a page + +1. Run the commands you intend to document; keep the actual output. +2. Write the page with `write_note`, passing frontmatter through the + `metadata` parameter (nested YAML in content frontmatter is unreliable on + some clients): + + ``` + write_note(title="my-tool(3)", directory="man3", project="manual", + note_type="manpage", + metadata={"section": 3, "name": "my-tool", + "summary": "...", "generated": "hand", + "tool": "my_tool", "verified": " mcp"}) + ``` + +3. Link related pages in `SEE ALSO` with `see_also [[other-page(3)]]`. + Forward references to pages that don't exist yet are fine — they resolve + automatically when the target is written. +4. Validate: `bm tool schema-validate manpage --project manual`. + +For mechanical updates to generated sections, prefer `edit_note` with +`replace_section` / `insert_after_section` so curated content (EXAMPLES, +GOTCHAS, SEE ALSO, observations) survives — that ownership split is what the +`generated:` field declares. + +## Roadmap + +- **Registry generator** — section-3 SYNOPSIS/PARAMETERS generated from the + MCP tool registry (docstrings + pydantic schemas), section-1 from Typer + help; the hand-written corpus is the template spec. Regenerate-and-diff in + CI becomes the drift gate. +- **`bm man `** — CLI sugar over `read_note` + metadata search. +- **Real man pages / docs site** — the same extraction renders to groff + ([#610](https://github.com/basicmachines-co/basic-memory/issues/610)) and + to the hosted docs site; the notes remain canonical for sections 5 and 7, + code is canonical for 1 and 3. From 97ee1d12ffa0bad1befa3009962336202a2260f0 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 11 Jun 2026 12:07:36 -0500 Subject: [PATCH 5/7] refactor(mcp): extract shared discovery-resource loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #971: - cloud_info and release_notes duplicated the read-and-substitute logic and its rationale comment; both now call resources.discovery.load_discovery_resource(), the single place that renders promo placeholders (and the single place to extend when more placeholders land) - character-handling doc claimed reindex 'reports conflicts'; it handles them during the scan but emits no conflict report — say what it does Co-Authored-By: Claude Fable 5 Signed-off-by: phernandez --- docs/character-handling.md | 2 +- src/basic_memory/mcp/resources/discovery.py | 17 +++++++++++++++++ src/basic_memory/mcp/tools/cloud_info.py | 13 ++----------- src/basic_memory/mcp/tools/release_notes.py | 13 ++----------- 4 files changed, 22 insertions(+), 23 deletions(-) create mode 100644 src/basic_memory/mcp/resources/discovery.py diff --git a/docs/character-handling.md b/docs/character-handling.md index 86a478d2d..698a65ba2 100644 --- a/docs/character-handling.md +++ b/docs/character-handling.md @@ -184,7 +184,7 @@ finance/ (lowercase f) Use Basic Memory's built-in conflict detection: ```bash -# Index local file changes (reports conflicts) +# Index local file changes (conflicts are handled during the scan) basic-memory reindex # Check sync status for warnings diff --git a/src/basic_memory/mcp/resources/discovery.py b/src/basic_memory/mcp/resources/discovery.py new file mode 100644 index 000000000..ce7f32c59 --- /dev/null +++ b/src/basic_memory/mcp/resources/discovery.py @@ -0,0 +1,17 @@ +"""Shared loader for the bundled cloud-discovery markdown resources.""" + +from pathlib import Path + + +def load_discovery_resource(filename: str) -> str: + """Read a bundled discovery markdown file with promo placeholders rendered. + + The markdown carries a {{OSS_DISCOUNT_CODE}} placeholder so the promo code + has one source of truth (cli.promo); substitute before it reaches users. + """ + # Import here to avoid pulling CLI promo machinery (analytics, rich, config) + # into the MCP server import graph at module load. + from basic_memory.cli.promo import OSS_DISCOUNT_CODE + + content = (Path(__file__).parent / filename).read_text(encoding="utf-8") + return content.replace("{{OSS_DISCOUNT_CODE}}", OSS_DISCOUNT_CODE) diff --git a/src/basic_memory/mcp/tools/cloud_info.py b/src/basic_memory/mcp/tools/cloud_info.py index 864ad3a16..43396c57d 100644 --- a/src/basic_memory/mcp/tools/cloud_info.py +++ b/src/basic_memory/mcp/tools/cloud_info.py @@ -1,7 +1,6 @@ """Cloud information MCP tool.""" -from pathlib import Path - +from basic_memory.mcp.resources.discovery import load_discovery_resource from basic_memory.mcp.server import mcp @@ -11,12 +10,4 @@ ) def cloud_info() -> str: """Return optional Basic Memory Cloud information and setup guidance.""" - # Import here to avoid pulling CLI promo machinery (analytics, rich, config) - # into the MCP server import graph at module load. - from basic_memory.cli.promo import OSS_DISCOUNT_CODE - - content_path = Path(__file__).parent.parent / "resources" / "cloud_info.md" - content = content_path.read_text(encoding="utf-8") - # The bundled markdown carries a template placeholder so the promo code has - # one source of truth (cli.promo); substitute before it reaches users. - return content.replace("{{OSS_DISCOUNT_CODE}}", OSS_DISCOUNT_CODE) + return load_discovery_resource("cloud_info.md") diff --git a/src/basic_memory/mcp/tools/release_notes.py b/src/basic_memory/mcp/tools/release_notes.py index c437a69fc..bc926ab0c 100644 --- a/src/basic_memory/mcp/tools/release_notes.py +++ b/src/basic_memory/mcp/tools/release_notes.py @@ -1,7 +1,6 @@ """Release notes MCP tool.""" -from pathlib import Path - +from basic_memory.mcp.resources.discovery import load_discovery_resource from basic_memory.mcp.server import mcp @@ -11,12 +10,4 @@ ) def release_notes() -> str: """Return the latest product release notes for optional user review.""" - # Import here to avoid pulling CLI promo machinery (analytics, rich, config) - # into the MCP server import graph at module load. - from basic_memory.cli.promo import OSS_DISCOUNT_CODE - - content_path = Path(__file__).parent.parent / "resources" / "release_notes.md" - content = content_path.read_text(encoding="utf-8") - # The bundled markdown carries a template placeholder so the promo code has - # one source of truth (cli.promo); substitute before it reaches users. - return content.replace("{{OSS_DISCOUNT_CODE}}", OSS_DISCOUNT_CODE) + return load_discovery_resource("release_notes.md") From 4c3e4887d065bd03685b57c955f84737d8149cba Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 11 Jun 2026 12:32:08 -0500 Subject: [PATCH 6/7] =?UTF-8?q?feat(cli):=20make=20'man=20bm'=20real=20?= =?UTF-8?q?=E2=80=94=20bundled=20groff=20page=20and=20'bm=20man=20install'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of #610, grown out of the #952 manual work: - src/basic_memory/man/bm.1: hand-written groff overview page (content from the verified bm(1) manual note), plus basic-memory.1 as a .so alias - 'bm man install [--dir]' copies the bundled pages to a man root (default ~/.local/share/man, which manpath(1) derives from ~/.local/bin on PATH on both man-db and BSD man); warns with a one-line MANPATH fix when the root is provably unsearched, stays quiet when manpath is unavailable - verified end-to-end: 'man -M bm' renders, alias resolves - docs/manual-pages.md: lay out the Unix section numbering (1-8, with examples and the crontab(1)/crontab(5) story), document man bm usage, update the roadmap Agents with shell access get 'man bm' as an offline quick reference; the per-tool detail stays in the manual project's section-3 pages. Co-Authored-By: Claude Fable 5 Signed-off-by: phernandez --- docs/manual-pages.md | 60 +++++++++- src/basic_memory/cli/commands/__init__.py | 2 + src/basic_memory/cli/commands/man.py | 76 ++++++++++++ src/basic_memory/cli/main.py | 1 + src/basic_memory/man/basic-memory.1 | 1 + src/basic_memory/man/bm.1 | 134 ++++++++++++++++++++++ tests/cli/test_man_command.py | 70 +++++++++++ 7 files changed, 338 insertions(+), 6 deletions(-) create mode 100644 src/basic_memory/cli/commands/man.py create mode 100644 src/basic_memory/man/basic-memory.1 create mode 100644 src/basic_memory/man/bm.1 create mode 100644 tests/cli/test_man_command.py diff --git a/docs/manual-pages.md b/docs/manual-pages.md index 74a3ec698..f2501ff84 100644 --- a/docs/manual-pages.md +++ b/docs/manual-pages.md @@ -27,8 +27,42 @@ manual/ └── diagrams/ # canvas visualizations of the manual graph ``` -Sections follow Unix numbering: `1` CLI commands, `3` MCP tools, `5` file -formats and schemas, `7` concepts, `8` admin/cloud operations. +### Why "man1", "man3", "man5"? + +The folder names are Unix's, unchanged since 1971. The manual is divided +into numbered **sections**, pages physically live in directories named +after them (`/usr/share/man/man1`, `man5`, ...), and the number tells you +what *kind* of thing is documented — not importance, not reading order: + +- **1** — user commands (`ls`, `grep`) +- **2** — system calls +- **3** — library functions / APIs (`printf(3)`) +- **4** — devices +- **5** — file formats and config files (`crontab(5)`, `passwd(5)`) +- **6** — games (really) +- **7** — miscellanea: concepts, conventions, overviews (`regex(7)`, `signal(7)`) +- **8** — system administration + +That's also why man page names carry the parenthesized number — +`crontab(1)` is the command, `crontab(5)` is the file format, same name in +two sections. `man 5 crontab` picks the section explicitly. + +This manual copies that layout with the sections that have a Basic Memory +analog: + +- **man1/** — `bm` CLI commands → `bm-status(1)` +- **man3/** — MCP tools, our equivalent of the "library API" section → `write-note(3)` +- **man5/** — file formats: note syntax, observations, relations, schemas → `bm-note(5)` +- **man7/** — concepts → `basic-memory(7)`, `semantic-memory(7)` +- **8** is reserved for admin/cloud operations but has no pages yet; 2, 4, + and 6 have no analog (no system calls, no devices, and no games — yet) + +When a page says `see_also [[bm-note(5)]]`, the `(5)` reads "the +file-format page," exactly the way a Unix manual cross-references — except +here it's a traversable relation in the graph instead of a typographic +convention. The manual explains its own conventions in `man-pages(7)` — +fittingly, the same page name Linux uses for this, and that almost nobody +ever reads. ## Page anatomy @@ -70,6 +104,19 @@ bm tool search-notes --project manual # then filter, or via MCP: A future `bm man ` command is thin sugar over exactly these calls. +And for the real thing — `man bm` in an actual terminal: + +```bash +bm man install # copies bundled groff pages to ~/.local/share/man +man bm # the overview page, rendered by man(1) +man basic-memory # same page via its alias +``` + +`bm man install` warns with a one-line `MANPATH` fix if the install root +isn't searched by your `man`. Agents with shell access can use `man bm` as +an offline quick reference; the full per-tool detail stays in the manual +project's section-3 pages. + ## The verification discipline Two rules make the manual trustworthy: @@ -129,7 +176,8 @@ GOTCHAS, SEE ALSO, observations) survives — that ownership split is what the help; the hand-written corpus is the template spec. Regenerate-and-diff in CI becomes the drift gate. - **`bm man `** — CLI sugar over `read_note` + metadata search. -- **Real man pages / docs site** — the same extraction renders to groff - ([#610](https://github.com/basicmachines-co/basic-memory/issues/610)) and - to the hosted docs site; the notes remain canonical for sections 5 and 7, - code is canonical for 1 and 3. + (`bm man install` + a hand-written `bm.1` already ship — the first slice + of [#610](https://github.com/basicmachines-co/basic-memory/issues/610); + the generator will produce per-command pages from the same extraction.) +- **Docs site** — the notes remain canonical for sections 5 and 7, code is + canonical for 1 and 3; both render to the hosted docs site. diff --git a/src/basic_memory/cli/commands/__init__.py b/src/basic_memory/cli/commands/__init__.py index f7776ef5b..d0daefaee 100644 --- a/src/basic_memory/cli/commands/__init__.py +++ b/src/basic_memory/cli/commands/__init__.py @@ -4,6 +4,7 @@ from . import ( import_claude_projects, import_chatgpt, + man, tool, project, format, @@ -29,4 +30,5 @@ "schema", "update", "workspace", + "man", ] diff --git a/src/basic_memory/cli/commands/man.py b/src/basic_memory/cli/commands/man.py new file mode 100644 index 000000000..eb24d6a38 --- /dev/null +++ b/src/basic_memory/cli/commands/man.py @@ -0,0 +1,76 @@ +"""Install the bundled man pages so `man bm` works.""" + +import shutil +import subprocess +from pathlib import Path +from typing import Annotated, Optional + +import typer +from rich.console import Console + +from basic_memory.cli.app import app + +console = Console() + +man_app = typer.Typer(help="Manage the bm man pages.") +app.add_typer(man_app, name="man") + +# Bundled groff sources ship inside the package (src/basic_memory/man). +_MAN_SOURCE_DIR = Path(__file__).parent.parent.parent / "man" + + +def _default_man_root() -> Path: + # Why ~/.local/share/man: manpath(1) derives man directories from PATH + # entries on both man-db (Linux) and BSD man (macOS), so ~/.local/bin on + # PATH — the pipx/uv tool layout — makes this root searchable without any + # MANPATH configuration. + return Path.home() / ".local" / "share" / "man" + + +def _man_root_on_manpath(man_root: Path) -> Optional[bool]: + """Best-effort check whether man(1) will search man_root; None if unknown.""" + try: + result = subprocess.run(["manpath"], capture_output=True, text=True, timeout=5) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if result.returncode != 0: + return None + paths = [entry.rstrip("/") for entry in result.stdout.strip().split(":") if entry] + return str(man_root).rstrip("/") in paths + + +@man_app.command() +def install( + directory: Annotated[ + Optional[Path], + typer.Option( + "--dir", + help="Man root to install into (default: ~/.local/share/man)", + ), + ] = None, +) -> None: + """Install the bm man pages, then try `man bm`.""" + man_root = (directory or _default_man_root()).expanduser() + man1 = man_root / "man1" + man1.mkdir(parents=True, exist_ok=True) + + pages = sorted(_MAN_SOURCE_DIR.glob("*.1")) + if not pages: # pragma: no cover - broken packaging, not a runtime state + console.print("[red]No bundled man pages found — broken installation[/red]") + raise typer.Exit(1) + + for page in pages: + shutil.copyfile(page, man1 / page.name) + console.print(f"installed {man1 / page.name}") + + # Trigger: the chosen root is provably absent from manpath output. + # Why: a silent install into an unsearched directory looks like success + # but `man bm` still fails; say so and hand over the one-line fix. + # Outcome: actionable hint; unknown (None) stays quiet to avoid false alarms. + if _man_root_on_manpath(man_root) is False: + console.print( + f"\n[yellow]{man_root} is not on your manpath.[/yellow] Add it with:\n" + f' export MANPATH="{man_root}:$MANPATH"' + ) + + console.print("\nTry: [bold]man bm[/bold]") diff --git a/src/basic_memory/cli/main.py b/src/basic_memory/cli/main.py index 46d9c94d6..a329ae7f7 100644 --- a/src/basic_memory/cli/main.py +++ b/src/basic_memory/cli/main.py @@ -24,6 +24,7 @@ def _version_only_invocation(argv: list[str]) -> bool: import_claude_conversations, import_claude_projects, import_memory_json, + man, mcp, orphans, project, diff --git a/src/basic_memory/man/basic-memory.1 b/src/basic_memory/man/basic-memory.1 new file mode 100644 index 000000000..dcf2fd572 --- /dev/null +++ b/src/basic_memory/man/basic-memory.1 @@ -0,0 +1 @@ +.so man1/bm.1 diff --git a/src/basic_memory/man/bm.1 b/src/basic_memory/man/bm.1 new file mode 100644 index 000000000..fbfdc89ea --- /dev/null +++ b/src/basic_memory/man/bm.1 @@ -0,0 +1,134 @@ +.TH BM 1 "2026-06-11" "basic-memory" "Basic Memory Manual" +.SH NAME +bm \- local-first knowledge base for humans and AI agents +.SH SYNOPSIS +.B bm +.I COMMAND +.RI [ ARGS ]... +.br +.B basic-memory +.I COMMAND +.RI [ ARGS ]... +.SH DESCRIPTION +.B bm +manages Basic Memory projects: plain markdown files that form a knowledge +graph. Files are the source of truth; SQLite provides indexing and +full-text search; the same operations are exposed to AI agents over the +Model Context Protocol (MCP) and to humans and scripts through this CLI. +.PP +Notes use semantic markdown: observations +.RB ( "\- [category] text #tag" ) +and relations +.RB ( "\- relation_type [[Target]]" ) +become queryable graph structure. Projects route independently to the +local API or to Basic Memory Cloud. +.SH COMMANDS +Knowledge operations: +.TP +.B bm tool +CLI access to the MCP tools (write-note, read-note, search-notes, +build-context, ...). These emit JSON and are the scriptable surface. +.TP +.B bm status +Show sync status between files and the database. +.TP +.B bm reindex +Index local file changes and rebuild search/embeddings. This is the manual +sync trigger when no MCP server is running. +.TP +.B bm doctor +Run end-to-end file/database consistency checks. +.TP +.B bm orphans +List entities with no relations in the knowledge graph. +.TP +.B bm format +Run configured formatters over note files. +.PP +Projects and schemas: +.TP +.B bm project +Add, remove, list projects; set the default; flip a project between local +and cloud routing. +.TP +.B bm schema +List, validate, infer, and drift-check Picoschema note-type contracts. +.PP +Data and cloud: +.TP +.B bm import +Import from ChatGPT, Claude, or memory.json exports. Imports write files; +run +.B bm reindex +afterwards. +.TP +.B bm cloud +Authenticate, sync (push/pull/sync/bisync), snapshots, and team workspace +administration. +.PP +Infrastructure: +.TP +.B bm mcp +Run the MCP server (hosts the live file watcher). +.TP +.B bm man +Manage these man pages +.RB ( "bm man install" ). +.TP +.B bm reset +Drop and recreate the database (destructive). +.PP +Most commands accept +.B \-\-project +.I NAME +to target a project and +.BR \-\-local / \-\-cloud +to override routing. +.SH EXAMPLES +Write and find a note from the shell: +.PP +.nf +.RS +echo "# Standup notes" | bm tool write-note \\ + \-\-title "Standup" \-\-folder notes +bm tool search-notes "standup" +.RE +.fi +.PP +Pick up files created outside the tools: +.PP +.nf +.RS +bm status # shows pending changes +bm reindex # indexes them +.RE +.fi +.SH FILES +.TP +.I ~/.basic-memory/config.json +Projects, default project, per-project routing modes, cloud settings. +.TP +.I ~/.basic-memory/memory.db +SQLite index (derived; safe to rebuild with bm reindex). +.SH ENVIRONMENT +.TP +.B BASIC_MEMORY_FORCE_LOCAL +Force local routing regardless of cloud mode. +.TP +.B BASIC_MEMORY_LOG_LEVEL +Logging verbosity (e.g. DEBUG). +.SH SEE ALSO +Full manual (machine-readable, agent-traversable): the +.I manual +Basic Memory project \(em see docs/manual-pages.md in the repository. +Documentation: https://docs.basicmemory.com +.PP +For AI agents: the complete tool reference lives in the manual project as +section\-3 pages (write-note(3), search-notes(3), ...), queryable via +.B search_notes +with +.BR "metadata_filters={\(dqtype\(dq: \(dqmanpage\(dq}" . +.SH BUGS +https://github.com/basicmachines-co/basic-memory/issues +.SH AUTHORS +Basic Machines (https://basicmachines.co) diff --git a/tests/cli/test_man_command.py b/tests/cli/test_man_command.py new file mode 100644 index 000000000..597b8b120 --- /dev/null +++ b/tests/cli/test_man_command.py @@ -0,0 +1,70 @@ +"""Tests for `bm man install` (#952 / #610: make `man bm` work).""" + +import subprocess + +from typer.testing import CliRunner + +from basic_memory.cli.app import app + +# Importing the module registers the man command group on the top-level app. +import basic_memory.cli.commands.man as man_command # noqa: F401 + +runner = CliRunner() + + +def test_man_install_writes_pages_to_target(tmp_path): + """Install copies every bundled page into /man1 as valid groff.""" + result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)]) + + assert result.exit_code == 0, result.output + + bm_page = tmp_path / "man1" / "bm.1" + alias_page = tmp_path / "man1" / "basic-memory.1" + assert bm_page.exists() + assert alias_page.exists() + # groff sanity: a real title header and the alias .so include + assert bm_page.read_text().startswith(".TH BM 1") + assert alias_page.read_text().strip() == ".so man1/bm.1" + assert "Try:" in result.output + + +def test_man_install_warns_when_root_not_on_manpath(tmp_path, monkeypatch): + """A root provably absent from manpath output gets the MANPATH hint.""" + + def fake_run(*args, **kwargs): + return subprocess.CompletedProcess( + args=["manpath"], returncode=0, stdout="/usr/share/man:/opt/man", stderr="" + ) + + monkeypatch.setattr(man_command.subprocess, "run", fake_run) + result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert "not on your manpath" in result.output + assert "MANPATH" in result.output + + +def test_man_install_stays_quiet_when_manpath_unavailable(tmp_path, monkeypatch): + """No manpath binary → no false-alarm warning, install still succeeds.""" + + def fake_run(*args, **kwargs): + raise FileNotFoundError("manpath") + + monkeypatch.setattr(man_command.subprocess, "run", fake_run) + result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert "not on your manpath" not in result.output + + +def test_man_install_treats_manpath_failure_as_unknown(tmp_path, monkeypatch): + """manpath exiting non-zero → unknown, no warning.""" + + def fake_run(*args, **kwargs): + return subprocess.CompletedProcess(args=["manpath"], returncode=1, stdout="", stderr="boom") + + monkeypatch.setattr(man_command.subprocess, "run", fake_run) + result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)]) + + assert result.exit_code == 0, result.output + assert "not on your manpath" not in result.output From bd87ae0e45cccc56c9958b0f80e75ec59e1ad762 Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 11 Jun 2026 12:58:14 -0500 Subject: [PATCH 7/7] test(cli): make man install assertions terminal-width-proof CI failed because rich wrapped 'not on your manpath' across a line break at the runner's 80-column width; the substring assertion missed it while local wider terminals passed. Collapse whitespace before asserting so the presence and absence checks are immune to wrap position. Verified at COLUMNS=80 and COLUMNS=40. Co-Authored-By: Claude Fable 5 Signed-off-by: phernandez --- tests/cli/test_man_command.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/cli/test_man_command.py b/tests/cli/test_man_command.py index 597b8b120..0088b6f07 100644 --- a/tests/cli/test_man_command.py +++ b/tests/cli/test_man_command.py @@ -12,6 +12,13 @@ runner = CliRunner() +def _flattened(output: str) -> str: + # rich wraps console output at the terminal width, which differs between + # local shells and CI — collapse all whitespace so phrase assertions can't + # be split by a line break. + return " ".join(output.split()) + + def test_man_install_writes_pages_to_target(tmp_path): """Install copies every bundled page into /man1 as valid groff.""" result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)]) @@ -25,7 +32,7 @@ def test_man_install_writes_pages_to_target(tmp_path): # groff sanity: a real title header and the alias .so include assert bm_page.read_text().startswith(".TH BM 1") assert alias_page.read_text().strip() == ".so man1/bm.1" - assert "Try:" in result.output + assert "Try:" in _flattened(result.output) def test_man_install_warns_when_root_not_on_manpath(tmp_path, monkeypatch): @@ -40,8 +47,8 @@ def fake_run(*args, **kwargs): result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)]) assert result.exit_code == 0, result.output - assert "not on your manpath" in result.output - assert "MANPATH" in result.output + assert "not on your manpath" in _flattened(result.output) + assert "MANPATH" in _flattened(result.output) def test_man_install_stays_quiet_when_manpath_unavailable(tmp_path, monkeypatch): @@ -54,7 +61,7 @@ def fake_run(*args, **kwargs): result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)]) assert result.exit_code == 0, result.output - assert "not on your manpath" not in result.output + assert "not on your manpath" not in _flattened(result.output) def test_man_install_treats_manpath_failure_as_unknown(tmp_path, monkeypatch): @@ -67,4 +74,4 @@ def fake_run(*args, **kwargs): result = runner.invoke(app, ["man", "install", "--dir", str(tmp_path)]) assert result.exit_code == 0, result.output - assert "not on your manpath" not in result.output + assert "not on your manpath" not in _flattened(result.output)