Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e969234ca5 | |||
| 4fd3c37c40 | |||
| 5d7407e30f | |||
| 6566c8343d | |||
| 9f289ba31b | |||
| a5c19e842d | |||
| d84a35efce | |||
| 9564880ebf | |||
| d0299d3f98 | |||
| c1f1c4109f | |||
| 710b626f47 | |||
| 1c19c0de59 | |||
| e0c9ba384a | |||
| 7ae2e77a44 | |||
| 88ea660891 | |||
| 1fbdf7ef2d | |||
| 4f79ce123f | |||
| f0ef13827b | |||
| 4af097f5eb | |||
| 476173ba83 | |||
| 0aaa25150c | |||
| 4173fc61e3 |
@@ -14,8 +14,8 @@ CRITICAL: real guidance lives in DOC/. When you encounter a reference below, use
|
||||
|
||||
## Quick facts
|
||||
|
||||
- **Tool model:** `bin/pos-<category>-<command>`, or **category-less** `bin/pos-<cmd>` 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: <cat> <cmd> — <desc>` 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-<category>-<command>`, or **category-less** `bin/pos-<cmd>` 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: <cat> <cmd> — <desc>` 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: <binary…>` declares space-separated runtime binaries the tool hard-requires via `command -v` guards, and optional `# POS_EXAMPLES: <command> | <description>` 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/<sha>` or `ci-fail/<sha>` (pushes only — query with `scripts/ci-status.sh [--wait] [<sha>]`; 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 <bin> || 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.
|
||||
|
||||
@@ -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 <name>` 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/<name>` 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 <names>` 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=<tag>` 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.
|
||||
|
||||
@@ -10,19 +10,19 @@
|
||||
|
||||
<!-- GEN:START docmap -->
|
||||
| ## 1. Project Overview | 28–43 |
|
||||
| ## 2. Directory Structure | 44–196 |
|
||||
| ## 3. Installation Flow | 197–250 |
|
||||
| ## 4. The `pos` CLI System | 251–324 |
|
||||
| ## 5. Shared Library — `lib/common.sh` | 325–356 |
|
||||
| ## 6. Docker Compose / ScaleTail | 357–399 |
|
||||
| ## 7. Optional Apps (`apps/`) | 400–429 |
|
||||
| ## 8. Entertainment Module | 430–443 |
|
||||
| ## 9. Systemd Services | 444–455 |
|
||||
| ## 10. Configuration Files | 456–482 |
|
||||
| ## 11. Coding Conventions | 483–515 |
|
||||
| ## 12. Development Workflow | 516–568 |
|
||||
| ## 13. Key File Quick Reference | 569–635 |
|
||||
| ## 14. Common Tasks for Agents | 636–669 |
|
||||
| ## 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 |
|
||||
<!-- GEN:END docmap -->
|
||||
|
||||
## 1. Project Overview
|
||||
@@ -61,7 +61,9 @@ Linux_post_install/
|
||||
├── bin/ # CLI tools — installed to /usr/local/bin/
|
||||
│ ├── pos # Main dispatcher — smart arg matching to pos-* scripts
|
||||
<!-- GEN:START tree -->
|
||||
│ ├── pos-ai-gemini # Chat with Google Gemini (ask, chat, models, sessions)
|
||||
│ ├── pos-ai-alias # manage AI agent aliases
|
||||
│ ├── pos-ai-gemini # Forward to pos ai --provider gemini (backward compat)
|
||||
│ ├── pos-ai-openrouter # Forward to pos ai --provider openrouter (backward compat)
|
||||
│ ├── pos-communication-matrix-listener # Matrix listener: map /command → bash, run them on room messages
|
||||
│ ├── pos-communication-matrix-sender # Send messages to a Matrix room via the client-server API (send, test, login)
|
||||
│ ├── pos-communication-scrcpy # Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info)
|
||||
@@ -70,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)
|
||||
@@ -80,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
|
||||
@@ -93,9 +98,12 @@ 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
|
||||
│ ├── pos-system-uninstall # Remove pos toolkit binaries, services, shell integration, config, and data
|
||||
│ ├── pos-ai # AI assistant: ask, chat, sessions, capture, models, providers
|
||||
│ ├── pos-config # Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry)
|
||||
│ ├── pos-tree # Show the pos CLI command tree: categories, commands, and subcommands
|
||||
<!-- GEN:END tree -->
|
||||
@@ -204,7 +212,7 @@ User runs: ./install.sh [--apps|--full|--feature|--dry-run|--skip <phase>|--step
|
||||
│
|
||||
├─ Phase 2: install.sh (requires root)
|
||||
│ └─ Copies bin/* → /usr/local/bin/ (chmod 755)
|
||||
│ └─ Copies lib/*.sh (common, flags, notify, entertainment-lib,
|
||||
│ └─ Copies lib/*.sh (common, flags, notify, registry, entertainment-lib,
|
||||
│ scheduler-lib, config-ui, user-timers-lib, entertainment-plugin-lib,
|
||||
│ usb-lib, share-lib, menu-lib) → /usr/local/bin/ (chmod 644)
|
||||
│ └─ Copies x64_bin/* → /usr/local/bin/ on x86_64 (arm64_bin/ on aarch64)
|
||||
@@ -264,46 +272,50 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst
|
||||
|
||||
### Available Commands
|
||||
|
||||
| Category | Command | Script | Description |
|
||||
|----------|---------|--------|-------------|
|
||||
<!-- GEN:START dispatch -->
|
||||
| ai | gemini | `pos-ai-gemini` | Chat with Google Gemini (ask, chat, models, sessions) |
|
||||
| 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 |
|
||||
| | 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 | | |
|
||||
<!-- GEN:END dispatch -->
|
||||
|
||||
### Legacy Wrappers
|
||||
@@ -349,7 +361,7 @@ source "$(dirname "$0")/../lib/common.sh"
|
||||
|
||||
**Scripts that do NOT source common.sh** (self-contained):
|
||||
<!-- GEN:START selfcontained -->
|
||||
`pos`, `pos-communication-matrix-listener`, `pos-communication-matrix-sender`, `pos-communication-telegram-listener`, `pos-communication-telegram-sender`, `pos-network-checkport`, `pos-network-hotspot`, `pos-network-ip`, `pos-network-scan`, `pos-ssh-load-keys`, `pos-system-firewall`.
|
||||
`pos`, `pos-ai-gemini`, `pos-ai-openrouter`, `pos-communication-matrix-listener`, `pos-communication-matrix-sender`, `pos-communication-telegram-listener`, `pos-communication-telegram-sender`, `pos-network-checkport`, `pos-network-hotspot`, `pos-network-ip`, `pos-network-scan`, `pos-ssh-load-keys`, `pos-system-firewall`.
|
||||
<!-- GEN:END selfcontained -->
|
||||
|
||||
---
|
||||
@@ -466,7 +478,7 @@ All `.service` files in `systemd/` are automatically copied to `/etc/systemd/sys
|
||||
- `~/.config/linux_post_install/entertainment.env` — entertainment plugin defaults: weather location + `ENABLED` auto-trigger list (`plugin, interval` pairs scheduled via `pos entertainment enable/disable`, systemd user timers); auto-installed from `config/entertainment.env` by `postinstall.sh` (no clobber, template printed)
|
||||
- `~/.config/linux_post_install/system.env` — shared "system" tool settings (loaded by `pos system health` / `pos system backup` via `load_system_env()` in `lib/common.sh`; env already exported wins over the file); template `config/system.env`
|
||||
- `~/.config/linux_post_install/notify.env` — alerting platform selection (`NOTIFY_PLATFORM=telegram,matrix`, comma-separated = fan out); read by `lib/notify.sh`; template `config/notify.env`
|
||||
- `~/.config/linux_post_install/ai.env` — Google Gemini config (`AI_GEMINI_API_KEY` secret, `AI_GEMINI_MODEL` default `gemini-2.5-flash`); read by `pos ai gemini`; template `config/ai.env`, auto-installed by postinstall, edit with `pos config ai`
|
||||
- `~/.config/linux_post_install/ai.env` — AI provider config (`AI_PROVIDER`, `AI_API_KEY` secret, `AI_MODEL`, `AI_SYSTEM_PROMPT`, plus legacy fallbacks `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`); read by `pos ai`; template `config/ai.env`, auto-installed by postinstall, edit with `pos config ai`
|
||||
- `~/.bashrc` — Modified by postinstall (PATH, bash completion)
|
||||
|
||||
### Feature Flags
|
||||
@@ -583,14 +595,17 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `lib/usb-lib.sh` | 205 | Shared USB-storage detection + pick flow (detect/mount-offer/`usb_pick_root`; EFI system partitions excluded; picker shows size/label/fs) — used by `pos system backup` + `pos media sync` |
|
||||
| `lib/share-lib.sh` | 318 | Domain layer for the share suite (usbsrv/smbclient record parsers, folder+mountpoint candidates, remote listings, service/firewall advisories; EOF-safe) + compat shims to `lib/menu-lib.sh` — used by all five `pos share *` tools |
|
||||
| `lib/menu-lib.sh` | 169 | Category-neutral interactive menu primitives (`menu_guard` tty guard, `menu_run` looping boxed menu, `menu_pick` type-to-filter picker, `menu_ask_value` prompt-with-default; stderr render, fail-closed on non-tty/EOF) — sourced by `share-lib.sh`, open to any category |
|
||||
| `lib/registry.sh` | 199 | Shared query API for POS tool metadata headers (`# POS_*:`) — `reg_scan`/`reg_list`/`reg_lookup`/`reg_each`/config scope helpers; used by `pos-tree` and `gen-docs.sh` |
|
||||
| `bin/flag-reader` | 58 | Inspect flags (list/status/`--raw`) |
|
||||
| `bin/flag-set` | 21 | Set a flag (optionally with a value) |
|
||||
| `bin/flag-clear` | 21 | Unset a flag |
|
||||
| `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) |
|
||||
<!-- GEN:START filetable -->
|
||||
| `bin/pos` | 295 | CLI dispatcher with smart arg matching + logging + category help |
|
||||
| `bin/pos-ai-gemini` | 311 | Chat with Google Gemini (ask, chat, models, sessions) |
|
||||
| `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 |
|
||||
| `bin/pos-communication-matrix-sender` | 224 | Send messages to a Matrix room via the client-server API (send, test, login) |
|
||||
| `bin/pos-communication-scrcpy` | 254 | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) |
|
||||
@@ -598,9 +613,9 @@ 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` | 261 | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) |
|
||||
| `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) |
|
||||
| `bin/pos-entertainment-disable` | 32 | Disable a plugin's auto-trigger (remove it from ENABLED) |
|
||||
| `bin/pos-entertainment-enable` | 49 | Enable an auto-trigger for a plugin on a schedule |
|
||||
@@ -608,26 +623,28 @@ 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 |
|
||||
| `bin/pos-share-nfs-client` | 343 | Mount NFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-nfs-client` | 504 | Mount NFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-nfs-server` | 245 | Manage the NFS kernel server (status, share/unshare exports, enable/disable) |
|
||||
| `bin/pos-share-smb-client` | 576 | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-smb-client` | 764 | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `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` | 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` | 112 | Show the pos CLI command tree: categories, commands, and subcommands |
|
||||
| `completions/pos.bash` | 302 | Dynamic bash completion |
|
||||
| `bin/pos-tree` | 118 | Show the pos CLI command tree: categories, commands, and subcommands |
|
||||
| `completions/pos.bash` | 307 | Dynamic bash completion |
|
||||
<!-- GEN:END filetable -->
|
||||
| `apps/install.sh` | 171 | App install/uninstall picker/orchestrator |
|
||||
|
||||
@@ -661,7 +678,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| Modify Music→USB sync logic | Edit `bin/pos-media-sync` / shared USB layer `lib/usb-lib.sh` (seams `MEDIA_SYNC_SOURCE`/`MEDIA_SYNC_DEST`/`USB_MOUNT_BASE`/`USB_BYID` in `~/.config/linux_post_install/system.env`) |
|
||||
| Modify YouTube channel sync logic | Edit `bin/pos-media-ytsync` (state in `~/.local/share/linux_post_install/ytsync`; config scope `ytsync` via `pos config ytsync`; research notes `tools-docs/ytsync.md`) |
|
||||
| Modify the scheduler / scheduled jobs | Edit `bin/pos-system-schedule` / `lib/scheduler-lib.sh` (jobs in `~/.config/linux_post_install/schedule.d/`) |
|
||||
| Modify AI/Gemini logic | Edit `bin/pos-ai-gemini` (config scope `ai` via `pos config ai`; `AI_GEMINI_API_KEY`/`AI_GEMINI_MODEL` in `~/.config/linux_post_install/ai.env`) |
|
||||
| Modify AI logic | Edit `bin/pos-ai` (main tool) + `lib/ai-providers/*.sh` (provider adapters); config scope `ai` via `pos config ai`; `AI_API_KEY`/`AI_MODEL`/`AI_PROVIDER` in `~/.config/linux_post_install/ai.env` |
|
||||
| Modify UFW/firewall logic | Edit `bin/pos-system-firewall` |
|
||||
| Modify pos logging | Edit log setup in `bin/pos` |
|
||||
| Modify install phases/flags | Edit arg parsing in `install.sh` |
|
||||
|
||||
+9
-3
@@ -31,7 +31,7 @@ Each phase is independent and runs only if the corresponding script exists.
|
||||
|-----------|---------|-------------|
|
||||
| `bin/` | Daily-use CLI tools and wrappers | `/usr/local/bin/` |
|
||||
| `apps/<category>/` | Optional desktop app installers | run on demand |
|
||||
| `lib/` | Shared libraries: `common.sh` (helpers), `flags.sh` (feature flags), `notify.sh` (multi-platform alerting), `entertainment-lib.sh` (entertainment scheduling + last-run state), `entertainment-plugin-lib.sh` (message-safe plugin helpers), `scheduler-lib.sh` (system scheduler), `user-timers-lib.sh` (shared systemd user timer machinery), `config-ui.sh` (interactive config UI), `menu-lib.sh` (category-neutral menu primitives: guard/looping menu/filter picker/prompt), `share-lib.sh` (share-suite domain probes/listings + compat shims to menu-lib) | sourced at build time |
|
||||
| `lib/` | Shared libraries: `common.sh` (helpers), `flags.sh` (feature flags), `notify.sh` (multi-platform alerting), `registry.sh` (shared query API for POS tool metadata headers), `entertainment-lib.sh` (entertainment scheduling + last-run state), `entertainment-plugin-lib.sh` (message-safe plugin helpers), `scheduler-lib.sh` (system scheduler), `user-timers-lib.sh` (shared systemd user timer machinery), `config-ui.sh` (interactive config UI), `menu-lib.sh` (category-neutral menu primitives: guard/looping menu/filter picker/prompt), `share-lib.sh` (share-suite domain probes/listings + compat shims to menu-lib) | sourced at build time |
|
||||
| `config/` | Gitignored user config files | `~/.config/<app>/` (via postinstall) |
|
||||
| `entertainment/` | Public-API plugins for the entertainment module | `/usr/local/bin` (via install.sh Phase 2) |
|
||||
| `compose/` | ScaleTail templates (git submodule) | `/usr/local/share/linux_post_install/scale-tail` |
|
||||
@@ -72,7 +72,7 @@ Sourced by most scripts. Key functions:
|
||||
| `run cmd` | Executes command, respects `$DRY_RUN` |
|
||||
| `spawn "msg" cmd` | Animated braille spinner + elapsed time |
|
||||
| `timer_start` / `timer_stop` | Elapsed time tracking |
|
||||
| `confirm "prompt"` | y/N prompt with optional default |
|
||||
| `confirm "prompt" [default]` | y/n prompt; Enter accepts the default (`y` when omitted) |
|
||||
|
||||
---
|
||||
|
||||
@@ -128,8 +128,10 @@ esac
|
||||
# POS: <category> <command> — one-line description rendered by `make gen`
|
||||
# POS_FLAGS: --flag1 --flag2 # ONLY for flag-style tools
|
||||
# POS_SUBCMDS: sub1 sub2 # ONLY for multi-command tools
|
||||
# POS_DEPS: binary1 binary2 # Optional: runtime deps (space-separated binary names)
|
||||
# POS_EXAMPLES: pos <tool> <args> | Description # Optional: usage examples
|
||||
```
|
||||
The description feeds the dispatch table, bin tree and file table in `DOC/AGENT_Context_Project.md`; `POS_FLAGS` feeds flag completion and `POS_SUBCMDS` feeds subcommand completion in `completions/pos.bash` (both update via `make gen`). `make gen` only reads the text after the first `— ` — the `<category> <command>` words before it are convention-only (for nested tools, keep the full path there, e.g. `# POS: communication telegram-listener — …`).
|
||||
The description feeds the dispatch table, bin tree and file table in `DOC/AGENT_Context_Project.md`; `POS_FLAGS` feeds flag completion and `POS_SUBCMDS` feeds subcommand completion in `completions/pos.bash` (both update via `make gen`). `POS_DEPS` lists runtime binary names that `command -v` would check — use when the tool requires specific binaries beyond what `preinstall.sh` installs. `POS_EXAMPLES` provides curated usage examples (one per line, pipe-delimited `command | description`) shown in `pos tree` and future help views. Both are optional and degrade gracefully when absent. `make gen` only reads the text after the first `— ` — the `<category> <command>` words before it are convention-only (for nested tools, keep the full path there, e.g. `# POS: communication telegram-listener — …`).
|
||||
- **Category-less vs categorized:** most tools are `bin/pos-<category>-<command>`. Use category-less `bin/pos-<cmd>` (e.g. `pos-config`, `pos-tree`) only for dispatcher/dev-level commands that fit no category — they dispatch and document like any tool but show with an empty category in the generated tables.
|
||||
- Nested tools (e.g. `bin/pos-communication-telegram-listener`) are auto-detected from filenames: the trailing segment (`listener`) is offered as a subcommand of the parent tool (`communication-telegram`) in `pos <category> --help` and tab-completion, instead of appearing as a flat sibling (`telegram-listener`). The flat dash-form (`pos communication telegram-listener`) still dispatches.
|
||||
- Optionally add an EXAMPLES line in `bin/pos` `usage()` to showcase the tool in `pos --help`.
|
||||
@@ -428,6 +430,10 @@ pos-communication-<platform> send <value> [--markdown] # exit 0 on delivery
|
||||
|
||||
then listing it in `NOTIFY_PLATFORM`. Platform keys map to tool names via `notify_sender_name()` in `lib/notify.sh` — the telegram platform key stays `telegram` but its tool is `pos-communication-telegram-sender`. `pos-communication-telegram-sender` already follows this (`--markdown` is an alias for `--parse-mode markdown`). No changes to `lib/notify.sh` are needed for a new platform.
|
||||
|
||||
### Confirmation prompts
|
||||
|
||||
`confirm()` rule: Enter accepts the displayed default; destructive call sites pass explicit `'n'`.
|
||||
|
||||
### Idempotency
|
||||
|
||||
Check before creating, use `>>` with grep guards, don't overwrite user configs.
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ templates (without overwriting an existing file):
|
||||
| `system.env` | `pos system health`, `pos system backup` | `BACKUP_SERVICE_ROOTS`, `HEALTH_BACKUP_MAX_AGE_DAYS` |
|
||||
| `compose.env` | `pos docker compose` | `TS_AUTHKEY`, `TZ`, `DNS_SERVER`, `SERVICES_BASE` |
|
||||
| `entertainment.env` | `pos entertainment *` | plugin keys (`WEATHER_LAT`…), `ENABLED` |
|
||||
| `ai.env` | `pos ai gemini` | `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL` |
|
||||
| `ai.env` | `pos ai` | `AI_PROVIDER`, `AI_API_KEY`, `AI_MODEL`, `AI_SYSTEM_PROMPT`, `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL` |
|
||||
| `schedule.d/` | `pos system schedule` | one `<name>.env` per job: `INTERVAL`, `NOTIFY`, `MSG`, `RULE`, `COMMAND` |
|
||||
|
||||
```bash
|
||||
|
||||
+38
-13
@@ -55,28 +55,50 @@ Category-less tools (`config`, `tree`) live outside any category and are documen
|
||||
|
||||
### ai
|
||||
|
||||
**File:** `bin/pos-ai-gemini`
|
||||
**Purpose:** chat with Google Gemini via the REST API (`generativelanguage.googleapis.com`). One tool, three subcommands: `ask` (one-shot, scriptable), `chat` (interactive multi-turn REPL), and `models` (list `generateContent`-capable ids).
|
||||
**File:** `bin/pos-ai` (provider-agnostic main tool), `bin/pos-ai-gemini` / `bin/pos-ai-openrouter` (backward-compat forwarders → `pos ai --provider <name>`)
|
||||
**Provider adapters:** `lib/ai-providers/gemini.sh`, `lib/ai-providers/openrouter.sh`
|
||||
**Purpose:** AI assistant with pluggable providers. Six subcommands: `ask` (scriptable, persistent session), `capture` (run a command and save its output for `--last`), `chat` (interactive multi-turn REPL), `models` (list available models), `providers` (list providers and config status), and `sessions` (list/clear sessions). Providers handle API-specific logic; the main tool handles sessions, rendering, machine context, and all shared logic.
|
||||
|
||||
| Command | Behavior |
|
||||
|---------|----------|
|
||||
| `pos ai gemini ask "<prompt>"` | One-shot; POSTs `generateContent` and prints **only** the answer text to stdout (pipe/script/Telegram-friendly). The prompt may also be piped in via stdin when no argument is given |
|
||||
| `pos ai gemini chat` | Interactive REPL with multi-turn history (the `contents[]` array is appended per turn); `q`/`quit`/`exit` or Ctrl+C quit, `/reset` clears the history, empty input re-prompts |
|
||||
| `pos ai gemini models` | Lists models that support `generateContent` and flags the configured default |
|
||||
| `pos ai gemini --model <id> …` | Overrides the model for one invocation |
|
||||
| `pos ai ask "<prompt>"` | Sends the prompt to the active provider (default: gemini) and prints the answer text to stdout. The prompt may also be piped in via stdin when no argument is given. Runs in the persistent `default` session (`~/.local/share/linux_post_install/ai/default.json`, capped at 40 turns; `--session <name>` picks another). Terse by default: a built-in system instruction asks for commands-first minimal prose and to diagnose pasted errors/output with the fix first (`--system "<text>"` replaces it wholesale, `--full` skips it; `AI_SYSTEM_PROMPT` env/config provides a custom default). With `--last`, the output of the most recent logged pos command or captured output (tail, max 4096 chars) is appended to the question. On a tty the answer is rendered as markdown (`glow` if installed, else a built-in renderer); non-tty stdout gets the raw markdown bytes unchanged |
|
||||
| `pos ai --provider openrouter ask "<prompt>"` | Same, but uses OpenRouter instead of the default Gemini provider |
|
||||
| `pos ai capture <cmd..>` | Run a command, capture its stdout+stderr to screen and to `~/.local/share/linux_post_install/last_cmd_output` for `--last`. Each capture overwrites the previous one. Returns the command's exit code |
|
||||
| `pos ai chat` | Interactive REPL with multi-turn history (the `messages[]` array is appended per turn and persisted to the session file — `default` unless `--session`); replies are rendered like `ask` on a tty; `q`/`quit`/`exit` or Ctrl+C quit, `/reset` clears the history, empty input re-prompts |
|
||||
| `pos ai sessions` | Lists session files with turn counts; `sessions reset <name>` clears one (e.g. `reset default`) |
|
||||
| `pos ai models` | Lists available models for the active provider and flags the configured default |
|
||||
| `pos ai providers` | Lists available providers, their config status, and the active provider |
|
||||
| `pos ai --model <id> …` | Overrides the model for one invocation |
|
||||
| `pos ai --provider <name> …` | Selects the provider for one invocation (gemini\|openrouter) |
|
||||
| `pos ai alias` | Interactive alias manager (`bin/pos-ai-alias`): menu loop (create / edit / remove / list) that shows the alias table (Name/Provider/Session/Prompt, prompts truncated) between picks |
|
||||
| `pos ai alias create [name]` | Interactive 4-step wizard: alias name (leading letter, then letters/digits/-/_; unique across aliases), provider pick (from installed `lib/ai-providers/*.sh` adapters), session name (defaults to the alias name), optional system prompt (must not contain `\|`; warns above 500 chars); confirm defaults to yes, then the alias is saved |
|
||||
| `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 <name>` | Prints one alias's details including the wrapper path and the resolved command: `pos ai <provider> ask --session <session>[ --system '<prompt>']` |
|
||||
|
||||
`pos ai gemini` with no subcommand prints usage (never blocks on stdin). `ask`/`chat` time out after 60s per request; on a non-2xx response the API's `error.message` is shown and the tool exits non-zero.
|
||||
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/<name>` (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/<name>` 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 <names>` 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`.
|
||||
|
||||
`pos ai` with no subcommand prints usage (never blocks on stdin). `ask`/`chat` time out after 60s per request; on a non-2xx response the API's `error.message` is shown and the tool exits non-zero.
|
||||
|
||||
**Configuration** (`~/.config/linux_post_install/ai.env`, edit with `pos config ai`):
|
||||
|
||||
| Key | Required | Default | Purpose |
|
||||
|-----|----------|---------|---------|
|
||||
| `AI_GEMINI_API_KEY` | yes | — | API key from aistudio.google.com (secret — masked in `pos config ai`) |
|
||||
| `AI_GEMINI_MODEL` | no | `gemini-2.5-flash` | Model id used by `ask`/`chat`/`models` |
|
||||
| `AI_PROVIDER` | no | `gemini` | Active provider (gemini\|openrouter) |
|
||||
| `AI_API_KEY` | yes | — | API key for the active provider (secret — masked in `pos config ai`) |
|
||||
| `AI_MODEL` | no | per provider | Model id used by `ask`/`chat`/`models` |
|
||||
| `AI_SYSTEM_PROMPT` | no | built-in terse prompt | Custom system prompt (overrides built-in; empty to reset) |
|
||||
| `AI_GEMINI_API_KEY` | fallback | — | Legacy: Gemini API key (used when `AI_API_KEY` is empty) |
|
||||
| `AI_GEMINI_MODEL` | fallback | `gemini-2.5-flash` | Legacy: Gemini model id (used when `AI_MODEL` is empty) |
|
||||
| `OPENROUTER_API_KEY` | fallback | — | Legacy: OpenRouter API key (used when `AI_API_KEY` is empty) |
|
||||
| `OPENROUTER_MODEL` | fallback | `openrouter/auto` | Legacy: OpenRouter model id (used when `AI_MODEL` is empty) |
|
||||
|
||||
Precedence: `--model` flag > `AI_GEMINI_MODEL` env > config file > `gemini-2.5-flash`. `postinstall.sh` copies the repo's `config/ai.env` template to `~/.config/linux_post_install/ai.env` on install (no clobber). Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES).
|
||||
Model precedence: `--model` flag > `AI_MODEL` env > provider-specific fallback (`AI_GEMINI_MODEL`/`OPENROUTER_MODEL`) > provider default. API key precedence: `AI_API_KEY` env > provider-specific fallback (`AI_GEMINI_API_KEY`/`OPENROUTER_API_KEY`) > error. `postinstall.sh` copies the repo's `config/ai.env` template to `~/.config/linux_post_install/ai.env` on install (no clobber). Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES). Sessions are stored in OpenAI `messages` format universally; old Gemini-format sessions (`contents[]`) are auto-migrated on load.
|
||||
|
||||
**Messaging bridges:** the Telegram and Matrix listeners forward non-command messages starting with `ai ` (case-insensitive) to `pos ai gemini ask` and reply with the model's answer — see [communication → listener](#communication). The Telegram bridge uses one session per chat (`telegram-<chat id>`), the Matrix bridge one per room (`matrix-<room>`).
|
||||
**Messaging bridges:** the Telegram and Matrix listeners forward non-command messages starting with `ai ` (case-insensitive) to `pos ai ask` and reply with the model's answer — see [communication → listener](#communication). The Telegram bridge uses one session per chat (`telegram-<chat id>`), the Matrix bridge one per room (`matrix-<room>`).
|
||||
|
||||
### network
|
||||
|
||||
@@ -200,12 +222,12 @@ Global config keys:
|
||||
|
||||
| Command | Behavior |
|
||||
|---------|----------|
|
||||
| `pos docker vbox create <name> [image] [--dir <path>]` | Creates a container from `ubuntu:22.04` (or the given image), bind-mounting `~/<name>` (or `--dir`, or `.` for cwd) as the working directory; prompts to enter immediately |
|
||||
| `pos docker vbox create <name> [image] [--dir <path>]… [--device </dev/node>]… [--gpu] [--port H:C]… [--cpus N] [--memory SIZE] [--network MODE]` | Creates a container from `ubuntu:22.04` (or the given image), bind-mounting `~/<name>` (or the first `--dir`; repeatable for extra same-path mounts) as the working directory; optional flags add GPU (`--gpus all`), device passthrough, port publishes and cpu/memory limits; prompts to enter immediately |
|
||||
| `pos docker vbox enter <name>` | Shell into the container (auto-starts it if stopped); detects the working dir from the container mounts |
|
||||
| `pos docker vbox start/stop/rm <name>` | Start, stop, or force-remove the container |
|
||||
| `pos docker vbox ls` | List vbox containers only (label filter) |
|
||||
|
||||
**`pos docker vbox menu`** — bare invocation on a terminal (or the explicit `menu` subcommand) opens an interactive hub wrapping these verbs: list, create (asks name/image/host directory, y/N before anything is pulled), enter (hands the terminal to the container shell — `exit` returns to the menu), start/stop (pick a VM), and remove (y/N confirm naming the VM; `rm -f` removes the container, the host folder is kept). Arguments stay scriptable; without a terminal the menu fails closed with a pointer to these subcommands.
|
||||
**`pos docker vbox menu`** — bare invocation on a terminal (or the explicit `menu` subcommand) opens an interactive hub wrapping these verbs: list, create (categorized flow: name → category hub with live basket counts — image quick-picks, GPU/Nvidia with automatic toolkit/device-node detection, host devices, dir mounts, ports, CPU/RAM → review screen rendering the exact `docker create` plan before anything runs; 'n' returns to the hub with edits preserved), enter (hands the terminal to the container shell — `exit` returns to the menu), start/stop (pick a VM), and remove (y/N confirm naming the VM; `rm -f` removes the container, the host folder is kept). Arguments stay scriptable; without a terminal the menu fails closed with a pointer to these subcommands.
|
||||
|
||||
The standalone `vbox` command still works and forwards to `pos docker vbox` (see [Legacy wrappers](#legacy-wrappers)).
|
||||
|
||||
@@ -238,6 +260,7 @@ reported as "N videos require sign-in — skipped" (escape hatch:
|
||||
| `pos system backup --service` | `bin/pos-system-backup` | Lists folders under `/srv` and `~/srv`, lets you pick one, then runs the same backup | Roots via `BACKUP_SERVICE_ROOTS` (space-separated, default `/srv $HOME/srv`) or `~/.config/linux_post_install/system.env` |
|
||||
| `pos system health` | `bin/pos-system-health` | Host health dashboard: disk per mount, RAM/swap, failed systemd units, backup age, fail2ban, docker containers. Exits 1 if any check FAILs | Console-only reporter — health itself never sends notifications; forward the output with a wrapper (e.g. the Telegram/Matrix listener map `/status=pos system health`) or schedule it via `pos system schedule` with a `NOTIFY` policy. `HEALTH_BACKUP_MAX_AGE_DAYS` (default 2) and `BACKUP_SERVICE_ROOTS` come from `~/.config/linux_post_install/system.env`; `--help` shows the effective values |
|
||||
| `pos system schedule <cmd>` | `bin/pos-system-schedule` | Scheduled jobs — run a command on a timer, notify (or stay silent): `run [name\|all]`, `list`, `config`, `enable [name\|all]`, `disable [name\|all]`, `status`, `migrate`. Each job is a file in `~/.config/linux_post_install/schedule.d/<name>.env` with `INTERVAL` (`5m…59m`, `1h…23h`, `hourly`, `daily`, `weekly`, `OnCalendar=…`), `NOTIFY` policy, optional `MSG`, `RULE` (threshold only), and `COMMAND` = the literal rest of the line (pipes/quotes/`sudo` fine). Policies: `always` (full output every run), `onchange` (send when output differs from the last run; first run always sends), `onerror` (non-zero exit or empty output), `threshold` (first numeric output vs `RULE`, alert on false→true + one recovery — the old event-trigger behavior), `never` (side-effect jobs, no notify) | One systemd **user** timer pair per job (`pos-schedule-<name>.timer` + oneshot `.service`, `Persistent=true`), reconciled on `enable`/`disable`; the legacy single `pos-event-trigger` timer is auto-removed. `migrate` converts a pre-existing `event.env` rule set into `schedule.d/rule-N.env` threshold jobs. `config` is an interactive editor (add/edit/remove/enable/disable, validates interval + threshold); alerts via `lib/notify.sh`; `--dry-run` previews runs/writes/sends; jobs are arbitrary shell commands (chmod 600, same trust model as the Telegram map); starter jobs in `config/schedule.d/` auto-installed no-clobber by postinstall. Bare invocation on a terminal (or the `menu` subcommand) opens an interactive hub over these verbs (list, timer status, run-now, enable, disable, config editor) — a menu run-now asks y/N first and goes through the same `run <name>` path the systemd timers use |
|
||||
| `pos system uninstall` | `bin/pos-system-uninstall` | Safe, interactive uninstaller for the pos toolkit — scans and removes binaries, services, shell integration, config, and data in three tiers | Tier 1 (always): binaries in `/usr/local/bin/` (pos, pos-*, libs, ai-providers, entertainment plugins, prebuilt, features), systemd services (disable+remove), shell integration in `~/.bashrc` (PATH, completion, pos-ai-hook source), completion file. Tier 2 (`--config`): `~/.config/linux_post_install/` (.env files, schedule.d/, authorized_keys, rclone.conf). Tier 3 (`--data`): `~/.local/share/linux_post_install/` (ai sessions, logs, captured output). Flags: `--yes` (skip prompts, tier 1 only), `--config` (include tier 2), `--data` (include tier 3). Combine all three for nuclear removal. Git repo is never removed |
|
||||
|
||||
A scheduled job is the recommended way to run the health dashboard on a timer: a `daily` job with `COMMAND=pos system health` and `NOTIFY=always` sends the dashboard output as the alert — no separate systemd unit needed (the old `pos-health.{service,timer}` units are gone; a legacy install may still have them failed/leftover — disable and remove them).
|
||||
|
||||
@@ -462,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/<scope>.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=<tag>` 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 |
|
||||
|
||||
+16
-1
@@ -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 <tool> <field>` 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 <callback>` 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 <category> --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`)
|
||||
|
||||
+166
-30
@@ -1,18 +1,60 @@
|
||||
# How-To: `pos ai`
|
||||
|
||||
Chat with Google Gemini — from the terminal and through the Telegram bot.
|
||||
Tools: `gemini` (`ask`, `chat`, `models`).
|
||||
Chat with AI models — Gemini, OpenRouter, and more — from the terminal and
|
||||
through the Telegram bot.
|
||||
Tool: `pos ai` with pluggable provider adapters (`gemini`, `openrouter`).
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| `pos ai gemini ask "<prompt>"` | One-shot answer to stdout (scriptable) |
|
||||
| `pos ai gemini ask --session <name> "…"` | Same, but remembers prior turns |
|
||||
| `pos ai gemini chat` | Interactive multi-turn conversation |
|
||||
| `pos ai gemini models` | List available model ids |
|
||||
| `pos ai gemini sessions` | List/clear persistent sessions (`reset <name>`) |
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `pos ai ask "<prompt>"` | Answer to stdout (scriptable; terse by default, `--full` for long form). Runs inside the persistent **`default`** session — it remembers prior turns across invocations |
|
||||
| `pos ai --provider openrouter ask "<prompt>"` | Same, but uses OpenRouter instead of the default Gemini provider |
|
||||
| `pos ai ask --last "why did that fail?"` | Same, but also appends the output of the **most recent logged pos command or captured output** so the model can diagnose a real failure (stderr notes which source + staleness warning) |
|
||||
| `pos ai capture <cmd>` | Run any command, capture its output for `--last`, and show it on screen |
|
||||
| `pos ai ask --session <name> "…"` | Same, but uses a named session instead of `default` |
|
||||
| `pos ai chat` | Interactive multi-turn conversation (session `default` unless `--session`) |
|
||||
| `pos ai models` | List available model ids for the active provider |
|
||||
| `pos ai providers` | List all available providers and their config status |
|
||||
| `pos ai sessions` | List persistent sessions / clear one (`reset <name>`, e.g. `reset default`) |
|
||||
|
||||
Shared flags: `--model <id>` overrides the model; `--system "<text>"` adds a
|
||||
system instruction to every turn (kept out of the session file).
|
||||
Shared flags: `--provider <name>` selects the backend (gemini|openrouter;
|
||||
default: gemini; also settable via `AI_PROVIDER` env/config); `--model <id>`
|
||||
overrides the model; `--system "<text>"` sets the system instruction for every
|
||||
turn (kept out of the session file) — it replaces the built-in terse ask prompt
|
||||
wholesale; `--full` skips that built-in prompt for long-form answers; `--last`
|
||||
attaches the latest pos command output or captured output (tail, max 4096 chars)
|
||||
to the question and notes on stderr which source was attached, its age, and a
|
||||
staleness warning once it is older than an hour (`ask` only; stdout stays pure
|
||||
answer). Use `capture` to save output from any command for `--last`.
|
||||
|
||||
Backward compatibility: `pos ai gemini` and `pos ai openrouter` still work as
|
||||
shorthand for `pos ai --provider gemini` and `pos ai --provider openrouter`.
|
||||
|
||||
Every `ask`/`chat` lands in a persistent session file under
|
||||
`~/.local/share/linux_post_install/ai/<name>.json` (capped at 40 turns).
|
||||
Terminal work accumulates in `default`; clear it with
|
||||
`pos ai sessions reset default`.
|
||||
|
||||
---
|
||||
|
||||
## Terse by default, rendered on screen
|
||||
|
||||
`ask` prepends a built-in system instruction telling the model to work like a
|
||||
CLI assistant: lead with the exact commands, one-line explanations, no essays —
|
||||
and when the message is a "how do I install/update/solve/edit X" question or
|
||||
pastes an error/command output, diagnose it and lead with the fix command(s).
|
||||
That prompt ends with one machine-context line (hostname, distro, kernel and
|
||||
architecture detected on this box), so answers match the actual machine;
|
||||
`--system "<text>"` swaps it wholesale; `--full` drops it for long-form output.
|
||||
`chat` keeps its neutral behavior (only `--system` applies).
|
||||
|
||||
On a terminal, answers are rendered as markdown, separated from your prompt
|
||||
line by one blank line: fenced code blocks stay
|
||||
monospace (indented + dimmed), inline `` `code` `` turns yellow, `**bold**`
|
||||
turns bold, headers become bold cyan, `---` becomes a thin rule. If `glow` is
|
||||
installed it is used automatically; otherwise a small built-in renderer kicks
|
||||
in — no extra dependency either way. When stdout is **not** a tty (pipes,
|
||||
scripts, cron, the Telegram/Matrix bridges) the raw markdown bytes are printed
|
||||
exactly as before (no added blank lines), so scripting stays byte-stable.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,23 +65,84 @@ system instruction to every turn (kept out of the session file).
|
||||
2. Configure it (masked input):
|
||||
|
||||
```bash
|
||||
pos config ai # enter AI_GEMINI_API_KEY
|
||||
pos config ai # enter AI_API_KEY (or AI_GEMINI_API_KEY)
|
||||
```
|
||||
|
||||
3. Test:
|
||||
|
||||
```bash
|
||||
pos ai gemini ask "Explain DNS in one line"
|
||||
pos ai gemini models # verify the default model id is live
|
||||
pos ai gemini chat # multi-turn conversation
|
||||
pos ai ask "Explain DNS in one line"
|
||||
pos ai models # verify the default model id is live
|
||||
pos ai chat # multi-turn conversation
|
||||
```
|
||||
|
||||
`ai.env` lives at `~/.config/linux_post_install/ai.env` (chmod 600); `pos config ai`
|
||||
is the only place the key is written. The key is never printed by `pos`.
|
||||
|
||||
## OpenRouter — many providers, one key
|
||||
|
||||
[OpenRouter](https://openrouter.ai) gives access to hundreds of models from
|
||||
different providers (Anthropic, OpenAI, Meta, Mistral, Google, …) through a
|
||||
single OpenAI-compatible API. Use `--provider openrouter` to switch:
|
||||
|
||||
```bash
|
||||
pos ai --provider openrouter ask "hi"
|
||||
# or the legacy shorthand:
|
||||
pos ai openrouter ask "hi"
|
||||
```
|
||||
|
||||
Configure the API key:
|
||||
|
||||
```bash
|
||||
pos config ai # enter AI_API_KEY (or OPENROUTER_API_KEY)
|
||||
```
|
||||
|
||||
The default model is `openrouter/auto` (OpenRouter picks the best available
|
||||
provider automatically). Override with `--model provider/model-name`:
|
||||
|
||||
```bash
|
||||
pos ai --provider openrouter ask --model anthropic/claude-sonnet-4 "explain DNS"
|
||||
```
|
||||
|
||||
All features work the same way across providers — `--last` for diagnosing
|
||||
failures, `--system` for custom instructions, `--full` for long-form answers,
|
||||
persistent sessions, tty markdown rendering, and machine context. Sessions are
|
||||
shared in `~/.local/share/linux_post_install/ai/` (universal messages format).
|
||||
|
||||
Switch providers per-invocation:
|
||||
|
||||
```bash
|
||||
pos ai ask "hello" # uses gemini (default)
|
||||
pos ai --provider openrouter ask "hello" # uses openrouter
|
||||
```
|
||||
|
||||
Or set the default via config:
|
||||
|
||||
```bash
|
||||
pos config ai # set AI_PROVIDER=openrouter
|
||||
```
|
||||
|
||||
## Provider architecture
|
||||
|
||||
`pos ai` uses a pluggable provider system. Each provider is a thin adapter
|
||||
in `lib/ai-providers/<name>.sh` that handles the API-specific logic (auth,
|
||||
request format, response parsing). The main tool handles sessions, rendering,
|
||||
machine context, and all shared logic.
|
||||
|
||||
Available providers:
|
||||
|
||||
| Provider | API | Default model | Config key |
|
||||
|----------|-----|---------------|------------|
|
||||
| `gemini` | Google Gemini REST API | `gemini-2.5-flash` | `AI_GEMINI_API_KEY` |
|
||||
| `openrouter` | OpenRouter (OpenAI-compatible) | `openrouter/auto` | `OPENROUTER_API_KEY` |
|
||||
|
||||
Adding a new provider: create `lib/ai-providers/<name>.sh` implementing
|
||||
`provider_name()`, `provider_default_model()`, `provider_generate()`, and
|
||||
`provider_models_list()`. See the existing adapters for the interface contract.
|
||||
|
||||
## From the Telegram bot
|
||||
|
||||
Once `pos ai gemini ask` works, any non-command message starting with `ai ` is
|
||||
Once `pos ai ask` works, any non-command message starting with `ai ` is
|
||||
answered by the model — no bot map entry needed:
|
||||
|
||||
```
|
||||
@@ -48,7 +151,7 @@ bot: NVIDIA is a company best known for GPUs...
|
||||
```
|
||||
|
||||
The bridge lives in the Telegram listener's `handle_message` (it calls
|
||||
`pos ai gemini ask`); only the owner chat is served, so your key stays private.
|
||||
`pos ai ask`); only the owner chat is served, so your key stays private.
|
||||
Set a different model per message:
|
||||
|
||||
```
|
||||
@@ -57,7 +160,8 @@ you: ai --model gemini-2.5-flash explain a Raft consensus log
|
||||
|
||||
### Telegram memory & formatting
|
||||
|
||||
Each chat has its own persistent session (`telegram-<chat id>`), so the model
|
||||
Each chat has its own persistent session (`telegram-<chat id>` — independent
|
||||
of your terminal's `default` session), so the model
|
||||
remembers the conversation; `ai /reset` clears it. The listener passes a system
|
||||
prompt telling the model it is answering in a Telegram chat — so it uses emojis
|
||||
and stays lively — and strips markdown (`**x**`, backticks, `#`, links…) from
|
||||
@@ -73,23 +177,55 @@ you: ai check this details about my linux ← reply to the /status message
|
||||
|
||||
## Recipes
|
||||
|
||||
- **Answer from a file:** `pos ai gemini ask "$(cat notes.txt)"`
|
||||
- **Pipe into it:** `echo "fix this: $(cat error.log)" | pos ai gemini ask`
|
||||
- **Answer in a cron job:** `pos ai gemini ask "summarize today's git log" > /tmp/ai_digest.txt`
|
||||
- **Diagnose the last failed pos run:** `pos ai ask --last "why did that fail?"` — every non-interactive `pos <cmd>` logs its output to `~/.local/share/linux_post_install/logs/`; `--last` attaches the newest one (tail, max 4096 chars, errors at the bottom kept) and says on stderr which log it grabbed (name, age, first line). Older than an hour? You get a `[!]` staleness warning — the newest log may predate your current problem, so pipe the fresh failure in instead
|
||||
- **Pipe arbitrary output in:** `failing-cmd 2>&1 | pos ai ask how do I fix this`
|
||||
- **Answer from a file:** `pos ai ask "$(cat notes.txt)"`
|
||||
- **Answer in a cron job:** `pos ai ask "summarize today's git log" > /tmp/ai_digest.txt`
|
||||
- **Long-form on demand:** `pos ai ask --full "compare ext4 and zfs in depth"`
|
||||
- **Forget what the terminal asked:** `pos ai sessions reset default`
|
||||
- **Switch to OpenRouter:** `pos ai --provider openrouter ask "hi"`
|
||||
- **Change the default model:**
|
||||
```bash
|
||||
pos config ai # set AI_GEMINI_MODEL, or:
|
||||
AI_GEMINI_MODEL=gemini-2.5-flash pos ai gemini ask "hi"
|
||||
pos config ai # set AI_MODEL, or:
|
||||
AI_MODEL=gemini-2.5-flash pos ai ask "hi"
|
||||
```
|
||||
- **List available providers:** `pos ai providers`
|
||||
|
||||
## Capturing any command's output for --last
|
||||
|
||||
By default, `--last` reads from pos dispatcher logs (only pos commands). To analyze
|
||||
output from **any** command (`pip install`, `apt upgrade`, `make`, etc.):
|
||||
|
||||
**Option A — explicit capture:**
|
||||
```bash
|
||||
pos ai capture pip install xyz
|
||||
pos ai ask --last "what happened"
|
||||
```
|
||||
The `capture` subcommand runs the command, shows its output on screen, and saves it
|
||||
for `--last`. Each `capture` overwrites the previous one (latest only).
|
||||
|
||||
**Option B — automatic capture (shell hook):**
|
||||
```bash
|
||||
# Add to ~/.bashrc:
|
||||
source /usr/local/bin/pos-ai-hook.sh
|
||||
```
|
||||
After sourcing, every command's output is silently captured. Then just run any
|
||||
command and `--last` picks it up automatically. Captures up to 1 MB (oldest
|
||||
truncated). To disable: `unset __POS_CAPTURE_ACTIVE`.
|
||||
|
||||
## How it works
|
||||
|
||||
- `ask` POSTs `contents:[{role:user, parts:[{text:"…"}]}]` to
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/<model>:generateContent`
|
||||
with the key in the `x-goog-api-key` header, and prints
|
||||
`.candidates[0].content.parts[].text` — nothing else.
|
||||
- `chat` keeps the whole conversation in memory as a growing `contents[]` array,
|
||||
so later turns have earlier context. `/reset` drops it.
|
||||
- `ask` sends the session history (OpenAI `messages` format) to the active
|
||||
provider's API. Gemini converts to `contents` format internally; OpenRouter
|
||||
sends `messages` directly. The answer text is printed to stdout.
|
||||
- Sessions live as one JSON file per name under
|
||||
`~/.local/share/linux_post_install/ai/` (`default.json` unless `--session`);
|
||||
each turn is appended and the file is pruned to the last 40 turns. Old
|
||||
Gemini-format sessions (`contents[]`) are auto-migrated to `messages` format
|
||||
on load.
|
||||
- `chat` keeps the whole conversation in memory as a growing `messages[]`
|
||||
array (seeded from the session file), so later turns have earlier context.
|
||||
`/reset` drops it (and empties the session file).
|
||||
- On a non-2xx response the API's `error.message` is shown and the exit code is
|
||||
non-zero — so scripts can rely on `ask` failing loudly.
|
||||
|
||||
@@ -98,7 +234,7 @@ you: ai check this details about my linux ← reply to the /status message
|
||||
- `ask` errors "No Gemini API key — run 'pos config ai'" → the key isn't set
|
||||
(or `ai.env` isn't readable). Run `pos config ai`.
|
||||
- `API error 400` → the model id is wrong or the prompt is too long for the
|
||||
model's context window; check `pos ai gemini models`.
|
||||
model's context window; check `pos ai models`.
|
||||
- `API error 429` → rate limit (free tier); wait and retry, or use a different
|
||||
model.
|
||||
- Nothing in Telegram for `ai …` → the listener daemon must be running
|
||||
|
||||
@@ -136,6 +136,8 @@ pos docker vbox create lab1 # default dir ~/lab1
|
||||
pos docker vbox create lab1 --dir . # files land in cwd
|
||||
pos docker vbox create lab1 --dir /mnt/data/lab1
|
||||
pos docker vbox create kali kalilinux/kali-rolling # custom image
|
||||
pos docker vbox create ai --gpu --cpus 4 --memory 8g
|
||||
pos docker vbox create iot --device /dev/ttyUSB0 --port 8080:80
|
||||
pos docker vbox enter lab1
|
||||
pos docker vbox stop lab1
|
||||
pos docker vbox start lab1
|
||||
@@ -143,6 +145,16 @@ pos docker vbox rm lab1
|
||||
pos docker vbox ls
|
||||
```
|
||||
|
||||
**Interactive create:** bare `pos docker vbox` (or the menu's "Create a VM")
|
||||
walks a name prompt → category hub → review screen that renders the exact
|
||||
`docker create` plan before anything is pulled; confirming runs the same
|
||||
`create` verb as the CLI. Categories: image quick-picks, GPU/Nvidia (offers
|
||||
`--gpus all` when the Nvidia container toolkit is present, explicit device
|
||||
nodes otherwise, info line when no GPU exists), host devices (USB, serial,
|
||||
video/sound, disks — system disks labelled), extra host-dir mounts, port
|
||||
publishes, CPU/RAM limits. Quitting or EOF at any point discards — nothing is
|
||||
created without an explicit `y` at the review.
|
||||
|
||||
**Recipe:** a disposable browsing/download box:
|
||||
```bash
|
||||
pos docker vbox create dl --dir /mnt/data/dl
|
||||
@@ -154,6 +166,8 @@ pos docker vbox rm dl # container gone, files kept
|
||||
- `enter` needs a shell/SSH-capable image; `kalilinux/kali-rolling` works.
|
||||
- If files "disappear" after `rm`, check you used `--dir` on a real path — the
|
||||
container image changes are lost, only the mounted dir persists.
|
||||
- `--gpu` needs `nvidia-container-toolkit`; without it, pass explicit nodes
|
||||
instead (`--device /dev/nvidia0 --device /dev/nvidiactl --device /dev/nvidia-uvm`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+15
-3
@@ -153,8 +153,14 @@ up — a down/unreachable NFS server can't break boot (with fstab it could).
|
||||
|
||||
**Interactive menu:** run `pos share nfs client` with no args for a menu
|
||||
(mount / persist / unmount / unpersist / list). Mountpoints are offered from
|
||||
existing mount-layout candidates with manual entry as fallback; unmount and
|
||||
unpersist tolerate already-absent targets instead of erroring.
|
||||
existing mount-layout candidates with manual entry as fallback — the picker
|
||||
also accepts the server-side export path as a "(as on server)" pick when it
|
||||
differs from your local layout, and `n=new` creates a fresh directory in
|
||||
place (y/N confirmed; a failure just returns to the picker). Unmount lists
|
||||
the active NFS mounts as `<mountpoint> ← <source>` picks and asks for
|
||||
confirmation before unmounting (with a typed fallback when nothing is
|
||||
mounted); unmount and unpersist tolerate already-absent targets instead of
|
||||
erroring.
|
||||
|
||||
**Troubleshooting:**
|
||||
- "mount.nfs not found" → `nfs-common` isn't installed; `sudo apt install nfs-common`
|
||||
@@ -285,7 +291,13 @@ fstab it could). `enable --now` arms the automount immediately.
|
||||
(enumerate / mount / persist / unmount / unpersist / list). Enter the server,
|
||||
an empty user tries guest enumeration first (with an auth retry on denial),
|
||||
then shares and mountpoints are offered as pickers with manual fallback —
|
||||
the account you authenticated with is reused for the mount.
|
||||
the account you authenticated with is reused for the mount. The mountpoint
|
||||
picker accepts `n=new` to create a fresh directory in place (y/N confirmed;
|
||||
a failure just returns to the picker); when the server is this machine, its
|
||||
underlying share directory is offered as a "(as on server)" pick too.
|
||||
Unmount lists the active CIFS mounts as `<mountpoint> ← <source>` picks and
|
||||
asks for confirmation before unmounting (typed fallback when nothing is
|
||||
mounted).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+33
-2
@@ -1,13 +1,14 @@
|
||||
# How-To: `pos system`
|
||||
|
||||
Host care: encrypted backups, firewall, and the health dashboard. Tools:
|
||||
`backup`, `firewall`, `health`.
|
||||
Host care: encrypted backups, firewall, health dashboard, and uninstall. Tools:
|
||||
`backup`, `firewall`, `health`, `uninstall`.
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| `pos system health` | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker) |
|
||||
| `pos system backup` | gpg-encrypted (AES-256) folder snapshots |
|
||||
| `pos system firewall` | Interactive UFW ("UFW POWER") management |
|
||||
| `pos system uninstall` | Safe, interactive uninstaller for the pos toolkit |
|
||||
|
||||
---
|
||||
|
||||
@@ -204,6 +205,36 @@ not).
|
||||
`sudo ufw allow 22/tcp`, then `sudo ufw reload`.
|
||||
- `ufw reset` requires typing `RESET` — deliberate.
|
||||
|
||||
## `pos system uninstall` — remove the pos toolkit
|
||||
|
||||
```bash
|
||||
pos system uninstall # interactive scan + confirm tier 1
|
||||
pos system uninstall --yes # non-interactive, tier 1 only
|
||||
pos system uninstall --yes --config --data # remove everything (nuclear option)
|
||||
```
|
||||
|
||||
Scans the system for installed pos components and removes them in three tiers:
|
||||
|
||||
| Tier | What it removes | How to include |
|
||||
|------|----------------|----------------|
|
||||
| **Tier 1** | Binaries (`/usr/local/bin/pos*`, libs, entertainment plugins, prebuilt, features), systemd services (disable+remove), shell integration (`~/.bashrc` PATH/completion/pos-ai-hook entries), completion file | Always (default) |
|
||||
| **Tier 2** | Config files (`~/.config/linux_post_install/` — `.env` files, `schedule.d/`, `authorized_keys`, `rclone.conf`) | `--config` flag |
|
||||
| **Tier 3** | Session/log data (`~/.local/share/linux_post_install/` — AI sessions, logs, captured output) | `--data` flag |
|
||||
|
||||
The default mode is interactive: it shows what will be removed and asks for
|
||||
confirmation. The git repo is **never** removed — delete it manually if desired.
|
||||
|
||||
**Recipes:**
|
||||
- Quick cleanup: `pos system uninstall --yes`
|
||||
- Full wipe: `pos system uninstall --yes --config --data`
|
||||
- Safe preview: run `pos system uninstall` without `--yes` to see the plan first
|
||||
|
||||
**Troubleshooting:**
|
||||
- "Nothing to remove" → pos toolkit is not installed (or already removed)
|
||||
- After uninstall, run `source ~/.bashrc` or restart your shell
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- Reference: [DOC/POS.md → system](../POS.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.
|
||||
|
||||
@@ -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-<cat>-<a>-<b> lists "b" under <a>.
|
||||
@@ -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 share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai-gemini 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"
|
||||
|
||||
Executable
+680
@@ -0,0 +1,680 @@
|
||||
#!/usr/bin/env bash
|
||||
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) | @[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"
|
||||
|
||||
# ── Paths & constants ──────────────────────────────────────────
|
||||
CONFIG_FILE="$HOME/.config/linux_post_install/ai.env"
|
||||
SESSION_DIR="$HOME/.local/share/linux_post_install/ai"
|
||||
DISPATCH_LOG_DIR="$HOME/.local/share/linux_post_install/logs" # bin/pos per-run logs
|
||||
LAST_CMD_OUTPUT_FILE="$HOME/.local/share/linux_post_install/last_cmd_output" # --last fallback for any command
|
||||
OS_RELEASE_FILE="${OS_RELEASE_FILE:-/etc/os-release}" # read-only test seam (DEV.md env-overridable paths)
|
||||
PROVIDER_DIR="$(dirname "$0")/../lib/ai-providers"
|
||||
# Fallback for installed layout (flat /usr/local/bin)
|
||||
[ -d "$PROVIDER_DIR" ] || PROVIDER_DIR="$(dirname "$0")/ai-providers"
|
||||
|
||||
SESSION="default"
|
||||
SYSTEM_PROMPT=""
|
||||
MAX_SESSION_TURNS=40
|
||||
LAST_LOG_MAX_BYTES=4096
|
||||
LAST_LOG_STALE_SECS=3600 # --last: warn when the attached log is older than this
|
||||
# Built-in terse ask prompt. cmd_ask appends a machine-context clause
|
||||
# (see machine_context) unless --system replaces it or --full drops everything.
|
||||
DEFAULT_SYSTEM_PROMPT_HARD="You are a Linux CLI assistant. Rules:
|
||||
1. Lead with exact command(s) — no explanations unless asked
|
||||
2. One line max per command; short bullets for multi-step only
|
||||
3. No greetings, no pleasantries, no closing offers
|
||||
4. For errors: diagnose and give the fix command first
|
||||
5. Match the user's OS/package manager (apt/dnf/pacman)"
|
||||
|
||||
# Legacy: kept for session migration and backward compat config
|
||||
LEGACY_GEMINI_CONFIG="$HOME/.config/linux_post_install/ai.env"
|
||||
LEGACY_OPENROUTER_CONFIG="$HOME/.config/linux_post_install/ai-openrouter.env"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos ai [subcommand] [--provider <name>] [--model <id>] [--session <name>] [--system <text>] [--full] [--last]
|
||||
|
||||
AI assistant with pluggable providers (gemini, openrouter).
|
||||
|
||||
Subcommands:
|
||||
ask "<prompt>" Answer; prints the answer text to stdout. The prompt may
|
||||
also be piped in via stdin when no argument is given.
|
||||
Runs inside the persistent 'default' session (prior turns
|
||||
are sent as context); --session <name> picks another.
|
||||
capture <cmd..> Run a command, capture its output for --last, and show it.
|
||||
Each capture overwrites the previous one (latest only).
|
||||
chat Interactive multi-turn conversation (session 'default'
|
||||
unless --session is given).
|
||||
models List available models for the active provider.
|
||||
providers List available providers and their config status.
|
||||
sessions List persistent sessions / clear one:
|
||||
'sessions' and 'sessions reset <name>'.
|
||||
|
||||
Options:
|
||||
--provider <name> Provider to use (gemini|openrouter; default: gemini).
|
||||
Can also be set via AI_PROVIDER env/config.
|
||||
--model <id> Override the model for this invocation.
|
||||
--session <name> Use a named persistent session instead of 'default':
|
||||
~/.local/share/linux_post_install/ai/<name>.json
|
||||
(capped at $MAX_SESSION_TURNS turns).
|
||||
--system <text> System instruction sent with every turn (kept out of the
|
||||
session file); replaces the built-in terse ask prompt
|
||||
wholesale, e.g. "Reply like a friendly Telegram chat".
|
||||
--full Skip the built-in terse prompt — long-form answers.
|
||||
--last ask only: attach the most recent pos dispatcher log or
|
||||
captured output (tail, max $LAST_LOG_MAX_BYTES chars) so
|
||||
the model can diagnose a real failure. Sources in priority
|
||||
order: (1) newest pos log, (2) captured output from
|
||||
'capture'. Notes on stderr which source was attached and
|
||||
its age; warns when stale (>60 min).
|
||||
-h|--help This help.
|
||||
|
||||
Config: $CONFIG_FILE (edit with 'pos config ai')
|
||||
AI_PROVIDER Provider to use (gemini|openrouter, default gemini)
|
||||
AI_SYSTEM_PROMPT Custom system prompt (overrides built-in; empty to reset)
|
||||
Provider keys: auto-discovered from lib/ai-providers/*.sh
|
||||
(AI_GEMINI_API_KEY, OPENROUTER_API_KEY, etc.)
|
||||
|
||||
Notes:
|
||||
ask is terse by default: a built-in system instruction tells the model to
|
||||
lead with the exact commands and keep prose minimal — and to diagnose pasted
|
||||
errors/output with a fix first. That prompt ends with one machine-context
|
||||
line (hostname, distro, kernel, arch detected on this box) so answers fit
|
||||
the actual machine; --system replaces it wholesale, --full drops it all.
|
||||
Every ask/chat lands in a persistent session ('default' unless --session);
|
||||
clear it with 'pos ai sessions reset default'. On a terminal the
|
||||
answer is rendered as markdown (glow if installed, else a built-in
|
||||
renderer); when stdout is not a tty (pipes, scripts, Telegram bridges) the
|
||||
raw markdown is printed unchanged.
|
||||
|
||||
Examples:
|
||||
pos ai ask "check disk space on /"
|
||||
pos ai --provider gemini ask "Explain DNS in one line"
|
||||
pos ai --provider openrouter ask "hi"
|
||||
echo "summarize this log" | pos ai ask
|
||||
failing-cmd 2>&1 | pos ai ask how do I fix this
|
||||
pos ai ask --last "why did that fail?" # attach last output
|
||||
pos ai capture pip install xyz # capture any command
|
||||
pos ai ask --last "what happened?" # after capture
|
||||
pos ai chat
|
||||
pos ai models
|
||||
pos ai providers
|
||||
pos ai ask --model gemini-2.5-flash "hi"
|
||||
pos ai ask --system "Reply like a pirate" "explain chmod"
|
||||
pos ai ask --session work "my name is joe"
|
||||
pos ai ask --session work "what is my name?" # remembers
|
||||
pos ai sessions
|
||||
pos ai sessions reset default # forget default memory
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Provider loading ───────────────────────────────────────────
|
||||
load_provider() {
|
||||
local p="${PROVIDER:-gemini}"
|
||||
local f="$PROVIDER_DIR/$p.sh"
|
||||
[ -f "$f" ] || err "Unknown provider '$p' — available: $(ls "$PROVIDER_DIR"/*.sh 2>/dev/null | xargs -I{} basename {} .sh | tr '\n' ' ')"
|
||||
# shellcheck source=/dev/null
|
||||
source "$f"
|
||||
}
|
||||
|
||||
# ── ai.env loader (same pattern as telegram.env) ────────────────
|
||||
load_config() {
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
local k v
|
||||
while IFS='=' read -r k v; do
|
||||
[ -n "$k" ] || continue
|
||||
case "$k" in
|
||||
\#*) continue ;;
|
||||
esac
|
||||
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
||||
v="${v//$'\r'/}"
|
||||
if [ -z "${!k:-}" ]; then
|
||||
export "$k"="$v"
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
|
||||
# Legacy provider-specific config files (fallback for old configs)
|
||||
# Both files are loaded — env-var precedence means unified AI_API_KEY wins.
|
||||
local legacy_files="$LEGACY_GEMINI_CONFIG $LEGACY_OPENROUTER_CONFIG"
|
||||
local legacy_env
|
||||
for legacy_env in $legacy_files; do
|
||||
[ -f "$legacy_env" ] && [ "$legacy_env" != "$CONFIG_FILE" ] || continue
|
||||
while IFS='=' read -r k v; do
|
||||
[ -n "$k" ] || continue
|
||||
case "$k" in \#*) continue ;; esac
|
||||
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
||||
v="${v//$'\r'/}"
|
||||
if [ -z "${!k:-}" ]; then
|
||||
export "$k"="$v"
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+=' "$legacy_env" || true)
|
||||
done
|
||||
}
|
||||
|
||||
# ── Config resolution ──────────────────────────────────────────
|
||||
resolve_key() {
|
||||
load_config
|
||||
local p="${PROVIDER:-gemini}"
|
||||
# Each provider has its own API key — set AI_API_KEY internally for adapters
|
||||
case "$p" in
|
||||
gemini) [ -n "${AI_GEMINI_API_KEY:-}" ] && export AI_API_KEY="$AI_GEMINI_API_KEY" && return 0 ;;
|
||||
openrouter) [ -n "${OPENROUTER_API_KEY:-}" ] && export AI_API_KEY="$OPENROUTER_API_KEY" && return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
require_key() {
|
||||
if ! resolve_key >/dev/null 2>&1; then
|
||||
local p="${PROVIDER:-gemini}"
|
||||
case "$p" in
|
||||
gemini) err "No Gemini API key — run 'pos config ai' and set AI_GEMINI_API_KEY" ;;
|
||||
openrouter) err "No OpenRouter API key — run 'pos config ai' and set OPENROUTER_API_KEY" ;;
|
||||
esac
|
||||
err "No API key for provider '$p' — run 'pos config ai'"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_model() {
|
||||
local p="${PROVIDER:-gemini}"
|
||||
if [ -n "${MODEL_OVERRIDE:-}" ]; then
|
||||
printf '%s' "$MODEL_OVERRIDE"
|
||||
elif [ -n "${AI_MODEL:-}" ]; then
|
||||
printf '%s' "$AI_MODEL"
|
||||
else
|
||||
# Provider-specific fallback
|
||||
case "$p" in
|
||||
gemini) [ -n "${AI_GEMINI_MODEL:-}" ] && printf '%s' "$AI_GEMINI_MODEL" && return ;;
|
||||
openrouter) [ -n "${OPENROUTER_MODEL:-}" ] && printf '%s' "$OPENROUTER_MODEL" && return ;;
|
||||
esac
|
||||
provider_default_model
|
||||
fi
|
||||
}
|
||||
|
||||
# ── --last: attach the most recent pos command output ───────────
|
||||
newest_pos_log() {
|
||||
[ -d "$DISPATCH_LOG_DIR" ] || return 1
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
[ -s "$f" ] && { printf '%s' "$f"; return 0; }
|
||||
done < <(ls "$DISPATCH_LOG_DIR"/*.log 2>/dev/null | LC_ALL=C sort -r | grep -v '/pos\.log$')
|
||||
return 1
|
||||
}
|
||||
|
||||
last_log_context() {
|
||||
local raw
|
||||
raw="$(tail -c "$LAST_LOG_MAX_BYTES" "$1")"
|
||||
if command -v iconv >/dev/null 2>&1; then
|
||||
raw="$(printf '%s' "$raw" | iconv -c -f utf-8 -t utf-8 2>/dev/null || printf '%s' "$raw")"
|
||||
fi
|
||||
if [ "$(wc -c <"$1")" -gt "$LAST_LOG_MAX_BYTES" ]; then
|
||||
printf '[…truncated…]\n%s' "$raw"
|
||||
else
|
||||
printf '%s' "$raw"
|
||||
fi
|
||||
}
|
||||
|
||||
human_age() {
|
||||
local s="$1"
|
||||
[ "$s" -lt 0 ] && s=0
|
||||
if [ "$s" -lt 60 ]; then printf 'just now'
|
||||
elif [ "$s" -lt 3600 ]; then printf '%sm' "$((s / 60))"
|
||||
elif [ "$s" -lt 86400 ]; then printf '%sh' "$((s / 3600))"
|
||||
else printf '%sd' "$((s / 86400))"
|
||||
fi
|
||||
}
|
||||
|
||||
last_log_annotate() {
|
||||
local f="$1" age_s age line
|
||||
age_s=$(( $(date +%s) - $(stat -c %Y "$f") ))
|
||||
[ "$age_s" -lt 0 ] && age_s=0
|
||||
age="$(human_age "$age_s")"
|
||||
printf '[i] attaching last pos output — %s (%s)\n' "$(basename "$f")" "$age" >&2
|
||||
line="$(grep -m1 '[^[:space:]]' "$f" 2>/dev/null || true)"
|
||||
if [ -n "$line" ]; then
|
||||
printf '[i] "%.100s"\n' "$line" >&2
|
||||
fi
|
||||
if [ "$age_s" -gt "$LAST_LOG_STALE_SECS" ]; then
|
||||
printf '[!] that log is %s old and may not match your current problem. For a FRESH failure of any command: failing-cmd 2>&1 | pos ai ask "what happened"\n' "$age" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Persistent session memory (universal OpenAI messages format) ─
|
||||
session_file() {
|
||||
local name="${1:-$SESSION}"
|
||||
name="${name//[^A-Za-z0-9_-]/_}"
|
||||
printf '%s/%s.json' "$SESSION_DIR" "$name"
|
||||
}
|
||||
|
||||
session_load() {
|
||||
[ -n "$SESSION" ] || { printf '{"messages":[]}'; return 0; }
|
||||
local f
|
||||
f="$(session_file)"
|
||||
if [ -s "$f" ]; then
|
||||
# Check for old Gemini contents format → migrate transparently
|
||||
if jq -e '.contents' "$f" >/dev/null 2>&1 && ! jq -e '.messages' "$f" >/dev/null 2>&1; then
|
||||
local migrated
|
||||
migrated="$(jq -c '{messages: [.contents[]? | {role: (if .role == "model" then "assistant" else .role end), content: (.parts | map(.text) | join(""))}]}' "$f" 2>/dev/null)" || {
|
||||
printf '{"messages":[]}'; return 0
|
||||
}
|
||||
printf '%s\n' "$migrated" > "$f"
|
||||
chmod 600 "$f"
|
||||
printf '%s' "$migrated"
|
||||
elif jq -e '.messages' "$f" >/dev/null 2>&1; then
|
||||
cat "$f"
|
||||
else
|
||||
printf '{"messages":[]}'
|
||||
fi
|
||||
else
|
||||
printf '{"messages":[]}'
|
||||
fi
|
||||
}
|
||||
|
||||
session_save() {
|
||||
[ -n "$SESSION" ] || return 0
|
||||
local f tmp
|
||||
f="$(session_file)"
|
||||
mkdir -p "$SESSION_DIR"
|
||||
tmp="$(mktemp)"
|
||||
printf '%s\n' "$1" >"$tmp"
|
||||
mv "$tmp" "$f"
|
||||
chmod 600 "$f"
|
||||
}
|
||||
|
||||
# Append a turn and prune to the last MAX_SESSION_TURNS entries. stdout = JSON.
|
||||
session_push() {
|
||||
local messages="$1" role="$2" text="$3"
|
||||
printf '%s' "$messages" | jq -c --arg r "$role" --arg t "$text" \
|
||||
'.messages += [{role:$r, content:$t}] | .messages |= .[-'"$MAX_SESSION_TURNS"':]'
|
||||
}
|
||||
|
||||
# ── Terminal markdown rendering (tty-only; raw bytes otherwise) ──
|
||||
render_markdown() {
|
||||
local text="$1"
|
||||
# Check stdout tty OR controlling terminal (/dev/tty) — the shell hook
|
||||
# (pos-ai-hook.sh) redirects stdout through tee, breaking [ -t 1 ], but
|
||||
# /dev/tty remains writable in interactive shells.
|
||||
if [ ! -t 1 ] && [ ! -w /dev/tty ]; then
|
||||
printf '%s\n' "$text"
|
||||
return 0
|
||||
fi
|
||||
local rendered prog
|
||||
prog='
|
||||
BEGIN {
|
||||
e = sprintf("%c", 27)
|
||||
R = e "[0m"; DIM = e "[2m"; B = e "[1m"
|
||||
YEL = e "[33m"; CYA = e "[1;36m"
|
||||
RULE = ""
|
||||
for (i = 0; i < 60; i++) RULE = RULE "─"
|
||||
RULE = DIM RULE R
|
||||
}
|
||||
/^```/ { fence = !fence; next }
|
||||
fence { printf "%s %s%s\n", DIM, $0, R; next }
|
||||
/^#{1,4} / {
|
||||
sub(/^#{1,4} +/, "")
|
||||
printf "%s%s%s\n", CYA, $0, R
|
||||
next
|
||||
}
|
||||
/^(-{3,}|\*{3,}|_{3,})$/ { print RULE; next }
|
||||
{
|
||||
line = $0
|
||||
out = ""; rest = line
|
||||
while (match(rest, /`[^`]*`/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) YEL \
|
||||
substr(rest, RSTART + 1, RLENGTH - 2) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
line = out rest
|
||||
out = ""; rest = line
|
||||
while (match(rest, /\*\*[^*]+\*\*/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) B \
|
||||
substr(rest, RSTART + 2, RLENGTH - 4) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
line = out rest
|
||||
out = ""; rest = line
|
||||
while (match(rest, /__[^_]+__/)) {
|
||||
out = out substr(rest, 1, RSTART - 1) B \
|
||||
substr(rest, RSTART + 2, RLENGTH - 4) R
|
||||
rest = substr(rest, RSTART + RLENGTH)
|
||||
}
|
||||
print out rest
|
||||
}
|
||||
'
|
||||
if command -v glow >/dev/null 2>&1; then
|
||||
rendered="$(printf '%s\n' "$text" | glow -)"
|
||||
else
|
||||
rendered="$(printf '%s\n' "$text" | awk "$prog")"
|
||||
fi
|
||||
printf '\n%s\n' "$rendered"
|
||||
}
|
||||
|
||||
# ── Command extraction from AI responses ────────────────────────
|
||||
_extract_commands() {
|
||||
local text="$1"
|
||||
printf '%s' "$text" | awk '
|
||||
/^```(bash|sh|shell)/ { in_block=1; next }
|
||||
/^```/ { if (in_block) in_block=0; next }
|
||||
in_block && NF > 0 { lines[++n] = $0 }
|
||||
END {
|
||||
for (i = 1; i <= n; i++) {
|
||||
if (i > 1) printf "\n"
|
||||
printf "%s", lines[i]
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
# ── Interactive prompt to run extracted commands ─────────────────
|
||||
_prompt_run_command() {
|
||||
local cmd="$1"
|
||||
# Only prompt on interactive terminals with a controlling tty
|
||||
[ -w /dev/tty ] || return 0
|
||||
printf '\n%s\n' "Command detected:" >&2
|
||||
printf ' %s\n\n' "$cmd" >&2
|
||||
printf 'Run this command? [Y/n] ' >&2
|
||||
local choice
|
||||
IFS= read -r choice </dev/tty || choice=""
|
||||
case "${choice,,}" in
|
||||
n|N)
|
||||
# Add to shell history so user can press ↑ to recall, edit, run
|
||||
history -s "$cmd" 2>/dev/null || true
|
||||
printf '%s\n' "Command added to history — press ↑ to recall, edit, and run." >&2
|
||||
;;
|
||||
*)
|
||||
# Y or Enter: execute
|
||||
printf '%s\n' "$cmd"
|
||||
run eval "$cmd"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Machine context appended to the built-in default prompt ─────
|
||||
mc_clean() {
|
||||
sed -e 's/\x1b\[[0-9;]*[A-Za-z]//g' \
|
||||
-e 's/[[:space:]][[:space:]]*/ /g' \
|
||||
| tr -d '\000-\010\013-\037\177' \
|
||||
| sed -e 's/^ //; s/ $//'
|
||||
}
|
||||
|
||||
machine_context() {
|
||||
local raw line key val h="" o="" k="" a="" part out=""
|
||||
if command -v hostnamectl >/dev/null 2>&1; then
|
||||
raw="$(hostnamectl status 2>/dev/null || true)"
|
||||
while IFS= read -r line; do
|
||||
key="$(printf '%s' "${line%%:*}" | tr -d '[:space:]')"
|
||||
val="${line#*:}"
|
||||
case "$key" in
|
||||
Statichostname|Transienthostname|Hostname)
|
||||
[ -z "$h" ] && h="$val" ;;
|
||||
OperatingSystem)
|
||||
[ -z "$o" ] && o="$val" ;;
|
||||
Kernel)
|
||||
[ -z "$k" ] && k="$val" ;;
|
||||
Architecture)
|
||||
[ -z "$a" ] && a="$val" ;;
|
||||
esac
|
||||
done <<< "$raw"
|
||||
fi
|
||||
if [ -z "$o" ] && [ -r "$OS_RELEASE_FILE" ]; then
|
||||
o="$(
|
||||
. "$OS_RELEASE_FILE" 2>/dev/null || true
|
||||
if [ -n "${PRETTY_NAME:-}" ]; then
|
||||
printf '%s' "$PRETTY_NAME"
|
||||
elif [ -n "${NAME:-}" ]; then
|
||||
printf '%s' "${NAME}${VERSION_ID:+ (${VERSION_ID})}"
|
||||
fi
|
||||
)"
|
||||
fi
|
||||
[ -n "$k" ] || k="$(uname -sr 2>/dev/null || true)"
|
||||
[ -n "$a" ] || a="$(uname -m 2>/dev/null || true)"
|
||||
h="$(printf '%s' "$h" | mc_clean)"
|
||||
o="$(printf '%s' "$o" | mc_clean)"
|
||||
k="$(printf '%s' "$k" | mc_clean)"
|
||||
a="$(printf '%s' "$a" | mc_clean)"
|
||||
case "$k" in "Linux "*) k="${k#Linux }" ;; esac
|
||||
local out=""
|
||||
for part in "$h" "$o" "${k:+kernel $k}" "$a"; do
|
||||
[ -n "$part" ] || continue
|
||||
if [ -n "$out" ]; then out="$out, $part"; else out="$part"; fi
|
||||
done
|
||||
[ -n "$out" ] || return 0
|
||||
printf 'Machine context (answers must fit this box): %s.' "$out"
|
||||
}
|
||||
|
||||
# ── Subcommands ────────────────────────────────────────────────
|
||||
cmd_capture() {
|
||||
[ $# -gt 0 ] || err "usage: pos ai capture <command> [args...]"
|
||||
mkdir -p "$(dirname "$LAST_CMD_OUTPUT_FILE")"
|
||||
"$@" 2>&1 | tee "$LAST_CMD_OUTPUT_FILE"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
printf '[captured → %s]\n' "$LAST_CMD_OUTPUT_FILE" >&2
|
||||
return $rc
|
||||
}
|
||||
|
||||
cmd_ask() {
|
||||
local prompt="" messages out system ctx mc
|
||||
if [ $# -gt 0 ]; then
|
||||
prompt="$*"
|
||||
elif [ ! -t 0 ]; then
|
||||
prompt="$(cat)"
|
||||
fi
|
||||
[ -n "$prompt" ] || err "No prompt given — usage: pos ai ask \"<prompt>\""
|
||||
# --last: append the most recent logged pos command output AFTER the
|
||||
# question, so the model diagnoses the real failure.
|
||||
if [ "$LAST_MODE" -eq 1 ]; then
|
||||
local log_file="" pos_log="" captured_log=""
|
||||
pos_log="$(newest_pos_log 2>/dev/null)" || true
|
||||
[ -s "$LAST_CMD_OUTPUT_FILE" ] && captured_log="$LAST_CMD_OUTPUT_FILE"
|
||||
if [ -n "$pos_log" ] && [ -n "$captured_log" ]; then
|
||||
local pos_age=$(( $(date +%s) - $(stat -c %Y "$pos_log") ))
|
||||
local cap_age=$(( $(date +%s) - $(stat -c %Y "$captured_log") ))
|
||||
if [ "$cap_age" -lt "$pos_age" ]; then
|
||||
log_file="$captured_log"
|
||||
else
|
||||
log_file="$pos_log"
|
||||
fi
|
||||
elif [ -n "$captured_log" ]; then
|
||||
log_file="$captured_log"
|
||||
else
|
||||
log_file="$pos_log"
|
||||
fi
|
||||
[ -n "$log_file" ] || err "No recent output found — run 'pos ai capture <cmd>' first, or pipe: cmd 2>&1 | pos ai ask \"what happened\""
|
||||
last_log_annotate "$log_file"
|
||||
ctx="$(last_log_context "$log_file")"
|
||||
prompt="$prompt"$'\n\n[last command output:]\n'"$ctx"
|
||||
fi
|
||||
require_key
|
||||
# Terse by default: user --system replaces the built-in prompt wholesale;
|
||||
# --full skips everything (built-in text AND machine context).
|
||||
system="$SYSTEM_PROMPT"
|
||||
if [ -z "$system" ] && [ "$FULL_MODE" -eq 0 ]; then
|
||||
# Check AI_SYSTEM_PROMPT config first, then fall back to built-in
|
||||
system="${AI_SYSTEM_PROMPT:-}"
|
||||
if [ -z "$system" ]; then
|
||||
system="$DEFAULT_SYSTEM_PROMPT_HARD"
|
||||
fi
|
||||
mc="$(machine_context)"
|
||||
[ -n "$mc" ] && mc=" $mc"
|
||||
system="$system$mc"
|
||||
fi
|
||||
# Persistent session memory ('default' unless --session).
|
||||
messages="$(session_load)"
|
||||
messages="$(session_push "$messages" user "$prompt")"
|
||||
if ! out="$(provider_generate "$(resolve_model)" "$messages" "$system" 2>&1)"; then
|
||||
err "$out"
|
||||
fi
|
||||
messages="$(session_push "$messages" assistant "$out")"
|
||||
session_save "$messages"
|
||||
render_markdown "$out"
|
||||
# Command execution prompt: extract commands from response and offer to run
|
||||
local _cmd
|
||||
_cmd="$(_extract_commands "$out")"
|
||||
[ -n "$_cmd" ] && _prompt_run_command "$_cmd"
|
||||
}
|
||||
|
||||
cmd_chat() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for chat: $*"
|
||||
local model messages text answer provider_display
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
provider_display="$(provider_name)"
|
||||
messages="$(session_load)"
|
||||
printf 'session: %s (resumed %s prior turns)\n' "$SESSION" "$(printf '%s' "$messages" | jq -r '.messages | length')"
|
||||
trap 'echo; echo "bye"; exit 0' INT
|
||||
echo "${provider_display} · ${model} — type a message; q=quit, /reset=clear history"
|
||||
while true; do
|
||||
printf '> '
|
||||
IFS= read -r text || break
|
||||
case "$text" in
|
||||
"" ) continue ;;
|
||||
q|Q|quit|exit) echo; echo "bye"; return 0 ;;
|
||||
/reset)
|
||||
messages='{"messages":[]}'
|
||||
session_save "$messages"
|
||||
echo "[history cleared]"
|
||||
continue ;;
|
||||
esac
|
||||
messages="$(session_push "$messages" user "$text")"
|
||||
if ! answer="$(provider_generate "$model" "$messages" "$SYSTEM_PROMPT" 2>&1)"; then
|
||||
warn "AI error: $answer"
|
||||
continue
|
||||
fi
|
||||
messages="$(session_push "$messages" assistant "$answer")"
|
||||
session_save "$messages"
|
||||
printf '\n'
|
||||
render_markdown "$answer"
|
||||
# Command execution prompt: extract commands from response and offer to run
|
||||
local _cmd
|
||||
_cmd="$(_extract_commands "$answer")"
|
||||
[ -n "$_cmd" ] && _prompt_run_command "$_cmd"
|
||||
printf '\n\n'
|
||||
done
|
||||
echo
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_sessions() {
|
||||
local action="${1:-list}" name f n
|
||||
case "$action" in
|
||||
list|"")
|
||||
[ -d "$SESSION_DIR" ] || { echo "no sessions"; return 0; }
|
||||
local found=0
|
||||
for f in "$SESSION_DIR"/*.json; do
|
||||
[ -f "$f" ] || continue
|
||||
found=1
|
||||
n="$(jq -r '.messages | length' "$f" 2>/dev/null || echo 0)"
|
||||
printf ' %-32s %s turns\n' "$(basename "$f" .json)" "${n:-0}"
|
||||
done
|
||||
[ "$found" -eq 1 ] || echo "no sessions"
|
||||
;;
|
||||
reset)
|
||||
[ $# -ge 2 ] || err "usage: pos ai sessions reset <name>"
|
||||
name="$2"
|
||||
if rm -f "$(session_file "$name")"; then
|
||||
ok "session '$name' cleared"
|
||||
fi
|
||||
;;
|
||||
*) err "Unknown sessions subcommand '$action' (list | reset <name>)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_models() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for models: $*"
|
||||
local model
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
provider_models_list "$model"
|
||||
}
|
||||
|
||||
cmd_providers() {
|
||||
echo "Available providers:"
|
||||
load_config # ensure env vars are populated
|
||||
local active="${PROVIDER:-gemini}"
|
||||
for f in "$PROVIDER_DIR"/*.sh; do
|
||||
[ -f "$f" ] || continue
|
||||
local name pname pmodel configured current
|
||||
name="$(basename "$f" .sh)"
|
||||
# Source provider in a subshell to get its metadata
|
||||
local meta
|
||||
meta="$( ( source "$f"; printf '%s\x00%s' "$(provider_name)" "$(provider_default_model)" ) 2>/dev/null )" || true
|
||||
pname="${meta%%$'\x00'*}"
|
||||
pmodel="${meta#*$'\x00'}"
|
||||
[ -n "$pname" ] || pname="$name"
|
||||
[ -n "$pmodel" ] || pmodel="unknown"
|
||||
# Check if API key exists for this provider
|
||||
configured="not configured"
|
||||
case "$name" in
|
||||
gemini) [ -n "${AI_GEMINI_API_KEY:-}" ] && configured="configured" ;;
|
||||
openrouter) [ -n "${OPENROUTER_API_KEY:-}" ] && configured="configured" ;;
|
||||
esac
|
||||
current=""
|
||||
[ "$name" = "$active" ] && current=" ← active"
|
||||
printf ' %-16s %s (model: %s)%s\n' "$name" "$configured" "$pmodel" "$current"
|
||||
done
|
||||
}
|
||||
|
||||
# ── Parse flags + subcommand ────────────────────────────────────
|
||||
MODEL_OVERRIDE=""
|
||||
FULL_MODE=0
|
||||
LAST_MODE=0
|
||||
PROVIDER=""
|
||||
cmd=""
|
||||
args=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help) usage ;;
|
||||
--provider)
|
||||
[ $# -ge 2 ] || err "--provider needs a value"
|
||||
PROVIDER="$2"; shift 2 ;;
|
||||
--model)
|
||||
[ $# -ge 2 ] || err "--model needs a value"
|
||||
MODEL_OVERRIDE="$2"; shift 2 ;;
|
||||
--session)
|
||||
[ $# -ge 2 ] || err "--session needs a value"
|
||||
SESSION="$2"; shift 2 ;;
|
||||
--system)
|
||||
[ $# -ge 2 ] || err "--system needs a value"
|
||||
SYSTEM_PROMPT="$2"; shift 2 ;;
|
||||
--full)
|
||||
FULL_MODE=1; shift ;;
|
||||
--last)
|
||||
LAST_MODE=1; shift ;;
|
||||
-*) err "Unknown option '$1' (see --help)" ;;
|
||||
*)
|
||||
if [ -z "$cmd" ]; then
|
||||
cmd="$1"
|
||||
else
|
||||
args+=("$1")
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Resolve provider: --provider flag > AI_PROVIDER env/config > default gemini
|
||||
if [ -z "$PROVIDER" ]; then
|
||||
load_config
|
||||
PROVIDER="${AI_PROVIDER:-gemini}"
|
||||
fi
|
||||
|
||||
# Load provider adapter functions
|
||||
load_provider
|
||||
|
||||
if [ "$LAST_MODE" -eq 1 ] && [ "${cmd:-}" != "ask" ]; then
|
||||
err "--last only applies to 'ask' — for capturing output use 'capture': pos ai capture <cmd>"
|
||||
fi
|
||||
|
||||
case "${cmd:-}" in
|
||||
"") usage ;;
|
||||
ask) cmd_ask "${args[@]}" ;;
|
||||
capture) cmd_capture "${args[@]}" ;;
|
||||
chat) cmd_chat "${args[@]}" ;;
|
||||
models) cmd_models "${args[@]}" ;;
|
||||
providers) cmd_providers "${args[@]}" ;;
|
||||
sessions) cmd_sessions "${args[@]}" ;;
|
||||
*) err "Unknown ai subcommand '$cmd' (see --help)" ;;
|
||||
esac
|
||||
Executable
+658
@@ -0,0 +1,658 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: ai alias — manage AI agent aliases
|
||||
# POS_SUBCMDS: create edit remove list show
|
||||
|
||||
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"
|
||||
|
||||
# ── Paths & constants ──────────────────────────────────────────
|
||||
ENV_FILE="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}/ai-aliases.env"
|
||||
SH_FILE="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}/ai-aliases.sh"
|
||||
|
||||
# Provider discovery — same pattern as bin/pos-ai (lines 17-18)
|
||||
PROVIDER_DIR="$(dirname "$0")/../lib/ai-providers"
|
||||
[ -d "$PROVIDER_DIR" ] || PROVIDER_DIR="$(dirname "$0")/ai-providers"
|
||||
|
||||
# ── Core helpers ───────────────────────────────────────────────
|
||||
|
||||
_alias_load() {
|
||||
_ALIAS_NAMES=(); _ALIAS_PROVIDERS=(); _ALIAS_SESSIONS=(); _ALIAS_PROMPTS=()
|
||||
[ -f "$ENV_FILE" ] || return 0
|
||||
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%% }"
|
||||
_ALIAS_NAMES+=("$name")
|
||||
_ALIAS_PROVIDERS+=("$provider")
|
||||
_ALIAS_SESSIONS+=("$session")
|
||||
_ALIAS_PROMPTS+=("$prompt")
|
||||
done < <(grep -v '^[[:space:]]*#' "$ENV_FILE" | grep -v '^[[:space:]]*$' || true)
|
||||
}
|
||||
|
||||
_alias_save() {
|
||||
mkdir -p "$(dirname "$ENV_FILE")"
|
||||
{
|
||||
printf '%s\n' "# AI aliases — managed by pos ai alias (do not hand-edit)"
|
||||
printf '%s\n' "# Format: alias_name|provider|session_name|system_prompt"
|
||||
printf '%s\n' "#"
|
||||
local i
|
||||
for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do
|
||||
printf '%s|%s|%s|%s\n' "${_ALIAS_NAMES[$i]}" "${_ALIAS_PROVIDERS[$i]}" \
|
||||
"${_ALIAS_SESSIONS[$i]}" "${_ALIAS_PROMPTS[$i]}"
|
||||
done
|
||||
} >"$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
}
|
||||
|
||||
# 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/<name>. 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 <<WRAPPER_EOF
|
||||
#!/usr/bin/env bash
|
||||
# Managed by pos ai alias — regenerated automatically; hand-edits are overwritten.
|
||||
# Alias: ${name} | provider: ${provider} | session: ${session}
|
||||
set -euo pipefail
|
||||
exec $(_alias_quote_cmd "$provider" "$session" "$prompt") "\$@"
|
||||
WRAPPER_EOF
|
||||
}
|
||||
|
||||
# Atomically install/refresh one wrapper. Skips the write when the rendered
|
||||
# content already matches (stable mtimes → sync idempotence is observable).
|
||||
# Pre-commit validation: bash -n on the rendered file; failure keeps previous.
|
||||
_wrapper_install() { # name provider session prompt
|
||||
local path="$(_wrapper_path "$1")" tmp
|
||||
tmp="$(mktemp "${HOME}/.local/bin/.pos-alias.XXXXXX")"
|
||||
_wrapper_render "$1" "$2" "$3" "$4" >"$tmp"
|
||||
if cmp -s "$tmp" "$path"; then
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
if ! bash -n "$tmp" 2>/dev/null; then
|
||||
warn "Wrapper for '$1' failed syntax check — keeping previous version" >&2
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
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() {
|
||||
local providers=()
|
||||
for f in "$PROVIDER_DIR"/*.sh; do
|
||||
[ -f "$f" ] || continue
|
||||
providers+=("$(basename "$f" .sh)")
|
||||
done
|
||||
if [ ${#providers[@]} -eq 0 ]; then
|
||||
err "No AI providers installed — run 'pos ai' setup first"
|
||||
fi
|
||||
menu_pick "Pick provider" "${providers[@]}"
|
||||
}
|
||||
|
||||
_alias_find() {
|
||||
local name="$1" i
|
||||
for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do
|
||||
if [ "${_ALIAS_NAMES[$i]}" = "$name" ]; then
|
||||
echo "$i"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "-1"
|
||||
return 0
|
||||
}
|
||||
|
||||
_alias_name_valid() {
|
||||
[[ "$1" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]]
|
||||
}
|
||||
|
||||
_alias_prompt_truncate() {
|
||||
local p="$1"
|
||||
if [ ${#p} -gt 42 ]; then
|
||||
printf '%s…' "${p:0:42}"
|
||||
else
|
||||
printf '%s' "$p"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Non-interactive output ─────────────────────────────────────
|
||||
|
||||
# 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' "------------" "------------" "------------" \
|
||||
"------------------------------------------"
|
||||
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() {
|
||||
local idx
|
||||
idx="$(_alias_find "$1")"
|
||||
[ "$idx" = "-1" ] && err "Alias '$1' not found"
|
||||
local name="${_ALIAS_NAMES[$idx]}" provider="${_ALIAS_PROVIDERS[$idx]}"
|
||||
local session="${_ALIAS_SESSIONS[$idx]}" prompt="${_ALIAS_PROMPTS[$idx]}"
|
||||
[ -z "$session" ] && session="$name"
|
||||
printf ' %-12s %s\n' "Alias:" "$name"
|
||||
printf ' %-12s %s\n' "Provider:" "$provider"
|
||||
printf ' %-12s %s\n' "Session:" "$session"
|
||||
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' "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 ─────────────────────────────────────
|
||||
|
||||
_alias_menu() {
|
||||
menu_guard || return 1
|
||||
while true; do
|
||||
{
|
||||
_alias_load
|
||||
if [ ${#_ALIAS_NAMES[@]} -eq 0 ]; then
|
||||
echo "${YELLOW}[!] No aliases defined yet — create one with option 1.${RESET}"
|
||||
else
|
||||
_alias_table
|
||||
printf ' %d alias(es)\n' "${#_ALIAS_NAMES[@]}"
|
||||
fi
|
||||
echo >&2
|
||||
} >&2
|
||||
local choice
|
||||
choice="$(menu_run "AI Agent Aliases" "Create new alias" "Edit existing alias" \
|
||||
"Remove alias" "List aliases")" || return 0
|
||||
case "$choice" in
|
||||
1) _alias_create ;;
|
||||
2) _alias_edit ;;
|
||||
3) _alias_remove ;;
|
||||
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
|
||||
}
|
||||
|
||||
# ── Interactive: create ────────────────────────────────────────
|
||||
|
||||
_alias_create() {
|
||||
local preset_name="${1:-}"
|
||||
section "Create AI Agent Alias" >&2
|
||||
|
||||
# Step 1: Alias name
|
||||
local name="$preset_name"
|
||||
while true; do
|
||||
if [ -z "$name" ]; then
|
||||
step 1 4 "Alias Name" >&2
|
||||
name="$(menu_ask_value "Alias name" "")" || return 0
|
||||
fi
|
||||
[ -z "$name" ] && { warn "Alias name cannot be empty" >&2; name=""; continue; }
|
||||
if ! _alias_name_valid "$name"; then
|
||||
warn "Invalid name '$name' — use letters, digits, hyphens, underscores (start with a letter)" >&2
|
||||
name=""; continue
|
||||
fi
|
||||
_alias_load
|
||||
local existing
|
||||
existing="$(_alias_find "$name")"
|
||||
if [ "$existing" != "-1" ]; then
|
||||
warn "Alias '$name' already exists — use 'pos ai alias edit $name' instead" >&2
|
||||
[ -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
|
||||
|
||||
# Step 2: Provider
|
||||
step 2 4 "Provider" >&2
|
||||
local pidx
|
||||
pidx="$(_alias_provider_pick)" || return 0
|
||||
local providers=()
|
||||
for f in "$PROVIDER_DIR"/*.sh; do
|
||||
[ -f "$f" ] || continue
|
||||
providers+=("$(basename "$f" .sh)")
|
||||
done
|
||||
local provider="${providers[$((pidx - 1))]}"
|
||||
|
||||
# Step 3: Session name
|
||||
local session=""
|
||||
while true; do
|
||||
step 3 4 "Session Name" >&2
|
||||
session="$(menu_ask_value "Session name" "$name")" || return 0
|
||||
if [ -n "$session" ] && ! _alias_name_valid "$session"; then
|
||||
warn "Invalid session '$session' — use letters, digits, hyphens, underscores" >&2
|
||||
session=""; continue
|
||||
fi
|
||||
break
|
||||
done
|
||||
[ -z "$session" ] && session="$name"
|
||||
|
||||
# Step 4: System prompt
|
||||
local prompt=""
|
||||
while true; do
|
||||
step 4 4 "System Prompt" >&2
|
||||
prompt="$(menu_ask_value "System prompt (empty = use built-in)" "")" || return 0
|
||||
if [[ "$prompt" == *'|'* ]]; then
|
||||
warn "System prompt must not contain '|' characters" >&2
|
||||
prompt=""; continue
|
||||
fi
|
||||
if [ ${#prompt} -gt 500 ]; then
|
||||
warn "Prompt is ${#prompt} chars — consider keeping it concise" >&2
|
||||
fi
|
||||
break
|
||||
done
|
||||
|
||||
# Confirmation
|
||||
{
|
||||
echo "────────────────────────────────────────────"
|
||||
printf ' Create alias '\''%s'\''?\n' "$name"
|
||||
printf ' Provider: %s\n' "$provider"
|
||||
printf ' Session: %s\n' "$session"
|
||||
local dp="$prompt"
|
||||
[ ${#dp} -gt 50 ] && dp="${dp:0:50}…"
|
||||
printf ' Prompt: %s\n' "${dp:-<built-in>}"
|
||||
echo "────────────────────────────────────────────"
|
||||
} >&2
|
||||
if ! confirm "Create alias '$name'?" y; then
|
||||
log "Aborted." >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
_alias_load
|
||||
_ALIAS_NAMES+=("$name")
|
||||
_ALIAS_PROVIDERS+=("$provider")
|
||||
_ALIAS_SESSIONS+=("$session")
|
||||
_ALIAS_PROMPTS+=("$prompt")
|
||||
_alias_save
|
||||
_alias_sync
|
||||
log "Alias '$name' created." >&2
|
||||
log "Available immediately: $(_wrapper_path "$name")" >&2
|
||||
}
|
||||
|
||||
# ── Interactive: edit ──────────────────────────────────────────
|
||||
|
||||
_alias_edit() {
|
||||
local preset_name="${1:-}"
|
||||
_alias_load
|
||||
if [ ${#_ALIAS_NAMES[@]} -eq 0 ]; then
|
||||
warn "No aliases to edit — create one first" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
local name="$preset_name"
|
||||
if [ -z "$name" ]; then
|
||||
section "Edit AI Agent Alias" >&2
|
||||
local display_items=() i
|
||||
for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do
|
||||
local p="${_ALIAS_PROMPTS[$i]}"
|
||||
if [ ${#p} -gt 30 ]; then
|
||||
p="${p:0:30}…"
|
||||
fi
|
||||
display_items+=("${_ALIAS_NAMES[$i]} [${_ALIAS_PROVIDERS[$i]}] ${p}")
|
||||
done
|
||||
local picked
|
||||
picked="$(menu_pick "Pick alias to edit" "${display_items[@]}")" || return 0
|
||||
name="${_ALIAS_NAMES[$((picked - 1))]}"
|
||||
fi
|
||||
|
||||
local idx
|
||||
idx="$(_alias_find "$name")"
|
||||
if [ "$idx" = "-1" ]; then
|
||||
err "Alias '$name' not found"
|
||||
fi
|
||||
|
||||
# Show current values
|
||||
{
|
||||
echo " Current values for '$name':"
|
||||
printf ' Provider: %s\n' "${_ALIAS_PROVIDERS[$idx]}"
|
||||
printf ' Session: %s\n' "${_ALIAS_SESSIONS[$idx]}"
|
||||
local cp="${_ALIAS_PROMPTS[$idx]}"
|
||||
[ -z "$cp" ] && cp="(default)"
|
||||
printf ' Prompt: %s\n' "$cp"
|
||||
echo >&2
|
||||
} >&2
|
||||
|
||||
local new_provider="${_ALIAS_PROVIDERS[$idx]}"
|
||||
local new_session="${_ALIAS_SESSIONS[$idx]}"
|
||||
local new_prompt="${_ALIAS_PROMPTS[$idx]}"
|
||||
local changed=0
|
||||
|
||||
# Edit provider
|
||||
step 1 3 "Provider" >&2
|
||||
local pidx
|
||||
pidx="$(_alias_provider_pick)" || return 0
|
||||
local providers=()
|
||||
for f in "$PROVIDER_DIR"/*.sh; do
|
||||
[ -f "$f" ] || continue
|
||||
providers+=("$(basename "$f" .sh)")
|
||||
done
|
||||
local picked_provider="${providers[$((pidx - 1))]}"
|
||||
if [ "$picked_provider" != "$new_provider" ]; then
|
||||
new_provider="$picked_provider"
|
||||
changed=1
|
||||
fi
|
||||
|
||||
# Edit session
|
||||
local tmp_session=""
|
||||
while true; do
|
||||
step 2 3 "Session Name" >&2
|
||||
tmp_session="$(menu_ask_value "Session name" "$new_session")" || return 0
|
||||
if [ -n "$tmp_session" ] && ! _alias_name_valid "$tmp_session"; then
|
||||
warn "Invalid session '$tmp_session' — use letters, digits, hyphens, underscores" >&2
|
||||
tmp_session=""; continue
|
||||
fi
|
||||
break
|
||||
done
|
||||
[ -n "$tmp_session" ] && new_session="$tmp_session"
|
||||
[ "$new_session" != "${_ALIAS_SESSIONS[$idx]}" ] && changed=1
|
||||
|
||||
# Edit prompt
|
||||
local tmp_prompt=""
|
||||
while true; do
|
||||
step 3 3 "System Prompt" >&2
|
||||
local default_prompt="${_ALIAS_PROMPTS[$idx]}"
|
||||
[ ${#default_prompt} -gt 80 ] && default_prompt="${default_prompt:0:80}…"
|
||||
[ -z "$default_prompt" ] && default_prompt=""
|
||||
tmp_prompt="$(menu_ask_value "System prompt" "$default_prompt")" || return 0
|
||||
if [[ "$tmp_prompt" == *'|'* ]]; then
|
||||
warn "System prompt must not contain '|' characters" >&2
|
||||
tmp_prompt=""; continue
|
||||
fi
|
||||
if [ ${#tmp_prompt} -gt 500 ]; then
|
||||
warn "Prompt is ${#tmp_prompt} chars — consider keeping it concise" >&2
|
||||
fi
|
||||
break
|
||||
done
|
||||
# Keep full current if user pressed Enter (tmp_prompt = default_prompt value)
|
||||
if [ -n "$tmp_prompt" ]; then
|
||||
new_prompt="$tmp_prompt"
|
||||
fi
|
||||
[ "$new_prompt" != "${_ALIAS_PROMPTS[$idx]}" ] && changed=1
|
||||
|
||||
# No changes?
|
||||
if [ "$changed" -eq 0 ]; then
|
||||
log "No changes — nothing to save." >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Show diff summary
|
||||
{
|
||||
echo "────────────────────────────────────────────"
|
||||
printf ' Save changes to '\''%s'\''?\n' "$name"
|
||||
local tag_p tag_s tag_pr
|
||||
[ "$new_provider" = "${_ALIAS_PROVIDERS[$idx]}" ] && tag_p="(unchanged)" || tag_p="(changed)"
|
||||
[ "$new_session" = "${_ALIAS_SESSIONS[$idx]}" ] && tag_s="(unchanged)" || tag_s="(changed)"
|
||||
[ "$new_prompt" = "${_ALIAS_PROMPTS[$idx]}" ] && tag_pr="(unchanged)" || tag_pr="(changed)"
|
||||
printf ' Provider: %-12s %s\n' "$new_provider" "$tag_p"
|
||||
printf ' Session: %-12s %s\n' "$new_session" "$tag_s"
|
||||
local dp="$new_prompt"
|
||||
[ -z "$dp" ] && dp="<built-in>"
|
||||
printf ' Prompt: %s %s\n' "${dp:0:40}" "$tag_pr"
|
||||
echo "────────────────────────────────────────────"
|
||||
} >&2
|
||||
if ! confirm "Save changes to '$name'?" y; then
|
||||
log "Discarded." >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
_alias_load
|
||||
_ALIAS_PROVIDERS[$idx]="$new_provider"
|
||||
_ALIAS_SESSIONS[$idx]="$new_session"
|
||||
_ALIAS_PROMPTS[$idx]="$new_prompt"
|
||||
_alias_save
|
||||
_alias_sync
|
||||
log "Alias '$name' updated — the change is live on next invocation." >&2
|
||||
}
|
||||
|
||||
# ── Interactive: remove ────────────────────────────────────────
|
||||
|
||||
_alias_remove() {
|
||||
local preset_name="${1:-}"
|
||||
_alias_load
|
||||
if [ ${#_ALIAS_NAMES[@]} -eq 0 ]; then
|
||||
warn "No aliases to remove" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
local name="$preset_name"
|
||||
if [ -z "$name" ]; then
|
||||
section "Remove AI Agent Alias" >&2
|
||||
local display_items=() i
|
||||
for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do
|
||||
local p="${_ALIAS_PROMPTS[$i]}"
|
||||
if [ ${#p} -gt 30 ]; then
|
||||
p="${p:0:30}…"
|
||||
fi
|
||||
display_items+=("${_ALIAS_NAMES[$i]} [${_ALIAS_PROVIDERS[$i]}] ${p}")
|
||||
done
|
||||
local picked
|
||||
picked="$(menu_pick "Pick alias to remove" "${display_items[@]}")" || return 0
|
||||
name="${_ALIAS_NAMES[$((picked - 1))]}"
|
||||
fi
|
||||
|
||||
local idx
|
||||
idx="$(_alias_find "$name")"
|
||||
if [ "$idx" = "-1" ]; then
|
||||
err "Alias '$name' not found"
|
||||
fi
|
||||
|
||||
# Show alias detail
|
||||
{
|
||||
echo " Alias: $name"
|
||||
printf ' Provider: %s\n' "${_ALIAS_PROVIDERS[$idx]}"
|
||||
printf ' Session: %s\n' "${_ALIAS_SESSIONS[$idx]}"
|
||||
printf ' Prompt: %s\n' "${_ALIAS_PROMPTS[$idx]:-<built-in>}"
|
||||
echo >&2
|
||||
} >&2
|
||||
|
||||
if ! confirm "Remove alias '$name'? This cannot be undone." n; then
|
||||
log "Cancelled." >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
_alias_load
|
||||
local new_names=() new_providers=() new_sessions=() new_prompts=() i
|
||||
for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do
|
||||
if [ "${_ALIAS_NAMES[$i]}" != "$name" ]; then
|
||||
new_names+=("${_ALIAS_NAMES[$i]}")
|
||||
new_providers+=("${_ALIAS_PROVIDERS[$i]}")
|
||||
new_sessions+=("${_ALIAS_SESSIONS[$i]}")
|
||||
new_prompts+=("${_ALIAS_PROMPTS[$i]}")
|
||||
fi
|
||||
done
|
||||
_ALIAS_NAMES=("${new_names[@]+"${new_names[@]}"}")
|
||||
_ALIAS_PROVIDERS=("${new_providers[@]+"${new_providers[@]}"}")
|
||||
_ALIAS_SESSIONS=("${new_sessions[@]+"${new_sessions[@]}"}")
|
||||
_ALIAS_PROMPTS=("${new_prompts[@]+"${new_prompts[@]}"}")
|
||||
_alias_save
|
||||
_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 <name> ───────────────────────────────────────────────
|
||||
|
||||
# (defined above as _alias_show)
|
||||
|
||||
# ── Usage ──────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: pos ai alias [subcommand] [args]
|
||||
|
||||
Manage named AI agent aliases — create, edit, remove, list, and show
|
||||
configured aliases. Each alias maps a name to a provider, session, and
|
||||
optional system prompt.
|
||||
|
||||
Subcommands:
|
||||
(no args) Interactive menu
|
||||
create [name] Create a new alias (interactive prompts for each field)
|
||||
edit [name] Edit an existing alias (interactive, Enter = keep)
|
||||
remove [name] Remove an alias (interactive, default = no)
|
||||
list List all aliases (non-interactive, machine-readable)
|
||||
show <name> Show one alias's details
|
||||
|
||||
Activation: every alias is materialized as an executable script at
|
||||
~/.local/bin/<name>, 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.
|
||||
|
||||
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
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── 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_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 <name>"
|
||||
_alias_sync
|
||||
_alias_show "$2"
|
||||
;;
|
||||
"") _alias_sync; _alias_menu ;;
|
||||
*) err "Unknown subcommand '$1' (use -h for help)" ;;
|
||||
esac
|
||||
+5
-309
@@ -1,311 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: ai gemini — Chat with Google Gemini (ask, chat, models, sessions)
|
||||
# POS_SUBCMDS: ask chat models sessions
|
||||
# POS_FLAGS: --model --session --system
|
||||
# POS_CONFIG: ai | ai.env | AI_GEMINI_API_KEY=secret:API key from aistudio.google.com | AI_GEMINI_MODEL=:Model id (default gemini-2.5-flash)
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
CONFIG_FILE="$HOME/.config/linux_post_install/ai.env"
|
||||
API="https://generativelanguage.googleapis.com/v1beta"
|
||||
DEFAULT_MODEL="gemini-2.5-flash"
|
||||
SESSION_DIR="$HOME/.local/share/linux_post_install/ai"
|
||||
SESSION=""
|
||||
SYSTEM_PROMPT=""
|
||||
MAX_SESSION_TURNS=40
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos ai gemini <subcommand> [--model <id>] [--session <name>] [--system <text>]
|
||||
|
||||
Chat with Google Gemini via the REST API (generativelanguage.googleapis.com).
|
||||
|
||||
Subcommands:
|
||||
ask "<prompt>" One-shot answer; prints ONLY the answer text to stdout
|
||||
(pipe/script/Telegram-friendly). The prompt may also be
|
||||
piped in via stdin when no argument is given. With
|
||||
--session, previous turns are sent as context.
|
||||
chat Interactive multi-turn conversation.
|
||||
models List models that support generateContent.
|
||||
sessions List persistent sessions / clear one:
|
||||
'sessions' and 'sessions reset <name>'.
|
||||
|
||||
Options:
|
||||
--model <id> Override the model for this invocation.
|
||||
--session <name> Persistent memory: ask/chat remember prior turns in
|
||||
~/.local/share/linux_post_install/ai/<name>.json
|
||||
(capped at $MAX_SESSION_TURNS turns). ask without
|
||||
--session stays one-shot.
|
||||
--system <text> System instruction sent with every turn (kept out of the
|
||||
session file), e.g. "Reply like a friendly Telegram chat".
|
||||
-h|--help This help.
|
||||
|
||||
Config: $CONFIG_FILE (edit with 'pos config ai')
|
||||
AI_GEMINI_API_KEY API key from aistudio.google.com (required)
|
||||
AI_GEMINI_MODEL Model id (default $DEFAULT_MODEL)
|
||||
|
||||
Examples:
|
||||
pos ai gemini ask "Explain DNS in one line"
|
||||
echo "summarize this log" | pos ai gemini ask
|
||||
pos ai gemini chat
|
||||
pos ai gemini models
|
||||
pos ai gemini ask --model gemini-2.5-flash "hi"
|
||||
pos ai gemini ask --session work "my name is joe"
|
||||
pos ai gemini ask --session work "what is my name?" # remembers
|
||||
pos ai gemini sessions
|
||||
pos ai gemini sessions reset work
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── ai.env loader (same pattern as telegram.env) ────────────────
|
||||
load_config() {
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
local k v
|
||||
while IFS='=' read -r k v; do
|
||||
[ -n "$k" ] || continue
|
||||
case "$k" in
|
||||
\#*) continue ;;
|
||||
esac
|
||||
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
||||
v="${v//$'\r'/}"
|
||||
if [ -z "${!k:-}" ]; then
|
||||
export "$k"="$v"
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
|
||||
}
|
||||
|
||||
require_key() {
|
||||
load_config
|
||||
[ -n "${AI_GEMINI_API_KEY:-}" ] || err "No Gemini API key — run 'pos config ai'"
|
||||
}
|
||||
|
||||
resolve_model() {
|
||||
if [ -n "${MODEL_OVERRIDE:-}" ]; then
|
||||
printf '%s' "$MODEL_OVERRIDE"
|
||||
elif [ -n "${AI_GEMINI_MODEL:-}" ]; then
|
||||
printf '%s' "$AI_GEMINI_MODEL"
|
||||
else
|
||||
printf '%s' "$DEFAULT_MODEL"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Persistent session memory ───────────────────────────────────
|
||||
# History lives as a Gemini "contents" JSON document per session name under
|
||||
# SESSION_DIR. Names are sanitized to [A-Za-z0-9_-]; ask/chat only touch the
|
||||
# session layer when --session is given (otherwise they stay stateless).
|
||||
session_file() {
|
||||
local name="${1:-$SESSION}"
|
||||
name="${name//[^A-Za-z0-9_-]/_}"
|
||||
printf '%s/%s.json' "$SESSION_DIR" "$name"
|
||||
}
|
||||
|
||||
session_load() {
|
||||
[ -n "$SESSION" ] || { printf '{"contents":[]}'; return 0; }
|
||||
local f
|
||||
f="$(session_file)"
|
||||
if [ -s "$f" ] && jq -e '.contents' "$f" >/dev/null 2>&1; then
|
||||
cat "$f"
|
||||
else
|
||||
printf '{"contents":[]}'
|
||||
fi
|
||||
}
|
||||
|
||||
session_save() {
|
||||
[ -n "$SESSION" ] || return 0
|
||||
local f tmp
|
||||
f="$(session_file)"
|
||||
mkdir -p "$SESSION_DIR"
|
||||
tmp="$(mktemp)"
|
||||
printf '%s\n' "$1" >"$tmp"
|
||||
mv "$tmp" "$f"
|
||||
chmod 600 "$f"
|
||||
}
|
||||
|
||||
# Append a turn and prune to the last MAX_SESSION_TURNS entries. stdout = JSON.
|
||||
session_push() {
|
||||
local contents="$1" role="$2" text="$3"
|
||||
printf '%s' "$contents" | jq -c --arg r "$role" --arg t "$text" \
|
||||
'.contents += [{role:$r, parts:[{text:$t}]}] | .contents |= .[-'"$MAX_SESSION_TURNS"':]'
|
||||
}
|
||||
|
||||
# One generateContent call. $1 = model, $2 = contents JSON, $3 = optional
|
||||
# system instruction (added as systemInstruction, not stored in the session).
|
||||
# stdout = the answer text on success; an error message on failure (exit 1).
|
||||
gemini_generate() {
|
||||
local model="$1" contents="$2" system="${3:-}" body
|
||||
body="$contents"
|
||||
if [ -n "$system" ]; then
|
||||
body="$(printf '%s' "$contents" | jq -c --arg s "$system" \
|
||||
'. + {systemInstruction:{role:"system",parts:[{text:$s}]}}')"
|
||||
fi
|
||||
local resp code body_out errmsg
|
||||
resp="$(curl -sS -m 60 -X POST "${API}/models/${model}:generateContent" \
|
||||
-H "x-goog-api-key: ${AI_GEMINI_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--write-out $'\n%{http_code}' \
|
||||
--data "$body")" || { echo "request failed (curl exit $?)" >&2; return 1; }
|
||||
code="${resp##*$'\n'}"
|
||||
body_out="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
errmsg="$(printf '%s' "$body_out" | jq -r '.error.message // empty' 2>/dev/null || true)"
|
||||
echo "API error $code${errmsg:+: $errmsg}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$body_out" | jq -r '[.candidates[0].content.parts[]?.text] | join("")'
|
||||
}
|
||||
|
||||
cmd_ask() {
|
||||
local prompt="" contents out
|
||||
if [ $# -gt 0 ]; then
|
||||
prompt="$*"
|
||||
elif [ ! -t 0 ]; then
|
||||
prompt="$(cat)"
|
||||
fi
|
||||
[ -n "$prompt" ] || err "No prompt given — usage: pos ai gemini ask \"<prompt>\""
|
||||
require_key
|
||||
if [ -n "$SESSION" ]; then
|
||||
contents="$(session_load)"
|
||||
contents="$(session_push "$contents" user "$prompt")"
|
||||
else
|
||||
contents="$(jq -nc --arg t "$prompt" '{contents:[{role:"user",parts:[{text:$t}]}]}')"
|
||||
fi
|
||||
if ! out="$(gemini_generate "$(resolve_model)" "$contents" "$SYSTEM_PROMPT" 2>&1)"; then
|
||||
err "$out"
|
||||
fi
|
||||
if [ -n "$SESSION" ]; then
|
||||
contents="$(session_push "$contents" model "$out")"
|
||||
session_save "$contents"
|
||||
fi
|
||||
printf '%s\n' "$out"
|
||||
}
|
||||
|
||||
cmd_chat() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for chat: $*"
|
||||
local model contents text answer
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
if [ -n "$SESSION" ]; then
|
||||
contents="$(session_load)"
|
||||
printf 'session: %s (resumed %s prior turns)\n' "$SESSION" "$(printf '%s' "$contents" | jq -r '.contents | length')"
|
||||
else
|
||||
contents='{"contents":[]}'
|
||||
fi
|
||||
trap 'echo; echo "bye"; exit 0' INT
|
||||
echo "Gemini · ${model} — type a message; q=quit, /reset=clear history"
|
||||
while true; do
|
||||
printf '> '
|
||||
IFS= read -r text || break
|
||||
case "$text" in
|
||||
"" ) continue ;;
|
||||
q|Q|quit|exit) echo; echo "bye"; return 0 ;;
|
||||
/reset)
|
||||
contents='{"contents":[]}'
|
||||
[ -n "$SESSION" ] && session_save "$contents"
|
||||
echo "[history cleared]"
|
||||
continue ;;
|
||||
esac
|
||||
contents="$(session_push "$contents" user "$text")"
|
||||
if ! answer="$(gemini_generate "$model" "$contents" "$SYSTEM_PROMPT" 2>&1)"; then
|
||||
warn "AI error: $answer"
|
||||
continue
|
||||
fi
|
||||
contents="$(session_push "$contents" model "$answer")"
|
||||
[ -n "$SESSION" ] && session_save "$contents"
|
||||
printf '\n%s\n\n' "$answer"
|
||||
done
|
||||
echo
|
||||
return 0
|
||||
}
|
||||
|
||||
cmd_sessions() {
|
||||
local action="${1:-list}" name f n
|
||||
case "$action" in
|
||||
list|"")
|
||||
[ -d "$SESSION_DIR" ] || { echo "no sessions"; return 0; }
|
||||
local found=0
|
||||
for f in "$SESSION_DIR"/*.json; do
|
||||
[ -f "$f" ] || continue
|
||||
found=1
|
||||
n="$(jq -r '.contents | length' "$f" 2>/dev/null || echo 0)"
|
||||
printf ' %-32s %s turns\n' "$(basename "$f" .json)" "${n:-0}"
|
||||
done
|
||||
[ "$found" -eq 1 ] || echo "no sessions"
|
||||
;;
|
||||
reset)
|
||||
[ $# -ge 2 ] || err "usage: pos ai gemini sessions reset <name>"
|
||||
name="$2"
|
||||
if rm -f "$(session_file "$name")"; then
|
||||
ok "session '$name' cleared"
|
||||
fi
|
||||
;;
|
||||
*) err "Unknown sessions subcommand '$action' (list | reset <name>)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_models() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for models: $*"
|
||||
local model resp code body m
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
resp="$(curl -sS -m 30 -G "${API}/models" \
|
||||
-H "x-goog-api-key: ${AI_GEMINI_API_KEY}" \
|
||||
--data-urlencode "pageSize=1000" \
|
||||
--write-out $'\n%{http_code}')" || err "request failed (curl exit $?)"
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
err "API error $code: $(printf '%s' "$body" | jq -r '.error.message // empty')"
|
||||
fi
|
||||
local list
|
||||
list="$(printf '%s' "$body" | jq -r '.models[]? | select((.supportedGenerationMethods // []) | index("generateContent")) | .name' | sed 's#^models/##' | sort)"
|
||||
echo "Gemini models (generateContent-capable):"
|
||||
while IFS= read -r m; do
|
||||
[ -n "$m" ] || continue
|
||||
if [ "$m" = "$model" ]; then
|
||||
printf ' %-32s <- default\n' "$m"
|
||||
else
|
||||
printf ' %-32s\n' "$m"
|
||||
fi
|
||||
done <<< "$list"
|
||||
if ! grep -qxF "$model" <<< "$list"; then
|
||||
warn "configured default '$model' is not in the list — set AI_GEMINI_MODEL"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Parse flags + subcommand ────────────────────────────────────
|
||||
MODEL_OVERRIDE=""
|
||||
cmd=""
|
||||
args=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help) usage ;;
|
||||
--model)
|
||||
[ $# -ge 2 ] || err "--model needs a value"
|
||||
MODEL_OVERRIDE="$2"; shift 2 ;;
|
||||
--session)
|
||||
[ $# -ge 2 ] || err "--session needs a value"
|
||||
SESSION="$2"; shift 2 ;;
|
||||
--system)
|
||||
[ $# -ge 2 ] || err "--system needs a value"
|
||||
SYSTEM_PROMPT="$2"; shift 2 ;;
|
||||
-*) err "Unknown option '$1' (see --help)" ;;
|
||||
*)
|
||||
if [ -z "$cmd" ]; then
|
||||
cmd="$1"
|
||||
else
|
||||
args+=("$1")
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "${cmd:-}" in
|
||||
"") usage ;;
|
||||
ask) cmd_ask "${args[@]}" ;;
|
||||
chat) cmd_chat "${args[@]}" ;;
|
||||
models) cmd_models "${args[@]}" ;;
|
||||
sessions) cmd_sessions "${args[@]}" ;;
|
||||
*) err "Unknown ai gemini subcommand '$cmd' (see --help)" ;;
|
||||
esac
|
||||
# POS: ai gemini — Forward to pos ai --provider gemini (backward compat)
|
||||
# POS_SUBCMDS: ask chat models sessions capture
|
||||
# Thin forwarder — all logic lives in bin/pos-ai + lib/ai-providers/gemini.sh
|
||||
case "${1:-}" in -h|--help) exec pos ai --provider gemini --help ;; esac
|
||||
exec pos ai --provider gemini "$@"
|
||||
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: ai openrouter — Forward to pos ai --provider openrouter (backward compat)
|
||||
# POS_SUBCMDS: ask chat sessions capture
|
||||
# Thin forwarder — all logic lives in bin/pos-ai + lib/ai-providers/openrouter.sh
|
||||
case "${1:-}" in -h|--help) exec pos ai --provider openrouter --help ;; esac
|
||||
exec pos ai --provider openrouter "$@"
|
||||
@@ -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() {
|
||||
|
||||
+881
-17
@@ -2,13 +2,14 @@
|
||||
set -euo pipefail
|
||||
# POS: docker vbox — Disposable Docker-based VMs (create/enter/start/stop/rm/ls)
|
||||
# POS_SUBCMDS: create enter stop start rm ls menu
|
||||
# POS_FLAGS: --dir --gpu --device --port --cpus --memory --network
|
||||
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"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage:
|
||||
pos docker vbox create <name> [image] [--dir <path>]
|
||||
pos docker vbox create <name> [image] [options]
|
||||
pos docker vbox enter <name>
|
||||
pos docker vbox stop <name>
|
||||
pos docker vbox start <name>
|
||||
@@ -18,20 +19,33 @@ Usage:
|
||||
Manage disposable Docker containers as lightweight VMs.
|
||||
|
||||
Bare \`pos docker vbox\` on a terminal (or \`pos docker vbox menu\`) opens an
|
||||
interactive menu wrapping these verbs; arguments stay scriptable.
|
||||
interactive menu wrapping these verbs; arguments stay scriptable. The
|
||||
interactive create flow picks image, GPU, host devices, dir mounts, ports
|
||||
and CPU/RAM from categorized menus, shows a review screen, then runs the
|
||||
same \`create\` verb shown below.
|
||||
|
||||
Each container gets a bind-mounted host directory so files persist
|
||||
on the host even after the container is removed.
|
||||
|
||||
Options:
|
||||
--dir <path> Use custom directory instead of default ~/<name>
|
||||
Use "." for current directory
|
||||
--dir <path> Bind-mount a host directory at the same path inside
|
||||
the VM (repeatable). The first --dir is the VM's
|
||||
working directory and defaults to ~/<name>;
|
||||
use "." for the current directory.
|
||||
--gpu Full GPU access (--gpus all)
|
||||
--device </dev/node> Pass a host device through to the VM (repeatable)
|
||||
--port HOST:CONTAINER Publish a port mapping (repeatable, e.g. 8080:80)
|
||||
--cpus N Limit CPUs (e.g. 2 or 1.5; default: Docker default)
|
||||
--memory SIZE Limit memory (e.g. 512m, 2g; default: Docker default)
|
||||
--network MODE Docker network mode (bridge/host/none)
|
||||
|
||||
Examples:
|
||||
pos docker vbox create lab1
|
||||
pos docker vbox create lab1 --dir .
|
||||
pos docker vbox create lab1 --dir /mnt/data/lab1
|
||||
pos docker vbox create kali kalilinux/kali-rolling
|
||||
pos docker vbox create ai --gpu --cpus 4 --memory 8g
|
||||
pos docker vbox create iot --device /dev/ttyUSB0 --port 8080:80
|
||||
pos docker vbox enter lab1
|
||||
pos docker vbox stop lab1
|
||||
pos docker vbox start lab1
|
||||
@@ -70,19 +84,796 @@ menu_pick_vm() { # $1 = prompt → picked VM name on stdout · rc 1 = cancelle
|
||||
printf '%s\n' "${vms[$((idx - 1))]}"
|
||||
}
|
||||
|
||||
menu_vbox_create() {
|
||||
local name image dir
|
||||
name="$(menu_ask_value "VM name")" || return 0
|
||||
image="$(menu_ask_value "Image" "ubuntu:22.04")" || return 0
|
||||
dir="$(menu_ask_value "Host directory (empty = ~/$name)")" || true
|
||||
if [ -n "${dir:-}" ]; then
|
||||
confirm "Create VM '$name' from $image (host dir: $dir)?" n \
|
||||
|| { log "Cancelled"; return 0; }
|
||||
menu_self create "$name" "$image" --dir "$dir"
|
||||
# ── Categorized interactive create ────────────────────────────────────────
|
||||
# Pure sugar over the scripted 'create' verb (single execution path): this
|
||||
# flow composes arguments through Name → category hub → Review, then re-enters
|
||||
# 'menu_self create …'. All state lives in C_*/CC_* globals for the lifetime
|
||||
# of the flow only.
|
||||
#
|
||||
# Quit/EOF discipline (binding): nothing is ever created unless the review
|
||||
# confirm is answered 'y', and EVERY teardown ends in ONE
|
||||
# "[!] setup discarded — nothing was created" line, back at the main vbox
|
||||
# menu, exit clean. Helpers never print the line themselves — they unwind
|
||||
# with rc 77, and the orchestrator converts that into the single print.
|
||||
# menu_run/menu_pick/confirm report a typed quit and a dead stream
|
||||
# identically (rc 1), and no probe can always tell them apart, so
|
||||
# arbitration is streak-based: TWO consecutive quit signals with no
|
||||
# successful interaction between them tear down (a vanished terminal fails
|
||||
# every read instantly; a live user quitting twice gets decisive teardown),
|
||||
# while a single quit keeps gentle per-site handling (hub quit asks first).
|
||||
# Raw `read` calls need no arbitration — their rc 1 IS a definitive EOF.
|
||||
# Confirmed discards (hub quit answered 'y') are self-explanatory and silent;
|
||||
# at the NAME prompt an empty answer and a dead stream are indistinguishable
|
||||
# (menu_ask_value has no default there), so both tear down loudly.
|
||||
C_discarded="setup discarded — nothing was created"
|
||||
|
||||
cc_log() { # stderr twin of log() — capture-safe inside $( )
|
||||
printf '%s\n' "${GREEN}[+]${RESET} $*" >&2
|
||||
}
|
||||
|
||||
cc_warn() { # stderr twin of warn() — capture-safe inside $( )
|
||||
printf '%s\n' "${YELLOW}[!]${RESET} $*" >&2
|
||||
}
|
||||
|
||||
cc_info() { # stderr [i] info line
|
||||
printf '%s\n' "${CYAN:-}[i]${RESET} $*" >&2
|
||||
}
|
||||
|
||||
cc_die() { # print the discard line once; caller returns 77
|
||||
# Best-effort emit: on a vanished terminal even this write fails (EIO)
|
||||
# and must not trip set -e — the cleanup contract is exit-clean.
|
||||
cc_warn "$C_discarded" || true
|
||||
}
|
||||
|
||||
# Quit-vs-EOF arbitration: menu_run/menu_pick/confirm report a typed quit and
|
||||
# a dead input stream identically (rc 1), and no read/write probe can always
|
||||
# distinguish them (bash `read -t` reports success on a dead pty; write-EIO is
|
||||
# kernel-dependent). Rule instead: TWO consecutive quit signals with no
|
||||
# successful interaction in between tear the setup down — a dead terminal
|
||||
# fails every read instantly, so it tears down at once; a live user quitting
|
||||
# twice in a row gets the same decisive treatment; a single quit keeps the
|
||||
# gentle per-site handling. Successful interactions reset the streak.
|
||||
cc_quit_tick() { # rc 0 = streak reached ⇒ caller tears down · rc 1 = keep going
|
||||
C_quits=$(( ${C_quits:-0} + 1 ))
|
||||
[ "${C_quits}" -ge 2 ]
|
||||
}
|
||||
cc_quit_reset() { C_quits=0; }
|
||||
|
||||
cc_reset() {
|
||||
C_image="ubuntu:22.04"
|
||||
C_image_set=0 # 0 = Image never visited (hub shows "(default)")
|
||||
C_gpu_mode="" # "" | all | nodes
|
||||
C_gpu_devs=() # explicit Nvidia/exotic nodes (mode=nodes)
|
||||
C_devs=() # host devices basket (raw paths)
|
||||
C_mounts=() # host dir mounts basket (validated absolutes)
|
||||
C_ports=() # ports basket (HOST:CONTAINER)
|
||||
C_cpus=""
|
||||
C_mem=""
|
||||
}
|
||||
|
||||
# ── device helpers ────────────────────────────────────────────────────────
|
||||
cc_perm_suffix() { # unreadable-but-present nodes stay offered (dockerd is root)
|
||||
[ -r "$1" ] && return 0
|
||||
printf '%s' " (perm-restricted for you — dockerd may still access)"
|
||||
}
|
||||
|
||||
cc_node_label() { # <node> → display label on stdout · rc 1 = unusable, drop
|
||||
[ -e "$1" ] || return 1
|
||||
stat "$1" &>/dev/null || return 1
|
||||
printf '%s%s' "$1" "$(cc_perm_suffix "$1")"
|
||||
}
|
||||
|
||||
cc_root_disk() { # basename of the disk hosting / — best effort, may fail
|
||||
local src pk
|
||||
src="$(findmnt -n -o SOURCE / 2>/dev/null)"
|
||||
[ -n "$src" ] || return 1
|
||||
pk="$(lsblk -sno PKNAME "$src" 2>/dev/null | tail -n1)"
|
||||
[ -n "$pk" ] || pk="${src##*/}"
|
||||
printf '%s' "$pk"
|
||||
}
|
||||
|
||||
cc_is_system_disk() { # <node> rc 0 iff it is (a partition of) the root disk
|
||||
local root base="${1##*/}"
|
||||
root="$(cc_root_disk)" || return 1
|
||||
[ "$base" = "$root" ] && return 0
|
||||
[[ "$base" =~ ^${root}(p[0-9]+|[0-9]+)$ ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# Candidate host devices → parallel arrays (names + labels). Every source is
|
||||
# optional: a missing binary means one info line, never an error. Block
|
||||
# devices list ALL disks, system disks labelled — never hidden.
|
||||
cc_collect_devices() { # $1 names-out · $2 labels-out (global array names)
|
||||
local -n o_names="$1"
|
||||
local -n o_labels="$2"
|
||||
local line bus dev node lbl typ size rm
|
||||
o_names=()
|
||||
o_labels=()
|
||||
|
||||
if command -v lsusb &>/dev/null; then
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^Bus\ ([0-9]+)\ Device\ ([0-9]+): ]] || continue
|
||||
bus="$(printf '%03d' "${BASH_REMATCH[1]}")"
|
||||
dev="$(printf '%03d' "${BASH_REMATCH[2]}")"
|
||||
node="/dev/bus/usb/$bus/$dev"
|
||||
lbl="$(cc_node_label "$node")" || continue
|
||||
o_names+=("$node")
|
||||
o_labels+=("$lbl")
|
||||
done < <(lsusb 2>/dev/null)
|
||||
else
|
||||
confirm "Create VM '$name' from $image?" n || { log "Cancelled"; return 0; }
|
||||
menu_self create "$name" "$image"
|
||||
cc_info "lsusb not available — skipping USB device scan"
|
||||
fi
|
||||
|
||||
for node in /dev/ttyUSB* /dev/ttyACM* /dev/video*; do
|
||||
[ -e "$node" ] || continue
|
||||
lbl="$(cc_node_label "$node")" || continue
|
||||
o_names+=("$node")
|
||||
o_labels+=("$lbl")
|
||||
done
|
||||
# sound offered as ONE entry (fine-grained snd nodes are out of scope)
|
||||
if [ -e /dev/snd ]; then
|
||||
o_names+=("/dev/snd")
|
||||
o_labels+=("/dev/snd (sound subsystem)")
|
||||
fi
|
||||
|
||||
if command -v lsblk &>/dev/null; then
|
||||
while read -r node typ size rm; do
|
||||
[ -e "$node" ] || continue
|
||||
stat "$node" &>/dev/null || continue
|
||||
lbl="$node ($typ, $size"
|
||||
[ "$rm" = "1" ] && lbl+=", removable"
|
||||
cc_is_system_disk "$node" && lbl+=", system disk — careful"
|
||||
lbl+="$(cc_perm_suffix "$node")"
|
||||
lbl+=")"
|
||||
o_names+=("$node")
|
||||
o_labels+=("$lbl")
|
||||
done < <(lsblk -rnpo NAME,TYPE,SIZE,RM 2>/dev/null)
|
||||
else
|
||||
cc_info "lsblk not available — skipping block-device scan"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── GPU / Nvidia detection (cheap-first, once per category entry) ──────────
|
||||
cc_nvidia_nodes() { # fills C_nv_nodes · rc 0 iff /dev/nvidiactl anchor exists
|
||||
C_nv_nodes=()
|
||||
local f
|
||||
[ -e /dev/nvidiactl ] || return 1
|
||||
for f in /dev/nvidia*; do
|
||||
[ -e "$f" ] || continue
|
||||
[[ "${f##*/}" =~ ^nvidia[0-9]+$ ]] || continue
|
||||
cc_node_label "$f" >/dev/null && C_nv_nodes+=("$f")
|
||||
done
|
||||
for f in /dev/nvidiactl /dev/nvidia-uvm; do
|
||||
[ -e "$f" ] || continue
|
||||
cc_node_label "$f" >/dev/null && C_nv_nodes+=("$f")
|
||||
done
|
||||
[ "${#C_nv_nodes[@]}" -gt 0 ]
|
||||
}
|
||||
|
||||
cc_gpu_toolkit() { # rc 0 iff an Nvidia container runtime is plausibly present
|
||||
[[ "$(docker info --format '{{json .Runtimes}}' 2>/dev/null)" == *nvidia* ]] && return 0
|
||||
command -v nvidia-container-runtime &>/dev/null && return 0
|
||||
command -v nvidia-container-ctk &>/dev/null
|
||||
}
|
||||
|
||||
# ── shared basket mechanics (loop-of-single-picks, no lib changes) ────────
|
||||
# Baskets are addressed by GLOBAL array name, passed verbatim down the chain
|
||||
# and bound one hop deep with local -n (never nameref-to-nameref).
|
||||
cc_add_loop() { # $1 basket-name · $2 noun · $3 picker prompt · $4 manual prompt
|
||||
local -n basket="$1"
|
||||
local noun="$2" add_prompt="$3" manual_prompt="$4"
|
||||
local idx sel p q skip joined ci clbl
|
||||
local -a items disp
|
||||
while true; do
|
||||
if [ "${#basket[@]}" -gt 0 ]; then
|
||||
joined="$(
|
||||
IFS=','
|
||||
echo "${basket[*]}"
|
||||
)"
|
||||
printf '[i] %s — selected: %d: %s\n' "$noun" "${#basket[@]}" "$joined" >&2
|
||||
else
|
||||
printf '[i] %s — selected: 0: (none yet)\n' "$noun" >&2
|
||||
fi
|
||||
items=()
|
||||
disp=()
|
||||
for p in "${CC_CAND_NAMES[@]}"; do
|
||||
skip=0
|
||||
for q in "${basket[@]}"; do
|
||||
[ "$q" = "$p" ] && {
|
||||
skip=1
|
||||
break
|
||||
}
|
||||
done
|
||||
[ "$skip" -eq 1 ] && continue # already selected → excluded
|
||||
items+=("$p")
|
||||
clbl=""
|
||||
for ((ci = 0; ci < ${#CC_CAND_NAMES[@]}; ci++)); do
|
||||
if [ "${CC_CAND_NAMES[$ci]}" = "$p" ]; then
|
||||
clbl="${CC_CAND_LABELS[$ci]}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
disp+=("${clbl:-$p}")
|
||||
done
|
||||
items+=("__manual__")
|
||||
disp+=("Type a device path manually")
|
||||
items+=("__done__")
|
||||
disp+=("✓ Done adding")
|
||||
if ! idx="$(menu_pick "$add_prompt" "${disp[@]}")"; then
|
||||
cc_quit_tick && return 77 # second consecutive quit ⇒ teardown
|
||||
return 0 # back/Done ends the loop, basket kept
|
||||
fi
|
||||
cc_quit_reset
|
||||
sel="${items[$((idx - 1))]}"
|
||||
case "$sel" in
|
||||
__done__)
|
||||
return 0
|
||||
;;
|
||||
__manual__)
|
||||
while true; do
|
||||
if ! read -rp "$manual_prompt: " p; then
|
||||
return 77 # raw read rc 1 = definitive EOF
|
||||
fi
|
||||
[ -z "$p" ] && break # empty = cancel
|
||||
[[ "$p" == /dev/* ]] || {
|
||||
cc_warn "device path must start with /dev/ — try again"
|
||||
continue
|
||||
}
|
||||
skip=0
|
||||
for q in "${basket[@]}"; do
|
||||
[ "$q" = "$p" ] && skip=1
|
||||
done
|
||||
if [ "$skip" -eq 1 ]; then
|
||||
cc_warn "$p is already selected — skipped"
|
||||
break
|
||||
fi
|
||||
if ! stat "$p" &>/dev/null; then
|
||||
cc_warn "cannot stat $p — skipped"
|
||||
break
|
||||
fi
|
||||
basket+=("$p")
|
||||
cc_log "added $p — pick another, or choose ✓ Done"
|
||||
break
|
||||
done
|
||||
;;
|
||||
*)
|
||||
basket+=("$sel")
|
||||
cc_log "added $sel — pick another, or choose ✓ Done"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
cc_remove_one() { # $1 basket-name · $2 remove prompt
|
||||
local -n basket="$1"
|
||||
local rm_prompt="$2"
|
||||
local idx p i
|
||||
local -a disp keep
|
||||
if [ "${#basket[@]}" -eq 0 ]; then
|
||||
cc_log "nothing selected yet"
|
||||
return 0
|
||||
fi
|
||||
disp=()
|
||||
for p in "${basket[@]}"; do disp+=("$p"); done
|
||||
if ! idx="$(menu_pick "$rm_prompt" "${disp[@]}")"; then
|
||||
cc_quit_tick && return 77
|
||||
return 0
|
||||
fi
|
||||
cc_quit_reset
|
||||
p="${basket[$((idx - 1))]}"
|
||||
keep=()
|
||||
for ((i = 0; i < ${#basket[@]}; i++)); do
|
||||
[ "$i" -ne $((idx - 1)) ] && keep+=("${basket[i]}")
|
||||
done
|
||||
basket=("${keep[@]}")
|
||||
cc_log "removed $p"
|
||||
}
|
||||
|
||||
cc_clear_basket() { # $1 basket-name · $2 noun phrase ("device(s)")
|
||||
local -n basket="$1"
|
||||
local word="$2" n="${#basket[@]}"
|
||||
if [ "$n" -eq 0 ]; then
|
||||
cc_log "nothing selected yet"
|
||||
return 0
|
||||
fi
|
||||
if ! confirm "Remove all $n selected $word?" n; then
|
||||
cc_quit_tick && return 77
|
||||
return 0
|
||||
fi
|
||||
cc_quit_reset
|
||||
basket=()
|
||||
cc_log "all $n $word removed"
|
||||
}
|
||||
|
||||
# Shared basket shape: Add … / Remove one (N selected) / Clear all / Back.
|
||||
# $5 = add function; remaining args forwarded to it verbatim.
|
||||
cc_basket_menu() { # $1 basket-name · $2 noun · $3 remove-prompt · $4 clear-word · $5 add-fn …
|
||||
local -n bref="$1"
|
||||
local bname="$1" noun="$2" rm_prompt="$3" clear_word="$4" add_fn="$5"
|
||||
shift 5
|
||||
local idx
|
||||
while true; do
|
||||
if [ "${#bref[@]}" -eq 0 ]; then
|
||||
"$add_fn" "$bname" "$@" || return 77
|
||||
return 0 # empty basket → straight to adding
|
||||
fi
|
||||
if ! idx="$(menu_run "$noun" \
|
||||
"Add …" \
|
||||
"Remove one (${#bref[@]} selected)" \
|
||||
"Clear all")"; then
|
||||
cc_quit_tick && return 77
|
||||
return 0 # Back
|
||||
fi
|
||||
cc_quit_reset
|
||||
case "$idx" in
|
||||
1) "$add_fn" "$bname" "$@" || return 77 ;;
|
||||
2) cc_remove_one "$bname" "$rm_prompt" || return 77 ;;
|
||||
3) cc_clear_basket "$bname" "$clear_word" || return 77 ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Typed-value add loop (mounts, ports). Raw read — NOT menu_ask_value — so an
|
||||
# EMPTY answer ("done") stays distinguishable from EOF (discard everything).
|
||||
# Validator contract: $1 raw input · rest = basket values · normalized value
|
||||
# on stdout · rc 1 = rejected (diagnostics printed by the validator).
|
||||
cc_add_typed_loop() { # $1 basket-name · $2 prompt · $3 validator fn
|
||||
local -n basket="$1"
|
||||
local prompt="$2" vfn="$3"
|
||||
local v norm
|
||||
local -a vals
|
||||
while true; do
|
||||
if ! read -rp "$prompt: " v; then
|
||||
return 77 # raw read rc 1 = definitive EOF
|
||||
fi
|
||||
[ -z "$v" ] && return 0 # empty = done
|
||||
vals=("${basket[@]}")
|
||||
if norm="$("$vfn" "$v" ${vals[@]+"${vals[@]}"})"; then
|
||||
basket+=("$norm")
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
cc_validate_mount() { # stdout: normalized absolute path · rc 1 = rejected
|
||||
local raw="$1" v
|
||||
shift
|
||||
v="${raw/#\~/$HOME}"
|
||||
case "$v" in
|
||||
/*) ;;
|
||||
*) cc_warn "not an absolute path: $raw — try again"; return 1 ;;
|
||||
esac
|
||||
[ -d "$v" ] || {
|
||||
cc_warn "directory not found: $raw — try again"
|
||||
return 1
|
||||
}
|
||||
local q
|
||||
for q in "$@"; do
|
||||
[ "$q" = "$v" ] && {
|
||||
cc_warn "$v is already mounted — skipped"
|
||||
return 1
|
||||
}
|
||||
done
|
||||
printf '%s\n' "$v"
|
||||
cc_log "will mount $v:$v"
|
||||
}
|
||||
|
||||
cc_validate_port() { # stdout: validated HOST:CONTAINER · rc 1 = rejected
|
||||
local v="$1"
|
||||
shift
|
||||
[[ "$v" =~ ^[0-9]+(:[0-9]+){1,2}$ ]] || {
|
||||
cc_warn "not a HOST:CONTAINER pair: $v — try again (e.g. 8080:80)"
|
||||
return 1
|
||||
}
|
||||
local hp="${v%%:*}" q hq
|
||||
for q in "$@"; do
|
||||
hq="${q%%:*}"
|
||||
[ "$hq" = "$hp" ] && {
|
||||
cc_warn "host port $hp already mapped — rejected"
|
||||
return 1
|
||||
}
|
||||
done
|
||||
printf '%s\n' "$v"
|
||||
cc_log "will publish $v"
|
||||
}
|
||||
|
||||
# ── categories ────────────────────────────────────────────────────────────
|
||||
cc_category_image() {
|
||||
local -a picks=("ubuntu:22.04" "ubuntu:24.04" "debian:12" "kalilinux/kali-rolling" "archlinux" "fedora:latest" "alpine:latest" "Other (type image ref)")
|
||||
local idx ref
|
||||
while true; do
|
||||
if ! idx="$(menu_pick "Pick image" "${picks[@]}")"; then
|
||||
cc_quit_tick && return 77
|
||||
return 0 # back keeps the current image
|
||||
fi
|
||||
cc_quit_reset
|
||||
if [ "$idx" -lt "${#picks[@]}" ]; then
|
||||
C_image="${picks[$((idx - 1))]}"
|
||||
C_image_set=1
|
||||
cc_log "image set to $C_image"
|
||||
return 0
|
||||
fi
|
||||
if ! ref="$(menu_ask_value "Image ref")"; then
|
||||
cc_quit_tick && return 77
|
||||
continue # cancelled typing → picker again
|
||||
fi
|
||||
cc_quit_reset
|
||||
if [[ "$ref" =~ [[:space:]] ]]; then
|
||||
cc_warn "not a valid image ref — spaces not allowed"
|
||||
continue
|
||||
fi
|
||||
C_image="$ref"
|
||||
C_image_set=1
|
||||
cc_log "image set to $C_image"
|
||||
return 0
|
||||
done
|
||||
}
|
||||
|
||||
cc_category_gpu() {
|
||||
local nodes_present=0 toolkit_present=0 pci_line="" choice sem nl
|
||||
if cc_nvidia_nodes; then nodes_present=1; fi
|
||||
if cc_gpu_toolkit; then toolkit_present=1; fi
|
||||
if command -v lspci &>/dev/null; then
|
||||
pci_line="$(lspci 2>/dev/null | grep -Ei 'vga|3d controller' | grep -i nvidia | head -n1)" || pci_line=""
|
||||
[ -n "$pci_line" ] && cc_log "Nvidia hardware: ${pci_line#* }"
|
||||
fi
|
||||
|
||||
if [ "$nodes_present" -eq 0 ]; then
|
||||
# Path C — no GPU found: informational, never an error, never blocking.
|
||||
# Category stays enterable via the manual escape hatch.
|
||||
cc_info "no Nvidia driver/GPU detected on this host — skipping GPU setup"
|
||||
cc_info "an exotic device path can still be added manually"
|
||||
CC_CAND_NAMES=()
|
||||
CC_CAND_LABELS=()
|
||||
cc_add_loop C_gpu_devs "GPU (manual)" \
|
||||
"Pick device to ADD" \
|
||||
"Device path (must start with /dev/, empty = cancel)" || return 77
|
||||
[ "${#C_gpu_devs[@]}" -gt 0 ] && C_gpu_mode="nodes"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local i_all="Full GPU access (--gpus all)"
|
||||
local i_nodes="Explicit Nvidia device nodes"
|
||||
local i_none="none (clears GPU configuration)"
|
||||
local -a opts=() sems=()
|
||||
if [ "$toolkit_present" -eq 1 ]; then
|
||||
# Path A — full support: recommend --gpus all (highlight ≠ preselect).
|
||||
cc_info "Nvidia container toolkit detected — --gpus all available"
|
||||
opts=("$i_all (recommended)" "$i_nodes" "$i_none")
|
||||
sems=("all" "nodes" "none")
|
||||
else
|
||||
# Path B — nodes without toolkit: steer to explicit nodes first.
|
||||
cc_info "nvidia-container-toolkit not detected — --gpus all will likely fail; offering explicit device nodes instead (install nvidia-container-toolkit for CUDA workloads)"
|
||||
opts=("$i_nodes (recommended here)" "Configure anyway (--gpus all)" "$i_none")
|
||||
sems=("nodes" "all" "none")
|
||||
fi
|
||||
case "$C_gpu_mode" in
|
||||
all) opts[0]+=" (current)" ;;
|
||||
nodes) opts[1]+=" (current)" ;;
|
||||
*) opts[2]+=" (current)" ;;
|
||||
esac
|
||||
if ! choice="$(menu_run "GPU / Nvidia" "${opts[@]}")"; then
|
||||
cc_quit_tick && return 77
|
||||
return 0
|
||||
fi
|
||||
cc_quit_reset
|
||||
sem="${sems[$((choice - 1))]}"
|
||||
case "$sem" in
|
||||
all)
|
||||
C_gpu_mode="all"
|
||||
C_gpu_devs=()
|
||||
cc_log "GPU: --gpus all"
|
||||
;;
|
||||
nodes)
|
||||
C_gpu_mode="nodes"
|
||||
C_gpu_devs=()
|
||||
cc_info "a working set is usually /dev/nvidia0 + /dev/nvidiactl + /dev/nvidia-uvm"
|
||||
CC_CAND_NAMES=("${C_nv_nodes[@]}")
|
||||
CC_CAND_LABELS=()
|
||||
for nl in "${C_nv_nodes[@]}"; do
|
||||
lbl="$(cc_node_label "$nl")" || lbl="$nl" # raced-away node: fall back to the bare path
|
||||
CC_CAND_LABELS+=("$lbl")
|
||||
done
|
||||
cc_add_loop C_gpu_devs "Nvidia devices" \
|
||||
"Pick device to ADD" \
|
||||
"Device path (must start with /dev/, empty = cancel)" || return 77
|
||||
[ "${#C_gpu_devs[@]}" -eq 0 ] && C_gpu_mode="" # nothing picked ⇒ back to unconfigured
|
||||
;;
|
||||
none)
|
||||
C_gpu_mode=""
|
||||
C_gpu_devs=()
|
||||
cc_log "GPU configuration cleared"
|
||||
;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
cc_category_devices() {
|
||||
cc_collect_devices CC_CAND_NAMES CC_CAND_LABELS
|
||||
if [ "${#CC_CAND_NAMES[@]}" -eq 0 ]; then
|
||||
cc_info "no candidate devices found — type a path manually"
|
||||
fi
|
||||
cc_basket_menu C_devs "Host devices" \
|
||||
"Remove which device?" "device(s)" cc_add_loop \
|
||||
"Host devices" \
|
||||
"Pick device to ADD" \
|
||||
"Device path (must start with /dev/, empty = cancel)"
|
||||
}
|
||||
|
||||
cc_category_mounts() {
|
||||
cc_basket_menu C_mounts "Host dir mounts" \
|
||||
"Remove which mount?" "mount(s)" cc_add_typed_loop \
|
||||
"Host directory to mount (empty = done)" cc_validate_mount
|
||||
}
|
||||
|
||||
cc_category_ports() {
|
||||
cc_basket_menu C_ports "Ports" \
|
||||
"Remove which port?" "port mapping(s)" cc_add_typed_loop \
|
||||
"Publish port HOST:CONTAINER (empty = done)" cc_validate_port
|
||||
}
|
||||
|
||||
cc_category_resources() {
|
||||
local v
|
||||
cc_info "press Enter to keep Docker defaults"
|
||||
while true; do
|
||||
v=""
|
||||
[ -n "$C_cpus" ] && v=" (current: $C_cpus)"
|
||||
if ! read -rp "--cpus$v — blank = Docker default: " v; then
|
||||
return 77 # raw read rc 1 = definitive EOF
|
||||
fi
|
||||
if [ -z "$v" ]; then
|
||||
C_cpus=""
|
||||
break
|
||||
fi
|
||||
[[ "$v" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
|
||||
cc_warn "CPUs must be a number (e.g. 2 or 1.5)"
|
||||
continue
|
||||
}
|
||||
C_cpus="$v"
|
||||
break
|
||||
done
|
||||
while true; do
|
||||
v=""
|
||||
[ -n "$C_mem" ] && v=" (current: $C_mem)"
|
||||
if ! read -rp "--memory$v — blank = Docker default (e.g. 512m, 2g): " v; then
|
||||
return 77 # raw read rc 1 = definitive EOF
|
||||
fi
|
||||
if [ -z "$v" ]; then
|
||||
C_mem=""
|
||||
break
|
||||
fi
|
||||
[[ "$v" =~ ^[0-9]+(b|k|m|g|mb|gb)?$ ]] || {
|
||||
cc_warn "memory formats: 500b, 100k, 512m, 2g (mb/gb accepted)"
|
||||
continue
|
||||
}
|
||||
C_mem="$v"
|
||||
break
|
||||
done
|
||||
cc_log "resources: ${C_cpus:-Docker default} cpu(s), ${C_mem:-Docker default} memory"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── compose + review ──────────────────────────────────────────────────────
|
||||
cc_compose() { # fills CC_ARGS (argv for menu_self) + CC_FLAGS_TXT (display)
|
||||
# The UI never asks for the primary dir — it stays ~/<name> (the verb's
|
||||
# default). Pass it as the FIRST --dir explicitly, so mount baskets map
|
||||
# to the repeatable --dir form without displacing the working directory.
|
||||
# It must exist before the verb resolves it (cd && pwd), so create it here.
|
||||
mkdir -p "$HOME/$C_name"
|
||||
CC_ARGS=(create "$C_name" "$C_image" --dir "$HOME/$C_name")
|
||||
CC_FLAGS_TXT=""
|
||||
local d p m
|
||||
if [ "$C_gpu_mode" = "all" ]; then
|
||||
CC_ARGS+=(--gpu)
|
||||
CC_FLAGS_TXT="--gpus all"
|
||||
fi
|
||||
for d in "${C_gpu_devs[@]}"; do
|
||||
CC_ARGS+=(--device "$d")
|
||||
CC_FLAGS_TXT+=" --device $d"
|
||||
done
|
||||
for d in "${C_devs[@]}"; do
|
||||
CC_ARGS+=(--device "$d")
|
||||
CC_FLAGS_TXT+=" --device $d"
|
||||
done
|
||||
for p in "${C_ports[@]}"; do
|
||||
CC_ARGS+=(--port "$p")
|
||||
CC_FLAGS_TXT+=" --port $p"
|
||||
done
|
||||
[ -n "$C_cpus" ] && {
|
||||
CC_ARGS+=(--cpus "$C_cpus")
|
||||
CC_FLAGS_TXT+=" --cpus $C_cpus"
|
||||
}
|
||||
[ -n "$C_mem" ] && {
|
||||
CC_ARGS+=(--memory "$C_mem")
|
||||
CC_FLAGS_TXT+=" --memory $C_mem"
|
||||
}
|
||||
for m in "${C_mounts[@]}"; do
|
||||
CC_ARGS+=(--dir "$m")
|
||||
CC_FLAGS_TXT+=" --dir $m"
|
||||
done
|
||||
}
|
||||
|
||||
cc_render_group() { # $1 label · rest = rendered values · truncates at 6 rows
|
||||
local label="$1"
|
||||
shift
|
||||
local max=6 shown=0 v n="$#"
|
||||
if [ "$n" -eq 0 ]; then
|
||||
printf ' %-10s %s\n' "$label" "none" >&2
|
||||
return 0
|
||||
fi
|
||||
for v in "$@"; do
|
||||
[ "$shown" -lt "$max" ] || break
|
||||
if [ "$shown" -eq 0 ]; then
|
||||
printf ' %-10s %s\n' "$label" "$v" >&2
|
||||
else
|
||||
printf ' %-10s %s\n' "" "$v" >&2
|
||||
fi
|
||||
shown=$((shown + 1))
|
||||
done
|
||||
if [ "$n" -gt "$max" ]; then
|
||||
printf ' %-10s … (+%d more)\n' "" "$((n - max))" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
cc_review() { # rc 0 = confirmed (CC_ARGS ready) · rc 1 = back to hub · rc 77 = discarded
|
||||
local -a gpu_vals=() dev_vals=() mnt_vals=() port_vals=() rv=()
|
||||
local d p m res
|
||||
case "$C_gpu_mode" in
|
||||
all) gpu_vals+=("--gpus all") ;;
|
||||
nodes)
|
||||
for d in "${C_gpu_devs[@]}"; do gpu_vals+=("--device $d"); done
|
||||
;;
|
||||
esac
|
||||
for d in "${C_devs[@]}"; do dev_vals+=("--device $d"); done
|
||||
for m in "${C_mounts[@]}"; do mnt_vals+=("$m:$m"); done
|
||||
for p in "${C_ports[@]}"; do port_vals+=("-p $p"); done
|
||||
|
||||
{
|
||||
echo
|
||||
echo "${CYAN}════════════════════════════════════════════${RESET}"
|
||||
echo "${CYAN} Create VM '$C_name' — review plan${RESET}"
|
||||
echo "${CYAN}════════════════════════════════════════════${RESET}"
|
||||
} >&2
|
||||
cc_render_group "image" "$C_image"
|
||||
cc_render_group "host dir" "~/$C_name (bind-mounted at same path, cwd inside VM)"
|
||||
cc_render_group "gpus" ${gpu_vals[@]+"${gpu_vals[@]}"}
|
||||
cc_render_group "devices" ${dev_vals[@]+"${dev_vals[@]}"}
|
||||
cc_render_group "mounts" ${mnt_vals[@]+"${mnt_vals[@]}"}
|
||||
cc_render_group "ports" ${port_vals[@]+"${port_vals[@]}"}
|
||||
[ -n "$C_cpus" ] && rv+=("--cpus $C_cpus")
|
||||
[ -n "$C_mem" ] && rv+=("--memory $C_mem")
|
||||
if [ "${#rv[@]}" -gt 0 ]; then
|
||||
res="$(
|
||||
IFS=' '
|
||||
echo "${rv[*]}"
|
||||
)"
|
||||
else
|
||||
res="Docker defaults"
|
||||
fi
|
||||
cc_render_group "cpu/ram" "$res"
|
||||
cc_render_group "network" "Docker default (bridge)"
|
||||
|
||||
cc_compose
|
||||
{
|
||||
echo "----------------------------------------" >&2
|
||||
echo " docker create -it --name $C_name --label linux_post_install.vbox=true \\" >&2
|
||||
echo " ${CC_FLAGS_TXT} $C_image bash" >&2
|
||||
}
|
||||
|
||||
# A CUDA image is ever only a hint — never forced, never auto-applied.
|
||||
if [ -n "$C_gpu_mode" ] && [[ "$C_image" != *cuda* && "$C_image" != nvidia/* ]]; then
|
||||
cc_info "tip: for CUDA inside the VM try an image like nvidia/cuda:12.4-base-ubuntu22.04 (set it under Image & distro)"
|
||||
fi
|
||||
|
||||
if confirm "Create?" n; then
|
||||
cc_quit_reset
|
||||
return 0
|
||||
fi
|
||||
cc_quit_tick && return 77 # second consecutive quit ⇒ teardown
|
||||
return 1 # 'n' → back to the hub, edits preserved
|
||||
}
|
||||
|
||||
cc_hub_items() { # rebuilds CC_ITEMS with live basket counts
|
||||
local img gpu dev mnt prt res
|
||||
if [ "$C_image_set" -eq 1 ]; then
|
||||
img="$C_image"
|
||||
else
|
||||
img="$C_image (default)"
|
||||
fi
|
||||
case "$C_gpu_mode" in
|
||||
all) gpu="--gpus all" ;;
|
||||
nodes) gpu="${#C_gpu_devs[@]} node(s)" ;;
|
||||
*) gpu="not configured" ;;
|
||||
esac
|
||||
if [ "${#C_devs[@]}" -gt 0 ]; then
|
||||
dev="${#C_devs[@]} selected"
|
||||
else
|
||||
dev="none"
|
||||
fi
|
||||
if [ "${#C_mounts[@]}" -gt 0 ]; then
|
||||
mnt="~/$C_name (auto) + ${#C_mounts[@]} more"
|
||||
else
|
||||
mnt="~/$C_name (auto)"
|
||||
fi
|
||||
if [ "${#C_ports[@]}" -gt 0 ]; then
|
||||
prt="${#C_ports[@]} mapped"
|
||||
else
|
||||
prt="none"
|
||||
fi
|
||||
local -a rv=()
|
||||
[ -n "$C_cpus" ] && rv+=("cpus=$C_cpus")
|
||||
[ -n "$C_mem" ] && rv+=("mem=$C_mem")
|
||||
if [ "${#rv[@]}" -gt 0 ]; then
|
||||
res="$(
|
||||
IFS=' '
|
||||
echo "${rv[*]}"
|
||||
)"
|
||||
else
|
||||
res="Docker defaults"
|
||||
fi
|
||||
CC_ITEMS=(
|
||||
"Image & distro ....... $img"
|
||||
"GPU / Nvidia ......... $gpu"
|
||||
"Host devices ......... $dev"
|
||||
"Host dir mounts ...... $mnt"
|
||||
"Ports ................ $prt"
|
||||
"CPU / RAM ............ $res"
|
||||
"Review & create"
|
||||
)
|
||||
}
|
||||
|
||||
menu_vbox_create() {
|
||||
local name choice
|
||||
local crc=0 rrc=0
|
||||
# §1: an EMPTY answer at the identity prompt backs out with nothing
|
||||
# created. menu_ask_value has no default here, so an empty answer and a
|
||||
# dead stream are indistinguishable (both rc 1) — both take the loud
|
||||
# teardown (one discard line), matching §3's "EOF at ANY depth" rule.
|
||||
if ! name="$(menu_ask_value "VM name")"; then
|
||||
cc_die
|
||||
return 0
|
||||
fi
|
||||
C_name="$name"
|
||||
cc_reset
|
||||
cc_quit_reset
|
||||
while true; do
|
||||
cc_hub_items
|
||||
if ! choice="$(menu_run "Configure VM '$C_name' — capabilities" "${CC_ITEMS[@]}")"; then
|
||||
cc_quit_tick && { cc_die; return 0; } # dead input / second quit
|
||||
if confirm "Discard this VM setup?" n; then
|
||||
return 0 # confirmed discard → main menu
|
||||
fi
|
||||
continue # single quit → guarded redraw
|
||||
fi
|
||||
cc_quit_reset
|
||||
# Categories honour a strict rc contract: 0 = normal · 77 = EOF
|
||||
# teardown somewhere below. Calls are ||-captured because a bare
|
||||
# nonzero from a simple command would trip set -e before the check.
|
||||
crc=0
|
||||
case "$choice" in
|
||||
1) cc_category_image || crc=$? ;;
|
||||
2) cc_category_gpu || crc=$? ;;
|
||||
3) cc_category_devices || crc=$? ;;
|
||||
4) cc_category_mounts || crc=$? ;;
|
||||
5) cc_category_ports || crc=$? ;;
|
||||
6) cc_category_resources || crc=$? ;;
|
||||
esac
|
||||
[ "$crc" -eq 77 ] && { cc_die; return 0; } # EOF teardown somewhere below
|
||||
if [ "$choice" -eq 7 ]; then
|
||||
rrc=0
|
||||
cc_review || rrc=$?
|
||||
case "$rrc" in
|
||||
77)
|
||||
cc_die
|
||||
return 0
|
||||
;;
|
||||
0)
|
||||
menu_self "${CC_ARGS[@]}"
|
||||
return 0
|
||||
;;
|
||||
*) continue ;; # 'n' → hub with edits preserved
|
||||
esac
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
menu_vbox_enter() {
|
||||
@@ -160,17 +951,68 @@ case "$cmd" in
|
||||
name="${2:-}"
|
||||
[ -z "$name" ] && usage
|
||||
|
||||
# Parse remaining args: [image] [--dir <path>]
|
||||
# Parse remaining args: [image] [--dir <path>]… [--device </dev/node>]…
|
||||
# [--gpu] [--port HOST:CONTAINER]… [--cpus N] [--memory SIZE] [--network MODE]
|
||||
# The new flags are additive; an invocation that uses none of them
|
||||
# behaves byte-identically to the pre-categorized verb.
|
||||
image="ubuntu:22.04"
|
||||
custom_dir=""
|
||||
extra_dirs=()
|
||||
want_gpu=0
|
||||
devices=()
|
||||
ports=()
|
||||
cpus=""
|
||||
memory=""
|
||||
network=""
|
||||
shift 2 || true
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dir)
|
||||
[ -z "${2:-}" ] && { echo "Missing value for --dir"; exit 1; }
|
||||
custom_dir="$2"
|
||||
if [ -z "$custom_dir" ]; then
|
||||
custom_dir="$2"
|
||||
else
|
||||
extra_dirs+=("$2")
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--gpu)
|
||||
want_gpu=1
|
||||
shift
|
||||
;;
|
||||
--device)
|
||||
[ -z "${2:-}" ] && { echo "Missing value for --device"; exit 1; }
|
||||
[[ "$2" == /dev/* ]] || { echo "[!] --device expects a path under /dev/: $2"; exit 1; }
|
||||
devices+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--port)
|
||||
[ -z "${2:-}" ] && { echo "Missing value for --port"; exit 1; }
|
||||
[[ "$2" =~ ^[0-9]+(:[0-9]+){1,2}$ ]] || { echo "[!] --port expects HOST:CONTAINER (e.g. 8080:80): $2"; exit 1; }
|
||||
ports+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--cpus)
|
||||
[ -z "${2:-}" ] && { echo "Missing value for --cpus"; exit 1; }
|
||||
[[ "$2" =~ ^[0-9]+(\.[0-9]+)?$ ]] || { echo "[!] --cpus expects a number (e.g. 2 or 1.5): $2"; exit 1; }
|
||||
cpus="$2"
|
||||
shift 2
|
||||
;;
|
||||
--memory)
|
||||
[ -z "${2:-}" ] && { echo "Missing value for --memory"; exit 1; }
|
||||
[[ "$2" =~ ^[0-9]+(b|k|m|g|mb|gb)?$ ]] || { echo "[!] --memory expects a size (e.g. 512m or 2g): $2"; exit 1; }
|
||||
memory="$2"
|
||||
shift 2
|
||||
;;
|
||||
--network)
|
||||
[ -z "${2:-}" ] && { echo "Missing value for --network"; exit 1; }
|
||||
network="$2"
|
||||
shift 2
|
||||
;;
|
||||
-*)
|
||||
echo "[!] Unknown option: $1 — see 'pos docker vbox --help'"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
image="$1"
|
||||
shift
|
||||
@@ -191,6 +1033,26 @@ case "$cmd" in
|
||||
mkdir -p "$lab_dir"
|
||||
echo "[+] Lab directory: $lab_dir"
|
||||
|
||||
# Additional bind mounts (repeated --dir): same-path convention like
|
||||
# the primary lab dir.
|
||||
mount_flags=()
|
||||
for d in "${extra_dirs[@]}"; do
|
||||
mnt_dir="$(cd "$d" 2>/dev/null && pwd)" || { echo "[!] Directory not found: $d"; exit 1; }
|
||||
mkdir -p "$mnt_dir"
|
||||
echo "[+] Bind mount: $mnt_dir:$mnt_dir"
|
||||
mount_flags+=(-v "$mnt_dir:$mnt_dir")
|
||||
done
|
||||
|
||||
# Optional flags in a fixed order: GPU → devices → ports → resources
|
||||
# → network. Mounts are appended after the primary -v below.
|
||||
create_flags=()
|
||||
[ "$want_gpu" -eq 1 ] && create_flags+=(--gpus all)
|
||||
for d in "${devices[@]}"; do create_flags+=(--device "$d"); done
|
||||
for p in "${ports[@]}"; do create_flags+=(-p "$p"); done
|
||||
[ -n "$cpus" ] && create_flags+=(--cpus "$cpus")
|
||||
[ -n "$memory" ] && create_flags+=(--memory "$memory")
|
||||
[ -n "$network" ] && create_flags+=(--network "$network")
|
||||
|
||||
echo "[+] Pulling image: $image"
|
||||
docker pull "$image"
|
||||
|
||||
@@ -199,7 +1061,9 @@ case "$cmd" in
|
||||
-it \
|
||||
--name "$name" \
|
||||
--label linux_post_install.vbox=true \
|
||||
${create_flags[@]+"${create_flags[@]}"} \
|
||||
-v "$lab_dir:$lab_dir" \
|
||||
${mount_flags[@]+"${mount_flags[@]}"} \
|
||||
-w "$lab_dir" \
|
||||
"$image" \
|
||||
bash >/dev/null
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
+185
-24
@@ -21,7 +21,8 @@ Mount and manage NFS shares from remote servers (nfs-common).
|
||||
|
||||
Commands:
|
||||
mount <server:export> <local-dir> One-shot mount (creates local-dir if needed)
|
||||
unmount <local-dir> Unmount the share
|
||||
unmount <local-dir> Unmount the share (idempotent: rc 0 when
|
||||
nothing is mounted)
|
||||
list Show active NFS mounts
|
||||
persist <server:export> <local-dir> Persistent mount via a systemd .mount unit
|
||||
(ordered after network-online.target)
|
||||
@@ -72,13 +73,36 @@ cmd_unmount() {
|
||||
validate_dir "$where"
|
||||
|
||||
if ! findmnt -r -n -o TARGET -t nfs,nfs4 2>/dev/null | grep -qxF "$where"; then
|
||||
log "$where is not mounted as NFS — nothing to do"
|
||||
# Idempotent no-op — rc 0 whether or not a unit exists. A persisted
|
||||
# boot-time .mount that is not currently mounted usually means the
|
||||
# unit failed or was stopped; say so instead of a bare nothing-to-do.
|
||||
if persisted_nfs_at "$where"; then
|
||||
warn "$where has a persistent NFS mount unit (${PERSISTED_UNIT}) — not currently mounted."
|
||||
log "Check it: systemctl status ${PERSISTED_UNIT%.mount} — or remove the persistence: menu option 5 (pos share nfs-client unpersist $where)"
|
||||
else
|
||||
log "$where is not mounted as NFS — nothing to do"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
sudo umount "$where"
|
||||
log "Unmounted $where"
|
||||
}
|
||||
|
||||
# Persisted Type=nfs/nfs4 unit declaring Where=<path>? Sets PERSISTED_UNIT.
|
||||
persisted_nfs_at() { # <path> — rc 0 persisted · rc 1 not persisted
|
||||
local uf
|
||||
PERSISTED_UNIT=""
|
||||
for uf in "${UNIT_DIR}"/*.mount; do
|
||||
[ -f "$uf" ] || continue
|
||||
grep -q '^Type=nfs' "$uf" || continue
|
||||
if [ "$(sed -n 's/^Where=//p' "$uf")" = "$1" ]; then
|
||||
PERSISTED_UNIT="$(basename "$uf")"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
local out
|
||||
if out="$(findmnt -t nfs,nfs4 2>/dev/null)" && [ "$(grep -c . <<<"$out")" -gt 1 ]; then
|
||||
@@ -175,8 +199,17 @@ cmd_unpersist() {
|
||||
unit_file="${UNIT_DIR}/${unit}"
|
||||
|
||||
if [ ! -f "$unit_file" ]; then
|
||||
warn "No systemd mount unit for $where (${unit})"
|
||||
exit 0
|
||||
# Idempotent no-op — rc 0 whether or not anything is configured.
|
||||
# Return, not exit: a bogus typed path reached from the menu must
|
||||
# not kill the whole session. Mirror of cmd_unmount's guidance for
|
||||
# a persisted unit under a non-escape-derived filename.
|
||||
if persisted_nfs_at "$where"; then
|
||||
warn "$where has a persistent NFS mount unit (${PERSISTED_UNIT}) under a non-standard unit name."
|
||||
log "Check it: systemctl status ${PERSISTED_UNIT%.mount} — or find it in the list: pos share nfs-client list"
|
||||
else
|
||||
warn "No systemd mount unit for $where (${unit})"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
sudo systemctl disable "$unit" 2>/dev/null || true
|
||||
@@ -188,6 +221,105 @@ cmd_unpersist() {
|
||||
}
|
||||
|
||||
# ── Interactive menu flows ─────────────────────────────────────
|
||||
|
||||
# Mountpoint picker — local port of the share_pick primitive with exactly
|
||||
# two deltas: the hint line offers `n=new`, and typing n runs the
|
||||
# create-new-dir flow below. share_pick cannot intercept `n` (it filters on
|
||||
# it) and lib/menu-lib.sh is shared, so the fork lives here. Rendering of
|
||||
# numbered picks / text filter / 0=back is byte-identical to menu_pick.
|
||||
# stdout: chosen item text (or the freshly created dir) · rc 1 = back/cancel.
|
||||
pick_mountpoint() {
|
||||
local prompt="$1"; shift
|
||||
local -a items=("$@")
|
||||
if [ "${#items[@]}" -eq 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
if ! [ -t 0 ]; then
|
||||
printf '[!] Interactive picker needs a terminal.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
local filter="" ans i n total=${#items[@]} made
|
||||
local -a shown=()
|
||||
while true; do
|
||||
shown=()
|
||||
for ((i = 0; i < total; i++)); do
|
||||
if [ -z "$filter" ] || [[ "${items[$i],,}" == *"${filter,,}"* ]]; then
|
||||
shown+=("${items[$i]}")
|
||||
fi
|
||||
done
|
||||
n=${#shown[@]}
|
||||
{
|
||||
echo
|
||||
if [ -n "$filter" ]; then
|
||||
printf -- "-- %d of %d match '%s' --\n" "$n" "$total" "$filter"
|
||||
else
|
||||
printf -- "-- %d available --\n" "$total"
|
||||
fi
|
||||
if [ "$n" -eq 0 ]; then
|
||||
printf '[!] no matches — enter nothing or / to clear the filter\n' >&2
|
||||
else
|
||||
for ((i = 0; i < n; i++)); do
|
||||
printf ' %2d) %s\n' $((i + 1)) "${shown[$i]}"
|
||||
done
|
||||
fi
|
||||
} >&2
|
||||
if ! read -rp "${prompt} [1-${n}], n=new, text=filter, 0=back " ans; then
|
||||
return 1 # EOF — cancel
|
||||
fi
|
||||
case "$ans" in
|
||||
"") [ -z "$filter" ] || filter="" ; continue ;;
|
||||
"/") filter="" ; continue ;;
|
||||
0 | q | Q | b | B) return 1 ;;
|
||||
n | N)
|
||||
made="$(ask_new_mountpoint)" && { echo "$made"; return 0; }
|
||||
continue # declined/invalid/mkdir-failed → redraw
|
||||
;;
|
||||
*[!0-9]*)
|
||||
filter="$ans"
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
if (( ans >= 1 && ans <= n )); then
|
||||
echo "${shown[$((ans - 1))]}"
|
||||
return 0
|
||||
fi
|
||||
echo "Unknown choice." >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Create-new-dir flow behind the picker's `n` key. Validates the shape
|
||||
# (absolute, no trailing slash), confirm-gates the creation, then mkdir -p.
|
||||
# Any decline, invalid input, EOF or mkdir failure is a warning + rc 1 —
|
||||
# the picker redraws, the tool never aborts.
|
||||
ask_new_mountpoint() { # stdout: created dir · rc 1 = cancelled/failed
|
||||
# NOTE: runs inside $( ) from the picker — every display line MUST go to
|
||||
# stderr (menu-lib contract: display → stderr, result → stdout).
|
||||
local dir
|
||||
dir="$(share_ask_value "New mountpoint (absolute path)")" || return 1
|
||||
case "$dir" in
|
||||
/*) ;;
|
||||
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;;
|
||||
esac
|
||||
case "$dir" in
|
||||
*/) warn "'$dir' must not end with a slash" >&2; return 1 ;;
|
||||
esac
|
||||
case "$dir" in
|
||||
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
|
||||
warn "Refusing system path as mountpoint" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
confirm "Create mountpoint ${dir}?" n || return 1
|
||||
if ! run sudo mkdir -p "$dir"; then
|
||||
warn "Could not create ${dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
log "Created mount point $dir" >&2
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
menu_pick_export() { # <host> — stdout: server:export · rc 1 cancelled
|
||||
local host="$1" idx exp
|
||||
local -a exports=()
|
||||
@@ -207,13 +339,26 @@ menu_pick_export() { # <host> — stdout: server:export · rc 1 cancelled
|
||||
esac
|
||||
}
|
||||
|
||||
menu_ask_mountpoint() { # stdout: absolute path · rc 1 cancelled
|
||||
local idx dir cand
|
||||
local -a cands=()
|
||||
if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then
|
||||
if idx="$(share_pick "Mountpoint" "${cands[@]}")"; then
|
||||
cand="${cands[$((idx - 1))]}"
|
||||
dir="${cand%% (*}" # strip "(mounted fstype)" annotation
|
||||
menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancelled
|
||||
local srv="${1:-}" idx res cand dir known=0
|
||||
local -a cands=() dirs=()
|
||||
mapfile -t cands < <(share_folder_candidates)
|
||||
for cand in "${cands[@]}"; do
|
||||
dirs+=("${cand%% (*}") # bare path (strip "(mounted fstype)" note)
|
||||
done
|
||||
# Same-as-server suggestion: unless the server-side path already exists
|
||||
# among the local candidates, append it as a synthetic pick so mounting
|
||||
# at a mirrored path is a normal selection.
|
||||
if [ -n "$srv" ] && [ "${#dirs[@]}" -gt 0 ] &&
|
||||
printf '%s\n' "${dirs[@]}" | grep -qxF -- "$srv"; then
|
||||
known=1
|
||||
fi
|
||||
if [ -n "$srv" ] && [ "$known" -eq 0 ]; then
|
||||
cands+=("${srv} (as on server)")
|
||||
fi
|
||||
if [ "${#cands[@]}" -gt 0 ]; then
|
||||
if res="$(pick_mountpoint "Mountpoint" "${cands[@]}")"; then
|
||||
dir="${res%% (*}" # strip "(as on server)"/mount note
|
||||
case "$dir" in
|
||||
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
|
||||
warn "Refusing system path as mountpoint"
|
||||
@@ -242,7 +387,7 @@ menu_mount() {
|
||||
fi
|
||||
|
||||
what="$(menu_pick_export "$host")" || return 1
|
||||
where="$(menu_ask_mountpoint)" || return 1
|
||||
where="$(menu_ask_mountpoint "${what#*:}")" || return 1 # ${what#*:} = server-side export path
|
||||
|
||||
if [ "$mode" = "persist" ]; then
|
||||
cmd_persist "$what" "$where"
|
||||
@@ -252,16 +397,30 @@ menu_mount() {
|
||||
}
|
||||
|
||||
menu_unmount() {
|
||||
local idx where
|
||||
local -a targets=()
|
||||
if mapfile -t targets < <(findmnt -r -n -o TARGET -t nfs,nfs4 2>/dev/null | tail -n +2) &&
|
||||
[ "${#targets[@]}" -gt 0 ]; then
|
||||
idx="$(share_pick "Unmount which NFS mount?" "${targets[@]}")" || return 1
|
||||
where="${targets[$((idx - 1))]}"
|
||||
else
|
||||
local idx row src where i
|
||||
local -a tgts=() srcs=() items=()
|
||||
# Same enumeration source as the `list` view (findmnt -t nfs,nfs4),
|
||||
# reduced to TARGET|SOURCE rows.
|
||||
while IFS= read -r row; do
|
||||
[ -n "$row" ] || continue
|
||||
tgts+=("${row%%|*}")
|
||||
srcs+=("${row#*|}")
|
||||
done < <(findmnt -rn -o TARGET,SOURCE -t nfs,nfs4 2>/dev/null |
|
||||
awk '{ src=$NF; $NF=""; sub(/[ \t]+$/, ""); print $0 "|" src }')
|
||||
if [ "${#tgts[@]}" -eq 0 ]; then
|
||||
log "No active NFS mounts"
|
||||
where="$(share_ask_value "Local mountpoint to unmount")" || return 1
|
||||
[ -n "$where" ] || return 1
|
||||
cmd_unmount "$where"
|
||||
return 0
|
||||
fi
|
||||
for ((i = 0; i < ${#tgts[@]}; i++)); do
|
||||
items+=("${tgts[$i]} ← ${srcs[$i]}")
|
||||
done
|
||||
idx="$(share_pick "Unmount which NFS mount?" "${items[@]}")" || return 1
|
||||
where="${tgts[$((idx - 1))]}"
|
||||
src="${srcs[$((idx - 1))]}"
|
||||
confirm "Unmount ${where} (from ${src})?" n || { log "Cancelled"; return 1; }
|
||||
cmd_unmount "$where"
|
||||
}
|
||||
|
||||
@@ -296,11 +455,13 @@ run_menu() {
|
||||
"Unmount a mounted share" \
|
||||
"Remove a persistent mount")" || return 0
|
||||
case "$choice" in
|
||||
1) menu_mount ephemeral ;;
|
||||
2) menu_mount persist ;;
|
||||
3) cmd_list ;;
|
||||
4) menu_unmount ;;
|
||||
5) menu_unpersist ;;
|
||||
# Handlers return nonzero on cancel/back — normalized here so a
|
||||
# cancel can never reach set -e and kill the whole session.
|
||||
1) menu_mount ephemeral || true ;;
|
||||
2) menu_mount persist || true ;;
|
||||
3) cmd_list || true ;;
|
||||
4) menu_unmount || true ;;
|
||||
5) menu_unpersist || true ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
+217
-29
@@ -10,6 +10,7 @@ source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$
|
||||
SMB_CREDS_DIR="${SMB_CREDS_DIR:-/etc/samba/credentials}"
|
||||
UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}"
|
||||
SMB_PORT="${SMB_PORT:-445}"
|
||||
SMB_CONF="${SMB_CONF:-/etc/samba/smb.conf}"
|
||||
command -v mount.cifs &>/dev/null || err "mount.cifs not found (install cifs-utils)"
|
||||
command -v systemd-escape &>/dev/null || err "systemd-escape not found"
|
||||
|
||||
@@ -21,7 +22,8 @@ Mount and manage SMB/CIFS shares from remote servers (cifs-utils).
|
||||
|
||||
Commands:
|
||||
mount <//server/share> <local-dir> [user] One-shot mount (creates local-dir if needed)
|
||||
unmount <local-dir> Unmount the share (idempotent)
|
||||
unmount <local-dir> Unmount the share (idempotent: rc 0 when
|
||||
nothing is mounted)
|
||||
list Show active + persistent SMB mounts
|
||||
persist <//server/share> <local-dir> [user] Persistent mount via systemd .mount + .automount
|
||||
units (mounts on first access — never blocks boot)
|
||||
@@ -103,6 +105,23 @@ mounted_src() {
|
||||
findmnt -rnf -t cifs -o SOURCE,TARGET 2>/dev/null | awk -v t="$1" '$2 == t {print $1; exit}'
|
||||
}
|
||||
|
||||
# Persisted Type=cifs unit declaring Where=<path>? An idle automount never
|
||||
# appears in findmnt, so this is the only way to tell "idle automount" apart
|
||||
# from "nothing configured". Sets PERSISTED_UNIT to the unit file basename.
|
||||
persisted_smb_at() { # <path> — rc 0 persisted · rc 1 not persisted
|
||||
local uf
|
||||
PERSISTED_UNIT=""
|
||||
for uf in "${UNIT_DIR}"/*.mount; do
|
||||
[ -f "$uf" ] || continue
|
||||
grep -q '^Type=cifs$' "$uf" || continue
|
||||
if [ "$(sed -n 's/^Where=//p' "$uf")" = "$1" ]; then
|
||||
PERSISTED_UNIT="$(basename "$uf")"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Create the mountpoint if needed; refuse to silently shadow a non-empty dir.
|
||||
ensure_mountpoint() {
|
||||
local where="$1"
|
||||
@@ -243,13 +262,19 @@ cmd_unmount() { # <local-dir>
|
||||
|
||||
src="$(mounted_src "$where")"
|
||||
if [ -z "$src" ]; then
|
||||
log "Nothing mounted at $where"
|
||||
exit 0
|
||||
# Idempotent no-op — rc 0 whether or not anything is configured.
|
||||
if persisted_smb_at "$where"; then
|
||||
warn "$where is a persisted automount — not currently mounted."
|
||||
log "Access it once (e.g.: ls $where) to auto-mount it, or remove the persistence first: menu option 5 (pos share smb-client unpersist $where)"
|
||||
else
|
||||
log "Nothing mounted at $where"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
if ! out="$(sudo umount "$where" 2>&1)"; then
|
||||
if grep -qE "busy|in use" <<<"$out"; then
|
||||
warn "$where is busy (${src})"
|
||||
confirm "Force a lazy unmount now?" y && { run sudo umount -l "$where"; log "Lazy-unmounted $where"; exit 0; }
|
||||
confirm "Force a lazy unmount now?" y && { run sudo umount -l "$where"; log "Lazy-unmounted $where"; return 0; }
|
||||
err "Still mounted. Find the blocker: sudo lsof +D $where (or fuser -vm $where)"
|
||||
fi
|
||||
err "Unmount failed: $out"
|
||||
@@ -410,8 +435,18 @@ cmd_unpersist() { # <local-dir>
|
||||
auto_file="${UNIT_DIR}/${auto_unit}"
|
||||
|
||||
if [ ! -f "$unit_file" ] && [ ! -f "$auto_file" ]; then
|
||||
log "No persistent SMB mount for $where — nothing to do"
|
||||
exit 0
|
||||
# Idempotent no-op — rc 0 whether or not anything is configured.
|
||||
# Return, not exit: a bogus typed path reached from the menu must
|
||||
# not kill the whole session. A persisted unit can also live under
|
||||
# a non-escape-derived filename (Where= still matches) — point at
|
||||
# it instead of a bare nothing-to-do.
|
||||
if persisted_smb_at "$where"; then
|
||||
warn "$where has a persistent SMB mount unit (${PERSISTED_UNIT}) under a non-standard unit name."
|
||||
log "Check it: systemctl status ${PERSISTED_UNIT%.mount} — or find it in the list: pos share smb-client list"
|
||||
else
|
||||
log "No persistent SMB mount for $where — nothing to do"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
sudo systemctl disable "$auto_unit" 2>/dev/null || true
|
||||
@@ -432,13 +467,149 @@ cmd_unpersist() { # <local-dir>
|
||||
}
|
||||
|
||||
# ── Interactive menu flows ─────────────────────────────────────
|
||||
menu_ask_mountpoint() { # stdout: absolute path · rc 1 cancelled
|
||||
local idx dir cand
|
||||
local -a cands=()
|
||||
if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then
|
||||
if idx="$(share_pick "Mountpoint" "${cands[@]}")"; then
|
||||
cand="${cands[$((idx - 1))]}"
|
||||
dir="${cand%% (*}" # strip "(mounted fstype)" annotation
|
||||
|
||||
# Best-effort resolution of the directory BEHIND share <$2> on host <$1>.
|
||||
# The underlying path of a remote SMB share is not remotely discoverable;
|
||||
# it is only locally knowable when THIS machine is the server (testparm
|
||||
# answers from the local config). Anything else stays unresolved (rc 1)
|
||||
# and the mountpoint picker silently skips the "(as on server)" suggestion.
|
||||
smb_server_path() { # <host> <share> — stdout: server-side dir · rc 1 = unresolved
|
||||
local host="${1,,}" share="$2" name p ips=""
|
||||
local -a names=("localhost" "127.0.0.1" "::1" "$(hostname)")
|
||||
names+=("$(hostname -f 2>/dev/null || true)")
|
||||
ips="$(hostname -I 2>/dev/null)" || true
|
||||
# shellcheck disable=SC2086 — $ips is an intentional space-split IP list
|
||||
for name in "${names[@]}" ${ips}; do
|
||||
if [ "$name" = "$host" ]; then
|
||||
share_require_bin testparm "" || return 1
|
||||
[ -f "$SMB_CONF" ] || return 1
|
||||
p="$(testparm -s --parameter-name=path --section-name="$share" "$SMB_CONF" 2>/dev/null)" || return 1
|
||||
[ -n "$p" ] || return 1
|
||||
printf '%s\n' "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Mountpoint picker — local port of the share_pick primitive with exactly
|
||||
# two deltas: the hint line offers `n=new`, and typing n runs the
|
||||
# create-new-dir flow below. share_pick cannot intercept `n` (it filters on
|
||||
# it) and lib/menu-lib.sh is shared, so the fork lives here. Rendering of
|
||||
# numbered picks / text filter / 0=back is byte-identical to menu_pick.
|
||||
# stdout: chosen item text (or the freshly created dir) · rc 1 = back/cancel.
|
||||
pick_mountpoint() {
|
||||
local prompt="$1"; shift
|
||||
local -a items=("$@")
|
||||
if [ "${#items[@]}" -eq 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
if ! [ -t 0 ]; then
|
||||
printf '[!] Interactive picker needs a terminal.\n' >&2
|
||||
return 1
|
||||
fi
|
||||
local filter="" ans i n total=${#items[@]} made
|
||||
local -a shown=()
|
||||
while true; do
|
||||
shown=()
|
||||
for ((i = 0; i < total; i++)); do
|
||||
if [ -z "$filter" ] || [[ "${items[$i],,}" == *"${filter,,}"* ]]; then
|
||||
shown+=("${items[$i]}")
|
||||
fi
|
||||
done
|
||||
n=${#shown[@]}
|
||||
{
|
||||
echo
|
||||
if [ -n "$filter" ]; then
|
||||
printf -- "-- %d of %d match '%s' --\n" "$n" "$total" "$filter"
|
||||
else
|
||||
printf -- "-- %d available --\n" "$total"
|
||||
fi
|
||||
if [ "$n" -eq 0 ]; then
|
||||
printf '[!] no matches — enter nothing or / to clear the filter\n' >&2
|
||||
else
|
||||
for ((i = 0; i < n; i++)); do
|
||||
printf ' %2d) %s\n' $((i + 1)) "${shown[$i]}"
|
||||
done
|
||||
fi
|
||||
} >&2
|
||||
if ! read -rp "${prompt} [1-${n}], n=new, text=filter, 0=back " ans; then
|
||||
return 1 # EOF — cancel
|
||||
fi
|
||||
case "$ans" in
|
||||
"") [ -z "$filter" ] || filter="" ; continue ;;
|
||||
"/") filter="" ; continue ;;
|
||||
0 | q | Q | b | B) return 1 ;;
|
||||
n | N)
|
||||
made="$(ask_new_mountpoint)" && { echo "$made"; return 0; }
|
||||
continue # declined/invalid/mkdir-failed → redraw
|
||||
;;
|
||||
*[!0-9]*)
|
||||
filter="$ans"
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
if (( ans >= 1 && ans <= n )); then
|
||||
echo "${shown[$((ans - 1))]}"
|
||||
return 0
|
||||
fi
|
||||
echo "Unknown choice." >&2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Create-new-dir flow behind the picker's `n` key. Validates the shape
|
||||
# (absolute, no trailing slash), confirm-gates the creation, then mkdir -p.
|
||||
# Any decline, invalid input, EOF or mkdir failure is a warning + rc 1 —
|
||||
# the picker redraws, the tool never aborts.
|
||||
ask_new_mountpoint() { # stdout: created dir · rc 1 = cancelled/failed
|
||||
# NOTE: runs inside $( ) from the picker — every display line MUST go to
|
||||
# stderr (menu-lib contract: display → stderr, result → stdout).
|
||||
local dir
|
||||
dir="$(share_ask_value "New mountpoint (absolute path)")" || return 1
|
||||
case "$dir" in
|
||||
/*) ;;
|
||||
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;;
|
||||
esac
|
||||
case "$dir" in
|
||||
*/) warn "'$dir' must not end with a slash" >&2; return 1 ;;
|
||||
esac
|
||||
case "$dir" in
|
||||
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
|
||||
warn "Refusing system path as mountpoint" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
confirm "Create mountpoint ${dir}?" n || return 1
|
||||
if ! run sudo mkdir -p "$dir"; then
|
||||
warn "Could not create ${dir}" >&2
|
||||
return 1
|
||||
fi
|
||||
log "Created mount point $dir" >&2
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancelled
|
||||
local srv="${1:-}" idx res cand dir known=0
|
||||
local -a cands=() dirs=()
|
||||
mapfile -t cands < <(share_folder_candidates)
|
||||
for cand in "${cands[@]}"; do
|
||||
dirs+=("${cand%% (*}") # bare path (strip "(mounted fstype)" note)
|
||||
done
|
||||
# Same-as-server suggestion: unless the server-side path already exists
|
||||
# among the local candidates, append it as a synthetic pick so mounting
|
||||
# at a mirrored path is a normal selection.
|
||||
if [ -n "$srv" ] && [ "${#dirs[@]}" -gt 0 ] &&
|
||||
printf '%s\n' "${dirs[@]}" | grep -qxF -- "$srv"; then
|
||||
known=1
|
||||
fi
|
||||
if [ -n "$srv" ] && [ "$known" -eq 0 ]; then
|
||||
cands+=("${srv} (as on server)")
|
||||
fi
|
||||
if [ "${#cands[@]}" -gt 0 ]; then
|
||||
if res="$(pick_mountpoint "Mountpoint" "${cands[@]}")"; then
|
||||
dir="${res%% (*}" # strip "(as on server)"/mount note
|
||||
case "$dir" in
|
||||
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
|
||||
warn "Refusing system path as mountpoint"
|
||||
@@ -479,7 +650,11 @@ menu_mount() { # ephemeral|persist
|
||||
user="${SMB_AUTH_USER:-$user}"
|
||||
|
||||
what="//${host}/${share}"
|
||||
where="$(menu_ask_mountpoint)" || return 1
|
||||
# Underlying server-side dir, when knowable (this machine is the server);
|
||||
# empty → picker silently skips the "(as on server)" suggestion.
|
||||
local srv_path=""
|
||||
srv_path="$(smb_server_path "$host" "$share")" || srv_path=""
|
||||
where="$(menu_ask_mountpoint "$srv_path")" || return 1
|
||||
|
||||
if [ "$mode" = "persist" ]; then
|
||||
cmd_persist "$what" "$where" "$user"
|
||||
@@ -489,19 +664,30 @@ menu_mount() { # ephemeral|persist
|
||||
}
|
||||
|
||||
menu_unmount() {
|
||||
local idx row where
|
||||
local -a targets=()
|
||||
if mapfile -t rows < <(findmnt -rnf -t cifs -o SOURCE,TARGET 2>/dev/null) &&
|
||||
[ "${#rows[@]}" -gt 0 ]; then
|
||||
for row in "${rows[@]}"; do
|
||||
targets+=("${row##* }") # last field of raw mode = TARGET
|
||||
done
|
||||
idx="$(share_pick "Unmount which SMB mount?" "${targets[@]}")" || return 1
|
||||
where="${targets[$((idx - 1))]}"
|
||||
else
|
||||
local idx row src where i
|
||||
local -a tgts=() srcs=() items=()
|
||||
# Same enumeration source as the `list` view's active section
|
||||
# (findmnt -t cifs), reduced to TARGET|SOURCE rows.
|
||||
while IFS= read -r row; do
|
||||
[ -n "$row" ] || continue
|
||||
tgts+=("${row%%|*}")
|
||||
srcs+=("${row#*|}")
|
||||
done < <(findmnt -rn -o TARGET,SOURCE -t cifs 2>/dev/null |
|
||||
awk '{ src=$NF; $NF=""; sub(/[ \t]+$/, ""); print $0 "|" src }')
|
||||
if [ "${#tgts[@]}" -eq 0 ]; then
|
||||
log "No active SMB mounts"
|
||||
where="$(share_ask_value "Local mountpoint to unmount")" || return 1
|
||||
[ -n "$where" ] || return 1
|
||||
cmd_unmount "$where"
|
||||
return 0
|
||||
fi
|
||||
for ((i = 0; i < ${#tgts[@]}; i++)); do
|
||||
items+=("${tgts[$i]} ← ${srcs[$i]}")
|
||||
done
|
||||
idx="$(share_pick "Unmount which SMB mount?" "${items[@]}")" || return 1
|
||||
where="${tgts[$((idx - 1))]}"
|
||||
src="${srcs[$((idx - 1))]}"
|
||||
confirm "Unmount ${where} (from ${src})?" n || { log "Cancelled"; return 1; }
|
||||
cmd_unmount "$where"
|
||||
}
|
||||
|
||||
@@ -535,11 +721,13 @@ run_menu() {
|
||||
"Unmount a mounted share" \
|
||||
"Remove a persistent mount")" || return 0
|
||||
case "$choice" in
|
||||
1) menu_mount ephemeral ;;
|
||||
2) menu_mount persist ;;
|
||||
3) cmd_list ;;
|
||||
4) menu_unmount ;;
|
||||
5) menu_unpersist ;;
|
||||
# Handlers return nonzero on cancel/back — normalized here so a
|
||||
# cancel can never reach set -e and kill the whole session.
|
||||
1) menu_mount ephemeral || true ;;
|
||||
2) menu_mount persist || true ;;
|
||||
3) cmd_list || true ;;
|
||||
4) menu_unmount || true ;;
|
||||
5) menu_unpersist || true ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Executable
+435
@@ -0,0 +1,435 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: system uninstall — Remove pos toolkit binaries, services, shell integration, config, and data
|
||||
# POS_FLAGS: --yes --config --data
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: pos system uninstall [--yes] [--config] [--data]
|
||||
|
||||
Remove the pos toolkit from this machine. Scans for installed components
|
||||
and removes them interactively (or non-interactively with --yes).
|
||||
|
||||
Tiers:
|
||||
Tier 1 (always): binaries, entertainment plugins, systemd services, shell integration
|
||||
Tier 2 (--config): config files in ~/.config/linux_post_install/
|
||||
Tier 3 (--data): session/log/capture data in ~/.local/share/linux_post_install/
|
||||
|
||||
Flags:
|
||||
--yes Skip confirmation prompts (removes defaults only; combine with --config/--data for more)
|
||||
--config Include config files (Tier 2) in removal
|
||||
--data Include session/log data (Tier 3) in removal
|
||||
|
||||
Examples:
|
||||
pos system uninstall # interactive, tier 1 only
|
||||
pos system uninstall --yes # non-interactive, tier 1 only
|
||||
pos system uninstall --yes --config --data # nuclear option
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Parse flags ─────────────────────────────────────────────────
|
||||
YES_MODE=0
|
||||
DEL_CONFIG=0
|
||||
DEL_DATA=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help) usage ;;
|
||||
--yes) YES_MODE=1; shift ;;
|
||||
--config) DEL_CONFIG=1; shift ;;
|
||||
--data) DEL_DATA=1; shift ;;
|
||||
-*) err "Unknown option '$1'" ;;
|
||||
*) err "Unexpected argument: $1 (no subcommands)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Scan functions ──────────────────────────────────────────────
|
||||
scan_tier1() {
|
||||
local found=()
|
||||
|
||||
# ── Binaries in /usr/local/bin ──
|
||||
# Main dispatcher
|
||||
[ -f /usr/local/bin/pos ] && found+=("/usr/local/bin/pos")
|
||||
|
||||
# All pos-* tools (sorted for deterministic display)
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
found+=("$f")
|
||||
done < <(compgen -G /usr/local/bin/pos-* 2>/dev/null | sort || true)
|
||||
|
||||
# Lib files shipped by install.sh
|
||||
for f in common.sh menu-lib.sh share-lib.sh; do
|
||||
[ -f "/usr/local/bin/$f" ] && found+=("/usr/local/bin/$f")
|
||||
done
|
||||
|
||||
# AI providers subdirectory
|
||||
[ -d /usr/local/bin/ai-providers ] && found+=("/usr/local/bin/ai-providers/")
|
||||
|
||||
# Entertainment plugins installed by install.sh
|
||||
while IFS= read -r f; do
|
||||
found+=("$f")
|
||||
done < <(for ep in weather.sh gold.sh joke.sh; do
|
||||
[ -f "/usr/local/bin/$ep" ] && echo "/usr/local/bin/$ep"
|
||||
done | sort)
|
||||
|
||||
# Legacy forwarders
|
||||
for f in wr-* mp3 mp4 vbox ssh-load-all; do
|
||||
if compgen -G "/usr/local/bin/$f" >/dev/null 2>&1; then
|
||||
while IFS= read -r lf; do
|
||||
found+=("$lf")
|
||||
done < <(compgen -G "/usr/local/bin/$f" 2>/dev/null)
|
||||
fi
|
||||
done
|
||||
|
||||
# Prebuilt binaries (hotspot)
|
||||
for f in wihotspot wihotspot-gui create_ap; do
|
||||
[ -f "/usr/local/bin/$f" ] && found+=("/usr/local/bin/$f")
|
||||
done
|
||||
|
||||
# Feature scripts installed by install.sh
|
||||
for f in autostart.sh usb-automount.sh; do
|
||||
[ -f "/usr/local/bin/$f" ] && found+=("/usr/local/bin/$f")
|
||||
done
|
||||
|
||||
# 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")
|
||||
|
||||
# ~/.bash_completion entries
|
||||
if [ -f "$HOME/.bash_completion" ]; then
|
||||
while IFS= read -r line; do
|
||||
found+=("~/.bash_completion: $(echo "$line" | sed 's/^[[:space:]]*//' | cut -c1-70)")
|
||||
done < <(grep -n 'pos' "$HOME/.bash_completion" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# ── Systemd services ──
|
||||
for svc in autostart.service ssh-agent.service usb-automount.service; do
|
||||
if systemctl is-enabled "$svc" &>/dev/null 2>&1; then
|
||||
found+=("service: $svc")
|
||||
elif [ -f "/etc/systemd/system/$svc" ]; then
|
||||
found+=("service: $svc")
|
||||
fi
|
||||
done
|
||||
# Catch any other linux_post_install-related services
|
||||
while IFS= read -r line; do
|
||||
local svc_name
|
||||
svc_name=$(echo "$line" | awk '{print $1}')
|
||||
# Skip already-listed services
|
||||
local already=0
|
||||
for listed in autostart.service ssh-agent.service usb-automount.service; do
|
||||
[ "$svc_name" = "$listed" ] && already=1 && break
|
||||
done
|
||||
[ "$already" -eq 0 ] && found+=("service: $svc_name")
|
||||
done < <(systemctl list-unit-files --type=service 2>/dev/null | grep -i 'linux_post_install\|pos-' || true)
|
||||
|
||||
# ── Shell integration (~/.bashrc) ──
|
||||
if [ -f "$HOME/.bashrc" ]; then
|
||||
while IFS= read -r line; do
|
||||
found+=("~/.bashrc: $(echo "$line" | sed 's/^[[:space:]]*//' | cut -c1-70)")
|
||||
done < <(grep -n 'source.*pos-ai-hook\|linux_post_install.*PATH\|source.*pos\.bash\|pos completion' "$HOME/.bashrc" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
printf '%s\n' "${found[@]}"
|
||||
}
|
||||
|
||||
scan_tier2() {
|
||||
local found=()
|
||||
local cfg="$HOME/.config/linux_post_install"
|
||||
if [ -d "$cfg" ]; then
|
||||
while IFS= read -r f; do
|
||||
found+=("$f")
|
||||
done < <(find "$cfg" -maxdepth 2 -type f 2>/dev/null | sort)
|
||||
fi
|
||||
printf '%s\n' "${found[@]}"
|
||||
}
|
||||
|
||||
scan_tier3() {
|
||||
local found=()
|
||||
local data="$HOME/.local/share/linux_post_install"
|
||||
if [ -d "$data" ]; then
|
||||
while IFS= read -r f; do
|
||||
found+=("$f")
|
||||
done < <(find "$data" -maxdepth 2 \( -type f -o -type d \) 2>/dev/null | sort)
|
||||
fi
|
||||
printf '%s\n' "${found[@]}"
|
||||
}
|
||||
|
||||
# ── Display function ────────────────────────────────────────────
|
||||
display_plan() {
|
||||
local -a t1=() t2=() t3=()
|
||||
# Collect non-empty entries from scan output
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && t1+=("$line")
|
||||
done <<< "${1:-}"
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && t2+=("$line")
|
||||
done <<< "${2:-}"
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && t3+=("$line")
|
||||
done <<< "${3:-}"
|
||||
|
||||
echo
|
||||
echo "${BOLD}pos uninstall — what will be removed:${RESET}"
|
||||
echo
|
||||
|
||||
local n=1
|
||||
|
||||
if [ "${#t1[@]}" -gt 0 ]; then
|
||||
echo "${CYAN}Tier 1 (always):${RESET}"
|
||||
for item in "${t1[@]}"; do
|
||||
printf " %3d) %s\n" "$n" "$item"
|
||||
n=$((n + 1))
|
||||
done
|
||||
else
|
||||
echo "${CYAN}Tier 1 (always):${RESET} (nothing found)"
|
||||
fi
|
||||
echo
|
||||
|
||||
if [ "${#t2[@]}" -gt 0 ]; then
|
||||
echo "${YELLOW}Tier 2 (--config to include):${RESET}"
|
||||
for item in "${t2[@]}"; do
|
||||
printf " %3d) %s\n" "$n" "$item"
|
||||
n=$((n + 1))
|
||||
done
|
||||
else
|
||||
echo "${YELLOW}Tier 2 (--config to include):${RESET} (nothing found)"
|
||||
fi
|
||||
echo
|
||||
|
||||
if [ "${#t3[@]}" -gt 0 ]; then
|
||||
echo "${YELLOW}Tier 3 (--data to include):${RESET}"
|
||||
for item in "${t3[@]}"; do
|
||||
printf " %3d) %s\n" "$n" "$item"
|
||||
n=$((n + 1))
|
||||
done
|
||||
else
|
||||
echo "${YELLOW}Tier 3 (--data to include):${RESET} (nothing found)"
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
# ── Removal functions ───────────────────────────────────────────
|
||||
remove_tier1() {
|
||||
local count=0
|
||||
|
||||
# ── Binaries ──
|
||||
# Main dispatcher
|
||||
[ -f /usr/local/bin/pos ] && { rm -f /usr/local/bin/pos && count=$((count+1)); }
|
||||
|
||||
# All pos-* tools
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
|
||||
done < <(compgen -G /usr/local/bin/pos-* 2>/dev/null | sort || true)
|
||||
|
||||
# Lib files
|
||||
for f in /usr/local/bin/common.sh /usr/local/bin/menu-lib.sh /usr/local/bin/share-lib.sh; do
|
||||
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
|
||||
done
|
||||
|
||||
# AI providers directory
|
||||
if [ -d /usr/local/bin/ai-providers ]; then
|
||||
rm -rf /usr/local/bin/ai-providers && count=$((count+1))
|
||||
fi
|
||||
|
||||
# Entertainment plugins
|
||||
for f in /usr/local/bin/weather.sh /usr/local/bin/gold.sh /usr/local/bin/joke.sh; do
|
||||
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
|
||||
done
|
||||
|
||||
# Legacy forwarders
|
||||
for pat in 'wr-*' mp3 mp4 vbox ssh-load-all; do
|
||||
while IFS= read -r f; do
|
||||
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
|
||||
done < <(compgen -G "/usr/local/bin/$pat" 2>/dev/null || true)
|
||||
done
|
||||
|
||||
# Prebuilt binaries
|
||||
for f in /usr/local/bin/wihotspot /usr/local/bin/wihotspot-gui /usr/local/bin/create_ap; do
|
||||
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
|
||||
done
|
||||
|
||||
# Feature scripts
|
||||
for f in /usr/local/bin/autostart.sh /usr/local/bin/usb-automount.sh; do
|
||||
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
|
||||
done
|
||||
|
||||
# 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)); }
|
||||
|
||||
# ── Systemd services ──
|
||||
for svc in autostart.service ssh-agent.service usb-automount.service; do
|
||||
if systemctl is-enabled "$svc" &>/dev/null 2>&1; then
|
||||
systemctl disable --now "$svc" 2>/dev/null || true
|
||||
rm -f "/etc/systemd/system/$svc"
|
||||
count=$((count+1))
|
||||
elif [ -f "/etc/systemd/system/$svc" ]; then
|
||||
rm -f "/etc/systemd/system/$svc"
|
||||
count=$((count+1))
|
||||
fi
|
||||
done
|
||||
# Additional linux_post_install services
|
||||
while IFS= read -r svc_file; do
|
||||
local svc_name
|
||||
svc_name=$(basename "$svc_file" .service)
|
||||
# Skip already-handled services
|
||||
local already=0
|
||||
for listed in autostart ssh-agent usb-automount; do
|
||||
[ "$svc_name" = "$listed" ] && already=1 && break
|
||||
done
|
||||
if [ "$already" -eq 0 ]; then
|
||||
systemctl disable --now "$svc_name.service" 2>/dev/null || true
|
||||
rm -f "$svc_file"
|
||||
count=$((count+1))
|
||||
fi
|
||||
done < <(find /etc/systemd/system/ -name '*linux_post_install*' -o -name 'pos-*' 2>/dev/null || true)
|
||||
|
||||
# Reload daemon after service changes
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
|
||||
# ── Shell integration (~/.bashrc) ──
|
||||
if [ -f "$HOME/.bashrc" ]; then
|
||||
local before
|
||||
before=$(wc -l < "$HOME/.bashrc")
|
||||
sed -i '/source.*pos-ai-hook/d' "$HOME/.bashrc"
|
||||
sed -i '/linux_post_install.*PATH/d' "$HOME/.bashrc"
|
||||
sed -i '/source.*pos\.bash/d' "$HOME/.bashrc"
|
||||
local after
|
||||
after=$(wc -l < "$HOME/.bashrc")
|
||||
local removed=$((before - after))
|
||||
count=$((count + removed))
|
||||
fi
|
||||
|
||||
# ── Shell completion (~/.bash_completion) ──
|
||||
if [ -f "$HOME/.bash_completion" ]; then
|
||||
local before
|
||||
before=$(wc -l < "$HOME/.bash_completion")
|
||||
sed -i '/pos/d' "$HOME/.bash_completion"
|
||||
local after
|
||||
after=$(wc -l < "$HOME/.bash_completion")
|
||||
local removed=$((before - after))
|
||||
count=$((count + removed))
|
||||
fi
|
||||
|
||||
ok "Removed $count items (tier 1)"
|
||||
}
|
||||
|
||||
remove_tier2() {
|
||||
local cfg="$HOME/.config/linux_post_install"
|
||||
local count=0
|
||||
|
||||
if [ -d "$cfg" ]; then
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
rm -f "$f" && count=$((count+1))
|
||||
done < <(find "$cfg" -maxdepth 2 -type f 2>/dev/null)
|
||||
|
||||
# Remove empty directory tree
|
||||
rmdir "$cfg/schedule.d" 2>/dev/null || true
|
||||
rmdir "$cfg" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
ok "Removed $count items (tier 2)"
|
||||
}
|
||||
|
||||
remove_tier3() {
|
||||
local data="$HOME/.local/share/linux_post_install"
|
||||
local count=0
|
||||
|
||||
if [ -d "$data" ]; then
|
||||
# Remove files first
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
rm -f "$f" && count=$((count+1))
|
||||
done < <(find "$data" -maxdepth 2 -type f 2>/dev/null)
|
||||
|
||||
# Remove directories bottom-up
|
||||
while IFS= read -r d; do
|
||||
rmdir "$d" 2>/dev/null && count=$((count+1)) || true
|
||||
done < <(find "$data" -mindepth 1 -depth -type d 2>/dev/null)
|
||||
|
||||
# Remove top-level directory if empty
|
||||
rmdir "$data" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
ok "Removed $count items (tier 3)"
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────
|
||||
main() {
|
||||
section "pos system uninstall"
|
||||
|
||||
# Scan all tiers
|
||||
local scan1 scan2 scan3
|
||||
scan1="$(scan_tier1)"
|
||||
scan2="$(scan_tier2)"
|
||||
scan3="$(scan_tier3)"
|
||||
|
||||
# Display the plan
|
||||
display_plan "$scan1" "$scan2" "$scan3"
|
||||
|
||||
# Nothing to do at all?
|
||||
if [ -z "$scan1" ] && [ -z "$scan2" ] && [ -z "$scan3" ]; then
|
||||
ok "Nothing to remove — pos toolkit does not appear to be installed."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── Tier 1: always remove (confirm unless --yes) ──
|
||||
if [ -n "$scan1" ]; then
|
||||
if [ "$YES_MODE" -eq 1 ]; then
|
||||
log "Removing tier 1 items (--yes)..."
|
||||
else
|
||||
confirm "Remove tier 1 items (binaries, services, shell integration)?" || return 0
|
||||
fi
|
||||
remove_tier1
|
||||
fi
|
||||
|
||||
# ── Tier 2: config files ──
|
||||
if [ -n "$scan2" ]; then
|
||||
if [ "$DEL_CONFIG" -eq 0 ] && [ "$YES_MODE" -eq 0 ]; then
|
||||
confirm "Also remove config files (tier 2)?" && DEL_CONFIG=1 || true
|
||||
fi
|
||||
[ "$DEL_CONFIG" -eq 1 ] && remove_tier2
|
||||
fi
|
||||
|
||||
# ── Tier 3: session/log data ──
|
||||
if [ -n "$scan3" ]; then
|
||||
if [ "$DEL_DATA" -eq 0 ] && [ "$YES_MODE" -eq 0 ]; then
|
||||
confirm "Also remove session/log data (tier 3)?" && DEL_DATA=1 || true
|
||||
fi
|
||||
[ "$DEL_DATA" -eq 1 ] && remove_tier3
|
||||
fi
|
||||
|
||||
echo
|
||||
ok "Uninstall complete."
|
||||
echo " The git repo was NOT removed — delete it manually if desired."
|
||||
echo " Restart your shell or run: source ~/.bashrc"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+12
-6
@@ -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 <<EOF
|
||||
@@ -44,12 +45,17 @@ add() {
|
||||
meta["$path"]="$type|$desc"
|
||||
}
|
||||
|
||||
for f in "$self"/pos-*; do
|
||||
[ -x "$f" ] || continue
|
||||
name="${f##*/pos-}"
|
||||
desc="$(sed -n '/^# POS: /{s/^# POS: //;p;q}' "$f")"
|
||||
desc="${desc#*— }"
|
||||
sc="$(sed -n '/^# POS_SUBCMDS: /{s/^# POS_SUBCMDS: //;p;q}' "$f")"
|
||||
reg_scan "$self"
|
||||
|
||||
for tool_key in $(reg_list); do
|
||||
cat="$(reg_lookup "$tool_key" cat)"
|
||||
desc="$(reg_lookup "$tool_key" desc)"
|
||||
sc="$(reg_lookup "$tool_key" subcmds)"
|
||||
deps="$(reg_lookup "$tool_key" deps)"
|
||||
name="${tool_key}"
|
||||
if [ -n "$deps" ]; then
|
||||
desc="$desc [deps: $deps]"
|
||||
fi
|
||||
words=(${name//-/ })
|
||||
parent="/"
|
||||
for ((i=0; i<${#words[@]}; i++)); do
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
# Install: source this file in ~/.bashrc or place in /etc/bash_completion.d/
|
||||
# GEN:START posflags
|
||||
declare -A _pos_flags
|
||||
_pos_flags[ai-gemini]="--model --session --system"
|
||||
_pos_flags[communication-matrix-listener]="--enable --disable --status --run"
|
||||
_pos_flags[communication-telegram-listener]="--enable --disable --status --sync-commands --run"
|
||||
_pos_flags[communication-telegram-sender]="--type --caption --parse-mode --no-preview --token --chat-id --markdown"
|
||||
_pos_flags[docker-stack]="-a --all"
|
||||
_pos_flags[docker-vbox]="--dir --gpu --device --port --cpus --memory --network"
|
||||
_pos_flags[entertainment-send]="--print --markdown"
|
||||
_pos_flags[media-mp3]="--output --no-playlist --cookies --by-artist --dry-run"
|
||||
_pos_flags[media-mp4]="--format --best --worst --output --no-playlist --cookies --dry-run"
|
||||
@@ -19,11 +19,15 @@ _pos_flags[network-hotspot]="--foreground"
|
||||
_pos_flags[share-usb-server]="--ls --ls-shared --share --unshare --auto-share --callback --close-callback --auto-connect --disconnect --nickname --timeout --port --info --version menu"
|
||||
_pos_flags[system-backup]="--service --no-encrypt"
|
||||
_pos_flags[system-schedule]="--dry-run"
|
||||
_pos_flags[system-uninstall]="--yes --config --data"
|
||||
_pos_flags[ai]="--provider --model --session --system --full --last"
|
||||
_pos_flags[tree]="--depth"
|
||||
# GEN:END posflags
|
||||
# GEN:START possubcmds
|
||||
declare -A _pos_subcmds
|
||||
_pos_subcmds[ai-gemini]="ask chat models sessions"
|
||||
_pos_subcmds[ai-alias]="create edit remove list show"
|
||||
_pos_subcmds[ai-gemini]="ask chat models sessions capture"
|
||||
_pos_subcmds[ai-openrouter]="ask chat sessions capture"
|
||||
_pos_subcmds[communication-matrix-sender]="send test login"
|
||||
_pos_subcmds[communication-scrcpy]="devices record tcpip connect push pull screenshot info"
|
||||
_pos_subcmds[communication-telegram-sender]="send test"
|
||||
@@ -38,6 +42,7 @@ _pos_subcmds[share-smb-client]="mount unmount list persist unpersist menu"
|
||||
_pos_subcmds[share-smb-server]="status share unshare list adduser deluser reload enable disable menu"
|
||||
_pos_subcmds[system-backup]="menu"
|
||||
_pos_subcmds[system-schedule]="run list config enable disable status migrate menu"
|
||||
_pos_subcmds[ai]="ask chat sessions capture models providers alias gemini openrouter"
|
||||
# GEN:END possubcmds
|
||||
# GEN:START posconfigscopes
|
||||
declare -a _pos_config_scopes=(ai compose entertainment matrix notify scrcpy system telegram ytsync)
|
||||
|
||||
+15
-5
@@ -1,8 +1,18 @@
|
||||
# ~/.config/linux_post_install/ai.env — Google Gemini config for 'pos ai gemini'
|
||||
# ~/.config/linux_post_install/ai.env — AI provider config for 'pos ai'
|
||||
# Copied to ~/.config/linux_post_install/ai.env on install (no clobber).
|
||||
# Edit with: pos config ai
|
||||
#
|
||||
# Syntax:
|
||||
# AI_GEMINI_API_KEY=<key> # required — API key from aistudio.google.com
|
||||
# # (never commit this to the repo)
|
||||
# AI_GEMINI_MODEL=<model> # optional — model id, default gemini-2.5-flash
|
||||
# Provider selection:
|
||||
# AI_PROVIDER=gemini # provider: gemini|openrouter (default gemini)
|
||||
#
|
||||
# API keys (each provider needs its own):
|
||||
# AI_GEMINI_API_KEY=<key> # Gemini API key from aistudio.google.com
|
||||
# OPENROUTER_API_KEY=<key> # OpenRouter API key from openrouter.ai
|
||||
#
|
||||
# Model overrides (optional, defaults per provider):
|
||||
# AI_GEMINI_MODEL=<model> # Gemini model id (default: gemini-2.5-flash)
|
||||
# OPENROUTER_MODEL=<model> # OpenRouter model id (default: openrouter/auto)
|
||||
# AI_MODEL=<model> # Override for all providers (takes priority)
|
||||
#
|
||||
# System prompt:
|
||||
# AI_SYSTEM_PROMPT=<prompt> # Custom system prompt (overrides built-in; empty to reset)
|
||||
|
||||
+14
-1
@@ -140,13 +140,26 @@ if should_run 2 scripts; then
|
||||
done
|
||||
lib_count=0
|
||||
lib_names=""
|
||||
for lf in common.sh flags.sh notify.sh entertainment-lib.sh scheduler-lib.sh config-ui.sh user-timers-lib.sh entertainment-plugin-lib.sh usb-lib.sh share-lib.sh menu-lib.sh; do
|
||||
for lf in common.sh flags.sh notify.sh entertainment-lib.sh scheduler-lib.sh config-ui.sh user-timers-lib.sh entertainment-plugin-lib.sh usb-lib.sh share-lib.sh menu-lib.sh registry.sh; do
|
||||
run sudo install -m 644 "lib/$lf" "/usr/local/bin/$lf"
|
||||
lib_count=$((lib_count + 1))
|
||||
lib_names+="$lf "
|
||||
done
|
||||
log "libs -> /usr/local/bin (644): ${lib_names% }"
|
||||
|
||||
# ── AI provider adapters ────────────────────────────────────
|
||||
# Installed into /usr/local/bin/ai-providers/ for pos-ai.
|
||||
ap_count=0
|
||||
ap_names=""
|
||||
run sudo mkdir -p /usr/local/bin/ai-providers
|
||||
for apf in lib/ai-providers/*.sh; do
|
||||
[ -f "$apf" ] || continue
|
||||
run sudo install -m 644 "$apf" "/usr/local/bin/ai-providers/"
|
||||
ap_count=$((ap_count + 1))
|
||||
ap_names+="$(basename "$apf") "
|
||||
done
|
||||
[ "$ap_count" -gt 0 ] && log "ai-providers -> /usr/local/bin/ai-providers (644): ${ap_names% }"
|
||||
|
||||
# ── Entertainment plugins ────────────────────────────────
|
||||
# Installed into /usr/local/bin so the repo can be deleted afterwards.
|
||||
pcount=0
|
||||
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Gemini provider adapter for pos-ai
|
||||
# Provider-specific: API call, auth, response parsing, models list
|
||||
# Part of the R8 provider-agnostic architecture (lib/ai-providers/).
|
||||
|
||||
# Provider-specific config variables (auto-discovered by pos config ai):
|
||||
# PROVIDER_CONFIG: AI_GEMINI_API_KEY=secret:Gemini API key from aistudio.google.com
|
||||
# PROVIDER_CONFIG: AI_GEMINI_MODEL=:Gemini model id (default: gemini-2.5-flash)
|
||||
|
||||
provider_name() { printf 'Google Gemini'; }
|
||||
provider_default_model() { printf 'gemini-2.5-flash'; }
|
||||
|
||||
# $1=model $2=messages JSON ({"messages":[{role,content}]}) $3=optional system prompt
|
||||
provider_generate() {
|
||||
local model="$1" messages="$2" system="${3:-}" body resp code body_out errmsg
|
||||
# Convert OpenAI messages format to Gemini contents format
|
||||
body="$(printf '%s' "$messages" | jq -c '{
|
||||
contents: [.messages[]? | {role: (.role | gsub("assistant";"model")), parts: [{text: .content}]}]
|
||||
}')"
|
||||
if [ -n "$system" ]; then
|
||||
body="$(printf '%s' "$body" | jq -c --arg s "$system" \
|
||||
'. + {systemInstruction:{role:"system",parts:[{text:$s}]}}')"
|
||||
fi
|
||||
resp="$(curl -sS -m 60 -X POST "https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent" \
|
||||
-H "x-goog-api-key: ${AI_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--write-out $'\n%{http_code}' \
|
||||
--data "$body")" || { echo "request failed (curl exit $?)" >&2; return 1; }
|
||||
code="${resp##*$'\n'}"
|
||||
body_out="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
errmsg="$(printf '%s' "$body_out" | jq -r '.error.message // empty' 2>/dev/null || true)"
|
||||
echo "API error $code${errmsg:+: $errmsg}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$body_out" | jq -r '[.candidates[0].content.parts[]?.text] | join("")'
|
||||
}
|
||||
|
||||
# $1=current default model → stdout=formatted model list
|
||||
provider_models_list() {
|
||||
local model="$1" resp code body m
|
||||
resp="$(curl -sS -m 30 -G "https://generativelanguage.googleapis.com/v1beta/models" \
|
||||
-H "x-goog-api-key: ${AI_API_KEY}" \
|
||||
--data-urlencode "pageSize=1000" \
|
||||
--write-out $'\n%{http_code}')" || err "request failed (curl exit $?)"
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
[ "$code" = "200" ] || err "API error $code: $(printf '%s' "$body" | jq -r '.error.message // empty')"
|
||||
local list
|
||||
list="$(printf '%s' "$body" | jq -r '.models[]? | select((.supportedGenerationMethods // []) | index("generateContent")) | .name' | sed 's#^models/##' | sort)"
|
||||
echo "Gemini models (generateContent-capable):"
|
||||
while IFS= read -r m; do
|
||||
[ -n "$m" ] || continue
|
||||
if [ "$m" = "$model" ]; then
|
||||
printf ' %-32s <- default\n' "$m"
|
||||
else
|
||||
printf ' %-32s\n' "$m"
|
||||
fi
|
||||
done <<< "$list"
|
||||
if ! grep -qxF "$model" <<< "$list" 2>/dev/null; then
|
||||
warn "configured default '$model' is not in the list — set AI_MODEL or AI_GEMINI_MODEL"
|
||||
fi
|
||||
}
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# OpenRouter provider adapter for pos-ai
|
||||
# Provider-specific: API call, auth, response parsing, models list
|
||||
# Part of the R8 provider-agnostic architecture (lib/ai-providers/).
|
||||
|
||||
# Provider-specific config variables (auto-discovered by pos config ai):
|
||||
# PROVIDER_CONFIG: OPENROUTER_API_KEY=secret:OpenRouter API key from openrouter.ai
|
||||
# PROVIDER_CONFIG: OPENROUTER_MODEL=:OpenRouter model id (default: openrouter/auto)
|
||||
|
||||
provider_name() { printf 'OpenRouter'; }
|
||||
provider_default_model() { printf 'openrouter/auto'; }
|
||||
|
||||
# $1=model $2=messages JSON ({"messages":[{role,content}]}) $3=optional system prompt
|
||||
provider_generate() {
|
||||
local model="$1" messages="$2" system="${3:-}" body resp code body_out errmsg
|
||||
if [ -n "$system" ]; then
|
||||
body="$(printf '%s' "$messages" | jq -c --arg s "$system" \
|
||||
'[{role:"system",content:$s}] + .messages')"
|
||||
else
|
||||
body="$(printf '%s' "$messages" | jq -c '.messages')"
|
||||
fi
|
||||
body="$(printf '%s' "$body" | jq -nc --arg m "$model" --argjson msgs "$body" \
|
||||
'{model:$m, messages:$msgs}')"
|
||||
resp="$(curl -sS -m 60 -X POST "https://openrouter.ai/api/v1/chat/completions" \
|
||||
-H "Authorization: Bearer ${AI_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "HTTP-Referer: https://github.com/admin/Linux_post_install" \
|
||||
--write-out $'\n%{http_code}' \
|
||||
--data "$body")" || { echo "request failed (curl exit $?)" >&2; return 1; }
|
||||
code="${resp##*$'\n'}"
|
||||
body_out="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
errmsg="$(printf '%s' "$body_out" | jq -r '.error.message // empty' 2>/dev/null || true)"
|
||||
echo "API error $code${errmsg:+: $errmsg}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$body_out" | jq -r '.choices[0].message.content // ""'
|
||||
}
|
||||
|
||||
# $1=current default model → stdout=formatted model list
|
||||
provider_models_list() {
|
||||
local model="$1" resp code body m
|
||||
resp="$(curl -sS -m 30 "https://openrouter.ai/api/v1/models" \
|
||||
-H "Authorization: Bearer ${AI_API_KEY}" \
|
||||
--write-out $'\n%{http_code}')" || err "request failed (curl exit $?)"
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
[ "$code" = "200" ] || err "API error $code: $(printf '%s' "$body" | jq -r '.error.message // empty')"
|
||||
local list
|
||||
list="$(printf '%s' "$body" | jq -r '.data[]?.id' | sort)"
|
||||
echo "OpenRouter models:"
|
||||
while IFS= read -r m; do
|
||||
[ -n "$m" ] || continue
|
||||
if [ "$m" = "$model" ]; then
|
||||
printf ' %-48s <- default\n' "$m"
|
||||
else
|
||||
printf ' %-48s\n' "$m"
|
||||
fi
|
||||
done <<< "$list"
|
||||
if ! grep -qxF "$model" <<< "$list" 2>/dev/null; then
|
||||
warn "configured default '$model' is not in the list — set AI_MODEL or OPENROUTER_MODEL"
|
||||
fi
|
||||
}
|
||||
+18
-7
@@ -117,15 +117,26 @@ spawn() {
|
||||
}
|
||||
|
||||
# ── Confirmation prompt ────────────────────────────────────────
|
||||
# confirm <prompt> [default] — Enter accepts the DISPLAYED DEFAULT ('y'
|
||||
# when omitted); explicit y/Y or n/N overrides; anything else (invalid
|
||||
# input, EOF/closed stdin) denies. EOF fails closed and rc-safely ($yn is
|
||||
# pre-initialized, so no set -u surprise on shells where read leaves it
|
||||
# unset). Destructive call sites pass explicit 'n'.
|
||||
confirm() {
|
||||
local prompt="$1" default="${2:-y}" yn
|
||||
if [ "$default" = "y" ]; then
|
||||
read -rp "${prompt} [Y/n]: " yn
|
||||
[[ -z "$yn" || "$yn" =~ ^[Yy] ]]
|
||||
else
|
||||
read -rp "${prompt} [y/N]: " yn
|
||||
[[ "$yn" =~ ^[Yy] ]]
|
||||
local prompt="$1" default="${2:-y}" hint="[y/N]" yn=""
|
||||
local d="${default,,}"
|
||||
if [ "$d" = "y" ]; then
|
||||
hint="[Y/n]"
|
||||
fi
|
||||
if ! read -rp "${prompt} ${hint}: " yn; then
|
||||
return 1 # EOF / closed stdin — deny
|
||||
fi
|
||||
case "$yn" in
|
||||
[Yy]) return 0 ;;
|
||||
[Nn]) return 1 ;;
|
||||
"") [ "$d" = "y" ] ;; # Enter → the displayed default
|
||||
*) return 1 ;; # invalid input — deny
|
||||
esac
|
||||
}
|
||||
|
||||
# ── system.env loader ──────────────────────────────────────────
|
||||
|
||||
+274
-34
@@ -5,12 +5,22 @@
|
||||
# (mirrors lib/notify.sh). Sourced opt-in by bin/pos-config.
|
||||
#
|
||||
# Header grammar — one "# POS_CONFIG:" line per scope a tool exposes:
|
||||
# # POS_CONFIG: <scope> | <env-file> | <KEY>=<flags>:<desc>[::<example>] | ... | *plugins
|
||||
# # POS_CONFIG: <scope> | <env-file> | <field> | ... | *plugins
|
||||
# <env-file> basename of the config file under ~/.config/linux_post_install/
|
||||
# <field> := <KEY>=<flags>:<desc>[::<example>]
|
||||
# | @<caption> group caption (unconditional)
|
||||
# | @[<KEY>=<alt>[|…]] <caption> conditional group caption —
|
||||
# active iff KEY's current value
|
||||
# equals a listed alt; an empty
|
||||
# alt segment ("gemini|") means
|
||||
# "or unset (= default)"
|
||||
# <flags> secret (masked display + stty -echo input) | digits | num | float
|
||||
# <example> 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[=<tag>] marker: keys from lib/ai-providers/*.sh adapters;
|
||||
# with =<tag>, only from <tag>.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,6 +179,85 @@ _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 <tag> argument restricts to <tag>.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
|
||||
if [ -d "$candidate" ]; then
|
||||
pdir="$(cd "$candidate" 2>/dev/null && pwd)"
|
||||
break
|
||||
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)
|
||||
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:' "$pdir"/*.sh 2>/dev/null | sed 's/^.*# PROVIDER_CONFIG:[[:space:]]*//' || true)
|
||||
return 0
|
||||
}
|
||||
|
||||
# Declared keys for a scope: "KEY|flags|description" lines, deduped.
|
||||
cfg_scope_keys() {
|
||||
local scope="$1" dir line s keystring field
|
||||
@@ -148,14 +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 ;;
|
||||
*plugins*) _cfg_plugin_keys ;;
|
||||
*providers*)
|
||||
local ptag=""
|
||||
[[ "$field" == *"="* ]] && ptag="${field#*=}"
|
||||
_cfg_provider_keys "$ptag" ;;
|
||||
esac
|
||||
else
|
||||
_cfg_key_line "$field"
|
||||
@@ -294,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 <text> to <width> columns, prefixing EVERY line with <indent>
|
||||
# (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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Optional shell hook for pos ai * --last: auto-captures terminal output.
|
||||
# Usage: add to ~/.bashrc:
|
||||
# source /usr/local/bin/pos-ai-hook.sh
|
||||
# — or —
|
||||
# source /path/to/Linux_post_install/lib/pos-ai-hook.sh
|
||||
#
|
||||
# After sourcing, every command's stdout+stderr is silently tee'd to
|
||||
# ~/.local/share/linux_post_install/last_cmd_output (truncated at 1 MB).
|
||||
# Then pos ai ask --last (or pos ai --provider openrouter ask --last) will
|
||||
# pick it up automatically — no 'capture' subcommand needed.
|
||||
# To disable: unset __POS_CAPTURE_ACTIVE
|
||||
|
||||
__POS_CAPTURE_FILE="${HOME}/.local/share/linux_post_install/last_cmd_output"
|
||||
__POS_CAPTURE_MAX=${__POS_CAPTURE_MAX:-1048576} # 1 MB, override with env
|
||||
|
||||
# Truncate if oversized (keep last half)
|
||||
if [ -f "$__POS_CAPTURE_FILE" ]; then
|
||||
__sz=$(stat -c%s "$__POS_CAPTURE_FILE" 2>/dev/null || echo 0)
|
||||
if [ "$__sz" -gt "$__POS_CAPTURE_MAX" ]; then
|
||||
tail -c $((__POS_CAPTURE_MAX / 2)) "$__POS_CAPTURE_FILE" > "${__POS_CAPTURE_FILE}.tmp" 2>/dev/null
|
||||
mv -- "${__POS_CAPTURE_FILE}.tmp" "$__POS_CAPTURE_FILE"
|
||||
fi
|
||||
else
|
||||
: > "$__POS_CAPTURE_FILE"
|
||||
fi
|
||||
|
||||
# Only activate in interactive terminals, not already redirected
|
||||
if [ -t 1 ] && [ -t 2 ] && [ -z "${__POS_CAPTURE_ACTIVE:-}" ]; then
|
||||
export __POS_CAPTURE_ACTIVE=1
|
||||
exec > >(tee -a "$__POS_CAPTURE_FILE" 2>&1) 2>&1
|
||||
fi
|
||||
+199
@@ -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 <cat> tool keys in a category
|
||||
# reg_lookup <tool> <field> field: cat|desc|flags|subcmds|deps|examples
|
||||
# reg_config_scopes sorted config scope names
|
||||
# reg_config_keys <scope> key|flags|desc lines
|
||||
# reg_config_envfile <scope> env-file basename for a scope
|
||||
# reg_each <callback> cb(category, tool_key, description)
|
||||
# reg_tool_exists <tool> 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}" ]
|
||||
}
|
||||
@@ -39,6 +39,7 @@ PACKAGES=(
|
||||
python3 python3-pip rclone
|
||||
ffmpeg
|
||||
libqrencode4 libgtk-3-0 adb
|
||||
xdotool xclip
|
||||
)
|
||||
|
||||
spawn "apt update" sudo apt update
|
||||
|
||||
+42
-16
@@ -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-<cat>, e.g. pos-config) get an empty cat.
|
||||
# tooldisp <cat> <sub> → 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-<cat>-<sub>-<extra> → "extra" completes under <cat>-<sub>).
|
||||
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
|
||||
|
||||
@@ -9,6 +9,9 @@ set -euo pipefail
|
||||
# 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. Exec bit: chmod +x bin/pos-<category>-<command>
|
||||
# 4. If it reads stdin (password/selection prompts), add it to
|
||||
# INTERACTIVE_CMDS in bin/pos or its prompt breaks under the log tee.
|
||||
|
||||
Reference in New Issue
Block a user