Compare commits

..

22 Commits

Author SHA1 Message Date
Your Name e969234ca5 feat: command registry, alias wrapper scripts, config-ui readability
gates / consistency-and-conventions (push) Successful in 1m28s
- lib/registry.sh: shared query API over POS_* headers (reg_scan, reg_list,
  reg_lookup, reg_tools_in, reg_each, reg_config_scopes/keys). Replaces
  per-consumer sed/grep header parsing.

- bin/pos-tree + bin/pos _pos_category_help(): migrated to registry API.
  Category help now shows [deps: ...] annotations. Tree output preserved.

- New optional headers # POS_DEPS: and # POS_EXAMPLES: in tool metadata.
  Added to pos-network-download (aria2c jq curl), pos-media-sync (lsblk jq),
  pos-system-backup (tar), pos-docker-ps (docker) as initial adopters.

- scripts/gen-docs.sh: extended tools array with deps/examples fields;
  conditional column rendering in gen_dispatch; deps annotation in gen_tree.
  Fixed URL-unsafe // joiner (→ middle dot ·) and \x1f caption delimiter
  collision in config-ui.

- bin/pos-ai-alias: rewrote activation from bash aliases (source-time-frozen)
  to executable wrapper scripts at ~/.local/bin. Staleness eliminated:
  edits apply on next invocation with no shell reload. _alias_sync()
  reconciliation on every subcommand, marker-guarded lifecycle, collision
  refusal, legacy .sh retirement. Fixed dup-table bug (option 4 no-op).

- lib/config-ui.sh: @caption/@[KEY=alt] conditional captions, *providers=<tag>
  tagged wildcards, uniform typography tier (bold/cyan/dim), honest prompt.
  Active provider keys bold, inactive dimmed with reason. Backward-compatible.

- bin/pos-system-uninstall: marker-scan for wrapper script cleanup.

- Docs synced: AGENTS.md (new headers + registry), DOC/SCRIPTS.md (registry
  section + lib list), DOC/POS.md (alias wrapper activation), MAINTENANCE.md
  (M-024). Lint fixed: pos-ai-alias registered in INTERACTIVE_CMDS.

Gates: make gen && make check && make lint = 0 FAIL, 0 WARN
2026-08-27 02:30:27 -04:00
Your Name 4fd3c37c40 update docs
gates / consistency-and-conventions (push) Failing after 16s
2026-08-26 07:28:03 -04:00
Your Name 5d7407e30f chore: update docmap + filetable for registry.sh addition 2026-08-26 06:23:35 -04:00
Your Name 6566c8343d chore: re-gen docs for pos-ai-alias addition 2026-08-26 06:02:10 -04:00
Your Name 9f289ba31b feat: pos ai alias — manage AI agent aliases
gates / consistency-and-conventions (push) Failing after 22s
- Create/edit/remove named aliases (provider + session + system prompt)
- Aliases stored in ai-aliases.env, generated ai-aliases.sh sourced by bashrc
- Interactive menu using lib/menu-lib.sh primitives
- Provider auto-discovered from lib/ai-providers/

Fix: _alias_find() return 1 crashed under set -e; changed to return 0
since -1 sentinel is the not-found signal, not the exit code.
2026-08-26 06:00:01 -04:00
Your Name a5c19e842d revert: remove e(dit) option from AI command prompt
gates / consistency-and-conventions (push) Successful in 1m40s
Keep only Y/n (run or skip). The edit feature was unreliable across
different terminal contexts (tee pipes, SSH, CLI). May revisit later.
2026-08-26 04:58:08 -04:00
Your Name d84a35efce fix: read -e -i stores into variable directly, not stdout
gates / consistency-and-conventions (push) Successful in 2m34s
edited="\$(read ...)" was always empty because read writes to a variable
name, not stdout. Changed to: read -e -p "Command: " -i "\$flat" edited
which stores directly into \$edited.
2026-08-26 04:42:18 -04:00
Your Name 9564880ebf fix: AI command edit - flatten multi-line for readline
gates / consistency-and-conventions (push) Successful in 1m47s
read -e -i only handles single-line text. Multi-line commands (docker
install etc) broke it. Now flattens newlines to spaces before pre-filling
the readline buffer. User sees a single editable line.
2026-08-26 04:37:52 -04:00
Your Name d0299d3f98 feat: AI command edit via clipboard + xdotool fallback
gates / consistency-and-conventions (push) Successful in 2m14s
- _inject_command tries: xclip/wl-copy (clipboard) -> xdotool (typing) -> tmux -> history
- Clipboard is primary: user pastes with Ctrl+Shift+V
- preinstall.sh: add xdotool and xclip to PACKAGES
2026-08-26 04:13:36 -04:00
Your Name c1f1c4109f feat: AI command prompt adds e(dit) option with keyboard simulation
gates / consistency-and-conventions (push) Successful in 2m11s
- e: xdotool type (X11/Wayland) -> tmux send-keys -> history fallback
- Command appears on active terminal line for editing before Enter
- Y/Enter: execute, n: add to history
2026-08-26 03:51:16 -04:00
Your Name 710b626f47 feat: AI command prompt - run or edit detected shell commands
gates / consistency-and-conventions (push) Successful in 2m6s
- _extract_commands() parses bash/sh/shell fenced code blocks
- _prompt_run_command() prompts [Y/n] via /dev/tty after AI response
- Y/Enter: execute via run helper (respects DRY_RUN)
- n: command added to history (press up-arrow to recall, edit, run)
- Integrated in both cmd_ask() and cmd_chat()
- Skipped when output is piped/redirected
2026-08-26 03:26:11 -04:00
Your Name 1c19c0de59 fix: _cfg_provider_keys path resolution for installed layout
gates / consistency-and-conventions (push) Successful in 1m47s
Try both repo (../lib/ai-providers/) and installed (./ai-providers/) paths.
Installed layout copies ai-providers/ to same dir as config-ui.sh.
2026-08-26 03:07:29 -04:00
Your Name e0c9ba384a feat: dynamic provider config — pos config ai auto-discovers provider keys
gates / consistency-and-conventions (push) Successful in 1m30s
- lib/ai-providers/*.sh declare # PROVIDER_CONFIG: headers
- lib/config-ui.sh: _cfg_provider_keys() scans providers at runtime
- bin/pos-ai: POS_CONFIG uses *providers marker (no hardcoded keys)
- Adding a new provider auto-populates config UI — no main tool edits needed
2026-08-26 02:58:45 -04:00
Your Name 7ae2e77a44 fix: ai — per-provider API keys (remove shared AI_API_KEY)
gates / consistency-and-conventions (push) Failing after 11s
Each provider now has its own key: AI_GEMINI_API_KEY and OPENROUTER_API_KEY.
No more shared AI_API_KEY that caused cross-provider key leakage (gemini
getting openrouter key → 400 error). resolve_key() sets AI_API_KEY internally
from the active provider's key for adapter use. Config UI shows both keys.
2026-08-26 02:36:48 -04:00
Your Name 88ea660891 feat: system uninstall — safe interactive pos toolkit remover
gates / consistency-and-conventions (push) Successful in 2m4s
- Three tiers: binaries/services/shell (default), config (--config), data (--data)
- Interactive scan + numbered plan display, confirm per tier
- --yes skips prompts (tier 1 only); --yes --config --data = nuclear
- Shell integration cleanup: bashrc PATH/completion/hook entries
- Systemd services disabled and stopped
- Idempotent, never removes git repo
2026-08-25 11:00:05 -04:00
Your Name 1fbdf7ef2d fix: ai — config UI pipe-in-description bug + render tty detection with shell hook
gates / consistency-and-conventions (push) Failing after 11s
- POS_CONFIG header: replace | with 'or' in AI_PROVIDER description
  (bare | was parsed as field separator, splitting one entry into two)
- render_markdown: check /dev/tty as fallback when shell hook redirects
  stdout through tee (breaks [ -t 1 ] but /dev/tty stays writable)
2026-08-25 10:04:10 -04:00
Your Name 4f79ce123f refactor: ai — merge gemini/openrouter into unified plugin architecture
gates / consistency-and-conventions (push) Successful in 1m59s
- bin/pos-ai: single provider-agnostic tool (ask/chat/sessions/capture/models/providers)
- lib/ai-providers/gemini.sh: Gemini adapter (59 ln)
- lib/ai-providers/openrouter.sh: OpenRouter adapter (59 ln)
- bin/pos-ai-gemini/openrouter: thin forwarders for backward compat
- Provider adapter interface: provider_name/default_model/generate/models_list
- Unified session format (OpenAI messages), auto-migrate old gemini contents
- Config: AI_PROVIDER/AI_API_KEY/AI_MODEL/AI_SYSTEM_PROMPT in ai.env
- Config fallback: AI_API_KEY → provider-specific env var → error
- Default system prompt configurable via AI_SYSTEM_PROMPT
- New subcommand: pos ai providers (lists providers + config status)
- Shell hook (pos-ai-hook.sh) for auto-capture
2026-08-25 09:57:10 -04:00
Your Name f0ef13827b fix: ai --last — prefer newer source (auto-capture beats stale pos logs)
gates / consistency-and-conventions (push) Failing after 14s
--last now compares mtime of pos dispatcher logs vs captured output
(last_cmd_output) and uses whichever is newer, instead of always
preferring pos logs even when they are hours old.
2026-08-25 08:49:36 -04:00
Your Name 4af097f5eb docs: sync POS/AGENT_Context/completions for share, vbox, ai features
gates / consistency-and-conventions (push) Successful in 2m28s
- POS.md: new openrouter rows, updated share/vbox entries
- AGENT_Context_Project.md: GEN tree/dispatch/filetable/docmap resync
- completions/pos.bash: new flags/subcommands for openrouter + capture
- bin/pos: INTERACTIVE_CMDS += ai-openrouter (stdin reader)
2026-08-25 08:40:05 -04:00
Your Name 476173ba83 feat: ai — gemini terse+render+last+session+machine, openrouter new tool, capture any command
gemini enhancements:
- built-in terse system prompt with troubleshooting clause + machine context
- markdown→terminal rendering (glow opportunistic + zero-dep awk fallback)
- --last: pos logs + captured output fallback, staleness warning, stderr annotations
- session default always on; --session override; answer separation on tty
- --full flag, --system wholesale override

openrouter (new tool):
- cloned from gemini, adapted for OpenAI-compatible REST API
- Bearer auth, messages array, choices[0].message.content parsing
- config: pos config ai-openrouter → OPENROUTER_API_KEY/MODEL
- default model: openrouter/auto (auto-picks best model)
- all features: ask, chat, sessions, --last, capture

capture subcommand (both tools):
- runs any command, tees output to last_cmd_output for --last
- --last fallback: pos logs (priority) → last_cmd_output (secondary)

shell hook (optional):
- lib/pos-ai-hook.sh: sourceable .bashrc snippet for auto-capture
- exec > >(tee ...) with 1 MB truncation
2026-08-25 08:39:55 -04:00
Your Name 0aaa25150c feat: vbox — categorized create UI with GPU/device/mount/port presets
- category hub with basket counts, review screen, single confirm
- GPU: nvidia-smi → /proc/driver/nvidia → vendor scan detection
- host devices: lsusb/tty/video/snd/lsblk + manual input, dedupe
- dir mounts with (system disk — careful) labels
- SHOULD tier: image/ports/cpus/mem, flag contract --gpu/--device/--dir/--port/--cpus/--memory/--network
- zero-flag run byte-identical to pre-edit
- cmd_unpersist not-found exit 0 → return 0 in both clients
- DOC/howto/docker.md: vbox categorized create section
2026-08-25 08:39:42 -04:00
Your Name 4173fc61e3 feat: share clients — picker enhancements, unmount fixes, confirm default-y convention
- mountpoint picker: synthetic (as on server) candidate + n=new mkdir flow
- unmount-by-pick via findmnt enumeration with confirm
- cmd_unmount idle-persisted branch exit 0 → return 0 + actionable guidance
- all menu handlers normalized … || true
- confirm() rewrite: default-y on Enter, EOF fail-closed, case-insensitive
- latent compose "Y" bug fixed
- DEV.md convention doc for confirm semantics
2026-08-25 08:39:29 -04:00
38 changed files with 4230 additions and 585 deletions
+2 -2
View File
@@ -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.
+1
View File
@@ -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 60120 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.
+85 -68
View File
@@ -10,19 +10,19 @@
<!-- GEN:START docmap -->
| ## 1. Project Overview | 2843 |
| ## 2. Directory Structure | 44196 |
| ## 3. Installation Flow | 197250 |
| ## 4. The `pos` CLI System | 251324 |
| ## 5. Shared Library — `lib/common.sh` | 325356 |
| ## 6. Docker Compose / ScaleTail | 357399 |
| ## 7. Optional Apps (`apps/`) | 400429 |
| ## 8. Entertainment Module | 430443 |
| ## 9. Systemd Services | 444455 |
| ## 10. Configuration Files | 456482 |
| ## 11. Coding Conventions | 483515 |
| ## 12. Development Workflow | 516568 |
| ## 13. Key File Quick Reference | 569635 |
| ## 14. Common Tasks for Agents | 636669 |
| ## 2. Directory Structure | 44204 |
| ## 3. Installation Flow | 205258 |
| ## 4. The `pos` CLI System | 259336 |
| ## 5. Shared Library — `lib/common.sh` | 337368 |
| ## 6. Docker Compose / ScaleTail | 369411 |
| ## 7. Optional Apps (`apps/`) | 412441 |
| ## 8. Entertainment Module | 442455 |
| ## 9. Systemd Services | 456467 |
| ## 10. Configuration Files | 468494 |
| ## 11. Coding Conventions | 495527 |
| ## 12. Development Workflow | 528580 |
| ## 13. Key File Quick Reference | 581652 |
| ## 14. Common Tasks for Agents | 653686 |
<!-- 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+11
View File
@@ -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.
+19 -12
View File
@@ -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
View File
@@ -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
+658
View File
@@ -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
View File
@@ -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 "$@"
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+1
View File
@@ -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"
+435
View File
@@ -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
View File
@@ -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
+7 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+63
View File
@@ -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
}
+63
View File
@@ -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
View File
@@ -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
View File
@@ -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 60120 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
+32
View File
@@ -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
View File
@@ -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}" ]
}
+1
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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.