diff --git a/.claude/commands/docs.md b/.claude/commands/docs.md index fff3f9f..f59e657 100644 --- a/.claude/commands/docs.md +++ b/.claude/commands/docs.md @@ -12,6 +12,9 @@ tool. If that fails (e.g. no base branch found), fall back to `git status` and `git diff` against HEAD to see uncommitted changes instead. +Sync the changelog files from `properties.yml` (see `.github/instructions/docs.instructions.md`) by +running `uv run --no-sync invoke docs.update_changelogs` using the Bash tool. + Using that diff, audit every doc and AI-config file that could be stale because of it. At minimum check: @@ -21,14 +24,19 @@ check: references to removed/renamed modules, commands, tasks, or config keys. 4. **`AGENTS.md` / `CLAUDE.md`** — thin pointer files; only need edits if the pointer chain itself changed (rare). -5. **The four synced command dirs** — `.github/prompts/*.prompt.md` (source of truth), - `.claude/commands/*.md`, `.claude/skills/*/SKILL.md`, `.clinerules/workflows/*.md` — these must - describe the same behavior; if one changed, the other three need the matching edit (see - `.github/instructions/prompts.instructions.md`). +5. **The five synced command dirs** — `.github/prompts/*.prompt.md` (source of truth), + `.claude/commands/*.md`, `.claude/skills/*/SKILL.md`, `.clinerules/workflows/*.md`, and + `.opencode/command/*.md` — these must describe the same behavior; if one changed, the other + four need the matching edit (see `.github/instructions/prompts.instructions.md`). 6. **`properties.yml`'s built-in template fragments** (`modules/setup/templates/properties/*.yml`) and any other example/config file describing setup — if `setup.sh`, `setup.ps1`, or `modules/setup/properties.py` changed what gets generated. 7. Any other `*.md` file that references a file, command, module, or behavior touched by the diff. +8. If the diff touches a component tracked under one of `properties.yml`'s change log categories + (see `.github/instructions/docs.instructions.md` — empty by default until a downstream repo adds + one), confirm that entry's `version`/`latest_changes` was actually bumped — the + `docs.update_changelogs` step above only syncs the changelog file to whatever's currently in + `properties.yml`; it can't tell you the version itself is stale. Flag it if it wasn't. For each stale doc you find, fix it directly — this is a repo-local consistency sweep, not a cross-repo sync, so no confirmation is needed before editing; git history is the safety net. diff --git a/.claude/skills/docs/SKILL.md b/.claude/skills/docs/SKILL.md index 67a48fe..bc49cb6 100644 --- a/.claude/skills/docs/SKILL.md +++ b/.claude/skills/docs/SKILL.md @@ -16,7 +16,14 @@ Gather what changed on this branch: uv run --no-sync invoke repo.pr_diff ``` +Sync the changelog files from `properties.yml` (see `.github/instructions/docs.instructions.md`): + +```bash +uv run --no-sync invoke docs.update_changelogs +``` + Then sweep every doc/AI-config surface the prompt lists (root `README.md`, module `README.md`s, -`.github/instructions/*.md`, `AGENTS.md`/`CLAUDE.md`, the synced command dirs, `properties.yml`'s -built-in template fragments) and fix anything stale directly — this is a repo-local consistency -sweep, so no confirmation is needed before editing. +`.github/instructions/*.md`, `AGENTS.md`/`CLAUDE.md`, the five synced command dirs, +`properties.yml`'s built-in template fragments, and whether any touched change-log-category entry +in `properties.yml` was actually bumped) and fix anything stale directly — this is a repo-local +consistency sweep, so no confirmation is needed before editing. diff --git a/.clinerules/workflows/docs.md b/.clinerules/workflows/docs.md index de82c74..b28380d 100644 --- a/.clinerules/workflows/docs.md +++ b/.clinerules/workflows/docs.md @@ -9,6 +9,14 @@ uv run --no-sync invoke repo.pr_diff If that fails (e.g. no base branch found), fall back to `git status` and `git diff` against HEAD to see uncommitted changes instead. +Sync the changelog files from `properties.yml` (see `.github/instructions/docs.instructions.md`): + +Run this terminal command: + +``` +uv run --no-sync invoke docs.update_changelogs +``` + Using that diff, audit every doc and AI-config file that could be stale because of it. At minimum check: @@ -18,14 +26,19 @@ check: references to removed/renamed modules, commands, tasks, or config keys. 4. **`AGENTS.md` / `CLAUDE.md`** — thin pointer files; only need edits if the pointer chain itself changed (rare). -5. **The four synced command dirs** — `.github/prompts/*.prompt.md` (source of truth), - `.claude/commands/*.md`, `.claude/skills/*/SKILL.md`, `.clinerules/workflows/*.md` — these must - describe the same behavior; if one changed, the other three need the matching edit (see - `.github/instructions/prompts.instructions.md`). +5. **The five synced command dirs** — `.github/prompts/*.prompt.md` (source of truth), + `.claude/commands/*.md`, `.claude/skills/*/SKILL.md`, `.clinerules/workflows/*.md`, and + `.opencode/command/*.md` — these must describe the same behavior; if one changed, the other + four need the matching edit (see `.github/instructions/prompts.instructions.md`). 6. **`properties.yml`'s built-in template fragments** (`modules/setup/templates/properties/*.yml`) and any other example/config file describing setup — if `setup.sh`, `setup.ps1`, or `modules/setup/properties.py` changed what gets generated. 7. Any other `*.md` file that references a file, command, module, or behavior touched by the diff. +8. If the diff touches a component tracked under one of `properties.yml`'s change log categories + (see `.github/instructions/docs.instructions.md` — empty by default until a downstream repo adds + one), confirm that entry's `version`/`latest_changes` was actually bumped — the + `docs.update_changelogs` step above only syncs the changelog file to whatever's currently in + `properties.yml`; it can't tell you the version itself is stale. Flag it if it wasn't. For each stale doc you find, fix it directly — this is a repo-local consistency sweep, not a cross-repo sync, so no confirmation is needed before editing; git history is the safety net. diff --git a/.github/instructions/docs.instructions.md b/.github/instructions/docs.instructions.md index f9692d1..59018f5 100644 --- a/.github/instructions/docs.instructions.md +++ b/.github/instructions/docs.instructions.md @@ -17,6 +17,46 @@ applyTo: "*.md" - Show invoke commands in fenced `sh` code blocks (`uv run --no-sync invoke ...`) - Keep it concise — link out rather than duplicating content +## Change Logs (`docs/change_logs//.md`) +Per-entry markdown change logs, generated from `properties.yml` — not hand-written prose. One +file per entry, newest version at the top. `modules/docs/lib/change_logs.py`'s +`CHANGELOG_CATEGORIES` tuple is empty by default here — this template has nothing version-tracked +in `properties.yml` yet. A downstream repo adds its own category (e.g. `cloudformation` for CDK +stacks, keyed by construct_id) to that tuple once it has entries worth logging; everything else in +the module already supports it. + +**Source of truth is `properties.yml`**, not the markdown file — each category section has one +entry per name: +```yaml +: + : + version: 1.0.0 + latest_changes: + author: Levon Becker # git config user.name — modules.common.properties.get_git_author() + date: 2026-08-11 + description: Initial Release # comma-separate for multiple bullets: "Added X, Fixed Y" +``` +Bumped by hand when the entry changes meaningfully — not automated. **A brand-new entry's first +`description` is always `"Initial Release"`.** + +**Rendered entry format** (`modules/docs/lib/change_logs.py`'s `expected_entry_text()`): +```markdown +## 1.0.0 - 2026-08-11 - Levon Becker +* Initial Release +``` +`## - - ` heading, one `* ` bullet per comma-separated `description` +item, blank line, next-newest entry below. No H1 title in the file — the first line is always a +`##` entry heading. + +**Sync mechanism** — `modules/docs/lib/change_logs.py`: +- `check_each_log(update=True)` — prepends any missing entry (idempotent; a current entry is a + no-op). Backs `invoke docs.update_changelogs`, which also runs as part of `invoke fix` and + `/docs` (see `.github/prompts/docs.prompt.md`) +- `check_each_log(update=False)` — read-only; raises `ValueError` on the first stale entry instead + of writing. Backs the drift gate `tests/drift/docs/test_changelogs_current.py` — bump + `properties.yml` without running `docs.update_changelogs` (or `invoke fix`) and this fails. + A no-op while `CHANGELOG_CATEGORIES` is empty. + ## Inline Code Comments - Comment the *why*, not the *what* - Reference external docs or issue numbers when a workaround is non-obvious diff --git a/.github/instructions/modules.instructions.md b/.github/instructions/modules.instructions.md index d0bc147..016c5d5 100644 --- a/.github/instructions/modules.instructions.md +++ b/.github/instructions/modules.instructions.md @@ -10,7 +10,11 @@ Modules provide reusable Python logic consumed by invoke tasks, prompts, and scr ## Locations | Path | Purpose | |------|---------| -| `modules/common/` | Helpers tightly coupled to invoke tasks (`cli`, `properties`, `utils`) | +| `modules/common/` | Helpers tightly coupled to invoke tasks (`cli`, `properties`, `prompt_commands`, `route_utils`, `utils`) | +| `modules/docs/` | Changelog sync — keeps `docs/change_logs//.md` in sync with `properties.yml` (`update.py`, `lib/change_logs.py`); `CHANGELOG_CATEGORIES` is empty until a downstream repo adds one | +| `modules/hermes/` | Syncs `~/.hermes/` config + SKILL.md from `.github/prompts/` (`sync.py`) | +| `modules/ollama/` | Local-LLM management — install/list/status/uninstall/update | +| `modules/opencode/` | Syncs `.opencode/command/` from `.github/prompts/` (`sync.py`) | | `modules/repo/` | Git/PR workflow logic (pull, push, log, squash, rebase, pr) | | `modules/setup/` | Repo bootstrap logic called by `setup.sh`/`setup.ps1` (`properties.py`) | | `modules/template/` | Syncs shared, generic tooling with the parent template repo for `/template` | @@ -19,6 +23,23 @@ Modules provide reusable Python logic consumed by invoke tasks, prompts, and scr Repo consistency checks (`check_agents`) live under root `tests/` as pytest tests, not `modules/` — see `.github/instructions/tests.instructions.md`. +## AI Tool Sync Modules + +`.github/prompts/` is the single source of truth for all slash commands. Two sync modules +generate tool-specific formats from it, both consuming the shared parser in +`modules/common/prompt_commands.py`: + +| Module | Output | Invoke command | +|---|---|---| +| `modules/hermes/sync.py` | `~/.hermes/config.yaml` + `SKILL.md` (real, global, user-home files — not repo-scoped) | `inv hermes.sync` | +| `modules/opencode/sync.py` | `.opencode/command/*.md` (repo-local, additive-only unless `--force`) | `inv opencode.sync` | + +Run `inv ai.sync` to regenerate both at once. Never hand-edit `.opencode/command/`. + +`.claude/commands/*.md`, `.claude/skills/*/SKILL.md`, and `.clinerules/workflows/*.md` have no +sync script — they're hand-maintained mirrors of `.github/prompts/`, verified by +`inv tests.check_agents`. See `.github/instructions/prompts.instructions.md`. + ## Module Conventions - One concern per file; filename matches the concern in snake_case - Use module-level functions, not classes, unless state genuinely requires it diff --git a/.github/instructions/prompts.instructions.md b/.github/instructions/prompts.instructions.md index 6831acd..8d0f8c3 100644 --- a/.github/instructions/prompts.instructions.md +++ b/.github/instructions/prompts.instructions.md @@ -1,6 +1,6 @@ --- -description: "Use when creating or editing slash-command prompt files for this project. Covers prompt structure, frontmatter, naming, the four-way sync across .github/prompts/, .claude/commands/, .claude/skills/, and .clinerules/workflows/, and how prompts interact with invoke tasks and modules." -applyTo: ".github/prompts/**,.claude/commands/**,.clinerules/workflows/**" +description: "Use when creating or editing slash-command prompt files for this project. Covers prompt structure, frontmatter, naming, the five-way sync across .github/prompts/, .claude/commands/, .claude/skills/, .clinerules/workflows/, and .opencode/command/, and how prompts interact with invoke tasks and modules." +applyTo: ".github/prompts/**,.claude/commands/**,.clinerules/workflows/**,.opencode/command/**" --- # Prompts Instructions @@ -8,8 +8,11 @@ applyTo: ".github/prompts/**,.claude/commands/**,.clinerules/workflows/**" `.github/prompts/*.prompt.md` is the **source of truth** for every slash command. It's mirrored into `.claude/commands/*.md` (Claude Code slash commands), `.claude/skills/*/SKILL.md` (Claude Code Skills — auto-discovered, not explicitly invoked, format documented in -`skills.instructions.md`), and `.clinerules/workflows/*.md` (Cline) — see Required Frontmatter and -Command Body below for the commands/clinerules format. +`skills.instructions.md`), `.clinerules/workflows/*.md` (Cline), and `.opencode/command/*.md` +(OpenCode) — see Required Frontmatter and Command Body below for the format each tool needs. +`.claude/commands/`, `.claude/skills/`, and `.clinerules/workflows/` are hand-maintained mirrors; +`.opencode/command/` is the one generated mirror — regenerate it with `invoke opencode.sync` +instead of hand-editing it (see `tasks.instructions.md`). ## Architecture Commands are the AI-facing entrypoint layer described in `.github/instructions/logic.instructions.md` @@ -47,11 +50,23 @@ Claude Code uses the filename as the command name. Extra frontmatter fields are No frontmatter — Cline workflows are plain markdown body only. The filename (minus extension) is the command name. +### OpenCode (.opencode/command/*.md) +```yaml +--- +description: Brief description +subtask: false # CRITICAL — prevents Task tool recursion +agent: general +slash_command: /command_name +--- +``` +Generated by `invoke opencode.sync` from `.github/prompts/*.prompt.md` — never hand-edit; a +mismatch is a sync bug in `modules/opencode/sync.py`, not something to patch here directly. + Claude Code Skills (`.claude/skills/*/SKILL.md`) also mirror every command, and GitHub Copilot Skills (`.github/skills/*/SKILL.md`) optionally do — see `skills.instructions.md` for both formats. ## Command Body -Claude Code and Copilot use the same inline-execution syntax: +Claude Code, Copilot, and OpenCode use the same inline-execution syntax: ``` !`uv run --no-sync python -m modules.your_module.route "$ARGUMENTS"` ``` @@ -104,9 +119,10 @@ uv run --no-sync python -m modules.your_module.route "$ARGUMENTS" if __name__ == "__main__": raise SystemExit(main()) ``` -3. Create command files in **all four tool dirs** (`.github/prompts/`, `.claude/commands/`, - `.clinerules/workflows/` with the thin wrapper body, and `.claude/skills//SKILL.md` with a - pointer body) — see Command Body above and `skills.instructions.md` for the SKILL.md format. +3. Create command files by hand in `.github/prompts/`, `.claude/commands/`, + `.clinerules/workflows/` (thin wrapper body), and `.claude/skills//SKILL.md` (pointer + body) — see Command Body above and `skills.instructions.md` for the SKILL.md format. Then run + `invoke opencode.sync` to generate the matching `.opencode/command/*.md` — don't hand-write it. 4. Run `uv run --no-sync invoke fix && uv run --no-sync invoke test` (must be 10/10 for `.py` changes) **DO NOT:** diff --git a/.github/instructions/review.instructions.md b/.github/instructions/review.instructions.md index 6fc512f..b5686c8 100644 --- a/.github/instructions/review.instructions.md +++ b/.github/instructions/review.instructions.md @@ -26,9 +26,9 @@ description: "Use when reviewing a pull request or code change in this repo — - `.github/instructions/` is this repo's source of truth for all AI/agent rules — a PR that changes behavior without updating the relevant instruction file is incomplete, not just under-documented. -- The four synced command dirs (`.github/prompts/`, `.claude/commands/`, `.claude/skills/`, - `.clinerules/workflows/`) must stay behaviorally consistent — see - `.github/instructions/prompts.instructions.md`. Flag a PR that edits some but not all of the four. +- The five synced command dirs (`.github/prompts/`, `.claude/commands/`, `.claude/skills/`, + `.clinerules/workflows/`, `.opencode/command/`) must stay behaviorally consistent — see + `.github/instructions/prompts.instructions.md`. Flag a PR that edits some but not all of the five. - Branch name and PR title/body should follow `.github/instructions/git.instructions.md` — flag a branch name that isn't lowercase `snake_case` ending in `_`, or a PR body missing the `## Summary`/`## Changes` structure. diff --git a/.github/instructions/tasks.instructions.md b/.github/instructions/tasks.instructions.md index 582fa58..899fcd9 100644 --- a/.github/instructions/tasks.instructions.md +++ b/.github/instructions/tasks.instructions.md @@ -17,11 +17,14 @@ here. - Group related tasks by subpackage: `tasks/common/` (template_python-inherited boilerplate — `main.py` for the bare `fix`/`test`/`update` aliases, `debug.py`, `ruff.py`, `setup.py`, `upgrade.py`, `uv.py`, `versioning.py`), `tasks/ai/` (tooling downstream repos use to operate on - themselves — `repo.py`, `template.py`), `tasks/tests/` (one file per check). Every subpackage is - registered at its *original* top-level names (`debug.*`, `ruff.*`, `repo.*`, `template.*`, - `tests.*`, ...), not nested under a new `common.*`/`ai.*` prefix — the whole point of these - three folders is to organize files, not to change what any repo cloned from this template - actually types. + themselves, or to integrate with a specific AI tool — `docs.py`, `hermes.py`, `ollama.py`, + `opencode.py`, `repo.py`, `template.py`), `tasks/tests/` (one file per check). Every subpackage is + registered at its *original* top-level names (`debug.*`, `ruff.*`, `docs.*`, `hermes.*`, + `ollama.*`, `opencode.*`, `repo.*`, `template.*`, `tests.*`, ...), not nested under a new + `common.*`/`ai.*` prefix — the whole point of these three folders is to organize files, not to + change what any repo cloned from this template actually types. The one exception is `ai.sync`, a + mini-`Collection("ai")` (not `ai.*` for the tools above — invoke doesn't allow a task and a + collection to share one name at the same level) holding just the `ai_sync` combo task. ## Collection Conventions - Sub-collections mirror file/folder names: `tasks/ai/repo.py` → `invoke repo.` @@ -90,8 +93,9 @@ groupings, not new namespaces. ### Combo Tasks (use these most often) | Task | Command | Description | |------|---------|-------------| -| Fix | `uv run --no-sync invoke fix` | Run all auto-fixes (ruff fix + format) | +| Fix | `uv run --no-sync invoke fix` | Run all auto-fixes (ruff fix + format + changelog sync) | | Test | `uv run --no-sync invoke test` | Run all tests (actionlint + check_agents + pylint + pytest + ruff + yamllint) | +| AI Sync | `uv run --no-sync invoke ai.sync` | Sync all AI tool commands from `.github/prompts/` (hermes + opencode) | ### Test Tasks Lives in `tasks/tests/` — one file per check (`actionlint.py`, `check_agents.py`, `pylint.py`, @@ -102,12 +106,48 @@ Pass `scope=` to `tests.pytest` to run a subset (e.g. `scope=agents`, `s | Task | Command | Description | |------|---------|-------------| | actionlint | `uv run --no-sync invoke tests.actionlint` | GitHub Actions workflow validation | -| check_agents | `uv run --no-sync invoke tests.check_agents` | Verify `.github/prompts/` is mirrored into `.claude/commands/`, `.claude/skills/`, and `.clinerules/workflows/` (`pytest -m "agents"`, i.e. `tests/agents/`) | +| check_agents | `uv run --no-sync invoke tests.check_agents` | Verify `.github/prompts/` is mirrored into `.claude/commands/`, `.claude/skills/`, `.clinerules/workflows/`, and `.opencode/command/` (`pytest -m "agents"`, i.e. `tests/agents/`) | | pylint | `uv run --no-sync invoke tests.pylint` | Python code quality | | pytest | `uv run --no-sync invoke tests.pytest` | Python unit test suite (`tests/`) | | rufflint | `uv run --no-sync invoke tests.rufflint` | Python linting and formatting | | yamllint | `uv run --no-sync invoke tests.yamllint` | YAML file validation | +### AI Sync Tasks +Lives in `tasks/ai/{hermes,opencode}.py`. `.github/prompts/` is the source of truth for all slash +commands. Run after adding or modifying any `.github/prompts/*.prompt.md` file. + +| Task | Command | Description | +|------|---------|-------------| +| sync all | `uv run --no-sync invoke ai.sync` | Sync all AI tools at once (runs both below) | +| hermes | `uv run --no-sync invoke hermes.sync` | Sync `~/.hermes/` config + SKILL.md | +| opencode | `uv run --no-sync invoke opencode.sync` | Sync `.opencode/command/` | + +`.claude/commands/`, `.claude/skills/`, and `.clinerules/workflows/` have no sync task — they're +hand-maintained mirrors, checked by `tests.check_agents` (above). + +### Ollama Tasks +Lives in `tasks/ai/ollama.py`. + +| Task | Command | Description | +|------|---------|-------------| +| clean | `uv run --no-sync invoke ollama.clean` | Remove all downloaded models and blob cache | +| install | `uv run --no-sync invoke ollama.install` | Install Ollama + a local coding LLM | +| list | `uv run --no-sync invoke ollama.list` | List installed and available models | +| restart | `uv run --no-sync invoke ollama.restart` | Restart Ollama service via Homebrew | +| start | `uv run --no-sync invoke ollama.start` | Start Ollama service via Homebrew | +| status | `uv run --no-sync invoke ollama.status` | Show Ollama service and running-model status | +| stop | `uv run --no-sync invoke ollama.stop` | Stop Ollama service via Homebrew | +| uninstall | `uv run --no-sync invoke ollama.uninstall` | Uninstall Ollama and remove all models | +| update | `uv run --no-sync invoke ollama.update` | Update Ollama binary + all installed models | + +### Docs Tasks +Lives in `tasks/ai/docs.py`. Runs as part of `invoke fix` and `/docs` — see +`.github/instructions/docs.instructions.md`. + +| Task | Command | Description | +|------|---------|-------------| +| update_changelogs | `uv run --no-sync invoke docs.update_changelogs` | Prepend any missing `docs/change_logs//.md` entries from `properties.yml` | + ### Ruff Tasks Lives in `tasks/common/ruff.py` (template_python-inherited, kept at its original `ruff.*` name). @@ -163,7 +203,9 @@ All `uv run` calls MUST use `--no-sync`. See `.github/instructions/tests.instruc `.github/prompts/*.prompt.md` is the source of truth for every slash command. `.claude/commands/`, `.claude/skills/`, and `.clinerules/workflows/` are hand-maintained mirrors, not generated by an invoke task — see `.github/instructions/prompts.instructions.md` for the required frontmatter/body -per tool and the "create in all four dirs" step whenever a prompt is added or changed. +per tool and the "create in all five dirs" step whenever a prompt is added or changed. +`.opencode/command/` is the one generated mirror — regenerate it with `invoke opencode.sync` +instead of hand-editing it. ## Task Ordering & File Locations Tasks within a file are ordered alphabetically by function name — see the Alphabetical Ordering diff --git a/.github/prompts/docs.prompt.md b/.github/prompts/docs.prompt.md index 17c1fe5..521cdd2 100644 --- a/.github/prompts/docs.prompt.md +++ b/.github/prompts/docs.prompt.md @@ -12,6 +12,10 @@ Gather what changed on this branch: If that fails (e.g. no base branch found), fall back to `git status` and `git diff` against HEAD to see uncommitted changes instead. +Sync the changelog files from `properties.yml` (see `.github/instructions/docs.instructions.md`): + +!`uv run --no-sync invoke docs.update_changelogs` + Using that diff, audit every doc and AI-config file that could be stale because of it. At minimum check: @@ -21,14 +25,19 @@ check: references to removed/renamed modules, commands, tasks, or config keys. 4. **`AGENTS.md` / `CLAUDE.md`** — thin pointer files; only need edits if the pointer chain itself changed (rare). -5. **The four synced command dirs** — `.github/prompts/*.prompt.md` (source of truth), - `.claude/commands/*.md`, `.claude/skills/*/SKILL.md`, `.clinerules/workflows/*.md` — these must - describe the same behavior; if one changed, the other three need the matching edit (see - `.github/instructions/prompts.instructions.md`). +5. **The five synced command dirs** — `.github/prompts/*.prompt.md` (source of truth), + `.claude/commands/*.md`, `.claude/skills/*/SKILL.md`, `.clinerules/workflows/*.md`, and + `.opencode/command/*.md` — these must describe the same behavior; if one changed, the other + four need the matching edit (see `.github/instructions/prompts.instructions.md`). 6. **`properties.yml`'s built-in template fragments** (`modules/setup/templates/properties/*.yml`) and any other example/config file describing setup — if `setup.sh`, `setup.ps1`, or `modules/setup/properties.py` changed what gets generated. 7. Any other `*.md` file that references a file, command, module, or behavior touched by the diff. +8. If the diff touches a component tracked under one of `properties.yml`'s change log categories + (see `.github/instructions/docs.instructions.md` — empty by default until a downstream repo adds + one), confirm that entry's `version`/`latest_changes` was actually bumped — the + `docs.update_changelogs` step above only syncs the changelog file to whatever's currently in + `properties.yml`; it can't tell you the version itself is stale. Flag it if it wasn't. For each stale doc you find, fix it directly — this is a repo-local consistency sweep, not a cross-repo sync, so no confirmation is needed before editing; git history is the safety net. diff --git a/.opencode/command/docs.md b/.opencode/command/docs.md new file mode 100644 index 0000000..a8251f4 --- /dev/null +++ b/.opencode/command/docs.md @@ -0,0 +1,46 @@ +--- +description: Audit the repo for doc/AI-config drift against recent changes and fix anything stale (READMEs, .github/instructions/, AGENTS.md, CLAUDE.md, and the synced command dirs). +subtask: false +agent: general +slash_command: /docs +--- + +Gather what changed on this branch: + +!`uv run --no-sync invoke repo.pr_diff` + +If that fails (e.g. no base branch found), fall back to `git status` and `git diff` against HEAD to +see uncommitted changes instead. + +Sync the changelog files from `properties.yml` (see `.github/instructions/docs.instructions.md`): + +!`uv run --no-sync invoke docs.update_changelogs` + +Using that diff, audit every doc and AI-config file that could be stale because of it. At minimum +check: + +1. **Root `README.md`** — Setup, Project Structure, Invoke Tasks, AI Prompts, Modules sections. +2. **Module READMEs** (`modules/*/README.md`) — for every module touched by the diff. +3. **`.github/instructions/*.md`** — the source of truth for all AI rules; look for now-stale + references to removed/renamed modules, commands, tasks, or config keys. +4. **`AGENTS.md` / `CLAUDE.md`** — thin pointer files; only need edits if the pointer chain itself + changed (rare). +5. **The five synced command dirs** — `.github/prompts/*.prompt.md` (source of truth), + `.claude/commands/*.md`, `.claude/skills/*/SKILL.md`, `.clinerules/workflows/*.md`, and + `.opencode/command/*.md` — these must describe the same behavior; if one changed, the other + four need the matching edit (see `.github/instructions/prompts.instructions.md`). +6. **`properties.yml`'s built-in template fragments** (`modules/setup/templates/properties/*.yml`) and + any other example/config file describing setup — if `setup.sh`, `setup.ps1`, or + `modules/setup/properties.py` changed what gets generated. +7. Any other `*.md` file that references a file, command, module, or behavior touched by the diff. +8. If the diff touches a component tracked under one of `properties.yml`'s change log categories + (see `.github/instructions/docs.instructions.md` — empty by default until a downstream repo adds + one), confirm that entry's `version`/`latest_changes` was actually bumped — the + `docs.update_changelogs` step above only syncs the changelog file to whatever's currently in + `properties.yml`; it can't tell you the version itself is stale. Flag it if it wasn't. + +For each stale doc you find, fix it directly — this is a repo-local consistency sweep, not a +cross-repo sync, so no confirmation is needed before editing; git history is the safety net. + +When done, report a short summary: which files you updated and why, or "no doc drift found" if +everything was already current. diff --git a/.opencode/command/fix.md b/.opencode/command/fix.md new file mode 100644 index 0000000..7cec556 --- /dev/null +++ b/.opencode/command/fix.md @@ -0,0 +1,8 @@ +--- +description: Auto-fix Python linting issues. Use when you want to run ruff check --fix and ruff format. +subtask: false +agent: general +slash_command: /fix +--- + +!`uv run --no-sync invoke fix` diff --git a/.opencode/command/pr-cleanup.md b/.opencode/command/pr-cleanup.md new file mode 100644 index 0000000..08ecad0 --- /dev/null +++ b/.opencode/command/pr-cleanup.md @@ -0,0 +1,8 @@ +--- +description: Clean up after a merged PR — switch to the default branch, pull, and delete the merged local feature branch. +subtask: false +agent: general +slash_command: /pr-cleanup +--- + +!`uv run --no-sync invoke repo.pr_cleanup` diff --git a/.opencode/command/pr-notes.md b/.opencode/command/pr-notes.md new file mode 100644 index 0000000..f77813a --- /dev/null +++ b/.opencode/command/pr-notes.md @@ -0,0 +1,20 @@ +--- +description: Compare the current feature branch to its base branch (development/main) and draft a Pull Request description with a Summary and bulleted Changes. +subtask: false +agent: general +slash_command: /pr-notes +--- + +Gather the branch and diff context: + +!`uv run --no-sync invoke repo.pr_diff` + +Using the branch, commit log, and diff above, write a Pull Request description using the canonical +format from `.github/instructions/git.instructions.md` (`## Summary` + `## Changes`). + +Then: +- If you were invoked directly by the user (they typed `/pr-notes`), save the notes by running: + `uv run --no-sync invoke repo.pr_notes_save --content=""` + Report the saved file path to the user — they may copy/paste it into an existing PR description. +- If you are running as a step inside another command (e.g. `/pr`), do not save — just hold the + composed notes so that command can use them directly. diff --git a/.opencode/command/pr.md b/.opencode/command/pr.md new file mode 100644 index 0000000..06e6054 --- /dev/null +++ b/.opencode/command/pr.md @@ -0,0 +1,25 @@ +--- +description: Draft PR notes for the current feature branch and open a Pull Request via gh (does not push). +subtask: false +agent: general +slash_command: /pr +--- + +Gather the branch and diff context: + +!`uv run --no-sync invoke repo.pr_diff` + +Using the branch, commit log, and diff above, write a Pull Request description (same as `/pr-notes`, +but do NOT save it to a file this time — just hold it in context). Use the canonical PR format +from `.github/instructions/git.instructions.md` (`## Summary` + `## Changes`). + +Then create the pull request: +1. Note the `Base branch:` value printed above. +2. Draft a concise PR title (under 70 characters) summarizing the change. +3. Run: + `uv run --no-sync invoke repo.pr_create --title="" --content="<notes>"` + (this also assigns the PR to the calling user via `--assignee @me`) +4. Report the PR URL to the user. + +If a PR already exists for this branch, `repo.pr_create` reports its URL instead of erroring — +just relay that to the user. diff --git a/.opencode/command/pull.md b/.opencode/command/pull.md new file mode 100644 index 0000000..fac24eb --- /dev/null +++ b/.opencode/command/pull.md @@ -0,0 +1,8 @@ +--- +description: Pull updates from git remote. Stashes local changes, pulls with rebase, then restores stash. +subtask: false +agent: general +slash_command: /pull +--- + +!`uv run --no-sync invoke repo.pull` diff --git a/.opencode/command/push.md b/.opencode/command/push.md new file mode 100644 index 0000000..941fe37 --- /dev/null +++ b/.opencode/command/push.md @@ -0,0 +1,13 @@ +--- +description: Push changes to git remote. Runs invoke fix, invoke test, then commits and pushes. +subtask: false +agent: general +slash_command: /push +--- + +Run the push workflow: + +!`uv run --no-sync python -m modules.repo.push` + +If it fails at any stage (fix, test, commit, or push), show the full output to the user, explain +which stage failed, and ask how they'd like to proceed — do not retry automatically. diff --git a/.opencode/command/rebase.md b/.opencode/command/rebase.md new file mode 100644 index 0000000..933a1f0 --- /dev/null +++ b/.opencode/command/rebase.md @@ -0,0 +1,8 @@ +--- +description: Rebase current branch onto the remote default branch. Optionally runs squash first before rebasing. +subtask: false +agent: general +slash_command: /rebase +--- + +!`uv run --no-sync invoke repo.rebase` diff --git a/.opencode/command/repo.md b/.opencode/command/repo.md new file mode 100644 index 0000000..2439edd --- /dev/null +++ b/.opencode/command/repo.md @@ -0,0 +1,8 @@ +--- +description: Repo operations (push, pull) +subtask: false +agent: general +slash_command: /repo +--- + +!`uv run --no-sync python -m modules.repo.route "$ARGUMENTS"` diff --git a/.opencode/command/repos.md b/.opencode/command/repos.md new file mode 100644 index 0000000..3a8a9ae --- /dev/null +++ b/.opencode/command/repos.md @@ -0,0 +1,22 @@ +--- +description: Show this repo's related-repos map (org/repo list + template lineage) from properties.yml, or apply a change to all of them. +subtask: false +agent: general +slash_command: /repos +--- + +First, read `.github/instructions/repos.instructions.md` in full — it's the source of truth for +both what the `repos` map means and the Cross-Repo Change Workflow. + +Then read `properties.yml` at the repo root and resolve its `repos` key (the org/repo list) and +`lineage` sub-key (parent → child chain). If `properties.yml` doesn't exist yet, tell the user to +run `/setup` first. + +- If the user just wants to know what the related repos are, summarize the map and stop there. +- If the user wants a change applied to the related repos (e.g. "apply this to the related repos" / + "update the related repos with this"), follow the Cross-Repo Change Workflow in + `repos.instructions.md` against each repo in scope. + +This command doubles as a recognition trigger: whenever the user says "related repos", "the repos", +or "other repos" about this repo's family (not generic talk about "the repository"), read +`repos.instructions.md` and act on it — even if they didn't explicitly run `/repos`. diff --git a/.opencode/command/setup.md b/.opencode/command/setup.md new file mode 100644 index 0000000..8a8c88b --- /dev/null +++ b/.opencode/command/setup.md @@ -0,0 +1,8 @@ +--- +description: Run initial project setup. Creates the uv virtual environment and installs dependencies. +subtask: false +agent: general +slash_command: /setup +--- + +!`./setup.sh` diff --git a/.opencode/command/ship-it.md b/.opencode/command/ship-it.md new file mode 100644 index 0000000..598962a --- /dev/null +++ b/.opencode/command/ship-it.md @@ -0,0 +1,22 @@ +--- +description: Run tests, audit docs for drift (/docs), push the current feature branch, then draft PR notes and open a Pull Request via gh. +subtask: false +agent: general +slash_command: /ship-it +--- + +First, follow the `/test` steps to run all tests and linters, auto-fixing what can be fixed. Do not +continue until tests pass — if they don't, show the failures and ask the user how they'd like to +proceed. + +Then follow the `/docs` steps to audit the repo for doc/AI-config drift against recent changes and +fix anything stale. + +Then follow the `/push` steps (fix, test, commit, and push — this commit picks up any doc edits +from the step above). If it fails at any stage, show the full output to the user, explain which +stage failed, and ask how they'd like to proceed — do not continue to the PR steps below. + +Then follow the `/pr` steps: gather the branch/diff context via `uv run --no-sync invoke repo.pr_diff`, +write a `## Summary` and `## Changes` description, then create the PR with +`uv run --no-sync invoke repo.pr_create --title="<title>" --content="<notes>"`. Report the PR URL +to the user. diff --git a/.opencode/command/squash.md b/.opencode/command/squash.md new file mode 100644 index 0000000..02b5d8f --- /dev/null +++ b/.opencode/command/squash.md @@ -0,0 +1,8 @@ +--- +description: Anchored squash of all commits to the root commit. Prompts to review the message, confirm squash, and optionally force push. +subtask: false +agent: general +slash_command: /squash +--- + +!`uv run --no-sync invoke repo.squash` diff --git a/.opencode/command/template.md b/.opencode/command/template.md new file mode 100644 index 0000000..f27ed03 --- /dev/null +++ b/.opencode/command/template.md @@ -0,0 +1,113 @@ +--- +description: Pull shared tooling updates from the parent template repo into this project (default), or push new generic tooling from this project into the parent template repo as a PR (template push). +subtask: false +agent: general +slash_command: /template +--- + +If `$ARGUMENTS` is empty or "pull", do a **Pull**. If `$ARGUMENTS` starts with "push", do a +**Push**. If `$ARGUMENTS` is "pull diff" (or the user otherwise asks for a manual/diff review), +use Pull's fallback path (step 3) instead of its default copy. + +## Pull + +1. Resolve the template repo and check whether it's a local checkout: + + !`uv run --no-sync python -m modules.template.route "pull resolve"` + + Parse `TEMPLATE_PATH=` (the resolved local path, cloned to `tmp/template_sync/` if not found + locally) and `TEMPLATE_LOCAL=` (`true`/`false`) from the output. + +2. **Default path — `TEMPLATE_LOCAL=true` and the user didn't ask for a manual/diff review:** + using the Bash tool, run `uv run --no-sync python -m modules.template.route "pull copy"`. This + clobber-copies every git-tracked file from the template repo into this project — `git ls-files` + already skips whatever the template repo's own `.gitignore` covers (caches, build artifacts, + `.venv/`, etc.), and this project's `template.ignore.yml` `exclude:` list skips everything else + project-specific (`properties.yml`, `README.md`, business modules, personal-vault content, ...). + Nothing is hardcoded in the Python code — populate `template.ignore.yml` with whatever this + project needs protected, including its own filename so local customizations survive future pulls. + Text files get the template repo's name rewritten to this repo's name (basenames of + `template.local`/`repo.local` in `properties.yml`); binary files copy as-is. Every other synced + file is overwritten outright — no per-file diff or confirmation. + + Afterward, run `git status` and summarize what changed (added/modified) so the user can review + and correct with normal git tools — the copy only touches the working tree, so nothing is + committed or pushed and mistakes are just a `git checkout`/edit away. + +3. **Fallback — `TEMPLATE_LOCAL=false`, or the user explicitly asked for a manual/diff review:** + compare the template repo against this project by hand instead of running `pull copy`. Slower + and less complete than the copy above; prefer it only when there's no local template checkout. + + a. **Always exclude** (never touch, even if present in the template repo): whatever the + template repo's `.gitignore` covers, plus this project's `template.ignore.yml` + `exclude:` list. + b. **Shared tooling — sync these by default** if present in the template repo: `modules/`, + `tasks/`, `.github/instructions/`, `.github/prompts/`, `.github/workflows/`, + `.claude/commands/`, `.vscode/`, `invoke.yml`, `setup.sh`, `CLAUDE.md`, `.editorconfig`, + `.yamllint`. Also look at anything else at the template repo's top level not covered by the + exclude list — use judgment on whether it's generic tooling or project-specific, and ask the + user if genuinely unsure. + c. For each candidate file: + - Missing in this project → propose adding it. + - Identical to what's already here → skip silently. + - Different from what's already here → show a short diff and ask the user whether to + overwrite, keep the local version, or merge by hand. Do not overwrite silently. + d. Apply only the changes the user approved (plus unambiguous additions/identical-skips), then + summarize what was added, updated, and skipped. + +4. If `.github/prompts/` changed (either path), mirror the same changes into `.claude/commands/` + and `.clinerules/workflows/` by hand — see `.github/instructions/prompts.instructions.md` for + the required frontmatter/body per tool. There is no sync task; these dirs are hand-maintained. + +## Push + +Push proposes NEW generic improvements made in this repo into its parent template repo as a pull +request. Only genuinely generic, project-agnostic content may be proposed — nothing specific to +this fork's own business or personal use. + +**Scope** (enforced by `modules/template/scope.py`, mirrored here for visibility): +- Eligible directories: `modules/`, `.github/instructions/`, `.github/prompts/`, + `.claude/commands/`, `.claude/skills/`, `.clinerules/workflows/`. +- Candidates come from `git ls-files`, so anything this repo's own `.gitignore` covers is already + excluded — nothing hardcoded for that. +- Also excluded: anything in this project's `template.ignore.yml` `exclude:` list — the same file + `/template pull` uses to protect project-specific content, applied here in the other direction so + it never leaks upstream either. A fork with its own business modules or personal-vault content + (e.g. `modules/fireball/`, `topics/`) lists them there; the root template hardcodes none of it. + +Repo-name references are rewritten automatically on copy (this repo's name → the template repo's +name, both derived from `properties.yml` `repo.local`/`template.local` basenames), so name-only +differences never need hand-editing and never count as modifications. + +**Steps:** + +1. Using the Bash tool, run `uv run --no-sync python -m modules.template.route "push diff"`. + This clones/updates the template default branch and prints three scope-filtered lists: + - `ADDED:` — in this repo, missing from the template. + - `MODIFIED:` — differs from the template after repo-name rewriting. + - `DELETED:` — in the template but no longer in this repo. These are deprecation candidates + only; nothing is deleted without explicit approval in step 3. +2. Review all three lists and assemble the FULL change set for one complete PR: + - Pair up renames: an ADDED file that replaces a DELETED one (e.g. a renamed command) should + ship together — the add and the delete in the same PR, plus a scan of the template's docs + for now-dangling references to the deleted name (fix those template-side files in step 4's + review if needed). + - Only propose a deletion when its replacement is included in this PR or the file is clearly + obsolete. When unsure whether something is deprecated or template-specific, ask the user. + - For borderline generic-vs-business content not already covered by the fixed exclusion list, + ask the user before including it. Drop anything the user doesn't want. +3. Show the user the final change set — files to add, update, and delete — with a one-line + summary of what changed, and ask them to confirm proposing it to the template repo. +4. If approved, using the Bash tool, run (once, with one `--file` per approved copy and one + `--delete` per approved removal): + `uv run --no-sync python -m modules.template.route "push apply --file <path1> --file <path2> --delete <path3> ..."` + This resolves/updates the local template checkout, creates a new branch, copies the approved + files (with repo-name rewriting), applies the deletions, commits, and pushes the branch — it + does **not** open a PR yet. Note the `TEMPLATE_PUSH_BRANCH=` and `TEMPLATE_PUSH_BASE=` values + it prints. `--delete` refuses paths outside the push scope. +5. Show the user the pushed branch name and propose a PR title/body covering adds, updates, AND + deletions. **Explicitly ask the user to confirm before opening the PR** — do not proceed + without an explicit "yes". +6. Only after confirmation, using the Bash tool, run: + `uv run --no-sync python -m modules.template.route "push create-pr --branch <branch> --title \"...\" --body \"...\""` + This opens the PR against the template repo via `gh pr create`. diff --git a/.opencode/command/test.md b/.opencode/command/test.md new file mode 100644 index 0000000..9df4351 --- /dev/null +++ b/.opencode/command/test.md @@ -0,0 +1,24 @@ +--- +description: Run all tests and linters. Use when you want to run ruff, pylint, yamllint, actionlint, and check_agents. +subtask: false +agent: general +slash_command: /test +--- + +Auto-fix first: + +!`uv run --no-sync invoke fix` + +Then run all tests: + +!`uv run --no-sync invoke test` + +If all tests pass, report success and stop. + +If any tests fail: +- For Ruff offenses that survived the auto-fix: show the remaining failures and ask the user how they would like to proceed. +- For Pylint offenses (must score 10.00/10): show the offending lines and ask the user how they would like to proceed. +- For YAML lint failures: show the offending lines and ask the user how they would like to proceed. +- For actionlint failures: show the offending workflow file and line, and ask the user how they would like to proceed. +- For check_agents failures: show the missing or orphaned command mirror files and ask the user how they would like to proceed. +- For any other failures: show the full error output and ask the user how they would like to approach fixing it. diff --git a/.opencode/command/update.md b/.opencode/command/update.md new file mode 100644 index 0000000..bb9cd82 --- /dev/null +++ b/.opencode/command/update.md @@ -0,0 +1,42 @@ +--- +description: Check pyproject.toml dependencies, the pinned Python version, and .github/workflows/ action refs against their latest published releases and update version locks. Does not install or run anything. +subtask: false +agent: general +slash_command: /update +--- + +Check for available version updates (read-only, makes no changes): + +!`uv run --no-sync invoke ver.update --dry-run` + +The output has three sections: a `pyproject.toml` dependency table (from `ver.libs`), a Python +version table (from `ver.python`), and a `.github/workflows/` action-ref table (from +`ver.workflows`). Any section may say everything is already up to date — treat that section as +done and move on. + +## Which section(s) to act on + +- `$ARGUMENTS` is `libs` → only act on the `pyproject.toml` section; ignore the others. +- `$ARGUMENTS` is `python` → only act on the Python version section; ignore the others. +- `$ARGUMENTS` is `workflows` → only act on the workflows section; ignore the others. +- `$ARGUMENTS` is `update`, or no arguments at all → walk through all three sections (this matches + `uv run --no-sync invoke ver.update`, which is what the check above already ran). + +## Applying an update + +Show the user the relevant table exactly as printed, then ask whether to apply it. + +- `pyproject.toml`: this only rewrites version constraints — it does NOT install anything. If the + user says yes, run `uv run --no-sync invoke ver.libs --yes`, then tell them the locks were + updated and that `uv run --no-sync invoke upgrade` will install them (do not run that yourself — + installing is a separate, explicit step). +- Python version: this only rewrites config file references (`pyproject.toml`, `.python-version`, + `.pylintrc`, `setup.sh`, `.github/actions/setup_uv/action.yml`) — it does NOT install a new + Python or rebuild `.venv`. If the user says yes, run `uv run --no-sync invoke ver.python --yes`, + then tell them the references were updated and that `uv run --no-sync invoke upgrade` will + install the new version and rebuild `.venv`. +- `.github/workflows/`: this only rewrites `@ref` pins in the workflow files — it does NOT run any + workflow. If the user says yes, run `uv run --no-sync invoke ver.workflows --yes`, then tell + them the workflow files were updated and suggest reviewing the diff before committing. + +If the user declines a section, make no changes for that section. diff --git a/.opencode/command/upgrade.md b/.opencode/command/upgrade.md new file mode 100644 index 0000000..af95245 --- /dev/null +++ b/.opencode/command/upgrade.md @@ -0,0 +1,64 @@ +--- +description: Upgrade Python and/or Dependencies +subtask: false +agent: general +slash_command: /upgrade +--- + +# upgrade - Upgrade Python and/or Dependencies + +Executes upgrades to Python and/or package dependencies after you've reviewed changes via `/update`. + +## Usage + +Upgrade everything (interactive): +!`uv run --no-sync invoke upgrade` + +Upgrade only Python: +!`uv run --no-sync invoke upgrade.python` + +Upgrade only libs: +!`uv run --no-sync invoke upgrade.libs` + +Sync dependencies without checking for updates: +!`uv run --no-sync invoke upgrade.sync` + +## Description + +The upgrade command performs actual installations and syncs: +- Downloads and installs new Python versions (if updated) +- Rebuilds virtual environment (if Python changed) +- Runs `uv sync --upgrade` to install updated dependencies + +## Workflow + +Best practice: +1. Run `/update` to review and update config files +2. Check `git diff` to see what changed +3. If satisfied, run `/upgrade` to execute the upgrades +4. If not satisfied, run `git restore` and adjust + +## Examples + +```bash +# Full workflow +/update # Review and update all configs +git diff # Review changes +/upgrade # Execute upgrades + +# Python-only workflow +/update python # Review and update Python configs +git diff # Review changes +/upgrade python # Execute Python upgrade + +# Libs-only workflow +/update libs # Review and update lib versions in pyproject.toml +git diff # Review changes +/upgrade libs # Execute libs upgrade +``` + +## Exit Codes + +- 0: Success (upgrades completed or nothing needed) +- 1: Error occurred +- 2: Cancelled by user diff --git a/README.md b/README.md index 6e512c4..fa71c66 100644 --- a/README.md +++ b/README.md @@ -24,21 +24,31 @@ properties.yml # Project configuration (repo path/remote, template path/remot template.ignore.yml # Paths /template pull/push must never touch (.gitignore handles the rest via git ls-files) tests/ # One marker-named subfolder per concern (pass scope=<marker> to tests.pytest for a subset) agents/ # test_check_agents.py — verify .github/prompts/ mirrors are in sync, called by tests.check_agents + drift/ + docs/ # test_changelogs_current.py — docs/change_logs/ vs properties.yml, gate for tests.pytest setup/ # test_setup_properties.py — modules/setup/ (properties.yml bootstrap) style/ # test_markdown_style.py — Markdown/doc style checks modules/ - common/ # cli.py, properties.py, route_utils.py, utils.py — shared helpers + common/ # cli.py, properties.py, prompt_commands.py, route_utils.py, utils.py — shared helpers + docs/ # update.py, lib/change_logs.py — changelog sync (docs/change_logs/ vs properties.yml) + hermes/ # sync.py — syncs ~/.hermes/ config + SKILL.md from .github/prompts/ + ollama/ # install.py, list.py, status.py, uninstall.py, update.py, helpers.py — local-LLM management + opencode/ # sync.py — syncs .opencode/command/ from .github/prompts/ repo/ # pull.py, push.py, squash.py, rebase.py, pr_*.py — git/PR workflow modules setup/ # properties.py — creates properties.yml (no-op if it exists), called by setup.sh/setup.ps1; templates/properties/*.yml — tier fragments template/ # ignore.py, naming.py, pull.py, push.py, resolve.py, route.py, scope.py — sync shared tooling with the parent template repo for /template versioning/ # libs.py, python.py, workflows.py, upgrade.py, project.py — check pyproject.toml deps & workflow action refs vs. latest releases, bump the repo's VERSION file tasks/ - __init__.py # Wires the invoke Collection: common/ and tests/ (registered at their original top-level names), ai/ (registered at repo, template — tooling downstream repos use to operate on themselves) + __init__.py # Wires the invoke Collection: common/ and tests/ (registered at their original top-level names), ai/ (registered at docs, hermes, ollama, opencode, repo, template — tooling downstream repos use to operate on themselves or a specific AI tool), plus the ai.sync combo task ai/ + docs.py # docs.update_changelogs + hermes.py # hermes.sync + ollama.py # ollama.install/list/update/uninstall/start/stop/status/restart/clean + opencode.py # opencode.sync repo.py # repo.pull, repo.push, repo.squash, repo.rebase, repo.pr_diff, repo.pr_notes_save, repo.pr_create, repo.pr_cleanup template.py # template.pull, template.pull_copy, template.push_diff, template.push_apply, template.push_create_pr common/ - main.py # Top-level aliases: fix, test, update (was combos.py) + main.py # Top-level aliases: fix, test, update, plus ai_sync (was combos.py) debug.py # debug.env — print cwd + sorted env vars ruff.py # ruff.fix, ruff.format setup.py # setup.properties — creates/stamps properties.yml @@ -57,6 +67,8 @@ tasks/ skills/ # Claude Code skills (auto-discovered), hand-maintained mirror of .github/prompts/ .clinerules/ workflows/ # Cline slash commands, hand-maintained mirror of .github/prompts/ +.opencode/ + command/ # OpenCode slash commands, generated mirror of .github/prompts/ — regenerate via `invoke opencode.sync`, never hand-edit .vscode/ extensions.json # Recommended VS Code extensions settings.json # Ruff formatter + Python interpreter settings @@ -97,6 +109,11 @@ uv run --no-sync invoke upgrade.sync # Sync dependencies without checking for uv run --no-sync invoke uv.upgrade_bin # Upgrade the uv binary itself (brew upgrade uv) uv run --no-sync invoke uv.upgrade_libs # Install the versions currently locked in pyproject.toml (uv sync) uv run --no-sync invoke setup.properties # Create/stamp properties.yml +uv run --no-sync invoke ai.sync # Sync all AI tool commands from .github/prompts/ (hermes + opencode) +uv run --no-sync invoke hermes.sync # Sync ~/.hermes/ config + SKILL.md +uv run --no-sync invoke opencode.sync # Sync .opencode/command/ +uv run --no-sync invoke docs.update_changelogs # Prepend any missing docs/change_logs/ entries from properties.yml +uv run --no-sync invoke ollama.install # Install Ollama + a local coding LLM ``` ## AI Prompts @@ -109,7 +126,7 @@ uv run --no-sync invoke setup.properties # Create/stamp properties.yml | `/fix` | `uv run --no-sync invoke fix` | Auto-fix Python linting issues | | `/test` | `uv run --no-sync invoke fix && uv run --no-sync invoke test` | Auto-fix, then run all tests and linters | | `/setup` | `./setup.sh` | Run initial project setup | -| `/docs` | `uv run --no-sync invoke repo.pr_diff` | Audit docs and AI-config for drift against recent changes and fix anything stale | +| `/docs` | `uv run --no-sync invoke repo.pr_diff && uv run --no-sync invoke docs.update_changelogs` | Audit docs and AI-config for drift against recent changes and fix anything stale | | `/pr-notes` | `uv run --no-sync invoke repo.pr_diff` | Draft PR notes vs. base branch; saves to `tmp/pull_requests/` when run standalone | | `/pr` | `uv run --no-sync invoke repo.pr_create` | Draft PR notes and open a Pull Request via `gh` (does not push) | | `/pr-cleanup` | `uv run --no-sync invoke repo.pr_cleanup` | Switch to the default branch, pull, and delete the merged local feature branch | @@ -122,15 +139,19 @@ uv run --no-sync invoke setup.properties # Create/stamp properties.yml ## Modules | Module | Purpose | |--------|---------| -| [`modules/common/`](modules/common/README.md) | CLI helpers, `properties.yml` config reader, output/utility helpers | +| [`modules/common/`](modules/common/README.md) | CLI helpers, `properties.yml` config reader, `.github/prompts/` parser, output/utility helpers | +| [`modules/docs/`](modules/docs/README.md) | Changelog sync — keeps `docs/change_logs/<category>/<name>.md` in sync with `properties.yml` | +| [`modules/hermes/`](modules/hermes/README.md) | Syncs `~/.hermes/` config + SKILL.md from `.github/prompts/` | +| [`modules/ollama/`](modules/ollama/README.md) | Local-LLM install/list/status/uninstall/update | +| [`modules/opencode/`](modules/opencode/README.md) | Syncs `.opencode/command/` from `.github/prompts/` | | [`modules/repo/`](modules/repo/README.md) | Git workflow (pull, push, squash, rebase, PR) | | [`modules/setup/`](modules/setup/README.md) | Creates `properties.yml` (no-op if it exists) from tier fragments, called by `setup.sh`/`setup.ps1` | | [`modules/template/`](modules/template/README.md) | Sync shared, generic tooling with the parent template repo for `/template` | | [`modules/versioning/`](modules/versioning/README.md) | Check `pyproject.toml` deps and workflow action refs vs. latest releases, update locks; bump the repo's `VERSION` file for deploys/releases | `tests/agents/test_check_agents.py` (not a `modules/` package) verifies `.github/prompts/` is -mirrored into `.claude/commands/`, `.claude/skills/`, and `.clinerules/workflows/` — run via -`uv run --no-sync invoke tests.check_agents`. +mirrored into `.claude/commands/`, `.claude/skills/`, `.clinerules/workflows/`, and +`.opencode/command/` — run via `uv run --no-sync invoke tests.check_agents`. See [modules/README.md](modules/README.md) for full details. diff --git a/modules/README.md b/modules/README.md index 5cbdc0d..7ee9118 100644 --- a/modules/README.md +++ b/modules/README.md @@ -7,7 +7,11 @@ no classes required except small helper validators in `common/cli.py`. ``` modules/ - common/ # cli, properties, utils helpers + common/ # cli, properties, prompt_commands, route_utils, utils helpers + docs/ # changelog sync — docs/change_logs/ vs properties.yml + hermes/ # syncs ~/.hermes/ config + SKILL.md from .github/prompts/ + ollama/ # local-LLM install/list/status/uninstall/update + opencode/ # syncs .opencode/command/ from .github/prompts/ repo/ # pull, push, log, squash, rebase (git workflow) setup/ # creates/stamps properties.yml, called by setup.sh/setup.ps1 template/ # sync shared, generic tooling with the parent template repo for /template @@ -22,7 +26,11 @@ Repo-consistency checks (`check_agents`) live under root `tests/` as pytest test | Directory | Purpose | |-----------|---------| -| [`common/`](common/README.md) | CLI helpers, `properties.yml` config reader, output/utility helpers | +| [`common/`](common/README.md) | CLI helpers, `properties.yml` config reader, `.github/prompts/` parser, output/utility helpers | +| [`docs/`](docs/README.md) | Changelog sync — `docs/change_logs/` vs `properties.yml` | +| [`hermes/`](hermes/README.md) | Syncs `~/.hermes/` config + SKILL.md from `.github/prompts/` | +| [`ollama/`](ollama/README.md) | Local-LLM install/list/status/uninstall/update | +| [`opencode/`](opencode/README.md) | Syncs `.opencode/command/` from `.github/prompts/` | | [`repo/`](repo/README.md) | Git workflow, session logging, squash, and rebase | | [`setup/`](setup/README.md) | Creates/stamps `properties.yml`, called by `setup.sh`/`setup.ps1` | | [`template/`](template/README.md) | Sync shared, generic tooling with the parent template repo for `/template` | diff --git a/modules/common/README.md b/modules/common/README.md index d8c57c1..3a95e5b 100644 --- a/modules/common/README.md +++ b/modules/common/README.md @@ -16,6 +16,8 @@ This module provides common functionality that is used by other modules througho `confirm`, `is_tty`, `Choice`, `command`/`option` decorators) — TUI-safe, no external dependency - `route_utils.py` — shared helpers for the `/repo`- and `/template`-style AI-tool command routers: `find_repo_root()` (walks upward for `properties.yml`), `build_env()` +- `prompt_commands.py` — shared parser for `.github/prompts/*.prompt.md` into structured + `PromptCommand` objects, consumed by `modules/hermes/sync.py` and `modules/opencode/sync.py` - `README.md` — this file ## Dependencies diff --git a/modules/common/prompt_commands.py b/modules/common/prompt_commands.py new file mode 100644 index 0000000..04c9857 --- /dev/null +++ b/modules/common/prompt_commands.py @@ -0,0 +1,81 @@ +""" +Parse `.github/prompts/*.prompt.md` — the source of truth for all slash commands — into a +structured command list shared by every AI-tool sync script (hermes, opencode). +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import yaml + +from .route_utils import find_repo_root + +REPO_ROOT = find_repo_root() +PROMPTS_DIR = REPO_ROOT / ".github" / "prompts" + + +class PromptCommand: # pylint: disable=too-many-instance-attributes,too-few-public-methods + """Parsed representation of one .github/prompts/*.prompt.md file.""" + + def __init__(self, path: Path) -> None: + self.path = path + # name uses underscores (Python identifier), slug preserves hyphens (for r- prefix) + self.slug: str = path.stem.replace(".prompt", "") + self.name: str = self.slug.replace("-", "_") + self.description: str = "" + self.argument_hint: str = "" + self.exec_line: str = "" + self.body: str = "" + self._parse() + + def _parse(self) -> None: + text = self.path.read_text(encoding="utf-8") + parts = text.split("---", 2) + if len(parts) >= 3: + fm_text = parts[1] + self.body = parts[2].strip() + try: + fm: dict = yaml.safe_load(fm_text) or {} + except yaml.YAMLError: + fm = {} + self.description = str(fm.get("description", "")) + self.argument_hint = str(fm.get("argument-hint", "")) + else: + self.body = text.strip() + + # Find exec line: line starting with !` + exec_match = re.search(r"^!`([^`]+)`", self.body, re.MULTILINE) + if exec_match: + self.exec_line = exec_match.group(1).strip() + + +def load_commands(*, skip: frozenset[str] = frozenset()) -> list[PromptCommand]: + """ + Load and parse all .github/prompts/*.prompt.md files, deduplicating by name. + + Args: + skip: Command names to exclude entirely (each caller passes its own reasoning-specific set). + """ + files = sorted(PROMPTS_DIR.glob("*.prompt.md")) + seen: set[str] = set() + unique: list[PromptCommand] = [] + for path in files: + cmd = PromptCommand(path) + if cmd.name in skip: + continue + if cmd.name in seen: + # Warn only if descriptions differ — could indicate accidental divergence + existing = next(c for c in unique if c.name == cmd.name) + if existing.description != cmd.description: + print( + f" ⚠️ Duplicate '{cmd.name}' with differing descriptions " + f"({cmd.slug!r} vs {existing.slug!r}) — keeping {existing.slug!r}", + file=sys.stderr, + ) + continue + seen.add(cmd.name) + unique.append(cmd) + return unique diff --git a/modules/docs/README.md b/modules/docs/README.md new file mode 100644 index 0000000..d0223aa --- /dev/null +++ b/modules/docs/README.md @@ -0,0 +1,21 @@ +# Docs Module +Changelog sync — keeps `docs/change_logs/<category>/<name>.md` in sync with `properties.yml`. + +## Commands +```sh +uv run --no-sync invoke docs.update_changelogs +``` + +## What It Does +Each root `properties.yml` key listed in `lib/change_logs.py`'s `CHANGELOG_CATEGORIES` tuple owns +a change log per entry under `docs/change_logs/<category>/<name>.md`. `CHANGELOG_CATEGORIES` is +empty in this template — nothing here is version-tracked yet, so both the sync task and its +drift-test counterpart (`tests/drift/docs/test_changelogs_current.py`) are no-ops until a +downstream repo adds a category. See `.github/instructions/docs.instructions.md` for the full +`properties.yml` entry shape and rendered markdown format. + +## Files +- `update.py` — `change_logs()`, the entry point called by `tasks/ai/docs.py` +- `lib/change_logs.py` — the actual sync logic (`check_each_log`, used both by + `invoke docs.update_changelogs` with `update=True` and the drift test with `update=False`) +- `README.md` — this file diff --git a/modules/docs/__init__.py b/modules/docs/__init__.py new file mode 100644 index 0000000..536b293 --- /dev/null +++ b/modules/docs/__init__.py @@ -0,0 +1,3 @@ +"""Documentation automation — changelog sync from properties.yml, etc. See +`.github/instructions/docs.instructions.md`. +""" diff --git a/modules/docs/lib/__init__.py b/modules/docs/lib/__init__.py new file mode 100644 index 0000000..25b4d78 --- /dev/null +++ b/modules/docs/lib/__init__.py @@ -0,0 +1 @@ +"""Library modules backing `modules/docs/update.py`'s entry points.""" diff --git a/modules/docs/lib/change_logs.py b/modules/docs/lib/change_logs.py new file mode 100644 index 0000000..c098890 --- /dev/null +++ b/modules/docs/lib/change_logs.py @@ -0,0 +1,157 @@ +"""Keep docs/change_logs/<category>/<name>.md in sync with properties.yml's version/latest_changes +entries — see `.github/instructions/docs.instructions.md`. +""" + +import fileinput +import logging +from pathlib import Path + +from ...common.properties import get_properties, get_repo_root + +LOGGER = logging.getLogger(__name__) + +# Root properties.yml keys that own a change log under docs/change_logs/<category>/<name>.md. +# Empty here — this template has nothing versioned in properties.yml yet. A downstream repo adds +# its own category (e.g. "cloudformation", "lambda_functions") to this tuple once it has +# version-tracked entries to log; everything else in this module already supports it. +CHANGELOG_CATEGORIES: tuple[str, ...] = () + + +def changelog_entries() -> list[tuple[str, str, dict]]: + """Return every properties.yml entry that owns a change log. + + Returns: + List of (category, name, product) tuples — product is the dict holding `version` and + `latest_changes`. + """ + if not CHANGELOG_CATEGORIES: + return [] # nothing to check — skip needing a properties.yml at all + props = get_properties() + return [ + (category, name, product) + for category, products in props.items() + if category in CHANGELOG_CATEGORIES + for name, product in products.items() + ] + + +def expected_entry_text(category: str, name: str, product: dict) -> str: + """Build the change log header + bullet block a properties.yml entry requires. + + Args: + category: Root properties.yml key that owns the entry. + name: Entry name under the category. + product: Mapping holding `version` and `latest_changes` (author/date/description). + + Returns: + Text block that must sit at the top of the entry's change log. + """ + if "version" not in product: + error_message = f"version is not in ({category}.{name}.{product})" + raise ValueError(error_message) + + formatted_header = ( + f"## {product['version']} - {product['latest_changes']['date']} - {product['latest_changes']['author']}" + ) + # `description` is one string — comma-separate it for multiple bullets. + changes_list = product["latest_changes"]["description"].split(", ") + formatted_changes = "\n".join("* " + change for change in changes_list) + return formatted_header + "\n" + formatted_changes + "\n" + + +def changelog_path(category: str, name: str) -> Path: + """Return the change log path for an entry. + + Args: + category: Root properties.yml key that owns the entry. + name: Entry name under the category. + + Returns: + Path to the entry's change log markdown file. + """ + return get_repo_root() / "docs" / "change_logs" / category / f"{name}.md" + + +def check_exists(filename: str | Path, text: str) -> bool: + """Return whether `filename` already starts with `text`.""" + filepath = Path(filename) + if not filepath.exists(): + return False + with open(filepath, encoding="utf-8", newline="\n") as f: + content = f.read() + if content.startswith(text): + return True + return False + + +def prepend_text(filename: str | Path, text: str) -> None: + """Insert `text` at the very top of `filename`, creating it (and its parent dir) if missing.""" + filepath = Path(filename) + + # If the file doesn't exist or is empty, just write the text. + if not filepath.exists() or filepath.stat().st_size == 0: + filepath.parent.mkdir(parents=True, exist_ok=True) + with open(filepath, "w", encoding="utf-8", newline="\n") as f: + f.write(text) + else: + # File exists and has content — prepend the text ahead of it. + with fileinput.input(str(filepath), inplace=True) as file: + for index, line in enumerate(file): + if index == 0: + print(text) + print(line, end="") + + +def entry_current(category: str, name: str, product: dict) -> bool: + """Report whether an entry's change log already leads with its current version/latest_changes. + + Args: + category: Root properties.yml key that owns the entry. + name: Entry name under the category. + product: Mapping holding `version` and `latest_changes`. + + Returns: + True when the change log is up to date. + """ + return check_exists(filename=changelog_path(category, name), text=expected_entry_text(category, name, product)) + + +def check_each_log(update: bool = False) -> None: + """Check every properties.yml changelog entry against its docs/change_logs/ file. + + With `update=False` (used by the drift test), raises `ValueError` on the first stale entry + found. With `update=True` (used by `invoke docs.update_changelogs`), prepends any missing + entry instead and never raises. A no-op either way while CHANGELOG_CATEGORIES is empty. + """ + if update: + LOGGER.info("Checking and Updating Change Logs as Needed") + else: + LOGGER.info("Only Checking if the Change Logs are Updated (update=false)") + + current_category = None + for category, name, product in changelog_entries(): + if update and category != current_category: + print("\n################################################################") + print(f"# {category.upper()}") + print("################################################################") + current_category = category + + combined_text = expected_entry_text(category, name, product) + if update: + LOGGER.debug(combined_text) + + if entry_current(category, name, product): + if update: + print(f"Passed: ({category}/{name})") + else: + LOGGER.info("Passed: (%s/%s)", category, name) + elif update: + prepend_text(filename=changelog_path(category, name), text=combined_text) + print(f"Updated: ({category}/{name})") + else: + print( + f"\nERROR: Change Log ({category}/{name}) Needs to be Updated!\nTry running 'invoke docs.update_changelogs'\n" + ) + LOGGER.error("Failed: (%s/%s)", category, name) + error_message = f"Change log mismatch for {category}/{name}" + raise ValueError(error_message) diff --git a/modules/docs/update.py b/modules/docs/update.py new file mode 100644 index 0000000..df2f734 --- /dev/null +++ b/modules/docs/update.py @@ -0,0 +1,15 @@ +"""Docs automation entry points — called by `tasks/ai/docs.py`. See +`.github/instructions/docs.instructions.md`. +""" + +import logging + +from .lib import change_logs as lib_change_logs + +LOGGER = logging.getLogger(__name__) + + +def change_logs() -> None: + """Prepend any missing docs/change_logs/<category>/<name>.md entries from properties.yml.""" + LOGGER.info("Running Change Log Update") + lib_change_logs.check_each_log(update=True) diff --git a/modules/hermes/README.md b/modules/hermes/README.md new file mode 100644 index 0000000..4304150 --- /dev/null +++ b/modules/hermes/README.md @@ -0,0 +1,43 @@ +# Hermes Module + +Generates the `quick_commands` block in `~/.hermes/config.yaml` and a full `r-research` +`SKILL.md` from `.github/prompts/*.prompt.md`, the source of truth for all slash commands. + +## Overview + +Hermes has no native prompt-file format at all — it's driven entirely by `~/.hermes/config.yaml` +(zero-token `quick_commands`) and skill files. `sync.py` reads every prompt file via +`modules/common/prompt_commands.py`, classifies each one (`exec` / `exec_long` / `arg` / +`ai_guided`) based on its own Hermes-specific rules (e.g. which commands are too long-running for +the 30s `quick_commands` timeout), and writes both output files. + +`claude` is skipped — not useful as a Hermes `/r-*` command. + +## Usage + +```bash +uv run --no-sync python -m modules.hermes.sync +uv run --no-sync invoke hermes.sync +``` + +Never hand-edit `~/.hermes/config.yaml`'s `quick_commands` key or the generated `SKILL.md` — re-run +this sync after adding or modifying any `.github/prompts/*.prompt.md` file, then start a new Hermes +session (or `/reset`) to pick up the changes. + +## Files + +- `sync.py` — reads `.github/prompts/` via `modules/common/prompt_commands.py`, classifies + commands, and writes `~/.hermes/config.yaml` + `~/.hermes/skills/r-research/SKILL.md` +- `README.md` — this file + +## Architecture + +``` +uv run --no-sync invoke hermes.sync + ↓ +modules/hermes/sync.py + ↓ +modules/common/prompt_commands.py (shared .prompt.md parser) + ↓ +~/.hermes/config.yaml (quick_commands) + ~/.hermes/skills/r-research/SKILL.md +``` diff --git a/modules/hermes/__init__.py b/modules/hermes/__init__.py new file mode 100644 index 0000000..7895b4f --- /dev/null +++ b/modules/hermes/__init__.py @@ -0,0 +1 @@ +"""Hermes AI agent integration modules.""" diff --git a/modules/hermes/sync.py b/modules/hermes/sync.py new file mode 100644 index 0000000..ac9c214 --- /dev/null +++ b/modules/hermes/sync.py @@ -0,0 +1,375 @@ +"""Sync Hermes /r-* commands from .github/prompts/*.prompt.md source of truth. + +This script reads all .github/prompts/*.prompt.md files (the canonical source of +truth for all slash commands in this repo), classifies each command, and writes: + + 1. The quick_commands block in ~/.hermes/config.yaml (exec-style, zero-token) + 2. A complete r-research SKILL.md in ~/.hermes/skills/r-research/SKILL.md + (routing table for AI-routed and AI-guided commands) + +NEVER hand-edit those two output files — run this script instead: + uv run --no-sync invoke hermes.sync + +WARNING: This script owns the entire quick_commands key in config.yaml. +If you manually add non-r- quick_commands, they will be overwritten on the +next sync run. If mixed quick_commands are ever needed, update this script +to merge instead of replace. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import yaml + +from modules.common.prompt_commands import PROMPTS_DIR, PromptCommand +from modules.common.prompt_commands import load_commands as _load_commands +from modules.common.properties import get_screenshots_latest_file, get_screenshots_location +from modules.common.route_utils import find_repo_root + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +REPO_ROOT = find_repo_root() +HERMES_CONFIG = Path.home() / ".hermes" / "config.yaml" +HERMES_SKILL_DIR = Path.home() / ".hermes" / "skills" / "r-research" +HERMES_SKILL_FILE = HERMES_SKILL_DIR / "SKILL.md" + +# Commands that are exec-style but too long-running for the 30 s quick_commands timeout +LONG_RUNNING: frozenset[str] = frozenset({"test", "upgrade"}) + +# Commands to skip entirely — not useful as Hermes /r-* commands +SKIP_COMMANDS: frozenset[str] = frozenset({"claude"}) + +# Shell command overrides for specific prompts. +# Key = prompt name (underscored), value = full shell command (use {repo_root} placeholder). +EXEC_OVERRIDES: dict[str, str] = { + "fix": "cd {repo_root} && uv run --no-sync invoke fix", + "test": "cd {repo_root} && uv run --no-sync invoke test", + "upgrade": "cd {repo_root} && uv run --no-sync invoke upgrade.upgrade", +} + + +# --------------------------------------------------------------------------- +# Hermes-specific derivations (classification, shell command) attached onto the +# shared PromptCommand parsed by modules/common/prompt_commands.py +# --------------------------------------------------------------------------- + + +def _classify(cmd: PromptCommand) -> str: + """Classify a command for Hermes routing: exec_long, ai_guided, arg, or exec.""" + if cmd.name in LONG_RUNNING and cmd.exec_line: + return "exec_long" + if not cmd.exec_line: + return "ai_guided" + if "$ARGUMENTS" in cmd.exec_line: + return "arg" + return "exec" + + +def shell_command(cmd: PromptCommand) -> str: + """Return the shell command for quick_commands (exec classification only).""" + repo = str(REPO_ROOT) + if cmd.name in EXEC_OVERRIDES: + return EXEC_OVERRIDES[cmd.name].format(repo_root=repo) + line = cmd.exec_line + if line.startswith("uv run"): + line = f"cd {repo} && {line}" + return line + + +def load_commands() -> list[PromptCommand]: + """Load all .github/prompts/*.prompt.md commands, skipping Hermes-unusable ones. + + Attaches Hermes-specific `.r_name` and `.classification` onto each shared PromptCommand + instance — these are Hermes routing concerns, not part of the shared parser's own API. + """ + cmds = _load_commands(skip=SKIP_COMMANDS) + for cmd in cmds: + cmd.r_name = f"r-{cmd.slug}" + cmd.classification = _classify(cmd) + return cmds + + +# --------------------------------------------------------------------------- +# Generator: quick_commands dict +# --------------------------------------------------------------------------- + + +def generate_quick_commands(cmds: list[PromptCommand]) -> dict: + """Return dict for config.yaml quick_commands (exec-style, zero-token only). + + Only "exec" classified commands are wired here. Commands classified as + arg, exec_long, or ai_guided require AI reasoning and are instead routed + via the r-research SKILL.md (see generate_skill_md). + """ + result: dict = {} + for cmd in cmds: + if cmd.classification == "exec": + result[cmd.r_name] = {"type": "exec", "command": shell_command(cmd)} + return result + + +# --------------------------------------------------------------------------- +# Generator: SKILL.md +# --------------------------------------------------------------------------- + +_SKILL_HEADER_TEMPLATE = """\ +--- +name: r-research +description: "Personal research repo (/r-* commands): routing table for topic, chat,\ + fireball, financials, repo, and all other template_ai_vault slash commands.\ + AUTO-GENERATED by modules/hermes/sync.py — do not hand-edit." +version: 1.0.0 +author: Levon Becker +license: MIT +platforms: [macos] +metadata: + hermes: + tags: [personal, research, template_ai_vault, routing, slash-commands] +--- + +# Personal Research Repo — `/r-*` Command System + +> **AUTO-GENERATED** by `modules/hermes/sync.py`. +> Re-run `uv run --no-sync invoke hermes.sync` after adding or modifying +> any `.github/prompts/*.prompt.md` file. Never hand-edit this file. + +This skill is the routing table for Levon's personal research repo: +`{repo_root}` + +The repo covers ALL of Levon's personal AI-assisted research: +Fireball Enterprise, home repair, property assets, app learning, +troubleshooting, personal finance — the whole world, not just business. + +## Critical Conventions + +- **ALWAYS `--no-sync`:** `uv run --no-sync python -m ...` — never omit +- **Repo root:** `{repo_root}` +- **Architecture:** Prompt files → `modules/*/route.py` router → `modules/*/*.py` logic +- **Tests:** `uv run --no-sync invoke test` must pass 10/10 before committing `.py`/`.yml` + +""" + + +def _build_exec_section(cmds: list[PromptCommand]) -> str: + """Build the zero-token quick commands table section.""" + exec_cmds = [c for c in cmds if c.classification == "exec"] + if not exec_cmds: + return "" + rows = "\n".join(f"| `/{c.r_name}` | {c.description} |" for c in exec_cmds) + return ( + "## Zero-Token Quick Commands (wired in `~/.hermes/config.yaml`)\n\n" + "These run via `quick_commands` — no LLM call, instant execution:\n\n" + "| Command | Description |\n" + "|---|---|\n" + f"{rows}\n\n" + ) + + +def _build_exec_long_section(cmds: list[PromptCommand]) -> str: + """Build the long-running exec section.""" + long_cmds = [c for c in cmds if c.classification == "exec_long"] + if not long_cmds: + return "" + rows = "\n".join(f"| `/{c.r_name}` | `{shell_command(c)}` |" for c in long_cmds) + return ( + "## Long-Running Exec Commands (use terminal tool)\n\n" + "These are exec-style but exceed the 30 s `quick_commands` timeout.\n" + "Ask Hermes to run them via `terminal(background=True, notify_on_complete=True)`:\n\n" + "| Command | Shell command |\n" + "|---|---|\n" + f"{rows}\n\n" + ) + + +def _body_extra(cmd: PromptCommand) -> str: + """Return body text after the !` exec line (AI guidance notes).""" + lines = cmd.body.split("\n") + past_exec = False + after: list[str] = [] + for line in lines: + if line.startswith("!`") and not past_exec: + past_exec = True + continue + if past_exec: + after.append(line) + return "\n".join(after).strip() + + +def _build_arg_section(cmds: list[PromptCommand], repo_root: str) -> str: + """Build the AI-routed commands section (one subsection per command).""" + arg_cmds = [c for c in cmds if c.classification == "arg"] + if not arg_cmds: + return "" + parts = [ + "## AI-Routed Commands (Hermes executes via terminal tool)\n\n" + "For these, run the appropriate `uv run --no-sync` command based on the arguments.\n\n" + "> **Note:** These are wired as `type: alias` quick_commands pointing to `/r-research`.\n" + "> When invoked as `/r-<cmd> <args>`, Hermes sees `r-<cmd> <args>` as the instruction.\n" + '> Strip the leading `r-` prefix when routing (e.g. `r-chat list` → `chat route "list"`).\n\n' + ] + for cmd in arg_cmds: + hint_line = f"**Args:** `{cmd.argument_hint}`\n\n" if cmd.argument_hint else "" + exec_display = cmd.exec_line.replace('"$ARGUMENTS"', '"<args>"') + extra = _body_extra(cmd) + extra_block = f"\n{extra}\n" if extra else "" + parts.append( + f"### `/{cmd.r_name}` — {cmd.description}\n\n" + f"{hint_line}" + f"```bash\ncd {repo_root}\n{exec_display}\n```\n" + f"{extra_block}\n" + ) + return "".join(parts) + + +def _build_ai_guided_section(cmds: list[PromptCommand]) -> str: + """Build the AI-guided workflows section.""" + ai_cmds = [c for c in cmds if c.classification == "ai_guided"] + if not ai_cmds: + return "" + parts = [ + "## AI-Guided Workflows (no direct exec — full AI reasoning required)\n\n" + "These commands have NO direct shell exec. Hermes drives the full workflow\n" + "using its tools (`read_file`, `write_file`, `terminal`, etc.) " + "per the guidance below.\n\n" + ] + for cmd in ai_cmds: + parts.append(f"### `/{cmd.r_name}` — {cmd.description}\n\n{cmd.body}\n\n") + return "".join(parts) + + +def _build_ss_note() -> str: + """Special note for the /r-ss two-step vision workflow.""" + latest_screenshot = get_screenshots_location() / get_screenshots_latest_file() + return ( + "## `/r-ss` — Screenshot Workflow (Two Steps)\n\n" + "`/r-ss` is a `quick_command` that copies the latest screenshot to\n" + "`screenshots/latest.png` and prints the path.\n\n" + "After it runs, Hermes MUST also display the image:\n\n" + "```python\n" + "vision_analyze(\n" + f' image_url="{latest_screenshot}",\n' + ' question="What does this screenshot show?"\n' + ")\n" + "```\n\n" + "Full flow:\n" + "1. `/r-ss` quick_command runs → copies screenshot, prints confirmation\n" + "2. Hermes calls `vision_analyze` on `screenshots/latest.png`\n" + "3. Hermes describes what it sees and asks how to help\n\n" + ) + + +def _build_footer() -> str: + return ( + "---\n\n" + "*Generated from `.github/prompts/*.prompt.md` — source of truth is the prompt files.*\n" + "*Re-generate: `uv run --no-sync invoke hermes.sync`*\n" + ) + + +def generate_skill_md(cmds: list[PromptCommand]) -> str: + """Return the full SKILL.md content string.""" + repo = str(REPO_ROOT) + return "".join( + [ + _SKILL_HEADER_TEMPLATE.format(repo_root=repo), + _build_ss_note(), + _build_exec_section(cmds), + _build_exec_long_section(cmds), + _build_arg_section(cmds, repo), + _build_ai_guided_section(cmds), + _build_footer(), + ] + ) + + +# --------------------------------------------------------------------------- +# Writers +# --------------------------------------------------------------------------- + + +def write_quick_commands(qc: dict) -> None: + """Patch the quick_commands key in ~/.hermes/config.yaml.""" + if not HERMES_CONFIG.exists(): + print(f"WARNING: {HERMES_CONFIG} not found — skipping config patch", file=sys.stderr) + return + + text = HERMES_CONFIG.read_text(encoding="utf-8") + + # Render new block (strip trailing newline — we manage spacing ourselves) + qc_yaml = yaml.dump({"quick_commands": qc}, default_flow_style=False, sort_keys=True).rstrip() + + # Replace existing quick_commands block (handles both `{}` and multi-line indented form) + new_lines: list[str] = [] + in_qc_block = False + inserted = False + + for line in text.splitlines(keepends=True): + if line.startswith("quick_commands:"): + new_lines.append(qc_yaml + "\n") + in_qc_block = True + inserted = True + continue + if in_qc_block: + # Skip lines that belong to the old block (indented or blank) + if line.startswith(" ") or line.rstrip() == "": + continue + in_qc_block = False + new_lines.append(line) + else: + new_lines.append(line) + + if not inserted: + new_lines.append("\n" + qc_yaml + "\n") + + HERMES_CONFIG.write_text("".join(new_lines), encoding="utf-8") + print(f"✅ quick_commands written to {HERMES_CONFIG}") + + +def write_skill(skill_md: str) -> None: + """Write SKILL.md to ~/.hermes/skills/r-research/SKILL.md.""" + HERMES_SKILL_DIR.mkdir(parents=True, exist_ok=True) + HERMES_SKILL_FILE.write_text(skill_md, encoding="utf-8") + print(f"✅ SKILL.md written to {HERMES_SKILL_FILE}") + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + """Read prompts, classify, write quick_commands + SKILL.md.""" + if not PROMPTS_DIR.exists(): + print(f"ERROR: prompts dir not found: {PROMPTS_DIR}", file=sys.stderr) + sys.exit(1) + + cmds = load_commands() + + print(f"📖 Found {len(cmds)} commands in {PROMPTS_DIR.relative_to(REPO_ROOT)}") + for cmd in cmds: + print(f" [{cmd.classification:10s}] /{cmd.r_name:25s} — {cmd.description}") + + print() + qc = generate_quick_commands(cmds) + skill_md = generate_skill_md(cmds) + + write_quick_commands(qc) + write_skill(skill_md) + + print() + print("✅ Hermes sync complete.") + print(f" quick_commands : {len(qc)} entries") + print(f" SKILL.md : {len(skill_md):,} chars") + print() + print("Next steps:") + print(" 1. Start a new Hermes session (or /reset) to pick up quick_commands") + print(" 2. Type /r-research to confirm skill is loaded") + print(" 3. Type /r-pull to test a quick_command") + + +if __name__ == "__main__": + main() diff --git a/modules/ollama/README.md b/modules/ollama/README.md new file mode 100644 index 0000000..ad2369e --- /dev/null +++ b/modules/ollama/README.md @@ -0,0 +1,44 @@ +# Ollama Module + +Local LLM setup and maintenance on Apple Silicon — invoke-only, no slash command. + +## Overview + +Unlike most modules, Ollama has no `route.py` and no slash command: it's used exclusively via +`invoke ollama.<task>` (see `tasks/ollama.py`), since local-model management is a machine-setup +concern rather than something an AI agent drives interactively. + +## Usage + +```bash +uv run --no-sync invoke ollama.install # install Ollama + a local coding LLM +uv run --no-sync invoke ollama.list # list installed and available models +uv run --no-sync invoke ollama.status # show service + running-model status +uv run --no-sync invoke ollama.update # update binary + models + cleanup orphaned blobs +uv run --no-sync invoke ollama.uninstall # uninstall Ollama + remove all models +uv run --no-sync invoke ollama.clean # remove all downloaded models and blob cache +uv run --no-sync invoke ollama.start # start the Ollama service via Homebrew +uv run --no-sync invoke ollama.stop # stop the Ollama service via Homebrew +uv run --no-sync invoke ollama.restart # restart the Ollama service via Homebrew +``` + +## Files + +- `install.py` — install Ollama + a local coding LLM +- `list.py` — list installed and available models +- `status.py` — show service + running-model status +- `uninstall.py` — uninstall Ollama + remove all models +- `update.py` — update binary + models + cleanup orphaned blobs +- `clean.py` — remove all downloaded models and blob cache +- `helpers.py` — shared helpers (`pull_model` via the Ollama REST API, terminal input helpers) +- `README.md` — this file + +## Architecture + +``` +uv run --no-sync invoke ollama.<task> + ↓ +tasks/ollama.py + ↓ +modules/ollama/<task>.py +``` diff --git a/modules/ollama/__init__.py b/modules/ollama/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/ollama/clean.py b/modules/ollama/clean.py new file mode 100644 index 0000000..1514d7d --- /dev/null +++ b/modules/ollama/clean.py @@ -0,0 +1,38 @@ +""" +Remove all downloaded Ollama models and blob cache (~/.ollama/models/). + +Usage: + uv run --no-sync python -m modules.ollama.clean + uv run --no-sync python -m modules.ollama.clean --yes +""" + +import shutil +from pathlib import Path + +from ..common import cli +from ..common.utils import success + +MODELS_DIR = Path.home() / ".ollama" / "models" + + +@cli.command() +@cli.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +def main(yes: bool = False) -> None: + """Remove all downloaded Ollama models and blob cache.""" + if not MODELS_DIR.exists(): + cli.echo("Nothing to clean — ~/.ollama/models/ does not exist.") + return + + size_gb = sum(f.stat().st_size for f in MODELS_DIR.rglob("*") if f.is_file()) / (1024**3) + cli.echo(f"\nThis will delete ~/.ollama/models/ ({size_gb:.1f}GB)") + + if not yes and not cli.confirm("Are you sure?"): + cli.echo("Aborted.") + return + + shutil.rmtree(MODELS_DIR) + success(f"Removed ~/.ollama/models/ ({size_gb:.1f}GB freed)") + + +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter diff --git a/modules/ollama/helpers.py b/modules/ollama/helpers.py new file mode 100644 index 0000000..f054b37 --- /dev/null +++ b/modules/ollama/helpers.py @@ -0,0 +1,140 @@ +"""Shared helpers for Ollama module operations.""" + +import json +import sys +import termios +import time +import tty +import urllib.error +import urllib.request + +from ..common.utils import error, success + +_OLLAMA_API = "http://localhost:11434" + + +def dbg(message: str, debug: bool) -> None: + """Print a debug line immediately to stderr.""" + if debug: + sys.stderr.write(f"[debug] {message}\n") + sys.stderr.flush() + + +def read_char_choice(max_choice: int, debug: bool = False) -> int: + """Prompt for a menu choice and return the integer selection. + + Uses single-keypress input when stdin is a TTY; falls back to regular + line input (with Enter) when running through invoke or a piped context. + """ + fd = sys.stdin.fileno() + dbg(f"read_char_choice: fd={fd} max_choice={max_choice}", debug) + dbg(f"read_char_choice: stdin.isatty()={sys.stdin.isatty()}", debug) + + try: + old = termios.tcgetattr(fd) + dbg("read_char_choice: termios.tcgetattr succeeded — real TTY path", debug) + except termios.error as exc: + dbg(f"read_char_choice: termios.error={exc!r} — fallback line-input path", debug) + # Not a real TTY — use regular line input + while True: + sys.stdout.write(f"Enter choice [1-{max_choice}]: ") + sys.stdout.flush() + dbg("read_char_choice: waiting on sys.stdin.readline()...", debug) + raw = sys.stdin.readline().strip() + dbg(f"read_char_choice: readline() returned {raw!r}", debug) + if raw.isdigit() and 1 <= int(raw) <= max_choice: + dbg(f"read_char_choice: valid choice {int(raw)}", debug) + return int(raw) + sys.stdout.write(f"Invalid choice. Enter a number between 1 and {max_choice}.\n") + sys.stdout.flush() + return -1 # unreachable, satisfies type checker + + # Real TTY — single keypress, no Enter needed + sys.stdout.write(f"Press a key [1-{max_choice}]: ") + sys.stdout.flush() + dbg("read_char_choice: entering tty.setraw loop", debug) + try: + tty.setraw(fd) + while True: + ch = sys.stdin.read(1) + dbg(f"read_char_choice: got char {ch!r} (ord={ord(ch) if ch else 'N/A'})", debug) + if ch == "\x03": + raise KeyboardInterrupt + if ch.isdigit() and 1 <= int(ch) <= max_choice: + sys.stdout.write(ch + "\n") + sys.stdout.flush() + dbg(f"read_char_choice: returning {int(ch)}", debug) + return int(ch) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + dbg("read_char_choice: terminal settings restored", debug) + + +def _pull_progress_line(status: str, total: int, completed: int, elapsed_secs: int) -> str: + """Format a single progress line for pull_model output.""" + mins, secs = divmod(elapsed_secs, 60) + prefix = f" ⏳ {mins}m{secs:02d}s — {status}" + if total and completed: + pct = completed / total * 100 + gb_done = completed / (1024**3) + gb_total = total / (1024**3) + return f"{prefix}: {gb_done:.1f}/{gb_total:.1f}GB ({pct:.0f}%)" + return prefix + + +def pull_model(model: str, debug: bool = False) -> None: + """Pull a model via the Ollama REST API, streaming real-time progress.""" + dbg(f"pull_model: start model={model!r}", debug) + + sys.stdout.write(f"\n📥 Pulling {model} — this may take several minutes...\n") + sys.stdout.flush() + + req = urllib.request.Request( + f"{_OLLAMA_API}/api/pull", + data=json.dumps({"model": model, "stream": True}).encode(), + method="POST", + headers={"Content-Type": "application/json"}, + ) + + start = time.time() + last_status = "" + last_print_time = start + + try: + with urllib.request.urlopen(req, timeout=None) as resp: # no socket timeout — downloads can be slow + dbg("pull_model: connected to Ollama API, streaming events...", debug) + for raw_line in resp: + line = raw_line.decode().strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + dbg(f"pull_model: unparseable line: {line!r}", debug) + continue + + dbg(f"pull_model: event={event}", debug) + + if event.get("error"): + error(f"Ollama error: {event['error']}") + + status = event.get("status", "") + now = time.time() + should_print = status != last_status or (event.get("total") and now - last_print_time >= 5) + + if should_print: + line_out = _pull_progress_line( + status, event.get("total", 0), event.get("completed", 0), int(now - start) + ) + sys.stdout.write(line_out + "\n") + sys.stdout.flush() + last_status = status + last_print_time = now + + except urllib.error.URLError as exc: + error(f"Cannot reach Ollama at {_OLLAMA_API} — is the service running? ({exc})") + + elapsed = int(time.time() - start) + mins, secs = divmod(elapsed, 60) + success(f"{model} ready — took {mins}m{secs:02d}s") + dbg("pull_model: done", debug) diff --git a/modules/ollama/install.py b/modules/ollama/install.py new file mode 100644 index 0000000..d1a2db0 --- /dev/null +++ b/modules/ollama/install.py @@ -0,0 +1,252 @@ +""" +Install Ollama and a local coding LLM on Apple Silicon. + +Usage: + uv run --no-sync python -u -m modules.ollama.install --model qwen3:14b + uv run --no-sync python -u -m modules.ollama.install --model qwen2.5-coder:32b --debug +""" + +import platform +import shutil +import subprocess +import sys +import tempfile +import time + +from ..common import cli +from ..common.utils import error, success +from .helpers import pull_model, read_char_choice + +MODELS: list[tuple[str, str, str, str]] = [ + ("qwen3:14b", "~9GB", "16GB+", "recommended — works best with Cline"), + ("qwen3:32b", "~20GB", "32GB+", "bigger qwen3 — same family, higher quality"), + ("qwen2.5-coder:7b", "~5GB", "8GB+", "fastest, lightweight"), + ("qwen2.5-coder:14b", "~9GB", "16GB+", "good balance"), + ("qwen2.5-coder:32b", "~20GB", "32GB+", "recommended for 32GB Mac"), + ("qwen2.5-coder:72b", "~45GB", "64GB+", "best quality for 64GB Mac"), +] + +OLLAMA_URL = "http://localhost:11434" + +# Ollama defaults num_ctx to ~2K-4K tokens, which an agentic tool like Cline blows +# past within a few tool calls (it then silently truncates/loops instead of erroring). +# We create a larger-context variant tag for agentic use alongside the base model. +# 16K keeps responses fast while still fitting the repo's .clinerules workflow content. +AGENT_NUM_CTX = 16000 +AGENT_TAG_SUFFIX = "-16k" + + +def dbg(message: str, debug: bool) -> None: + """Print a debug line immediately to stderr.""" + if debug: + sys.stderr.write(f"[debug] {message}\n") + sys.stderr.flush() + + +def check_apple_silicon(debug: bool) -> None: + """Verify the script is running on Apple Silicon and report total RAM.""" + dbg(">>> check_apple_silicon: start", debug) + dbg(f" platform.system() = {platform.system()!r}", debug) + dbg(f" platform.machine() = {platform.machine()!r}", debug) + if platform.system() != "Darwin" or platform.machine() != "arm64": + error(f"This script requires Apple Silicon (arm64). Detected: {platform.machine()}") + success("Apple Silicon confirmed") + + dbg(" running sysctl hw.memsize", debug) + result = subprocess.run( + ["sysctl", "-n", "hw.memsize"], + capture_output=True, + text=True, + check=False, + ) + dbg(f" sysctl returncode = {result.returncode}", debug) + dbg(f" sysctl stdout = {result.stdout.strip()!r}", debug) + if result.returncode == 0: + ram_gb = int(result.stdout.strip()) // (1024**3) + cli.echo(f" RAM: {ram_gb}GB") + dbg(f" ram_gb = {ram_gb}", debug) + dbg(">>> check_apple_silicon: done", debug) + + +def check_homebrew(debug: bool) -> None: + """Verify Homebrew is installed.""" + dbg(">>> check_homebrew: start", debug) + brew_path = shutil.which("brew") + dbg(f" shutil.which('brew') = {brew_path!r}", debug) + if not brew_path: + error("Homebrew not found. Install it first: https://brew.sh") + success("Homebrew found") + dbg(">>> check_homebrew: done", debug) + + +def install_ollama(debug: bool) -> None: + """Install Ollama via Homebrew if not already present.""" + dbg(">>> install_ollama: start", debug) + ollama_path = shutil.which("ollama") + dbg(f" shutil.which('ollama') = {ollama_path!r}", debug) + if ollama_path: + result = subprocess.run(["ollama", "--version"], capture_output=True, text=True, check=False) + dbg(f" ollama --version returncode = {result.returncode}", debug) + dbg(f" ollama --version stdout = {result.stdout.strip()!r}", debug) + version = result.stdout.strip() if result.returncode == 0 else "version unknown" + success(f"Ollama already installed ({version})") + dbg(">>> install_ollama: already installed, done", debug) + return + + cli.echo("📦 Installing Ollama via Homebrew...") + result = subprocess.run(["brew", "install", "ollama"], check=False) + dbg(f" brew install returncode = {result.returncode}", debug) + if result.returncode != 0: + error("Failed to install Ollama via Homebrew") + success("Ollama installed") + dbg(">>> install_ollama: done", debug) + + +def start_ollama_service(debug: bool) -> None: + """Start the Ollama service and wait until it responds.""" + dbg(">>> start_ollama_service: start", debug) + cli.echo("🚀 Starting Ollama service...") + result = subprocess.run(["brew", "services", "start", "ollama"], check=False) + dbg(f" brew services start returncode = {result.returncode}", debug) + + cli.echo(" Waiting for Ollama to be ready...") + for i in range(15): + dbg(f" probe {i + 1}/15: curling {OLLAMA_URL}", debug) + result = subprocess.run( + ["curl", "-s", OLLAMA_URL], + capture_output=True, + check=False, + ) + dbg(f" probe {i + 1}/15: returncode = {result.returncode}", debug) + if result.returncode == 0: + success(f"Ollama is running at {OLLAMA_URL}") + dbg(">>> start_ollama_service: done (service up)", debug) + return + time.sleep(1) + + error("Ollama did not start in time. Run 'ollama serve' manually and retry.") + + +def create_agent_variant(model: str, debug: bool) -> str: + """Create a larger-context tag of `model` for agentic tool use (e.g. Cline). Returns the new tag.""" + agent_tag = f"{model}{AGENT_TAG_SUFFIX}" + dbg(f">>> create_agent_variant: model={model!r} agent_tag={agent_tag!r}", debug) + + with tempfile.NamedTemporaryFile("w", suffix=".modelfile", delete=False) as fh: + fh.write(f"FROM {model}\nPARAMETER num_ctx {AGENT_NUM_CTX}\n") + modelfile_path = fh.name + dbg(f" wrote Modelfile to {modelfile_path}", debug) + + result = subprocess.run( + ["ollama", "create", agent_tag, "-f", modelfile_path], + capture_output=True, + text=True, + check=False, + ) + dbg(f" ollama create returncode = {result.returncode}", debug) + dbg(f" ollama create stdout = {result.stdout.strip()!r}", debug) + dbg(f" ollama create stderr = {result.stderr.strip()!r}", debug) + + if result.returncode != 0: + cli.echo(f" ⚠️ Could not create {agent_tag} (num_ctx={AGENT_NUM_CTX}): {result.stderr.strip()}") + return model + + success(f"{agent_tag} ready (num_ctx={AGENT_NUM_CTX}, for use in Cline)") + dbg(">>> create_agent_variant: done", debug) + return agent_tag + + +def select_model_interactive(debug: bool) -> list[str]: + """Display an interactive numbered menu and return the selected model(s).""" + dbg(">>> select_model_interactive: start", debug) + cli.echo("\nSelect a model to install:\n") + for i, (name, size, ram, desc) in enumerate(MODELS, 1): + cli.echo(f" {i}) {name:<26} {size:<7} | {ram:<7} RAM | {desc}") + extra = len(MODELS) + 1 + cli.echo(f" {extra}) 32b + 72b ~65GB | 64GB+ RAM | full setup") + cli.echo("") + + dbg(f" max_choice = {extra}", debug) + dbg(f" stdin.isatty() = {sys.stdin.isatty()}", debug) + dbg(" calling read_char_choice...", debug) + choice = read_char_choice(extra) + dbg(f" read_char_choice returned: {choice}", debug) + + if choice == extra: + models = ["qwen2.5-coder:32b", "qwen2.5-coder:72b"] + else: + models = [MODELS[choice - 1][0]] + dbg(f" resolved models: {models}", debug) + dbg(">>> select_model_interactive: done", debug) + return models + + +@cli.command() +@cli.option( + "--model", + default=None, + help="Model to install (e.g. qwen2.5-coder:32b). Omit for interactive menu.", +) +@cli.option("--debug", is_flag=True, help="Print verbose step-by-step debug output") +def main(model: str | None = None, debug: bool = False) -> None: + """Install Ollama and a local coding LLM on Apple Silicon.""" + dbg(">>> main: entered", debug) + dbg(f" model arg = {model!r}", debug) + dbg(f" debug arg = {debug!r}", debug) + dbg(f" sys.stdout.isatty() = {sys.stdout.isatty()}", debug) + dbg(f" sys.stdin.isatty() = {sys.stdin.isatty()}", debug) + dbg(f" sys.version = {sys.version}", debug) + + cli.echo("\n=== Local Coding LLM Setup — Apple Silicon ===\n") + + dbg(">>> calling check_apple_silicon", debug) + check_apple_silicon(debug) + + dbg(">>> calling check_homebrew", debug) + check_homebrew(debug) + + dbg(">>> calling install_ollama", debug) + install_ollama(debug) + + dbg(">>> calling start_ollama_service", debug) + start_ollama_service(debug) + + dbg(">>> resolving model list", debug) + if model: + dbg(f" using --model flag: {model!r}", debug) + models = [model] + else: + dbg(" no --model flag, using interactive menu", debug) + models = select_model_interactive(debug) + + dbg(f">>> models to pull: {models}", debug) + agent_tags: list[str] = [] + for m in models: + dbg(f">>> calling pull_model({m!r})", debug) + pull_model(m, debug) + dbg(f">>> pull_model({m!r}) returned", debug) + dbg(f">>> calling create_agent_variant({m!r})", debug) + agent_tags.append(create_agent_variant(m, debug)) + dbg(">>> create_agent_variant returned", debug) + + dbg(">>> calling success", debug) + success("Setup complete!") + + cli.echo("\n" + "━" * 54) + cli.echo("Cline setup") + cli.echo("━" * 54) + cli.echo(" In Cline's model picker, select the Ollama provider and pick:") + for tag in agent_tags: + cli.echo(f" {tag}") + cli.echo(f" (the {AGENT_TAG_SUFFIX} tags have a larger context window — required for") + cli.echo(" Cline's agent loop; the plain base tags will silently fail") + cli.echo(" or loop past a few tool calls with Ollama's small default context)") + cli.echo(" In Cline's Ollama provider settings, set Request Timeout to 120000 ms") + cli.echo(" (local inference is slower than a hosted API and needs the headroom).") + cli.echo(" Custom slash commands are available via .clinerules/workflows/") + cli.echo(" (hand-maintained mirror of .github/prompts/). Try /push to test.\n") + dbg(">>> main: done", debug) + + +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter diff --git a/modules/ollama/list.py b/modules/ollama/list.py new file mode 100644 index 0000000..6a4104b --- /dev/null +++ b/modules/ollama/list.py @@ -0,0 +1,54 @@ +""" +List installed and available Ollama models. + +Usage: + uv run --no-sync python -m modules.ollama.list +""" + +import shutil +import subprocess + +from ..common import cli +from ..common.utils import error +from .install import MODELS + + +def get_installed() -> list[tuple[str, str]]: + """Return (name, size) for each locally installed Ollama model.""" + if not shutil.which("ollama"): + error("Ollama not found. Run 'invoke ollama.install' first.") + result = subprocess.run(["ollama", "list"], capture_output=True, text=True, check=False) + if result.returncode != 0: + error("Failed to list installed models") + + installed: list[tuple[str, str]] = [] + for line in result.stdout.strip().splitlines()[1:]: + parts = line.split() + if len(parts) >= 4: + installed.append((parts[0], f"{parts[2]} {parts[3]}")) + return installed + + +@cli.command() +def main() -> None: + """List installed and available Ollama models.""" + cli.echo("\n=== Installed Ollama Models ===\n") + installed = get_installed() + if not installed: + cli.echo(" (none installed)") + else: + for name, size in installed: + cli.echo(f" ✅ {name:<26} {size}") + + installed_names = {name for name, _ in installed} + + cli.echo("\n=== Available to Install ===\n") + for name, size, ram, desc in MODELS: + status = "✅ installed" if name in installed_names else f"{size:<7} | {ram:<7} RAM" + cli.echo(f" {name:<26} {status:<20} | {desc}") + + cli.echo("\n💡 Install one with: uv run --no-sync invoke ollama.install --model <name>\n") + + +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter diff --git a/modules/ollama/status.py b/modules/ollama/status.py new file mode 100644 index 0000000..a0492bd --- /dev/null +++ b/modules/ollama/status.py @@ -0,0 +1,70 @@ +""" +Show Ollama service and running-model status. + +Usage: + uv run --no-sync python -m modules.ollama.status +""" + +import shutil +import subprocess +import urllib.error +import urllib.request + +from ..common import cli +from ..common.utils import success, warning + +OLLAMA_URL = "http://localhost:11434" + + +def check_binary() -> str | None: + """Return the installed Ollama version string, or None if not installed.""" + if not shutil.which("ollama"): + return None + result = subprocess.run(["ollama", "--version"], capture_output=True, text=True, check=False) + return result.stdout.strip() if result.returncode == 0 else "version unknown" + + +def check_service() -> bool: + """Return True if the Ollama service is responding.""" + try: + with urllib.request.urlopen(OLLAMA_URL, timeout=3): + return True + except urllib.error.URLError: + return False + + +def print_loaded_models() -> None: + """Print currently loaded (in-memory) models via `ollama ps`.""" + result = subprocess.run(["ollama", "ps"], capture_output=True, text=True, check=False) + lines = result.stdout.strip().splitlines() + if len(lines) <= 1: + cli.echo(" (no models currently loaded in memory)") + return + for line in lines: + cli.echo(f" {line}") + + +@cli.command() +def main() -> None: + """Show Ollama service and running-model status.""" + cli.echo("\n=== Ollama Service Status ===\n") + + version = check_binary() + if version is None: + warning("Ollama not installed. Run 'inv ollama.install' first.") + return + success(f"Ollama installed ({version})") + + if check_service(): + success(f"Service running at {OLLAMA_URL}") + else: + warning(f"Service not responding at {OLLAMA_URL}. Run 'inv ollama.start'.") + return + + cli.echo("\n=== Loaded Models (in memory) ===\n") + print_loaded_models() + cli.echo("") + + +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter diff --git a/modules/ollama/uninstall.py b/modules/ollama/uninstall.py new file mode 100644 index 0000000..51a6adf --- /dev/null +++ b/modules/ollama/uninstall.py @@ -0,0 +1,91 @@ +""" +Uninstall Ollama — stops the service, removes models, and uninstalls the binary. + +Usage: + uv run --no-sync python -m modules.ollama.uninstall + uv run --no-sync python -m modules.ollama.uninstall --yes + uv run --no-sync python -m modules.ollama.uninstall --keep-models +""" + +import shutil +import subprocess +from pathlib import Path + +from ..common import cli +from ..common.utils import error, success, warning + +OLLAMA_DIR = Path.home() / ".ollama" + + +def stop_service() -> None: + """Stop the Ollama brew service if running.""" + if not shutil.which("brew"): + return + cli.echo("🛑 Stopping Ollama service...") + subprocess.run(["brew", "services", "stop", "ollama"], check=False) + success("Ollama service stopped") + + +def remove_models_dir() -> None: + """Delete ~/.ollama and all downloaded models and blobs.""" + if not OLLAMA_DIR.exists(): + cli.echo("No ~/.ollama directory found — nothing to remove.") + return + + size_bytes = sum(f.stat().st_size for f in OLLAMA_DIR.rglob("*") if f.is_file()) + size_gb = size_bytes / (1024**3) + cli.echo(f"🗑️ Removing ~/.ollama ({size_gb:.2f} GB)...") + shutil.rmtree(OLLAMA_DIR) + success(f"Removed ~/.ollama — freed {size_gb:.2f} GB") + + +def uninstall_binary() -> None: + """Uninstall the Ollama binary via Homebrew.""" + if not shutil.which("brew"): + error("Homebrew not found.") + if not shutil.which("ollama"): + cli.echo("Ollama binary not found — already uninstalled.") + return + cli.echo("📦 Uninstalling Ollama via Homebrew...") + result = subprocess.run(["brew", "uninstall", "ollama"], check=False) + if result.returncode != 0: + warning("Homebrew uninstall had issues") + else: + success("Ollama binary removed") + + +@cli.command() +@cli.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") +@cli.option("--keep-models", is_flag=True, help="Remove binary only, keep downloaded models") +def main(yes: bool = False, keep_models: bool = False) -> None: + """Uninstall Ollama — stops the service, removes models, and uninstalls the binary.""" + cli.echo("\n=== Ollama Uninstall ===\n") + + if keep_models: + cli.echo("Will remove: Ollama binary") + cli.echo("Will keep: Downloaded models (~/.ollama)\n") + else: + size_bytes = sum(f.stat().st_size for f in OLLAMA_DIR.rglob("*") if f.is_file()) if OLLAMA_DIR.exists() else 0 + size_gb = size_bytes / (1024**3) + cli.echo("Will remove: Ollama binary + all downloaded models") + cli.echo(f" ~/.ollama ({size_gb:.2f} GB will be freed)\n") + + if not yes and not cli.confirm("⚠️ Proceed with uninstall?"): + cli.echo("Cancelled.") + raise SystemExit(0) + + cli.echo("") + stop_service() + + if not keep_models: + remove_models_dir() + + uninstall_binary() + + success("Ollama uninstall complete!") + if keep_models: + cli.echo(f"\n💡 Models are still in {OLLAMA_DIR} — delete manually if needed.") + + +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter diff --git a/modules/ollama/update.py b/modules/ollama/update.py new file mode 100644 index 0000000..f5da273 --- /dev/null +++ b/modules/ollama/update.py @@ -0,0 +1,144 @@ +""" +Update the Ollama binary and all installed models, then prune orphaned blobs. + +Usage: + uv run --no-sync python -m modules.ollama.update + uv run --no-sync python -m modules.ollama.update --skip-binary + uv run --no-sync python -m modules.ollama.update --skip-models + uv run --no-sync python -m modules.ollama.update --skip-cleanup +""" + +import json +import shutil +import subprocess +from pathlib import Path + +from ..common import cli +from ..common.utils import error, success, warning +from .helpers import pull_model + +OLLAMA_MODELS_DIR = Path.home() / ".ollama" / "models" + + +def update_binary() -> None: + """Update the Ollama binary via Homebrew.""" + if not shutil.which("brew"): + error("Homebrew not found.") + cli.echo("📦 Updating Ollama binary via Homebrew...") + result = subprocess.run(["brew", "upgrade", "ollama"], check=False) + if result.returncode != 0: + warning("Ollama binary upgrade had issues or was already up to date") + else: + success("Ollama binary updated") + + +def get_installed_models() -> list[str]: + """Return names of all locally installed Ollama models.""" + if not shutil.which("ollama"): + error("Ollama not found. Run 'invoke ollama.setup' first.") + result = subprocess.run( + ["ollama", "list"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + error("Failed to list installed models") + lines = result.stdout.strip().splitlines() + return [line.split()[0] for line in lines[1:] if line.split()] + + +def update_models(models: list[str]) -> None: + """Pull the latest version of each installed model.""" + for model in models: + pull_model(model) + + +def get_referenced_blobs() -> set[str]: + """Collect all blob filenames referenced across every installed model manifest.""" + manifests_dir = OLLAMA_MODELS_DIR / "manifests" + referenced: set[str] = set() + + if not manifests_dir.exists(): + return referenced + + for manifest_path in manifests_dir.rglob("*"): + if not manifest_path.is_file(): + continue + try: + data = json.loads(manifest_path.read_text()) + except json.JSONDecodeError, OSError: + # Skip unreadable manifests — keep their blobs to be safe + continue + + config_digest = data.get("config", {}).get("digest", "") + if config_digest: + referenced.add(config_digest.replace(":", "-")) + + for layer in data.get("layers", []): + digest = layer.get("digest", "") + if digest: + referenced.add(digest.replace(":", "-")) + + return referenced + + +def cleanup_orphaned_blobs() -> None: + """Delete blob files in ~/.ollama/models/blobs not referenced by any manifest.""" + blobs_dir = OLLAMA_MODELS_DIR / "blobs" + + if not blobs_dir.exists(): + cli.echo("No Ollama blobs directory found — nothing to clean.") + return + + referenced = get_referenced_blobs() + + orphaned = [b for b in blobs_dir.iterdir() if b.is_file() and b.name not in referenced] + + if not orphaned: + success("No orphaned blobs — storage is clean") + return + + total_bytes = sum(b.stat().st_size for b in orphaned) + total_gb = total_bytes / (1024**3) + cli.echo(f"Found {len(orphaned)} orphaned blob(s) ({total_gb:.2f} GB) — removing...") + + for blob in orphaned: + blob.unlink() + + success(f"Freed {total_gb:.2f} GB of orphaned blobs") + + +@cli.command() +@cli.option("--skip-binary", is_flag=True, help="Skip Ollama binary update, update models only") +@cli.option("--skip-models", is_flag=True, help="Skip model updates, update binary only") +@cli.option("--skip-cleanup", is_flag=True, help="Skip orphaned blob cleanup") +def main( + skip_binary: bool = False, + skip_models: bool = False, + skip_cleanup: bool = False, +) -> None: + """Update the Ollama binary and all installed models, then prune orphaned blobs.""" + cli.echo("\n=== Ollama Update ===\n") + + if not skip_binary: + update_binary() + + if not skip_models: + cli.echo("\n🔍 Checking installed models...") + models = get_installed_models() + if not models: + cli.echo("No models installed.") + else: + cli.echo(f"Found {len(models)} model(s): {', '.join(models)}") + update_models(models) + + if not skip_cleanup: + cli.echo("\n🧹 Cleaning up orphaned blobs...") + cleanup_orphaned_blobs() + + success("Ollama update complete!") + + +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter diff --git a/modules/opencode/README.md b/modules/opencode/README.md new file mode 100644 index 0000000..89a2983 --- /dev/null +++ b/modules/opencode/README.md @@ -0,0 +1,42 @@ +# OpenCode Module + +Generates `.opencode/command/*.md` from `.github/prompts/*.prompt.md`, the source of truth for +all slash commands. + +## Overview + +By default only writes NEW commands (files that don't exist yet). Use `--force` to overwrite +existing hand-crafted command files. Unlike `hermes/sync.py` (and the hand-maintained +`.claude/commands/`/`.clinerules/workflows/` mirrors), `claude` is **not** skipped here — `/claude` +legitimately proxies to the real `claude` CLI from within OpenCode, with no circularity or +filesystem-collision concern. + +## Usage + +```bash +uv run --no-sync python -m modules.opencode.sync # additive only +uv run --no-sync python -m modules.opencode.sync --force # overwrite all +uv run --no-sync invoke opencode.sync +uv run --no-sync invoke opencode.sync --force +``` + +Never hand-edit `.opencode/command/` — re-run this sync after adding or modifying any +`.github/prompts/*.prompt.md` file, then restart OpenCode to pick up new commands. + +## Files + +- `sync.py` — reads `.github/prompts/` via `modules/common/prompt_commands.py`, writes + `.opencode/command/*.md` +- `README.md` — this file + +## Architecture + +``` +uv run --no-sync invoke opencode.sync + ↓ +modules/opencode/sync.py + ↓ +modules/common/prompt_commands.py (shared .prompt.md parser) + ↓ +.opencode/command/*.md +``` diff --git a/modules/opencode/__init__.py b/modules/opencode/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/opencode/sync.py b/modules/opencode/sync.py new file mode 100644 index 0000000..cebf0db --- /dev/null +++ b/modules/opencode/sync.py @@ -0,0 +1,53 @@ +""" +Sync OpenCode slash commands from .github/prompts/*.prompt.md source of truth. + +By default only writes NEW commands (files that don't exist yet). +Use --force to overwrite existing hand-crafted command files. + +Usage: + uv run --no-sync python -m modules.opencode.sync # additive only + uv run --no-sync python -m modules.opencode.sync --force # overwrite all + uv run --no-sync invoke opencode.sync + uv run --no-sync invoke opencode.sync --force +""" + +from pathlib import Path + +from ..common import cli +from ..common.prompt_commands import load_commands +from ..common.route_utils import find_repo_root + +_REPO_ROOT = find_repo_root() +_OPENCODE_COMMAND_DIR = _REPO_ROOT / ".opencode" / "command" + + +def _command_content(slug: str, description: str, body: str) -> str: + header = f"---\ndescription: {description}\nsubtask: false\nagent: general\nslash_command: /{slug}\n---" + return f"{header}\n\n{body}\n" if body else f"{header}\n" + + +@cli.option("--force", is_flag=True, default=False, help="Overwrite existing command files") +@cli.command() +def main(force: bool = False) -> None: + """Sync .opencode/command/ from .github/prompts/ source of truth.""" + _OPENCODE_COMMAND_DIR.mkdir(parents=True, exist_ok=True) + # Unlike Claude Code and Cline, /claude is legitimate here — it proxies to the real `claude` + # CLI from within OpenCode, so nothing is skipped. + cmds = load_commands() + written = skipped = 0 + for cmd in cmds: + out: Path = _OPENCODE_COMMAND_DIR / f"{cmd.slug}.md" + if out.exists() and not force: + cli.echo(f" ⏭️ {cmd.slug}.md (exists — use --force to overwrite)") + skipped += 1 + else: + out.write_text(_command_content(cmd.slug, cmd.description, cmd.body), encoding="utf-8") + cli.echo(f" ✅ {cmd.slug}.md") + written += 1 + cli.echo(f"\n✅ Synced {written} new commands → .opencode/command/ ({skipped} skipped)") + if written: + cli.echo("💡 Restart OpenCode to pick up new commands.") + + +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter diff --git a/modules/setup/README.md b/modules/setup/README.md index 68e1426..a949ea7 100644 --- a/modules/setup/README.md +++ b/modules/setup/README.md @@ -7,10 +7,14 @@ uv run --no-sync invoke setup.properties ``` ## What It Does -`properties.yml` is gitignored. **A no-op if it already exists** — `modules/setup/properties.py` -only ever creates the file, it never rewrites an existing one. To regenerate it (e.g. after moving -the repo, renaming it, or pointing it at a new fork), delete or rename `properties.yml` first, then -run again. +`properties.yml` is gitignored **only in template repos** (this one and its siblings — anything +named `template_*`), since it would otherwise leak this machine's local paths into the template's +own history. In a real repo forked from a template, setup strips the ignore line from `.gitignore` +the first time it runs there, so `properties.yml` is committed like any other repo config — see +`_sync_gitignore_tracking()`. Creating the file itself is **a no-op if it already exists** — +`modules/setup/properties.py` only ever creates the file, it never rewrites an existing one (the +gitignore check still runs every time, though). To regenerate the file (e.g. after moving the repo, +renaming it, or pointing it at a new fork), delete or rename `properties.yml` first, then run again. On first run, assembles it from every tier fragment under `modules/setup/templates/properties/*.yml` — one file per repo in the lineage, each named after itself. This repo (template_ai_python) forked diff --git a/modules/setup/properties.py b/modules/setup/properties.py index 228f801..5704562 100644 --- a/modules/setup/properties.py +++ b/modules/setup/properties.py @@ -6,7 +6,11 @@ moving the repo, renaming it, or forking it to a new remote), delete or rename properties.yml first, then run again. -properties.yml is gitignored. It's assembled from every tier fragment under +properties.yml is gitignored in template repos (this one and its siblings — anything named +`template_*`) since it would otherwise leak this machine's local paths into the template's own +history. A real repo forked from a template gets the ignore line stripped from .gitignore the +first time setup runs there instead, so properties.yml is committed like any other repo config +(see `_sync_gitignore_tracking()` below). It's assembled from every tier fragment under `modules/setup/templates/properties/*.yml` — one file per repo in the lineage, each named after itself. This repo (template_ai_python) forked from template_python, the root: its own fragment, `template_ai_python.yml`, holds its own `repos` entry plus the lineage edge to `template_python`; @@ -238,6 +242,47 @@ def _has_section(lines: list[str], section: str) -> bool: return any(line.rstrip("\n") == f"{section}:" for line in lines) +def _is_template_repo(repo_remote: str | None) -> bool: + """Return whether this repo is itself a template (vs. a real repo forked from one). + + Every template repo in this family is named `template_*` (template_python, template_ai_python, + template_ai_vault, template_shopify, ...) — checked against the git remote when available, + falling back to the local folder name so it still works before a remote is set. + """ + name = repo_remote.rsplit("/", 1)[-1] if repo_remote else _REPO_ROOT.name + return name.startswith("template_") + + +def _sync_gitignore_tracking(repo_remote: str | None) -> None: + """Strip properties.yml's ignore line from .gitignore for a real (non-template) repo. + + Template repos always keep properties.yml gitignored — see the module docstring. A repo forked + from one should commit it instead, so this removes the ignore line (and its explanatory + comment) the first time setup runs there. Idempotent: a repo that's already had the line + removed, or never had it, is left untouched. + """ + gitignore = _REPO_ROOT / ".gitignore" + if not gitignore.exists() or _is_template_repo(repo_remote): + return + + text = gitignore.read_text() + if "/properties.yml" not in text: + return + + kept = [ + line + for line in text.splitlines() + if line.strip() != "/properties.yml" and "generated by `inv setup.properties`" not in line + ] + collapsed: list[str] = [] + for line in kept: + if line == "" and collapsed and collapsed[-1] == "": + continue # the removal above left two blank lines in a row — keep just one + collapsed.append(line) + gitignore.write_text("\n".join(collapsed) + "\n") + success("properties.yml is no longer gitignored — commit it along with the rest of setup") + + def _prompt_icloud_enabled(lines: list[str]) -> None: """Ask (interactively) whether to turn on iCloud sync for a freshly created properties.yml.""" enabled = cli.confirm( @@ -284,6 +329,7 @@ def main() -> None: """Create properties.yml from every tier fragment; a no-op if it already exists.""" if _PROPERTIES_FILE.exists(): info("properties.yml already exists — leaving it untouched (delete or rename it to regenerate)") + _sync_gitignore_tracking(_detect_repo_remote()) return _PROPERTIES_FILE.write_text(_build_initial_content()) @@ -303,6 +349,7 @@ def main() -> None: if _has_section(lines, "icloud"): _prompt_icloud_enabled(lines) _PROPERTIES_FILE.write_text("".join(lines)) + _sync_gitignore_tracking(repo_remote) success(f"properties.yml: repo.local = {repo_local}") if repo_remote: diff --git a/pyproject.toml b/pyproject.toml index e242a42..0e04430 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ testpaths = ["tests"] norecursedirs = ["tmp", ".venv", ".ruff_cache", "__pycache__", "addons"] markers = [ "agents: .github/prompts/ mirror consistency — tests/agents/", + "drift: gate tests that catch a forgotten regen step before merge — tests/drift/", "setup: modules/setup/ (properties.yml bootstrap) — tests/setup/", "style: Markdown/doc style checks — tests/style/", ] diff --git a/tasks/__init__.py b/tasks/__init__.py index 81a3f9e..e61bed2 100644 --- a/tasks/__init__.py +++ b/tasks/__init__.py @@ -9,7 +9,7 @@ if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) -from .ai import repo, template # noqa: E402 # pylint: disable=wrong-import-position +from .ai import docs, hermes, ollama, opencode, repo, template # noqa: E402 # pylint: disable=wrong-import-position from .common import debug, ruff, setup, upgrade, uv, versioning # noqa: E402 # pylint: disable=wrong-import-position from .common import main as common_main # noqa: E402 # pylint: disable=wrong-import-position from .tests import namespace as tests_namespace # noqa: E402 # pylint: disable=wrong-import-position @@ -33,7 +33,20 @@ namespace.add_task(common_main.update, name="update") # `ai/` groups tooling downstream repos (this template's own children) use to operate on -# themselves — git/PR workflow (`repo`) and parent-template sync (`template`, this repo's own -# link back to template_python). Same "keep the original top-level name" treatment as `common/`. +# themselves, or to integrate with a specific AI tool — git/PR workflow (`repo`), parent-template +# sync (`template`, this repo's own link back to template_python), per-tool command sync +# (`hermes`, `opencode`), local-LLM management (`ollama`), and changelog sync (`docs`). Same "keep +# the original top-level name" treatment as `common/`. +namespace.add_collection(docs, name="docs") +namespace.add_collection(hermes, name="hermes") +namespace.add_collection(ollama, name="ollama") +namespace.add_collection(opencode, name="opencode") namespace.add_collection(repo, name="repo") namespace.add_collection(template, name="template") + +# Combo Task — sync all AI tool commands from .github/prompts/ source of truth at once (runs +# hermes.sync + opencode.sync). Registered as `ai.sync` (a mini-Collection, not `ai.*` for the +# tools above — invoke doesn't allow a task and a collection to share one name at the same level). +ai_collection = Collection("ai") +ai_collection.add_task(common_main.ai_sync, name="sync") +namespace.add_collection(ai_collection) diff --git a/tasks/ai/docs.py b/tasks/ai/docs.py new file mode 100644 index 0000000..e4f341e --- /dev/null +++ b/tasks/ai/docs.py @@ -0,0 +1,13 @@ +"""Docs automation tasks — changelog sync from properties.yml, etc. See +`.github/instructions/docs.instructions.md`. +""" + +from invoke import task + +from modules.docs import update as update_module + + +@task +def update_changelogs(_context): + """Prepend any missing docs/change_logs/<category>/<name>.md entries from properties.yml""" + update_module.change_logs() diff --git a/tasks/ai/hermes.py b/tasks/ai/hermes.py new file mode 100644 index 0000000..e4a862a --- /dev/null +++ b/tasks/ai/hermes.py @@ -0,0 +1,10 @@ +"""Hermes AI agent integration tasks.""" + +from invoke import task + + +@task +def sync(context): + """Sync Hermes /r-* commands from .github/prompts source of truth.""" + context.run("uv run --no-sync python -m modules.hermes.sync", pty=True) + print("\n💡 Restart your Hermes session (/quit then hermes) to pick up new commands.") diff --git a/tasks/ai/ollama.py b/tasks/ai/ollama.py new file mode 100644 index 0000000..57cc85b --- /dev/null +++ b/tasks/ai/ollama.py @@ -0,0 +1,101 @@ +"""Ollama tasks for local LLM setup and maintenance.""" + +import shutil +from pathlib import Path + +from invoke import task + + +@task +def clean(_context, yes=False): + """Remove all downloaded Ollama models and blob cache (~/.ollama/models/) + + Args: + yes: Skip confirmation prompt + """ + models_dir = Path.home() / ".ollama" / "models" + if not models_dir.exists(): + print("Nothing to clean — ~/.ollama/models/ does not exist.") + return + + size_gb = sum(f.stat().st_size for f in models_dir.rglob("*") if f.is_file()) / (1024**3) + print(f"\nThis will delete ~/.ollama/models/ ({size_gb:.1f}GB)") + + if not yes: + confirm = input("Are you sure? [y/N]: ").strip().lower() + if confirm != "y": + print("Aborted.") + return + + shutil.rmtree(models_dir) + print(f"✅ Removed ~/.ollama/models/ ({size_gb:.1f}GB freed)") + + +@task +def install(context, model="qwen2.5-coder:32b", debug=False): + """Install Ollama and a local coding LLM on Apple Silicon + + Args: + model: Model to install (default: qwen2.5-coder:32b) + debug: Enable verbose step-by-step debug logging + """ + title = "Ollama Install (Debug Mode)" if debug else "Ollama Install" + print("\n" + "=" * 50) + print(title) + print("=" * 50 + "\n") + debug_flag = "--debug" if debug else "" + context.run(f"python -u -m modules.ollama.install --model {model} {debug_flag}") + + +@task(name="list") +def list_models(context): + """List installed and available Ollama models""" + context.run("python -m modules.ollama.list") + + +@task +def restart(context): + """Restart the Ollama service via Homebrew""" + context.run("brew services restart ollama") + + +@task +def start(context): + """Start the Ollama service via Homebrew""" + context.run("brew services start ollama") + + +@task +def status(context): + """Show Ollama service and running-model status""" + context.run("python -m modules.ollama.status") + + +@task +def stop(context): + """Stop the Ollama service via Homebrew""" + context.run("brew services stop ollama") + + +@task +def uninstall(context): + """Uninstall Ollama binary and remove all downloaded models""" + print("\n" + "=" * 50) + print("Ollama Uninstall") + print("=" * 50 + "\n") + context.run("python -u -m modules.ollama.uninstall") + + +@task +def update(context, debug=False): + """Update Ollama binary and all installed models + + Args: + debug: Enable verbose step-by-step debug logging + """ + title = "Ollama Update (Debug Mode)" if debug else "Ollama Update" + print("\n" + "=" * 50) + print(title) + print("=" * 50 + "\n") + debug_flag = "--debug" if debug else "" + context.run(f"python -u -m modules.ollama.update {debug_flag}") diff --git a/tasks/ai/opencode.py b/tasks/ai/opencode.py new file mode 100644 index 0000000..82b4c74 --- /dev/null +++ b/tasks/ai/opencode.py @@ -0,0 +1,10 @@ +"""OpenCode integration tasks.""" + +from invoke import task + + +@task +def sync(context, force=False): + """Sync .opencode/command/ from .github/prompts/ source of truth.""" + flag = " --force" if force else "" + context.run(f"uv run --no-sync python -m modules.opencode.sync{flag}", pty=True) diff --git a/tasks/common/main.py b/tasks/common/main.py index ecfdd87..be4f2de 100644 --- a/tasks/common/main.py +++ b/tasks/common/main.py @@ -1,14 +1,23 @@ from invoke import task +from ..ai import docs, hermes, opencode from ..tests import actionlint, check_agents, pylint, pytest, rufflint, yamllint from . import ruff, versioning +@task +def ai_sync(context, force=False): + """Sync all AI tool commands from .github/prompts/ source of truth""" + hermes.sync(context) + opencode.sync(context, force=force) + + @task def fix(context): """Run All Automated Fixes""" ruff.fix(context) ruff.format(context) + docs.update_changelogs(context) @task diff --git a/tests/agents/test_check_agents.py b/tests/agents/test_check_agents.py index 8826bcf..19b3f70 100644 --- a/tests/agents/test_check_agents.py +++ b/tests/agents/test_check_agents.py @@ -1,4 +1,4 @@ -"""Verify .github/prompts/ is mirrored into .claude/commands/, .claude/skills/, and .clinerules/workflows/.""" +"""Verify .github/prompts/ is mirrored into .claude/commands/, .claude/skills/, .clinerules/workflows/, and .opencode/command/.""" from __future__ import annotations @@ -27,6 +27,10 @@ def _clinerules_slugs() -> set[str]: return {p.stem for p in (REPO_ROOT / ".clinerules" / "workflows").glob("*.md")} +def _opencode_slugs() -> set[str]: + return {p.stem for p in (REPO_ROOT / ".opencode" / "command").glob("*.md")} + + def _assert_mirrored(label: str, expected: set[str], actual: set[str]) -> None: missing = sorted(expected - actual) extra = sorted(actual - expected) @@ -43,3 +47,7 @@ def test_claude_skills_mirror_prompts() -> None: def test_clinerules_workflows_mirror_prompts() -> None: _assert_mirrored("`.clinerules/workflows/`", _prompt_slugs(), _clinerules_slugs()) + + +def test_opencode_commands_mirror_prompts() -> None: + _assert_mirrored("`.opencode/command/`", _prompt_slugs(), _opencode_slugs()) diff --git a/tests/drift/__init__.py b/tests/drift/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/drift/docs/__init__.py b/tests/drift/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/drift/docs/test_changelogs_current.py b/tests/drift/docs/test_changelogs_current.py new file mode 100644 index 0000000..55a89c7 --- /dev/null +++ b/tests/drift/docs/test_changelogs_current.py @@ -0,0 +1,17 @@ +"""Drift gate — every docs/change_logs/<category>/<name>.md must already lead with the entry +properties.yml's current version/latest_changes requires. See docs.instructions.md. + +`check_each_log(update=False)` raises `ValueError` itself on the first stale entry it finds +(rather than returning a list to assert on) — see `modules/docs/lib/change_logs.py` — so this test +just calls it and lets that propagate as the failure. A no-op while CHANGELOG_CATEGORIES is empty. +""" + +import pytest + +from modules.docs.lib.change_logs import check_each_log + +pytestmark = pytest.mark.drift + + +def test_changelogs_current(): + check_each_log(update=False) diff --git a/tests/setup/test_setup_properties.py b/tests/setup/test_setup_properties.py index 6382890..212e14f 100644 --- a/tests/setup/test_setup_properties.py +++ b/tests/setup/test_setup_properties.py @@ -65,3 +65,55 @@ def test_prompted_parent_stamped_when_confirmed(self, monkeypatch: pytest.Monkey monkeypatch.setattr(setup_props.cli, "prompt", lambda *_a, **_k: "github.com/user/template_my_vault") _stamp(lines, "$HOME/dev/my_vault", monkeypatch, detected=None, confirm=True) assert 'remote: "github.com/user/template_my_vault"' in "".join(lines) + + +class TestIsTemplateRepo: + """Tests for _is_template_repo().""" + + def test_template_remote_is_a_template(self): + assert setup_props._is_template_repo("github.com/LevonBecker/template_ai_python") # pylint: disable=protected-access + + def test_real_repo_remote_is_not_a_template(self): + assert not setup_props._is_template_repo("github.com/user/my_vault") # pylint: disable=protected-access + + def test_falls_back_to_local_folder_name_when_no_remote(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(setup_props, "_REPO_ROOT", setup_props._REPO_ROOT.parent / "template_shopify") # pylint: disable=protected-access + assert setup_props._is_template_repo(None) # pylint: disable=protected-access + + +class TestSyncGitignoreTracking: + """Tests for _sync_gitignore_tracking().""" + + _BLOCK = ( + "node_modules/\n" + "\n" + "# Machine-specific config — generated by `inv setup.properties` (see modules/setup/properties.py)\n" + "/properties.yml\n" + "\n" + ".DS_Store\n" + ) + + def test_template_repo_leaves_gitignore_untouched(self, tmp_path, monkeypatch: pytest.MonkeyPatch): + gitignore = tmp_path / ".gitignore" + gitignore.write_text(self._BLOCK) + monkeypatch.setattr(setup_props, "_REPO_ROOT", tmp_path) + setup_props._sync_gitignore_tracking("github.com/LevonBecker/template_ai_python") # pylint: disable=protected-access + assert gitignore.read_text() == self._BLOCK + + def test_real_repo_strips_ignore_line_and_comment(self, tmp_path, monkeypatch: pytest.MonkeyPatch): + gitignore = tmp_path / ".gitignore" + gitignore.write_text(self._BLOCK) + monkeypatch.setattr(setup_props, "_REPO_ROOT", tmp_path) + setup_props._sync_gitignore_tracking("github.com/user/my_vault") # pylint: disable=protected-access + content = gitignore.read_text() + assert "/properties.yml" not in content + assert "generated by `inv setup.properties`" not in content + assert "\n\n\n" not in content + assert "node_modules/" in content and ".DS_Store" in content + + def test_already_stripped_gitignore_is_idempotent(self, tmp_path, monkeypatch: pytest.MonkeyPatch): + gitignore = tmp_path / ".gitignore" + gitignore.write_text("node_modules/\n\n.DS_Store\n") + monkeypatch.setattr(setup_props, "_REPO_ROOT", tmp_path) + setup_props._sync_gitignore_tracking("github.com/user/my_vault") # pylint: disable=protected-access + assert gitignore.read_text() == "node_modules/\n\n.DS_Store\n"