Skip to content

Commit ca5a721

Browse files
Add openarmature CLI and patterns API (agent-docs A3+A4) (#73)
* Add openarmature CLI and patterns API Two new agent-discovery surfaces that complement the bundled AGENTS.md from the previous release cycle: - openarmature.patterns exposes list() and get(name) reading the same patterns content as the bundled file via importlib.resources. Useful in sandboxed environments that can import openarmature but can't freely read arbitrary package paths. - openarmature CLI registers init (writes a discovery pointer block into the host project's AGENTS.md / CLAUDE.md) and docs (prints the bundled AGENTS.md path). The same surface is reachable as python -m openarmature via __main__.py for environments where the [project.scripts] entry point doesn't land cleanly. The CLI's pointer block is sourced from a canonical src/openarmature/_pointer_block.md so editing what init writes doesn't require touching Python code. init uses a comment marker (<!-- openarmature-init -->) for idempotency so renaming the visible heading doesn't fool the re-run detection. The generator at scripts/build_agents_md.py now emits per-pattern .md files under src/openarmature/_patterns/ with a programmatic-only transform (no heading demotion; intra-pattern links rewritten to absolute openarmature.ai URLs). The drift test extends to cover the new directory. __init__.py advertises all three discovery surfaces (bundled AGENTS.md, programmatic patterns API, CLI). README's "For AI agents" section mentions the CLI and the patterns API. * Add UTF-8 encoding and zipimport guard in CLI Address two issues raised in PR #73 review: - _apply_init_to_file now reads and writes project AGENTS.md / CLAUDE.md with an explicit encoding="utf-8" on every read_text / write_text call. The platform default text encoding is not UTF-8 on Windows (cp1252), which would produce UnicodeDecodeError on UTF-8 content in existing files or mojibake when writing the pointer block. - _bundled_agents_md_path now uses importlib.resources.as_file to resolve the bundled AGENTS.md and raises RuntimeError with a clear message when the install isn't filesystem-backed (pure zipimport). Previously the function would print a non-existent path that the caller would then fail to open. cmd_docs handles the RuntimeError by printing the message to stderr and exiting with code 2. The docstring now claims only wheel and editable installs (the realistic shapes for this distribution) rather than implying "or zipped".
1 parent 6b5cc13 commit ca5a721

17 files changed

Lines changed: 1315 additions & 49 deletions

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
88

99
### Added
1010

11-
- **Bundled agent documentation at `openarmature/AGENTS.md`.** The wheel now ships a generated `AGENTS.md` file at the installed package root, agent-discoverable via `python -c "import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')"`. Sections include a TL;DR, capability summaries pulled from the pinned spec submodule's §1 (Purpose) + §2 (Concepts), the patterns docs, hand-written non-obvious-shapes recipes, and a one-line example index. Generator lives at `scripts/build_agents_md.py`; the committed file is CI-drift-checked by `tests/test_agents_md_drift.py`. The submodule pin discipline (build refuses unless the submodule HEAD is AT a `v*` tag via `git tag --points-at HEAD`) prevents draft (untagged) spec text — or text from a commit between two release tags — from leaking into a release bundle. Adopting projects can point their own `AGENTS.md` / `CLAUDE.md` at this path so agent sessions in their codebase find it automatically.
11+
- **`openarmature.patterns` programmatic API.** Two-function surface (`list() -> list[str]`, `get(name: str) -> str`) exposing the same patterns content shipped in the bundled `AGENTS.md`. Each pattern is returned as a standalone markdown document: no heading demotion (patterns keep their original `# Title`), and relative `../concepts/...md` / `../examples/...md` / intra-pattern links are rewritten to absolute `openarmature.ai` URLs at build time so cross-references resolve outside the source tree. Useful for agents in sandboxed environments that can `import openarmature` but can't freely read arbitrary package paths. Content lives at `src/openarmature/_patterns/<slug>.md`, generated alongside the bundled `AGENTS.md` and drift-checked by `tests/test_agents_md_drift.py`. Unknown names raise `KeyError` with a message listing the known names.
12+
- **`openarmature` CLI** registered as a `[project.scripts]` entry point with two subcommands:
13+
- `openarmature init` appends a discovery pointer block (the `python -c "..."` one-liner + `openarmature docs` recipe) into the current project's `AGENTS.md` and `CLAUDE.md` so agent sessions opening the project find the bundled OpenArmature docs. Creates files when absent, appends when they exist, and skips re-runs via a `<!-- openarmature-init -->` comment marker. Flags: `--force` (re-append despite the marker), `--dry-run` (print what would be written), `--cwd PATH` (operate against a path other than the current directory).
14+
- `openarmature docs` prints the absolute path to the bundled `AGENTS.md`. Equivalent to the README discovery one-liner but ergonomic to type and remember.
15+
- The same surface is reachable as `python -m openarmature ...` via `src/openarmature/__main__.py`, so environments where the `[project.scripts]` entry doesn't land cleanly (some `pip install --target` layouts, path-shadowed venvs) still work as long as the package is importable.
16+
- **Bundled agent documentation at `openarmature/AGENTS.md`.** The wheel now ships a generated `AGENTS.md` file at the installed package root, agent-discoverable via `python -c "import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')"`. Sections include a TL;DR, capability summaries pulled from the pinned spec submodule's §1 (Purpose) + §2 (Concepts), the patterns docs, hand-written non-obvious-shapes recipes, and a one-line example index. Generator lives at `scripts/build_agents_md.py`; the committed file is CI-drift-checked by `tests/test_agents_md_drift.py`. The submodule pin discipline (build refuses unless the submodule HEAD is AT a `v*` tag via `git tag --points-at HEAD`) prevents draft (untagged) spec text — or text from a commit between two release tags — from leaking into a release bundle. Adopting projects can point their own `AGENTS.md` / `CLAUDE.md` at this path so agent sessions in their codebase find it automatically (or use `openarmature init` to do the wiring automatically).
1217
- **`FanOutInstanceProgress.result_is_error` field** (proposal 0027, accepted in spec v0.21.0). Explicit boolean discriminator on each per-instance entry in `CheckpointRecord.fan_out_progress``True` for `collect`-mode error contributions (roll forward into `errors_field`), `False` for success contributions (roll forward into `target_field`). The engine reads the explicit field on resume rather than inferring routing from `result`'s shape; the previous structural heuristic (`_looks_like_error_record`) is removed. Backward-compat path on load: pre-0027 records that omit the key default to `False`.
1318
- **Strict `CheckpointRecordInvalid` on fan-out count drift** (proposal 0029, accepted in spec v0.22.0). When the resumed run's resolved instance count differs from the saved `fan_out_progress` entry's `instance_count`, the engine raises `CheckpointRecordInvalid` before any fan-out instance work runs on the resumed path. Replaces the pre-0029 pad/truncate behavior which silently dropped `completed` contributions on shrink (breaking §10.11.1's exactly-once guarantee) and dispatched unsaved work on grow.
1419
- **`tool_choice` parameter on `Provider.complete()`** (proposal 0025, accepted in spec v0.20.0). Optional discriminated-union value constraining the model's tool-calling behavior — one of `"auto"`, `"required"`, `"none"`, or a `ForceTool(name=...)` record. Validation runs pre-send: `"required"` and `ForceTool` both demand non-empty `tools`, and `ForceTool.name` must appear in the supplied list; violations raise `ProviderInvalidRequest` (§7's existing category — no new error category). When `tool_choice` is `None` (the default) the wire field is omitted and the provider's own default applies, preserving pre-0025 behavior exactly. The `OpenAIProvider` maps the spec shape onto OpenAI's wire shape per §8.1.1 (the `ForceTool.type="tool"` renames to wire `type="function"`).

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,4 +204,20 @@ If you're an AI agent working in code that uses openarmature, read the bundled a
204204
python -c "import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')"
205205
```
206206

207-
The file ships with the package and covers capability contracts, common patterns, non-obvious shapes, and an example index. Adopting projects can point their own `AGENTS.md` / `CLAUDE.md` at this path so agent sessions in their codebase find it automatically.
207+
Or use the convenience CLI:
208+
209+
```bash
210+
openarmature docs # print the path to the bundled AGENTS.md
211+
python -m openarmature docs # same, via the module entry point
212+
```
213+
214+
The file ships with the package and covers capability contracts, common patterns, non-obvious shapes, and an example index. Adopting projects can run `openarmature init` from the project root to append a discovery pointer block into their own `AGENTS.md` / `CLAUDE.md` so agent sessions in their codebase find the bundled file automatically.
215+
216+
The same patterns content is also available programmatically:
217+
218+
```python
219+
import openarmature.patterns as patterns
220+
221+
patterns.list() # ['bypass-if-output-exists', ...]
222+
patterns.get('bypass-if-output-exists') # canonical recipe content (markdown)
223+
```

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ otel = [
4747
Repository = "https://github.com/LunarCommand/openarmature-python"
4848
Specification = "https://github.com/LunarCommand/openarmature-spec"
4949

50+
[project.scripts]
51+
openarmature = "openarmature.cli:main"
52+
5053
[tool.openarmature]
5154
spec_version = "0.22.1"
5255

scripts/build_agents_md.py

Lines changed: 167 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@
6161
DOCS = REPO_ROOT / "docs"
6262
EXAMPLES = REPO_ROOT / "examples"
6363
OUTPUT = REPO_ROOT / "src" / "openarmature" / "AGENTS.md"
64+
# Directory holding per-pattern transformed markdown for the
65+
# programmatic API (``openarmature.patterns``). Each ``<slug>.md``
66+
# file inside is a generated artifact; the directory is a package
67+
# (``__init__.py`` exists) so ``importlib.resources.files()`` can
68+
# locate it through the standard import mechanism. Sandboxed
69+
# environments that can ``import openarmature`` can also resolve
70+
# its package resources.
71+
PATTERNS_DIR_OUTPUT = REPO_ROOT / "src" / "openarmature" / "_patterns"
6472

6573
# Spec capability directory names under ``openarmature-spec/spec/``,
6674
# in the order they appear in the bundle's "Capability contracts"
@@ -219,51 +227,99 @@ def _capability_summaries(spec_tag: str) -> str:
219227
_PATTERN_INTRA_LINK_RE = re.compile(r"\((?!\.\.|https?://|#)([a-z0-9-]+)\.md\)")
220228

221229

222-
def _transform_pattern_content(text: str) -> str:
223-
"""Bundle-side rewrite of a pattern doc's markdown.
224-
225-
Two transforms applied for the wheel-shipped bundle (the source
226-
files in ``docs/patterns/`` stay unchanged — they're MkDocs source
227-
where relative links work correctly):
228-
229-
1. **Demote ATX headings by two levels.** Pattern files open with
230-
``# Title`` (H1); inlined verbatim under the bundle's
231-
``## Patterns`` H2, those H1s would create multiple top-level
232-
headings in the same document. Prepending ``##`` to every
233-
``#``-prefixed line puts pattern titles at H3 (under
234-
``## Patterns``) and preserves the relative depth of any
235-
deeper nested headings.
236-
237-
2. **Rewrite relative doc-tree links to absolute docs-site URLs.**
238-
Patterns link to ``../concepts/<name>.md`` and
239-
``../examples/<name>.md`` — relative paths that resolve in the
240-
MkDocs source tree but break in the installed wheel (no docs/
241-
tree present). The MkDocs site strips ``.md`` and serves at
242-
``/<section>/<name>/``, so the rewrite is mechanical.
243-
``../<section>/index.md`` collapses to the section root.
230+
def _demote_headings(text: str) -> str:
231+
"""Demote ATX headings by two levels by prepending ``##``.
232+
233+
Bundle-only transform. Pattern files open with ``# Title`` (H1);
234+
inlined verbatim under the bundle's ``## Patterns`` H2, those
235+
H1s would create multiple top-level headings in the same
236+
document. Prepending ``##`` to every ``#``-prefixed line puts
237+
pattern titles at H3 (under ``## Patterns``) and preserves the
238+
relative depth of any deeper nested headings.
244239
"""
245-
# Demote headings.
246-
demoted: list[str] = []
240+
out: list[str] = []
247241
for line in text.splitlines():
248242
if line.startswith("#"):
249243
line = "##" + line
250-
demoted.append(line)
251-
out = "\n".join(demoted)
244+
out.append(line)
245+
return "\n".join(out)
246+
247+
248+
def _rewrite_doc_tree_links(text: str) -> str:
249+
"""Rewrite relative ``../concepts/...md`` / ``../examples/...md``
250+
references to absolute ``openarmature.ai`` URLs.
251+
252+
Shared between the bundle and the programmatic patterns API —
253+
relative paths resolve in the MkDocs source tree but break
254+
everywhere else (the installed wheel, programmatic `import`
255+
consumers). The MkDocs site strips ``.md`` and serves at
256+
``/<section>/<name>/``; ``../<section>/index.md`` collapses to
257+
the section root.
258+
"""
252259

253-
# Rewrite relative doc-tree links.
254260
def _rewrite(m: re.Match[str]) -> str:
255261
section, name = m.group(1), m.group(2)
256262
if name == "index":
257263
return f"(https://openarmature.ai/{section}/)"
258264
return f"(https://openarmature.ai/{section}/{name}/)"
259265

260-
out = _PATTERN_LINK_RE.sub(_rewrite, out)
261-
# Rewrite intra-pattern links to in-document anchors. Bare-name
262-
# ``.md`` references render fine on the MkDocs site (sibling-file
263-
# resolution) but break in the bundled single-file AGENTS.md.
264-
# The demoted H3 heading slug matches the filename slug — e.g.,
265-
# ``(bypass-if-output-exists.md)`` → ``(#bypass-if-output-exists)``.
266-
return _PATTERN_INTRA_LINK_RE.sub(lambda m: f"(#{m.group(1)})", out)
266+
return _PATTERN_LINK_RE.sub(_rewrite, text)
267+
268+
269+
def _rewrite_intra_pattern_to_anchor(text: str) -> str:
270+
"""Bundle-only: rewrite pattern-to-pattern bare-name ``.md``
271+
references to in-document anchors.
272+
273+
In the bundled single-file ``AGENTS.md`` all patterns appear
274+
inline; the demoted H3 heading slug matches the filename slug,
275+
so ``(bypass-if-output-exists.md)`` → ``(#bypass-if-output-exists)``
276+
resolves to the in-document section.
277+
"""
278+
return _PATTERN_INTRA_LINK_RE.sub(lambda m: f"(#{m.group(1)})", text)
279+
280+
281+
def _rewrite_intra_pattern_to_url(text: str) -> str:
282+
"""Programmatic-only: rewrite pattern-to-pattern bare-name ``.md``
283+
references to absolute docs-site URLs.
284+
285+
The programmatic API returns one pattern at a time; in-document
286+
anchors would be dead links because the other patterns aren't
287+
in the same string. Absolute URLs to the MkDocs site let the
288+
consumer follow the cross-reference if they want to.
289+
"""
290+
291+
def _rewrite(m: re.Match[str]) -> str:
292+
name = m.group(1)
293+
return f"(https://openarmature.ai/patterns/{name}/)"
294+
295+
return _PATTERN_INTRA_LINK_RE.sub(_rewrite, text)
296+
297+
298+
def _transform_pattern_content_for_bundle(text: str) -> str:
299+
"""Apply bundle-side transforms to a pattern doc's markdown.
300+
301+
Composes the heading-demotion + doc-tree-link + intra-anchor
302+
rewrites. The source files in ``docs/patterns/`` stay unchanged
303+
— they're MkDocs source where relative links work correctly;
304+
only the bundled copy gets these rewrites.
305+
"""
306+
out = _demote_headings(text)
307+
out = _rewrite_doc_tree_links(out)
308+
out = _rewrite_intra_pattern_to_anchor(out)
309+
return out
310+
311+
312+
def _transform_pattern_content_for_programmatic(text: str) -> str:
313+
"""Apply programmatic-API transforms to a pattern doc's markdown.
314+
315+
Doc-tree-link rewrites + intra-pattern → absolute URL. No
316+
heading demotion: each pattern accessed via
317+
``openarmature.patterns.get(name)`` is a standalone document; its
318+
``# Title`` H1 is the right level.
319+
"""
320+
out = _rewrite_doc_tree_links(text)
321+
out = _rewrite_intra_pattern_to_url(out)
322+
return out
267323

268324

269325
def _patterns() -> str:
@@ -279,7 +335,7 @@ def _patterns() -> str:
279335
pattern_files = sorted(p for p in (DOCS / "patterns").glob("*.md") if p.name != "index.md")
280336
for pf in pattern_files:
281337
sections.append("")
282-
sections.append(_transform_pattern_content(pf.read_text()).rstrip())
338+
sections.append(_transform_pattern_content_for_bundle(pf.read_text()).rstrip())
283339
return "\n".join(sections)
284340

285341

@@ -365,13 +421,89 @@ def build() -> str:
365421
return "\n\n".join(sections).rstrip() + "\n"
366422

367423

424+
_PATTERNS_INIT_CONTENT = (
425+
'"""Auto-generated package holding the programmatic patterns API\'s\n'
426+
"transformed markdown payload.\n"
427+
"\n"
428+
"``openarmature.patterns.list()`` / ``get(name)`` resolve the\n"
429+
"per-pattern ``<slug>.md`` files in this package via\n"
430+
"``importlib.resources``. The files are generated artifacts —\n"
431+
"regenerate with ``uv run python scripts/build_agents_md.py``.\n"
432+
"\n"
433+
"Source: ``docs/patterns/*.md`` (excluding ``index.md``) with\n"
434+
"the programmatic-API transforms applied — relative\n"
435+
"``../concepts/...md`` / ``../examples/...md`` links rewritten\n"
436+
"to absolute ``openarmature.ai`` URLs, intra-pattern bare-name\n"
437+
"``.md`` links rewritten to absolute\n"
438+
"``openarmature.ai/patterns/...`` URLs (see\n"
439+
"``_transform_pattern_content_for_programmatic`` in\n"
440+
"``scripts/build_agents_md.py``). No heading demotion: each\n"
441+
"pattern stands alone when read via the programmatic API.\n"
442+
'"""\n'
443+
)
444+
445+
446+
def build_patterns_data() -> dict[str, str]:
447+
"""Build the per-pattern transformed markdown payload.
448+
449+
Returns a dict mapping ``<slug>.md`` filename → transformed
450+
content. Caller writes each entry to
451+
``src/openarmature/_patterns/<slug>.md``. Consumed by the
452+
programmatic patterns API (``openarmature.patterns.list()`` /
453+
``get(name)``) via ``importlib.resources``.
454+
455+
Uses the programmatic transform set (no heading demotion,
456+
intra-pattern → absolute URLs) so each pattern stands alone
457+
when read individually.
458+
"""
459+
pattern_files = sorted(p for p in (DOCS / "patterns").glob("*.md") if p.name != "index.md")
460+
out: dict[str, str] = {}
461+
for pf in pattern_files:
462+
content = _transform_pattern_content_for_programmatic(pf.read_text()).rstrip() + "\n"
463+
out[f"{pf.stem}.md"] = content
464+
return out
465+
466+
467+
def _write_patterns_data(payload: dict[str, str]) -> tuple[int, int]:
468+
"""Write per-pattern files into ``_patterns/`` + the package
469+
``__init__.py``. Returns ``(file_count, total_bytes)``.
470+
471+
Files that exist but aren't in the payload (e.g., a pattern
472+
removed upstream) are deleted so the directory stays in lockstep
473+
with the source. The ``__init__.py`` is rewritten unconditionally
474+
to keep its docstring current.
475+
"""
476+
PATTERNS_DIR_OUTPUT.mkdir(exist_ok=True)
477+
init_path = PATTERNS_DIR_OUTPUT / "__init__.py"
478+
init_path.write_text(_PATTERNS_INIT_CONTENT)
479+
expected = set(payload.keys())
480+
expected.add("__init__.py")
481+
# Clean up stray .md files from a prior generation that aren't in
482+
# the current payload (e.g., a pattern was renamed or removed).
483+
for existing in PATTERNS_DIR_OUTPUT.iterdir():
484+
if existing.name not in expected and existing.suffix == ".md":
485+
existing.unlink()
486+
total_bytes = len(_PATTERNS_INIT_CONTENT.encode("utf-8"))
487+
for filename, content in payload.items():
488+
(PATTERNS_DIR_OUTPUT / filename).write_text(content)
489+
total_bytes += len(content.encode("utf-8"))
490+
return (len(payload), total_bytes)
491+
492+
368493
def main() -> None:
369494
content = build()
370495
OUTPUT.write_text(content)
371496
line_count = content.count("\n")
372497
byte_count = len(content.encode("utf-8"))
373498
print(f"wrote {OUTPUT.relative_to(REPO_ROOT)}: {line_count} lines, {byte_count:,} bytes")
374499

500+
patterns_payload = build_patterns_data()
501+
file_count, total_bytes = _write_patterns_data(patterns_payload)
502+
print(
503+
f"wrote {PATTERNS_DIR_OUTPUT.relative_to(REPO_ROOT)}/: "
504+
f"{file_count} pattern files + __init__.py, {total_bytes:,} bytes total"
505+
)
506+
375507

376508
if __name__ == "__main__":
377509
main()

src/openarmature/__init__.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,27 @@
11
"""OpenArmature: workflow framework for LLM pipelines and tool-calling agents.
22
3-
AI agents: see ``AGENTS.md`` in this package for usage guidance
4-
(``python -c "import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')"``).
3+
AI agents: three discovery surfaces are available, pick whichever
4+
your environment can reach:
5+
6+
1. **Bundled reference** at ``openarmature/AGENTS.md`` — capability
7+
contracts, common patterns, non-obvious shapes, and an example
8+
index. Path resolves via::
9+
10+
python -c "import openarmature; print(openarmature.__path__[0] + '/AGENTS.md')"
11+
12+
Or via the CLI: ``openarmature docs`` prints the same path.
13+
14+
2. **Programmatic patterns catalog** at ``openarmature.patterns`` —
15+
``list()`` returns the available pattern names; ``get(name)``
16+
returns the canonical recipe as a markdown string. Useful in
17+
sandboxed environments that can ``import openarmature`` but
18+
can't freely read arbitrary package paths.
19+
20+
3. **CLI** registered as ``openarmature`` (and reachable as
21+
``python -m openarmature`` where script entry points don't land
22+
cleanly). ``openarmature init`` writes a discovery pointer block
23+
into the project's ``AGENTS.md`` / ``CLAUDE.md`` so future agent
24+
sessions opening the project find the bundled docs automatically.
525
"""
626

727
__version__ = "0.8.0"

src/openarmature/__main__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""Allow ``python -m openarmature`` to invoke the CLI.
2+
3+
Provides a path-independent way to reach :func:`openarmature.cli.main`
4+
in environments where the ``[project.scripts]`` entry point doesn't
5+
land cleanly — some ``pip install --target`` layouts, path-shadowed
6+
venvs, etc. As long as ``import openarmature`` works,
7+
``python -m openarmature`` works too.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import sys
13+
14+
from openarmature.cli import main
15+
16+
if __name__ == "__main__":
17+
sys.exit(main())

0 commit comments

Comments
 (0)