Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4215eaf
feat(cli): export and validate OKF v0.2 bundles
phernandez Sep 14, 2026
286c68f
fix(cli): address OKF export compatibility review
phernandez Sep 14, 2026
0fcf890
fix(cli): tighten OKF concept and entry boundaries
phernandez Sep 14, 2026
64a0be9
fix(cli): preserve BM source frontmatter conventions
phernandez Sep 14, 2026
73a1f83
fix(cli): narrow export history schema dependencies
phernandez Sep 14, 2026
68cc8c9
fix(cli): preserve indented source fence content
phernandez Sep 14, 2026
a16b65f
fix(cli): resolve unique BM filename aliases in exports
phernandez Sep 14, 2026
540b76e
fix(cli): reuse BM source frontmatter classification
phernandez Sep 14, 2026
753ee70
fix(cli): normalize OKF export project references
phernandez Sep 15, 2026
2072488
fix(cli): preserve export indentation and link policy
phernandez Sep 15, 2026
a53c548
fix(cli): preserve canonical title lookup and ambiguity
phernandez Sep 15, 2026
9bb9fae
fix(cli): reuse Wiki ownership and canonical note types
phernandez Sep 15, 2026
a3765de
fix(cli): keep bare filename aliases project scoped
phernandez Sep 15, 2026
eb82d9f
fix(cli): preserve Markdown labels and literal OKF paths
phernandez Sep 15, 2026
45b92be
fix(cli): resolve nested manual commands and scalar OKF aliases
phernandez Sep 15, 2026
5c46f90
fix(cli): honor escaped OKF wikilink delimiters
phernandez Sep 15, 2026
9bacc52
fix(cli): preserve OKF permalink resolution precedence
phernandez Sep 15, 2026
961ff85
fix(cli): reject duplicate offline OKF permalinks
phernandez Sep 15, 2026
5d2f07a
fix(cli): separate OKF title and path resolution
phernandez Sep 15, 2026
1aee8d2
fix(cli): keep OKF filename fallback source-specific
phernandez Sep 15, 2026
ec0b373
fix(cli): preserve unresolved paths and deterministic YAML sets
phernandez Sep 15, 2026
d0b9b4c
fix(cli): honor explicit OKF project qualifiers
phernandez Sep 15, 2026
4e38727
fix(cli): abort OKF export on incomplete file scans
phernandez Sep 15, 2026
d97b053
fix(cli): include generated OKF identities and reject stage collisions
phernandez Sep 15, 2026
1bb4dc6
fix(cli): reject ambiguous source identity and network link rewrites
phernandez Sep 15, 2026
111154e
fix(cli): guard export source identity and exact project selection
phernandez Sep 15, 2026
b71487e
test(cli): model case-insensitive existence on Python 3.14
phernandez Sep 15, 2026
c5dc3f5
fix(cli): honor strict ignore rules and resource title links
phernandez Sep 15, 2026
0f4fc77
fix(cli): preserve external ID wikilinks during export
phernandez Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion scripts/update_man_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,18 @@
# must import them explicitly or every subcommand comes back empty.
from basic_memory.cli.app import app
import basic_memory.cli.commands.posix # noqa: F401 (registers cat/grep/ls/find/tail/head/tree)
import basic_memory.cli.commands.okf # noqa: F401 (registers OKF commands)
import basic_memory.cli.commands.man # noqa: F401 (registers `bm man apropos`)

# Section-1 page name -> `bm` command path. Seven pages resolve directly from the
# page name (`grep` -> `bm grep`); apropos(1) documents `bm man apropos`, a verb on
# the `man` subgroup, so it needs an explicit path. The map lives here rather than
# in page frontmatter because man1/*.md is only ever rewritten by this generator.
SECTION1_COMMAND_PATHS: Mapping[str, str] = {"apropos": "man apropos"}
SECTION1_COMMAND_PATHS: Mapping[str, str] = {
"apropos": "man apropos",
"okf-export": "okf export",
"okf-check": "okf check",
}


def resolve_cli_command(command_path: str) -> Any:
Expand Down
4 changes: 4 additions & 0 deletions src/basic_memory/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ def app_callback(
) -> None:
"""Basic Memory - Local-first personal knowledge management."""

# OKF check is filesystem-only; export resolves config lazily without source repair.
if ctx.invoked_subcommand == "okf":
return
Comment thread
phernandez marked this conversation as resolved.

command_name = ctx.invoked_subcommand or "root"

# Host installation only copies packaged resources. Broken DB/config state
Expand Down
70 changes: 70 additions & 0 deletions src/basic_memory/cli/commands/okf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Export and check static OKF v0.2 directory bundles."""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

import typer

from basic_memory.cli.app import app

if TYPE_CHECKING:
from basic_memory.okf.validation import CheckReport

okf_app = typer.Typer(help="Export and check OKF v0.2-compatible directory bundles")
app.add_typer(okf_app, name="okf")


def print_report(report: CheckReport, json_output: bool) -> None:
if json_output:
typer.echo(report.model_dump_json())
else:
typer.echo(
f"{'Valid' if report.success else 'Invalid'} OKF bundle: {report.concepts} concepts"
)
for diagnostic in report.diagnostics:
typer.echo(f"{diagnostic.path}: {diagnostic.rule}: {diagnostic.message}")
if not report.success:
raise typer.Exit(1)


@okf_app.command("check")
def check(
bundle_path: Path = typer.Argument(..., help="Directory bundle to validate"),
json_output: bool = typer.Option(False, "--json", help="Output machine-readable diagnostics"),
) -> None:
"""Validate OKF v0.2 structural rules; exit nonzero on violations."""
from basic_memory.okf.validation import check_bundle

print_report(check_bundle(bundle_path), json_output)


@okf_app.command("export")
def export(
destination: Path = typer.Argument(..., help="Destination outside the source project"),
project: str = typer.Option(..., "--project", "-p", help="Configured local project to export"),
replace: bool = typer.Option(False, "--replace", help="Replace an existing destination bundle"),
json_output: bool = typer.Option(False, "--json", help="Output machine-readable diagnostics"),
) -> None:
"""Stage, validate, and publish a static OKF v0.2-compatible bundle.

Preserves source files and non-Markdown assets. Links become standard Markdown;
BM semantics use the bm.okf_export extension. History is best-effort recorded history.
"""
from basic_memory.cli.commands.command_utils import run_with_cleanup
from basic_memory.cli.container import get_or_create_container
from basic_memory.db import maybe_install_uvloop
from basic_memory.okf.export import export_project
from basic_memory.okf.validation import CheckReport, Diagnostic

try:
config = get_or_create_container().config
# PostgreSQL needs the guarded policy before run_with_cleanup creates its loop.
maybe_install_uvloop(config)
report = run_with_cleanup(export_project(config, project, destination, replace=replace))
except (ValueError, OSError, UnicodeError) as error:
report = CheckReport(
diagnostics=[Diagnostic(path=str(destination), rule="export", message=str(error))]
)
print_report(report, json_output)
1 change: 1 addition & 0 deletions src/basic_memory/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def _version_only_invocation(argv: list[str]) -> bool:
install,
man,
mcp,
okf,
orphans,
posix,
project,
Expand Down
24 changes: 23 additions & 1 deletion src/basic_memory/ignore_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,9 @@ def load_bmignore_patterns() -> Set[str]:
return patterns


def load_gitignore_patterns(base_path: Path, use_gitignore: bool = True) -> Set[str]:
def load_gitignore_patterns(
base_path: Path, use_gitignore: bool = True, *, strict: bool = False
) -> Set[str]:
"""Load gitignore patterns from .gitignore file and .bmignore.

Combines patterns from:
Expand All @@ -212,10 +214,30 @@ def load_gitignore_patterns(base_path: Path, use_gitignore: bool = True) -> Set[
Args:
base_path: The base directory to search for .gitignore file
use_gitignore: If False, only load patterns from .bmignore (default: True)
strict: Read without creating files; only missing ignore files permit defaults.

Returns:
Set of patterns to ignore
"""
if strict:
# Publishing a snapshot must never silently include files excluded by unreadable rules.
patterns: set[str] = set()
bmignore_path = get_bmignore_path()
paths = [bmignore_path, base_path / ".gitignore"] if use_gitignore else [bmignore_path]
for path in paths:
try:
content = path.read_text(encoding="utf-8")
except FileNotFoundError:
content = ""
patterns.update(
pattern
for line in content.splitlines()
if (pattern := _parse_ignore_pattern_line(line)) is not None
)
if path == bmignore_path and not patterns:
patterns.update(DEFAULT_IGNORE_PATTERNS)
return patterns

# Start with patterns from .bmignore
patterns = load_bmignore_patterns()

Expand Down
21 changes: 15 additions & 6 deletions src/basic_memory/index/local_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import asyncio
import os
import stat
from collections.abc import Mapping, Sequence
from contextlib import nullcontext
from dataclasses import dataclass
Expand Down Expand Up @@ -223,6 +224,7 @@ def scan_local_project_index_files(
project_root: Path,
*,
ignore_patterns: LocalProjectIndexIgnorePatterns | None = None,
strict: bool = False,
) -> LocalProjectIndexScan:
"""Walk one local project and report eligible files plus unreadable subtrees."""
project_root = project_root.expanduser().resolve()
Expand Down Expand Up @@ -254,7 +256,7 @@ def _scan_error(error: OSError) -> None:
# The root scan failed (onerror re-raised). Never return an empty,
# delete-everything snapshot; files discovered before a deeper traversal
# error are kept.
if not file_paths:
if strict or not file_paths:
raise
break

Expand All @@ -269,16 +271,23 @@ def _scan_error(error: OSError) -> None:

for name in filenames:
path = root_path / name
try:
if path.is_symlink() or not path.is_file():
continue
except OSError:
continue
relative_path = path.relative_to(project_root).as_posix()
if local_relative_path_is_filtered(relative_path):
continue
if should_ignore_path(path, project_root, active_ignore_patterns):
continue
try:
# Export requires every eligible file: lstat propagates errors that
# pathlib predicates may suppress, without following symlinks.
if strict:
if not stat.S_ISREG(path.lstat().st_mode):
continue
elif path.is_symlink() or not path.is_file():
continue
except OSError:
if strict:
raise
continue
file_paths.append(relative_path)

return LocalProjectIndexScan(
Expand Down
6 changes: 5 additions & 1 deletion src/basic_memory/man/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,11 @@ def _render_cli_form(
alternatives = [_synopsis_option_token(present_longs[opt])[1:-1] for opt in pair]
tokens.append("[" + " | ".join(alternatives) + "]")
else:
tokens.append(_synopsis_option_token(param, required=param.name in required_options))
tokens.append(
_synopsis_option_token(
param, required=param.required or param.name in required_options
)
)

prefix = f"bm {command_path}"
indent = " " * (len(prefix) + 1)
Expand Down
56 changes: 56 additions & 0 deletions src/basic_memory/man/man1/okf-check(1).md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
title: okf-check(1)
type: manpage
section: 1
name: okf-check
summary: check OKF v0.2 structural conformance without a project database
generated: cli
---

# okf-check(1)

## NAME

**okf-check** — check OKF v0.2 structural conformance without a project database

## SYNOPSIS

```
bm okf check BUNDLE_PATH [--json]
```

## DESCRIPTION

Walk a directory bundle without Basic Memory configuration, indexing, or ignore
rules. Every non-reserved .md file must be UTF-8, have parseable YAML mapping
frontmatter, and carry a non-empty string `type`. Only root index.md may have
frontmatter, containing only `okf_version`. Index sections have headings and
entries use standard Markdown links. log.md has no frontmatter and groups
recorded entries under `## YYYY-MM-DD` headings, newest first.

Diagnostics identify the file, rule, and problem. Exit status is 0 for a valid
bundle and 1 for violations or unreadable files. JSON contains `concepts` and
`diagnostics`; reserved files and assets are not counted as concepts.

Unknown types, unknown keys, missing optional fields, broken cross-links,
missing indexes, and non-Markdown assets are accepted. Version declarations
are advisory. Symlinks are diagnosed as non-portable. This checks the structural
contract, not trust, attestation execution, or every optional field convention.

The contract follows OKF v0.2 §11:
https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/e6d34fd29c1c6c75ec23078e7a8191a9c8209620/okf/SPEC.md

## OPTIONS

- **--json** — Output machine-readable diagnostics

## EXAMPLES

```
bm okf check ~/exports/research
bm okf check ~/exports/research --json
```

## SEE ALSO

- see_also [[okf-export(1)]]
88 changes: 88 additions & 0 deletions src/basic_memory/man/man1/okf-export(1).md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
title: okf-export(1)
type: manpage
section: 1
name: okf-export
summary: export a local project as an OKF v0.2-compatible bundle
generated: cli
---

# okf-export(1)

## NAME

**okf-export** — export a local project as an OKF v0.2-compatible bundle

## SYNOPSIS

```
bm okf export DESTINATION --project PROJECT [--replace] [--json]
```

## DESCRIPTION

Export a configured local project to a static directory outside the project.
Source files remain unchanged. Cloud projects must first be pulled locally.
The export follows Basic Memory's project ignore rules; non-Markdown assets
such as PDFs retain their relative paths. Symlinks are not exported.
Concept filenames must use lowercase `.md`; supported BM alternatives such as
`.markdown` or `.MD` must be renamed before export so OKF readers cannot skip them.

Concept frontmatter is retained, with `type` using BM's canonical string value
and defaulting to `note` when absent or null. Absent `tags` default to an empty
list. Other metadata values are preserved. Wikilinks become standard Markdown links.
Exact file paths, titles, and permalinks resolve within the exported snapshot;
unresolved links remain broken links. Ambiguous aliases are not guessed.
Code examples retain literal wikilinks.

Generated index.md files contain standard links and only the root carries
`okf_version: "0.2"` frontmatter. The root log.md has no frontmatter and
records accepted Basic Memory journal history under ISO date headings.
File materialization may lag recorded acceptance; the log does not claim
every recorded version is represented by the exported files.
It does not reconstruct offline edits. Live Wiki bytes are not copied.
Unmarked files at reserved filenames must be renamed before export, even without
frontmatter; only recognized Wiki artifacts or marked OKF indexes are replaced.
Databases predating the accepted-change journal produce an empty history without
being migrated by export.

The destination is staged and checked before publication. Existing destinations
are refused unless `--replace` is explicit. A failed publication restores the
previous bundle; if restoration also fails, its bytes remain in a sibling
`.NAME.bm-okf-backup-*` directory. Source changes detected during export cause
failure. Export is intended for a quiescent project, not as a transaction over
concurrent filesystem edits. Unchanged project state produces identical bytes.

## BM EXTENSION

The YAML `bm.okf_export` mapping has `version: 1` and `relations`, an ordered
list of original BM wikilink relations with `type`, `target`, and `context`.
This preserves typed edges and authored target spelling after links become
ordinary Markdown. Existing `bm` keys are preserved; an existing `okf_export`
key or non-mapping `bm` is a collision and fails export.

Categorized observations retain their human-readable `[category] content`
syntax, tags, context, and temporal qualifiers in the body. They are not copied
into a second metadata list. The extension declares this BM interpretation of
the body; generic OKF consumers can read it as ordinary Markdown. Relations
in metadata are authoritative for recovering BM edge types; ordinary Markdown
links alone only express untyped edges. This command does not add an importer
or switch Basic Memory's canonical syntax.

## OPTIONS

- **-p, --project** — Configured local project to export
- **--replace** — Replace an existing destination bundle
- **--json** — Output machine-readable diagnostics

## EXAMPLES

```
bm okf export ~/exports/research --project research
bm okf export ~/exports/research --project research --replace --json
bm okf check ~/exports/research
```

## SEE ALSO

- see_also [[okf-check(1)]]
1 change: 1 addition & 0 deletions src/basic_memory/okf/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Static Open Knowledge Format export and filesystem conformance checks."""
Loading
Loading