Setup¶
# Clone the repository
git clone https://github.com/ThatDevStudio/ztlctl.git
cd ztlctl
# Install all development dependencies
uv sync --group dev
# Verify the installation
uv run ztlctl --version
# Run the test suite
uv run pytest
Development Commands¶
uv run ztlctl --help # Run the CLI
uv run pytest --cov --cov-report=term-missing # Tests with coverage
uv run ruff check . # Lint
uv run ruff format . # Format
uv run mypy src/ # Type check
uv run pre-commit run --all-files # All pre-commit hooks
CI/CD Pipeline¶
GitHub Actions exposes two CI/CD workflows:
PR CIruns on pull requests todevelopand exposes the requiredValidate PRcheck.Release Pipelineruns only after changes land ondevelop, then performs merge validation, release, publish, and Homebrew sync as dependent jobs.
Validate PR covers lint, format, type checking, package build, security audit, the full pytest
suite, the MCP stdio integration test, and commit lint. Validate Merge in the release pipeline
re-runs the merge validation after landing on develop and adds the semantic CI smoke test using
the lightweight internal semantic-ci dependency group.
The release workflow builds dist/release-manifest.json and treats it as the source of truth
for release version, tag, asset path, download URL, and source tarball hash. Downstream publish
and Homebrew steps consume that manifest instead of rediscovering release metadata.
Homebrew Formula¶
The Homebrew tap publishes ztlctl at ThatDev/ztlctl.
# Build release assets for an existing tag
python3 scripts/build_release_manifest.py --release-tag v1.7.1 --output dist/release-manifest.json
# Generate the Homebrew formula from the release manifest
python3 scripts/update_homebrew_formula.py --manifest dist/release-manifest.json --output dist/ztlctl.rb
The formula is now a derived release artifact rather than a checked-in source file. It uses the
release tarball as the stable source, installs the CLI into a Python virtualenv, and derives both
runtime and build-backend resources from the repository's uv.lock.
Architecture¶
ztlctl follows a strict 6-layer package structure where dependencies flow downward:
commands → output → services → config/infrastructure → domain
src/ztlctl/
├── domain/ # Types, enums, lifecycle rules, ID patterns
├── infrastructure/ # SQLite/SQLAlchemy, NetworkX graph, filesystem
├── config/ # Pydantic config models, TOML discovery
├── services/ # Business logic (create, query, graph, reweave, ...)
├── output/ # Rich/JSON formatters
├── commands/ # Click CLI commands
├── plugins/ # Pluggy hook specs and built-in plugins
├── mcp/ # MCP server adapter
└── templates/ # Jinja2 templates for content creation
For the complete internal design specification (architecture decisions, invariants, implementation details), see DESIGN.md in the repository.
Action Model¶
ztlctl uses a 4-layer action model that connects user-facing surfaces (CLI, MCP) to service logic:
| Layer | Components | Role |
|---|---|---|
| Data | ActionParam, ActionDefinition |
Frozen dataclasses describing an action's name, params, side effect, and surface metadata |
| Service | *Service classes |
Business logic that executes the action and returns a ServiceResult |
| Controller | BaseController subclasses |
Wires one ActionDefinition to one Service call; handles input coercion and error wrapping |
| Registry | ActionRegistry (singleton) |
Indexed map of all registered ActionDefinition objects; queried at startup |
How CLI auto-generation works:
At startup, the CLI generator calls get_action_registry().list_actions(category=...) for each command group. It iterates the returned ActionDefinition objects and builds Click commands from ActionParam metadata (cli_is_argument, cli_flag, cli_multiple, cli_name). No code generation — the CLI surface is driven entirely by the registry at import time.
How MCP auto-generation works:
The MCP adapter calls get_action_registry().list_actions() and converts each ActionDefinition into an MCP tool descriptor using mcp_when_to_use, mcp_avoid_when, mcp_common_errors, and mcp_example fields. Same registry, different surface formatter.
Plugin integration points:
1. pre_action fires before the Controller calls the Service — plugins can abort (return ActionRejection) or transform kwargs
2. post_action fires after the Controller returns the ServiceResult — all plugins receive the call regardless of success/failure
3. register_note_types() returns NoteTypeDefinition objects — PluginManager creates ActionDefinition entries in the registry for CRUD operations on each custom type
4. register_content_models() extends the CONTENT_REGISTRY so new note subtypes are recognized by CreateService
ServiceResult contract:
Every Service method returns ServiceResult. Controllers unwrap it to emit exit codes (CLI) or structured responses (MCP). Plugins receive the raw ServiceResult via post_action(result=...).
Template Overrides¶
Vault-specific Jinja2 overrides can live under .ztlctl/templates/.
- Self-document templates:
.ztlctl/templates/self/identity.md.j2or.ztlctl/templates/identity.md.j2 - Content body templates:
.ztlctl/templates/content/note.md.j2or.ztlctl/templates/note.md.j2
ztlctl checks those override paths first and falls back to the bundled package templates when no user template exists.
Workflow Templates¶
ztlctl workflow init and ztlctl workflow update scaffold vault workflow guidance using a packaged Copier template.
- Choices: source control (
git|none), profile (dynamic installed profile ids, with deprecatednoneandvanillaaliases resolving tocore), workflow (claude-driven|agent-generic|manual), skill set (research|engineering|minimal) - Answers file:
.ztlctl/workflow-answers.yml - Generated guidance:
.ztlctl/workflow/
ztlctl init applies the default workflow scaffold automatically unless --no-workflow is passed.
Plugin Init Hooks¶
Plugins can now contribute ordered init steps through register_vault_init_steps().
- Each step receives a normalized
VaultInitContext - Steps can create files, emit warnings, and return structured setup instructions
ztlctl initaggregates those instructions into the result payload and prints them underNext steps- The first-party Obsidian profile plugin uses this surface to scaffold
.obsidian/, seedgarden/, and print plugin-install guidance before handing.obsidian/off to the user for further customization
Legacy WorkspaceProfileContribution.init_scaffold remains supported temporarily and is wrapped into the same ordered init pipeline for compatibility.
Further Reading¶
- Plugin Authoring Guide — build plugins with hookspecs, custom note types, and capability declarations
- API Reference — auto-generated reference for
ActionDefinition,ActionRegistry, hookspecs, and plugin contracts
Contributing¶
See CONTRIBUTING.md for the full contribution guide, including:
- Branching model and PR workflow
- Conventional commit format
- Pre-submit checklist
- Code standards and dependency management
- Dependency management (always use
uv add, neveruv pip install)