diff --git a/AGENTS.md b/AGENTS.md index 9adcc24..15800b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,8 @@ CRITICAL: real guidance lives in DOC/. When you encounter a reference below, use ## Quick facts -- **Tool model:** `bin/pos--`, or **category-less** `bin/pos-` for dispatcher/dev-level commands that fit no category (`pos-config`, `pos-tree`) — they dispatch like any tool and show with an empty category in the generated tables. `bin/pos` dispatches by longest-prefix arg matching. New tools are auto-discovered but must be executable (`100755`) and carry a `# POS: ` header right after the shebang; `make gen` only uses the text after the first `— ` (the leading words are convention-only), so keep the one-line description concise. `# POS_FLAGS:` / `# POS_SUBCMDS:` / `# POS_CONFIG:` headers feed tab-completion and the `pos config` scope registry. A missing `# POS:` header hard-fails `make gen`. Legacy `bin/wr-*`, `mp3`, `mp4`, `vbox`, `ssh-load-all` are thin forwarders to `pos` — keep them that way. -- **Categories:** `ai`, `communication`, `docker`, `entertainment`, `media`, `network`, `share` (usb, nfs, smb), `ssh`, `system`, plus category-less `config`/`tree`. `pos tree` (bin/pos-tree) is the authoritative structure — it derives the hierarchy from `bin/pos-*` filenames + `# POS:`/`# POS_SUBCMDS:` headers. +- **Tool model:** `bin/pos--`, or **category-less** `bin/pos-` for dispatcher/dev-level commands that fit no category (`pos-config`, `pos-tree`) — they dispatch like any tool and show with an empty category in the generated tables. `bin/pos` dispatches by longest-prefix arg matching. New tools are auto-discovered but must be executable (`100755`) and carry a `# POS: ` header right after the shebang; `make gen` only uses the text after the first `— ` (the leading words are convention-only), so keep the one-line description concise. `# POS_FLAGS:` / `# POS_SUBCMDS:` / `# POS_CONFIG:` headers feed tab-completion and the `pos config` scope registry; optional `# POS_DEPS: ` declares space-separated runtime binaries the tool hard-requires via `command -v` guards, and optional `# POS_EXAMPLES: | ` adds curated usage examples, one per line. `lib/registry.sh` is the shared query API over all `POS_*` headers — consumers source it (`reg_scan` + `reg_list`/`reg_lookup`/…) instead of re-implementing sed/grep header parsing; new consumers should prefer it. A missing `# POS:` header hard-fails `make gen`. Legacy `bin/wr-*`, `mp3`, `mp4`, `vbox`, `ssh-load-all` are thin forwarders to `pos` — keep them that way. +- **Categories:** `ai`, `communication`, `docker`, `entertainment`, `media`, `network`, `share` (usb, nfs, smb), `ssh`, `system`, plus category-less `config`/`tree`. `pos tree` (bin/pos-tree) is the authoritative structure — it derives the hierarchy from `bin/pos-*` filenames + `# POS:`/`# POS_SUBCMDS:` headers, reads the metadata through `lib/registry.sh`, and annotates each command's declared `# POS_DEPS:`. - **Generated code:** blocks between `GEN:START`/`GEN:END` markers in `DOC/AGENT_Context_Project.md` (tree, dispatch, selfcontained, filetable, docmap) and `completions/pos.bash` (flags, subcmds, config scopes) are `make gen` output — never hand-edit them. Generators must be **byte-order deterministic** (sort with `LC_ALL=C`, as `scripts/gen-docs.sh` does) or CI's `git diff --exit-code` trips on a locale that collates differently. After touching `bin/pos-*`, run `make gen`, then `make check`, then `make lint` (definition of done: check green + lint ends `0 FAIL, 0 WARN`). `make check` (`scripts/check-sync.sh`) is the self-consistency gate — bash -n + exec-bit check + doc-sync + dispatch smoke; `make lint` (`scripts/lint-conventions.sh`) is the convention gate — it enforces every rule in this file (shebang/strict-mode, exec bits, `# POS:` headers, `-h|--help` present and after deps guards, stdin-readers in `INTERACTIVE_CMDS`, POS.md coverage, plugin/app/unit/wrapper/secrets/env-seam classes — see `DOC/DEV.md → Convention Lint Gate`). Hand-maintained, not gen-checked: `DOC/POS.md`, the line-count rows above the filetable marker in `DOC/AGENT_Context_Project.md` (the non-`pos-*` files — `install.sh`, `preinstall.sh`, `postinstall.sh`, `lib/*`, `features/*`; bump a row's count only when that file's length changes), `bin/pos` usage() EXAMPLES, root README. CI (`.gitea/workflows/lint.yml`, job `gates`) runs the same four commands on every push to main and PR, then records the result as a git tag on the commit: `ci-ok/` or `ci-fail/` (pushes only — query with `scripts/ci-status.sh [--wait] []`; exit 0 green / 1 red / 2 pending). A red run means gen drift or a gate failure and is a merge-blocker; still run the gates locally too (lint isn't in the pre-commit hook). - **Stdin gotcha:** any tool that reads stdin must be added to `INTERACTIVE_CMDS` in `bin/pos` — otherwise the logging `tee` pipe hangs on (or swallows) the prompt. - **Deps:** apt packages → `PACKAGES` array in `preinstall.sh`; non-apt/manual installers (e.g. `usbsrv`) → `command -v || err "…"` guard inside the tool, never in PACKAGES. Hotspot binaries (`create_ap`, `wihotspot*`) are prebuilt in `x64_bin/` (or `arm64_bin/`) and copied by `install.sh` — not apt packages. diff --git a/AGENT_TODO.md b/AGENT_TODO.md index 219f218..8709d1d 100644 --- a/AGENT_TODO.md +++ b/AGENT_TODO.md @@ -134,3 +134,4 @@ summary (newest last). - **2026-08-23** — ytsync menu render bugfix (`bin/pos-media-ytsync`, live-box report): `cut -d'·'` at :292/:301 used U+00B7 = 2 bytes UTF-8 (GNU cut is byte-oriented → "delimiter must be a single character", masked by `|| true` so the `· last run …` suffix and LAST SYNC column never rendered); replaced with grep/tail capture + `${last%% ·*}` parameter expansion (semantics identical incl. empty-string=no-last-run); :1053 `printf '----…\n'` format starting with `-` parsed as invalid option → `printf '%s\n' '----…'`. Chain: Detective root cause (`reportAgents/2026-08-23-detective-ytsync-menu-errors.md`) → Builder 3-site fix (`-builder-ytsync-menu-fix.md`, pty probe: suffix + separator render, zero stderr noise) → Reviewer ACCEPT-WITH-NITS (`-reviewer-ytsync-menu-fix.md` delivered inline). Gates re-run by Orchestrator post-review: `make gen` idempotent, check OK, lint 0 FAIL / 0 WARN. - **2026-08-23** — Menu Phase 1 (user-ratified decision "b"): category-neutral menu library extracted from share-suite Pattern B + four P1 tool menus. New `lib/menu-lib.sh` (169 ln): `menu_guard`/`menu_run`/`menu_pick`/`menu_ask_value` (stderr render, /dev/tty reads, EOF fail-closed rc=1, index/value→stdout); `lib/share-lib.sh` (436→318) keeps its public names as pure delegating shims so all five `pos share *` tools stay untouched; install.sh Phase-2 explicit lib list += menu-lib.sh. Opt-in no-args+tty front doors (or `menu` verb, `# POS_SUBCMDS:` registered, completions regen'd) on `pos media sync` (164→216: Sync-now/Preview/mp3/mp4/source-folder items), `pos system backup` (216→292: typed/service-root/plain variants, every backup behind folder-naming y/N), `pos docker compose` (366→487: ls/up/down/restart/logs/update/config items, down/restart/update confirm-gated naming the stack), `pos system schedule` (81→151: list/status/run-now(confirm)/enable/disable/editor — timer-invoked `run ` verb dispatch byte-identical to HEAD). INTERACTIVE_CMDS unchanged; all CLI verbs byte-compatible. Docs: POS.md ×4 rows, DEV.md lib row, SCRIPTS.md sections, AGENT_Context rows + GEN. Chain: Explorer survey (37 tools, `reportAgents/2026-08-23-explorer-pos-menu-survey.md`) → Designer classification (`-designer-pos-menu-suitability.md`: 14 MENU-FIT / 7 CONDITIONAL / 16 NO-FIT) → Builder T1/T2/T3 (`-builder-t1-menu-lib-extraction.md`, `-t2-p1-menus-media-backup.md`, `-t3-p1-menus-compose-schedule.md`; T3 discloses a mid-verify symlink clobber restored+re-verified) → Reviewer ACCEPT_WITH_NOTES over the consolidated diff (`-reviewer-phase1-menu.md`, T3 integrity clean). Verified: bash -n ×7, pty probes (render/quit/EOF/non-tty fail-closed/destructive prompt-abort), gates green after each pass and re-run by Orchestrator post-review (`make gen` idempotent · `make check` OK · `make lint` 0 FAIL / 0 WARN). Open for later phases: P2 (docker-vbox, network-download), firewall style-migration decision, usb-server `menu` in POS_FLAGS nit (owning track). - **2026-08-23** — Menu Phase 2 + firewall style-migration (decision "a" activated: P1 landed, `lib/menu-lib.sh` exists). `pos docker vbox` (157→261): 6-item menu hub over the inline case verbs via a quoted self-invocation `menu_self` (verbs never re-enter the menu → no recursion); `enter` hands over the terminal and returns to the loop; rm/create behind VM-naming y/N. `pos network download` (950→1104): 13-item top-verb map onto existing cmd_* fns — add URL (`menu_ask_value`, optional `--tmux`), gid-pick → info/pause/resume/remove/restart (remove names name+gid before delete), typed-confirm purge, watch handover, daemon start/stop (stop confirmed); non-fatal RPC liveness gate (`-m 3`) keeps queue views alive on a dead daemon; deliberately NOT added to INTERACTIVE_CMDS — menu-lib's tty-guarded reads make membership unnecessary and keep tee-logging for all scripted verbs (survey E-002; Reviewer traced the lint pass as honest through `uses_stdin`). `pos system firewall` (308→325) migrated to repo-standard mechanics ONLY: menu heredoc render → stderr `{ … } >&2` (body byte-preserved), all **38** interactive reads → `/dev/tty` via tool-local `tty_read()` (EOF/no-tty → pointer + rc1, never hangs), `prompt_ipver` de-command-substituted so EOF exits gracefully; root gate / per-cmd confirm / typed RESET / pager / notify / every ufw invocation untouched. Both new tools register `# POS_SUBCMDS:` += `menu`; POS.md rows updated; GEN regen'd. Chain: Builder T4 (`reportAgents/2026-08-23-builder-t4-p2-menus-vbox-download.md`; correctly caught an Orchestrator brief error claiming download was in INTERACTIVE_CMDS) + T5 (`-t5-firewall-menu-migration.md`; pty parity captures vs pre-edit baseline) → Reviewer ACCEPT-WITH-NITS over both (`-reviewer-phase2-menu.md`, transcribed by Orchestrator; recursion/injection analysis, 13/13 mapping proof, four T5 intents verified hunk-by-hunk). Verified: bash -n ×3 + gates green after each pass; final trio re-run by Orchestrator post-T5 — `make check` OK · `make lint` 0 FAIL / 0 WARN (76s under box load ~7; the earlier apparent lint hang was shared-box CPU contention, no code issue). Remaining notes for later sessions: errexit kills whole menu when a backing verb hard-fails (repo-wide pattern, all six menus); `confirm()` EOF hits set-u unbound `yn` (pre-existing common.sh); vbox create EOF at dir prompt degrades to default while name/image prompts abort (cosmetic). +- **2026-08-26** — `pos ai alias` activation rework (Option B) + `pos config` listing readability, per the 2026-08-26 Architect/Designer specs (`AgentsReport/{architect,designer}/2026-08-26-*.md`). **Alias activation:** the stale sourced-snapshot mechanism is gone — every `pos ai alias` invocation runs `_alias_sync()` (two-way reconciliation: render-diff-install of one executable wrapper per ENV record at `~/.local/bin/` chmod 755 via mktemp+mv with a `bash -n` pre-commit guard; marker-guarded deletion of owned wrappers missing from ENV; legacy `ai-aliases.sh` generation stopped and generator-marker-guarded auto-removal with an `unalias ` remediation hint; loud PATH guidance when `~/.local/bin` is off PATH). Edits are live on next invocation with no shell reload (kills the reported stale-gemini-alias bug class); create refuses foreign-file and PATH-binary collisions; `show` gains the wrapper path; `pos-system-uninstall` sweeps the wrappers by their line-2 marker in discovery+removal. Dup-table menu bug fixed with a single `_alias_table` renderer (menu option 4 returns to the loop whose pre-render already shows fresh state). **Config readability** (`lib/config-ui.sh`, fully generic): new optional `# POS_CONFIG:` field types — `@Caption` / `@[KEY=v1|v2] Caption` group captions (condition evaluated per render via `cfg_value`; inactive groups dimmed with a textual reason, never hidden → numbering stable; empty-alt segment = unset-as-default) and `*providers=` adapter filtering (zero match warns once + suppresses its caption); uniform typography tier for ALL scopes (bold title/keys, CYAN rule, dim numbers/placeholders/examples/captions, hanging-indent wrap clamped 60–120 cols, whole render block → stderr per menu-lib house pattern, honest prompt `Number to edit [r=refresh, q=quit]:`); masking/edit flow byte-compatible, no per-scope branches. `bin/pos-ai` line-6 header adopted to the caption/tag syntax (single-line change). Verified: stub-PATH harness (`HOME=/tmp/…`, `CONFIG_DIR` seam, argv-capturing `pos` shim) covering %q quoting round-trips (quotes/backticks/`$()`/%/unicode), staleness kill-test, orphan retraction, collision-refusal matrix, legacy migration (marker + foreign), PATH-absent warning, non-tty guard, idempotent double-sync; rendered-output diffs vs Designer mockups for `ai` AND old-format `system`; gates `make gen && make check && make lint` 0 FAIL / 0 WARN. diff --git a/DOC/AGENT_Context_Project.md b/DOC/AGENT_Context_Project.md index 3ee2b8b..18c8c14 100644 --- a/DOC/AGENT_Context_Project.md +++ b/DOC/AGENT_Context_Project.md @@ -10,19 +10,19 @@ | ## 1. Project Overview | 28–43 | -| ## 2. Directory Structure | 44–200 | -| ## 3. Installation Flow | 201–254 | -| ## 4. The `pos` CLI System | 255–332 | -| ## 5. Shared Library — `lib/common.sh` | 333–364 | -| ## 6. Docker Compose / ScaleTail | 365–407 | -| ## 7. Optional Apps (`apps/`) | 408–437 | -| ## 8. Entertainment Module | 438–451 | -| ## 9. Systemd Services | 452–463 | -| ## 10. Configuration Files | 464–490 | -| ## 11. Coding Conventions | 491–523 | -| ## 12. Development Workflow | 524–576 | -| ## 13. Key File Quick Reference | 577–648 | -| ## 14. Common Tasks for Agents | 649–682 | +| ## 2. Directory Structure | 44–204 | +| ## 3. Installation Flow | 205–258 | +| ## 4. The `pos` CLI System | 259–336 | +| ## 5. Shared Library — `lib/common.sh` | 337–368 | +| ## 6. Docker Compose / ScaleTail | 369–411 | +| ## 7. Optional Apps (`apps/`) | 412–441 | +| ## 8. Entertainment Module | 442–455 | +| ## 9. Systemd Services | 456–467 | +| ## 10. Configuration Files | 468–494 | +| ## 11. Coding Conventions | 495–527 | +| ## 12. Development Workflow | 528–580 | +| ## 13. Key File Quick Reference | 581–652 | +| ## 14. Common Tasks for Agents | 653–686 | ## 1. Project Overview @@ -72,6 +72,7 @@ Linux_post_install/ │ ├── pos-docker-compose # Docker Compose service manager (ls/up/down/restart/logs/update/config) │ ├── pos-docker-health # One-glance container health dashboard (exits 1 if unhealthy) │ ├── pos-docker-ps # Enhanced container overview (health, IPs, ports, uptime) +│ │ [deps: docker] │ ├── pos-docker-stack # Containers grouped by compose stack (project); standalone group; -a/--all includes stopped │ ├── pos-docker-vbox # Disposable Docker-based VMs (create/enter/start/stop/rm/ls) │ ├── pos-entertainment-config # Show or edit the entertainment config (ENABLED auto-trigger list, weather location) @@ -82,9 +83,11 @@ Linux_post_install/ │ ├── pos-media-mp3 # Download audio as MP3 (yt-dlp) │ ├── pos-media-mp4 # Download video as MP4 (smart/interactive format select) │ ├── pos-media-sync # Incremental Music → USB sync (mp3/mp4, add/update only) +│ │ [deps: lsblk jq] │ ├── pos-media-ytsync # Incrementally sync YouTube channels/playlists into ~/Videos │ ├── pos-network-checkport # Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view │ ├── pos-network-download # aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) +│ │ [deps: aria2c jq curl] │ ├── pos-network-hotspot # Wi-Fi hotspot via create_ap + wihotspot-gui │ ├── pos-network-ip # Show interfaces, routes, public IP + location │ ├── pos-network-scan # Parallel ping sweep of CIDR @@ -95,6 +98,7 @@ Linux_post_install/ │ ├── pos-share-usb-server # USB Redirector server control (--ls, --share; prompts when args omitted) │ ├── pos-ssh-load-keys # Load all SSH keys into the agent │ ├── pos-system-backup # Encrypted (AES-256) folder snapshots (tar + gpg) +│ │ [deps: tar] │ ├── pos-system-firewall # Interactive UFW management │ ├── pos-system-health # Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL │ ├── pos-system-schedule # Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently @@ -268,50 +272,50 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst ### Available Commands -| Category | Command | Script | Description | -|----------|---------|--------|-------------| -| ai | alias | `pos-ai-alias` | manage AI agent aliases | -| ai | gemini | `pos-ai-gemini` | Forward to pos ai --provider gemini (backward compat) | -| ai | openrouter | `pos-ai-openrouter` | Forward to pos ai --provider openrouter (backward compat) | -| communication | matrix-listener | `pos-communication-matrix-listener` | Matrix listener: map /command → bash, run them on room messages | -| communication | matrix-sender | `pos-communication-matrix-sender` | Send messages to a Matrix room via the client-server API (send, test, login) | -| communication | scrcpy | `pos-communication-scrcpy` | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) | -| communication | telegram-listener | `pos-communication-telegram-listener` | Telegram bot listener: map /command → bash, run them on chat messages | -| communication | telegram-sender | `pos-communication-telegram-sender` | Send Telegram messages/files/links/stickers via Bot API (send, test) | -| docker | compose | `pos-docker-compose` | Docker Compose service manager (ls/up/down/restart/logs/update/config) | -| docker | health | `pos-docker-health` | One-glance container health dashboard (exits 1 if unhealthy) | -| docker | ps | `pos-docker-ps` | Enhanced container overview (health, IPs, ports, uptime) | -| docker | stack | `pos-docker-stack` | Containers grouped by compose stack (project); standalone group; -a/--all includes stopped | -| docker | vbox | `pos-docker-vbox` | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) | -| entertainment | config | `pos-entertainment-config` | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) | -| entertainment | disable | `pos-entertainment-disable` | Disable a plugin's auto-trigger (remove it from ENABLED) | -| entertainment | enable | `pos-entertainment-enable` | Enable an auto-trigger for a plugin on a schedule | -| entertainment | send | `pos-entertainment-send` | Run a public-API plugin and send its output via the configured notify platforms | -| entertainment | status | `pos-entertainment-status` | Show enabled plugins and scheduler state | -| media | mp3 | `pos-media-mp3` | Download audio as MP3 (yt-dlp) | -| media | mp4 | `pos-media-mp4` | Download video as MP4 (smart/interactive format select) | -| media | sync | `pos-media-sync` | Incremental Music → USB sync (mp3/mp4, add/update only) | -| media | ytsync | `pos-media-ytsync` | Incrementally sync YouTube channels/playlists into ~/Videos | -| network | checkport | `pos-network-checkport` | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view | -| network | download | `pos-network-download` | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) | -| network | hotspot | `pos-network-hotspot` | Wi-Fi hotspot via create_ap + wihotspot-gui | -| network | ip | `pos-network-ip` | Show interfaces, routes, public IP + location | -| network | scan | `pos-network-scan` | Parallel ping sweep of CIDR | -| share | nfs-client | `pos-share-nfs-client` | Mount NFS shares (ephemeral or persistent systemd mount units) | -| share | nfs-server | `pos-share-nfs-server` | Manage the NFS kernel server (status, share/unshare exports, enable/disable) | -| share | smb-client | `pos-share-smb-client` | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) | -| share | smb-server | `pos-share-smb-server` | Manage the Samba server (status, share/unshare exports, users, enable/disable) | -| share | usb-server | `pos-share-usb-server` | USB Redirector server control (--ls, --share; prompts when args omitted) | -| ssh | load-keys | `pos-ssh-load-keys` | Load all SSH keys into the agent | -| system | backup | `pos-system-backup` | Encrypted (AES-256) folder snapshots (tar + gpg) | -| system | firewall | `pos-system-firewall` | Interactive UFW management | -| system | health | `pos-system-health` | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL | -| system | schedule | `pos-system-schedule` | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently | -| system | uninstall | `pos-system-uninstall` | Remove pos toolkit binaries, services, shell integration, config, and data | -| | ai | `pos-ai` | AI assistant: ask, chat, sessions, capture, models, providers | -| | config | `pos-config` | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) | -| | tree | `pos-tree` | Show the pos CLI command tree: categories, commands, and subcommands | +| Category | Command | Script | Description | Deps | Examples | +|----------|---------|--------|-------------|------|----------| +| ai | alias | `pos-ai-alias` | manage AI agent aliases | | | +| ai | gemini | `pos-ai-gemini` | Forward to pos ai --provider gemini (backward compat) | | | +| ai | openrouter | `pos-ai-openrouter` | Forward to pos ai --provider openrouter (backward compat) | | | +| communication | matrix-listener | `pos-communication-matrix-listener` | Matrix listener: map /command → bash, run them on room messages | | | +| communication | matrix-sender | `pos-communication-matrix-sender` | Send messages to a Matrix room via the client-server API (send, test, login) | | | +| communication | scrcpy | `pos-communication-scrcpy` | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) | | | +| communication | telegram-listener | `pos-communication-telegram-listener` | Telegram bot listener: map /command → bash, run them on chat messages | | | +| communication | telegram-sender | `pos-communication-telegram-sender` | Send Telegram messages/files/links/stickers via Bot API (send, test) | | | +| docker | compose | `pos-docker-compose` | Docker Compose service manager (ls/up/down/restart/logs/update/config) | | | +| docker | health | `pos-docker-health` | One-glance container health dashboard (exits 1 if unhealthy) | | | +| docker | ps | `pos-docker-ps` | Enhanced container overview (health, IPs, ports, uptime) | docker | | +| docker | stack | `pos-docker-stack` | Containers grouped by compose stack (project); standalone group; -a/--all includes stopped | | | +| docker | vbox | `pos-docker-vbox` | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) | | | +| entertainment | config | `pos-entertainment-config` | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) | | | +| entertainment | disable | `pos-entertainment-disable` | Disable a plugin's auto-trigger (remove it from ENABLED) | | | +| entertainment | enable | `pos-entertainment-enable` | Enable an auto-trigger for a plugin on a schedule | | | +| entertainment | send | `pos-entertainment-send` | Run a public-API plugin and send its output via the configured notify platforms | | | +| entertainment | status | `pos-entertainment-status` | Show enabled plugins and scheduler state | | | +| media | mp3 | `pos-media-mp3` | Download audio as MP3 (yt-dlp) | | | +| media | mp4 | `pos-media-mp4` | Download video as MP4 (smart/interactive format select) | | | +| media | sync | `pos-media-sync` | Incremental Music → USB sync (mp3/mp4, add/update only) | lsblk jq | pos media sync --mp3 → Sync only MP3 files to USB · pos media sync --mp4 --dry-run → Preview MP4 sync without copying | +| media | ytsync | `pos-media-ytsync` | Incrementally sync YouTube channels/playlists into ~/Videos | | | +| network | checkport | `pos-network-checkport` | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view | | | +| network | download | `pos-network-download` | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) | aria2c jq curl | pos network download add https://example.com/file.zip → Enqueue an HTTP download (auto-starts daemon) · pos network download status → Daemon health + global transfer stats · pos network download watch → Live progress view | +| network | hotspot | `pos-network-hotspot` | Wi-Fi hotspot via create_ap + wihotspot-gui | | | +| network | ip | `pos-network-ip` | Show interfaces, routes, public IP + location | | | +| network | scan | `pos-network-scan` | Parallel ping sweep of CIDR | | | +| share | nfs-client | `pos-share-nfs-client` | Mount NFS shares (ephemeral or persistent systemd mount units) | | | +| share | nfs-server | `pos-share-nfs-server` | Manage the NFS kernel server (status, share/unshare exports, enable/disable) | | | +| share | smb-client | `pos-share-smb-client` | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) | | | +| share | smb-server | `pos-share-smb-server` | Manage the Samba server (status, share/unshare exports, users, enable/disable) | | | +| share | usb-server | `pos-share-usb-server` | USB Redirector server control (--ls, --share; prompts when args omitted) | | | +| ssh | load-keys | `pos-ssh-load-keys` | Load all SSH keys into the agent | | | +| system | backup | `pos-system-backup` | Encrypted (AES-256) folder snapshots (tar + gpg) | tar | | +| system | firewall | `pos-system-firewall` | Interactive UFW management | | | +| system | health | `pos-system-health` | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL | | | +| system | schedule | `pos-system-schedule` | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently | | | +| system | uninstall | `pos-system-uninstall` | Remove pos toolkit binaries, services, shell integration, config, and data | | | +| | ai | `pos-ai` | AI assistant: ask, chat, sessions, capture, models, providers | | | +| | config | `pos-config` | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) | | | +| | tree | `pos-tree` | Show the pos CLI command tree: categories, commands, and subcommands | | | ### Legacy Wrappers @@ -598,8 +602,8 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `features/autostart.sh` | 50 | Boot-time feature (moved from `bin/`, flag-gated service) | | `features/usb-automount.sh` | 138 | USB automount feature (udev rule + flag-gated service) | -| `bin/pos` | 295 | CLI dispatcher with smart arg matching + logging + category help | -| `bin/pos-ai-alias` | 542 | manage AI agent aliases | +| `bin/pos` | 302 | CLI dispatcher with smart arg matching + logging + category help | +| `bin/pos-ai-alias` | 658 | manage AI agent aliases | | `bin/pos-ai-gemini` | 7 | Forward to pos ai --provider gemini (backward compat) | | `bin/pos-ai-openrouter` | 7 | Forward to pos ai --provider openrouter (backward compat) | | `bin/pos-communication-matrix-listener` | 568 | Matrix listener: map /command → bash, run them on room messages | @@ -609,7 +613,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/pos-communication-telegram-sender` | 221 | Send Telegram messages/files/links/stickers via Bot API (send, test) | | `bin/pos-docker-compose` | 487 | Docker Compose service manager (ls/up/down/restart/logs/update/config) | | `bin/pos-docker-health` | 107 | One-glance container health dashboard (exits 1 if unhealthy) | -| `bin/pos-docker-ps` | 125 | Enhanced container overview (health, IPs, ports, uptime) | +| `bin/pos-docker-ps` | 126 | Enhanced container overview (health, IPs, ports, uptime) | | `bin/pos-docker-stack` | 101 | Containers grouped by compose stack (project); standalone group; -a/--all includes stopped | | `bin/pos-docker-vbox` | 1125 | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) | | `bin/pos-entertainment-config` | 143 | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) | @@ -619,10 +623,10 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/pos-entertainment-status` | 62 | Show enabled plugins and scheduler state | | `bin/pos-media-mp3` | 86 | Download audio as MP3 (yt-dlp) | | `bin/pos-media-mp4` | 132 | Download video as MP4 (smart/interactive format select) | -| `bin/pos-media-sync` | 216 | Incremental Music → USB sync (mp3/mp4, add/update only) | +| `bin/pos-media-sync` | 219 | Incremental Music → USB sync (mp3/mp4, add/update only) | | `bin/pos-media-ytsync` | 1191 | Incrementally sync YouTube channels/playlists into ~/Videos | | `bin/pos-network-checkport` | 496 | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view | -| `bin/pos-network-download` | 1104 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) | +| `bin/pos-network-download` | 1108 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) | | `bin/pos-network-hotspot` | 93 | Wi-Fi hotspot via create_ap + wihotspot-gui | | `bin/pos-network-ip` | 69 | Show interfaces, routes, public IP + location | | `bin/pos-network-scan` | 272 | Parallel ping sweep of CIDR | @@ -632,11 +636,11 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/pos-share-smb-server` | 441 | Manage the Samba server (status, share/unshare exports, users, enable/disable) | | `bin/pos-share-usb-server` | 362 | USB Redirector server control (--ls, --share; prompts when args omitted) | | `bin/pos-ssh-load-keys` | 31 | Load all SSH keys into the agent | -| `bin/pos-system-backup` | 292 | Encrypted (AES-256) folder snapshots (tar + gpg) | +| `bin/pos-system-backup` | 293 | Encrypted (AES-256) folder snapshots (tar + gpg) | | `bin/pos-system-firewall` | 325 | Interactive UFW management | | `bin/pos-system-health` | 209 | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL | | `bin/pos-system-schedule` | 151 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently | -| `bin/pos-system-uninstall` | 415 | Remove pos toolkit binaries, services, shell integration, config, and data | +| `bin/pos-system-uninstall` | 435 | Remove pos toolkit binaries, services, shell integration, config, and data | | `bin/pos-ai` | 680 | AI assistant: ask, chat, sessions, capture, models, providers | | `bin/pos-config` | 80 | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) | | `bin/pos-tree` | 118 | Show the pos CLI command tree: categories, commands, and subcommands | diff --git a/DOC/POS.md b/DOC/POS.md index 6949c2a..979f22b 100644 --- a/DOC/POS.md +++ b/DOC/POS.md @@ -75,9 +75,9 @@ Category-less tools (`config`, `tree`) live outside any category and are documen | `pos ai alias edit [name]` | Edits an existing alias (pick from list or pass the name): provider/session/prompt are re-prompted pre-filled with the current values — Enter keeps the current value; a per-field changed/unchanged summary is confirmed (default yes) before saving; nothing is written if nothing changed | | `pos ai alias remove [name]` | Removes an alias (pick from list or pass the name); the confirmation defaults to **no** and removal cannot be undone | | `pos ai alias list` | Non-interactive: prints all aliases as a Name/Provider/Session/Prompt table (prompts truncated at 42 chars) | -| `pos ai alias show ` | Prints one alias's details including the resolved command: `pos ai ask --session [ --system '']` | +| `pos ai alias show ` | Prints one alias's details including the wrapper path and the resolved command: `pos ai ask --session [ --system '']` | -Alias storage: records live in `~/.config/linux_post_install/ai-aliases.env` — one `name\|provider\|session\|system_prompt` line per alias, chmod 600, managed by the tool (do not hand-edit). Every create/edit/remove also rewrites `~/.config/linux_post_install/ai-aliases.sh` (chmod 644), which defines one shell alias per record — `alias ='pos ai ask --session [ --system '']'`. An empty session falls back to the alias name, prompts are single-quote-escaped, and the generated file is syntax-checked before it replaces the previous version. Source this file from your shell rc (e.g. `.bashrc`) to activate the aliases in new shells. +Alias storage & activation: records live in `~/.config/linux_post_install/ai-aliases.env` — one `name\|provider\|session\|system_prompt` line per alias, chmod 600, managed by the tool (do not hand-edit); an empty session falls back to the alias name. **Activation needs no shell sourcing**: every `pos ai alias` invocation syncs the ENV file (the single source of truth) against executable wrapper scripts at `~/.local/bin/` (chmod 755) — missing or changed wrappers are atomically rewritten, wrappers pos owns but ENV no longer lists are deleted, and hand-edited wrappers are healed. A wrapper re-reads its bytes on every run, so an edit is **live on the next invocation** (no reload), and the scripts work identically in interactive shells, scripts, cron, and non-login ssh sessions (`~/.local/bin` must stay on `PATH` — a loud warning with a copy-paste fix appears when it isn't). Create refuses name collisions: a foreign file at `~/.local/bin/` and names resolving to another binary on `PATH` are never overwritten. The legacy generated `~/.config/linux_post_install/ai-aliases.sh` is no longer written; on the next invocation pos removes it automatically (marker-guarded — a foreign-content file is left untouched with a warning) and prints an `unalias ` remediation hint for already-running shells (or simply start a new shell). Backward compatibility: `pos ai gemini` and `pos ai openrouter` still work as shorthands for `pos ai --provider gemini` and `pos ai --provider openrouter`. @@ -485,6 +485,8 @@ Feature-flag management CLIs (see [SCRIPTS.md → lib/flags.sh](SCRIPTS.md#libfl `pos config` is the interactive editor for the tools' runtime config (see [DEV.md](DEV.md#config-files) and §10 of AGENT_Context). Every tool exposes its configuration by declaring a `# POS_CONFIG:` header; `pos config` reads those at runtime — it knows nothing about the variables themselves. Values live in `~/.config/linux_post_install/.env` (chmod 600). +Headers may also declare **group captions**: `@Caption` starts a visual group, and `@[KEY=v1|v2] Caption` makes the group conditional — while `KEY`'s current value matches none of the listed alternatives, the group stays visible but dimmed with a textual reason (`— inactive while KEY=…`), so row numbering never changes mid-session. Wildcards can be tagged: `*providers=` pulls keys from a single AI provider adapter instead of all of them. The listing renders uniformly for every scope (bold title/keys, dim numbers/examples/placeholders, word-wrapped descriptions); at the prompt type a number to edit, `r` to refresh, or `q` to quit. + | Command | Purpose | |---------|---------| | `pos config` | Scope picker (on a TTY), otherwise the scope list | diff --git a/DOC/SCRIPTS.md b/DOC/SCRIPTS.md index 1f78d6f..a01e6e6 100644 --- a/DOC/SCRIPTS.md +++ b/DOC/SCRIPTS.md @@ -12,6 +12,12 @@ Everything that runs during the bootstrap install: `install.sh`, `preinstall.sh` - [lib/common.sh — shared library](#libcommonsh--shared-library) - [lib/flags.sh — feature flags](#libflagssh--feature-flags) - [lib/notify.sh — multi-platform alerting](#libnotifysh--multi-platform-alerting) +- [lib/entertainment-lib.sh — entertainment module](#libentertainmentlibsh--entertainment-module) +- [lib/user-timers-lib.sh — shared systemd user timers](#libusertimerslibsh--shared-systemd-user-timers) +- [lib/usb-lib.sh — shared USB-storage detection](#libusblibsh--shared-usb-storage-detection) +- [lib/share-lib.sh — share-suite domain layer + compat shims](#sharelibsh--share-suite-domain-layer--compat-shims) +- [lib/menu-lib.sh — category-neutral menu primitives](#libmenulibsh--category-neutral-menu-primitives) +- [lib/registry.sh — tool metadata query API](#libregistrysh--tool-metadata-query-api) - [features/autostart.sh — boot-time feature](#featuresautostartsh--boot-time-feature) - [features/usb-automount.sh — USB automount feature](#featuresusb-automountsh--usb-automount-feature) - [x64_bin/ — precompiled binaries](#x64_bin--precompiled-binaries) @@ -38,7 +44,7 @@ The phases: | # | Phase | Script/action | |---|-------|----------------| | 1 | preinstall | `preinstall.sh` — apt packages + yt-dlp | -| 2 | scripts | Copies `bin/*` → `/usr/local/bin/` (755), `lib/common.sh` + `lib/flags.sh` + `lib/notify.sh` + `lib/entertainment-lib.sh` + `lib/entertainment-plugin-lib.sh` + `lib/scheduler-lib.sh` + `lib/config-ui.sh` + `lib/user-timers-lib.sh` + `lib/usb-lib.sh` + `lib/share-lib.sh` + `lib/menu-lib.sh` → `/usr/local/bin/` (644). Copies precompiled arch binaries from `x64_bin/` (or `arm64_bin/`) → `/usr/local/bin/`. With `--feature`: also installs `features/*` (see below) | +| 2 | scripts | Copies `bin/*` → `/usr/local/bin/` (755), `lib/common.sh` + `lib/flags.sh` + `lib/notify.sh` + `lib/entertainment-lib.sh` + `lib/entertainment-plugin-lib.sh` + `lib/scheduler-lib.sh` + `lib/config-ui.sh` + `lib/user-timers-lib.sh` + `lib/usb-lib.sh` + `lib/share-lib.sh` + `lib/menu-lib.sh` + `lib/registry.sh` → `/usr/local/bin/` (644). Copies precompiled arch binaries from `x64_bin/` (or `arm64_bin/`) → `/usr/local/bin/`. With `--feature`: also installs `features/*` (see below) | | 3 | postinstall | `postinstall.sh` — PATH, completion, SSH keys, systemd | | 4 | scalepoint | Shallow-clones ScaleTail templates to `/usr/local/share/linux_post_install/scale-tail` | | 5 (opt) | apps | `apps/install.sh` when `--apps` (interactive) or `--full` (all, non-interactive) | @@ -228,6 +234,15 @@ Sourced by `bin/pos-entertainment-send|config|enable|disable|status` (after `lib --- +## lib/registry.sh — tool metadata query API + +**File:** `lib/registry.sh` (installed to `/usr/local/bin/registry.sh`) +**Purpose:** the one query API over the tools' `# POS_*:` metadata headers, so consumers source it instead of re-implementing sed/grep header scans. `reg_scan [dir]` reads every executable `pos-*` file once — sorted under `LC_ALL=C`, and cheap enough to call lazily (plain dispatch paths skip it entirely); each tool's key is its filename after `pos-` with the category split off at the first dash (category-less tools carry an empty category). The populated stores serve `reg_list`, `reg_categories`, `reg_tools_in` and `reg_lookup ` with fields `cat|desc|flags|subcmds|deps|examples` (`deps`/`examples` come from the optional `# POS_DEPS:` / `# POS_EXAMPLES:` headers); the multi-line `# POS_CONFIG:` registry gets its own helpers (`reg_config_scopes`, `reg_config_keys`, `reg_config_envfile`); `reg_each ` iterates every tool calling `cb(category, tool_key, description)`; `reg_tool_exists` is the membership probe. Like `lib/config-ui.sh` it defines guarded `log`/`warn`/`err` fallbacks so it sources cleanly without `lib/common.sh`; no shebang and never executed (installed 644). + +Sourced by `bin/pos-tree` (tree rendering incl. the `[deps: …]` annotations) and by `bin/pos` `_pos_category_help()` for `pos --help` (lazy load there, so plain dispatch never pays the scan cost). `scripts/gen-docs.sh` predates the registry and keeps parsing the same headers independently for its generated blocks; new consumers should prefer the registry. + +--- + ## features/autostart.sh — boot-time feature **File:** `features/autostart.sh` (installed to `/usr/local/bin/autostart.sh` by `./install.sh --feature`) diff --git a/MAINTENANCE.md b/MAINTENANCE.md index 1f1c3f4..571ebae 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -275,6 +275,17 @@ Recommended fix: add rows to the POS.md command table (and cross-check HOWTO for Verification: lint WARNs gone; `grep` shows each tool in POS.md. Fix (2026-08-14): the tools were documented by command name but not by filename (the lint references basenames). Added `**File:** bin/pos-config` (config section), `**File:** bin/pos-tree` (tree section), and a file list on the entertainment section header covering `bin/pos-entertainment-{config,enable,disable,status}`. Verified: lint 0 WARN. HOWTO already covers the entertainment group via `pos entertainment *` command forms. +### M-024 +Status: VERIFIED +Severity: LOW +Category: docs +Files: AGENTS.md:17-18; DOC/SCRIPTS.md (Phase-2 lib list, TOC, new lib section) +Evidence: the command-registry feature landed (`lib/registry.sh`, 199 lines; optional `# POS_DEPS:`/`# POS_EXAMPLES:` headers already codified in `templates/pos-tool.sh:13-14` and `DOC/DEV.md:126-134`) but three docs kept describing the old reality: AGENTS.md Quick facts enumerated only `POS_FLAGS`/`SUBCMDS`/`CONFIG` with no mention of the shared query API; DOC/SCRIPTS.md's Phase-2 lib list omitted `registry.sh` and had no section for it (its TOC also lacked five pre-existing lib sections). +Expected: docs describe what IS — code + `# POS:` headers are ground truth (Phase 0 rule 4). +Recommended fix: sync the three drifted docs to implemented reality; no code/template/completion changes. +Verification: `make gen` produces zero diff beyond pre-existing work; `make check` green; `make lint` 0 FAIL / 0 WARN; `grep -n "POS_DEPS"` hits AGENTS.md, DEV.md, SCRIPTS.md. +Fix (2026-08-26): template `templates/pos-tool.sh` now documents the optional `# POS_DEPS:`/`# POS_EXAMPLES:` headers (pre-existing); `lib/registry.sh` added as the shared query API over all `POS_*` headers (`reg_scan` + `reg_list`/`reg_lookup`/…) — AGENTS.md Tool-model + Categories bullets updated, DOC/SCRIPTS.md got the lib-list row (install.sh:143 order), a per-lib reference section, and a completed TOC. Consumers were already migrated (`bin/pos-tree`, `bin/pos` `_pos_category_help()`); lint unchanged (0 FAIL / 0 WARN). + ### P3 — intentional / legacy (no action) - install.sh:123,135,155,185 — installer writes to /usr/local/bin are its purpose; no seam needed (lint excludes install scripts). - network-download RPC_SECRET at :150 — generated at runtime (`/dev/urandom`), not a committed secret. diff --git a/bin/pos b/bin/pos index 8c9ca1f..a797f01 100755 --- a/bin/pos +++ b/bin/pos @@ -66,21 +66,27 @@ _pos_category_exists() { } _pos_category_help() { - local cat="$1" f - local files=() s d - for f in "$self"/pos-"$cat"-*; do - [ -x "$f" ] || continue - files+=("${f##*/pos-$cat-}") - done + local cat="$1" s s2 extra d sc deps + # Lazy registry load — plain dispatch paths never pay the scan cost. + source "$self/../lib/registry.sh" 2>/dev/null || source "$self/registry.sh" + reg_scan "$self" + + local files=() + local t + while IFS= read -r t; do + [ -n "$t" ] || continue + files+=("${t#"$cat"-}") + done < <(reg_tools_in "$cat") mapfile -t files < <(printf '%s\n' "${files[@]}" | sort -u) - local -A desc subcmds - local sc + local -A desc subcmds _deps for s in "${files[@]}"; do - d="$(sed -n '/^# POS: /{s/^# POS: //;p;q}' "$self/pos-$cat-$s" 2>/dev/null)" - [ -n "$d" ] && desc["$s"]="${d#*— }" - sc="$(sed -n '/^# POS_SUBCMDS: /{s/^# POS_SUBCMDS: //;p;q}' "$self/pos-$cat-$s" 2>/dev/null)" + d="$(reg_lookup "$cat-$s" desc)" + [ -n "$d" ] && desc["$s"]="$d" + sc="$(reg_lookup "$cat-$s" subcmds)" [ -n "$sc" ] && subcmds["$s"]="$sc" + deps="$(reg_lookup "$cat-$s" deps)" + [ -n "$deps" ] && _deps["$s"]="$deps" done # Nested sub-tools: pos--- lists "b" under . @@ -113,6 +119,7 @@ _pos_category_help() { done [ "$is_nested" -eq 1 ] && continue printf ' %-28s%s\n' "$s" "${desc[$s]:-}" + [ -n "${_deps[$s]:-}" ] && printf ' [deps: %s]\n' "${_deps[$s]}" for c in ${subcmds[$s]:-}; do printf ' %s %s\n' "$s" "$c" done @@ -259,7 +266,7 @@ MAIN_LOG="$LOG_DIR/pos.log" log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; } # Commands that read from stdin interactively — only log invocation -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" +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 ai-alias system-schedule entertainment-config config" for ((i=n-1; i>=0; i--)); do cmd="pos" diff --git a/bin/pos-ai b/bin/pos-ai index fd009bf..b853285 100755 --- a/bin/pos-ai +++ b/bin/pos-ai @@ -3,7 +3,7 @@ set -euo pipefail # POS: ai ask — AI assistant: ask, chat, sessions, capture, models, providers # POS_SUBCMDS: ask chat sessions capture models providers # POS_FLAGS: --provider --model --session --system --full --last -# POS_CONFIG: ai | ai.env | AI_PROVIDER=:Provider (gemini or openrouter, default gemini) | *providers | AI_SYSTEM_PROMPT=:Custom system prompt (overrides built-in, empty to reset) +# POS_CONFIG: ai | ai.env | AI_PROVIDER=:Provider (gemini or openrouter, default gemini) | @[AI_PROVIDER=gemini|] Gemini | *providers=gemini | @[AI_PROVIDER=openrouter] OpenRouter | *providers=openrouter | @General | AI_SYSTEM_PROMPT=:Custom system prompt (overrides built-in, empty to reset) source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" diff --git a/bin/pos-ai-alias b/bin/pos-ai-alias index f30611f..b245dbd 100755 --- a/bin/pos-ai-alias +++ b/bin/pos-ai-alias @@ -48,35 +48,134 @@ _alias_save() { chmod 600 "$ENV_FILE" } -_alias_regen() { - local tmp - tmp="$(mktemp)" - printf '%s\n' "#!/usr/bin/env bash" >"$tmp" - printf '%s\n\n' "# Auto-generated by pos ai alias — do not hand-edit." >>"$tmp" - if [ -f "$ENV_FILE" ]; then - while IFS='|' read -r name provider session prompt _rest; do - [[ "$name" =~ ^[[:space:]]*# ]] && continue - [[ -z "${name// /}" ]] && continue - name="${name## }"; name="${name%% }" - [[ "$name" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] || continue - provider="${provider## }"; provider="${provider%% }" - session="${session## }"; session="${session%% }" - [ -z "$session" ] && session="$name" - 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) +# Build the pos-ai command for an alias with the prompt safely quoted as +# ONE shell word (printf %q) — shared by regen (stored form) and show +# (copy-pasteable display form). Empty prompt → no --system fragment. +_alias_quote_cmd() { + local provider="$1" session="$2" prompt="${3:-}" q_prompt + printf -v q_prompt '%q' "$prompt" + printf 'pos ai %s ask --session %s' "$provider" "$session" + [ -n "$prompt" ] && printf ' --system %s' "$q_prompt" + return 0 +} + +# ── Activation artifacts (Option B) ──────────────────────────── +# ENV stays the single source of truth; each alias is materialized as an +# executable wrapper script at ~/.local/bin/. Every invocation re-reads +# current bytes, so a stale snapshot (the old sourced-alias failure mode) is +# impossible by construction. No shell sourcing of any kind. + +_wrapper_path() { + printf '%s/.local/bin/%s' "$HOME" "$1" +} + +# Ownership test: line 2 must carry our generator marker. Files failing this +# test are NEVER overwritten or deleted. +_alias_owned() { + [ -f "$1" ] && sed -n '2p' "$1" 2>/dev/null | grep -q 'Managed by pos ai alias' +} + +# Render one wrapper to stdout (args: name provider session prompt). +# The exec line reuses _alias_quote_cmd's double-%q mechanics so the prompt +# lands as exactly ONE shell word; "$@" passes user args through. +_wrapper_render() { + local name="$1" provider="$2" session="$3" prompt="${4:-}" + cat <"$tmp" + if cmp -s "$tmp" "$path"; then + rm -f "$tmp" + return 0 fi if ! bash -n "$tmp" 2>/dev/null; then - warn "Generated alias file has syntax errors — keeping previous version" + warn "Wrapper for '$1' failed syntax check — keeping previous version" >&2 rm -f "$tmp" return 1 fi - mv "$tmp" "$SH_FILE" - chmod 644 "$SH_FILE" + mv "$tmp" "$path" + chmod 755 "$path" +} + +# rc 0 iff ~/.local/bin is on PATH. +_alias_check_path() { + case ":$PATH:" in + *":$HOME/.local/bin:"*) return 0 ;; + *) return 1 ;; + esac +} + +# Legacy ~/.config/.../ai-aliases.sh retirement: activation moved to wrapper +# scripts, and a stale sourced alias would shadow them (interactive bash gives +# aliases precedence over PATH lookups). Marker-guarded auto-remove only — +# foreign files are warned about and left untouched. +_alias_retire_legacy_sh() { + [ -f "$SH_FILE" ] || return 0 + if ! head -n 3 "$SH_FILE" | grep -q 'Auto-generated by pos ai alias'; then + warn "$SH_FILE was not generated by pos ai alias — left untouched; review manually" + return 0 + fi + local stale + stale="$(sed -n 's/^alias \([A-Za-z_][A-Za-z0-9_-]*\)=.*/\1/p' "$SH_FILE" | tr '\n' ' ')" + stale="${stale% }" + rm -f "$SH_FILE" + { + echo "[!] Alias activation moved to executable scripts in ~/.local/bin/ — legacy file removed: $SH_FILE" + [ -n "$stale" ] && echo " Stale sourced aliases shadow the new scripts until cleaned — run: unalias $stale" + echo " (or simply start a new shell)" + } >&2 + return 0 +} + +# Two-way reconciliation on EVERY invocation: +# forward: each ENV entry → render-diff-install (first-run migration, +# create/edit/remove consistency, silent heal of hand-edited wrappers) +# reverse: owned wrappers whose name is not in ENV → deleted (covers remove, +# manual ENV edits, and the empty-set case) +# plus: legacy .sh retirement; PATH guidance when owned wrappers exist but +# ~/.local/bin is absent from PATH (wrappers are written regardless). +_alias_sync() { + _alias_load + local bin_dir="${HOME}/.local/bin" i name f base match any=0 + mkdir -p "$bin_dir" + for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do + _wrapper_install "${_ALIAS_NAMES[$i]}" "${_ALIAS_PROVIDERS[$i]}" \ + "${_ALIAS_SESSIONS[$i]}" "${_ALIAS_PROMPTS[$i]}" || : + done + for f in "$bin_dir"/*; do + [ -f "$f" ] || continue + _alias_owned "$f" || continue + base="${f##*/}" + match=0 + for name in ${_ALIAS_NAMES[@]+"${_ALIAS_NAMES[@]}"}; do + [ "$base" = "$name" ] && { match=1; break; } + done + [ "$match" -eq 1 ] || rm -f "$f" + done + _alias_retire_legacy_sh + if ! _alias_check_path; then + for f in "$bin_dir"/*; do + [ -f "$f" ] && _alias_owned "$f" && { any=1; break; } + done + if [ "$any" -eq 1 ]; then + warn "~/.local/bin is not on your PATH — alias scripts will not resolve by name." + warn " Fix now: export PATH=\"\$HOME/.local/bin:\$PATH\"" + warn " Persist it: echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.profile" + fi + fi + return 0 } _alias_provider_pick() { @@ -118,23 +217,27 @@ _alias_prompt_truncate() { # ── Non-interactive output ───────────────────────────────────── -_alias_list() { - _alias_load - local count=${#_ALIAS_NAMES[@]} - printf 'Aliases (%d):\n' "$count" +# SINGLE alias-table renderer — used by `list` (stdout) and the menu +# pre-render (inside its stderr display block). One source of truth for the +# grid so the two contexts can never drift or duplicate each other. +_alias_table() { + local count=${#_ALIAS_NAMES[@]} i [ "$count" -eq 0 ] && return 0 printf ' %-12s %-12s %-12s %s\n' "Name" "Provider" "Session" "Prompt" printf ' %-12s %-12s %-12s %s\n' "------------" "------------" "------------" \ "------------------------------------------" - local i for ((i = 0; i < count; i++)); do printf ' %-12s %-12s %-12s %s\n' "${_ALIAS_NAMES[$i]}" "${_ALIAS_PROVIDERS[$i]}" \ "${_ALIAS_SESSIONS[$i]}" "$(_alias_prompt_truncate "${_ALIAS_PROMPTS[$i]}")" done } +_alias_list() { + printf 'Aliases (%d):\n' "${#_ALIAS_NAMES[@]}" + _alias_table +} + _alias_show() { - _alias_load local idx idx="$(_alias_find "$1")" [ "$idx" = "-1" ] && err "Alias '$1' not found" @@ -144,14 +247,15 @@ _alias_show() { printf ' %-12s %s\n' "Alias:" "$name" printf ' %-12s %s\n' "Provider:" "$provider" printf ' %-12s %s\n' "Session:" "$session" - printf ' %-12s %s\n' "Prompt:" "${prompt:-$(printf '%s' "(default)")}" - # Show the resolved command - local cmd="pos ai $provider ask --session $session" - if [ -n "$prompt" ]; then - local escaped="${prompt//\'/\'\\\'\'}" - cmd="$cmd --system '$escaped'" + if _alias_check_path; then + printf ' %-12s %s\n' "Wrapper:" "$(_wrapper_path "$name")" + else + printf ' %-12s %s\n' "Wrapper:" "(not installed — ~/.local/bin not on PATH)" fi - printf ' %-12s %s\n' "Command:" "$cmd" + printf ' %-12s %s\n' "Prompt:" "${prompt:-$(printf '%s' "(default)")}" + # Show the resolved command (same quoting mechanism as the generated + # wrapper — what users copy from here pastes into a shell verbatim) + printf ' %-12s %s\n' "Command:" "$(_alias_quote_cmd "$provider" "$session" "$prompt")" } # ── Interactive: main menu ───────────────────────────────────── @@ -164,21 +268,8 @@ _alias_menu() { if [ ${#_ALIAS_NAMES[@]} -eq 0 ]; then echo "${YELLOW}[!] No aliases defined yet — create one with option 1.${RESET}" else - local count=${#_ALIAS_NAMES[@]} i - printf ' %-12s %-12s %-12s %s\n' "Name" "Provider" "Session" "Prompt" - printf ' %-12s %-12s %-12s %s\n' "------------" "------------" "------------" \ - "------------------------------------------" - for ((i = 0; i < count; i++)); do - local p="${_ALIAS_PROMPTS[$i]}" - if [ ${#p} -gt 42 ]; then - p="${p:0:42}…" - fi - printf ' %-12s %-12s %-12s %s\n' "${_ALIAS_NAMES[$i]}" "${_ALIAS_PROVIDERS[$i]}" \ - "${_ALIAS_SESSIONS[$i]}" "$p" - done - printf ' %-12s %-12s %-12s %s\n' "------------" "------------" "------------" \ - "------------------------------------------" - printf ' %d alias(es)\n' "$count" + _alias_table + printf ' %d alias(es)\n' "${#_ALIAS_NAMES[@]}" fi echo >&2 } >&2 @@ -189,7 +280,10 @@ _alias_menu() { 1) _alias_create ;; 2) _alias_edit ;; 3) _alias_remove ;; - 4) _alias_list ;; + 4) : ;; # List aliases — the loop's pre-render above IS the current + # table (single renderer, redrawn fresh every iteration); + # option 4 returns to the loop for a fresh render instead + # of printing a second copy (dup-table bug fix). esac done } @@ -220,6 +314,17 @@ _alias_create() { [ -n "$preset_name" ] && return 1 name=""; continue fi + # Collision refusals (never clobber foreign files or real binaries): + # 1. wrapper exists WITH our marker → fine, sync regenerates it + # 2. file exists WITHOUT marker → refuse + # 3. name resolves to another binary on PATH → refuse, naming it + local wpath + wpath="$(_wrapper_path "$name")" + if [ -e "$wpath" ]; then + _alias_owned "$wpath" || err "File '~/.local/bin/$name' already exists and was not created by pos ai alias — pick another name" + elif command -v "$name" >/dev/null 2>&1; then + err "'$name' already exists on PATH as $(command -v "$name") — pick another name" + fi break done @@ -284,8 +389,9 @@ _alias_create() { _ALIAS_SESSIONS+=("$session") _ALIAS_PROMPTS+=("$prompt") _alias_save - _alias_regen - log "Alias '$name' created. Reload shell: source ~/.bashrc" >&2 + _alias_sync + log "Alias '$name' created." >&2 + log "Available immediately: $(_wrapper_path "$name")" >&2 } # ── Interactive: edit ────────────────────────────────────────── @@ -419,8 +525,8 @@ _alias_edit() { _ALIAS_SESSIONS[$idx]="$new_session" _ALIAS_PROMPTS[$idx]="$new_prompt" _alias_save - _alias_regen - log "Alias '$name' updated." >&2 + _alias_sync + log "Alias '$name' updated — the change is live on next invocation." >&2 } # ── Interactive: remove ──────────────────────────────────────── @@ -484,8 +590,9 @@ _alias_remove() { _ALIAS_SESSIONS=("${new_sessions[@]+"${new_sessions[@]}"}") _ALIAS_PROMPTS=("${new_prompts[@]+"${new_prompts[@]}"}") _alias_save - _alias_regen - log "Alias '$name' removed." >&2 + _alias_sync + log "Alias '$name' removed — script deleted from $(_wrapper_path "$name")." >&2 + log "If the name still autocompletes stale in this shell, run: hash -r" >&2 } # ── show ─────────────────────────────────────────────── @@ -510,6 +617,12 @@ Subcommands: list List all aliases (non-interactive, machine-readable) show Show one alias's details +Activation: every alias is materialized as an executable script at +~/.local/bin/, synced automatically on every invocation — no shell +sourcing required. Changes are live on the next invocation, and the +scripts work identically in interactive shells, scripts, cron, and +non-login ssh sessions. + Options: -h|--help Show this help. @@ -526,17 +639,20 @@ EOF } # ── Main dispatch ────────────────────────────────────────────── +# Every subcommand syncs first: artifacts always equal ENV truth before any +# subcommand logic runs (migration, healing, retraction — all automatic). case "${1:-}" in -h|--help) usage ;; - create) shift; _alias_create "${1:-}" ;; - edit) shift; _alias_edit "${1:-}" ;; - remove) shift; _alias_remove "${1:-}" ;; - list) _alias_list ;; + create) shift; _alias_sync; _alias_create "${1:-}" ;; + edit) shift; _alias_sync; _alias_edit "${1:-}" ;; + remove) shift; _alias_sync; _alias_remove "${1:-}" ;; + list) _alias_sync; _alias_list ;; show) [ -n "${2:-}" ] || err "Usage: pos ai alias show " + _alias_sync _alias_show "$2" ;; - "") _alias_menu ;; + "") _alias_sync; _alias_menu ;; *) err "Unknown subcommand '$1' (use -h for help)" ;; esac diff --git a/bin/pos-docker-ps b/bin/pos-docker-ps index 34708ae..d86adad 100755 --- a/bin/pos-docker-ps +++ b/bin/pos-docker-ps @@ -1,6 +1,7 @@ #!/usr/bin/env bash set -euo pipefail # POS: docker ps — Enhanced container overview (health, IPs, ports, uptime) +# POS_DEPS: docker source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" usage() { diff --git a/bin/pos-media-sync b/bin/pos-media-sync index 704c137..6f1cbff 100755 --- a/bin/pos-media-sync +++ b/bin/pos-media-sync @@ -3,6 +3,9 @@ set -euo pipefail # POS: media sync — Incremental Music → USB sync (mp3/mp4, add/update only) # POS_FLAGS: --mp3 --mp4 --source --dry-run # POS_SUBCMDS: menu +# POS_DEPS: lsblk jq +# POS_EXAMPLES: pos media sync --mp3 | Sync only MP3 files to USB +# POS_EXAMPLES: pos media sync --mp4 --dry-run | Preview MP4 sync without copying source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh" diff --git a/bin/pos-network-download b/bin/pos-network-download index d29f7a9..e397a73 100755 --- a/bin/pos-network-download +++ b/bin/pos-network-download @@ -3,6 +3,10 @@ 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 | Enqueue an HTTP download (auto-starts daemon) +# POS_EXAMPLES: pos network download status | Daemon health + global transfer stats +# POS_EXAMPLES: pos network download watch | Live progress view source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh" diff --git a/bin/pos-system-backup b/bin/pos-system-backup index 383935e..3354661 100755 --- a/bin/pos-system-backup +++ b/bin/pos-system-backup @@ -4,6 +4,7 @@ set -euo pipefail # POS_FLAGS: --service --no-encrypt # POS_SUBCMDS: menu # POS_CONFIG: notify | notify.env | NOTIFY_PLATFORM=:Comma-separated notify platforms (default telegram) — shared by backup, firewall, share nfs client/server +# POS_DEPS: tar source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh" diff --git a/bin/pos-system-uninstall b/bin/pos-system-uninstall index 5d9480c..dd6dc32 100755 --- a/bin/pos-system-uninstall +++ b/bin/pos-system-uninstall @@ -95,6 +95,15 @@ scan_tier1() { # User-local binaries [ -f "$HOME/.local/bin/pos-ai-hook.sh" ] && found+=("$HOME/.local/bin/pos-ai-hook.sh") + # pos ai alias wrapper scripts (marker-managed) + if [ -d "$HOME/.local/bin" ]; then + local awf + for awf in "$HOME/.local/bin"/*; do + [ -f "$awf" ] || continue + grep -q 'Managed by pos ai alias' "$awf" 2>/dev/null && found+=("$awf") + done + fi + # Completion file [ -f /usr/local/share/bash-completion/completions/pos.bash ] && found+=("/usr/local/share/bash-completion/completions/pos.bash") @@ -260,6 +269,17 @@ remove_tier1() { # User-local binaries [ -f "$HOME/.local/bin/pos-ai-hook.sh" ] && { rm -f "$HOME/.local/bin/pos-ai-hook.sh" && count=$((count+1)); } + # pos ai alias wrapper scripts (marker-managed only — foreign files untouched) + if [ -d "$HOME/.local/bin" ]; then + local arwf + for arwf in "$HOME/.local/bin"/*; do + [ -f "$arwf" ] || continue + if grep -q 'Managed by pos ai alias' "$arwf" 2>/dev/null; then + rm -f "$arwf" && count=$((count+1)) + fi + done + fi + # Completion file [ -f /usr/local/share/bash-completion/completions/pos.bash ] && { rm -f /usr/local/share/bash-completion/completions/pos.bash && count=$((count+1)); } diff --git a/bin/pos-tree b/bin/pos-tree index 9c54c66..dd4116f 100755 --- a/bin/pos-tree +++ b/bin/pos-tree @@ -4,6 +4,7 @@ set -euo pipefail # POS_FLAGS: --depth source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh" +source "$(dirname "$0")/../lib/registry.sh" 2>/dev/null || source "$(dirname "$0")/registry.sh" usage() { cat < | | =:[::] | ... | *plugins +# # POS_CONFIG: | | | ... | *plugins # basename of the config file under ~/.config/linux_post_install/ +# := =:[::] +# | @ group caption (unconditional) +# | @[=[|…]] conditional group caption — +# active iff KEY's current value +# equals a listed alt; an empty +# alt segment ("gemini|") means +# "or unset (= default)" # secret (masked display + stty -echo input) | digits | num | float # optional value format hint shown in the editor, e.g. "weather,5m joke,10m" # *plugins marker: also list every key declared by the installed # entertainment plugins' "# POS_KEYS:" headers (dynamic) +# *providers[=] marker: keys from lib/ai-providers/*.sh adapters; +# with =, only from .sh (zero match → warn + the +# preceding caption is suppressed) # Example: # # POS_CONFIG: telegram | telegram.env | TELEGRAM_BOT_TOKEN=secret:Bot token | TELEGRAM_CHAT_ID=digits:Numeric chat id # @@ -27,8 +37,18 @@ declare -F warn >/dev/null || warn() { echo "[!] $*"; } declare -F err >/dev/null || err() { echo "ERROR: $*" >&2; exit 1; } declare -F ok >/dev/null || ok() { echo " OK $*"; } +# Color tokens (guarded — mirrors lib/menu-lib.sh): degrade to plain text when +# common.sh didn't define them, never an error on standalone sourcing. +BOLD="${BOLD:-}" +DIM="${DIM:-}" +CYAN="${CYAN:-}" +RESET="${RESET:-}" + _cfg_scope="" # scope being edited (drives the post-write hook) declare -A _cfg_seen=() # key dedupe registry for cfg_scope_keys +_CS=$'\x1f' # unit-separator for caption records — never in env + # names or alt strings, avoids collision with | in + # alternation syntax (AI_PROVIDER=gemini|) # ── tool directory ───────────────────────────────────────────────── # Repo layout: lib/config-ui.sh → tools live in ../bin. @@ -86,6 +106,29 @@ cfg_scope_envfile() { return 1 } +# Split a POS_CONFIG keystring into fields on "|", IGNORING separators inside +# [...] condition brackets (caption conditions legitimately contain pipes, +# e.g. @[AI_PROVIDER=gemini|]). Byte-identical output to IFS='|' splitting for +# any string without brackets — fully backward compatible. +_cfg_split_fields() { # $1=keystring → one field per line + local s="$1" cur="" i ch depth=0 + for ((i = 0; i < ${#s}; i++)); do + ch="${s:i:1}" + if [ "$ch" = "[" ]; then + depth=$((depth + 1)) + elif [ "$ch" = "]" ] && [ "$depth" -gt 0 ]; then + depth=$((depth - 1)) + fi + if [ "$ch" = "|" ] && [ "$depth" -eq 0 ]; then + printf '%s\n' "$cur" + cur="" + else + cur+="$ch" + fi + done + printf '%s\n' "$cur" +} + # One key field → "KEY|flags|description|example" (deduped via _cfg_seen). # The optional example is "desc::example" — a literal "::" separates the # value-format hint from the description. @@ -136,13 +179,43 @@ _cfg_plugin_keys() { return 0 } +# Emit the "# PROVIDER_CONFIG:" keys of ONE adapter file (helper for +# _cfg_provider_keys; keeps the tag-filter path and the all-adapters path DRY). +_cfg_provider_file() { + local pfile="$1" line key desc flags rest + while IFS= read -r line; do + [ -n "$line" ] || continue + # Format: KEY=flags:description (same as POS_CONFIG key fields) + key="${line%%=*}" + [ -n "$key" ] || continue + [ -n "${_cfg_seen[$key]:-}" ] && continue + _cfg_seen[$key]=1 + rest="${line#*=}" flags="" desc="" + if [[ "$rest" == *":"* ]]; then + flags="${rest%%:*}" + desc="${rest#*:}" + else + flags="$rest" + fi + printf '%s|%s|%s|\n' "$key" "$flags" "$desc" + done < <(grep '^# PROVIDER_CONFIG:' "$pfile" 2>/dev/null | sed 's/^.*# PROVIDER_CONFIG:[[:space:]]*//' || true) + return 0 +} + # "*providers" expansion: keys declared by the installed AI provider # adapters' "# PROVIDER_CONFIG:" headers (lib/ai-providers/*.sh). +# Optional argument restricts to .sh; an explicit tag matching zero +# adapters warns once (stderr) — silent emptiness would hide authoring errors, +# and the preceding caption is suppressed by cfg_ui's lazy flush. Bare +# *providers stays silent, exactly as today. +declare -A _CFG_TAG_WARNED=() _cfg_provider_keys() { + local want_tag="${1:-}" local pdir line key desc flags # Repo layout: lib/config-ui.sh → ../lib/ai-providers/ # Installed layout: /usr/local/bin/config-ui.sh → ./ai-providers/ pdir="" + local candidate for candidate in \ "$(dirname "${BASH_SOURCE[0]}")/../lib/ai-providers" \ "$(dirname "${BASH_SOURCE[0]}")/ai-providers"; do @@ -152,6 +225,20 @@ _cfg_provider_keys() { fi done [ -n "$pdir" ] || return 0 + if [ -n "$want_tag" ]; then + local matched=0 pfile + for pfile in "$pdir"/*.sh; do + [ -f "$pfile" ] || continue + [ "$(basename "$pfile" .sh)" = "$want_tag" ] || continue + matched=1 + _cfg_provider_file "$pfile" + done + if [ "$matched" -eq 0 ] && [ -z "${_CFG_TAG_WARNED[$want_tag]:-}" ]; then + _CFG_TAG_WARNED["$want_tag"]=1 + printf '[!] config scope: *providers=%s matched no adapter in %s\n' "$want_tag" "$pdir" >&2 + fi + return 0 + fi while IFS= read -r line; do [ -n "$line" ] || continue # Format: KEY=flags:description (same as POS_CONFIG key fields) @@ -159,8 +246,7 @@ _cfg_provider_keys() { [ -n "$key" ] || continue [ -n "${_cfg_seen[$key]:-}" ] && continue _cfg_seen[$key]=1 - # Parse flags and description from the rest - local rest="${line#*=}" flags="" desc="" + rest="${line#*=}" flags="" desc="" if [[ "$rest" == *":"* ]]; then flags="${rest%%:*}" desc="${rest#*:}" @@ -184,15 +270,33 @@ cfg_scope_keys() { [ "$s" = "$scope" ] || continue keystring="${line#*|}" keystring="${keystring#*|}" # drop the env-file field - IFS='|' read -r -a fields <<<"$keystring" + mapfile -t fields < <(_cfg_split_fields "$keystring") for field in "${fields[@]}"; do field="${field#"${field%%[![:space:]]*}"}" field="${field%"${field##*[![:space:]]}"}" if [ -n "$field" ]; then - if [[ "$field" == "*"* ]]; then + if [[ "$field" == "@"* ]]; then + # Caption record (key position ">"): >|cond|caption| + # @[KEY=alt1|alt2] Caption → cond "KEY=alt1|alt2" + # @Caption → cond "" (always active) + local cond="" cap="" + if [[ "$field" == "@["*"]"* ]]; then + cond="${field:2}" + cond="${cond%%]*}" + cap="${field#*]}" + cap="${cap# }" + else + cap="${field#@}" + cap="${cap# }" + fi + printf '%s\n' ">${_CS}${cond}${_CS}${cap}${_CS}" + elif [[ "$field" == "*"* ]]; then case "$field" in *plugins*) _cfg_plugin_keys ;; - *providers*) _cfg_provider_keys ;; + *providers*) + local ptag="" + [[ "$field" == *"="* ]] && ptag="${field#*=}" + _cfg_provider_keys "$ptag" ;; esac else _cfg_key_line "$field" @@ -331,53 +435,152 @@ _cfg_edit_one() { _cfg_post_write "$key" } +# Evaluate a caption condition against the env file: active iff KEY's current +# value equals any listed alt, or an empty alt segment is present and the value +# is unset/empty (trailing/double/leading pipe). Empty cond → always active. +_cfg_cond_active() { # file cond + [ -n "$2" ] || return 0 + local key alts cur alt hit=0 has_empty=0 oldIFS + key="${2%%=*}" + alts="${2#*=}" + cur="$(cfg_value "$1" "$key")" + case "$alts" in + "|"*|*"||"*|*"|") has_empty=1 ;; + esac + oldIFS="$IFS" + IFS='|' + for alt in $alts; do + if [ -n "$alt" ] && [ "$alt" = "$cur" ]; then hit=1; break; fi + done + IFS="$oldIFS" + [ "$hit" -eq 1 ] && return 0 + [ "$has_empty" -eq 1 ] && [ -z "$cur" ] && return 0 + return 1 +} + +# Word-wrap to columns, prefixing EVERY line with +# (hanging indent). Breaks at spaces only, no hyphenation; over-long tokens +# pass through unbroken. +_cfg_wrap() { # text width indent + local text="$1" width="$2" indent="$3" + local line="" w + for w in $text; do + if [ -z "$line" ]; then + line="$w" + elif (( ${#line} + 1 + ${#w} <= width )); then + line="$line $w" + else + printf '%s%s\n' "$indent" "$line" + line="$w" + fi + done + [ -n "$line" ] && printf '%s%s\n' "$indent" "$line" + return 0 +} + # Interactive numbered-menu editor for one scope. q quits; r re-renders. +# +# Rendering contract (menu-lib house pattern): the whole render block goes to +# stderr — display only, nothing on stdout. Caption records ('>') group keys; +# conditions are evaluated per render from the env file, so an edit flips group +# emphasis on the very next redraw. Inactive groups are dimmed with a textual +# reason — never hidden — so numbering stays stable across edits. cfg_ui() { - local scope="$1" envfile file line + local scope="$1" envfile file line idx envfile="$(cfg_scope_envfile "$scope")" || { warn "unknown config scope '$scope'"; return 1; } file="$CONFIG_DIR/$envfile" _cfg_scope="$scope" - local -a keys=() - while IFS= read -r line; do - if [ -n "$line" ]; then - keys+=("$line") - fi - done < <(cfg_scope_keys "$scope") - if [ ${#keys[@]} -eq 0 ]; then + # Collect records: KEY|flags|desc|example for keys, >|cond|caption| for captions + local -a recs=() nums=() + mapfile -t recs < <(cfg_scope_keys "$scope") + if [ ${#recs[@]} -eq 0 ]; then warn "no config keys declared for scope '$scope'" return 1 fi + # number→record map: numbers go to keys only, in static header order → + # stable across renders and provider switches + for idx in "${!recs[@]}"; do + [[ "${recs[$idx]}" == ">"* ]] || nums+=("$idx") + done - local choice i k f d e v + # Wrap width clamped to 60–120 cols minus the 6-column hanging indent + local W="${COLUMNS:-80}" + (( W < 60 )) && W=60 + (( W > 120 )) && W=120 + local wrapW=$((W - 6)) + local rule + rule="$(printf '─%.0s' $(seq 1 40))" + + local choice k f d e v disp n dim pend_cap="" pend_cond="" ckey cval why while true; do - echo - echo "pos config — ${scope} (${envfile})" - echo "------------------------------------" - i=0 - for line in "${keys[@]}"; do - i=$((i + 1)) - IFS='|' read -r k f d e <<<"$line" - v="$(cfg_value "$file" "$k")" - printf ' %2d) %-28s %s\n' "$i" "$k" "$(cfg_display "$v" "$f")" - if [ -n "$d" ]; then - printf ' %s\n' "$d" - fi - if [ -n "$e" ]; then - printf ' e.g. %s\n' "$e" - fi - done - echo - read -rp "Variable number [q to quit]: " choice || { echo; return 0; } + { + echo + echo "${BOLD}pos config — ${scope} (${envfile})${RESET}" + echo "${CYAN}${rule}${RESET}" + n=0; dim=0; pend_cap=""; pend_cond="" + for idx in "${!recs[@]}"; do + # Caption records use \x1f (unit separator) to avoid collision + # with | in alternation syntax; key records use | as before. + if [[ "${recs[$idx]}" == ">"* ]]; then + # Caption record: >\x1fcond\x1fcaption\x1f + # Strip leading > and first \x1f, then split on next \x1f + pend_cond="${recs[$idx]#>}" + pend_cond="${pend_cond#$_CS}" + pend_cond="${pend_cond%%$_CS*}" + pend_cap="${recs[$idx]#>}" + pend_cap="${pend_cap#$_CS}" + pend_cap="${pend_cap#*$_CS}" + pend_cap="${pend_cap%%$_CS*}" + continue + fi + IFS='|' read -r k f d e <<<"${recs[$idx]}" + if [ -n "$pend_cap" ]; then + if _cfg_cond_active "$file" "$pend_cond"; then + dim=0 + printf '\n%s ── %s%s\n' "$DIM" "$pend_cap" "$RESET" + else + dim=1 + ckey="${pend_cond%%=*}" + cval="$(cfg_value "$file" "$ckey")" + if [ -z "$cval" ]; then why="— inactive (${ckey} not set)" + else why="— inactive while ${ckey}=${cval}"; fi + printf '\n%s ── %s %s%s\n' "$DIM" "$pend_cap" "$why" "$RESET" + fi + pend_cap="" + fi + n=$((n + 1)) + v="$(cfg_value "$file" "$k")" + disp="$(cfg_display "$v" "$f")" + [ "$disp" = "(not set)" ] && disp="${DIM}(not set)${RESET}" + if [ "$dim" -eq 1 ]; then + printf '%s %2d) %-28s %s%s\n' "$DIM" "$n" "$k" "$disp" "$RESET" + else + printf ' %s%2d)%s %s%-28s%s %s\n' "$DIM" "$n" "$RESET" "$BOLD" "$k" "$RESET" "$disp" + fi + if [ -n "$d" ]; then + [ "$dim" -eq 1 ] && printf '%s' "$DIM" + _cfg_wrap "$d" "$wrapW" " " + [ "$dim" -eq 1 ] && printf '%s' "$RESET" + fi + if [ -n "$e" ]; then + printf '%s' "$DIM" + _cfg_wrap "e.g. $e" "$wrapW" " " + printf '%s' "$RESET" + fi + done + echo + read -rp "Number to edit [r=refresh, q=quit]: " choice || { echo; return 0; } + } >&2 case "$choice" in q|Q|quit|exit) echo; return 0 ;; r|R|refresh) continue ;; "") continue ;; *) - if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#keys[@]} )); then - _cfg_edit_one "$file" "${keys[$((choice - 1))]}" + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#nums[@]} )); then + _cfg_edit_one "$file" "${recs[${nums[$((choice - 1))]}]}" else - warn "invalid number '$choice' (1-${#keys[@]})" + warn "invalid number '$choice' (1-${#nums[@]})" fi ;; esac diff --git a/lib/registry.sh b/lib/registry.sh new file mode 100644 index 0000000..6d5d3a6 --- /dev/null +++ b/lib/registry.sh @@ -0,0 +1,199 @@ +# lib/registry.sh — shared query API for POS tool metadata headers. +# Sourced opt-in by consumers that need tool metadata. +# Populates bash arrays from "# POS_*:" headers in bin/pos-* files; +# consumers call reg_scan once, then reg_list / reg_lookup / reg_each. +# +# API: +# reg_scan [dir] scan pos-* files → populate arrays +# reg_list sorted tool keys +# reg_categories sorted unique category names +# reg_tools_in tool keys in a category +# reg_lookup field: cat|desc|flags|subcmds|deps|examples +# reg_config_scopes sorted config scope names +# reg_config_keys key|flags|desc lines +# reg_config_envfile env-file basename for a scope +# reg_each cb(category, tool_key, description) +# reg_tool_exists exit 0 if registered + +# ── common.sh helpers (guarded — mirrors lib/config-ui.sh) ───── +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 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 + key="$name" + if [[ "$name" == *-* ]]; then + cat="${name%%-*}" + else + cat="" + fi + + _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) + + # Restore LC_ALL + if [ -n "$LC_ALL_PREV" ]; then + export LC_ALL="$LC_ALL_PREV" + else + unset LC_ALL + fi +} + +# ── discovery ─────────────────────────────────────────────────── +reg_list() { printf '%s\n' "${_reg_tools[@]}"; } + +reg_categories() { + local -a cats=() + local t cat _rc_key + local -A _rc_seen=() + for t in "${_reg_tools[@]}"; do + cat="${_reg_cat[$t]}" + if [ -z "$cat" ]; then + _rc_key="__empty__" + else + _rc_key="$cat" + fi + if [ -z "${_rc_seen[$_rc_key]+x}" ]; then + _rc_seen["$_rc_key"]=1 + cats+=("$cat") + fi + 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 +} + +# ── config scope helpers ──────────────────────────────────────── +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}" ] +} diff --git a/scripts/gen-docs.sh b/scripts/gen-docs.sh index e053152..a88895e 100755 --- a/scripts/gen-docs.sh +++ b/scripts/gen-docs.sh @@ -10,6 +10,8 @@ set -euo pipefail # - "# POS:" header line → one-line description # - "# POS_FLAGS:" line → flag completion list (flag-style tools only) # - "# POS_SUBCMDS:" line → subcommand completion list (multi-command tools) +# - "# POS_DEPS:" line → runtime binary dependencies (optional) +# - "# POS_EXAMPLES:" line → curated usage examples (optional, multi-line) root="$(cd "$(dirname "$0")/.." && pwd)" mode="write" @@ -22,7 +24,7 @@ export LC_ALL=C ctx="$root/DOC/AGENT_Context_Project.md" comp="$root/completions/pos.bash" -# ── Collect tools: "cat|sub|desc|flags|subcmds" ──────────────── +# ── Collect tools: "cat|sub|desc|flags|subcmds|deps|examples" ── # Category-less tools (pos-, e.g. pos-config) get an empty cat. # tooldisp → display name (pos-config / pos-communication-telegram-sender). tooldisp() { printf 'pos-%s%s' "${1:+$1-}" "$2"; } @@ -42,31 +44,55 @@ for f in "$root"/bin/pos-*; do desc="${desc#*— }" flags="$(sed -n '/^# POS_FLAGS: /{s/^# POS_FLAGS: //;p;q}' "$f")" subcmds="$(sed -n '/^# POS_SUBCMDS: /{s/^# POS_SUBCMDS: //;p;q}' "$f")" - tools+=("$cat|$sub|$desc|$flags|$subcmds") + deps="$(sed -n '/^# POS_DEPS: /{s/^# POS_DEPS: //;p;q}' "$f")" + examples="$(grep '^# POS_EXAMPLES:' "$f" 2>/dev/null | sed 's/^# POS_EXAMPLES:[[:space:]]*//' | awk 'NR>1{printf " · "}{printf "%s", $0}END{print ""}' || true)" + tools+=("$cat|$sub|$desc|$flags|$subcmds|$deps|$examples") done mapfile -t tools < <(printf '%s\n' "${tools[@]}" | sort) # ── Block generators (emit inner content only, no markers) ────── +# Check whether any tool has non-empty deps or examples (for conditional columns) +_has_deps_examples=0 +for t in "${tools[@]}"; do + IFS='|' read -r _ _ _ _ _ _tdeps _texamples <<<"$t" + if [ -n "$_tdeps" ] || [ -n "$_texamples" ]; then + _has_deps_examples=1 + break + fi +done + gen_tree() { - local width=0 cat sub desc flags name t + local width=0 cat sub desc flags name t deps examples for t in "${tools[@]}"; do - IFS='|' read -r cat sub desc flags subcmds <<<"$t" + IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t" name="$(tooldisp "$cat" "$sub")" [ ${#name} -gt "$width" ] && width=${#name} done for t in "${tools[@]}"; do - IFS='|' read -r cat sub desc flags subcmds <<<"$t" + IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t" name="$(tooldisp "$cat" "$sub")" printf '│ ├── %-*s# %s\n' "$((width + 1))" "$name" "$desc" + if [ -n "$deps" ]; then + printf '│ %*s│ [deps: %s]\n' "" "" "$deps" + fi done } gen_dispatch() { - local cat sub desc flags t - for t in "${tools[@]}"; do - IFS='|' read -r cat sub desc flags subcmds <<<"$t" - printf '| %s | %s | `%s` | %s |\n' "$cat" "$sub" "$(tooldisp "$cat" "$sub")" "$desc" - done + local cat sub desc flags t deps examples + if [ "$_has_deps_examples" -eq 1 ]; then + printf '| Category | Command | Script | Description | Deps | Examples |\n' + printf '|----------|---------|--------|-------------|------|----------|\n' + for t in "${tools[@]}"; do + IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t" + printf '| %s | %s | `%s` | %s | %s | %s |\n' "$cat" "$sub" "$(tooldisp "$cat" "$sub")" "$desc" "$deps" "$(printf '%s' "$examples" | sed 's/ | / → /g')" + done + else + for t in "${tools[@]}"; do + IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t" + printf '| %s | %s | `%s` | %s |\n' "$cat" "$sub" "$(tooldisp "$cat" "$sub")" "$desc" + done + fi } gen_selfcontained() { @@ -83,10 +109,10 @@ gen_selfcontained() { } gen_filetable() { - local cat sub desc flags name t + local cat sub desc flags name t deps examples printf '| `bin/pos` | %s | CLI dispatcher with smart arg matching + logging + category help |\n' "$(wc -l < "$root/bin/pos")" for t in "${tools[@]}"; do - IFS='|' read -r cat sub desc flags subcmds <<<"$t" + IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t" name="bin/$(tooldisp "$cat" "$sub")" printf '| `%s` | %s | %s |\n' "$name" "$(wc -l < "$root/$name")" "$desc" done @@ -94,10 +120,10 @@ gen_filetable() { } gen_posflags() { - local cat sub desc flags t + local cat sub desc flags t deps examples echo "declare -A _pos_flags" for t in "${tools[@]}"; do - IFS='|' read -r cat sub desc flags subcmds <<<"$t" + IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t" [ -n "$flags" ] || continue printf '_pos_flags[%s]="%s"\n' "$(tooldisp "$cat" "$sub" | sed 's/^pos-//')" "$flags" done @@ -106,10 +132,10 @@ gen_posflags() { gen_possubcmds() { # Subcommand completion: "# POS_SUBCMDS:" list + nested sub-tools from # filenames (pos--- → "extra" completes under -). - local cat sub desc flags subcmds rest f t + local cat sub desc flags subcmds deps examples rest f t echo "declare -A _pos_subcmds" for t in "${tools[@]}"; do - IFS='|' read -r cat sub desc flags subcmds <<<"$t" + IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t" subcmds="${subcmds:-}" for f in "$root"/bin/"$(tooldisp "$cat" "$sub")"-*; do [ -x "$f" ] || continue diff --git a/templates/pos-tool.sh b/templates/pos-tool.sh index 85ebcd6..0451ce5 100755 --- a/templates/pos-tool.sh +++ b/templates/pos-tool.sh @@ -9,6 +9,9 @@ set -euo pipefail # lines (single source of truth for generated docs): # # POS: — 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 | Description (optional) # 3. Exec bit: chmod +x bin/pos-- # 4. If it reads stdin (password/selection prompts), add it to # INTERACTIVE_CMDS in bin/pos or its prompt breaks under the log tee.