ai continue ...
gates / consistency-and-conventions (push) Successful in 1m24s

This commit is contained in:
Your Name
2026-09-04 11:09:21 -04:00
parent 072a8e72c1
commit 06a05f0567
76 changed files with 15648 additions and 122623 deletions
@@ -0,0 +1,527 @@
# Architecture: `pos ai alias` — AI Agent Alias Manager
**Date:** 2026-08-26
**Author:** Architect (big-pickle)
**Status:** DECISION_READY
---
## TL;DR
| Decision | Choice |
|----------|--------|
| Storage | `~/.config/linux_post_install/ai-aliases.env` (pipe-delimited structured data) |
| Shell aliases | `~/.config/linux_post_install/ai-aliases.sh` (generated, never hand-edited) |
| Tool | `bin/pos-ai-alias` — new `ai` category tool |
| No POS_CONFIG scope | Alias management is CRUD, not env-key editing; `pos config` is not involved |
| Source of truth | `.env` file; `.sh` file is regenerated on every write |
| .bashrc integration | One conditional `source` line, added by postinstall.sh |
| INSTALL_CHANGES | `postinstall.sh` adds `.bashrc` source line; `bin/pos` adds `ai-alias` to INTERACTIVE_CMDS |
**Open items:**
- None — all decisions are evidence-backed from project conventions.
---
## Decision 1: Data Format
**Problem:** Store named AI aliases (provider, session name, system prompt) durably, with safe special-character handling and trivial parsing.
### Options Considered
**Option A: Shell alias format (one-liner shell aliases)**
```
alias devbot='pos ai gemini ask --session devbot --system "You are a dev assistant"'
```
- Architecture: Store raw shell alias lines; `.bashrc` sources the file directly.
- Advantages: No generation step; shell sources it natively.
- Costs: Parsing aliases back into components (for edit/list) requires fragile quote-aware shell parsing; single quotes inside system prompts break the syntax.
- Risks: Prompt containing `'` corrupts the file. Edit must read then reconstruct — fragile.
**Option B: Pipe-delimited structured data + generated .sh**
```
devbot|gemini|devbot|You are a dev assistant
```
- Architecture: `.env` file is the source of truth (pipe-delimited fields). A separate `.sh` file is regenerated from it on every write. `.bashrc` sources the `.sh` file.
- Advantages: Parsing is trivial (`IFS='|'`); single-quote escaping is handled at generation time; data is safe to `grep`/`sort`/`awk`.
- Costs: One extra file (`.sh`); a `_regen_aliases()` helper function.
- Risks: Generation must escape correctly — but this is a single, testable function.
**Option C: Individual env files per alias**
```
~/.config/linux_post_install/ai-aliases/devbot.env
```
- Architecture: One file per alias; load all at shell startup.
- Advantages: No parsing of multi-entry files.
- Costs: Directory management; glob at shell startup; harder to list all; no atomic operations.
### Decision: Option B
Pipe-delimited structured data is the cleanest separation. The `.env` file is the source of truth. The `.sh` file is a generated artifact. This matches the project's own pattern of "generated code between GEN markers" — except here the generator lives inside the tool itself, not `make gen`.
**Rationale:** The project already has a pattern of generated files (e.g., `GEN:START`/`GEN:END` blocks, `completions/pos.bash`). The tool owns its own generation. Parsing shell aliases is fragile and error-prone; pipe-delimited data is trivially safe.
### Data Format Specification
```
# ~/.config/linux_post_install/ai-aliases.env
# Managed by: pos ai alias (do not hand-edit)
# Format: alias_name|provider|session_name|system_prompt
# Pipe characters in system_prompt are not supported.
#
# agent_name|provider|session_name|system_prompt
devbot|gemini|devbot|You are a Linux dev assistant. Reply with commands only.
code|openrouter|codereview|You are a code reviewer. Be concise.
```
**Field constraints:**
| Field | Rules |
|-------|-------|
| `alias_name` | Shell-valid identifier: `[a-zA-Z][a-zA-Z0-9_-]*` |
| `provider` | Must match an installed provider: `gemini`, `openrouter`, etc. |
| `session_name` | Defaults to the alias name if empty; `[a-zA-Z0-9_-]+` |
| `system_prompt` | Free text; no `\|` (pipe) characters; may be empty (uses built-in prompt) |
**Header:** Two comment lines at the top (file description + format) are auto-maintained by the tool.
**Empty session_name convention:** When session_name is empty (field is blank between pipes), the alias uses the alias name as the session name. This avoids redundant repetition for the common case.
---
## Decision 2: File Layout
### New files
| File | Purpose |
|------|---------|
| `bin/pos-ai-alias` | New CLI tool (100755) |
### Modified files
| File | Change |
|------|--------|
| `bin/pos` | Add `ai-alias` to `INTERACTIVE_CMDS` list |
| `postinstall.sh` | Add `.bashrc` source line for `ai-aliases.sh` (conditional, no-clobber) |
### Runtime files (user config, NOT in repo)
| File | Purpose | Mutability |
|------|---------|------------|
| `~/.config/linux_post_install/ai-aliases.env` | Alias data (source of truth) | Created/modified by tool |
| `~/.config/linux_post_install/ai-aliases.sh` | Generated shell aliases | Regenerated on every write |
### Files NOT modified
| File | Why not |
|------|---------|
| `.gitignore` | `~/.config/linux_post_install/` is a user directory, not in the repo. No new repo files to ignore. |
| `config/` | No template file needed — the env file is user-created on first use. |
| `lib/` | No new library. Tool sources `common.sh` + `menu-lib.sh` from existing libs. |
---
## Decision 3: Tool Interface
### POS Header
```bash
# POS: ai alias — Create/edit/remove named AI agent aliases
# POS_SUBCMDS: create edit remove list show
```
No `POS_FLAGS:` — this is a subcommand-based tool, not a flag-based tool.
### CLI Interface
```
pos ai alias [subcommand] [args]
Subcommands:
(no args) Interactive menu (create/edit/remove/list)
create Create a new alias (interactive prompts)
create <name> Create with given name (interactive prompts for rest)
edit Pick an alias to edit (interactive)
edit <name> Edit a specific alias
remove Pick an alias to remove (interactive, with confirmation)
remove <name> Remove a specific alias (with confirmation)
list List all aliases (non-interactive, machine-readable)
show <name> Show one alias's details
Options:
-h|--help Show this help.
Examples:
pos ai alias # interactive menu
pos ai alias list # show all aliases
pos ai alias create # interactive create
pos ai alias create mybot # create 'mybot' alias
pos ai alias edit mybot # edit the 'mybot' alias
pos ai alias remove mybot # remove 'mybot' (with confirm)
pos ai alias show mybot # show alias details
```
### Subcommand Details
**`pos ai alias` (no args):** Interactive menu using `menu_run` from `lib/menu-lib.sh`. Options:
1. Create new alias
2. Edit existing alias
3. Remove alias
4. List aliases
**`pos ai alias list`:** Non-interactive table output:
```
Aliases (3):
mybot gemini mybot You are a helpful assistant
code openrouter code You are a code reviewer
dev gemini devbot Dev assistant
```
Format: `%-12s %-12s %-12s %s` (name, provider, session, prompt-truncated-to-60).
**`pos ai alias show <name>`:** Full details including the resolved shell command.
---
## Decision 4: POS_CONFIG
**Decision: NO POS_CONFIG scope.**
**Rationale:** The `pos config` / `POS_CONFIG` system is designed for simple `KEY=VALUE` env files (like `ai.env`, `telegram.env`, `entertainment.env`). Aliases are structured, multi-field records, not key-value pairs. The `cfg_ui()` pattern from `lib/config-ui.sh` doesn't apply here — it renders a numbered menu of KEY=VALUE pairs, not CRUD operations on named records.
The alias tool is self-contained with its own interactive menus. It does not participate in `pos config`.
---
## Decision 5: Shell Alias Generation
### The `_regen_aliases()` function
This function reads `ai-aliases.env` and writes `ai-aliases.sh`:
```bash
_regen_aliases() {
local env_file="$1" sh_file="$2" tmp
tmp="$(mktemp)"
printf '#!/usr/bin/env bash\n# Auto-generated by pos ai alias — do not hand-edit.\n# Source: %s\n\n' "$env_file" >"$tmp"
if [ -f "$env_file" ]; then
while IFS='|' read -r name provider session prompt _rest; do
# Skip comments and empty lines
[[ "$name" =~ ^[[:space:]]*# ]] && continue
[[ -z "$name" ]] && continue
# Validate alias name
[[ "$name" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] || continue
# Session defaults to alias name if empty
[ -z "$session" ] && session="$name"
# Escape single quotes in the system prompt for shell-safe embedding
local escaped_prompt="${prompt//\'/\'\\\'\'}"
printf "alias %s='pos ai %s ask --session %s" "$name" "$provider" "$session" >>"$tmp"
if [ -n "$prompt" ]; then
printf " --system '%s'" "$escaped_prompt" >>"$tmp"
fi
printf "'\n" >>"$tmp"
done < <(grep -v '^[[:space:]]*#' "$env_file" | grep -v '^[[:space:]]*$' || true)
fi
mv "$tmp" "$sh_file"
chmod 644 "$sh_file"
}
```
**Single-quote escaping:** `${prompt//\'/\'\\\'\'}` — bash `parameter expansion` replaces every `'` with `'\''` (close-quote, escaped-quote, open-quote). This is the standard and safe pattern for embedding arbitrary strings in single-quoted shell contexts.
**Edge case — empty prompt:** When the system prompt is empty, the `--system` flag is omitted entirely, letting `pos ai` use its built-in default prompt.
### Syntax validation before commit
After generating the `.sh` file, run `bash -n` to verify syntax:
```bash
if ! bash -n "$sh_file" 2>/dev/null; then
warn "Generated alias file has syntax errors — keeping previous version"
rm -f "$tmp"
return 1
fi
```
### Shell integration in .bashrc
```bash
# AI aliases (managed by pos ai alias)
[ -f ~/.config/linux_post_install/ai-aliases.sh ] && source ~/.config/linux_post_install/ai-aliases.sh
```
This line is added by `postinstall.sh` with the standard no-clobber grep check.
---
## Decision 6: Interactive Flow
### Main Menu (`pos ai alias` — no args)
Uses `menu_run` from `lib/menu-lib.sh`:
```
════════════════════════════════════════════
AI Agent Aliases
════════════════════════════════════════════
1) Create new alias
2) Edit existing alias
3) Remove alias
4) List aliases
0) Exit
----------------------------------------
Choose:
```
### Create Flow
1. Prompt for alias name: `menu_ask_value "Alias name" ""` — validate format (`[a-zA-Z][a-zA-Z0-9_-]*`)
2. Check for duplicate name → warn and re-prompt if taken
3. Prompt for provider: show available providers (read from `$PROVIDER_DIR/*.sh`), default `gemini`
4. Prompt for session name: default = alias name
5. Prompt for system prompt: default = empty (uses built-in)
6. Confirm: `confirm "Create alias '<name>'?" y`
7. Write to `.env`, regenerate `.sh`, log success
### Edit Flow
1. List existing aliases (name + provider + first-40-chars of prompt)
2. Pick one (if no arg given): `menu_pick "Pick alias" "${names[@]}"`
3. Show current values
4. For each field, prompt with current value as default (Enter = keep)
5. Confirm changes
6. Rewrite `.env` entry, regenerate `.sh`
### Remove Flow
1. Pick alias: `menu_pick` or named
2. Show alias details
3. `confirm "Remove alias '<name>'? This cannot be undone." n` (default = no)
4. Remove from `.env`, regenerate `.sh`
### List Flow (non-interactive)
Prints formatted table to stdout. Provider column left-aligned, name left-aligned, prompt truncated to 60 chars with `...`.
### Show Flow (non-interactive)
Full details + resolved command:
```
Alias: mybot
Provider: gemini
Session: mybot
Prompt: You are a helpful assistant
Command: pos ai gemini ask --session mybot --system 'You are a helpful assistant'
```
---
## Decision 7: Edge Cases and Error Handling
| Edge Case | Handling |
|-----------|----------|
| **Duplicate alias name on create** | `warn "Alias '$name' already exists — use 'pos ai alias edit $name' instead"`; re-prompt |
| **Empty alias name** | `err "Alias name cannot be empty"` |
| **Invalid alias name** (contains spaces, starts with digit) | `err "Invalid alias name '$name' — use letters, digits, hyphens, underscores"` |
| **Invalid provider** | `err "Unknown provider '$p' — available: $(ls ...)"` — uses same provider discovery as `pos-ai` |
| **Empty system prompt** | Allowed — omit `--system` flag; `pos ai` uses its built-in default prompt |
| **Very long system prompt** | Truncate display in `list` output (60 chars + `...`); full value preserved in `.env` and `.sh`. Warn if > 500 chars during creation. |
| **Pipe character in system prompt** | Rejected on input: `err "System prompt must not contain '\|' characters"` |
| **System prompt with single quotes** | Handled by `_regen_aliases()` escaping: `'` -> `'\''` in the generated alias line |
| **Editing an alias "in use"** | No lock/detection needed. The user edits the `.env`; on next shell startup (or `source ~/.bashrc`), aliases update. No runtime state conflict. |
| **File doesn't exist yet** | First create auto-creates both `.env` and `.sh` |
| **Corrupted/invalid .env line** | Skipped by `_regen_aliases()` (name validation regex) |
| **Concurrent edits** | Not a concern — personal single-user tool. Last write wins. |
| **Missing pos-ai dependency** | Tool doesn't require `pos-ai` at runtime — it only writes config. No dep guard needed. |
---
## Decision 8: Security Considerations
### System prompt injection
System prompts are user-authored text that becomes a shell argument. Risks:
- **Shell injection via alias execution:** The prompt is single-quoted in the alias, so shell metacharacters (`$`, backtick, `!`) are literal — safe.
- **`pos ai` prompt injection:** This is a user's own prompt for their own AI. No trust boundary crossing.
- **File permissions:** `ai-aliases.env` gets `chmod 600` (user-only read; consistent with other config files). `ai-aliases.sh` gets `chmod 644` (needed by bash `source`).
### Escaping correctness
The single-quote escaping `${prompt//\'/\'\\\'\'}` is the only place where correctness matters critically. If it fails, the generated alias has a syntax error and `source` will report it. Mitigation:
- The tool runs `bash -n` on the generated `.sh` file before committing it.
- If syntax check fails, warn and skip the regeneration (keep the old `.sh`).
### No secrets in the alias file
System prompts are not secrets — they're user-authored instructions. API keys stay in `ai.env` (already managed by `pos config ai`). No new secret surface.
---
## Decision 9: Integration with pos-ai
### How aliases invoke pos-ai
Each generated alias calls:
```bash
alias <name>='pos ai <provider> ask --session <session> --system "<prompt>"'
```
This uses the existing `pos-ai` flags:
- `--provider <name>` — supported since the beginning (line 633 of `bin/pos-ai`)
- `--session <name>` — supported (line 639)
- `--system <text>` — supported (line 642)
**No changes to `bin/pos-ai` are required.** The alias tool is a standalone configuration tool that writes shell aliases calling `pos ai`.
### Provider validation
The alias tool must validate the provider name against installed providers. It reuses the same discovery logic from `pos-ai`:
```bash
PROVIDER_DIR="$(dirname "$0")/../lib/ai-providers"
# Fallback for installed layout
[ -d "$PROVIDER_DIR" ] || PROVIDER_DIR="$(dirname "$0")/ai-providers"
```
This is the same pattern used in `pos-ai` at line 17-18. The alias tool discovers providers independently (no dependency on `pos-ai` being sourced).
---
## Decision 10: Installation Changes
### postinstall.sh modification
Add a block after the existing `ai.env` installation (around line 50):
```bash
# ── AI aliases shell integration ───────────────────────────────
ALIAS_SRC_LINE='# AI aliases (managed by pos ai alias)
[ -f ~/.config/linux_post_install/ai-aliases.sh ] && source ~/.config/linux_post_install/ai-aliases.sh'
if ! grep -qsF "ai-aliases.sh" "$BASHRC" 2>/dev/null; then
run printf '%s\n' "$ALIAS_SRC_LINE" >> "$BASHRC"
log "Added AI aliases source to ~/.bashrc"
fi
```
### bin/pos modification
Add `ai-alias` to the `INTERACTIVE_CMDS` list (line 262). Current value:
```
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup system-uninstall share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai ai-gemini ai-openrouter system-schedule entertainment-config config"
```
Add `ai-alias` to this space-separated list.
### No other installation changes
- No new apt packages (no deps beyond bash)
- No new lib files (tool sources `common.sh` + `menu-lib.sh` from existing libs)
- No systemd services
- No config template in `config/`
---
## Decision 11: Long Prompt Handling
System prompts can be arbitrarily long. Shell aliases have a practical limit (ARG_MAX, typically 2MB on Linux), so this is not a hard constraint. However:
- **Display:** `list` output truncates to 60 chars + `...`
- **Storage:** Full prompt in `.env` and `.sh` — no truncation
- **Interactive edit:** Shows full current value, allows full editing
- **Warning:** During creation, if prompt exceeds 500 chars: `warn "System prompt is long (${#prompt} chars) — consider keeping it concise"`
**Decision:** No artificial length limit.
---
## Implementation Phases
### Phase 1: Core tool (single commit)
1. Create `bin/pos-ai-alias` with:
- Shebang, strict mode, `common.sh` source, `menu-lib.sh` source
- `# POS:` header + `# POS_SUBCMDS:`
- `CONFIG_FILE` and `ALIASES_SH_FILE` path constants
- `_load_aliases()` — reads `.env` into parallel arrays (names, providers, sessions, prompts)
- `_find_alias()` — lookup by name, returns index
- `_write_env_file()` — writes entire `.env` from arrays
- `_regen_aliases()` — reads `.env`, writes `.sh` with proper escaping
- `_list_aliases()` — non-interactive table output
- `_show_alias()` — non-interactive single alias details
- `_create_alias()` — interactive create with validation
- `_edit_alias()` — interactive edit with field-level prompts
- `_remove_alias()` — interactive remove with confirm
- `_main_menu()` — interactive menu via `menu_run`
- Subcommand dispatch (`case` pattern)
- `usage()` function
2. Add `ai-alias` to `INTERACTIVE_CMDS` in `bin/pos`
3. Add `.bashrc` source line to `postinstall.sh`
### Phase 2: Verification
1. `chmod +x bin/pos-ai-alias`
2. `bash -n bin/pos-ai-alias`
3. `make gen` — regenerate tables (new tool appears in dispatch table, bin tree, file table)
4. `make check` — self-consistency gate
5. `make lint` — convention gate (0 FAIL, 0 WARN)
6. Manual test: create, list, show, edit, remove aliases; verify `.sh` file is correct
7. Source `.bashrc` and verify aliases work
### Phase 3: Documentation
1. `DOC/POS.md` — add `ai alias` section (hand-written)
2. `DOC/HOWTO.md` — add index row
3. `DOC/howto/ai.md` — add aliases section (if ai.md exists; otherwise add to existing ai howto)
4. `DOC/AGENT_Context_Project.md` — regenerated by `make gen`; hand-add to Common Tasks table
5. Update `AGENT_TODO.md` Done section (dated)
---
## Architectural Constraints
1. **Tool must source `lib/common.sh`** via the fallback chain (not self-contained)
2. **Tool must source `lib/menu-lib.sh`** for interactive menus
3. **Tool MUST be in `INTERACTIVE_CMDS`** in `bin/pos`
4. **Generated `.sh` file must pass `bash -n`** before commit
5. **Env-seam:** all file paths use `${CONFIG_DIR:-...}` pattern (already in `common.sh`)
6. **`chmod 600`** for `.env`, `chmod 644` for `.sh`
7. **No dependency on `pos-ai`** being installed — tool writes config, doesn't run `pos ai`
8. **Pipe delimiter** — system prompts must not contain `|`; validated on input
---
## Verification Checklist
- [ ] `bash -n bin/pos-ai-alias` passes
- [ ] `shellcheck bin/pos-ai-alias` passes (or only known false positives)
- [ ] `make gen` regenerates tables correctly
- [ ] `make check` passes (bash -n, exec bits, doc sync, smoke)
- [ ] `make lint` passes (0 FAIL, 0 WARN)
- [ ] `pos ai alias --help` shows help
- [ ] `pos ai alias` shows interactive menu
- [ ] Create -> list -> show -> edit -> remove cycle works
- [ ] Generated `.sh` file has correct alias syntax
- [ ] `bash -n` on generated `.sh` passes
- [ ] `.bashrc` source line works (aliases available after source)
- [ ] Special chars in system prompt (single quotes, spaces, $) survive round-trip
- [ ] Duplicate name is rejected
- [ ] Invalid alias name is rejected
- [ ] Invalid provider is rejected
---
## Risks and Mitigations
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Single-quote escaping fails for exotic prompts | Low | High (syntax error in .sh) | `bash -n` check before commit; warn + skip on failure |
| User has many aliases -> list becomes long | Low | Low | `menu_pick` already supports filtering |
| `.bashrc` source line conflicts with existing alias definitions | Very Low | Medium | Grep-check before adding; line is a conditional source, not an alias definition |
| `make lint` rejects the new tool for a convention violation | Low | Low (blocking) | Follow template exactly; deps guards before help; proper header |
---
**End of architecture document.**
@@ -0,0 +1,223 @@
# Architecture: `pos ai alias` — robust activation mechanism
**Date:** 2026-08-26
**Author:** Architect (ox-alpha)
**Status:** DECISION_READY (supersedes activation decisions in `AgentsReport/architect/2026-08-26_ai-alias-architecture.md`)
---
## TL;DR
| Decision | Choice |
|----------|--------|
| D1 — Activation artifact | **Option B**: executable wrapper scripts at `~/.local/bin/<name>`, generated from the ENV source of truth. No shell aliases. |
| D2 — Migration | Internal `_alias_sync()` runs on every `pos ai alias` invocation; reconciles wrappers ↔ ENV both directions; legacy `ai-aliases.sh` auto-removed when marker-guarded, else manual guidance. `.sh` generation stops entirely (no deprecated shim). |
| D3 — Edge cases | Name collisions refused (foreign file or other binary on PATH); empty set → sync deletes all owned wrappers; dead provider → existing `pos-ai:121` error is sufficient; list/show formats unchanged, show gains wrapper path. |
| D4 — Scope fence | Only `bin/pos-ai-alias` + small marker-scan addition to `bin/pos-system-uninstall` + docs. NOT: `bin/pos-ai`, ENV format, config-ui, postinstall.sh, menu flows. |
| Postinstall gap | The deferred `.bashrc` wiring is closed by **obsolescence**, not implementation — no wiring needed anymore. |
**Open items:** None blocking. One provisional nuance in D2 (legacy-file heuristic guard) flagged inline.
---
## Problem statement
- Aliases were frozen at source-time: editing provider gemini→openrouter left the stale alias live in running shells → Gemini 429 while `list` showed openrouter (live failure).
- Activation required a `.bashrc` source line that postinstall never wired — the feature was broken-by-omission even before staleness.
- Non-interactive contexts (cron, scripts, ssh non-login) could never use aliases at all.
## Evidence base
- `bin/pos-ai-alias:62-104` — current `_alias_regen()` writes `ai-aliases.sh`; success messages at :308 say "Reload shell: source ~/.bashrc".
- `postinstall.sh:80` — PATH export already includes `$HOME/.local/bin` on pos-managed machines (Debian default `~/.profile` also adds it when the dir exists).
- `bin/pos-system-uninstall:96,261` — established precedent for marker-managed user-local binaries (`$HOME/.local/bin/pos-ai-hook.sh`): discovery list + removal pass already exist as a pattern.
- `bin/pos-ai:658-665, :118-121` — provider resolution flag > env > default; unknown provider yields `err "Unknown provider '$p' — available: …"` (good runtime failure quality, no pos-ai change needed).
- Prior architect report D5/D10 chose `.sh` + `.bashrc` wiring; the wiring was never implemented. This report supersedes those two decisions; storage (ENV format) and CRUD UX decisions carry forward unchanged.
---
## Decision 1: Activation artifact
### Options evaluated
**Option A: keep generated bash aliases + wire `.bashrc` + louder hints + unalias guards**
- Advantages: smallest diff; familiar alias UX.
- Costs: staleness is *inherent* — the artifact is a snapshot copied into each shell at source time; the live failure (stale gemini alias) can only be mitigated, never eliminated. Requires postinstall `.bashrc` wiring (the deferred gap), reload-hint UX that demonstrably fails ("users miss it"), and per-shell unalias guard logic for a mechanism bash makes awkward to retract.
- Risks: cron/scripts/ssh non-interactive shells get nothing. Two truths (env file vs sourced copy) persist forever.
- Compatibility impact: none. Operational impact: permanent "did you re-source?" support burden.
**Option B: executable wrapper scripts at `~/.local/bin/<name>`**
- Architecture: ENV stays the single source of truth; tool renders one standalone script per alias:
```bash
#!/usr/bin/env bash
# Managed by pos ai alias — regenerated automatically; hand-edits are overwritten.
# Alias: assist | provider: openrouter | session: assist
set -euo pipefail
exec pos ai openrouter ask --session assist --system <printf-%q-prompt> "$@"
```
(empty prompt → omit `--system`; prompt embedded via existing double-layer `printf %q` mechanics, reused from `_alias_quote_cmd`, so it lands as exactly ONE shell word; `"$@"` passes user args through).
- Advantages: **staleness eliminated** — next invocation reads current file bytes; no shell integration of any kind (kills the postinstall gap instead of closing it); works identically in interactive shells, scripts, cron, ssh non-login; removal = delete one marker-identified file; no INTERACTIVE_CMDS/tee-pipe interaction changes; content edits bypass `hash` caching entirely (bash caches paths, not contents) and new names are found on first PATH scan.
- Costs: PATH-presence dependency (`~/.local/bin` must be on PATH — already guaranteed by `postinstall.sh:80` and Debian default `~/.profile`, but needs a runtime check + guidance); collision policy needed (scripts are filesystem entries, aliases weren't); ~40 lines more logic than Option A.
- Risks: name shadows a real binary → mitigated by refusal policy (D3); user hand-edits wrapper → healed by sync regeneration (D2), and the header says so.
- Compatibility impact: activation semantics change (documented). Operational impact: self-healing artifacts; zero shell-config coupling. Migration impact: handled by D2.
**Option C: hybrid — wrappers primary + optional still-generated alias file**
- Advantages: covers users attached to aliases.
- Costs: keeps the stale-snapshot mechanism alive alongside the fix — two activation paths, two truth-drift surfaces, double the validation matrix. Directly contradicts the motivation ("more robust" = fewer failure modes, not one more).
- Risks: the exact reported bug remains reachable through the optional path.
### Decision: Option B
Staleness was an architectural property of source-time snapshots, not an implementation bug — no amount of hints or guards fixes Option A. Option B removes the class of bug (artifact always equals source of truth at invocation time) and deletes the deferred `.bashrc` wiring requirement rather than implementing it. Option C preserves the bug class for zero new capability. Smallest robust design wins.
**Artifact specification (binding for Builder):**
| Property | Value |
|----------|-------|
| Location | `${HOME}/.local/bin/<name>` |
| Permissions | `0755` |
| Ownership marker | Line 2 contains literal `Managed by pos ai alias` (grep target for all ownership checks) |
| Body | `set -euo pipefail` + single `exec pos ai <provider> ask --session <session>[ --system <%q prompt>] "$@"` |
| Quoting | Reuse `_alias_quote_cmd` verbatim (double-layer `%q` mechanics preserved per brief) |
| Atomic write | mktemp in same dir → `mv` → `chmod 755` (same pattern as current `_alias_regen`) |
| Pre-commit validation | `bash -n` on rendered wrapper; on failure warn + keep previous file |
| Secrets | None inside (prompt is content, not credential) |
[DECIDED]
## Decision 2: Migration & back-compat
### Regeneration trigger: `_alias_sync()` on every invocation
New internal function, called at the top of **every** subcommand dispatch entry (`create`, `edit`, `remove`, `list`, `show`, interactive menu) before the subcommand's own logic. It reconciles `~/.local/bin` against the ENV file in both directions:
1. For each ENV entry: render expected wrapper content; if target is missing **or differs byte-wise** → atomically install. This means:
- first run after upgrade materializes wrappers for all existing aliases (one-time migration happens on any command, including a harmless `list`);
- every create/edit/remove leaves artifacts consistent by construction;
- hand-edited or half-deleted wrappers are silently healed (idempotent, cheap for realistic alias counts).
2. Every executable in `~/.local/bin` bearing our marker whose name is **not** in ENV → deleted (covers remove, covers manual ENV edits, covers the empty-set case).
3. If ≥1 wrapper exists/installed and `$HOME/.local/bin` is absent from `$PATH` → loud `warn` with copy-paste fix (`export PATH="$HOME/.local/bin:$PATH"` + persist to `~/.profile`). Wrappers are still written regardless.
4. Legacy handling (below).
No public `sync` subcommand: every subcommand already syncs, so an explicit one adds surface without capability. `POS_SUBCMDS` header stays `create edit remove list show`.
### Existing `ai-aliases.sh`: stop writing entirely — no deprecated shim
A shim keeps two activation truths alive, and the sourced-alias-still-shadows-wrapper scenario is precisely the reported failure mode (in interactive bash, aliases take precedence over PATH lookups). The `.sh` artifact must die, not fade.
On detecting `SH_FILE`, sync emits a warning block explaining that activation moved to `~/.local/bin/<name>` scripts and that stale sourced aliases shadow them until cleaned. Then:
- **If line 13 of the file carry our generator marker** (`Auto-generated by pos ai alias`) → auto-remove the file and print remediation for *running* shells: an `unalias <names>` line with names extracted from the `.sh` contents themselves (the stale file inventories its own definitions — including names no longer in ENV), plus "or simply start a new shell". Auto-remove is safe because (a) the file is regenerable output, not user data, (b) postinstall never shipped the source line, so nothing references it at startup, and (c) the conditional-source idiom (`[ -f ] && source`) tolerates absence even if a user wired it manually.
- **If the marker does not match** (foreign/hand-built file) → leave untouched; advise manual review. Never delete files we didn't generate — same policy as wrapper collisions.
*[PROVISIONAL nuance]* The auto-remove guard currently checks only the generator-marker header; a user who appended private aliases into our generated file would lose them on upgrade-migration. Accepted risk: the file header says "do not hand-edit", likelihood is low, and the alternative (parsing full-file provenance) buys complexity the requirement doesn't need. Revisit only if a real case appears.
### What carries over unchanged
- `ai-aliases.env` format, location, chmod 600, comment conventions — untouched. Previously created aliases migrate with zero data conversion.
- `pos config` compatibility: no env-key semantics touched.
- All menu flows, name regex, non-tty guard behavior.
[DECIDED]
## Decision 3: Edge cases & subcommand semantics
### Name collisions with real binaries — refuse
Create-time check order (after existing ENV-duplicate redirect to `edit`):
1. `$HOME/.local/bin/<name>` exists **with** marker → not a collision; sync will overwrite (regeneration path).
2. `$HOME/.local/bin/<name>` exists **without** marker → refuse: `err "File '~/.local/bin/<name>' already exists and was not created by pos ai alias — pick another name"`. Never silently overwrite foreign files.
3. `command -v <name>` resolves to anything else on PATH (`ls`, `git`, `gcc`, …) → refuse with the conflicting path named.
No override flag. Shadowing an arbitrary binary is never a legitimate intent for an *alias* feature, a refusal error costs one rename, and a `--force` surface invites exactly the "surprise factor" this rework is meant to remove. Edit cannot collide (name is the record key); rename remains remove+create (Designer out-of-scope list already excludes renaming).
### Empty result set → wrappers fully retracted
With zero ENV entries, sync deletes every marker-bearing wrapper in `~/.local/bin`. `list` prints `Aliases (0):` as today. No empty husks left behind.
### Provider adapter deleted later → runtime failure is already good enough
Wrapper execs `pos ai <provider> …`; if the adapter vanished, `bin/pos-ai:121` errors: `Unknown provider '<p>' — available: gemini openrouter`. Actionable, names valid alternatives, zero changes to `pos-ai`. Sync does **not** prune wrappers whose provider directory entry disappeared (ENV is truth for existence; a temporarily missing adapter shouldn't silently eat user config). The provider picker at edit time only offers installed providers, so edit is the natural repair path.
### Subcommand semantics under Option B
| Subcommand | Change |
|------------|--------|
| `list` | Format unchanged; runs after sync so it always reflects disk truth |
| `show <name>` | Adds one line: `Wrapper: ~/.local/bin/<name>` (or `(not installed)` if PATH check failed) |
| `create` | Gains collision refusals above; success message replaces "Reload shell: source ~/.bashrc" with `Available immediately: ~/.local/bin/<name>` (+ PATH warning when applicable) |
| `edit` | Unchanged flow; on save, sync refreshes the wrapper — change is live on next invocation (this kills the reported bug) |
| `remove` | Unchanged confirm(default=n); success message notes the script was deleted from `~/.local/bin`; add hint that running shells may need `hash -r` only if the name still autocompletes stale (rare; bash normally re-scans when a hashed file vanishes) |
| menu / `-h` | Help text updated: activation = executable scripts in `~/.local/bin`, no sourcing required |
[DECIDED]
---
## Decision 4: Scope fence for Builder
### Approved outcome
Alias activation via marker-managed wrapper scripts in `~/.local/bin`, synced against `ai-aliases.env` on every invocation, with legacy `.sh` auto-retirement.
### In-scope components/files
| File | Allowed changes |
|------|-----------------|
| `bin/pos-ai-alias` | Replace `_alias_regen()` with `_wrapper_path()` + `_wrapper_render()` + `_alias_sync()`; keep `_alias_quote_cmd` mechanics verbatim; add `_alias_check_path()`; wire sync into all dispatch entries; collision checks in `_alias_create`; message deltas in create/edit/remove/show/usage; legacy `.sh` retirement block; SH_FILE constant retained solely for migration detection |
| `bin/pos-system-uninstall` | Add marker-scan of `~/.local/bin` (grep for `Managed by pos ai alias`) to the discovery list (~line 96 area) and removal pass (~line 261 area), mirroring the existing `pos-ai-hook.sh` pattern — closes uninstall hygiene |
| `DOC/POS.md` | `ai alias` section: activation semantics, subcommand table unchanged otherwise (hand-maintained file) |
| `DOC/HOWTO.md` / relevant howto | Row/section wording update if it mentions sourcing/reload |
| `AGENT_TODO.md` | Move task to Done (dated) in same commit |
### Must NOT change
- `bin/pos-ai` — any file byte.
- `ai-aliases.env` format, fields, header comments, chmod 600.
- `lib/common.sh`, `lib/menu-lib.sh`, `lib/config-ui.sh`.
- `postinstall.sh` — the deferred `.bashrc` wiring stays unimplemented by design (obsoleted, not added).
- `# POS:` / `# POS_SUBCMDS:` headers (description and subcommand set unchanged → no gen churn beyond none).
- Menu structure, step counts, name regex, non-tty guard behavior (Designer spec remains authoritative).
- Other categories' tools; entertainment plugins; completions (no flag/subcmd changes).
### Architectural constraints
1. Atomic writes only (mktemp+mv), never in-place truncation of live wrappers.
2. Ownership established exclusively via the line-2 marker string; never delete/overwrite files failing the marker test.
3. All output discipline per Designer spec: tables/results stdout, display/warnings stderr (`log`/`warn`/`err`).
4. Sync must be idempotent and safe to run concurrently-lossy (single-user tool: last write wins, no locking).
5. Wrapper body contains no secrets and no absolute paths except the `pos` lookup by name (PATH-resolved, consistent with old aliases).
### Required verification (adversarial where it matters)
1. `bash -n bin/pos-ai-alias`; `make gen && make check && make lint` ending `0 FAIL, 0 WARN`.
2. **Quoting round-trip through the NEW artifact**: prompts containing `'`, `"`, backtick, `$()`, `%`, `\`, unicode, leading/trailing spaces → create each; execute wrapper under a stubbed `pos` shim on a temp PATH capturing argv; assert `--system` arrives as exactly one intact word and passthrough args (`assist "hi there"`) append correctly.
3. **Staleness kill-test**: create `assist`(gemini) → run wrapper via shim → edit provider→openrouter → run again → argv shows openrouter with **no shell reload** (the regression test for the live failure).
4. Sync idempotency: two consecutive runs → byte-identical artifacts, mtimes stable second run.
5. Orphan retraction: delete an ENV line manually → next `pos ai alias list` removes that wrapper; empty ENV → zero owned wrappers remain.
6. Collision tests: foreign file at `~/.local/bin/<name>` → refused; marker file → refreshed; `command -v` conflict (e.g. `gcc`) → refused with path named.
7. Legacy migration: plant prior-generator-format `ai-aliases.sh` with stale `alias assist=…gemini…` → any subcommand removes it, prints `unalias assist` remediation; plant foreign-content file → untouched, warned.
8. PATH-absent: strip `$HOME/.local/bin` from PATH → loud warn, wrappers still written.
9. Non-tty: `pos ai alias` (menu) still fails cleanly via `menu_guard`.
10. Dead-provider runtime: wrapper pointing at removed adapter produces `pos-ai:121` available-providers error (assert message quality manually once).
### Explicitly out of scope
Rename operation; multi-line prompt input; alias import/export; public `sync` subcommand; completion headers; `pos config` integration; systemd/cron integration examples beyond help text.
### Open risks
- Users who sourced `ai-aliases.sh` into `.bashrc` manually keep a dead reference — harmless under the conditional-source idiom; warning text covers it.
- `~/.local/bin` absent from PATH in exotic shells (non-login ssh without postinstall) — mitigated by persistent warning + fix line.
[DECIDED]
---
## Builder-ready step order
1. Core rewrite in `bin/pos-ai-alias`: `_wrapper_path`, `_wrapper_render` (reuse `_alias_quote_cmd`; marker line 2; `set -euo pipefail`; `exec … "$@"`), `_alias_check_path`, `_alias_sync` (render-diff-install, orphan sweep, legacy block). Delete `_alias_regen` body (keep SH_FILE constant for migration).
2. Wire `_alias_sync` into every dispatch entry before subcommand logic.
3. Create-flow collision refusals (marker-aware, `command -v` check) + success-message swap ("Available immediately", drop reload hints everywhere including menu flows).
4. Edit/remove/show/list/usage deltas per D3 table.
5. `bin/pos-system-uninstall`: marker-scan additions in discovery + removal passes.
6. Full verification suite (D4 list) — quoting round-trip and staleness kill-test are the acceptance gates.
7. Docs (`DOC/POS.md`, HOWTO row) + `AGENT_TODO.md` Done entry; conventional commit (`feat:` or `fix:`).
Recommended next agent: **Builder** — scope is fully determined; no architectural choices remain. Suggest a Reviewer pass afterward focused on the marker-guard logic (the only place where a bug could delete/overwrite a foreign file).
Architect changes: this report only.
@@ -0,0 +1,808 @@
# Architect Report — Self-Describing Command Registry for POS
**Date:** 2026-08-26
**Status:** DECISION_READY
---
## TL;DR
- **Decision:** Add a thin `lib/registry.sh` library that parses `# POS_*:` headers into a queryable API; two new optional headers (`# POS_DEPS:`, `# POS_EXAMPLES:`) extend existing conventions; no framework, no new abstraction layer.
- **Key insight:** The existing header system is already 80% of a registry. The missing piece is a shared parsing library so every consumer stops reimplementing `sed` + `grep` header reading.
- **Scope:** `lib/registry.sh` (new), `scripts/gen-docs.sh` (extend), `bin/pos-tree` (use registry), `templates/pos-tool.sh` (document new headers), docs. All existing tools keep working unchanged.
- **Migration:** Zero-downtime — new headers are optional. Tools add `# POS_DEPS:` and `# POS_EXAMPLES:` incrementally. Registry degrades gracefully when headers are absent.
- **Open items:** `pos help` and `pos menu` are future consumers (not in this phase). Dashboard is out of scope.
---
## Decision 1: Header Format — Extend, Don't Replace
### Problem
The spec asks for dependencies, curated examples, and a shared query API. Currently, each consumer (`pos tree`, `pos <category> --help`, `gen-docs.sh`, `pos config`) independently implements `sed`/`grep` header parsing with its own logic.
### Decision
Add two new optional `# POS_*:` header lines. Keep all existing headers unchanged.
### Exact Syntax
```bash
# POS_DEPS: <binary1> [binary2 ...]
# POS_EXAMPLES: <command> | <description>
```
**Rules:**
- All new headers are **optional** — tools that don't declare them simply won't expose that metadata. Progressive metadata preserved.
- `# POS_DEPS:` is space-separated binary names (what `command -v` checks, not apt package names).
- `# POS_EXAMPLES:` can appear on multiple lines — each is `<command> | <description>` (pipe-delimited, max one `|`).
- Headers must appear between the shebang/strict-mode block (lines 16) and the first non-comment line.
- Gen-docs and lint only parse headers from the first ~10 lines of each file.
**Existing headers (unchanged):**
```bash
# POS: <category> <command> — <description>
# POS_FLAGS: --flag1 --flag2
# POS_SUBCMDS: sub1 sub2 sub3
# POS_CONFIG: <scope> | <env-file> | <KEY>=<flags>:<desc> | ...
```
**New headers (optional):**
```bash
# POS_DEPS: docker nmap jq
# POS_EXAMPLES: pos network scan 192.168.1.0/24 | Scan a /24 CIDR
# POS_EXAMPLES: pos network scan 10.0.0.0/28 --full | Full scan with OS detection
```
### Example: Minimal Tool (No Change Needed)
```bash
#!/usr/bin/env bash
set -euo pipefail
# POS: ssh load-keys — Load all SSH keys into the agent
# ... rest of script
```
### Example: Rich Tool
```bash
#!/usr/bin/env bash
set -euo pipefail
# POS: network download — aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits)
# POS_SUBCMDS: start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch restart retry replace menu
# POS_FLAGS: --dir --out --split --seed --force --upload --gid --tmux
# POS_DEPS: aria2c jq curl
# POS_EXAMPLES: pos network download add https://example.com/file.zip | Download a file
# POS_EXAMPLES: pos network download status | Show download queue status
# POS_EXAMPLES: pos network download --tmux start aria2 daemon with live view
```
### Rationale
| Option | Architecture | Advantages | Costs | Risks | When to choose |
|--------|-------------|------------|-------|-------|----------------|
| **A: Extend existing headers** | Add `# POS_DEPS:` and `# POS_EXAMPLES:` alongside existing headers | Backward compatible, zero migration cost, progressive metadata, consistent with existing patterns | Two new header formats to parse | Low — optional headers degrade gracefully | **Chosen** — smallest sufficient design |
| B: Unified YAML frontmatter | Replace all `# POS_*:` with a YAML block in each file | Richer structure, easier to extend | Breaks all existing consumers, requires migration of 40+ tools, adds YAML dependency | High — YAML parser availability in bash, migration burden | Only if the header system were fundamentally inadequate |
| C: Separate registry file | `registry/<tool>.yaml` per tool | Clean separation, richer metadata | Duplicates what headers already provide, extra files to maintain, sync risk between header and registry | Medium — source of truth drift | Only if headers couldn't hold the metadata |
**Option A wins** because the existing header system already works, is already the source of truth for gen-docs output, and the new metadata (deps, examples) fits naturally into the comment-header format.
### Constraints for Builder
- `# POS_DEPS:` line: `sed -n '/^# POS_DEPS: /{s/^# POS_DEPS: //;p;q}' <file>` — space-separated tokens.
- `# POS_EXAMPLES:` lines: `grep '^# POS_EXAMPLES:' <file | sed 's/^# POS_EXAMPLES:[[:space:]]*//'` — one per line, `|`-delimited command|description.
- The `# POS:` header line **must remain the first metadata line** after shebang/strict-mode. New headers go after existing headers, before any code.
[DECIDED]
---
## Decision 2: Registry Library — `lib/registry.sh`
### Problem
Four consumers independently parse tool headers with their own `sed`/`grep` patterns:
- `bin/pos` `_pos_category_help()` (lines 68123): reads `# POS:` and `# POS_SUBCMDS:` per tool
- `bin/pos-tree` (lines 4768): reads `# POS:` and `# POS_SUBCMDS:` per tool
- `scripts/gen-docs.sh` (lines 3046): reads `# POS:`, `# POS_FLAGS:`, `# POS_SUBCMDS:`, `# POS_CONFIG:` per tool
- `lib/config-ui.sh` (lines 5057): reads `# POS_CONFIG:` per tool
Each reimplements the same header-reading pattern. Adding new headers means updating every consumer.
### Decision
Create `lib/registry.sh` — a thin library (target ~180 lines) that provides a shared API for querying tool metadata from `# POS_*:` headers.
### Data Structures
All data lives in bash associative arrays and indexed arrays, populated by a single `reg_scan` call.
```bash
# Indexed array — all tool keys, sorted (LC_ALL=C)
declare -a _reg_tools=()
# Associative arrays — keyed by tool key (e.g., "network-download", "config")
declare -A _reg_cat=() # tool → category ("" for category-less)
declare -A _reg_desc=() # tool → description (text after "— ")
declare -A _reg_flags=() # tool → raw POS_FLAGS value
declare -A _reg_subcmds=() # tool → raw POS_SUBCMDS value
declare -A _reg_deps=() # tool → raw POS_DEPS value
declare -A _reg_examples=() # tool → newline-joined POS_EXAMPLES lines
# Config is special: multiple headers per tool, multiple fields per header.
# Stored as pipe-delimited lines keyed by scope (not tool).
declare -a _reg_config_scopes=() # unique scope names, sorted
declare -A _reg_config_keys=() # scope → newline-joined key|flags|desc lines
```
**Why not store everything in one mega-array?** Bash associative arrays can't hold structured records. Separate arrays per field keep lookups O(1) and code readable.
### Tool Key Convention
Tool keys match the existing filename convention:
- `bin/pos-network-download` → key `network-download`, category `network`
- `bin/pos-config` → key `config`, category `""` (category-less)
- `bin/pos-ai-alias` → key `ai-alias`, category `ai`
### API
```bash
# ── Initialization ──────────────────────────────────────────────
reg_scan [dir]
Scan all pos-* files in dir (default: auto-detect from BASH_SOURCE).
Populates _reg_tools, _reg_cat, _reg_desc, _reg_flags, _reg_subcmds,
_reg_deps, _reg_examples, _reg_config_scopes, _reg_config_keys.
Must be called before any other reg_* function.
Uses LC_ALL=C for deterministic sort.
# ── Discovery ───────────────────────────────────────────────────
reg_list
Echo sorted list of all tool keys, one per line.
reg_categories
Echo sorted unique category names (empty string for category-less tools).
reg_tools_in <category>
Echo sorted tool keys belonging to <category>.
Pass "" for category-less tools.
# ── Lookup ──────────────────────────────────────────────────────
reg_lookup <tool> <field>
Echo a field's value for a tool. Fields:
cat, desc, flags, subcmds, deps, examples
Returns empty string if field not set or tool not found.
Exit code: 0 if tool found, 1 if not.
reg_config_scopes
Echo sorted list of unique config scope names.
reg_config_keys <scope>
Echo key|flags|description lines for a scope (newline-delimited).
reg_config_envfile <scope>
Echo the env-file basename for a scope.
Exit code: 0 if found, 1 if not.
# ── Iteration ───────────────────────────────────────────────────
reg_each <callback>
Call <callback> for each tool, passing:
<callback> <category> <tool_key> <description>
Category is empty for category-less tools.
# ── Convenience (for common patterns) ──────────────────────────
reg_tool_exists <tool>
Exit 0 if tool is registered, 1 otherwise.
reg_tools_for_category <category>
Alias for reg_tools_in. Kept for clarity.
```
### Source Pattern
```bash
# lib/registry.sh — no shebang (library, not executable)
# Sourced opt-in by consumers that need tool metadata.
# Common.sh helpers (guarded fallback — mirrors lib/config-ui.sh pattern)
declare -F log >/dev/null || log() { echo "[+] $*"; }
declare -F warn >/dev/null || warn() { echo "[!] $*"; }
declare -F err >/dev/null || err() { echo "ERROR: $*" >&2; exit 1; }
# ── Tool directory detection ────────────────────────────────────
# Repo: lib/registry.sh → ../bin
# Install: /usr/local/bin/registry.sh → /usr/local/bin (same dir)
_reg_tools_dir() {
local dir
dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../bin" 2>/dev/null && pwd)"
if [ -d "$dir" ] && ls "$dir"/pos-* &>/dev/null; then
echo "$dir"
else
dirname "${BASH_SOURCE[0]}"
fi
}
# ── Data stores ─────────────────────────────────────────────────
declare -a _reg_tools=()
declare -A _reg_cat=()
declare -A _reg_desc=()
declare -A _reg_flags=()
declare -A _reg_subcmds=()
declare -A _reg_deps=()
declare -A _reg_examples=()
declare -a _reg_config_scopes=()
declare -A _reg_config_keys=()
# ── reg_scan ────────────────────────────────────────────────────
reg_scan() {
local dir="${1:-$(_reg_tools_dir)}" f
local old LC_ALL_PREV="$LC_ALL"
export LC_ALL=C
_reg_tools=()
# Clear all associative arrays
for key in "${!_reg_cat[@]:-}"; do
unset "_reg_cat[$key]" "_reg_desc[$key]" "_reg_flags[$key]"
unset "_reg_subcmds[$key]" "_reg_deps[$key]" "_reg_examples[$key]"
done
_reg_config_scopes=()
for scope in "${!_reg_config_keys[@]:-}"; do
unset "_reg_config_keys[$scope]"
done
local -A scope_seen=()
for f in "$dir"/pos-*; do
[ -x "$f" ] || continue
local name="${f##*/pos-}"
local key cat sub
if [[ "$name" == *-* ]]; then
cat="${name%%-*}"
sub="${name#*-}"
else
cat=""
sub="$name"
fi
key="$sub"
_reg_tools+=("$key")
_reg_cat["$key"]="$cat"
# POS: — description (text after first "— ")
local pos_line
pos_line="$(sed -n '/^# POS: /{s/^# POS: //;p;q}' "$f" 2>/dev/null)"
_reg_desc["$key"]="${pos_line#*— }"
# POS_FLAGS:
_reg_flags["$key"]="$(sed -n '/^# POS_FLAGS: /{s/^# POS_FLAGS: //;p;q}' "$f" 2>/dev/null)"
# POS_SUBCMDS:
_reg_subcmds["$key"]="$(sed -n '/^# POS_SUBCMDS: /{s/^# POS_SUBCMDS: //;p;q}' "$f" 2>/dev/null)"
# POS_DEPS:
_reg_deps["$key"]="$(sed -n '/^# POS_DEPS: /{s/^# POS_DEPS: //;p;q}' "$f" 2>/dev/null)"
# POS_EXAMPLES: (may appear multiple times — join with newlines)
local examples=""
examples="$(sed -n '/^# POS_EXAMPLES: /{s/^# POS_EXAMPLES: //;p}' "$f" 2>/dev/null)"
_reg_examples["$key"]="$examples"
# POS_CONFIG: (may appear multiple lines per file)
local line
while IFS= read -r line; do
[ -n "$line" ] || continue
line="${line#*POS_CONFIG:}"
local scope="${line%%|*}"
scope="${scope// }"
[ -n "$scope" ] || continue
_reg_config_keys["$scope"]+="${_reg_config_keys[$scope]:+$'\n'}$line"
if [ -z "${scope_seen[$scope]:-}" ]; then
scope_seen["$scope"]=1
_reg_config_scopes+=("$scope")
fi
done < <(grep '^# POS_CONFIG:' "$f" 2>/dev/null || true)
done
# Sort tools
mapfile -t _reg_tools < <(printf '%s\n' "${_reg_tools[@]}" | sort)
# Sort config scopes
mapfile -t _reg_config_scopes < <(printf '%s\n' "${_reg_config_scopes[@]}" | sort -u)
export LC_ALL="$LC_ALL_PREV"
}
# ── Discovery ───────────────────────────────────────────────────
reg_list() { printf '%s\n' "${_reg_tools[@]}"; }
reg_categories() {
local -A cats=()
local t
for t in "${_reg_tools[@]}"; do
cats["${_reg_cat[$t]}"]=1
done
printf '%s\n' "${!cats[@]}" | sort
}
reg_tools_in() {
local cat="$1" t
for t in "${_reg_tools[@]}"; do
[ "${_reg_cat[$t]}" = "$cat" ] && echo "$t"
done
}
# ── Lookup ──────────────────────────────────────────────────────
reg_lookup() {
local tool="$1" field="$2"
case "$field" in
cat) echo "${_reg_cat[$tool]:-}" ;;
desc) echo "${_reg_desc[$tool]:-}" ;;
flags) echo "${_reg_flags[$tool]:-}" ;;
subcmds) echo "${_reg_subcmds[$tool]:-}" ;;
deps) echo "${_reg_deps[$tool]:-}" ;;
examples) echo "${_reg_examples[$tool]:-}" ;;
*) return 1 ;;
esac
}
reg_config_scopes() { printf '%s\n' "${_reg_config_scopes[@]}"; }
reg_config_keys() {
local scope="$1"
echo "${_reg_config_keys[$scope]:-}"
}
reg_config_envfile() {
local scope="$1" line
line="$(echo "${_reg_config_keys[$scope]:-}" | head -1)"
[ -n "$line" ] || return 1
line="${line#*|}" # drop scope
local env="${line%%|*}"
echo "${env// }"
}
# ── Iteration ───────────────────────────────────────────────────
reg_each() {
local cb="$1" t
for t in "${_reg_tools[@]}"; do
"$cb" "${_reg_cat[$t]}" "$t" "${_reg_desc[$t]}"
done
}
# ── Convenience ─────────────────────────────────────────────────
reg_tool_exists() {
[ -n "${_reg_desc[$1]+x}" ]
}
```
### Rationale
| Option | Architecture | Advantages | Costs | Risks | When to choose |
|--------|-------------|------------|-------|-------|----------------|
| **A: Regenerate-on-source library** | `reg_scan` parses all files into bash arrays on first call | O(1) lookups after scan, no external deps, works from /usr/local/bin, bash-native | ~180 lines of code, scan cost at startup (~5ms for 40 tools) | Low — scan is fast enough for interactive use | **Chosen** — matches project's bash-only, no-framework philosophy |
| B: Cached JSON file | `make gen` produces `registry.json`, consumers parse with `jq` | Fast lookups, rich queries | Requires `jq` at runtime (currently a dep, but adds coupling), extra build step, staleness risk | Medium — JSON dependency for all consumers | Only if performance of header parsing became a bottleneck (it won't for 40 tools) |
| C: Per-tool .meta files | Each tool has a sidecar `pos-<tool>.meta` | Clean separation, rich format | Duplicates header data, extra files to maintain, sync drift risk | Medium — two sources of truth | Only if headers were fundamentally limited |
**Option A wins** because:
1. Headers are already the source of truth — no sync risk.
2. The scan takes ~5ms for 40 tools — no performance concern.
3. Works from `/usr/local/bin/` (all libs live there after install).
4. Matches the project's "no unnecessary framework" philosophy.
5. Follows the pattern established by `lib/config-ui.sh`.
### Source Chain
```bash
# In any consumer:
source "$(dirname "$0")/../lib/registry.sh" 2>/dev/null || source "$(dirname "$0")/registry.sh"
reg_scan
# ... use reg_list, reg_lookup, etc.
```
[DECIDED]
---
## Decision 3: Consumer Integration
### Problem
Four consumers need to use the registry. Each has different requirements:
- `bin/pos-tree` needs tree-building from tool data
- `bin/pos` `_pos_category_help()` needs category → tool listing
- `scripts/gen-docs.sh` needs all metadata for code generation
- `lib/config-ui.sh` needs config scopes and keys
### Decision
Migrate consumers to `lib/registry.sh` in this order (highest value first):
#### 3.1: `bin/pos-tree` (Priority: HIGH)
**Current state** (lines 4768): Scans `pos-*` files independently, reads `# POS:` and `# POS_SUBCMDS:` via `sed`.
**After migration:**
```bash
source "$(dirname "$0")/../lib/registry.sh" 2>/dev/null || source "$(dirname "$0")/registry.sh"
reg_scan
# Build tree from registry instead of scanning files
for tool in $(reg_list); do
cat="$(reg_lookup "$tool" cat)"
desc="$(reg_lookup "$tool" desc)"
deps="$(reg_lookup "$tool" deps)"
# ... add to tree, annotate with deps if present
done
```
**Enhancement:** When `# POS_DEPS:` is present, show it in the tree view:
```
├── docker ps # Enhanced container overview (health, IPs, ports, uptime)
│ [deps: docker]
```
This is the highest-value consumer change — it proves the registry works at runtime and shows the immediate benefit of new metadata.
**Implementation note:** Keep the existing `add()` / `render()` tree-building logic. Replace only the data-collection loop (lines 4768) with registry calls. The tree structure is already correct from filenames + `POS_SUBCMDS`.
#### 3.2: `scripts/gen-docs.sh` (Priority: HIGH)
**Current state** (lines 3046): Collects tools into a pipe-delimited array via direct `sed` calls.
**After migration:**
```bash
# In the tools collection loop, use registry for new fields
# Keep the existing collection pattern for backward compatibility
# (gen-docs.sh has its own sorting and rendering logic)
# Add deps and examples to the tools array format
tools+=("$cat|$sub|$desc|$flags|$subcmds|$deps|$examples")
```
**New gen blocks:**
- Add `deps` and `examples` columns to `gen_tree()`, `gen_dispatch()`, `gen_filetable()`.
- These become visible in `DOC/AGENT_Context_Project.md` once tools add the new headers.
**Important:** The existing `gen_*` functions use their own `tools` array (not the registry) because they need specific formatting. The registry provides the raw data; gen-docs formats it. This avoids tight coupling between the generator and the library.
**Alternative considered:** Have gen-docs source the registry directly. Rejected because gen-docs needs the data in a specific format (pipe-delimited array) and the registry's data structure is an implementation detail. Keeping the data flow explicit (`registry → gen-docs tools array → gen_* functions`) is cleaner.
#### 3.3: `bin/pos` `_pos_category_help()` (Priority: MEDIUM)
**Current state** (lines 68123): Scans `pos-<cat>-*` files, reads `# POS:` and `# POS_SUBCMDS:` per file.
**After migration:**
```bash
_pos_category_help() {
local cat="$1"
source "$(dirname "$0")/../lib/registry.sh" 2>/dev/null || source "$(dirname "$0")/registry.sh"
reg_scan
echo "pos $cat$cat tools"
echo
echo "USAGE"
echo " pos $cat <command> [args]"
echo
echo "COMMANDS"
for tool in $(reg_tools_in "$cat"); do
local desc deps
desc="$(reg_lookup "$tool" desc)"
deps="$(reg_lookup "$tool" deps)"
printf ' %-28s%s' "$tool" "$desc"
[ -n "$deps" ] && printf ' [deps: %s]' "$deps"
echo
# ... subcommands from reg_lookup "$tool" subcmds
done
echo
echo "Run 'pos $cat <command> --help' for details on a command."
exit 0
}
```
**Trade-off:** This adds a `reg_scan` call every time `pos <category>` runs. For 40 tools, the scan takes ~5ms — negligible for interactive use. If profiling shows this matters, `pos` could cache the scan in a temp file (but this is premature optimization).
**Alternative considered:** Keep `_pos_category_help()` using direct `sed` (no registry dependency). Rejected because the whole point is to centralize header parsing. The 5ms scan cost is acceptable.
#### 3.4: `lib/config-ui.sh` (Priority: LOW — separate decision)
**Current state** (lines 50203): Has its own header parsing for `# POS_CONFIG:` and `# POS_KEYS:`. The config library is already a mature, working abstraction.
**Decision: Do NOT integrate config-ui.sh with the registry in this phase.**
**Rationale:**
1. config-ui.sh already works correctly and is well-tested.
2. Its parsing is specialized (multi-line key fields, `*plugins` expansion, `*providers` expansion).
3. Integrating it with the registry would require the registry to handle all config-ui's edge cases, bloating the library.
4. config-ui.sh's `cfg_headers()` / `cfg_scopes()` / `cfg_scope_keys()` API is already the "registry" for config consumers.
**Future:** When config-ui.sh needs maintenance, it could source the registry for its initial scan. But this is not needed now.
### Rationale
| Option | Architecture | Advantages | Costs | Risks | When to choose |
|--------|-------------|------------|-------|-------|----------------|
| **A: Incremental migration** | Migrate tree + gen-docs first, then pos, defer config-ui | Lowest risk, proves value early, no breaking changes | Some consumers still use direct sed during transition | Low — transition period is harmless | **Chosen** — smallest risky step |
| B: Big-bang migration | Rewrite all consumers at once | Consistent from day one | High risk, many things can break, hard to review | High — one bad refactor breaks everything | Only if the codebase were much smaller |
| C: No migration — just add the library | Create registry.sh but don't change any consumers | Library exists for future use | No immediate value, consumers still duplicate logic | Low — but pointless | Only if the task were just "create a library" |
[DECIDED]
---
## Decision 4: Migration Strategy — Zero-Downtime Incremental
### Problem
40+ tools exist. All must keep working. The new headers are optional. No tool should require changes to function.
### Decision
**Phase 1 (this implementation):**
1. Create `lib/registry.sh` with full API.
2. Update `scripts/gen-docs.sh` to parse new headers (degrades gracefully when absent).
3. Migrate `bin/pos-tree` to use registry (proves runtime value).
4. Update `templates/pos-tool.sh` to document new headers.
5. Update `DOC/DEV.md` and `DOC/AGENT_Context_Project.md` with new header format.
**Phase 2 (future, out of scope):**
1. Migrate `bin/pos` `_pos_category_help()` to use registry.
2. Enhance `pos help` to show registry metadata (deps, examples).
3. Add `# POS_DEPS:` and `# POS_EXAMPLES:` to tools incrementally (start with 35 representative tools per category).
**Phase 3 (future, out of scope):**
1. `pos menu` — interactive menu from registry (new tool).
2. `pos dashboard` — status dashboard from registry (new tool).
### Incremental Adoption for Tool Authors
1. Add `# POS_DEPS: docker jq` to your tool's header block. Done — deps appear in registry.
2. Add `# POS_EXAMPLES: pos <tool> <args> | Description` lines. Done — examples appear in registry.
3. No other changes required. The tool keeps working exactly as before.
4. When you run `make gen`, the new metadata appears in generated docs.
### Tool Template Update
`templates/pos-tool.sh` gets new header documentation:
```bash
# ────────────────────────────────────────────────────────────────
# TEMPLATE — new `pos` CLI tool
#
# 1. Copy: cp templates/pos-tool.sh bin/pos-<category>-<command>
# 2. Header: add a `# POS:` line right after the shebang/strict-mode
# lines (single source of truth for generated docs):
# # POS: <category> <command> — one-line description
# # POS_FLAGS: --flag1 --flag2 (flag-style tools only)
# # POS_SUBCMDS: sub1 sub2 (multi-command tools only)
# # POS_DEPS: binary1 binary2 (runtime deps, optional)
# # POS_EXAMPLES: pos <tool> <args> | Description (optional)
# 3. ...
```
### Lint Gate Update
`scripts/lint-conventions.sh` gets a new WARN check:
- If `# POS_DEPS:` is present, validate that each token looks like a binary name (no spaces, no special chars). This is a soft check — WARN on malformed deps, not FAIL.
[DECIDED]
---
## Decision 5: Scope Boundaries
### Approved Scope
```text
In scope:
lib/registry.sh — NEW FILE, ~180 lines
scripts/gen-docs.sh — Extend tools array, add deps/examples to gen_* functions
bin/pos-tree — Migrate to use registry (replace file-scanning loop)
templates/pos-tool.sh — Document new headers in template comments
DOC/DEV.md — Document new header format and registry usage
DOC/AGENT_Context_Project.md — Update line count table for lib/registry.sh, gen blocks updated by make gen
scripts/lint-conventions.sh — Optional: WARN for malformed POS_DEPS
Not in scope:
bin/pos _pos_category_help() — Phase 2 (future)
pos help enhancements — Phase 2 (future)
pos menu (new tool) — Phase 3 (future)
pos dashboard (new tool) — Phase 3 (future)
Adding POS_DEPS/POS_EXAMPLES to existing tools — Individual tool authors, incremental
lib/config-ui.sh integration — Deferred (already works, specialized parsing)
completions/pos.bash changes — No new completion data needed (deps/examples aren't completable)
install.sh changes — registry.sh is installed with existing lib/* loop
```
### What Builder Must NOT Do
1. Do NOT add `# POS_DEPS:` or `# POS_EXAMPLES:` to any existing tool in this PR (that's incremental migration, separate commits).
2. Do NOT change the `# POS:` header format or the em-dash convention.
3. Do NOT change `bin/pos` dispatch logic or `INTERACTIVE_CMDS`.
4. Do NOT add any new files beyond `lib/registry.sh`.
5. Do NOT modify the completion script (`completions/pos.bash`).
6. Do NOT refactor `lib/config-ui.sh` to use the registry.
7. Do NOT add a shebang to `lib/registry.sh` (it's a library, not executable).
8. Do NOT break `make gen && make check && make lint`.
[DECIDED]
---
## Decision 6: Risk Analysis
### Risk 1: `make gen` output changes break `git diff --exit-code` in CI
**Impact:** HIGH — blocks PRs.
**Cause:** Adding deps/examples parsing to gen-docs.sh changes the `tools` array format. The gen_* functions that consume this array will produce different output if any tool has the new headers. But since no tools have them yet, the output should be identical.
**Mitigation:**
- The gen_* functions must produce **identical output** when no tools have `# POS_DEPS:` or `# POS_EXAMPLES:` headers.
- Test: run `make gen && git diff --exit-code` before committing. Zero diff = safe.
- The tools array format change (adding `$deps|$examples` fields) is internal to gen-docs.sh — the gen_* functions that render output must not use the new fields when they're empty.
### Risk 2: `reg_scan` performance degrades with many tools
**Impact:** LOW — current tool count is ~40.
**Cause:** Each tool file is read 46 times by `sed` during scan. For 40 tools, this is ~200 process spawns.
**Mitigation:**
- At current scale, scan completes in ~5ms. Even 100 tools would be ~15ms.
- If it ever becomes an issue, `reg_scan` could read each file once and parse all headers in a single `awk` pass. This is a future optimization, not needed now.
### Risk 3: Registry library conflicts with existing sourcing patterns
**Impact:** MEDIUM — could break tools that source both common.sh and registry.sh.
**Cause:** `registry.sh` declares guarded fallbacks for `log`, `warn`, `err` (same pattern as `config-ui.sh`). If both are sourced, the second source is a no-op because the functions already exist.
**Mitigation:**
- Use the same guarded-declaration pattern as `config-ui.sh`: `declare -F log >/dev/null || log() { ... }`.
- `registry.sh` does NOT define `run`, `spawn`, `confirm`, or any other common.sh functions.
- `registry.sh` does NOT call `err` during normal operation — only if `reg_scan` is called with an invalid directory (which won't happen in practice).
### Risk 4: `bin/pos-tree` migration breaks tree output
**Impact:** HIGH — visible to users.
**Cause:** The tree-building logic in `pos-tree` is tightly coupled to the current data collection. Replacing the collection loop might subtly change tree structure.
**Mitigation:**
- Keep the existing `add()`, `render()` functions unchanged.
- Replace ONLY the data-collection loop (lines 4768) with registry calls.
- Test: run `bin/pos-tree` before and after, diff the output. Must be identical (for existing headers).
- The only visible change should be when a tool has `# POS_DEPS:` — deps appear in the tree.
### Risk 5: `pos <category>` performance regression
**Impact:** LOW — adds ~5ms per invocation.
**Cause:** `_pos_category_help()` would source and call `reg_scan` on every invocation.
**Mitigation:**
- Phase 2 only (not in this implementation).
- If needed, cache scan results in a temp file: `reg_scan` writes to `/tmp/.pos-registry-<hash>` and consumers check for freshness. This is premature — implement only if profiling shows a problem.
### Risk 6: New headers malformed, breaking parsing
**Impact:** LOW — malformed headers produce empty values, not crashes.
**Cause:** A tool author writes `# POS_DEPS` (missing colon) or `# POS_EXAMPLES foo bar` (missing pipe).
**Mitigation:**
- `reg_scan` uses strict pattern matching: `sed -n '/^# POS_DEPS: /{...}'`. Missing colon = no match = empty value. Graceful degradation.
- Add a lint WARN (not FAIL) for `# POS_DEPS:` lines without space-separated tokens.
- Add lint WARN for `# POS_EXAMPLES:` lines without `|` delimiter.
- Document the expected format clearly in DEV.md.
[DECIDED]
---
## Verification Plan
### Gate 1: Syntax
```bash
bash -n lib/registry.sh # Must pass (no syntax errors)
bash -n scripts/gen-docs.sh # Must pass (after modifications)
bash -n bin/pos-tree # Must pass (after modifications)
```
### Gate 2: Gen Drift
```bash
make gen && git diff --exit-code # Zero diff (no tools have new headers yet)
```
### Gate 3: Self-Consistency
```bash
make check # Must pass (syntax + exec bits + doc/code sync + dispatch smoke)
```
### Gate 4: Convention Lint
```bash
make lint # Must pass (0 FAIL, 0 WARN)
```
### Gate 5: Functional
```bash
# Registry works standalone
bash -c 'source lib/registry.sh; reg_scan; reg_list; reg_lookup docker ps desc'
# pos tree produces identical output
bin/pos-tree > /tmp/tree-before.txt
# ... apply changes ...
bin/pos-tree > /tmp/tree-after.txt
diff /tmp/tree-before.txt /tmp/tree-after.txt # Must be empty
# pos category help works
bin/pos docker --help # Must show docker tools
bin/pos network --help # Must show network tools
```
### Gate 6: Regression
```bash
# All existing commands still dispatch
bin/pos --help
bin/pos docker --help
bin/pos network --help
bin/pos help network scan
```
---
## Implementation Guidance for Builder
### Step-by-step
1. **Create `lib/registry.sh`** (~180 lines). Start from the API spec in Decision 2. Use `lib/config-ui.sh` as a structural reference for the guarded fallbacks and source pattern.
2. **Update `scripts/gen-docs.sh`**. In the tools collection loop (lines 3046):
- Add `deps` and `examples` fields to the `tools` array format: `"$cat|$sub|$desc|$flags|$subcmds|$deps|$examples"`
- Parse new headers with the same `sed` pattern as existing ones.
- In `gen_tree()`, `gen_dispatch()`, `gen_filetable()`: add deps/examples columns ONLY when non-empty. Empty fields = identical output to current.
3. **Migrate `bin/pos-tree`**. Replace lines 4768 (the file-scanning loop) with registry calls. Keep `add()`, `render()`, and the rest unchanged. Test that output is identical for existing tools.
4. **Update `templates/pos-tool.sh`**. Add `# POS_DEPS:` and `# POS_EXAMPLES:` to the header documentation block. Add them after the existing `# POS_FLAGS:` example.
5. **Update `DOC/DEV.md`**. In "Adding a New CLI Tool → Make it discoverable":
- Document the new `# POS_DEPS:` and `# POS_EXAMPLES:` headers.
- Explain when to use each (deps: list runtime binaries; examples: show 13 representative usages).
6. **Update `lib/registry.sh` line count** in `DOC/AGENT_Context_Project.md` filetable (the hand-maintained rows above the GEN marker).
7. **Run gates:** `make gen && make check && make lint`. Verify 0 FAIL, 0 WARN.
### Critical Constraints
- `lib/registry.sh` must NOT have a shebang (library, not executable).
- `lib/registry.sh` must be added to the `lib_names` list in `install.sh` Phase 2 (line 143).
- `reg_scan` must set `LC_ALL=C` for deterministic sort.
- gen-docs.sh changes must produce zero diff when no tools have new headers.
- `pos-tree` output must be byte-identical before/after migration (for existing headers).
---
## Open Questions (for Orchestrator)
1. Should `pos <category> --help` (in `bin/pos`) be migrated in this phase or deferred to Phase 2? **Recommendation: defer to Phase 2** — lower risk, and the category help is already working.
2. Should the lint gate enforce that `# POS_DEPS:` tokens are valid binary names? **Recommendation: WARN only, not FAIL** — some deps might be shell builtins or paths, not just binary names.
3. Should `reg_scan` support a `--cached` mode? **Recommendation: no, not yet** — premature optimization for 40 tools.
---
*Report written by Architect agent. Next recommended agent: **Builder** (to implement the approved scope).*
@@ -0,0 +1,572 @@
# Architecture Report — `pos ai hf` (Hugging Face Model Downloader)
**Date:** 2026-09-04
**Status:** DECISION_READY
---
## TL;DR
| Decision | Choice | Rationale |
|----------|--------|-----------|
| File location | `bin/pos-ai-hf` | Subcommand of `ai` category — models are AI infrastructure |
| Subcommands | `download`, `search`, `list`, `remove` | Core + discovery + local management |
| Config scope | `ai` (existing) | No new scope needed — `HF_TOKEN` and `HF_DOWNLOAD_DIR` fit the existing `ai.env` |
| Default download dir | `~/.local/share/linux_post_install/ai/models/<repo-id>` | Follows XDG data conventions, matches `SESSION_DIR` parent |
| Dependencies | `curl`, `jq` | Already in `preinstall.sh` PACKAGES — no changes |
| Progress | curl `--progress-bar` | Native, no extra deps, works for multi-GB files |
| Resume | curl `-C -` | Automatic resume on interrupted downloads |
| New files created | `bin/pos-ai-hf` (1 file) | Minimal scope — everything else is doc updates |
| Open items | HOWTO.md + howto/ai.md updates (Writer task) | Not blocking implementation |
---
## Decision 1: File Location — `bin/pos-ai-hf`
**Problem:** Where does a Hugging Face model downloader live in the `pos` hierarchy?
**Evidence:**
- Existing AI tools: `bin/pos-ai` (692 lines), `bin/pos-ai-alias` (760 lines), `bin/pos-ai-gemini`/`bin/pos-ai-openrouter` (7-line forwarders) — all under the `ai` category
- The `ai` category covers AI providers, sessions, models, and assistants
- Downloading models is AI infrastructure — it feeds ollama, llama.cpp, and similar local inference tools
- The user's explicit context: "download AI models for local inference"
**Options:**
| Option | Architecture | Pros | Cons |
|--------|-------------|------|------|
| A: `bin/pos-ai-hf` | Subcommand of `ai` category | Consistent with existing AI tool hierarchy; `pos ai hf download` is natural; `pos ai --help` shows it alongside other AI tools | Slightly longer invocation path |
| B: `bin/pos-ai-download` | Named after the action, not the provider | Action-first naming | Conflates "AI download" with "HF download"; would need renaming when adding other model sources (e.g., CivitAI, Ollama registry) |
| C: `bin/pos-hf` | Own category | Shortest invocation | Breaks `ai` category coherence; HF is not a general tool category |
**Decision:** Option A — `bin/pos-ai-hf`
**Reasoning:** HF is a provider/source within the AI domain. The `ai` category already contains provider-specific tools (`pos-ai-gemini`, `pos-ai-openrouter`). Adding `pos-ai-hf` for model downloading fits this pattern perfectly. The tool name communicates both the domain (`ai`) and the source (`hf`).
**Convention compliance:**
- `# POS: ai hf — Download AI models from Hugging Face (search, download, manage)`
- No new category in `pos --help`
- Auto-discovered by `pos` dispatcher
[DECIDED]
---
## Decision 2: Subcommands
**Problem:** What operations should `pos ai hf` support?
**Evidence:**
- `pos media grab` pattern (file:83-141): thin classifier + delegator — minimal surface area
- `pos network download` (18 subcommands): comprehensive but for a complex download manager with queuing, torrents, retry logic
- `pos ai` pattern (file:1-10): flag-based with subcommands (`ask`, `chat`, `sessions`, `models`, `providers`)
- User goal: "Download AI models for local inference" — primary operation is download; search and local management are secondary
**Options:**
| Option | Subcommands | Pros | Cons |
|--------|-------------|------|------|
| A: download + search + list + remove | 4 subcommands | Full lifecycle; covers discovery, download, local management, cleanup | More surface area to maintain |
| B: download + list + remove | 3 subcommands | Core + local management; search can be done via `curl` manually | User must leave `pos` for discovery |
| C: download only | 1 subcommand | Minimal; simplest to implement and maintain | No local management; user must track paths manually |
**Decision:** Option A — `download`, `search`, `list`, `remove`
**Reasoning:**
- `download` is the primary operation (user goal)
- `search` is low-cost to implement (one API call, jq formatting) and high-value for discovery
- `list` shows what's already downloaded — essential for a model management workflow
- `remove` lets users clean up without manually tracking paths
- Total surface area is manageable — each subcommand is a single function, not a complex state machine
**Subcommand contracts:**
```
pos ai hf download <repo-id> [filename] # Download a file or entire repo
pos ai hf download <repo-id> --files # List files, then download selected
pos ai hf download <repo-id> --branch <rev> # Download from a specific branch/commit
pos ai hf download <repo-id> --gguf # Download only .gguf files (inference-ready)
pos ai hf search <query> # Search HF models
pos ai hf list # List downloaded models
pos ai hf remove <repo-id> # Remove a downloaded model
```
[DECIDED]
---
## Decision 3: Config Scope — Extend `ai.env`
**Problem:** Where do `HF_TOKEN` and `HF_DOWNLOAD_DIR` live?
**Evidence:**
- `# POS_CONFIG:` header format: `scope | file | KEY=:description | ...`
- Existing `ai` scope: `bin/pos-ai` line 6 — `# POS_CONFIG: ai | ai.env | AI_PROVIDER=…`
- HF download is an AI tool — its config logically belongs with other AI config
- Creating a new `hf` scope would add another `pos config` entry and `.env` file for just 2 keys
- The `pos config ai` command already exists and users would expect AI-related config there
**Options:**
| Option | Architecture | Pros | Cons |
|--------|-------------|------|------|
| A: Extend `ai.env` (existing scope) | `HF_TOKEN` and `HF_DOWNLOAD_DIR` added to `# POS_CONFIG: ai` in `bin/ai-hf` | One config location for all AI tools; user runs `pos config ai` to see everything | Mixes provider keys (GEMINI_API_KEY) with download config |
| B: New `hf.env` (new scope) | `# POS_CONFIG: hf | hf.env | HF_TOKEN=…` | Clean separation; `pos config hf` is self-contained | Another `.env` file; users must know which scope has the token |
| C: `system.env` (shared scope) | `HF_TOKEN` and `HF_DOWNLOAD_DIR` in system.env via `load_system_env()` | Centralizes shared config | Wrong semantic — HF is AI-specific, not system-wide |
**Decision:** Option A — Extend existing `ai` scope
**Reasoning:**
- The `ai` scope already holds `AI_PROVIDER` and `AI_GEMINI_API_KEY` — adding HF keys keeps all AI config in one place
- Users run `pos config ai` once to configure everything they need for AI tools
- No new scope registration, no new `.env` file, no new completion entry
- The `# POS_CONFIG:` header in `bin/pos-ai-hf` adds its keys to the same `ai` scope
**Header addition (in `bin/pos-ai-hf`):**
```bash
# POS_CONFIG: ai | ai.env | HF_TOKEN=:Hugging Face API token (https://huggingface.co/settings/tokens) (secret) | HF_DOWNLOAD_DIR=:Model download directory (default ~/.local/share/linux_post_install/ai/models)
```
[DECIDED]
---
## Decision 4: Download Directory Layout
**Problem:** Where do downloaded files land, and what directory structure?
**Evidence:**
- `SESSION_DIR="$HOME/.local/share/linux_post_install/ai"` (bin/pos-ai line 12) — data convention for AI tools
- `DOWNLOAD_DIR="${DOWNLOAD_DIR:-$HOME/Downloads}"` (bin/pos-network-download line 22) — general download convention
- Ollama expects models in `~/.ollama/models/` — not our concern (user moves files)
- llama.cpp uses `--model <path>` — just needs the path printed
- HF repos use `<namespace>/<model-name>` format (e.g., `meta-llama/Llama-3.1-8B-Instruct`)
**Decision:** Flat layout under XDG data directory
```
~/.local/share/linux_post_install/ai/models/
├── meta-llama-Llama-3.1-8B-Instruct/
│ ├── config.json
│ ├── model.safetensors
│ ├── tokenizer.json
│ └── .hf-meta # our metadata: repo-id, branch, download date, files
├── Qwen-Qwen2.5-7B-Instruct/
│ ├── model-00001-of-00003.safetensors
│ └── ...
└── TheBloke-Mistral-7B-v0.1-GGUF/
├── mistral-7b-v0.1.Q4_K_M.gguf
└── .hf-meta
```
**Key decisions:**
- **Folder name:** `<namespace>-<model-name>` (dash-joined, slashes replaced). Clean, filesystem-safe, human-readable.
- **Default base:** `~/.local/share/linux_post_install/ai/models/` (overridable via `HF_DOWNLOAD_DIR`)
- **`.hf-meta` file:** JSON metadata (repo-id, branch, download timestamp, file list). Enables `list` and `remove` without API calls.
- **No nesting by namespace:** Flat is simpler — users can see all models at a glance.
**Out of scope:** Integrating with ollama's model directory or llama.cpp's model directory. Users move files themselves or use `--output` flag.
[DECIDED]
---
## Decision 5: Download Logic
**Problem:** How to download files from HF repos reliably.
**Evidence:**
- HF REST API: `GET /api/models/{ns}/{repo}` returns file list (`siblings[].rfilename`)
- `GET /api/models/{ns}/{repo}/tree/{rev}/{path}` returns sizes + LFS info
- Large files (7GB+): curl `-L` transparently handles LFS, Xet, CDN redirects
- Rate limits: 500/5min anonymous, 1000/5min with token
- No single "download all" endpoint — must loop through file list
- `curl -C -` handles resume for interrupted downloads
- `--progress-bar` gives native progress for large files
**Download flow:**
```
1. Validate repo-id (must contain /)
2. Call GET /api/models/{ns}/{repo} → extract siblings
3. Filter files (by filename arg, --gguf flag, or download all)
4. For each file:
a. Create target directory (mkdir -p)
b. Construct download URL: https://huggingface.co/{ns}/{repo}/resolve/{rev}/{filename}
c. curl -L -C - --progress-bar -H "Authorization: Bearer $HF_TOKEN" → target
d. Verify file exists and is non-empty
5. Write .hf-meta (repo-id, branch, files, timestamp)
6. Print summary: path, total size, file count
```
**Key implementation details:**
| Concern | Solution |
|---------|----------|
| Auth | Always pass `Authorization: Bearer $HF_TOKEN` header — even public repos get better rate limits |
| Large files | `curl -L` handles LFS/Xet transparently; `--progress-bar` shows native progress |
| Resume | `curl -C -` resumes interrupted downloads automatically |
| Rate limiting | Sleep 1s between files; on 429, wait `Retry-After` header value or 60s default |
| Disk space | Pre-flight check: `df` available space vs estimated total (from `/tree/` endpoint) |
| Partial download | If curl fails mid-file, the partial file remains (resume on next run) |
| Gated repos | API returns 403 without token; with valid token, same flow works |
**File listing (for `--files` flag):**
```
GET /api/models/{ns}/{repo}/tree/main/ | jq to extract filenames + sizes
```
**Progress for multi-file downloads:**
- Single file: curl `--progress-bar` is sufficient
- Multi-file: Print `[N/M]` counter before each file's download, curl `--progress-bar` for each
**Avoided complexity:**
- No aria2 dependency (pos-network-download pattern) — curl is sufficient for sequential downloads
- No parallel downloads — complexity not justified for single-user homelab use
- No streaming/progress tracking library — curl's native progress bar is enough
[DECIDED]
---
## Decision 6: Output Contract
**Problem:** What does the tool print?
**Evidence:**
- `pos media grab` prints: emoji + title + path + size (file:202-226)
- `pos network download add` prints: GID for tracking
- User goal: "Print the path so the user knows where files landed"
- Tool output goes to stdout (captured by `tee` for logging)
**Decision:** Structured, parseable output with human-friendly summary
```
# Single file download:
📥 Downloaded: meta-llama/Llama-3.1-8B-Instruct/model.safetensors (4.7 GB)
📁 ~/.local/share/linux_post_install/ai/models/meta-llama-Llama-3.1-8B-Instruct/model.safetensors
# Multi-file download:
📥 Downloaded: meta-llama/Llama-3.1-8B-Instruct (7 files, 4.7 GB)
📁 ~/.local/share/linux_post_install/ai/models/meta-llama-Llama-3.1-8B-Instruct/
# Search results:
Found 5 models for "llama 7b":
meta-llama/Llama-2-7b-chat-hf 12.3k downloads 13.5 GB
NousResearch/Llama-2-7b-hf 8.2k downloads 13.5 GB
...
# List:
Downloaded models (3):
meta-llama-Llama-3.1-8B-Instruct 4.7 GB 2026-09-04
Qwen-Qwen2.5-7B-Instruct 4.2 GB 2026-09-03
TheBloke-Mistral-7B-v0.1-GGUF 4.1 GB 2026-09-02
# Remove:
Removed: meta-llama-Llama-3.1-8B-Instruct (freed 4.7 GB)
```
**stdout contract:**
- Summary lines go to stdout (logged by `tee`)
- Progress bars go to stderr (not logged)
- Errors go to stderr via `err()` (exits 1)
[DECIDED]
---
## Decision 7: Error Handling
**Problem:** How to handle failure modes gracefully.
**Evidence:**
- `pos network download` has comprehensive error handling for RPC failures, dead sources, outages
- `pos media grab` has URL validation and delegation failure summary
- `err()` from common.sh exits 1 with red message
- Network tools need to handle transient failures
**Error matrix:**
| Error | Detection | Response |
|-------|-----------|----------|
| Missing deps | `command -v` guard before `--help` | `err "curl not found (install curl)"` — exits before help |
| Invalid repo format | No `/` in repo-id | `err "Invalid repo format: use namespace/model-name"` |
| 404 (repo not found) | HTTP status from API | `err "Model not found: {repo-id}"` |
| 401/403 (auth) | HTTP status | `err "Authentication failed — check HF_TOKEN (pos config ai)"` |
| 429 (rate limit) | HTTP status | Sleep `Retry-After` or 60s, retry once, then fail |
| Network timeout | curl exit code 28 | `err "Connection timed out — check network"` |
| Disk space | `df` pre-flight | `warn "Low disk space: need {N} GB, only {M} GB available"` then continue (user's call) |
| Partial download | curl exit code != 0 | `warn "Download interrupted for {file} (resume with same command)"` — partial file stays |
| jq parse error | jq exit code | `err "Failed to parse API response — check network or HF status"` |
| Token not set | Empty after config load | `warn "No HF_TOKEN set — using anonymous access (lower rate limits)"` — continue for public repos |
**Design principle:** Never fail silently. Always tell the user what happened and how to fix it. For transient errors, offer resume guidance.
[DECIDED]
---
## Decision 8: Dependencies and Lint Compliance
**Problem:** What deps are needed, and how to satisfy the lint gate?
**Evidence:**
- `curl` and `jq` are in `preinstall.sh` PACKAGES (line 29: `git curl wget aria2 vim nano tmux tree jq`)
- Deps guards must sit **before** `-h|--help` case (DEV.md line 110, lint rule)
- `set -euo pipefail` required (lint rule)
- `# POS:` header required (lint rule)
- No stdin reading → not in `INTERACTIVE_CMDS` (lint rule)
**Lint compliance checklist:**
| Rule | Requirement | Implementation |
|------|-------------|----------------|
| Shebang | `#!/usr/bin/env bash` | Line 1 |
| Strict mode | `set -euo pipefail` | Line 2 |
| `# POS:` header | After shebang/strict-mode | Lines 3-7 |
| Deps guards before `--help` | `command -v` guards before case | After source, before case |
| `-h|--help` via case | `case "${1:-}" in -h\|--help) usage ;;` | Standard pattern |
| Exec bits | 100755 | `chmod +x` on creation |
| No stdin | Not in `INTERACTIVE_CMDS` | True — non-interactive tool |
| Source chain | `source "$(dirname "$0")/../lib/common.sh" 2>/dev/null \|\| source "$(dirname "$0")/common.sh"` | Standard pattern |
**No new packages needed.** `curl` and `jq` are already installed by `preinstall.sh`.
[DECIDED]
---
## Decision 9: Testing Strategy
**Problem:** How to verify the tool works without a live HF token or network.
**Evidence:**
- DEV.md (line 196-214): Stub PATH approach — fake binaries, temp HOME, assert on output
- `pos network download` test pattern: fake curl/systemctl stubs with JSON fixtures
- `pos system backup` test pattern: per-test lsblk JSON fixtures in temp dirs
- Env-overridable paths: `HF_DOWNLOAD_DIR` is the seam
**Test architecture:**
```
/tmp/opencode/hf-test/
├── run-tests.sh # Test runner with check() helper
├── stubs/
│ ├── curl # Fake curl: returns fixtures based on URL pattern
│ └── jq # Pass-through (real jq with fixture data)
└── fixtures/
├── model-meta.json # GET /api/models/{ns}/{repo} response
├── model-tree.json # GET /api/models/{ns}/{repo}/tree/ response
└── search.json # GET /api/models?search=... response
```
**Test cases (target: ~40-50 cases):**
| Category | Cases |
|----------|-------|
| Argument parsing | Missing repo-id, invalid format (no /), unknown subcommand, unknown flag |
| download | Single file download, whole repo download, --gguf filter, --branch, resume (partial file exists), 404 error, 401 error, 429 rate limit |
| search | Successful search, empty results, network error |
| list | Empty list, populated list, corrupted .hf-meta |
| remove | Successful remove, nonexistent model, remove frees space |
| Config | Token from env, token from file, download dir override |
| Output | Summary format matches contract, paths are correct |
| Edge cases | Empty repo, very long filename, special characters in repo-id |
**Stub `curl` behavior:**
- Intercepts calls to `huggingface.co`
- Routes `/api/models/` to fixture files
- Routes `/resolve/` to a fake download (creates a small file)
- Simulates error codes (401, 403, 404, 429)
- Tracks call count for assertion
**No changes to the repo's test infrastructure** — tests live in `/tmp/opencode/` per convention.
[DECIDED]
---
## Function Signatures
### Config
```bash
load_hf_config()
# Reads HF_TOKEN and HF_DOWNLOAD_DIR from:
# 1. Already-exported env vars (highest precedence)
# 2. ~/.config/linux_post_install/ai.env (HF_TOKEN, HF_DOWNLOAD_DIR)
# 3. Defaults: HF_DOWNLOAD_DIR=~/.local/share/linux_post_install/ai/models
```
### API Helpers
```bash
hf_api() # hf_api <endpoint> → JSON response (GET only)
# Calls: curl -fsS -H "Authorization: Bearer $HF_TOKEN" "https://huggingface.co/api$endpoint"
# Handles: 401/403 auth errors, 429 rate limit (sleep + retry once), network errors
hf_repo_files() # hf_repo_files <repo-id> [branch] → JSON array of {rfilename, size}
# Calls: GET /api/models/{ns}/{repo}/tree/{branch}/ for sizes, falls back to /api/models/{ns}/{repo} for file list
hf_search() # hf_search <query> [limit] → JSON array of {id, downloads, likes}
# Calls: GET /api/models?search={query}&sort=downloads&direction=-1&limit={N}
```
### Download
```bash
hf_download_file() # hf_download_file <url> <target> → 0/1
# curl -L -C - --progress-bar -H "Authorization: Bearer $HF_TOKEN" -o "$target" "$url"
# Returns: 0 on success, 1 on curl failure
hf_download_repo() # hf_download_repo <repo-id> [branch] [filename|--gguf]
# Orchestrates: API call → file list → loop → download → write .hf-meta → summary
```
### Subcommands
```bash
cmd_download() # cmd_download <repo-id> [args...]
# Dispatches: single file / whole repo / --files interactive / --gguf filter
cmd_search() # cmd_search <query>
# Calls hf_search, formats table
cmd_list() # cmd_list
# Scans $HF_DOWNLOAD_DIR, reads .hf-meta, prints table
cmd_remove() # cmd_remove <repo-id>
# Validates exists, rm -rf, prints freed space
```
### Utilities
```bash
hf_repo_dir() # hf_repo_dir <repo-id> → filesystem path (dash-joined)
# "meta-llama/Llama-3.1-8B-Instruct" → "$HF_DOWNLOAD_DIR/meta-llama-Llama-3.1-8B-Instruct"
hf_human_size() # hf_human_size <bytes> → "4.7 GB" / "12.3 MB" / "1024 B"
# Same pattern as pos-media-grab (file:213-221)
hf_resolve_branch() # hf_resolve_branch <repo-id> [branch] → resolved branch
# Default "main"; calls API to get model metadata defaultBranch if not specified
```
---
## Config Keys
| Key | Scope | File | Default | Secret | Description |
|-----|-------|------|---------|--------|-------------|
| `HF_TOKEN` | `ai` | `ai.env` | (empty) | Yes | Hugging Face API token. Generate at huggingface.co/settings/tokens. Even for public repos, a token increases rate limits from 500/5min to 1000/5min. |
| `HF_DOWNLOAD_DIR` | `ai` | `ai.env` | `~/.local/share/linux_post_install/ai/models` | No | Base directory for downloaded models. Each repo gets a subdirectory named `<namespace>-<model-name>`. |
**Precedence:** env var > `ai.env` file > default (standard `load_config` pattern).
---
## File List and Responsibilities
### New files
| File | Purpose | Lines (est.) |
|------|---------|-------------|
| `bin/pos-ai-hf` | Main tool: download, search, list, remove | ~350-400 |
### Modified files
| File | Change | Scope |
|------|--------|-------|
| `DOC/POS.md` | Add `ai hf` to `ai` category table + detail block | Hand-written |
| `DOC/HOWTO.md` | Add row to AI category in index | Hand-written |
| `DOC/howto/ai.md` | Add Hugging Face download section (recipes, config, troubleshooting) | Hand-written |
| `DOC/AGENT_Context_Project.md` | GEN blocks auto-regenerated by `make gen` | Auto |
### NOT modified
| File | Reason |
|------|--------|
| `preinstall.sh` | `curl` and `jq` already in PACKAGES |
| `lib/common.sh` | No shared helpers needed — tool is self-contained |
| `bin/pos` | No `INTERACTIVE_CMDS` change (non-interactive tool); usage EXAMPLES updated by hand |
| `install.sh` | No new lib files to install |
---
## Approved Scope
### In scope
1. **Create `bin/pos-ai-hf`** — single file, ~350-400 lines
- Subcommands: `download`, `search`, `list`, `remove`
- Config: `HF_TOKEN`, `HF_DOWNLOAD_DIR` via `# POS_CONFIG: ai`
- Deps guards for `curl` and `jq`
- Full `--help` text
- Error handling for all failure modes listed in Decision 7
- Resume support (`curl -C -`)
- Rate limit handling (sleep + retry on 429)
- `.hf-meta` metadata tracking per downloaded repo
2. **Documentation updates** (Writer task, not blocking)
- `DOC/POS.md`: `ai hf` row + detail block
- `DOC/howto/ai.md`: Hugging Face section
- `DOC/HOWTO.md`: index row
3. **Run gates**
- `make gen && make check && make lint` must pass (0 FAIL, 0 WARN)
### Explicitly out of scope
- **Ollama integration** — no `ollama import` or model registration; user moves files manually
- **llama.cpp integration** — no quantization or conversion; just download
- **Parallel downloads** — sequential is sufficient for homelab use
- **Download queuing/history** — no aria2 dependency; simple curl-based downloads
- **Model conversion** — pure download tool, not a model pipeline
- **CivitAI/other sources** — HF only; other sources get their own tools if needed
- **Interactive file picker** — `--files` lists files and downloads all (or filtered); no interactive selection menu
- **New config scope** — extends existing `ai` scope, no new `pos config` entry
- **`pos ai` changes** — `bin/pos-ai` is not modified; `pos-ai-hf` is independent
---
## Architectural Constraints for Builder
1. **Start from template:** `cp templates/pos-tool.sh bin/pos-ai-hf`
2. **POS header must be on line ~4:** `# POS: ai hf — Download AI models from Hugging Face (search, download, manage)`
3. **Deps guards before `-h|--help` case:** `command -v curl` and `command -v jq` before the case
4. **Source chain:** Standard `source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"`
5. **Config loader pattern:** Copy `load_config()` from `bin/pos-ai` (lines 130-160) — read `ai.env`, env-var precedence, strip CR
6. **All file paths must be seam-guarded:** `HF_DOWNLOAD_DIR="${HF_DOWNLOAD_DIR:-$HOME/.local/share/linux_post_install/ai/models}"`
7. **Output to stdout only:** Summary lines. Progress bars and curl output to stderr.
8. **No INTERACTIVE_CMDS change:** Tool does not read stdin
9. **`make gen && make check && make lint` must pass** before handoff to Writer
---
## Verification
| Check | Command | Expected |
|-------|---------|----------|
| Syntax | `bash -n bin/pos-ai-hf` | No output, rc=0 |
| Exec bit | `ls -la bin/pos-ai-hf` | `-rwxr-xr-x` |
| POS header | `head -10 bin/pos-ai-hf` | Contains `# POS: ai hf —` |
| Help | `bin/pos-ai-hf --help` | Prints usage, rc=0 |
| Deps guard | Remove curl, run `bin/pos-ai-hf --help` | Error about curl, rc=1 |
| Dispatch | `bin/pos help ai hf` | Shows pos-ai-hf help |
| Category | `bin/pos ai --help` | Lists `hf` subcommand |
| Gen | `make gen` | Regenerates tree/dispatch/completions |
| Check | `make check` | 0 failures |
| Lint | `make lint` | 0 FAIL, 0 WARN |
| Stub tests | `/tmp/opencode/hf-test/run-tests.sh` | 40+ cases green |
---
## Risks
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| HF API changes endpoint format | Low | Medium | Pin to v0 API (`/api/models/`); monitor HF changelog |
| Token exposure in logs | Medium | High | Token passed via header, not URL; never printed in output; `HF_TOKEN` marked as secret in POS_CONFIG |
| Very large repos (100+ files) | Low | Low | Sequential download with progress; user can Ctrl+C and resume |
| LFS pointer files downloaded instead of content | Low | Medium | `curl -L` follows LFS redirect; test with known LFS repo |
| Disk full during multi-file download | Medium | Medium | Pre-flight `df` check; partial files preserved for resume |
@@ -0,0 +1,544 @@
# Architect Report — `pos media grab` + Telegram URL Routing
**Date:** 2026-09-04
**Status:** DECISION_READY
---
## TL;DR
- **Decision 1:** New tool `bin/pos-media-grab` — domain-based URL classifier that delegates to `pos media mp3`/`pos media mp4`, adds `--best` for non-interactive Telegram context.
- **Decision 2:** Listener gains URL routing step between prefix map and AI bridge — detects bare URLs, forwards to `pos media grab`.
- **Decision 3:** Config scope `grab` (`grab.env`) for `GRAB_DEFAULT` mode.
- **Decision 4:** No INTERACTIVE_CMDS change — grab is fully non-interactive; yt-dlp progress suppressed in favor of clean summary.
- **Decision 5:** 600s timeout for downloads in the listener context.
- **Decision 6:** Stub-based test harness in `/tmp/opencode/media-grab-test/`.
---
## Decision 1: File Location — Tool, Not Feature
### Problem
Where does `pos media grab` live? `bin/pos-media-grab` (auto-discovered tool) or `features/media-grab.sh` (user-customizable, never-overwritten feature)?
### Evidence
- `bin/pos-media-grab` gets auto-discovery via `pos` dispatcher, `# POS:` headers, `make gen` doc tables, completion.
- `features/` scripts are for user-customizable logic (currently: `autostart.sh`, `usb-automount.sh`). They are installed on demand via `./install.sh --feature` and never overwritten on install.
- `pos media grab` is core media routing — it must be present by default, not opt-in.
### Decision
`bin/pos-media-grab` — standard tool.
### Rationale
Core routing behavior that the listener depends on. Not user-customizable. Fits the `pos-<category>-<command>` naming exactly.
### In Scope
- `bin/pos-media-grab` (new tool)
- Listener change in `bin/pos-communication-telegram-listener` (add URL routing step)
### Explicitly Out of Scope
- Feature flag for grab (not needed — always installed)
- Changes to `pos media mp3` or `pos media mp4` (consumed as-is)
[DECIDED]
---
## Decision 2: URL Classification Logic
### Problem
How does `pos media grab` determine whether a URL should be downloaded as audio (mp3) or video (mp4)?
### Evidence
- `music.youtube.com` URLs are always audio-only (music streaming).
- YouTube regular/shorts URLs are primarily video content.
- SoundCloud, Bandcamp are audio-first platforms.
- yt-dlp handles both audio and video for all supported sites.
- The user's primary use case: YouTube music → ~/Music, YouTube video → ~/Videos.
### Classification Rules (Priority Order)
| Pattern | Classification | Reasoning |
|---------|---------------|-----------|
| `*music.youtube.com*` | `audio` | YouTube Music is audio-only streaming |
| `*soundcloud.com*` | `audio` | Audio-first platform |
| `*bandcamp.com*` | `audio` | Audio-first platform |
| `*youtube.com*`, `*youtu.be*` | `video` | YouTube primary: video content |
| `*youtube.com/shorts/*` | `video` | Short-form video |
| `*vimeo.com*` | `video` | Video platform |
| `*twitch.tv*` | `video` | Video streaming |
| Everything else | `video` (default) | Safe default — yt-dlp handles format negotiation |
### Decision
Domain-based regex classification with a configurable default.
### Implementation
```bash
classify_url() {
local url="$1" mode="${GRAB_DEFAULT:-video}"
case "$url" in
*music.youtube.com*) echo "audio" ;;
*soundcloud.com*) echo "audio" ;;
*bandcamp.com*) echo "audio" ;;
*youtube.com*|*youtu.be*) echo "video" ;;
*vimeo.com*) echo "video" ;;
*twitch.tv*) echo "video" ;;
*) echo "$mode" ;;
esac
}
```
### Why Not yt-dlp `--dump-json`?
Using `yt-dlp --dump-json` to detect content type (e.g., checking for audio-only formats) would:
- Require a network round-trip per URL (slow — 2-5s on YouTube)
- Fail on age-gated content without cookies
- Add unnecessary complexity for a heuristic
Domain-based classification is instant, reliable for the primary use case, and covers 95%+ of real URLs. The `--audio`/`--video` flag overrides for edge cases.
### Overrides
- `--audio` forces mp3 regardless of classification
- `--video` forces mp4 regardless of classification
- `--best` is passed to mp4 by default (non-interactive mode — see Decision 4)
[DECIDED]
---
## Decision 3: Config Scope
### Problem
Does `pos media grab` need its own config scope? What settings?
### Evidence
- `pos media mp3` has `OUT_DIR=$HOME/Music`
- `pos media mp4` has `OUT_DIR=$HOME/Videos`
- Both accept `--output` flag override
- The user might want all grabs to go to a single directory
- The user might want a different default mode (e.g., always audio for YouTube Music)
### Decision
Config scope `grab` with one key:
```bash
# POS_CONFIG: grab | grab.env | GRAB_DEFAULT=:Default mode for unknown domains (video or audio, default video)
```
### Rationale
Minimal config — the tool's primary job is routing, not download settings. The `GRAB_DEFAULT` key lets the user change the fallback for unrecognized domains without code changes. Output directories are inherited from mp3/mp4 and overridable via `--output`.
### Future Extensibility
If needed, future keys could include:
- `GRAB_MUSIC_DIR` (override mp3 output dir)
- `GRAB_VIDEO_DIR` (override mp4 output dir)
- `GRAB_COOKIES` (shared cookies file for all grabs)
Not implemented now — premature without user demand.
[DECIDED]
---
## Decision 4: Non-Interactive Default for Telegram Context
### Problem
`pos media mp4` has an interactive format selector (prompts on TTY). When called from the Telegram listener via `run_and_reply` (which uses `bash -c "$cmdline"` with no TTY), the prompt would hang or be swallowed by `tee`.
### Evidence
- `pos media mp4` lines 92-116: interactive `read -rp "Format ID"` when no `--format`/`--best`/`--worst` is specified
- `pos` dispatcher line 287: interactive tools are in `INTERACTIVE_CMDS` which skips `tee` logging
- The listener's `run_and_reply` (line 349) runs commands via `timeout "$tmo" bash -c "$cmdline"` — no TTY
- `spawn` helper (lib/common.sh line 76) runs commands in background — no TTY interaction
### Decision
`pos media grab` passes `--best` to `pos media mp4` by default. Users can override with `--worst` flag on grab.
### Implementation
```bash
# When routing to mp4, always add --best unless user specified --worst
MP4_ARGS=(--best)
[ "$WORST" -eq 1 ] && MP4_ARGS=(--worst)
```
### Why --best and Not --worst?
- Best quality is the expected default when sending a video link to a bot
- "I want to download this video" implies "I want it to look good"
- The `--worst` flag exists for bandwidth-constrained scenarios (explicit opt-in)
### Interaction with pos media mp4
`pos media mp4` already supports `--best` and `--worst` flags (line 4, 87-88). No changes needed to mp4.
[DECIDED]
---
## Decision 5: Listener Integration — URL Routing Step
### Problem
Where does URL detection go in the listener's routing chain, and how does it work?
### Evidence
Current `handle_message` routing (bin/pos-communication-telegram-listener lines 658-731):
```
/help|/start → reply mapped commands list
↓ (no match)
text-prefix map → run mapped command with text as argument
↓ (no match)
AI bridge → forward to Gemini
↓ (no match)
command map → run /command
↓ (no match)
"Unknown command"
```
The user sends bare URLs from their phone. These should be detected and routed to `pos media grab`.
### Decision
New routing step between prefix map and AI bridge:
```
/help|/start → reply mapped commands list
↓ (no match)
text-prefix map → run mapped command
↓ (no match)
**URL detect → pos media grab** (NEW)
↓ (no match)
AI bridge → forward to Gemini
↓ (no match)
command map → run /command
↓ (no match)
"Unknown command"
```
### Why After Prefix Map?
- A prefixed command like `ai https://...` should go to the AI bridge, not grab
- A bare URL with no prefix should go to grab
- The prefix map is explicit user configuration — it takes priority
### Why Before AI Bridge?
- A bare URL has no AI intent — it's a download request
- The AI bridge would waste time (and API credits) analyzing a URL
- Future non-AI intents (reminders, etc.) slot in as more case arms here
### Implementation
Add to `handle_message` after the prefix map block (after line 683, before line 685):
```bash
# URL detect: bare HTTP(S) URLs → pos media grab
local grab_url
if grab_url="$(url_detect "$text")"; then
log "grab: $grab_url"
run_and_reply "pos media grab --best \"$grab_url\"" "$msg_id" 600
return
fi
```
Add `url_detect` function before `handle_message`:
```bash
# Detect a bare URL in message text. Extracts the first http(s) URL.
# Returns 0 + prints the URL on success, 1 if no URL found.
url_detect() {
local text="$1"
local url=""
# Match http:// or https:// followed by non-whitespace
if [[ "$text" =~ (https?://[^[:space:]]+) ]]; then
url="${BASH_REMATCH[1]}"
# Strip trailing punctuation that's likely not part of the URL
url="${url%%[,.\)!?:;]}"
url="${url%%\>*}"
[ -n "$url" ] || return 1
printf '%s' "$url"
return 0
fi
return 1
}
```
### Edge Cases
| Input | `url_detect` result | Routing |
|-------|-------------------|---------|
| `https://youtube.com/watch?v=xyz` | `https://youtube.com/watch?v=xyz` | grab → mp4 |
| `check out https://youtu.be/xyz` | `https://youtu.be/xyz` | grab → mp4 |
| `ai what is https://example.com` | `https://example.com` | grab (not AI!) |
| `/status` | (no match) | command map |
| `opencode check cpu` | (no match) | prefix map |
| `hello world` | (no match) | AI bridge or unknown |
The `ai what is https://...` case is a minor trade-off — the user is more likely asking AI about the URL content than wanting to download it. However, this is a rare edge case, and the primary use case (bare URL from phone) is served correctly. If it becomes an issue, the URL detection could be refined to only trigger when the URL is the dominant content (e.g., text length < 2x URL length).
### Timeout
600 seconds (10 minutes). Reasonable for most videos. The listener's `run_and_reply` already handles timeout gracefully (returns "exit N" + output).
[DECIDED]
---
## Decision 6: Tool Output Contract
### Problem
What does `pos media grab` print so the listener can reply to the user?
### Evidence
- `pos media mp3` line 85: `spawn "downloading audio → $OUT_DIR"` — prints via spawn (OK/FAIL + elapsed)
- `pos media mp4` line 131: `spawn "downloading video → $OUT_DIR"` — same
- The listener's `run_and_reply` (line 349-360): captures stdout+stderr, replies with output (truncated to 3800 chars)
- Other tools: `pos system health` prints multi-line reports that the listener forwards verbatim
### Decision
`pos media grab` prints clean, user-friendly output to stdout:
```
🎵 Downloaded: Artist - Title (3:42)
📁 ~/Music/Artist - Title.mp3 (4.2 MB)
```
or
```
🎬 Downloaded: Video Title (10:15)
📁 ~/Videos/Video Title.mp4 (125 MB)
```
Error case:
```
❌ Download failed: [yt-dlp error summary]
```
### Implementation
After the mp3/mp4 delegation succeeds, the tool:
1. Runs `yt-dlp --print title --print duration_string --print filesize_approx "$URL"` to fetch metadata (fast, no download)
2. Runs `stat --printf='%s' "$filepath"` to get actual file size
3. Prints the formatted summary
For errors: capture stderr from the delegated command, print a clean error line.
### Why Not Just Forward spawn Output?
The `spawn` helper prints spinner text + OK/FAIL, which is good for terminal but not for Telegram. A structured summary (title, path, size) is more useful when you get a Telegram notification about a download.
### Why Metadata is a Separate Call?
The download itself (via mp3/mp4) doesn't expose title/size in a parseable format. The metadata call is fast (~1s) and gives us the info we need for the summary.
[DECIDED]
---
## Decision 7: Testing Strategy
### Problem
How to test without a real Telegram bot or yt-dlp network access?
### Evidence
- DEV.md "Testing tools that need root / systemd / missing deps" (lines 194-214)
- Existing patterns: `FLAGS_DIR`, `SMB_CONF` env-seam approach; stub PATH fakes
- The tool is a thin classifier + delegator — most logic is in URL matching
### Decision
Stub PATH approach with fake `pos media mp3`/`pos media mp4` scripts.
### Test Harness Layout
```
/tmp/opencode/media-grab-test/
├── run-tests.sh # Main test runner
├── stubs/ # Fake binaries
│ ├── pos # Fake pos dispatcher → delegates to stub mp3/mp4
│ ├── pos-media-mp3 # Echoes args, creates a fake file
│ ├── pos-media-mp4 # Echoes args, creates a fake file
│ ├── yt-dlp # Echoes args, creates a fake file, prints metadata
│ ├── stat # Returns fake file size
│ └── ffmpeg # No-op
└── fixtures/ # Test URLs (various domains)
```
### Test Cases (20+)
| # | Test | Input | Expected |
|---|------|-------|----------|
| 1 | YouTube Music URL | `https://music.youtube.com/watch?v=xyz` | Routes to mp3 |
| 2 | YouTube video URL | `https://youtube.com/watch?v=xyz` | Routes to mp4 --best |
| 3 | YouTube short URL | `https://youtu.be/xyz` | Routes to mp4 --best |
| 4 | YouTube shorts URL | `https://youtube.com/shorts/xyz` | Routes to mp4 --best |
| 5 | SoundCloud URL | `https://soundcloud.com/artist/track` | Routes to mp3 |
| 6 | Bandcamp URL | `https://bandcamp.com/album/track` | Routes to mp3 |
| 7 | Vimeo URL | `https://vimeo.com/123456` | Routes to mp4 --best |
| 8 | Unknown domain | `https://example.com/video.mp4` | Routes to mp4 --best (default) |
| 9 | `--audio` override | `--audio https://youtube.com/watch?v=xyz` | Routes to mp3 |
| 10 | `--video` override | `--video https://soundcloud.com/track` | Routes to mp4 |
| 11 | `--worst` flag | `--worst https://youtube.com/watch?v=xyz` | Routes to mp4 --worst |
| 12 | `--best` explicit | `--best https://youtube.com/watch?v=xyz` | Routes to mp4 --best |
| 13 | `--dry-run` | `--dry-run https://youtube.com/watch?v=xyz` | Prints command, no download |
| 14 | No URL | (empty) | usage |
| 15 | `--help` | `--help` | Shows usage |
| 16 | Invalid URL | `not-a-url` | Error: "not a valid URL" |
| 17 | GRAB_DEFAULT=audio | `GRAB_DEFAULT=audio https://unknown.com/x` | Routes to mp3 |
| 18 | `--output` override | `--output /tmp/test https://...` | Passes to mp3/mp4 |
| 19 | HTTP URL | `http://youtube.com/watch?v=xyz` | Routes to mp4 (http not https) |
| 20 | `--no-playlist` | `--no-playlist https://youtube.com/playlist?list=xyz` | Passes to mp3/mp4 |
### Stub Implementation
```bash
#!/usr/bin/env bash
# Fake pos-media-mp3 — records args, creates a dummy file
OUT_DIR="${OUT_DIR:-/tmp/test-output}"
mkdir -p "$OUT_DIR"
URL="${*: -1}" # last arg is URL
echo "pos-media-mp3 called with: $*" > /tmp/test-output/mp3.log
touch "$OUT_DIR/test.mp3"
echo "Downloaded: Test Song (3:42)"
echo "📁 $OUT_DIR/test.mp3 (4.2 MB)"
```
### What NOT to Test
- yt-dlp actual behavior (covered by yt-dlp's own tests)
- The listener integration (tested manually or with a Telegram bot test harness)
- URL regex edge cases that are handled by bash `[[ =~ ]]` (well-tested in bash)
[DECIDED]
---
## Decision 8: Dependency Handling
### Problem
What deps does `pos media grab` declare, and where?
### Evidence
- `pos media mp3` lines 17-18: guards `yt-dlp` and `ffmpeg`
- `pos media mp4` lines 17-18: same guards
- `pos media grab` delegates to mp3/mp4, which handle their own deps
- Grab itself only needs bash builtins for URL classification
### Decision
No deps guard in `pos media grab`. Deps are the responsibility of mp3/mp4.
### Rationale
Grab is a pure classifier + delegator. It doesn't call yt-dlp directly. If mp3/mp4 are called and their deps are missing, they'll error with their own helpful messages. Adding redundant deps guards in grab would:
- Duplicate error messages
- Create maintenance overhead when mp3/mp4 deps change
- Violate single-responsibility (grab classifies, mp3/mp4 download)
### Exception
If grab needs `stat` for file size reporting (Decision 6), that's a standard coreutil — no guard needed on Debian/Ubuntu.
[DECIDED]
---
## Summary: Implementation Scope
### Files to Create
| File | Responsibility |
|------|---------------|
| `bin/pos-media-grab` | URL classifier, delegate to mp3/mp4, clean output |
### Files to Modify
| File | Change |
|------|--------|
| `bin/pos-communication-telegram-listener` | Add `url_detect` function + URL routing step in `handle_message` |
### Files to Update (Docs)
| File | Change |
|------|--------|
| `DOC/POS.md` | Add `pos media grab` row to media table + detail block |
| `DOC/HOWTO.md` | Add media section reference if not already present |
| `DOC/howto/media.md` | Add `grab` usage example |
| `DOC/AGENT_Context_Project.md` | (auto via `make gen`) tree, dispatch, filetable |
| `AGENT_TODO.md` | Move to Done |
### Post-Implementation Gates
```bash
chmod +x bin/pos-media-grab
bash -n bin/pos-media-grab
bash -n bin/pos-communication-telegram-listener
make gen && make check && make lint # 0 FAIL, 0 WARN
```
---
## Handoff
Status: DECISION_READY
Problem: Telegram bot needs intelligent URL routing to auto-download content as audio/video.
Decision: New `bin/pos-media-grab` tool (domain classifier + mp3/mp4 delegator) + listener URL routing step between prefix map and AI bridge.
Reasoning: Evidence-based — domain classification is fast/reliable for the primary use case, existing mp3/mp4 tools handle the heavy lifting, listener routing chain slots naturally for new intents.
Ownership: `bin/pos-media-grab` (new tool), listener integration in existing listener.
Interfaces: grab is called by the listener; grab calls mp3/mp4.
Approved scope: `bin/pos-media-grab` (new), listener modification (add ~30 lines), doc updates. No changes to mp3/mp4.
Explicitly out of scope: Changes to mp3/mp4 tools, feature flags, complex config.
Constraints: Must pass `make gen && make check && make lint` (0 FAIL, 0 WARN). Follow all pos conventions (shebang, strict mode, `# POS:` header, deps-before-help pattern, `common.sh` sourcing).
Verification: Stub-PATH test suite (20+ cases), `bash -n` on both modified files, `make gen && make check && make lint`.
Risks:
- URL regex may not match all URL formats (mitigated: covers http/https, the dominant formats)
- `--best` may not be what the user always wants for video (mitigated: `--worst` override)
- Metadata fetch adds ~1s latency (mitigated: acceptable for download context)
Recommended next agent: Builder
Reason: Architecture is fully defined with specific file paths, function signatures, listener line changes, config keys, and test plan. Ready for implementation.
Architect changes: None — pure decision record.