Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99f87e6042 | |||
| e6fa0a4ee9 | |||
| 4306a53fef | |||
| 300b742ac8 | |||
| 59935dc5ef | |||
| e969234ca5 | |||
| 4fd3c37c40 | |||
| 5d7407e30f | |||
| 6566c8343d | |||
| 9f289ba31b | |||
| a5c19e842d | |||
| d84a35efce | |||
| 9564880ebf | |||
| d0299d3f98 | |||
| c1f1c4109f | |||
| 710b626f47 | |||
| 1c19c0de59 | |||
| e0c9ba384a | |||
| 7ae2e77a44 | |||
| 88ea660891 | |||
| 1fbdf7ef2d | |||
| 4f79ce123f | |||
| f0ef13827b |
@@ -14,8 +14,8 @@ CRITICAL: real guidance lives in DOC/. When you encounter a reference below, use
|
||||
|
||||
## Quick facts
|
||||
|
||||
- **Tool model:** `bin/pos-<category>-<command>`, or **category-less** `bin/pos-<cmd>` for dispatcher/dev-level commands that fit no category (`pos-config`, `pos-tree`) — they dispatch like any tool and show with an empty category in the generated tables. `bin/pos` dispatches by longest-prefix arg matching. New tools are auto-discovered but must be executable (`100755`) and carry a `# POS: <cat> <cmd> — <desc>` header right after the shebang; `make gen` only uses the text after the first `— ` (the leading words are convention-only), so keep the one-line description concise. `# POS_FLAGS:` / `# POS_SUBCMDS:` / `# POS_CONFIG:` headers feed tab-completion and the `pos config` scope registry. A missing `# POS:` header hard-fails `make gen`. Legacy `bin/wr-*`, `mp3`, `mp4`, `vbox`, `ssh-load-all` are thin forwarders to `pos` — keep them that way.
|
||||
- **Categories:** `ai`, `communication`, `docker`, `entertainment`, `media`, `network`, `share` (usb, nfs, smb), `ssh`, `system`, plus category-less `config`/`tree`. `pos tree` (bin/pos-tree) is the authoritative structure — it derives the hierarchy from `bin/pos-*` filenames + `# POS:`/`# POS_SUBCMDS:` headers.
|
||||
- **Tool model:** `bin/pos-<category>-<command>`, or **category-less** `bin/pos-<cmd>` for dispatcher/dev-level commands that fit no category (`pos-config`, `pos-tree`) — they dispatch like any tool and show with an empty category in the generated tables. `bin/pos` dispatches by longest-prefix arg matching. New tools are auto-discovered but must be executable (`100755`) and carry a `# POS: <cat> <cmd> — <desc>` header right after the shebang; `make gen` only uses the text after the first `— ` (the leading words are convention-only), so keep the one-line description concise. `# POS_FLAGS:` / `# POS_SUBCMDS:` / `# POS_CONFIG:` headers feed tab-completion and the `pos config` scope registry; optional `# POS_DEPS: <binary…>` declares space-separated runtime binaries the tool hard-requires via `command -v` guards, and optional `# POS_EXAMPLES: <command> | <description>` adds curated usage examples, one per line. `lib/registry.sh` is the shared query API over all `POS_*` headers — consumers source it (`reg_scan` + `reg_list`/`reg_lookup`/…) instead of re-implementing sed/grep header parsing; new consumers should prefer it. A missing `# POS:` header hard-fails `make gen`. Legacy `bin/wr-*`, `mp3`, `mp4`, `vbox`, `ssh-load-all` are thin forwarders to `pos` — keep them that way.
|
||||
- **Categories:** `ai`, `communication`, `docker`, `entertainment`, `media`, `network`, `share` (usb, nfs, smb), `ssh`, `system`, plus category-less `config`/`tree`. `pos tree` (bin/pos-tree) is the authoritative structure — it derives the hierarchy from `bin/pos-*` filenames + `# POS:`/`# POS_SUBCMDS:` headers, reads the metadata through `lib/registry.sh`, and annotates each command's declared `# POS_DEPS:`.
|
||||
- **Generated code:** blocks between `GEN:START`/`GEN:END` markers in `DOC/AGENT_Context_Project.md` (tree, dispatch, selfcontained, filetable, docmap) and `completions/pos.bash` (flags, subcmds, config scopes) are `make gen` output — never hand-edit them. Generators must be **byte-order deterministic** (sort with `LC_ALL=C`, as `scripts/gen-docs.sh` does) or CI's `git diff --exit-code` trips on a locale that collates differently. After touching `bin/pos-*`, run `make gen`, then `make check`, then `make lint` (definition of done: check green + lint ends `0 FAIL, 0 WARN`). `make check` (`scripts/check-sync.sh`) is the self-consistency gate — bash -n + exec-bit check + doc-sync + dispatch smoke; `make lint` (`scripts/lint-conventions.sh`) is the convention gate — it enforces every rule in this file (shebang/strict-mode, exec bits, `# POS:` headers, `-h|--help` present and after deps guards, stdin-readers in `INTERACTIVE_CMDS`, POS.md coverage, plugin/app/unit/wrapper/secrets/env-seam classes — see `DOC/DEV.md → Convention Lint Gate`). Hand-maintained, not gen-checked: `DOC/POS.md`, the line-count rows above the filetable marker in `DOC/AGENT_Context_Project.md` (the non-`pos-*` files — `install.sh`, `preinstall.sh`, `postinstall.sh`, `lib/*`, `features/*`; bump a row's count only when that file's length changes), `bin/pos` usage() EXAMPLES, root README. CI (`.gitea/workflows/lint.yml`, job `gates`) runs the same four commands on every push to main and PR, then records the result as a git tag on the commit: `ci-ok/<sha>` or `ci-fail/<sha>` (pushes only — query with `scripts/ci-status.sh [--wait] [<sha>]`; exit 0 green / 1 red / 2 pending). A red run means gen drift or a gate failure and is a merge-blocker; still run the gates locally too (lint isn't in the pre-commit hook).
|
||||
- **Stdin gotcha:** any tool that reads stdin must be added to `INTERACTIVE_CMDS` in `bin/pos` — otherwise the logging `tee` pipe hangs on (or swallows) the prompt.
|
||||
- **Deps:** apt packages → `PACKAGES` array in `preinstall.sh`; non-apt/manual installers (e.g. `usbsrv`) → `command -v <bin> || err "…"` guard inside the tool, never in PACKAGES. Hotspot binaries (`create_ap`, `wihotspot*`) are prebuilt in `x64_bin/` (or `arm64_bin/`) and copied by `install.sh` — not apt packages.
|
||||
|
||||
@@ -134,3 +134,7 @@ summary (newest last).
|
||||
- **2026-08-23** — ytsync menu render bugfix (`bin/pos-media-ytsync`, live-box report): `cut -d'·'` at :292/:301 used U+00B7 = 2 bytes UTF-8 (GNU cut is byte-oriented → "delimiter must be a single character", masked by `|| true` so the `· last run …` suffix and LAST SYNC column never rendered); replaced with grep/tail capture + `${last%% ·*}` parameter expansion (semantics identical incl. empty-string=no-last-run); :1053 `printf '----…\n'` format starting with `-` parsed as invalid option → `printf '%s\n' '----…'`. Chain: Detective root cause (`reportAgents/2026-08-23-detective-ytsync-menu-errors.md`) → Builder 3-site fix (`-builder-ytsync-menu-fix.md`, pty probe: suffix + separator render, zero stderr noise) → Reviewer ACCEPT-WITH-NITS (`-reviewer-ytsync-menu-fix.md` delivered inline). Gates re-run by Orchestrator post-review: `make gen` idempotent, check OK, lint 0 FAIL / 0 WARN.
|
||||
- **2026-08-23** — Menu Phase 1 (user-ratified decision "b"): category-neutral menu library extracted from share-suite Pattern B + four P1 tool menus. New `lib/menu-lib.sh` (169 ln): `menu_guard`/`menu_run`/`menu_pick`/`menu_ask_value` (stderr render, /dev/tty reads, EOF fail-closed rc=1, index/value→stdout); `lib/share-lib.sh` (436→318) keeps its public names as pure delegating shims so all five `pos share *` tools stay untouched; install.sh Phase-2 explicit lib list += menu-lib.sh. Opt-in no-args+tty front doors (or `menu` verb, `# POS_SUBCMDS:` registered, completions regen'd) on `pos media sync` (164→216: Sync-now/Preview/mp3/mp4/source-folder items), `pos system backup` (216→292: typed/service-root/plain variants, every backup behind folder-naming y/N), `pos docker compose` (366→487: ls/up/down/restart/logs/update/config items, down/restart/update confirm-gated naming the stack), `pos system schedule` (81→151: list/status/run-now(confirm)/enable/disable/editor — timer-invoked `run <name>` verb dispatch byte-identical to HEAD). INTERACTIVE_CMDS unchanged; all CLI verbs byte-compatible. Docs: POS.md ×4 rows, DEV.md lib row, SCRIPTS.md sections, AGENT_Context rows + GEN. Chain: Explorer survey (37 tools, `reportAgents/2026-08-23-explorer-pos-menu-survey.md`) → Designer classification (`-designer-pos-menu-suitability.md`: 14 MENU-FIT / 7 CONDITIONAL / 16 NO-FIT) → Builder T1/T2/T3 (`-builder-t1-menu-lib-extraction.md`, `-t2-p1-menus-media-backup.md`, `-t3-p1-menus-compose-schedule.md`; T3 discloses a mid-verify symlink clobber restored+re-verified) → Reviewer ACCEPT_WITH_NOTES over the consolidated diff (`-reviewer-phase1-menu.md`, T3 integrity clean). Verified: bash -n ×7, pty probes (render/quit/EOF/non-tty fail-closed/destructive prompt-abort), gates green after each pass and re-run by Orchestrator post-review (`make gen` idempotent · `make check` OK · `make lint` 0 FAIL / 0 WARN). Open for later phases: P2 (docker-vbox, network-download), firewall style-migration decision, usb-server `menu` in POS_FLAGS nit (owning track).
|
||||
- **2026-08-23** — Menu Phase 2 + firewall style-migration (decision "a" activated: P1 landed, `lib/menu-lib.sh` exists). `pos docker vbox` (157→261): 6-item menu hub over the inline case verbs via a quoted self-invocation `menu_self` (verbs never re-enter the menu → no recursion); `enter` hands over the terminal and returns to the loop; rm/create behind VM-naming y/N. `pos network download` (950→1104): 13-item top-verb map onto existing cmd_* fns — add URL (`menu_ask_value`, optional `--tmux`), gid-pick → info/pause/resume/remove/restart (remove names name+gid before delete), typed-confirm purge, watch handover, daemon start/stop (stop confirmed); non-fatal RPC liveness gate (`-m 3`) keeps queue views alive on a dead daemon; deliberately NOT added to INTERACTIVE_CMDS — menu-lib's tty-guarded reads make membership unnecessary and keep tee-logging for all scripted verbs (survey E-002; Reviewer traced the lint pass as honest through `uses_stdin`). `pos system firewall` (308→325) migrated to repo-standard mechanics ONLY: menu heredoc render → stderr `{ … } >&2` (body byte-preserved), all **38** interactive reads → `/dev/tty` via tool-local `tty_read()` (EOF/no-tty → pointer + rc1, never hangs), `prompt_ipver` de-command-substituted so EOF exits gracefully; root gate / per-cmd confirm / typed RESET / pager / notify / every ufw invocation untouched. Both new tools register `# POS_SUBCMDS:` += `menu`; POS.md rows updated; GEN regen'd. Chain: Builder T4 (`reportAgents/2026-08-23-builder-t4-p2-menus-vbox-download.md`; correctly caught an Orchestrator brief error claiming download was in INTERACTIVE_CMDS) + T5 (`-t5-firewall-menu-migration.md`; pty parity captures vs pre-edit baseline) → Reviewer ACCEPT-WITH-NITS over both (`-reviewer-phase2-menu.md`, transcribed by Orchestrator; recursion/injection analysis, 13/13 mapping proof, four T5 intents verified hunk-by-hunk). Verified: bash -n ×3 + gates green after each pass; final trio re-run by Orchestrator post-T5 — `make check` OK · `make lint` 0 FAIL / 0 WARN (76s under box load ~7; the earlier apparent lint hang was shared-box CPU contention, no code issue). Remaining notes for later sessions: errexit kills whole menu when a backing verb hard-fails (repo-wide pattern, all six menus); `confirm()` EOF hits set-u unbound `yn` (pre-existing common.sh); vbox create EOF at dir prompt degrades to default while name/image prompts abort (cosmetic).
|
||||
- **2026-08-26** — `pos ai alias` activation rework (Option B) + `pos config` listing readability, per the 2026-08-26 Architect/Designer specs (`AgentsReport/{architect,designer}/2026-08-26-*.md`). **Alias activation:** the stale sourced-snapshot mechanism is gone — every `pos ai alias` invocation runs `_alias_sync()` (two-way reconciliation: render-diff-install of one executable wrapper per ENV record at `~/.local/bin/<name>` chmod 755 via mktemp+mv with a `bash -n` pre-commit guard; marker-guarded deletion of owned wrappers missing from ENV; legacy `ai-aliases.sh` generation stopped and generator-marker-guarded auto-removal with an `unalias <names>` remediation hint; loud PATH guidance when `~/.local/bin` is off PATH). Edits are live on next invocation with no shell reload (kills the reported stale-gemini-alias bug class); create refuses foreign-file and PATH-binary collisions; `show` gains the wrapper path; `pos-system-uninstall` sweeps the wrappers by their line-2 marker in discovery+removal. Dup-table menu bug fixed with a single `_alias_table` renderer (menu option 4 returns to the loop whose pre-render already shows fresh state). **Config readability** (`lib/config-ui.sh`, fully generic): new optional `# POS_CONFIG:` field types — `@Caption` / `@[KEY=v1|v2] Caption` group captions (condition evaluated per render via `cfg_value`; inactive groups dimmed with a textual reason, never hidden → numbering stable; empty-alt segment = unset-as-default) and `*providers=<tag>` adapter filtering (zero match warns once + suppresses its caption); uniform typography tier for ALL scopes (bold title/keys, CYAN rule, dim numbers/placeholders/examples/captions, hanging-indent wrap clamped 60–120 cols, whole render block → stderr per menu-lib house pattern, honest prompt `Number to edit [r=refresh, q=quit]:`); masking/edit flow byte-compatible, no per-scope branches. `bin/pos-ai` line-6 header adopted to the caption/tag syntax (single-line change). Verified: stub-PATH harness (`HOME=/tmp/…`, `CONFIG_DIR` seam, argv-capturing `pos` shim) covering %q quoting round-trips (quotes/backticks/`$()`/%/unicode), staleness kill-test, orphan retraction, collision-refusal matrix, legacy migration (marker + foreign), PATH-absent warning, non-tty guard, idempotent double-sync; rendered-output diffs vs Designer mockups for `ai` AND old-format `system`; gates `make gen && make check && make lint` 0 FAIL / 0 WARN.
|
||||
- **2026-08-27** — Critical fix: paste injection + multiline paste in `pos ai alias`'s Insert Prompt (root cause: `menu_ask_value` → plain line-oriented `read -rp`; a multi-line Ctrl+V paste floods the tty queue, `read` consumes only line one and the rest execute as commands later or get eaten by the next prompt — user-verified `$(whoami)`/`; ls`/`sudo apt update` behavior). New `menu_read_value()` in `lib/menu-lib.sh` (169→362): raw-mode (`stty -icanon -echo -isig min 1 time 0`) bracketed-paste-aware value reader — `\e[?2004h/l` markers, text inside `[200~…[201~` inserted LITERALLY (embedded newlines/CR are data), Enter submits only outside a paste, Backspace/DEL/Left/Right/Home/End/Delete/Ctrl-U edit, Ctrl-D-on-empty + Ctrl-C/Z/\ cancel (terminal restored first); bytes read chunk-wise via `dd bs=4096|od -tx1|tr` — NOT bash's `read` builtin, which self-interrupts on an ETX byte from a tty even with ISIG disabled (SIGINTs the whole script on Ctrl-C); confirmed `read -erp` (readline) atomically consumes a paste but returns only its first line, so a custom reader was required. `bin/pos-ai-alias` (712→760): `_alias_prompt_encode/_decode` (backslash→`\\`, newline→`\n`; literal `[ = ]` comparisons — bash `case` patterns don't match a single backslash), `_alias_prompt_truncate` newline-safe + max-length arg; load/save encode/decode the prompt field; edit wizard shows a truncated display default but Enter restores the FULL original prompt (fixes pre-existing silent truncation of >80-char prompts), empty-original Enter continues. Verified: pty harnesses (`/tmp/pty_{menulib,cancel,e2e_alias}.py`, `/tmp/roundtrip_test.sh`) — bracketed multiline paste captured verbatim incl. `C:\temp\note`/`$(whoami)`/`; ls`/`echo test`/`sudo apt update`, nothing executed, clean exit; single-line paste; Ctrl-D and Ctrl-C both cancel cleanly (CANCELLED→DONE, terminal restored); full create→list→show→edit E2E with decode round-trip and Enter-keeps-full; `bash -n` ×2, `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
|
||||
- **2026-08-27** — Configurable AI-bridge trigger word for the Telegram listener: the hard-coded `ai ` prefix in `pos-communication-telegram-listener` became `TELEGRAM_AI_PREFIX` (default `ai`) — messages starting with `<prefix> ` (case-insensitive, literal match) are forwarded to Gemini. New `prefix` verb: `pos communication telegram listener prefix` shows the current word, `prefix <word>` sets it (validated `[A-Za-z0-9][A-Za-z0-9_-]*`, writes `TELEGRAM_AI_PREFIX` to `telegram.env` chmod 600); also editable via `pos config telegram` (field added to the sender's `# POS_CONFIG:` telegram scope — registry-driven, no code in config-ui). Matching is per-message hot-reloaded (like the command map — no daemon restart), via scoped `shopt -s nocasematch` + quoted-literal `=~` prefix (bash `case` patterns can't do literal-then-whitespace + case-insensitivity in one test); `ai_bridge_prefix()` precedence: telegram.env > env from load_config > default `ai`. `--status` shows the current prefix; usage + `# POS_SUBCMDS: prefix` added (completions regenerate). Preserved edge: bare `ai` (no trailing space) never matched the old regex, so it still falls through to "Unknown command". Docs: POS.md listener rows/paragraph, howto/ai.md Telegram section + troubleshooting (also corrected a stale claim that AI errors reply with a `pos config ai` hint — code replies `AI error: …` only). Verified: function-level routing harness (`/tmp/ai_prefix_routing_test.sh` — extraction of the real listener functions + PATH stub `pos`): default `ai`/`AI` routes, bare-prefix and unknown-command fallthrough, `ai /reset` and custom-`bot` `/reset` reset the session, custom `bot`/`BOT` routes and old `ai` no longer routes, per-message hot-reload after removing the var; CLI verb tests (show/set/invalid rc 1/leading-digit/`--status`); dispatch smoke `pos communication telegram listener prefix` + flat form; `pos config telegram` render shows the field; `bash -n` ×2, `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
|
||||
- **2026-08-29** — Generic text-prefix map for the Telegram listener (user's clarification superseding the scalar `TELEGRAM_AI_PREFIX` setter): `bin/pos-communication-telegram-listener` (623→782) now routes any non-command message `<word> <text>` to a mapped command with `<text>` appended as ONE quoted argument — `opencode=opencode` turns "opencode check cpu" into `opencode "check cpu"`. New map file `telegram_prefixes.env` (chmod 600, re-read per message, `@quiet` values, 120s cap, empty→`OK`, `exit <rc>` reply, syntax-checked on save, first-file-match wins, case-insensitive, word must be space-delimited so bare `<word>` still falls through). Routing order: text-prefix map → built-in Gemini `ai` bridge → `/command` map → Unknown (a mapped `ai` shadows the bridge). `prefix` verb reworked: bare = list map + bridge word; `prefix <word> <command...>` = map (validated `[A-Za-z0-9][A-Za-z0-9_-]*`, `bash -n` via check_syntax); `prefix <word>` = show one; `prefix -r <word>` = remove; the AI-bridge word itself is now set ONLY via `pos config telegram` (`TELEGRAM_AI_PREFIX`, default `ai` — `--status` + bare `prefix` still display it). `run_and_reply()` extracted to share `/command`-map (60s) and prefix (120s) execution semantics; dispatch passes `"${@:2}"`. Docs: POS.md listener rows/paragraph, howto/communication.md bullet, howto/ai.md (prefix-map + shadowing), usage(), `# POS:` header, AGENT_Context regen. Verified: routing harness `/tmp/prefix_map_routing_test.sh` 27/27 (ai-bridge regression incl. `/reset`, opencode remainder=ONE arg, case-insensitivity, bare/trailing-space fallthrough, shadowing, no partial-prefix false match, exit/OK/@quiet/env-expansion, /command-map regression via run_and_reply); CLI verb suite (set/show/remove/missing rc 1/invalid word rc 1/invalid cmd rc 1 — fixed latent `set -e` cmdsubst abort on syntax errors); dispatch smoke nested + flat + `--status`; `pos config telegram` render; `bash -n`, `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
|
||||
|
||||
@@ -10,19 +10,19 @@
|
||||
|
||||
<!-- GEN:START docmap -->
|
||||
| ## 1. Project Overview | 28–43 |
|
||||
| ## 2. Directory Structure | 44–197 |
|
||||
| ## 3. Installation Flow | 198–251 |
|
||||
| ## 4. The `pos` CLI System | 252–326 |
|
||||
| ## 5. Shared Library — `lib/common.sh` | 327–358 |
|
||||
| ## 6. Docker Compose / ScaleTail | 359–401 |
|
||||
| ## 7. Optional Apps (`apps/`) | 402–431 |
|
||||
| ## 8. Entertainment Module | 432–445 |
|
||||
| ## 9. Systemd Services | 446–457 |
|
||||
| ## 10. Configuration Files | 458–484 |
|
||||
| ## 11. Coding Conventions | 485–517 |
|
||||
| ## 12. Development Workflow | 518–570 |
|
||||
| ## 13. Key File Quick Reference | 571–638 |
|
||||
| ## 14. Common Tasks for Agents | 639–672 |
|
||||
| ## 2. Directory Structure | 44–204 |
|
||||
| ## 3. Installation Flow | 205–258 |
|
||||
| ## 4. The `pos` CLI System | 259–336 |
|
||||
| ## 5. Shared Library — `lib/common.sh` | 337–368 |
|
||||
| ## 6. Docker Compose / ScaleTail | 369–411 |
|
||||
| ## 7. Optional Apps (`apps/`) | 412–441 |
|
||||
| ## 8. Entertainment Module | 442–455 |
|
||||
| ## 9. Systemd Services | 456–467 |
|
||||
| ## 10. Configuration Files | 468–494 |
|
||||
| ## 11. Coding Conventions | 495–527 |
|
||||
| ## 12. Development Workflow | 528–580 |
|
||||
| ## 13. Key File Quick Reference | 581–652 |
|
||||
| ## 14. Common Tasks for Agents | 653–686 |
|
||||
<!-- GEN:END docmap -->
|
||||
|
||||
## 1. Project Overview
|
||||
@@ -61,16 +61,18 @@ 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, capture, chat, models, sessions)
|
||||
│ ├── pos-ai-openrouter # Chat with OpenRouter models (ask, capture, 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)
|
||||
│ ├── pos-communication-telegram-listener # Telegram bot listener: map /command → bash, run them on chat messages
|
||||
│ ├── pos-communication-telegram-listener # Telegram bot listener: map /command → bash and <prefix> → app, run them on chat messages
|
||||
│ ├── pos-communication-telegram-sender # Send Telegram messages/files/links/stickers via Bot API (send, test)
|
||||
│ ├── 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)
|
||||
@@ -81,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
|
||||
@@ -94,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 -->
|
||||
@@ -205,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)
|
||||
@@ -265,47 +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, capture, chat, models, sessions) |
|
||||
| ai | openrouter | `pos-ai-openrouter` | Chat with OpenRouter models (ask, capture, 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 and <prefix> → app, 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
|
||||
@@ -351,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 -->
|
||||
|
||||
---
|
||||
@@ -468,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
|
||||
@@ -584,24 +594,26 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `lib/user-timers-lib.sh` | 112 | Shared systemd **user** timer machinery (interval→OnCalendar, unit pair writer, linger) |
|
||||
| `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/menu-lib.sh` | 362 | 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 via raw-mode bracketed-paste-safe `menu_read_value`; 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` | 586 | Chat with Google Gemini (ask, capture, chat, models, sessions) |
|
||||
| `bin/pos-ai-openrouter` | 587 | Chat with OpenRouter models (ask, capture, chat, models, sessions) |
|
||||
| `bin/pos` | 302 | CLI dispatcher with smart arg matching + logging + category help |
|
||||
| `bin/pos-ai-alias` | 760 | 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) |
|
||||
| `bin/pos-communication-telegram-listener` | 566 | Telegram bot listener: map /command → bash, run them on chat messages |
|
||||
| `bin/pos-communication-telegram-listener` | 782 | Telegram bot listener: map /command → bash and <prefix> → app, run them on chat messages |
|
||||
| `bin/pos-communication-telegram-sender` | 221 | Send Telegram messages/files/links/stickers via Bot API (send, test) |
|
||||
| `bin/pos-docker-compose` | 487 | Docker Compose service manager (ls/up/down/restart/logs/update/config) |
|
||||
| `bin/pos-docker-health` | 107 | One-glance container health dashboard (exits 1 if unhealthy) |
|
||||
| `bin/pos-docker-ps` | 125 | Enhanced container overview (health, IPs, ports, uptime) |
|
||||
| `bin/pos-docker-ps` | 126 | Enhanced container overview (health, IPs, ports, uptime) |
|
||||
| `bin/pos-docker-stack` | 101 | Containers grouped by compose stack (project); standalone group; -a/--all includes stopped |
|
||||
| `bin/pos-docker-vbox` | 1125 | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) |
|
||||
| `bin/pos-entertainment-config` | 143 | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) |
|
||||
@@ -611,10 +623,10 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `bin/pos-entertainment-status` | 62 | Show enabled plugins and scheduler state |
|
||||
| `bin/pos-media-mp3` | 86 | Download audio as MP3 (yt-dlp) |
|
||||
| `bin/pos-media-mp4` | 132 | Download video as MP4 (smart/interactive format select) |
|
||||
| `bin/pos-media-sync` | 216 | Incremental Music → USB sync (mp3/mp4, add/update only) |
|
||||
| `bin/pos-media-sync` | 219 | Incremental Music → USB sync (mp3/mp4, add/update only) |
|
||||
| `bin/pos-media-ytsync` | 1191 | Incrementally sync YouTube channels/playlists into ~/Videos |
|
||||
| `bin/pos-network-checkport` | 496 | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view |
|
||||
| `bin/pos-network-download` | 1104 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) |
|
||||
| `bin/pos-network-download` | 1108 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) |
|
||||
| `bin/pos-network-hotspot` | 93 | Wi-Fi hotspot via create_ap + wihotspot-gui |
|
||||
| `bin/pos-network-ip` | 69 | Show interfaces, routes, public IP + location |
|
||||
| `bin/pos-network-scan` | 272 | Parallel ping sweep of CIDR |
|
||||
@@ -624,13 +636,15 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `bin/pos-share-smb-server` | 441 | Manage the Samba server (status, share/unshare exports, users, enable/disable) |
|
||||
| `bin/pos-share-usb-server` | 362 | USB Redirector server control (--ls, --share; prompts when args omitted) |
|
||||
| `bin/pos-ssh-load-keys` | 31 | Load all SSH keys into the agent |
|
||||
| `bin/pos-system-backup` | 292 | Encrypted (AES-256) folder snapshots (tar + gpg) |
|
||||
| `bin/pos-system-backup` | 293 | Encrypted (AES-256) folder snapshots (tar + gpg) |
|
||||
| `bin/pos-system-firewall` | 325 | Interactive UFW management |
|
||||
| `bin/pos-system-health` | 209 | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL |
|
||||
| `bin/pos-system-schedule` | 151 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
|
||||
| `bin/pos-system-uninstall` | 435 | Remove pos toolkit binaries, services, shell integration, config, and data |
|
||||
| `bin/pos-ai` | 692 | 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` | 305 | Dynamic bash completion |
|
||||
| `bin/pos-tree` | 118 | Show the pos CLI command tree: categories, commands, and subcommands |
|
||||
| `completions/pos.bash` | 308 | Dynamic bash completion |
|
||||
<!-- GEN:END filetable -->
|
||||
| `apps/install.sh` | 171 | App install/uninstall picker/orchestrator |
|
||||
|
||||
@@ -664,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` |
|
||||
|
||||
+4
-2
@@ -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` |
|
||||
@@ -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`.
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ templates (without overwriting an existing file):
|
||||
| `system.env` | `pos system health`, `pos system backup` | `BACKUP_SERVICE_ROOTS`, `HEALTH_BACKUP_MAX_AGE_DAYS` |
|
||||
| `compose.env` | `pos docker compose` | `TS_AUTHKEY`, `TZ`, `DNS_SERVER`, `SERVICES_BASE` |
|
||||
| `entertainment.env` | `pos entertainment *` | plugin keys (`WEATHER_LAT`…), `ENABLED` |
|
||||
| `ai.env` | `pos ai gemini` | `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL` |
|
||||
| `ai.env` | `pos ai` | `AI_PROVIDER`, `AI_API_KEY`, `AI_MODEL`, `AI_SYSTEM_PROMPT`, `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL` |
|
||||
| `schedule.d/` | `pos system schedule` | one `<name>.env` per job: `INTERVAL`, `NOTIFY`, `MSG`, `RULE`, `COMMAND` |
|
||||
|
||||
```bash
|
||||
|
||||
+45
-38
@@ -55,52 +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, four subcommands: `ask` (scriptable, persistent session), `capture` (run a command and save its output for `--last`), `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>"` | POSTs `generateContent` 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). 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 gemini 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 gemini chat` | Interactive REPL with multi-turn history (the `contents[]` 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 gemini sessions` | Lists session files with turn counts; `sessions reset <name>` clears one (e.g. `reset default`) |
|
||||
| `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>`).
|
||||
|
||||
**File:** `bin/pos-ai-openrouter`
|
||||
**Purpose:** chat with OpenRouter models via the REST API (`openrouter.ai`). One tool, four subcommands: `ask` (scriptable, persistent session), `capture` (run a command and save its output for `--last`), `chat` (interactive multi-turn REPL), and `sessions` (list/clear sessions). OpenRouter provides access to hundreds of models from different providers through a single OpenAI-compatible API.
|
||||
|
||||
| Command | Behavior |
|
||||
|---------|----------|
|
||||
| `pos ai openrouter ask "<prompt>"` | POSTs a chat completion 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-openrouter/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). 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 openrouter 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 openrouter 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 openrouter sessions` | Lists session files with turn counts; `sessions reset <name>` clears one (e.g. `reset default`) |
|
||||
| `pos ai openrouter --model <id> …` | Overrides the model for one invocation |
|
||||
|
||||
`pos ai openrouter` 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-openrouter.env`, edit with `pos config ai-openrouter`):
|
||||
|
||||
| Key | Required | Default | Purpose |
|
||||
|-----|----------|---------|---------|
|
||||
| `OPENROUTER_API_KEY` | yes | — | API key from openrouter.ai (secret — masked in `pos config ai-openrouter`) |
|
||||
| `OPENROUTER_MODEL` | no | `openrouter/auto` | Model id used by `ask`/`chat`/`models` |
|
||||
|
||||
Precedence: `--model` flag > `OPENROUTER_MODEL` env > config file > `openrouter/auto`. Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES).
|
||||
**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
|
||||
|
||||
@@ -262,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).
|
||||
|
||||
@@ -308,7 +307,7 @@ Subcommands that need input prompt interactively when args are omitted. Bare inv
|
||||
| Command | File | Purpose | Configuration |
|
||||
|---------|------|---------|---------------|
|
||||
| `pos communication telegram sender send "text"` | `bin/pos-communication-telegram-sender` | Send a message, link, or media file (auto-detects the type) to a Telegram chat via the Bot API | Token + chat ID from `~/.config/linux_post_install/telegram.env` (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, chmod 600). Precedence: `--token`/`--chat-id` flags > env > config file |
|
||||
| `pos communication telegram listener` | `bin/pos-communication-telegram-listener` | Telegram bot listener: map `/command` → bash commands and run them from chat; interactive editor for the map | Same `telegram.env` (the bot is the owner, `TELEGRAM_CHAT_ID`). Map lives in `~/.config/linux_post_install/telegram_commands.env` (`/cmd=bash command` lines, chmod 600) |
|
||||
| `pos communication telegram listener` | `bin/pos-communication-telegram-listener` | Telegram bot listener: map `/command` → bash commands and `<prefix>` → apps, run them from chat; interactive editor for the map | Same `telegram.env` (the bot is the owner, `TELEGRAM_CHAT_ID`). Map lives in `~/.config/linux_post_install/telegram_commands.env` (`/cmd=bash command` lines); text-prefix app map in `telegram_prefixes.env` (`<word>=command` lines) — both chmod 600 |
|
||||
| `pos communication matrix sender send "text"` | `bin/pos-communication-matrix-sender` | Send a text message (plain or `--markdown`) to a Matrix room via the client-server API; also `login` (password → access token) and `test` | Homeserver + room from `~/.config/linux_post_install/matrix.env` (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_ROOM_ID`, chmod 600, secrets masked by `pos config matrix`). Precedence: `--room` flag > env > config file |
|
||||
| `pos communication matrix listener` | `bin/pos-communication-matrix-listener` | Matrix listener: map `/command` → bash commands and run them from room messages; interactive editor for the map | Same `matrix.env` (reacts to `MATRIX_USER_ID`'s own messages; watches `MATRIX_ROOM_ID` or all joined rooms). Map lives in `~/.config/linux_post_install/matrix_commands.env` (`/cmd=bash command` lines, chmod 600) |
|
||||
| `pos communication scrcpy [cmd]` | `bin/pos-communication-scrcpy` | Mirror/control an Android device via scrcpy+adb: `devices`, `record`, `tcpip`, `connect`, `push`, `pull`, `screenshot`, `info` (bare = mirror) | `scrcpy.env` (`SCRCPY_SERIAL`, `SCRCPY_MAX_SIZE`, `SCRCPY_MAX_FPS`, `SCRCPY_BIT_RATE`, `SCRCPY_FULLSCREEN`, `SCRCPY_RECORD_DIR`, `SCRCPY_PUSH_TARGET`, `SCRCPY_EXTRA_FLAGS`) via `pos config scrcpy` |
|
||||
@@ -336,12 +335,18 @@ The bot token is a secret — it is stored only in `~/.config/linux_post_install
|
||||
|---------|----------|
|
||||
| `pos communication telegram listener` | Interactive editor for the `/command` → bash map (`a`dd / `e`dit / `r`emove / `t`est / `q`uit); test-runs run `bash -n` first and may execute the command live |
|
||||
| `pos communication telegram listener --status` | Shows service state (running/autostart), config + map file paths, and the mapped commands |
|
||||
| `pos communication telegram listener --enable` | Installs + starts a systemd **user** service (`pos-telegram-listener.service`); the daemon polls `getUpdates` and runs mapped commands |
|
||||
| `pos communication telegram listener --enable` | Installs + starts a systemd **user** service (`pos-telegram-listener.service`); the daemon polls `getUpdates` and runs mapped commands + text-prefix apps |
|
||||
| `pos communication telegram listener --disable` | Stops, disables, and removes the service |
|
||||
| `pos communication telegram listener --sync-commands` | Push the mapped `/commands` to the bot's `/` menu (`setMyCommands`) — also run automatically after every map edit, on `--enable`, and at daemon start |
|
||||
| `pos communication telegram listener --run` | Run the polling loop in the foreground (what the service executes) |
|
||||
| `pos communication telegram listener prefix` | List the text-prefix map (`telegram_prefixes.env`: `<word>=command` lines) plus the built-in Gemini bridge word |
|
||||
| `pos communication telegram listener prefix <word>` | Show one mapping, or map `<word>` to a command with `prefix <word> <command...>` — non-command messages `<word> <text>` run the command with `<text>` appended as ONE quoted argument (e.g. `prefix opencode opencode` → "opencode check cpu" runs `opencode "check cpu"`). `prefix -r <word>` removes. Mapped words shadow the Gemini bridge; the bridge word itself (`TELEGRAM_AI_PREFIX`, default `ai`) is set via `pos config telegram` |
|
||||
|
||||
The map file is re-read for every message — edits apply without a restart. The listener only reacts to the owner chat (`TELEGRAM_CHAT_ID`); anyone else's message is ignored. `/help` lists mapped commands; an unmapped command replies "Unknown command". Non-command text starting with `ai ` (case-insensitive, e.g. `ai what is Nvidia`) is forwarded to Gemini via `pos ai gemini ask` and the answer is replied verbatim; an AI failure replies the error plus a `pos config ai` hint. Commands run as your user via `timeout 60 bash -c "…"` (stdout + stderr are replied, truncated to ~3800 chars; empty output → `OK`), so `sudo` inside them needs a NOPASSWD rule. A map value prefixed with `@quiet ` runs the command but does NOT reply — for commands that already send their own notification (e.g. `pos system backup` self-notifies, so `/backup=@quiet pos system backup $HOME/Documents` avoids a double message). `--enable` warns if linger is off — the service stops when you log out unless you run `sudo loginctl enable-linger $(whoami)`.
|
||||
The map file is re-read for every message — edits apply without a restart. The listener only reacts to the owner chat (`TELEGRAM_CHAT_ID`); anyone else's message is ignored. `/help` lists mapped commands; an unmapped command replies "Unknown command".
|
||||
|
||||
**Text-prefix map** (`telegram_prefixes.env`, managed via the `prefix` verb): for apps, not bash snippets — a non-command message `<word> <text>` runs the mapped command with `<text>` appended as ONE quoted argument, e.g. `opencode=opencode` turns "opencode check cpu" into `opencode "check cpu"`. First match wins (file order), matching is case-insensitive and the word must be space-delimited (bare `<word>` with no trailing space replies Unknown command). Values are syntax-checked on save; `@quiet ` values suppress the reply; the 120s timeout + empty→`OK` + `exit <rc>` reply mirror the command map. Routing order on every non-command message: text-prefix map → AI bridge → `/command` map → "Unknown command".
|
||||
|
||||
**AI bridge**: non-command text starting with `<word> ` — default `ai `, configurable via `pos config telegram` → `TELEGRAM_AI_PREFIX` (a text-prefix entry with the same word shadows it) — is forwarded to Gemini via `pos ai gemini ask` (case-insensitive, e.g. `ai what is Nvidia` or `BOT what is Nvidia` with prefix `bot`) and the answer is replied verbatim; an AI failure replies the error. Commands run as your user via `timeout bash -c "…"` (stdout + stderr are replied, truncated to ~3800 chars; empty output → `OK`), so `sudo` inside them needs a NOPASSWD rule. A map value prefixed with `@quiet ` runs the command but does NOT reply — for commands that already send their own notification (e.g. `pos system backup` self-notifies, so `/backup=@quiet pos system backup $HOME/Documents` avoids a double message). `--enable` warns if linger is off — the service stops when you log out unless you run `sudo loginctl enable-linger $(whoami)`.
|
||||
|
||||
Map entries may carry an optional **description** shown in the bot's `/` menu: `/cmd::short description=bash command` (the description falls back to the bash command, truncated to ~40 chars, when omitted). After every add/edit/remove the command list is pushed to the bot via `setMyCommands`, so the menu stays in sync; an empty map clears the menu. Telegram only registers lowercase `[a-z0-9_]` names (1–32 chars) — commands like `/Status` or `/my-cmd` are skipped from the menu with a warning but still resolve when typed.
|
||||
|
||||
@@ -486,6 +491,8 @@ Feature-flag management CLIs (see [SCRIPTS.md → lib/flags.sh](SCRIPTS.md#libfl
|
||||
|
||||
`pos config` is the interactive editor for the tools' runtime config (see [DEV.md](DEV.md#config-files) and §10 of AGENT_Context). Every tool exposes its configuration by declaring a `# POS_CONFIG:` header; `pos config` reads those at runtime — it knows nothing about the variables themselves. Values live in `~/.config/linux_post_install/<scope>.env` (chmod 600).
|
||||
|
||||
Headers may also declare **group captions**: `@Caption` starts a visual group, and `@[KEY=v1|v2] Caption` makes the group conditional — while `KEY`'s current value matches none of the listed alternatives, the group stays visible but dimmed with a textual reason (`— inactive while KEY=…`), so row numbering never changes mid-session. Wildcards can be tagged: `*providers=<tag>` pulls keys from a single AI provider adapter instead of all of them. The listing renders uniformly for every scope (bold title/keys, dim numbers/examples/placeholders, word-wrapped descriptions); at the prompt type a number to edit, `r` to refresh, or `q` to quit.
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `pos config` | Scope picker (on a TTY), otherwise the scope list |
|
||||
|
||||
+16
-1
@@ -12,6 +12,12 @@ Everything that runs during the bootstrap install: `install.sh`, `preinstall.sh`
|
||||
- [lib/common.sh — shared library](#libcommonsh--shared-library)
|
||||
- [lib/flags.sh — feature flags](#libflagssh--feature-flags)
|
||||
- [lib/notify.sh — multi-platform alerting](#libnotifysh--multi-platform-alerting)
|
||||
- [lib/entertainment-lib.sh — entertainment module](#libentertainmentlibsh--entertainment-module)
|
||||
- [lib/user-timers-lib.sh — shared systemd user timers](#libusertimerslibsh--shared-systemd-user-timers)
|
||||
- [lib/usb-lib.sh — shared USB-storage detection](#libusblibsh--shared-usb-storage-detection)
|
||||
- [lib/share-lib.sh — share-suite domain layer + compat shims](#sharelibsh--share-suite-domain-layer--compat-shims)
|
||||
- [lib/menu-lib.sh — category-neutral menu primitives](#libmenulibsh--category-neutral-menu-primitives)
|
||||
- [lib/registry.sh — tool metadata query API](#libregistrysh--tool-metadata-query-api)
|
||||
- [features/autostart.sh — boot-time feature](#featuresautostartsh--boot-time-feature)
|
||||
- [features/usb-automount.sh — USB automount feature](#featuresusb-automountsh--usb-automount-feature)
|
||||
- [x64_bin/ — precompiled binaries](#x64_bin--precompiled-binaries)
|
||||
@@ -38,7 +44,7 @@ The phases:
|
||||
| # | Phase | Script/action |
|
||||
|---|-------|----------------|
|
||||
| 1 | preinstall | `preinstall.sh` — apt packages + yt-dlp |
|
||||
| 2 | scripts | Copies `bin/*` → `/usr/local/bin/` (755), `lib/common.sh` + `lib/flags.sh` + `lib/notify.sh` + `lib/entertainment-lib.sh` + `lib/entertainment-plugin-lib.sh` + `lib/scheduler-lib.sh` + `lib/config-ui.sh` + `lib/user-timers-lib.sh` + `lib/usb-lib.sh` + `lib/share-lib.sh` + `lib/menu-lib.sh` → `/usr/local/bin/` (644). Copies precompiled arch binaries from `x64_bin/` (or `arm64_bin/`) → `/usr/local/bin/`. With `--feature`: also installs `features/*` (see below) |
|
||||
| 2 | scripts | Copies `bin/*` → `/usr/local/bin/` (755), `lib/common.sh` + `lib/flags.sh` + `lib/notify.sh` + `lib/entertainment-lib.sh` + `lib/entertainment-plugin-lib.sh` + `lib/scheduler-lib.sh` + `lib/config-ui.sh` + `lib/user-timers-lib.sh` + `lib/usb-lib.sh` + `lib/share-lib.sh` + `lib/menu-lib.sh` + `lib/registry.sh` → `/usr/local/bin/` (644). Copies precompiled arch binaries from `x64_bin/` (or `arm64_bin/`) → `/usr/local/bin/`. With `--feature`: also installs `features/*` (see below) |
|
||||
| 3 | postinstall | `postinstall.sh` — PATH, completion, SSH keys, systemd |
|
||||
| 4 | scalepoint | Shallow-clones ScaleTail templates to `/usr/local/share/linux_post_install/scale-tail` |
|
||||
| 5 (opt) | apps | `apps/install.sh` when `--apps` (interactive) or `--full` (all, non-interactive) |
|
||||
@@ -228,6 +234,15 @@ Sourced by `bin/pos-entertainment-send|config|enable|disable|status` (after `lib
|
||||
|
||||
---
|
||||
|
||||
## lib/registry.sh — tool metadata query API
|
||||
|
||||
**File:** `lib/registry.sh` (installed to `/usr/local/bin/registry.sh`)
|
||||
**Purpose:** the one query API over the tools' `# POS_*:` metadata headers, so consumers source it instead of re-implementing sed/grep header scans. `reg_scan [dir]` reads every executable `pos-*` file once — sorted under `LC_ALL=C`, and cheap enough to call lazily (plain dispatch paths skip it entirely); each tool's key is its filename after `pos-` with the category split off at the first dash (category-less tools carry an empty category). The populated stores serve `reg_list`, `reg_categories`, `reg_tools_in` and `reg_lookup <tool> <field>` with fields `cat|desc|flags|subcmds|deps|examples` (`deps`/`examples` come from the optional `# POS_DEPS:` / `# POS_EXAMPLES:` headers); the multi-line `# POS_CONFIG:` registry gets its own helpers (`reg_config_scopes`, `reg_config_keys`, `reg_config_envfile`); `reg_each <callback>` iterates every tool calling `cb(category, tool_key, description)`; `reg_tool_exists` is the membership probe. Like `lib/config-ui.sh` it defines guarded `log`/`warn`/`err` fallbacks so it sources cleanly without `lib/common.sh`; no shebang and never executed (installed 644).
|
||||
|
||||
Sourced by `bin/pos-tree` (tree rendering incl. the `[deps: …]` annotations) and by `bin/pos` `_pos_category_help()` for `pos <category> --help` (lazy load there, so plain dispatch never pays the scan cost). `scripts/gen-docs.sh` predates the registry and keeps parsing the same headers independently for its generated blocks; new consumers should prefer the registry.
|
||||
|
||||
---
|
||||
|
||||
## features/autostart.sh — boot-time feature
|
||||
|
||||
**File:** `features/autostart.sh` (installed to `/usr/local/bin/autostart.sh` by `./install.sh --feature`)
|
||||
|
||||
+115
-72
@@ -2,31 +2,37 @@
|
||||
|
||||
Chat with AI models — Gemini, OpenRouter, and more — from the terminal and
|
||||
through the Telegram bot.
|
||||
Tools: `gemini` (`ask`, `chat`, `models`), `openrouter` (`ask`, `chat`, `sessions`).
|
||||
Tool: `pos ai` with pluggable provider adapters (`gemini`, `openrouter`).
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| `pos ai gemini 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 gemini 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 gemini capture <cmd>` | Run any command, capture its output for `--last`, and show it on screen |
|
||||
| `pos ai gemini ask --session <name> "…"` | Same, but uses a named session instead of `default` |
|
||||
| `pos ai gemini chat` | Interactive multi-turn conversation (session `default` unless `--session`) |
|
||||
| `pos ai gemini models` | List available model ids |
|
||||
| `pos ai gemini sessions` | List persistent sessions / clear one (`reset <name>`, e.g. `reset default`) |
|
||||
| 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>"` 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`.
|
||||
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 gemini sessions reset default`.
|
||||
`pos ai sessions reset default`.
|
||||
|
||||
---
|
||||
|
||||
@@ -59,15 +65,15 @@ exactly as before (no added blank lines), so scripting stays byte-stable.
|
||||
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`
|
||||
@@ -77,46 +83,66 @@ is the only place the key is written. The key is never printed by `pos`.
|
||||
|
||||
[OpenRouter](https://openrouter.ai) gives access to hundreds of models from
|
||||
different providers (Anthropic, OpenAI, Meta, Mistral, Google, …) through a
|
||||
single OpenAI-compatible API. `pos ai openrouter` works identically to the
|
||||
Gemini tool — same subcommands (`ask`, `chat`, `sessions`), same flags
|
||||
(`--last`, `--system`, `--full`, `--session`), same terminal rendering and
|
||||
machine context.
|
||||
single OpenAI-compatible API. Use `--provider openrouter` to switch:
|
||||
|
||||
1. Get an API key from https://openrouter.ai/settings/keys.
|
||||
```bash
|
||||
pos ai --provider openrouter ask "hi"
|
||||
# or the legacy shorthand:
|
||||
pos ai openrouter ask "hi"
|
||||
```
|
||||
|
||||
2. Configure it:
|
||||
Configure the API key:
|
||||
|
||||
```bash
|
||||
pos config ai-openrouter # enter OPENROUTER_API_KEY
|
||||
```
|
||||
|
||||
3. Test:
|
||||
|
||||
```bash
|
||||
pos ai openrouter ask "hi"
|
||||
```
|
||||
```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 openrouter ask --model anthropic/claude-sonnet-4 "explain DNS"
|
||||
pos ai --provider openrouter ask --model anthropic/claude-sonnet-4 "explain DNS"
|
||||
```
|
||||
|
||||
Sessions are stored separately from Gemini's:
|
||||
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).
|
||||
|
||||
```
|
||||
~/.local/share/linux_post_install/ai-openrouter/<name>.json
|
||||
Switch providers per-invocation:
|
||||
|
||||
```bash
|
||||
pos ai ask "hello" # uses gemini (default)
|
||||
pos ai --provider openrouter ask "hello" # uses openrouter
|
||||
```
|
||||
|
||||
All features work the same way — `--last` for diagnosing failures, `--system`
|
||||
for custom instructions, `--full` for long-form answers, persistent sessions,
|
||||
tty markdown rendering, and machine context. The only difference is the backend
|
||||
API.
|
||||
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:
|
||||
|
||||
```
|
||||
@@ -124,8 +150,21 @@ you: ai what is Nvidia
|
||||
bot: NVIDIA is a company best known for GPUs...
|
||||
```
|
||||
|
||||
The trigger word is configurable via `pos config telegram` →
|
||||
`TELEGRAM_AI_PREFIX` (default `ai`); it takes effect immediately, so with
|
||||
prefix `bot` you'd message `bot what is Nvidia`. `pos communication telegram
|
||||
listener prefix` shows the current value.
|
||||
|
||||
The listener also has a generic **text-prefix map** (`telegram_prefixes.env`,
|
||||
managed with `pos communication telegram listener prefix <word> <command...>`)
|
||||
that runs any app with the rest of the message as one argument — e.g.
|
||||
`prefix opencode opencode` turns the message `opencode check cpu` into
|
||||
`opencode "check cpu"`. Routing order per non-command message: text-prefix
|
||||
map → AI bridge → `/command` map, so mapping a word in the prefix map
|
||||
shadows the Gemini bridge for that word.
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
@@ -136,13 +175,14 @@ you: ai --model gemini-2.5-flash explain a Raft consensus log
|
||||
|
||||
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
|
||||
remembers the conversation; `<prefix> /reset` clears it (with the default
|
||||
prefix that's `ai /reset`). 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
|
||||
the reply before sending it, since messages go out as plain text.
|
||||
|
||||
Replying to a message before `ai …` makes that message part of the prompt, so
|
||||
the model can answer about it:
|
||||
Replying to a message before `<prefix> …` makes that message part of the
|
||||
prompt, so the model can answer about it:
|
||||
|
||||
```
|
||||
you: /status → bot: (system health output…)
|
||||
@@ -151,17 +191,19 @@ you: ai check this details about my linux ← reply to the /status message
|
||||
|
||||
## Recipes
|
||||
|
||||
- **Diagnose the last failed pos run:** `pos ai gemini 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 gemini ask how do I fix this`
|
||||
- **Answer from a file:** `pos ai gemini ask "$(cat notes.txt)"`
|
||||
- **Answer in a cron job:** `pos ai gemini ask "summarize today's git log" > /tmp/ai_digest.txt`
|
||||
- **Long-form on demand:** `pos ai gemini ask --full "compare ext4 and zfs in depth"`
|
||||
- **Forget what the terminal asked:** `pos ai gemini sessions reset default`
|
||||
- **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
|
||||
|
||||
@@ -170,8 +212,8 @@ output from **any** command (`pip install`, `apt upgrade`, `make`, etc.):
|
||||
|
||||
**Option A — explicit capture:**
|
||||
```bash
|
||||
pos ai gemini capture pip install xyz
|
||||
pos ai gemini ask --last "what happened"
|
||||
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).
|
||||
@@ -187,15 +229,15 @@ truncated). To disable: `unset __POS_CAPTURE_ACTIVE`.
|
||||
|
||||
## How it works
|
||||
|
||||
- `ask` POSTs `contents:[…]` (prior turns of the active session plus the new
|
||||
user turn) 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.
|
||||
- `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.
|
||||
- `chat` keeps the whole conversation in memory as a growing `contents[]`
|
||||
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
|
||||
@@ -206,12 +248,13 @@ truncated). To disable: `unset __POS_CAPTURE_ACTIVE`.
|
||||
- `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
|
||||
(`pos communication telegram listener --status`); the bot token and owner
|
||||
chat id must match `pos config telegram`.
|
||||
- Nothing in Telegram for `<prefix> …` (default `ai`) → the listener daemon
|
||||
must be running (`pos communication telegram listener --status`); the bot
|
||||
token and owner chat id must match `pos config telegram`. Check the current
|
||||
trigger word with `pos communication telegram listener prefix`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -97,6 +97,21 @@ pos communication telegram listener --disable # remove it
|
||||
- **Runs as you:** mapped commands execute as your user with a 60s timeout,
|
||||
stdout + stderr are replied to the chat (truncated ~3800 chars; empty → `OK`).
|
||||
`sudo` inside a command needs a NOPASSWD rule.
|
||||
- **Text-prefix map (apps):** `~/.config/linux_post_install/telegram_prefixes.env`
|
||||
(chmod 600), one `<word>=command` per line — a non-command message
|
||||
`<word> <text>` runs the app with `<text>` appended as ONE quoted argument,
|
||||
e.g. `opencode=opencode` turns "opencode check cpu" into `opencode "check cpu"`.
|
||||
First match wins (file order), case-insensitive, word must be space-delimited.
|
||||
Manage it with `pos communication telegram listener prefix <word> <command...>`
|
||||
(bare `prefix` lists, `prefix <word>` shows one, `prefix -r <word>` removes):
|
||||
```bash
|
||||
pos communication telegram listener prefix opencode opencode
|
||||
# then message: opencode check cpu → runs opencode "check cpu"
|
||||
pos communication telegram listener prefix ai "pos ai gemini ask --session telegram-\$TELEGRAM_CHAT_ID"
|
||||
# overrides the built-in Gemini bridge for the word 'ai'
|
||||
```
|
||||
Routing order per non-command message: text-prefix map → AI bridge →
|
||||
`/command` map → "Unknown command".
|
||||
- **`@quiet` prefix:** a map value starting with `@quiet ` runs the command but
|
||||
does NOT reply — for commands that already send their own notification, so
|
||||
you don't get it twice. `pos system backup` self-notifies, so
|
||||
|
||||
+33
-2
@@ -1,13 +1,14 @@
|
||||
# How-To: `pos system`
|
||||
|
||||
Host care: encrypted backups, firewall, and the health dashboard. Tools:
|
||||
`backup`, `firewall`, `health`.
|
||||
Host care: encrypted backups, firewall, health dashboard, and uninstall. Tools:
|
||||
`backup`, `firewall`, `health`, `uninstall`.
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| `pos system health` | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker) |
|
||||
| `pos system backup` | gpg-encrypted (AES-256) folder snapshots |
|
||||
| `pos system firewall` | Interactive UFW ("UFW POWER") management |
|
||||
| `pos system uninstall` | Safe, interactive uninstaller for the pos toolkit |
|
||||
|
||||
---
|
||||
|
||||
@@ -204,6 +205,36 @@ not).
|
||||
`sudo ufw allow 22/tcp`, then `sudo ufw reload`.
|
||||
- `ufw reset` requires typing `RESET` — deliberate.
|
||||
|
||||
## `pos system uninstall` — remove the pos toolkit
|
||||
|
||||
```bash
|
||||
pos system uninstall # interactive scan + confirm tier 1
|
||||
pos system uninstall --yes # non-interactive, tier 1 only
|
||||
pos system uninstall --yes --config --data # remove everything (nuclear option)
|
||||
```
|
||||
|
||||
Scans the system for installed pos components and removes them in three tiers:
|
||||
|
||||
| Tier | What it removes | How to include |
|
||||
|------|----------------|----------------|
|
||||
| **Tier 1** | Binaries (`/usr/local/bin/pos*`, libs, entertainment plugins, prebuilt, features), systemd services (disable+remove), shell integration (`~/.bashrc` PATH/completion/pos-ai-hook entries), completion file | Always (default) |
|
||||
| **Tier 2** | Config files (`~/.config/linux_post_install/` — `.env` files, `schedule.d/`, `authorized_keys`, `rclone.conf`) | `--config` flag |
|
||||
| **Tier 3** | Session/log data (`~/.local/share/linux_post_install/` — AI sessions, logs, captured output) | `--data` flag |
|
||||
|
||||
The default mode is interactive: it shows what will be removed and asks for
|
||||
confirmation. The git repo is **never** removed — delete it manually if desired.
|
||||
|
||||
**Recipes:**
|
||||
- Quick cleanup: `pos system uninstall --yes`
|
||||
- Full wipe: `pos system uninstall --yes --config --data`
|
||||
- Safe preview: run `pos system uninstall` without `--yes` to see the plan first
|
||||
|
||||
**Troubleshooting:**
|
||||
- "Nothing to remove" → pos toolkit is not installed (or already removed)
|
||||
- After uninstall, run `source ~/.bashrc` or restart your shell
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- Reference: [DOC/POS.md → system](../POS.md)
|
||||
|
||||
@@ -275,6 +275,17 @@ Recommended fix: add rows to the POS.md command table (and cross-check HOWTO for
|
||||
Verification: lint WARNs gone; `grep` shows each tool in POS.md.
|
||||
Fix (2026-08-14): the tools were documented by command name but not by filename (the lint references basenames). Added `**File:** bin/pos-config` (config section), `**File:** bin/pos-tree` (tree section), and a file list on the entertainment section header covering `bin/pos-entertainment-{config,enable,disable,status}`. Verified: lint 0 WARN. HOWTO already covers the entertainment group via `pos entertainment *` command forms.
|
||||
|
||||
### M-024
|
||||
Status: VERIFIED
|
||||
Severity: LOW
|
||||
Category: docs
|
||||
Files: AGENTS.md:17-18; DOC/SCRIPTS.md (Phase-2 lib list, TOC, new lib section)
|
||||
Evidence: the command-registry feature landed (`lib/registry.sh`, 199 lines; optional `# POS_DEPS:`/`# POS_EXAMPLES:` headers already codified in `templates/pos-tool.sh:13-14` and `DOC/DEV.md:126-134`) but three docs kept describing the old reality: AGENTS.md Quick facts enumerated only `POS_FLAGS`/`SUBCMDS`/`CONFIG` with no mention of the shared query API; DOC/SCRIPTS.md's Phase-2 lib list omitted `registry.sh` and had no section for it (its TOC also lacked five pre-existing lib sections).
|
||||
Expected: docs describe what IS — code + `# POS:` headers are ground truth (Phase 0 rule 4).
|
||||
Recommended fix: sync the three drifted docs to implemented reality; no code/template/completion changes.
|
||||
Verification: `make gen` produces zero diff beyond pre-existing work; `make check` green; `make lint` 0 FAIL / 0 WARN; `grep -n "POS_DEPS"` hits AGENTS.md, DEV.md, SCRIPTS.md.
|
||||
Fix (2026-08-26): template `templates/pos-tool.sh` now documents the optional `# POS_DEPS:`/`# POS_EXAMPLES:` headers (pre-existing); `lib/registry.sh` added as the shared query API over all `POS_*` headers (`reg_scan` + `reg_list`/`reg_lookup`/…) — AGENTS.md Tool-model + Categories bullets updated, DOC/SCRIPTS.md got the lib-list row (install.sh:143 order), a per-lib reference section, and a completed TOC. Consumers were already migrated (`bin/pos-tree`, `bin/pos` `_pos_category_help()`); lint unchanged (0 FAIL / 0 WARN).
|
||||
|
||||
### P3 — intentional / legacy (no action)
|
||||
- install.sh:123,135,155,185 — installer writes to /usr/local/bin are its purpose; no seam needed (lint excludes install scripts).
|
||||
- network-download RPC_SECRET at :150 — generated at runtime (`/dev/urandom`), not a committed secret.
|
||||
|
||||
@@ -66,21 +66,27 @@ _pos_category_exists() {
|
||||
}
|
||||
|
||||
_pos_category_help() {
|
||||
local cat="$1" f
|
||||
local files=() s d
|
||||
for f in "$self"/pos-"$cat"-*; do
|
||||
[ -x "$f" ] || continue
|
||||
files+=("${f##*/pos-$cat-}")
|
||||
done
|
||||
local cat="$1" s s2 extra d sc deps
|
||||
# Lazy registry load — plain dispatch paths never pay the scan cost.
|
||||
source "$self/../lib/registry.sh" 2>/dev/null || source "$self/registry.sh"
|
||||
reg_scan "$self"
|
||||
|
||||
local files=()
|
||||
local t
|
||||
while IFS= read -r t; do
|
||||
[ -n "$t" ] || continue
|
||||
files+=("${t#"$cat"-}")
|
||||
done < <(reg_tools_in "$cat")
|
||||
mapfile -t files < <(printf '%s\n' "${files[@]}" | sort -u)
|
||||
|
||||
local -A desc subcmds
|
||||
local sc
|
||||
local -A desc subcmds _deps
|
||||
for s in "${files[@]}"; do
|
||||
d="$(sed -n '/^# POS: /{s/^# POS: //;p;q}' "$self/pos-$cat-$s" 2>/dev/null)"
|
||||
[ -n "$d" ] && desc["$s"]="${d#*— }"
|
||||
sc="$(sed -n '/^# POS_SUBCMDS: /{s/^# POS_SUBCMDS: //;p;q}' "$self/pos-$cat-$s" 2>/dev/null)"
|
||||
d="$(reg_lookup "$cat-$s" desc)"
|
||||
[ -n "$d" ] && desc["$s"]="$d"
|
||||
sc="$(reg_lookup "$cat-$s" subcmds)"
|
||||
[ -n "$sc" ] && subcmds["$s"]="$sc"
|
||||
deps="$(reg_lookup "$cat-$s" deps)"
|
||||
[ -n "$deps" ] && _deps["$s"]="$deps"
|
||||
done
|
||||
|
||||
# Nested sub-tools: pos-<cat>-<a>-<b> lists "b" under <a>.
|
||||
@@ -113,6 +119,7 @@ _pos_category_help() {
|
||||
done
|
||||
[ "$is_nested" -eq 1 ] && continue
|
||||
printf ' %-28s%s\n' "$s" "${desc[$s]:-}"
|
||||
[ -n "${_deps[$s]:-}" ] && printf ' [deps: %s]\n' "${_deps[$s]}"
|
||||
for c in ${subcmds[$s]:-}; do
|
||||
printf ' %s %s\n' "$s" "$c"
|
||||
done
|
||||
@@ -259,7 +266,7 @@ MAIN_LOG="$LOG_DIR/pos.log"
|
||||
log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; }
|
||||
|
||||
# Commands that read from stdin interactively — only log invocation
|
||||
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai-gemini ai-openrouter system-schedule entertainment-config config"
|
||||
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup system-uninstall share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai ai-gemini ai-openrouter ai-alias system-schedule entertainment-config config"
|
||||
|
||||
for ((i=n-1; i>=0; i--)); do
|
||||
cmd="pos"
|
||||
|
||||
Executable
+692
@@ -0,0 +1,692 @@
|
||||
#!/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 --trust
|
||||
# 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] [--trust]
|
||||
|
||||
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).
|
||||
--trust Auto-execute agent-detected commands without confirmation.
|
||||
Used by trusted alias wrappers — do NOT pass manually
|
||||
unless you fully trust the agent's output.
|
||||
-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" trusted="${2:-0}"
|
||||
# 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
|
||||
if [ "$trusted" -eq 1 ]; then
|
||||
printf '[trusted] Auto-executing (no confirmation)\n\n' >&2
|
||||
printf '%s\n' "$cmd"
|
||||
run eval "$cmd"
|
||||
return
|
||||
fi
|
||||
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" "$TRUST_MODE"
|
||||
}
|
||||
|
||||
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" "$TRUST_MODE"
|
||||
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
|
||||
TRUST_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 ;;
|
||||
--trust)
|
||||
TRUST_MODE=1; shift ;;
|
||||
-*) err "Unknown option '$1' (see --help)" ;;
|
||||
*)
|
||||
if [ -z "$cmd" ]; then
|
||||
cmd="$1"
|
||||
else
|
||||
args+=("$1")
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Resolve provider: --provider flag > AI_PROVIDER env/config > default gemini
|
||||
if [ -z "$PROVIDER" ]; then
|
||||
load_config
|
||||
PROVIDER="${AI_PROVIDER:-gemini}"
|
||||
fi
|
||||
|
||||
# Load provider adapter functions
|
||||
load_provider
|
||||
|
||||
if [ "$LAST_MODE" -eq 1 ] && [ "${cmd:-}" != "ask" ]; then
|
||||
err "--last only applies to 'ask' — for capturing output use 'capture': pos ai capture <cmd>"
|
||||
fi
|
||||
|
||||
case "${cmd:-}" in
|
||||
"") usage ;;
|
||||
ask) cmd_ask "${args[@]}" ;;
|
||||
capture) cmd_capture "${args[@]}" ;;
|
||||
chat) cmd_chat "${args[@]}" ;;
|
||||
models) cmd_models "${args[@]}" ;;
|
||||
providers) cmd_providers "${args[@]}" ;;
|
||||
sessions) cmd_sessions "${args[@]}" ;;
|
||||
*) err "Unknown ai subcommand '$cmd' (see --help)" ;;
|
||||
esac
|
||||
Executable
+760
@@ -0,0 +1,760 @@
|
||||
#!/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=()
|
||||
_ALIAS_TRUSTED=()
|
||||
[ -f "$ENV_FILE" ] || return 0
|
||||
# NOTE: loop vars use _l* prefix to avoid dynamic-scope collision with
|
||||
# callers that declare 'local name' (bash read clobbers the nearest
|
||||
# matching variable up the call chain).
|
||||
local _ln _lp _ls _lp2 _lr
|
||||
while IFS='|' read -r _ln _lp _ls _lp2 _lr; do
|
||||
[[ "$_ln" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ -z "${_ln// /}" ]] && continue
|
||||
_ln="${_ln## }"; _ln="${_ln%% }"
|
||||
[[ "$_ln" =~ ^[a-zA-Z][a-zA-Z0-9_-]*$ ]] || continue
|
||||
_lp="${_lp## }"; _lp="${_lp%% }"
|
||||
_ls="${_ls## }"; _ls="${_ls%% }"
|
||||
_ALIAS_NAMES+=("$_ln")
|
||||
_ALIAS_PROVIDERS+=("$_lp")
|
||||
_ALIAS_SESSIONS+=("$_ls")
|
||||
_ALIAS_PROMPTS+=("$(_alias_prompt_decode "$_lp2")")
|
||||
_ALIAS_TRUSTED+=("${_lr:-0}")
|
||||
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|trusted"
|
||||
printf '%s\n' "#"
|
||||
local i
|
||||
for ((i = 0; i < ${#_ALIAS_NAMES[@]}; i++)); do
|
||||
printf '%s|%s|%s|%s|%s\n' "${_ALIAS_NAMES[$i]}" "${_ALIAS_PROVIDERS[$i]}" \
|
||||
"${_ALIAS_SESSIONS[$i]}" "$(_alias_prompt_encode "${_ALIAS_PROMPTS[$i]}")" "${_ALIAS_TRUSTED[$i]:-0}"
|
||||
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:-}" trusted="${4:-0}" q_prompt
|
||||
printf -v q_prompt '%q' "$prompt"
|
||||
printf 'pos ai %s ask --session %s' "$provider" "$session"
|
||||
[ "$trusted" -eq 1 ] && printf ' --trust'
|
||||
[ -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 [trusted]).
|
||||
# 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:-}" trusted="${5:-0}"
|
||||
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" "$trusted") "\$@"
|
||||
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 [trusted]
|
||||
local path="$(_wrapper_path "$1")" tmp
|
||||
tmp="$(mktemp "${HOME}/.local/bin/.pos-alias.XXXXXX")"
|
||||
_wrapper_render "$1" "$2" "$3" "$4" "${5:-0}" >"$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]}" "${_ALIAS_TRUSTED[$i]:-0}" || :
|
||||
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//$'\n'/\\n}" max="${2:-42}"
|
||||
if [ ${#p} -gt "$max" ]; then
|
||||
printf '%s…' "${p:0:max}"
|
||||
else
|
||||
printf '%s' "$p"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Prompt encoding for the line-based env file ────────────────
|
||||
# menu-lib's reader accepts literal multiline prompts; the ENV file is one
|
||||
# record per line, so prompts are escaped on save and unescaped on load:
|
||||
# backslash → \\ newline → \n
|
||||
# ('|' is already rejected at input, so | never needs escaping.)
|
||||
_alias_prompt_encode() {
|
||||
local s="$1" out="" c="" i
|
||||
for ((i = 0; i < ${#s}; i++)); do
|
||||
c="${s:i:1}"
|
||||
if [ "$c" = '\' ]; then
|
||||
out+='\\'
|
||||
elif [ "$c" = $'\n' ]; then
|
||||
out+='\n'
|
||||
else
|
||||
out+="$c"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# Decode is order-safe: \\n (escaped newline text) is backslash + n, which
|
||||
# decodes to literal '\n' only after both escapes are consumed in order.
|
||||
_alias_prompt_decode() {
|
||||
local s="$1" out="" c="" n="" i=0
|
||||
while [ "$i" -lt "${#s}" ]; do
|
||||
c="${s:i:1}"
|
||||
if [ "$c" = '\' ] && [ $((i + 1)) -lt "${#s}" ]; then
|
||||
n="${s:i+1:1}"
|
||||
if [ "$n" = 'n' ]; then
|
||||
out+=$'\n'; i=$((i + 2)); continue
|
||||
elif [ "$n" = '\' ]; then
|
||||
out+='\'; i=$((i + 2)); continue
|
||||
fi
|
||||
fi
|
||||
out+="$c"
|
||||
i=$((i + 1))
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# ── 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 %-5s %s\n' "Name" "Provider" "Session" "Trust" "Prompt"
|
||||
printf ' %-12s %-12s %-12s %-5s %s\n' "------------" "------------" "------------" "-----" \
|
||||
"------------------------------------------"
|
||||
for ((i = 0; i < count; i++)); do
|
||||
local _tmark="—"
|
||||
[ "${_ALIAS_TRUSTED[$i]:-0}" = "1" ] && _tmark="yes"
|
||||
printf ' %-12s %-12s %-12s %-5s %s\n' "${_ALIAS_NAMES[$i]}" "${_ALIAS_PROVIDERS[$i]}" \
|
||||
"${_ALIAS_SESSIONS[$i]}" "$_tmark" "$(_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]}"
|
||||
local trusted="${_ALIAS_TRUSTED[$idx]:-0}"
|
||||
[ -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)")}"
|
||||
printf ' %-12s %s\n' "Trusted:" "$([ "$trusted" = "1" ] && echo "yes (auto-executes commands)" || echo "no (prompts before running)")"
|
||||
# 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" "$trusted")"
|
||||
}
|
||||
|
||||
# ── 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 5 "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 5 "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
|
||||
|
||||
# Step 5: Trust level
|
||||
local trusted="0"
|
||||
while true; do
|
||||
step 5 5 "Trust Level" >&2
|
||||
{
|
||||
echo " TRUSTED aliases auto-execute commands from the agent"
|
||||
echo " WITHOUT asking for confirmation."
|
||||
echo ""
|
||||
echo " Only enable this for aliases you fully trust with"
|
||||
echo " unrestricted shell access on this machine."
|
||||
} >&2
|
||||
local trust_ans
|
||||
trust_ans="$(menu_ask_value "Trust this alias? (y/N)" "N")" || return 0
|
||||
case "${trust_ans,,}" in
|
||||
y|yes) trusted="1"; break ;;
|
||||
n|no|"") trusted="0"; break ;;
|
||||
*) warn "Please answer y or n" >&2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Confirmation
|
||||
{
|
||||
echo "────────────────────────────────────────────"
|
||||
printf ' Create alias '\''%s'\''?\n' "$name"
|
||||
printf ' Provider: %s\n' "$provider"
|
||||
printf ' Session: %s\n' "$session"
|
||||
local dp="$(_alias_prompt_truncate "$prompt" 50)"
|
||||
printf ' Prompt: %s\n' "${dp:-<built-in>}"
|
||||
printf ' Trusted: %s\n' "$([ "$trusted" = "1" ] && echo "yes (auto-execute)" || echo "no (confirm before run)")"
|
||||
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_TRUSTED+=("$trusted")
|
||||
_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"
|
||||
printf ' Trusted: %s\n' "$([ "${_ALIAS_TRUSTED[$idx]:-0}" = "1" ] && echo "yes" || echo "no")"
|
||||
echo >&2
|
||||
} >&2
|
||||
|
||||
local new_provider="${_ALIAS_PROVIDERS[$idx]}"
|
||||
local new_session="${_ALIAS_SESSIONS[$idx]}"
|
||||
local new_prompt="${_ALIAS_PROMPTS[$idx]}"
|
||||
local new_trusted="${_ALIAS_TRUSTED[$idx]:-0}"
|
||||
local changed=0
|
||||
|
||||
# Edit provider
|
||||
step 1 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 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 4 "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=""
|
||||
local default_prompt="${_ALIAS_PROMPTS[$idx]}"
|
||||
# Display-safe default (newlines → \n, truncated); Enter on it keeps the
|
||||
# FULL original prompt — a long/multiline prompt must never be silently
|
||||
# replaced by its truncated display form.
|
||||
local default_display="$(_alias_prompt_truncate "$default_prompt" 80)"
|
||||
while true; do
|
||||
step 3 4 "System Prompt" >&2
|
||||
if ! tmp_prompt="$(menu_ask_value "System prompt" "$default_display")"; then
|
||||
# EOF/cancel: empty-answer abort only when there IS a default;
|
||||
# Enter on an empty original prompt keeps it empty and continues.
|
||||
[ -z "$default_prompt" ] && tmp_prompt="" || return 0
|
||||
fi
|
||||
if [ "$tmp_prompt" = "$default_display" ]; then
|
||||
tmp_prompt="$default_prompt" # Enter → keep the full original
|
||||
fi
|
||||
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
|
||||
|
||||
# Edit trust
|
||||
step 4 4 "Trust Level" >&2
|
||||
local cur_trust_label="no"
|
||||
[ "$new_trusted" = "1" ] && cur_trust_label="yes"
|
||||
local trust_ans
|
||||
trust_ans="$(menu_ask_value "Trust this alias? (y/N)" "$cur_trust_label")" || return 0
|
||||
case "${trust_ans,,}" in
|
||||
y|yes) new_trusted="1" ;;
|
||||
n|no|"") new_trusted="$new_trusted" ;;
|
||||
*) warn "Please answer y or n" >&2 ;;
|
||||
esac
|
||||
[ "$new_trusted" != "${_ALIAS_TRUSTED[$idx]:-0}" ] && 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 tag_t
|
||||
[ "$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)"
|
||||
[ "$new_trusted" = "${_ALIAS_TRUSTED[$idx]:-0}" ] && tag_t="(unchanged)" || tag_t="(changed)"
|
||||
printf ' Provider: %-12s %s\n' "$new_provider" "$tag_p"
|
||||
printf ' Session: %-12s %s\n' "$new_session" "$tag_s"
|
||||
local dp="$(_alias_prompt_truncate "$new_prompt" 40)"
|
||||
[ -z "$dp" ] && dp="<built-in>"
|
||||
printf ' Prompt: %s %s\n' "$dp" "$tag_pr"
|
||||
printf ' Trusted: %-12s %s\n' "$([ "$new_trusted" = "1" ] && echo "yes" || echo "no")" "$tag_t"
|
||||
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_TRUSTED[$idx]="$new_trusted"
|
||||
_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,
|
||||
optional system prompt, and a trust level.
|
||||
|
||||
Trusted aliases auto-execute the agent's commands without asking for
|
||||
confirmation. Only enable for aliases you fully trust with shell access.
|
||||
|
||||
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
-584
@@ -1,586 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: ai gemini — Chat with Google Gemini (ask, capture, chat, models, sessions)
|
||||
# POS_SUBCMDS: ask capture chat models sessions
|
||||
# POS_FLAGS: --model --session --system --full --last
|
||||
# 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"
|
||||
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)
|
||||
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="You assist a user working in a Linux/Unix CLI terminal. Be extremely terse: lead with the exact command(s) to run; one-line explanations max; short bullets only when necessary; no greetings, no closing offers, no essays. The user's message may be an install/update/solve/edit question ('how do I …') and/or may paste a problem, error, or command output: diagnose it from that and lead with the fix command(s)."
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos ai gemini <subcommand> [--model <id>] [--session <name>] [--system <text>] [--full] [--last]
|
||||
|
||||
Chat with Google Gemini via the REST API (generativelanguage.googleapis.com).
|
||||
|
||||
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 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> 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_GEMINI_API_KEY API key from aistudio.google.com (required)
|
||||
AI_GEMINI_MODEL Model id (default $DEFAULT_MODEL)
|
||||
|
||||
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 gemini 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 gemini ask "check disk space on /"
|
||||
pos ai gemini ask --full "Explain DNS in depth"
|
||||
echo "summarize this log" | pos ai gemini ask
|
||||
failing-cmd 2>&1 | pos ai gemini ask how do I fix this
|
||||
pos ai gemini ask --last "why did that fail?" # attach last output
|
||||
pos ai gemini capture pip install xyz # capture any command
|
||||
pos ai gemini ask --last "what happened?" # after capture
|
||||
pos ai gemini chat
|
||||
pos ai gemini models
|
||||
pos ai gemini ask --model gemini-2.5-flash "hi"
|
||||
pos ai gemini ask --system "Reply like a pirate" "explain chmod"
|
||||
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 default # forget default memory
|
||||
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
|
||||
}
|
||||
|
||||
# ── --last: attach the most recent pos command output ───────────
|
||||
# bin/pos logs every non-interactive run to DISPATCH_LOG_DIR/<ts>_pos_<cmd>.log
|
||||
# (ai-gemini itself is interactive-logged, so it never creates its own output
|
||||
# log). The <ts> prefix is zero-padded sortable, so name-descending = newest;
|
||||
# mtime alone would tie-flake for same-second runs. pos.log is the invocation
|
||||
# index, not command output — excluded.
|
||||
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
|
||||
}
|
||||
|
||||
# stdout = the context block body for log $1: its END kept (errors live at
|
||||
# the bottom), head-truncated to LAST_LOG_MAX_BYTES with a marker.
|
||||
last_log_context() {
|
||||
local raw
|
||||
raw="$(tail -c "$LAST_LOG_MAX_BYTES" "$1")"
|
||||
# a byte cut can split a multibyte char — drop invalid sequences when possible
|
||||
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
|
||||
}
|
||||
|
||||
# ── --last transparency (stderr-only; stdout stays pure answer) ──
|
||||
# Seconds → human age: just now / Nm / Nh / Nd.
|
||||
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
|
||||
}
|
||||
|
||||
# Tell the user WHICH pos log got attached and how fresh it is — on STDERR,
|
||||
# so a misread of an ancient log as the current failure is visible before
|
||||
# the model answers. $1 = log file (as recorded by last_log_context).
|
||||
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
|
||||
# preview: first meaningful line of the log (blank lines skipped)
|
||||
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 gemini ask "what happened"\n' "$age" >&2
|
||||
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 always run in a
|
||||
# session ('default' unless --session names another).
|
||||
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("")'
|
||||
}
|
||||
|
||||
# ── Terminal markdown rendering (tty-only; raw bytes otherwise) ──
|
||||
# On a tty: one blank line separates the answer from the prompt line above,
|
||||
# then fenced code indents+dims, `code`→yellow, **bold**/__bold__→bold,
|
||||
# #-headers (1-4)→bold cyan with #'s stripped, --- rules→thin rule, list
|
||||
# markers kept. glow(1) is used opportunistically when installed. The answer
|
||||
# ends with exactly one trailing newline.
|
||||
# stdout not a tty (pipes/scripts/Telegram bridges) → RAW markdown unchanged
|
||||
# (nothing added: single trailing newline only).
|
||||
render_markdown() {
|
||||
local text="$1"
|
||||
if [ ! -t 1 ]; then
|
||||
printf '%s\n' "$text"
|
||||
return 0
|
||||
fi
|
||||
local rendered prog
|
||||
# The awk program lives in a variable: its backtick regexes would be
|
||||
# parsed as command substitution inside $( … ). $() strips any
|
||||
# renderer-added trailing newlines; the final printf re-adds exactly
|
||||
# one, plus the leading blank separator line.
|
||||
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, /`[^`]*`/)) { # inline code first: keeps ** literal in backticks
|
||||
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"
|
||||
}
|
||||
|
||||
# ── Machine context appended to the built-in default prompt ─────
|
||||
# One compact clause so default answers fit the actual box. Collected
|
||||
# best-effort: hostnamectl(1) first (single call), then /etc/os-release +
|
||||
# uname(1) fill any gaps. Every source is optional and failures are
|
||||
# ignored — with nothing resolvable the clause is omitted entirely.
|
||||
|
||||
mc_clean() {
|
||||
# stdin→stdout: strip ANSI color sequences, drop control chars (emoji,
|
||||
# CR…), collapse all whitespace runs to single spaces, trim both ends.
|
||||
# (tr handles the control ranges: sed lacks \xHH inside [brackets].)
|
||||
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=""
|
||||
# Preferred single source: one hostnamectl status call.
|
||||
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
|
||||
# Fallback/complement for the distro: os-release (PRETTY_NAME, else
|
||||
# NAME + VERSION_ID). Sourced in a subshell — its vars stay local.
|
||||
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
|
||||
# Fallback/complement for kernel + arch: uname(1).
|
||||
[ -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)"
|
||||
# Normalize to the bare release: both hostnamectl and uname -sr report
|
||||
# "Linux <rel>".
|
||||
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"
|
||||
}
|
||||
|
||||
cmd_capture() {
|
||||
[ $# -gt 0 ] || err "usage: pos ai gemini 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="" contents 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 gemini 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=""
|
||||
# Priority 1: most recent pos dispatcher log
|
||||
log_file="$(newest_pos_log 2>/dev/null)" || true
|
||||
# Priority 2: captured output from any command
|
||||
if [ -z "$log_file" ] && [ -s "$LAST_CMD_OUTPUT_FILE" ]; then
|
||||
log_file="$LAST_CMD_OUTPUT_FILE"
|
||||
fi
|
||||
[ -n "$log_file" ] || err "No recent output found — run 'pos ai gemini capture <cmd>' first, or pipe: cmd 2>&1 | pos ai gemini 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). The
|
||||
# default prompt carries a machine-context clause so answers fit this
|
||||
# box; with no detectable facts it is omitted.
|
||||
system="$SYSTEM_PROMPT"
|
||||
if [ -z "$system" ] && [ "$FULL_MODE" -eq 0 ]; then
|
||||
mc="$(machine_context)"
|
||||
[ -n "$mc" ] && mc=" $mc"
|
||||
system="$DEFAULT_SYSTEM_PROMPT$mc"
|
||||
fi
|
||||
# Persistent session memory ('default' unless --session).
|
||||
contents="$(session_load)"
|
||||
contents="$(session_push "$contents" user "$prompt")"
|
||||
if ! out="$(gemini_generate "$(resolve_model)" "$contents" "$system" 2>&1)"; then
|
||||
err "$out"
|
||||
fi
|
||||
contents="$(session_push "$contents" model "$out")"
|
||||
session_save "$contents"
|
||||
render_markdown "$out"
|
||||
}
|
||||
|
||||
cmd_chat() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for chat: $*"
|
||||
local model contents text answer
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
contents="$(session_load)"
|
||||
printf 'session: %s (resumed %s prior turns)\n' "$SESSION" "$(printf '%s' "$contents" | jq -r '.contents | length')"
|
||||
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":[]}'
|
||||
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")"
|
||||
session_save "$contents"
|
||||
# The bare '> ' prompt has no newline: this one closes the prompt
|
||||
# line, and render_markdown's tty-only blank line then shows as the
|
||||
# visible gap before the answer. On a pipe both bytes are preserved.
|
||||
printf '\n'
|
||||
render_markdown "$answer"
|
||||
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 '.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=""
|
||||
FULL_MODE=0
|
||||
LAST_MODE=0
|
||||
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 ;;
|
||||
--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
|
||||
|
||||
if [ "$LAST_MODE" -eq 1 ] && [ "${cmd:-}" != "ask" ]; then
|
||||
err "--last only applies to 'ask' — for capturing output use 'capture': pos ai gemini capture <cmd>"
|
||||
fi
|
||||
|
||||
case "${cmd:-}" in
|
||||
"") usage ;;
|
||||
ask) cmd_ask "${args[@]}" ;;
|
||||
capture) cmd_capture "${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 "$@"
|
||||
|
||||
+5
-585
@@ -1,587 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: ai openrouter — Chat with OpenRouter models (ask, capture, chat, models, sessions)
|
||||
# POS_SUBCMDS: ask capture chat models sessions
|
||||
# POS_FLAGS: --model --session --system --full --last
|
||||
# POS_CONFIG: ai-openrouter | ai-openrouter.env | OPENROUTER_API_KEY=secret:API key from openrouter.ai (https://openrouter.ai/settings/keys) | OPENROUTER_MODEL=:Model id (default openrouter/auto)
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
CONFIG_FILE="$HOME/.config/linux_post_install/ai-openrouter.env"
|
||||
API="https://openrouter.ai/api/v1"
|
||||
DEFAULT_MODEL="openrouter/auto"
|
||||
SESSION_DIR="$HOME/.local/share/linux_post_install/ai-openrouter"
|
||||
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)
|
||||
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="You assist a user working in a Linux/Unix CLI terminal. Be extremely terse: lead with the exact command(s) to run; one-line explanations max; short bullets only when necessary; no greetings, no closing offers, no essays. The user's message may be an install/update/solve/edit question ('how do I …') and/or may paste a problem, error, or command output: diagnose it from that and lead with the fix command(s)."
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos ai openrouter <subcommand> [--model <id>] [--session <name>] [--system <text>] [--full] [--last]
|
||||
|
||||
Chat with OpenRouter models via the REST API (openrouter.ai).
|
||||
|
||||
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 and flag the configured default.
|
||||
sessions List persistent sessions / clear one:
|
||||
'sessions' and 'sessions reset <name>'.
|
||||
|
||||
Options:
|
||||
--model <id> Override the model for this invocation.
|
||||
--session <name> Use a named persistent session instead of 'default':
|
||||
~/.local/share/linux_post_install/ai-openrouter/<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-openrouter')
|
||||
OPENROUTER_API_KEY API key from openrouter.ai (required)
|
||||
OPENROUTER_MODEL Model id (default $DEFAULT_MODEL)
|
||||
|
||||
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 openrouter 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 openrouter ask "check disk space on /"
|
||||
pos ai openrouter ask --full "Explain DNS in depth"
|
||||
echo "summarize this log" | pos ai openrouter ask
|
||||
failing-cmd 2>&1 | pos ai openrouter ask how do I fix this
|
||||
pos ai openrouter ask --last "why did that fail?" # attach last output
|
||||
pos ai openrouter capture pip install xyz # capture any command
|
||||
pos ai openrouter ask --last "what happened?" # after capture
|
||||
pos ai openrouter chat
|
||||
pos ai openrouter ask --model anthropic/claude-sonnet-4 "hi"
|
||||
pos ai openrouter ask --system "Reply like a pirate" "explain chmod"
|
||||
pos ai openrouter ask --session work "my name is joe"
|
||||
pos ai openrouter ask --session work "what is my name?" # remembers
|
||||
pos ai openrouter sessions
|
||||
pos ai openrouter sessions reset default # forget default memory
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── ai-openrouter.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 "${OPENROUTER_API_KEY:-}" ] || err "No OpenRouter API key — run 'pos config ai-openrouter'"
|
||||
}
|
||||
|
||||
resolve_model() {
|
||||
if [ -n "${MODEL_OVERRIDE:-}" ]; then
|
||||
printf '%s' "$MODEL_OVERRIDE"
|
||||
elif [ -n "${OPENROUTER_MODEL:-}" ]; then
|
||||
printf '%s' "$OPENROUTER_MODEL"
|
||||
else
|
||||
printf '%s' "$DEFAULT_MODEL"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── --last: attach the most recent pos command output ───────────
|
||||
# bin/pos logs every non-interactive run to DISPATCH_LOG_DIR/<ts>_pos_<cmd>.log
|
||||
# (ai-openrouter itself is interactive-logged, so it never creates its own output
|
||||
# log). The <ts> prefix is zero-padded sortable, so name-descending = newest;
|
||||
# mtime alone would tie-flake for same-second runs. pos.log is the invocation
|
||||
# index, not command output — excluded.
|
||||
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
|
||||
}
|
||||
|
||||
# stdout = the context block body for log $1: its END kept (errors live at
|
||||
# the bottom), head-truncated to LAST_LOG_MAX_BYTES with a marker.
|
||||
last_log_context() {
|
||||
local raw
|
||||
raw="$(tail -c "$LAST_LOG_MAX_BYTES" "$1")"
|
||||
# a byte cut can split a multibyte char — drop invalid sequences when possible
|
||||
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
|
||||
}
|
||||
|
||||
# ── --last transparency (stderr-only; stdout stays pure answer) ──
|
||||
# Seconds → human age: just now / Nm / Nh / Nd.
|
||||
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
|
||||
}
|
||||
|
||||
# Tell the user WHICH pos log got attached and how fresh it is — on STDERR,
|
||||
# so a misread of an ancient log as the current failure is visible before
|
||||
# the model answers. $1 = log file (as recorded by last_log_context).
|
||||
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
|
||||
# preview: first meaningful line of the log (blank lines skipped)
|
||||
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 openrouter ask "what happened"\n' "$age" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Persistent session memory ───────────────────────────────────
|
||||
# History lives as an OpenAI-style "messages" JSON document per session name under
|
||||
# SESSION_DIR. Names are sanitized to [A-Za-z0-9_-]; ask/chat always run in a
|
||||
# session ('default' unless --session names another).
|
||||
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" ] && jq -e '.messages' "$f" >/dev/null 2>&1; then
|
||||
cat "$f"
|
||||
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"':]'
|
||||
}
|
||||
|
||||
# One OpenRouter chat completion call. $1 = model, $2 = messages JSON, $3 = optional
|
||||
# system instruction (prepended as a system message, not stored in the session).
|
||||
# stdout = the answer text on success; an error message on failure (exit 1).
|
||||
openrouter_generate() {
|
||||
local model="$1" messages_json="$2" system="${3:-}" body resp code body_out errmsg
|
||||
if [ -n "$system" ]; then
|
||||
body="$(printf '%s' "$messages_json" | jq -c --arg s "$system" \
|
||||
'[{role:"system",content:$s}] + .messages')"
|
||||
else
|
||||
body="$(printf '%s' "$messages_json" | 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 "${API}/chat/completions" \
|
||||
-H "Authorization: Bearer ${OPENROUTER_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 // ""'
|
||||
}
|
||||
|
||||
# ── Terminal markdown rendering (tty-only; raw bytes otherwise) ──
|
||||
# On a tty: one blank line separates the answer from the prompt line above,
|
||||
# then fenced code indents+dims, `code`→yellow, **bold**/__bold__→bold,
|
||||
# #-headers (1-4)→bold cyan with #'s stripped, --- rules→thin rule, list
|
||||
# markers kept. glow(1) is used opportunistically when installed. The answer
|
||||
# ends with exactly one trailing newline.
|
||||
# stdout not a tty (pipes/scripts/Telegram bridges) → RAW markdown unchanged
|
||||
# (nothing added: single trailing newline only).
|
||||
render_markdown() {
|
||||
local text="$1"
|
||||
if [ ! -t 1 ]; then
|
||||
printf '%s\n' "$text"
|
||||
return 0
|
||||
fi
|
||||
local rendered prog
|
||||
# The awk program lives in a variable: its backtick regexes would be
|
||||
# parsed as command substitution inside $( … ). $() strips any
|
||||
# renderer-added trailing newlines; the final printf re-adds exactly
|
||||
# one, plus the leading blank separator line.
|
||||
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, /`[^`]*`/)) { # inline code first: keeps ** literal in backticks
|
||||
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"
|
||||
}
|
||||
|
||||
# ── Machine context appended to the built-in default prompt ─────
|
||||
# One compact clause so default answers fit the actual box. Collected
|
||||
# best-effort: hostnamectl(1) first (single call), then /etc/os-release +
|
||||
# uname(1) fill any gaps. Every source is optional and failures are
|
||||
# ignored — with nothing resolvable the clause is omitted entirely.
|
||||
|
||||
mc_clean() {
|
||||
# stdin→stdout: strip ANSI color sequences, drop control chars (emoji,
|
||||
# CR…), collapse all whitespace runs to single spaces, trim both ends.
|
||||
# (tr handles the control ranges: sed lacks \xHH inside [brackets].)
|
||||
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=""
|
||||
# Preferred single source: one hostnamectl status call.
|
||||
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
|
||||
# Fallback/complement for the distro: os-release (PRETTY_NAME, else
|
||||
# NAME + VERSION_ID). Sourced in a subshell — its vars stay local.
|
||||
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
|
||||
# Fallback/complement for kernel + arch: uname(1).
|
||||
[ -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)"
|
||||
# Normalize to the bare release: both hostnamectl and uname -sr report
|
||||
# "Linux <rel>".
|
||||
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"
|
||||
}
|
||||
|
||||
cmd_capture() {
|
||||
[ $# -gt 0 ] || err "usage: pos ai openrouter 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 openrouter 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=""
|
||||
# Priority 1: most recent pos dispatcher log
|
||||
log_file="$(newest_pos_log 2>/dev/null)" || true
|
||||
# Priority 2: captured output from any command
|
||||
if [ -z "$log_file" ] && [ -s "$LAST_CMD_OUTPUT_FILE" ]; then
|
||||
log_file="$LAST_CMD_OUTPUT_FILE"
|
||||
fi
|
||||
[ -n "$log_file" ] || err "No recent output found — run 'pos ai openrouter capture <cmd>' first, or pipe: cmd 2>&1 | pos ai openrouter 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). The
|
||||
# default prompt carries a machine-context clause so answers fit this
|
||||
# box; with no detectable facts it is omitted.
|
||||
system="$SYSTEM_PROMPT"
|
||||
if [ -z "$system" ] && [ "$FULL_MODE" -eq 0 ]; then
|
||||
mc="$(machine_context)"
|
||||
[ -n "$mc" ] && mc=" $mc"
|
||||
system="$DEFAULT_SYSTEM_PROMPT$mc"
|
||||
fi
|
||||
# Persistent session memory ('default' unless --session).
|
||||
messages="$(session_load)"
|
||||
messages="$(session_push "$messages" user "$prompt")"
|
||||
if ! out="$(openrouter_generate "$(resolve_model)" "$messages" "$system" 2>&1)"; then
|
||||
err "$out"
|
||||
fi
|
||||
messages="$(session_push "$messages" assistant "$out")"
|
||||
session_save "$messages"
|
||||
render_markdown "$out"
|
||||
}
|
||||
|
||||
cmd_chat() {
|
||||
[ $# -eq 0 ] || err "Unexpected argument for chat: $*"
|
||||
local model messages text answer
|
||||
model="$(resolve_model)"
|
||||
require_key
|
||||
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 "OpenRouter · ${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="$(openrouter_generate "$model" "$messages" "$SYSTEM_PROMPT" 2>&1)"; then
|
||||
warn "AI error: $answer"
|
||||
continue
|
||||
fi
|
||||
messages="$(session_push "$messages" assistant "$answer")"
|
||||
session_save "$messages"
|
||||
# The bare '> ' prompt has no newline: this one closes the prompt
|
||||
# line, and render_markdown's tty-only blank line then shows as the
|
||||
# visible gap before the answer. On a pipe both bytes are preserved.
|
||||
printf '\n'
|
||||
render_markdown "$answer"
|
||||
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 openrouter 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 "${API}/models" \
|
||||
-H "Authorization: Bearer ${OPENROUTER_API_KEY}" \
|
||||
--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 '.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"; then
|
||||
warn "configured default '$model' is not in the list — set OPENROUTER_MODEL"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Parse flags + subcommand ────────────────────────────────────
|
||||
MODEL_OVERRIDE=""
|
||||
FULL_MODE=0
|
||||
LAST_MODE=0
|
||||
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 ;;
|
||||
--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
|
||||
|
||||
if [ "$LAST_MODE" -eq 1 ] && [ "${cmd:-}" != "ask" ]; then
|
||||
err "--last only applies to 'ask' — for capturing output use 'capture': pos ai openrouter capture <cmd>"
|
||||
fi
|
||||
|
||||
case "${cmd:-}" in
|
||||
"") usage ;;
|
||||
ask) cmd_ask "${args[@]}" ;;
|
||||
capture) cmd_capture "${args[@]}" ;;
|
||||
chat) cmd_chat "${args[@]}" ;;
|
||||
models) cmd_models "${args[@]}" ;;
|
||||
sessions) cmd_sessions "${args[@]}" ;;
|
||||
*) err "Unknown ai openrouter subcommand '$cmd' (see --help)" ;;
|
||||
esac
|
||||
# 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,17 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: communication telegram-listener — Telegram bot listener: map /command → bash, run them on chat messages
|
||||
# POS: communication telegram-listener — Telegram bot listener: map /command → bash and <prefix> → app, run them on chat messages
|
||||
# POS_FLAGS: --enable --disable --status --sync-commands --run
|
||||
# POS_SUBCMDS: prefix
|
||||
|
||||
CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
|
||||
CONFIG_FILE="$CONFIG_DIR/telegram.env"
|
||||
MAP_FILE="$CONFIG_DIR/telegram_commands.env"
|
||||
PREFIX_FILE="$CONFIG_DIR/telegram_prefixes.env"
|
||||
API="https://api.telegram.org"
|
||||
SERVICE="pos-telegram-listener.service"
|
||||
USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
|
||||
# System prompt for the "ai " bridge: replies are posted straight into the
|
||||
# chat, so ask for concise, emoji-friendly Telegram-style answers.
|
||||
# System prompt for the "<prefix> " AI bridge (default prefix: "ai"): replies
|
||||
# are posted straight into the chat, so ask for concise, emoji-friendly
|
||||
# Telegram-style answers. The trigger word is configurable via
|
||||
# TELEGRAM_AI_PREFIX in telegram.env ('pos config telegram', default 'ai');
|
||||
# the text-prefix map checked before it can override any word.
|
||||
AI_SYSTEM="You are a friendly assistant chatting in a Telegram chat. Keep replies concise, use emojis and light formatting to make them lively, and never claim to send messages yourself."
|
||||
|
||||
err() { echo "ERROR: $*" >&2; exit 1; }
|
||||
@@ -32,10 +37,25 @@ Commands:
|
||||
--sync-commands
|
||||
Push the mapped /commands to the bot's "/" menu (setMyCommands)
|
||||
--run Run the polling loop in the foreground (used by the service)
|
||||
prefix [word [command...]]
|
||||
Manage the text-prefix map (telegram_prefixes.env): any
|
||||
non-command message '<prefix> <text>' runs the mapped command
|
||||
with <text> as ONE argument. Bare: list; <word>: show one;
|
||||
<word> <command...>: map (e.g. 'prefix opencode opencode' →
|
||||
"opencode check cpu" runs 'opencode "check cpu"');
|
||||
-r <word>: remove. Built-in Gemini bridge word (default 'ai')
|
||||
is set via 'pos config telegram' (TELEGRAM_AI_PREFIX).
|
||||
|
||||
Config: $CONFIG_FILE (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID — edit with 'pos config telegram')
|
||||
Config: $CONFIG_FILE (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID,
|
||||
TELEGRAM_AI_PREFIX — edit with 'pos config telegram')
|
||||
Map: $MAP_FILE — '/cmd=bash command' per line (optional
|
||||
'/cmd::short description=bash command' shown in the bot menu)
|
||||
Prefix map: $PREFIX_FILE — '<word>=command' per line: a non-command
|
||||
message starting with '<word> <text>' runs the command with <text>
|
||||
appended as ONE quoted argument. First match wins (file order),
|
||||
case-insensitive; a mapped word shadows the built-in Gemini 'ai'
|
||||
bridge. Routing order: text-prefix map → AI bridge → /command map →
|
||||
'Unknown command'. Manage it with the 'prefix' verb.
|
||||
|
||||
Prefix a map value with '@quiet ' to run the command without replying —
|
||||
for commands that already send their own notification, so you don't get it
|
||||
@@ -241,6 +261,105 @@ strip_quiet() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ── text-prefix map (PREFIX_FILE) ───────────────────────────────
|
||||
# Lines: <word>=command. Unlike the /command map (exact match on the whole
|
||||
# message), a matching <word> at the START of a non-command message routes
|
||||
# the REST of the message to the mapped command as ONE quoted argument:
|
||||
# 'opencode=opencode' + message "opencode check cpu" → 'opencode "check cpu"'
|
||||
# The map is re-read per message (edits apply without restarting), matching
|
||||
# is case-insensitive, the FIRST matching line wins (file order), a bare
|
||||
# <word> with no trailing space does NOT match, and a mapped word shadows the
|
||||
# built-in Gemini bridge in handle_message.
|
||||
|
||||
prefix_map_find() {
|
||||
[ -f "$PREFIX_FILE" ] || return 1
|
||||
local text="$1" line word cmd rem
|
||||
shopt -s nocasematch
|
||||
while IFS= read -r line; do
|
||||
case "$line" in \#*|'') continue ;; esac
|
||||
word="${line%%=*}"
|
||||
cmd="${line#*=}"
|
||||
word="${word# }"
|
||||
cmd="${cmd# }"
|
||||
[ -n "$word" ] && [ -n "$cmd" ] || continue
|
||||
if [[ "$text" =~ ^"$word"[[:space:]](.*)$ ]]; then
|
||||
rem="${BASH_REMATCH[1]}"
|
||||
[ -n "$rem" ] || continue
|
||||
shopt -u nocasematch
|
||||
printf '%s\x1f%s\n' "$cmd" "$rem"
|
||||
return 0
|
||||
fi
|
||||
done < "$PREFIX_FILE"
|
||||
shopt -u nocasematch
|
||||
return 1
|
||||
}
|
||||
|
||||
prefix_map_set() {
|
||||
local word="$1" cmd="$2"
|
||||
if ! [[ "$word" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then
|
||||
err "invalid prefix '$word' — use one word of letters, digits, '-' or '_' (no spaces)"
|
||||
fi
|
||||
local errs
|
||||
errs="$(check_syntax "$cmd" 2>&1)" || err "invalid command for '$word': $errs"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
touch "$PREFIX_FILE"
|
||||
chmod 600 "$PREFIX_FILE"
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
awk -v k="$word" 'index($0, k "=") != 1 { print }' "$PREFIX_FILE" > "$tmp"
|
||||
printf '%s=%s\n' "$word" "$cmd" >> "$tmp"
|
||||
mv "$tmp" "$PREFIX_FILE"
|
||||
chmod 600 "$PREFIX_FILE"
|
||||
}
|
||||
|
||||
prefix_map_del() {
|
||||
[ -f "$PREFIX_FILE" ] || return 1
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
awk -v k="$1" 'index($0, k "=") != 1 { print }' "$PREFIX_FILE" > "$tmp"
|
||||
if cmp -s "$tmp" "$PREFIX_FILE"; then
|
||||
rm -f "$tmp"
|
||||
return 1
|
||||
fi
|
||||
mv "$tmp" "$PREFIX_FILE"
|
||||
chmod 600 "$PREFIX_FILE"
|
||||
return 0
|
||||
}
|
||||
|
||||
prefix_map_show() {
|
||||
local word="$1" line key
|
||||
[ -f "$PREFIX_FILE" ] || return 1
|
||||
while IFS= read -r line; do
|
||||
case "$line" in \#*|'') continue ;; esac
|
||||
key="${line%%=*}"
|
||||
if [ "$key" = "$word" ]; then
|
||||
printf '%s -> %s\n' "$key" "${line#*=}"
|
||||
return 0
|
||||
fi
|
||||
done < "$PREFIX_FILE"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Run a mapped command line and reply with its output: empty output → "OK",
|
||||
# non-zero exit → "exit <rc>" + output; quiet=1 suppresses the reply (for
|
||||
# '@quiet ' entries that self-notify). Used by the /command map (60s cap)
|
||||
# and the text-prefix bridge (120s cap for app calls).
|
||||
run_and_reply() {
|
||||
local cmdline="$1" msg_id="$2" tmo="${3:-120}" quiet="${4:-0}" output rc
|
||||
if output="$(timeout "$tmo" bash -c "$cmdline" 2>&1)"; then
|
||||
rc=0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
[ "$quiet" -eq 1 ] && return
|
||||
[ -n "$output" ] || output="OK"
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
reply "$(printf 'exit %s\n%s' "$rc" "$output")" "$msg_id" "$rc"
|
||||
else
|
||||
reply "$output" "$msg_id"
|
||||
fi
|
||||
}
|
||||
|
||||
ui_run_command() {
|
||||
local value="$1" output rc
|
||||
value="$(strip_quiet "$value")"
|
||||
@@ -417,12 +536,95 @@ status() {
|
||||
fi
|
||||
echo "config: $CONFIG_FILE"
|
||||
echo "map file: $MAP_FILE"
|
||||
echo "prefix map: $PREFIX_FILE"
|
||||
load_config
|
||||
echo "ai prefix: ${TELEGRAM_AI_PREFIX:-ai}"
|
||||
load_map
|
||||
echo "commands: $MAP_N mapped"
|
||||
local i
|
||||
for ((i=1; i<=MAP_N; i++)); do
|
||||
printf ' %-16s -> %s%s\n' "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" "${MAP_DESCS[$i]:+ (${MAP_DESCS[$i]})}"
|
||||
done
|
||||
if [ -f "$PREFIX_FILE" ] && [ -s "$PREFIX_FILE" ]; then
|
||||
local pline pword pcmd pn=0
|
||||
echo "prefixes: ('<prefix> <text>' routes to the mapped app)"
|
||||
while IFS= read -r pline; do
|
||||
case "$pline" in \#*|'') continue ;; esac
|
||||
pword="${pline%%=*}" pcmd="${pline#*=}"
|
||||
pn=$((pn + 1))
|
||||
printf ' %-16s -> %s\n' "$pword" "$pcmd"
|
||||
done < "$PREFIX_FILE"
|
||||
[ "$pn" -gt 0 ] || echo " (none)"
|
||||
else
|
||||
echo "prefixes: (none)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── text-prefix map + built-in Gemini bridge word (prefix verb) ──
|
||||
# 'prefix' manages PREFIX_FILE (word → command). The built-in Gemini bridge
|
||||
# word (TELEGRAM_AI_PREFIX, default 'ai') is set via 'pos config telegram';
|
||||
# it is only reached when no text-prefix entry matches first.
|
||||
prefix_cmd() {
|
||||
local arg="${1:-}" rest="${*:2}"
|
||||
if [ -z "$arg" ]; then
|
||||
load_config
|
||||
echo "Text-prefix map ($PREFIX_FILE): messages like '<prefix> <text>' run the"
|
||||
echo "mapped command with <text> passed as ONE argument. First match wins"
|
||||
echo "(file order), case-insensitive; a mapped word shadows the built-in"
|
||||
echo "Gemini bridge. A bare <prefix> with no trailing space does not match."
|
||||
echo
|
||||
if [ -f "$PREFIX_FILE" ] && [ -s "$PREFIX_FILE" ]; then
|
||||
local line word cmd
|
||||
while IFS= read -r line; do
|
||||
case "$line" in \#*|'') continue ;; esac
|
||||
word="${line%%=*}" cmd="${line#*=}"
|
||||
printf ' %-16s -> %s\n' "$word" "$cmd"
|
||||
done < "$PREFIX_FILE"
|
||||
else
|
||||
echo " (none)"
|
||||
fi
|
||||
echo
|
||||
echo "Built-in Gemini bridge word: ${TELEGRAM_AI_PREFIX:-ai} (set via 'pos config telegram')"
|
||||
echo
|
||||
echo "Usage:"
|
||||
echo " prefix list this map + the Gemini bridge word"
|
||||
echo " prefix <word> show one mapping"
|
||||
echo " prefix <word> <command...> map <word> to a command"
|
||||
echo " prefix -r <word> remove a mapping"
|
||||
echo "Examples:"
|
||||
echo " pos communication telegram listener prefix opencode opencode"
|
||||
echo " # then sending 'opencode check cpu' runs: opencode \"check cpu\""
|
||||
return 0
|
||||
fi
|
||||
case "$arg" in
|
||||
-r|--remove)
|
||||
[ -n "$rest" ] || err "usage: prefix -r <word>"
|
||||
if prefix_map_del "$rest"; then
|
||||
log "removed prefix mapping '$rest'"
|
||||
else
|
||||
warn "no prefix mapping for '$rest'"
|
||||
fi
|
||||
return 0 ;;
|
||||
esac
|
||||
if [ -z "$rest" ]; then
|
||||
prefix_map_show "$arg" && return 0
|
||||
warn "no prefix mapping for '$arg'"
|
||||
echo "Map one with: prefix <word> <command...> (e.g. prefix opencode opencode)"
|
||||
echo "The built-in Gemini bridge word is set via 'pos config telegram' (TELEGRAM_AI_PREFIX)."
|
||||
return 1
|
||||
fi
|
||||
prefix_map_set "$arg" "$rest"
|
||||
log "prefix '$arg' -> '$rest' — sending '<$arg> <text>' runs: $rest \"<text>\" (takes effect immediately, no restart)"
|
||||
}
|
||||
|
||||
# Current AI-bridge trigger word. Like the command map, re-read per message so
|
||||
# 'prefix <word>' edits apply without restarting the daemon. Precedence:
|
||||
# telegram.env > env var from load_config (--run) > default 'ai'.
|
||||
ai_bridge_prefix() {
|
||||
local v=""
|
||||
[ -f "$CONFIG_FILE" ] && v="$(grep -E '^TELEGRAM_AI_PREFIX=' "$CONFIG_FILE" | tail -1 | sed 's/^[^=]*=//; s/^["'\'']//; s/["'\'']$//')" || true
|
||||
[ -n "$v" ] || v="${TELEGRAM_AI_PREFIX:-ai}"
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
# ── polling daemon ──────────────────────────────────────────────
|
||||
@@ -454,20 +656,45 @@ strip_markdown() {
|
||||
}
|
||||
|
||||
handle_message() {
|
||||
local text="$1" msg_id="$2" reply_text="${3:-}" value output rc quiet=0
|
||||
local text="$1" msg_id="$2" reply_text="${3:-}" value quiet=0
|
||||
case "$text" in
|
||||
/help|/start)
|
||||
reply "Mapped commands: $(map_cmds_list)" "$msg_id"
|
||||
return ;;
|
||||
esac
|
||||
# AI bridge: non-command text starting with "ai " (case-insensitive) is
|
||||
# forwarded to Gemini; the model's answer is replied verbatim. Each chat
|
||||
# gets its own persistent memory session ("telegram-<chat_id>"); the exact
|
||||
# prompt "ai /reset" clears it. Future non-command intents (e.g. reminders)
|
||||
# slot in as more case arms here.
|
||||
if [[ "$text" != /* && "$text" =~ ^[Aa][Ii][[:space:]](.*)$ ]]; then
|
||||
# Text-prefix bridge: '<prefix> <text>' runs the mapped command with
|
||||
# <text> passed as ONE quoted argument (telegram_prefixes.env), e.g.
|
||||
# 'opencode=opencode' → sending "opencode check cpu" runs
|
||||
# 'opencode "check cpu"'. Re-read per message, first match wins (file
|
||||
# order), case-insensitive; a mapped word shadows the built-in Gemini
|
||||
# bridge below. A bare <prefix> with no trailing space does not match.
|
||||
local pv cmd t qtext
|
||||
if pv="$(prefix_map_find "$text")"; then
|
||||
cmd="${pv%%$'\x1f'*}"
|
||||
t="${pv#*$'\x1f'}"
|
||||
if [ "${cmd#"$QUIET_PREFIX "}" != "$cmd" ]; then
|
||||
quiet=1
|
||||
cmd="${cmd#"$QUIET_PREFIX "}"
|
||||
fi
|
||||
qtext="$(printf '%q' "$t")"
|
||||
log "prefix: $text"
|
||||
run_and_reply "$cmd $qtext" "$msg_id" 120 "$quiet"
|
||||
return
|
||||
fi
|
||||
# AI bridge: non-command text starting with "<prefix> " (default "ai",
|
||||
# case-insensitive, configurable via TELEGRAM_AI_PREFIX in 'pos config
|
||||
# telegram') is forwarded to Gemini; the model's answer is replied
|
||||
# verbatim. Each chat gets its own persistent memory session
|
||||
# ("telegram-<chat_id>"); the exact prompt "<prefix> /reset" clears it.
|
||||
# Future non-command intents (e.g. reminders) slot in as more case arms
|
||||
# here.
|
||||
local prefix
|
||||
prefix="$(ai_bridge_prefix)"
|
||||
shopt -s nocasematch
|
||||
if [[ "$text" != /* && "$text" =~ ^"$prefix"[[:space:]](.*)$ ]]; then
|
||||
shopt -u nocasematch
|
||||
local prompt="${BASH_REMATCH[1]}" answer session
|
||||
[ -n "$prompt" ] || { reply "Usage: ai <prompt> — e.g. 'ai what is Nvidia'" "$msg_id"; return; }
|
||||
[ -n "$prompt" ] || { reply "Usage: $prefix <prompt> — e.g. '$prefix what is Nvidia'" "$msg_id"; return; }
|
||||
session="telegram-${TELEGRAM_CHAT_ID}"
|
||||
if [[ "$prompt" =~ ^/?reset[[:space:]]*$ ]]; then
|
||||
if pos ai gemini sessions reset "$session" >/dev/null 2>&1; then
|
||||
@@ -477,7 +704,7 @@ handle_message() {
|
||||
fi
|
||||
return
|
||||
fi
|
||||
log "ai: $prompt"
|
||||
log "$prefix: $prompt"
|
||||
if [ -n "$reply_text" ]; then
|
||||
prompt="[Reply context — the message you are replying to]\n${reply_text}\n\n${prompt}"
|
||||
fi
|
||||
@@ -489,6 +716,7 @@ handle_message() {
|
||||
fi
|
||||
return
|
||||
fi
|
||||
shopt -u nocasematch
|
||||
value="$(map_get "$text")"
|
||||
if [ -z "$value" ]; then
|
||||
reply "Unknown command: $text (send /help)" "$msg_id"
|
||||
@@ -499,20 +727,7 @@ handle_message() {
|
||||
value="${value#"$QUIET_PREFIX "}"
|
||||
fi
|
||||
log "exec: $text"
|
||||
if output="$(timeout 60 bash -c "$value" 2>&1)"; then
|
||||
rc=0
|
||||
else
|
||||
rc=$?
|
||||
fi
|
||||
[ "$quiet" -eq 1 ] && return
|
||||
if [ -z "$output" ]; then
|
||||
output="OK"
|
||||
fi
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
reply "$(printf 'exit %s\n%s' "$rc" "$output")" "$msg_id" "$rc"
|
||||
else
|
||||
reply "$output" "$msg_id"
|
||||
fi
|
||||
run_and_reply "$value" "$msg_id" 60 "$quiet"
|
||||
}
|
||||
|
||||
run_daemon() {
|
||||
@@ -561,6 +776,7 @@ case "${1:-}" in
|
||||
--status) status ;;
|
||||
--sync-commands) sync_bot_commands ;;
|
||||
--run) run_daemon ;;
|
||||
prefix) prefix_cmd "${@:2}" ;;
|
||||
"") ui ;;
|
||||
*) err "Unknown option '$1' (see --help)" ;;
|
||||
esac
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
# POS: communication telegram-sender — Send Telegram messages/files/links/stickers via Bot API (send, test)
|
||||
# POS_FLAGS: --type --caption --parse-mode --no-preview --token --chat-id --markdown
|
||||
# POS_SUBCMDS: send test
|
||||
# POS_CONFIG: telegram | telegram.env | TELEGRAM_BOT_TOKEN=secret:Bot token from @BotFather | TELEGRAM_CHAT_ID=digits:Numeric chat id from @userinfobot
|
||||
# POS_CONFIG: telegram | telegram.env | TELEGRAM_BOT_TOKEN=secret:Bot token from @BotFather | TELEGRAM_CHAT_ID=digits:Numeric chat id from @userinfobot | TELEGRAM_AI_PREFIX=:AI-bridge trigger word in the telegram listener (default ai)::ai
|
||||
|
||||
CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
|
||||
CONFIG_FILE="$CONFIG_DIR/telegram.env"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -3,6 +3,9 @@ set -euo pipefail
|
||||
# POS: media sync — Incremental Music → USB sync (mp3/mp4, add/update only)
|
||||
# POS_FLAGS: --mp3 --mp4 --source --dry-run
|
||||
# POS_SUBCMDS: menu
|
||||
# POS_DEPS: lsblk jq
|
||||
# POS_EXAMPLES: pos media sync --mp3 | Sync only MP3 files to USB
|
||||
# POS_EXAMPLES: pos media sync --mp4 --dry-run | Preview MP4 sync without copying
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
|
||||
|
||||
@@ -3,6 +3,10 @@ set -euo pipefail
|
||||
# POS: network download — aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits)
|
||||
# POS_SUBCMDS: start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch restart retry replace menu
|
||||
# POS_FLAGS: --dir --out --split --seed --force --upload --gid --tmux
|
||||
# POS_DEPS: aria2c jq curl
|
||||
# POS_EXAMPLES: pos network download add https://example.com/file.zip | Enqueue an HTTP download (auto-starts daemon)
|
||||
# POS_EXAMPLES: pos network download status | Daemon health + global transfer stats
|
||||
# POS_EXAMPLES: pos network download watch | Live progress view
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
|
||||
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
# POS_FLAGS: --service --no-encrypt
|
||||
# POS_SUBCMDS: menu
|
||||
# POS_CONFIG: notify | notify.env | NOTIFY_PLATFORM=:Comma-separated notify platforms (default telegram) — shared by backup, firewall, share nfs client/server
|
||||
# POS_DEPS: tar
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
|
||||
|
||||
Executable
+435
@@ -0,0 +1,435 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: system uninstall — Remove pos toolkit binaries, services, shell integration, config, and data
|
||||
# POS_FLAGS: --yes --config --data
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: pos system uninstall [--yes] [--config] [--data]
|
||||
|
||||
Remove the pos toolkit from this machine. Scans for installed components
|
||||
and removes them interactively (or non-interactively with --yes).
|
||||
|
||||
Tiers:
|
||||
Tier 1 (always): binaries, entertainment plugins, systemd services, shell integration
|
||||
Tier 2 (--config): config files in ~/.config/linux_post_install/
|
||||
Tier 3 (--data): session/log/capture data in ~/.local/share/linux_post_install/
|
||||
|
||||
Flags:
|
||||
--yes Skip confirmation prompts (removes defaults only; combine with --config/--data for more)
|
||||
--config Include config files (Tier 2) in removal
|
||||
--data Include session/log data (Tier 3) in removal
|
||||
|
||||
Examples:
|
||||
pos system uninstall # interactive, tier 1 only
|
||||
pos system uninstall --yes # non-interactive, tier 1 only
|
||||
pos system uninstall --yes --config --data # nuclear option
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── Parse flags ─────────────────────────────────────────────────
|
||||
YES_MODE=0
|
||||
DEL_CONFIG=0
|
||||
DEL_DATA=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help) usage ;;
|
||||
--yes) YES_MODE=1; shift ;;
|
||||
--config) DEL_CONFIG=1; shift ;;
|
||||
--data) DEL_DATA=1; shift ;;
|
||||
-*) err "Unknown option '$1'" ;;
|
||||
*) err "Unexpected argument: $1 (no subcommands)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Scan functions ──────────────────────────────────────────────
|
||||
scan_tier1() {
|
||||
local found=()
|
||||
|
||||
# ── Binaries in /usr/local/bin ──
|
||||
# Main dispatcher
|
||||
[ -f /usr/local/bin/pos ] && found+=("/usr/local/bin/pos")
|
||||
|
||||
# All pos-* tools (sorted for deterministic display)
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
found+=("$f")
|
||||
done < <(compgen -G /usr/local/bin/pos-* 2>/dev/null | sort || true)
|
||||
|
||||
# Lib files shipped by install.sh
|
||||
for f in common.sh menu-lib.sh share-lib.sh; do
|
||||
[ -f "/usr/local/bin/$f" ] && found+=("/usr/local/bin/$f")
|
||||
done
|
||||
|
||||
# AI providers subdirectory
|
||||
[ -d /usr/local/bin/ai-providers ] && found+=("/usr/local/bin/ai-providers/")
|
||||
|
||||
# Entertainment plugins installed by install.sh
|
||||
while IFS= read -r f; do
|
||||
found+=("$f")
|
||||
done < <(for ep in weather.sh gold.sh joke.sh; do
|
||||
[ -f "/usr/local/bin/$ep" ] && echo "/usr/local/bin/$ep"
|
||||
done | sort)
|
||||
|
||||
# Legacy forwarders
|
||||
for f in wr-* mp3 mp4 vbox ssh-load-all; do
|
||||
if compgen -G "/usr/local/bin/$f" >/dev/null 2>&1; then
|
||||
while IFS= read -r lf; do
|
||||
found+=("$lf")
|
||||
done < <(compgen -G "/usr/local/bin/$f" 2>/dev/null)
|
||||
fi
|
||||
done
|
||||
|
||||
# Prebuilt binaries (hotspot)
|
||||
for f in wihotspot wihotspot-gui create_ap; do
|
||||
[ -f "/usr/local/bin/$f" ] && found+=("/usr/local/bin/$f")
|
||||
done
|
||||
|
||||
# Feature scripts installed by install.sh
|
||||
for f in autostart.sh usb-automount.sh; do
|
||||
[ -f "/usr/local/bin/$f" ] && found+=("/usr/local/bin/$f")
|
||||
done
|
||||
|
||||
# User-local binaries
|
||||
[ -f "$HOME/.local/bin/pos-ai-hook.sh" ] && found+=("$HOME/.local/bin/pos-ai-hook.sh")
|
||||
|
||||
# pos ai alias wrapper scripts (marker-managed)
|
||||
if [ -d "$HOME/.local/bin" ]; then
|
||||
local awf
|
||||
for awf in "$HOME/.local/bin"/*; do
|
||||
[ -f "$awf" ] || continue
|
||||
grep -q 'Managed by pos ai alias' "$awf" 2>/dev/null && found+=("$awf")
|
||||
done
|
||||
fi
|
||||
|
||||
# Completion file
|
||||
[ -f /usr/local/share/bash-completion/completions/pos.bash ] && found+=("/usr/local/share/bash-completion/completions/pos.bash")
|
||||
|
||||
# ~/.bash_completion entries
|
||||
if [ -f "$HOME/.bash_completion" ]; then
|
||||
while IFS= read -r line; do
|
||||
found+=("~/.bash_completion: $(echo "$line" | sed 's/^[[:space:]]*//' | cut -c1-70)")
|
||||
done < <(grep -n 'pos' "$HOME/.bash_completion" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# ── Systemd services ──
|
||||
for svc in autostart.service ssh-agent.service usb-automount.service; do
|
||||
if systemctl is-enabled "$svc" &>/dev/null 2>&1; then
|
||||
found+=("service: $svc")
|
||||
elif [ -f "/etc/systemd/system/$svc" ]; then
|
||||
found+=("service: $svc")
|
||||
fi
|
||||
done
|
||||
# Catch any other linux_post_install-related services
|
||||
while IFS= read -r line; do
|
||||
local svc_name
|
||||
svc_name=$(echo "$line" | awk '{print $1}')
|
||||
# Skip already-listed services
|
||||
local already=0
|
||||
for listed in autostart.service ssh-agent.service usb-automount.service; do
|
||||
[ "$svc_name" = "$listed" ] && already=1 && break
|
||||
done
|
||||
[ "$already" -eq 0 ] && found+=("service: $svc_name")
|
||||
done < <(systemctl list-unit-files --type=service 2>/dev/null | grep -i 'linux_post_install\|pos-' || true)
|
||||
|
||||
# ── Shell integration (~/.bashrc) ──
|
||||
if [ -f "$HOME/.bashrc" ]; then
|
||||
while IFS= read -r line; do
|
||||
found+=("~/.bashrc: $(echo "$line" | sed 's/^[[:space:]]*//' | cut -c1-70)")
|
||||
done < <(grep -n 'source.*pos-ai-hook\|linux_post_install.*PATH\|source.*pos\.bash\|pos completion' "$HOME/.bashrc" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
printf '%s\n' "${found[@]}"
|
||||
}
|
||||
|
||||
scan_tier2() {
|
||||
local found=()
|
||||
local cfg="$HOME/.config/linux_post_install"
|
||||
if [ -d "$cfg" ]; then
|
||||
while IFS= read -r f; do
|
||||
found+=("$f")
|
||||
done < <(find "$cfg" -maxdepth 2 -type f 2>/dev/null | sort)
|
||||
fi
|
||||
printf '%s\n' "${found[@]}"
|
||||
}
|
||||
|
||||
scan_tier3() {
|
||||
local found=()
|
||||
local data="$HOME/.local/share/linux_post_install"
|
||||
if [ -d "$data" ]; then
|
||||
while IFS= read -r f; do
|
||||
found+=("$f")
|
||||
done < <(find "$data" -maxdepth 2 \( -type f -o -type d \) 2>/dev/null | sort)
|
||||
fi
|
||||
printf '%s\n' "${found[@]}"
|
||||
}
|
||||
|
||||
# ── Display function ────────────────────────────────────────────
|
||||
display_plan() {
|
||||
local -a t1=() t2=() t3=()
|
||||
# Collect non-empty entries from scan output
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && t1+=("$line")
|
||||
done <<< "${1:-}"
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && t2+=("$line")
|
||||
done <<< "${2:-}"
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] && t3+=("$line")
|
||||
done <<< "${3:-}"
|
||||
|
||||
echo
|
||||
echo "${BOLD}pos uninstall — what will be removed:${RESET}"
|
||||
echo
|
||||
|
||||
local n=1
|
||||
|
||||
if [ "${#t1[@]}" -gt 0 ]; then
|
||||
echo "${CYAN}Tier 1 (always):${RESET}"
|
||||
for item in "${t1[@]}"; do
|
||||
printf " %3d) %s\n" "$n" "$item"
|
||||
n=$((n + 1))
|
||||
done
|
||||
else
|
||||
echo "${CYAN}Tier 1 (always):${RESET} (nothing found)"
|
||||
fi
|
||||
echo
|
||||
|
||||
if [ "${#t2[@]}" -gt 0 ]; then
|
||||
echo "${YELLOW}Tier 2 (--config to include):${RESET}"
|
||||
for item in "${t2[@]}"; do
|
||||
printf " %3d) %s\n" "$n" "$item"
|
||||
n=$((n + 1))
|
||||
done
|
||||
else
|
||||
echo "${YELLOW}Tier 2 (--config to include):${RESET} (nothing found)"
|
||||
fi
|
||||
echo
|
||||
|
||||
if [ "${#t3[@]}" -gt 0 ]; then
|
||||
echo "${YELLOW}Tier 3 (--data to include):${RESET}"
|
||||
for item in "${t3[@]}"; do
|
||||
printf " %3d) %s\n" "$n" "$item"
|
||||
n=$((n + 1))
|
||||
done
|
||||
else
|
||||
echo "${YELLOW}Tier 3 (--data to include):${RESET} (nothing found)"
|
||||
fi
|
||||
echo
|
||||
}
|
||||
|
||||
# ── Removal functions ───────────────────────────────────────────
|
||||
remove_tier1() {
|
||||
local count=0
|
||||
|
||||
# ── Binaries ──
|
||||
# Main dispatcher
|
||||
[ -f /usr/local/bin/pos ] && { rm -f /usr/local/bin/pos && count=$((count+1)); }
|
||||
|
||||
# All pos-* tools
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
|
||||
done < <(compgen -G /usr/local/bin/pos-* 2>/dev/null | sort || true)
|
||||
|
||||
# Lib files
|
||||
for f in /usr/local/bin/common.sh /usr/local/bin/menu-lib.sh /usr/local/bin/share-lib.sh; do
|
||||
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
|
||||
done
|
||||
|
||||
# AI providers directory
|
||||
if [ -d /usr/local/bin/ai-providers ]; then
|
||||
rm -rf /usr/local/bin/ai-providers && count=$((count+1))
|
||||
fi
|
||||
|
||||
# Entertainment plugins
|
||||
for f in /usr/local/bin/weather.sh /usr/local/bin/gold.sh /usr/local/bin/joke.sh; do
|
||||
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
|
||||
done
|
||||
|
||||
# Legacy forwarders
|
||||
for pat in 'wr-*' mp3 mp4 vbox ssh-load-all; do
|
||||
while IFS= read -r f; do
|
||||
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
|
||||
done < <(compgen -G "/usr/local/bin/$pat" 2>/dev/null || true)
|
||||
done
|
||||
|
||||
# Prebuilt binaries
|
||||
for f in /usr/local/bin/wihotspot /usr/local/bin/wihotspot-gui /usr/local/bin/create_ap; do
|
||||
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
|
||||
done
|
||||
|
||||
# Feature scripts
|
||||
for f in /usr/local/bin/autostart.sh /usr/local/bin/usb-automount.sh; do
|
||||
[ -f "$f" ] && { rm -f "$f" && count=$((count+1)); }
|
||||
done
|
||||
|
||||
# User-local binaries
|
||||
[ -f "$HOME/.local/bin/pos-ai-hook.sh" ] && { rm -f "$HOME/.local/bin/pos-ai-hook.sh" && count=$((count+1)); }
|
||||
|
||||
# pos ai alias wrapper scripts (marker-managed only — foreign files untouched)
|
||||
if [ -d "$HOME/.local/bin" ]; then
|
||||
local arwf
|
||||
for arwf in "$HOME/.local/bin"/*; do
|
||||
[ -f "$arwf" ] || continue
|
||||
if grep -q 'Managed by pos ai alias' "$arwf" 2>/dev/null; then
|
||||
rm -f "$arwf" && count=$((count+1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Completion file
|
||||
[ -f /usr/local/share/bash-completion/completions/pos.bash ] && { rm -f /usr/local/share/bash-completion/completions/pos.bash && count=$((count+1)); }
|
||||
|
||||
# ── Systemd services ──
|
||||
for svc in autostart.service ssh-agent.service usb-automount.service; do
|
||||
if systemctl is-enabled "$svc" &>/dev/null 2>&1; then
|
||||
systemctl disable --now "$svc" 2>/dev/null || true
|
||||
rm -f "/etc/systemd/system/$svc"
|
||||
count=$((count+1))
|
||||
elif [ -f "/etc/systemd/system/$svc" ]; then
|
||||
rm -f "/etc/systemd/system/$svc"
|
||||
count=$((count+1))
|
||||
fi
|
||||
done
|
||||
# Additional linux_post_install services
|
||||
while IFS= read -r svc_file; do
|
||||
local svc_name
|
||||
svc_name=$(basename "$svc_file" .service)
|
||||
# Skip already-handled services
|
||||
local already=0
|
||||
for listed in autostart ssh-agent usb-automount; do
|
||||
[ "$svc_name" = "$listed" ] && already=1 && break
|
||||
done
|
||||
if [ "$already" -eq 0 ]; then
|
||||
systemctl disable --now "$svc_name.service" 2>/dev/null || true
|
||||
rm -f "$svc_file"
|
||||
count=$((count+1))
|
||||
fi
|
||||
done < <(find /etc/systemd/system/ -name '*linux_post_install*' -o -name 'pos-*' 2>/dev/null || true)
|
||||
|
||||
# Reload daemon after service changes
|
||||
systemctl daemon-reload 2>/dev/null || true
|
||||
|
||||
# ── Shell integration (~/.bashrc) ──
|
||||
if [ -f "$HOME/.bashrc" ]; then
|
||||
local before
|
||||
before=$(wc -l < "$HOME/.bashrc")
|
||||
sed -i '/source.*pos-ai-hook/d' "$HOME/.bashrc"
|
||||
sed -i '/linux_post_install.*PATH/d' "$HOME/.bashrc"
|
||||
sed -i '/source.*pos\.bash/d' "$HOME/.bashrc"
|
||||
local after
|
||||
after=$(wc -l < "$HOME/.bashrc")
|
||||
local removed=$((before - after))
|
||||
count=$((count + removed))
|
||||
fi
|
||||
|
||||
# ── Shell completion (~/.bash_completion) ──
|
||||
if [ -f "$HOME/.bash_completion" ]; then
|
||||
local before
|
||||
before=$(wc -l < "$HOME/.bash_completion")
|
||||
sed -i '/pos/d' "$HOME/.bash_completion"
|
||||
local after
|
||||
after=$(wc -l < "$HOME/.bash_completion")
|
||||
local removed=$((before - after))
|
||||
count=$((count + removed))
|
||||
fi
|
||||
|
||||
ok "Removed $count items (tier 1)"
|
||||
}
|
||||
|
||||
remove_tier2() {
|
||||
local cfg="$HOME/.config/linux_post_install"
|
||||
local count=0
|
||||
|
||||
if [ -d "$cfg" ]; then
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
rm -f "$f" && count=$((count+1))
|
||||
done < <(find "$cfg" -maxdepth 2 -type f 2>/dev/null)
|
||||
|
||||
# Remove empty directory tree
|
||||
rmdir "$cfg/schedule.d" 2>/dev/null || true
|
||||
rmdir "$cfg" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
ok "Removed $count items (tier 2)"
|
||||
}
|
||||
|
||||
remove_tier3() {
|
||||
local data="$HOME/.local/share/linux_post_install"
|
||||
local count=0
|
||||
|
||||
if [ -d "$data" ]; then
|
||||
# Remove files first
|
||||
local f
|
||||
while IFS= read -r f; do
|
||||
rm -f "$f" && count=$((count+1))
|
||||
done < <(find "$data" -maxdepth 2 -type f 2>/dev/null)
|
||||
|
||||
# Remove directories bottom-up
|
||||
while IFS= read -r d; do
|
||||
rmdir "$d" 2>/dev/null && count=$((count+1)) || true
|
||||
done < <(find "$data" -mindepth 1 -depth -type d 2>/dev/null)
|
||||
|
||||
# Remove top-level directory if empty
|
||||
rmdir "$data" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
ok "Removed $count items (tier 3)"
|
||||
}
|
||||
|
||||
# ── Main ────────────────────────────────────────────────────────
|
||||
main() {
|
||||
section "pos system uninstall"
|
||||
|
||||
# Scan all tiers
|
||||
local scan1 scan2 scan3
|
||||
scan1="$(scan_tier1)"
|
||||
scan2="$(scan_tier2)"
|
||||
scan3="$(scan_tier3)"
|
||||
|
||||
# Display the plan
|
||||
display_plan "$scan1" "$scan2" "$scan3"
|
||||
|
||||
# Nothing to do at all?
|
||||
if [ -z "$scan1" ] && [ -z "$scan2" ] && [ -z "$scan3" ]; then
|
||||
ok "Nothing to remove — pos toolkit does not appear to be installed."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── Tier 1: always remove (confirm unless --yes) ──
|
||||
if [ -n "$scan1" ]; then
|
||||
if [ "$YES_MODE" -eq 1 ]; then
|
||||
log "Removing tier 1 items (--yes)..."
|
||||
else
|
||||
confirm "Remove tier 1 items (binaries, services, shell integration)?" || return 0
|
||||
fi
|
||||
remove_tier1
|
||||
fi
|
||||
|
||||
# ── Tier 2: config files ──
|
||||
if [ -n "$scan2" ]; then
|
||||
if [ "$DEL_CONFIG" -eq 0 ] && [ "$YES_MODE" -eq 0 ]; then
|
||||
confirm "Also remove config files (tier 2)?" && DEL_CONFIG=1 || true
|
||||
fi
|
||||
[ "$DEL_CONFIG" -eq 1 ] && remove_tier2
|
||||
fi
|
||||
|
||||
# ── Tier 3: session/log data ──
|
||||
if [ -n "$scan3" ]; then
|
||||
if [ "$DEL_DATA" -eq 0 ] && [ "$YES_MODE" -eq 0 ]; then
|
||||
confirm "Also remove session/log data (tier 3)?" && DEL_DATA=1 || true
|
||||
fi
|
||||
[ "$DEL_DATA" -eq 1 ] && remove_tier3
|
||||
fi
|
||||
|
||||
echo
|
||||
ok "Uninstall complete."
|
||||
echo " The git repo was NOT removed — delete it manually if desired."
|
||||
echo " Restart your shell or run: source ~/.bashrc"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+12
-6
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
# POS_FLAGS: --depth
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
source "$(dirname "$0")/../lib/registry.sh" 2>/dev/null || source "$(dirname "$0")/registry.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -44,12 +45,17 @@ add() {
|
||||
meta["$path"]="$type|$desc"
|
||||
}
|
||||
|
||||
for f in "$self"/pos-*; do
|
||||
[ -x "$f" ] || continue
|
||||
name="${f##*/pos-}"
|
||||
desc="$(sed -n '/^# POS: /{s/^# POS: //;p;q}' "$f")"
|
||||
desc="${desc#*— }"
|
||||
sc="$(sed -n '/^# POS_SUBCMDS: /{s/^# POS_SUBCMDS: //;p;q}' "$f")"
|
||||
reg_scan "$self"
|
||||
|
||||
for tool_key in $(reg_list); do
|
||||
cat="$(reg_lookup "$tool_key" cat)"
|
||||
desc="$(reg_lookup "$tool_key" desc)"
|
||||
sc="$(reg_lookup "$tool_key" subcmds)"
|
||||
deps="$(reg_lookup "$tool_key" deps)"
|
||||
name="${tool_key}"
|
||||
if [ -n "$deps" ]; then
|
||||
desc="$desc [deps: $deps]"
|
||||
fi
|
||||
words=(${name//-/ })
|
||||
parent="/"
|
||||
for ((i=0; i<${#words[@]}; i++)); do
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
# 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 --full --last"
|
||||
_pos_flags[ai-openrouter]="--model --session --system --full --last"
|
||||
_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"
|
||||
@@ -21,14 +19,18 @@ _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 --trust"
|
||||
_pos_flags[tree]="--depth"
|
||||
# GEN:END posflags
|
||||
# GEN:START possubcmds
|
||||
declare -A _pos_subcmds
|
||||
_pos_subcmds[ai-gemini]="ask capture chat models sessions"
|
||||
_pos_subcmds[ai-openrouter]="ask capture 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-listener]="prefix"
|
||||
_pos_subcmds[communication-telegram-sender]="send test"
|
||||
_pos_subcmds[docker-compose]="ls installed up down restart logs update config menu"
|
||||
_pos_subcmds[docker-vbox]="create enter stop start rm ls menu"
|
||||
@@ -41,9 +43,10 @@ _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 ai-openrouter compose entertainment matrix notify scrcpy system telegram ytsync)
|
||||
declare -a _pos_config_scopes=(ai compose entertainment matrix notify scrcpy system telegram ytsync)
|
||||
# GEN:END posconfigscopes
|
||||
|
||||
_pos() {
|
||||
|
||||
+15
-5
@@ -1,8 +1,18 @@
|
||||
# ~/.config/linux_post_install/ai.env — Google Gemini config for 'pos ai gemini'
|
||||
# ~/.config/linux_post_install/ai.env — AI provider config for 'pos ai'
|
||||
# Copied to ~/.config/linux_post_install/ai.env on install (no clobber).
|
||||
# Edit with: pos config ai
|
||||
#
|
||||
# Syntax:
|
||||
# AI_GEMINI_API_KEY=<key> # required — API key from aistudio.google.com
|
||||
# # (never commit this to the repo)
|
||||
# AI_GEMINI_MODEL=<model> # optional — model id, default gemini-2.5-flash
|
||||
# Provider selection:
|
||||
# AI_PROVIDER=gemini # provider: gemini|openrouter (default gemini)
|
||||
#
|
||||
# API keys (each provider needs its own):
|
||||
# AI_GEMINI_API_KEY=<key> # Gemini API key from aistudio.google.com
|
||||
# OPENROUTER_API_KEY=<key> # OpenRouter API key from openrouter.ai
|
||||
#
|
||||
# Model overrides (optional, defaults per provider):
|
||||
# AI_GEMINI_MODEL=<model> # Gemini model id (default: gemini-2.5-flash)
|
||||
# OPENROUTER_MODEL=<model> # OpenRouter model id (default: openrouter/auto)
|
||||
# AI_MODEL=<model> # Override for all providers (takes priority)
|
||||
#
|
||||
# System prompt:
|
||||
# AI_SYSTEM_PROMPT=<prompt> # Custom system prompt (overrides built-in; empty to reset)
|
||||
|
||||
+14
-1
@@ -140,13 +140,26 @@ if should_run 2 scripts; then
|
||||
done
|
||||
lib_count=0
|
||||
lib_names=""
|
||||
for lf in common.sh flags.sh notify.sh entertainment-lib.sh scheduler-lib.sh config-ui.sh user-timers-lib.sh entertainment-plugin-lib.sh usb-lib.sh share-lib.sh menu-lib.sh; do
|
||||
for lf in common.sh flags.sh notify.sh entertainment-lib.sh scheduler-lib.sh config-ui.sh user-timers-lib.sh entertainment-plugin-lib.sh usb-lib.sh share-lib.sh menu-lib.sh registry.sh; do
|
||||
run sudo install -m 644 "lib/$lf" "/usr/local/bin/$lf"
|
||||
lib_count=$((lib_count + 1))
|
||||
lib_names+="$lf "
|
||||
done
|
||||
log "libs -> /usr/local/bin (644): ${lib_names% }"
|
||||
|
||||
# ── AI provider adapters ────────────────────────────────────
|
||||
# Installed into /usr/local/bin/ai-providers/ for pos-ai.
|
||||
ap_count=0
|
||||
ap_names=""
|
||||
run sudo mkdir -p /usr/local/bin/ai-providers
|
||||
for apf in lib/ai-providers/*.sh; do
|
||||
[ -f "$apf" ] || continue
|
||||
run sudo install -m 644 "$apf" "/usr/local/bin/ai-providers/"
|
||||
ap_count=$((ap_count + 1))
|
||||
ap_names+="$(basename "$apf") "
|
||||
done
|
||||
[ "$ap_count" -gt 0 ] && log "ai-providers -> /usr/local/bin/ai-providers (644): ${ap_names% }"
|
||||
|
||||
# ── Entertainment plugins ────────────────────────────────
|
||||
# Installed into /usr/local/bin so the repo can be deleted afterwards.
|
||||
pcount=0
|
||||
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Gemini provider adapter for pos-ai
|
||||
# Provider-specific: API call, auth, response parsing, models list
|
||||
# Part of the R8 provider-agnostic architecture (lib/ai-providers/).
|
||||
|
||||
# Provider-specific config variables (auto-discovered by pos config ai):
|
||||
# PROVIDER_CONFIG: AI_GEMINI_API_KEY=secret:Gemini API key from aistudio.google.com
|
||||
# PROVIDER_CONFIG: AI_GEMINI_MODEL=:Gemini model id (default: gemini-2.5-flash)
|
||||
|
||||
provider_name() { printf 'Google Gemini'; }
|
||||
provider_default_model() { printf 'gemini-2.5-flash'; }
|
||||
|
||||
# $1=model $2=messages JSON ({"messages":[{role,content}]}) $3=optional system prompt
|
||||
provider_generate() {
|
||||
local model="$1" messages="$2" system="${3:-}" body resp code body_out errmsg
|
||||
# Convert OpenAI messages format to Gemini contents format
|
||||
body="$(printf '%s' "$messages" | jq -c '{
|
||||
contents: [.messages[]? | {role: (.role | gsub("assistant";"model")), parts: [{text: .content}]}]
|
||||
}')"
|
||||
if [ -n "$system" ]; then
|
||||
body="$(printf '%s' "$body" | jq -c --arg s "$system" \
|
||||
'. + {systemInstruction:{role:"system",parts:[{text:$s}]}}')"
|
||||
fi
|
||||
resp="$(curl -sS -m 60 -X POST "https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent" \
|
||||
-H "x-goog-api-key: ${AI_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--write-out $'\n%{http_code}' \
|
||||
--data "$body")" || { echo "request failed (curl exit $?)" >&2; return 1; }
|
||||
code="${resp##*$'\n'}"
|
||||
body_out="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
errmsg="$(printf '%s' "$body_out" | jq -r '.error.message // empty' 2>/dev/null || true)"
|
||||
echo "API error $code${errmsg:+: $errmsg}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$body_out" | jq -r '[.candidates[0].content.parts[]?.text] | join("")'
|
||||
}
|
||||
|
||||
# $1=current default model → stdout=formatted model list
|
||||
provider_models_list() {
|
||||
local model="$1" resp code body m
|
||||
resp="$(curl -sS -m 30 -G "https://generativelanguage.googleapis.com/v1beta/models" \
|
||||
-H "x-goog-api-key: ${AI_API_KEY}" \
|
||||
--data-urlencode "pageSize=1000" \
|
||||
--write-out $'\n%{http_code}')" || err "request failed (curl exit $?)"
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
[ "$code" = "200" ] || err "API error $code: $(printf '%s' "$body" | jq -r '.error.message // empty')"
|
||||
local list
|
||||
list="$(printf '%s' "$body" | jq -r '.models[]? | select((.supportedGenerationMethods // []) | index("generateContent")) | .name' | sed 's#^models/##' | sort)"
|
||||
echo "Gemini models (generateContent-capable):"
|
||||
while IFS= read -r m; do
|
||||
[ -n "$m" ] || continue
|
||||
if [ "$m" = "$model" ]; then
|
||||
printf ' %-32s <- default\n' "$m"
|
||||
else
|
||||
printf ' %-32s\n' "$m"
|
||||
fi
|
||||
done <<< "$list"
|
||||
if ! grep -qxF "$model" <<< "$list" 2>/dev/null; then
|
||||
warn "configured default '$model' is not in the list — set AI_MODEL or AI_GEMINI_MODEL"
|
||||
fi
|
||||
}
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# OpenRouter provider adapter for pos-ai
|
||||
# Provider-specific: API call, auth, response parsing, models list
|
||||
# Part of the R8 provider-agnostic architecture (lib/ai-providers/).
|
||||
|
||||
# Provider-specific config variables (auto-discovered by pos config ai):
|
||||
# PROVIDER_CONFIG: OPENROUTER_API_KEY=secret:OpenRouter API key from openrouter.ai
|
||||
# PROVIDER_CONFIG: OPENROUTER_MODEL=:OpenRouter model id (default: openrouter/auto)
|
||||
|
||||
provider_name() { printf 'OpenRouter'; }
|
||||
provider_default_model() { printf 'openrouter/auto'; }
|
||||
|
||||
# $1=model $2=messages JSON ({"messages":[{role,content}]}) $3=optional system prompt
|
||||
provider_generate() {
|
||||
local model="$1" messages="$2" system="${3:-}" body resp code body_out errmsg
|
||||
if [ -n "$system" ]; then
|
||||
body="$(printf '%s' "$messages" | jq -c --arg s "$system" \
|
||||
'[{role:"system",content:$s}] + .messages')"
|
||||
else
|
||||
body="$(printf '%s' "$messages" | jq -c '.messages')"
|
||||
fi
|
||||
body="$(printf '%s' "$body" | jq -nc --arg m "$model" --argjson msgs "$body" \
|
||||
'{model:$m, messages:$msgs}')"
|
||||
resp="$(curl -sS -m 60 -X POST "https://openrouter.ai/api/v1/chat/completions" \
|
||||
-H "Authorization: Bearer ${AI_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "HTTP-Referer: https://github.com/admin/Linux_post_install" \
|
||||
--write-out $'\n%{http_code}' \
|
||||
--data "$body")" || { echo "request failed (curl exit $?)" >&2; return 1; }
|
||||
code="${resp##*$'\n'}"
|
||||
body_out="${resp%$'\n'*}"
|
||||
if [ "$code" != "200" ]; then
|
||||
errmsg="$(printf '%s' "$body_out" | jq -r '.error.message // empty' 2>/dev/null || true)"
|
||||
echo "API error $code${errmsg:+: $errmsg}" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$body_out" | jq -r '.choices[0].message.content // ""'
|
||||
}
|
||||
|
||||
# $1=current default model → stdout=formatted model list
|
||||
provider_models_list() {
|
||||
local model="$1" resp code body m
|
||||
resp="$(curl -sS -m 30 "https://openrouter.ai/api/v1/models" \
|
||||
-H "Authorization: Bearer ${AI_API_KEY}" \
|
||||
--write-out $'\n%{http_code}')" || err "request failed (curl exit $?)"
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
[ "$code" = "200" ] || err "API error $code: $(printf '%s' "$body" | jq -r '.error.message // empty')"
|
||||
local list
|
||||
list="$(printf '%s' "$body" | jq -r '.data[]?.id' | sort)"
|
||||
echo "OpenRouter models:"
|
||||
while IFS= read -r m; do
|
||||
[ -n "$m" ] || continue
|
||||
if [ "$m" = "$model" ]; then
|
||||
printf ' %-48s <- default\n' "$m"
|
||||
else
|
||||
printf ' %-48s\n' "$m"
|
||||
fi
|
||||
done <<< "$list"
|
||||
if ! grep -qxF "$model" <<< "$list" 2>/dev/null; then
|
||||
warn "configured default '$model' is not in the list — set AI_MODEL or OPENROUTER_MODEL"
|
||||
fi
|
||||
}
|
||||
+274
-34
@@ -5,12 +5,22 @@
|
||||
# (mirrors lib/notify.sh). Sourced opt-in by bin/pos-config.
|
||||
#
|
||||
# Header grammar — one "# POS_CONFIG:" line per scope a tool exposes:
|
||||
# # POS_CONFIG: <scope> | <env-file> | <KEY>=<flags>:<desc>[::<example>] | ... | *plugins
|
||||
# # POS_CONFIG: <scope> | <env-file> | <field> | ... | *plugins
|
||||
# <env-file> basename of the config file under ~/.config/linux_post_install/
|
||||
# <field> := <KEY>=<flags>:<desc>[::<example>]
|
||||
# | @<caption> group caption (unconditional)
|
||||
# | @[<KEY>=<alt>[|…]] <caption> conditional group caption —
|
||||
# active iff KEY's current value
|
||||
# equals a listed alt; an empty
|
||||
# alt segment ("gemini|") means
|
||||
# "or unset (= default)"
|
||||
# <flags> secret (masked display + stty -echo input) | digits | num | float
|
||||
# <example> optional value format hint shown in the editor, e.g. "weather,5m joke,10m"
|
||||
# *plugins marker: also list every key declared by the installed
|
||||
# entertainment plugins' "# POS_KEYS:" headers (dynamic)
|
||||
# *providers[=<tag>] marker: keys from lib/ai-providers/*.sh adapters;
|
||||
# with =<tag>, only from <tag>.sh (zero match → warn + the
|
||||
# preceding caption is suppressed)
|
||||
# Example:
|
||||
# # POS_CONFIG: telegram | telegram.env | TELEGRAM_BOT_TOKEN=secret:Bot token | TELEGRAM_CHAT_ID=digits:Numeric chat id
|
||||
#
|
||||
@@ -27,8 +37,18 @@ declare -F warn >/dev/null || warn() { echo "[!] $*"; }
|
||||
declare -F err >/dev/null || err() { echo "ERROR: $*" >&2; exit 1; }
|
||||
declare -F ok >/dev/null || ok() { echo " OK $*"; }
|
||||
|
||||
# Color tokens (guarded — mirrors lib/menu-lib.sh): degrade to plain text when
|
||||
# common.sh didn't define them, never an error on standalone sourcing.
|
||||
BOLD="${BOLD:-}"
|
||||
DIM="${DIM:-}"
|
||||
CYAN="${CYAN:-}"
|
||||
RESET="${RESET:-}"
|
||||
|
||||
_cfg_scope="" # scope being edited (drives the post-write hook)
|
||||
declare -A _cfg_seen=() # key dedupe registry for cfg_scope_keys
|
||||
_CS=$'\x1f' # unit-separator for caption records — never in env
|
||||
# names or alt strings, avoids collision with | in
|
||||
# alternation syntax (AI_PROVIDER=gemini|)
|
||||
|
||||
# ── tool directory ─────────────────────────────────────────────────
|
||||
# Repo layout: lib/config-ui.sh → tools live in ../bin.
|
||||
@@ -86,6 +106,29 @@ cfg_scope_envfile() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Split a POS_CONFIG keystring into fields on "|", IGNORING separators inside
|
||||
# [...] condition brackets (caption conditions legitimately contain pipes,
|
||||
# e.g. @[AI_PROVIDER=gemini|]). Byte-identical output to IFS='|' splitting for
|
||||
# any string without brackets — fully backward compatible.
|
||||
_cfg_split_fields() { # $1=keystring → one field per line
|
||||
local s="$1" cur="" i ch depth=0
|
||||
for ((i = 0; i < ${#s}; i++)); do
|
||||
ch="${s:i:1}"
|
||||
if [ "$ch" = "[" ]; then
|
||||
depth=$((depth + 1))
|
||||
elif [ "$ch" = "]" ] && [ "$depth" -gt 0 ]; then
|
||||
depth=$((depth - 1))
|
||||
fi
|
||||
if [ "$ch" = "|" ] && [ "$depth" -eq 0 ]; then
|
||||
printf '%s\n' "$cur"
|
||||
cur=""
|
||||
else
|
||||
cur+="$ch"
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "$cur"
|
||||
}
|
||||
|
||||
# One key field → "KEY|flags|description|example" (deduped via _cfg_seen).
|
||||
# The optional example is "desc::example" — a literal "::" separates the
|
||||
# value-format hint from the description.
|
||||
@@ -136,6 +179,85 @@ _cfg_plugin_keys() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# Emit the "# PROVIDER_CONFIG:" keys of ONE adapter file (helper for
|
||||
# _cfg_provider_keys; keeps the tag-filter path and the all-adapters path DRY).
|
||||
_cfg_provider_file() {
|
||||
local pfile="$1" line key desc flags rest
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
# Format: KEY=flags:description (same as POS_CONFIG key fields)
|
||||
key="${line%%=*}"
|
||||
[ -n "$key" ] || continue
|
||||
[ -n "${_cfg_seen[$key]:-}" ] && continue
|
||||
_cfg_seen[$key]=1
|
||||
rest="${line#*=}" flags="" desc=""
|
||||
if [[ "$rest" == *":"* ]]; then
|
||||
flags="${rest%%:*}"
|
||||
desc="${rest#*:}"
|
||||
else
|
||||
flags="$rest"
|
||||
fi
|
||||
printf '%s|%s|%s|\n' "$key" "$flags" "$desc"
|
||||
done < <(grep '^# PROVIDER_CONFIG:' "$pfile" 2>/dev/null | sed 's/^.*# PROVIDER_CONFIG:[[:space:]]*//' || true)
|
||||
return 0
|
||||
}
|
||||
|
||||
# "*providers" expansion: keys declared by the installed AI provider
|
||||
# adapters' "# PROVIDER_CONFIG:" headers (lib/ai-providers/*.sh).
|
||||
# Optional <tag> argument restricts to <tag>.sh; an explicit tag matching zero
|
||||
# adapters warns once (stderr) — silent emptiness would hide authoring errors,
|
||||
# and the preceding caption is suppressed by cfg_ui's lazy flush. Bare
|
||||
# *providers stays silent, exactly as today.
|
||||
declare -A _CFG_TAG_WARNED=()
|
||||
_cfg_provider_keys() {
|
||||
local want_tag="${1:-}"
|
||||
local pdir line key desc flags
|
||||
# Repo layout: lib/config-ui.sh → ../lib/ai-providers/
|
||||
# Installed layout: /usr/local/bin/config-ui.sh → ./ai-providers/
|
||||
pdir=""
|
||||
local candidate
|
||||
for candidate in \
|
||||
"$(dirname "${BASH_SOURCE[0]}")/../lib/ai-providers" \
|
||||
"$(dirname "${BASH_SOURCE[0]}")/ai-providers"; do
|
||||
if [ -d "$candidate" ]; then
|
||||
pdir="$(cd "$candidate" 2>/dev/null && pwd)"
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ -n "$pdir" ] || return 0
|
||||
if [ -n "$want_tag" ]; then
|
||||
local matched=0 pfile
|
||||
for pfile in "$pdir"/*.sh; do
|
||||
[ -f "$pfile" ] || continue
|
||||
[ "$(basename "$pfile" .sh)" = "$want_tag" ] || continue
|
||||
matched=1
|
||||
_cfg_provider_file "$pfile"
|
||||
done
|
||||
if [ "$matched" -eq 0 ] && [ -z "${_CFG_TAG_WARNED[$want_tag]:-}" ]; then
|
||||
_CFG_TAG_WARNED["$want_tag"]=1
|
||||
printf '[!] config scope: *providers=%s matched no adapter in %s\n' "$want_tag" "$pdir" >&2
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
# Format: KEY=flags:description (same as POS_CONFIG key fields)
|
||||
key="${line%%=*}"
|
||||
[ -n "$key" ] || continue
|
||||
[ -n "${_cfg_seen[$key]:-}" ] && continue
|
||||
_cfg_seen[$key]=1
|
||||
rest="${line#*=}" flags="" desc=""
|
||||
if [[ "$rest" == *":"* ]]; then
|
||||
flags="${rest%%:*}"
|
||||
desc="${rest#*:}"
|
||||
else
|
||||
flags="$rest"
|
||||
fi
|
||||
printf '%s|%s|%s|\n' "$key" "$flags" "$desc"
|
||||
done < <(grep '^# PROVIDER_CONFIG:' "$pdir"/*.sh 2>/dev/null | sed 's/^.*# PROVIDER_CONFIG:[[:space:]]*//' || true)
|
||||
return 0
|
||||
}
|
||||
|
||||
# Declared keys for a scope: "KEY|flags|description" lines, deduped.
|
||||
cfg_scope_keys() {
|
||||
local scope="$1" dir line s keystring field
|
||||
@@ -148,14 +270,33 @@ cfg_scope_keys() {
|
||||
[ "$s" = "$scope" ] || continue
|
||||
keystring="${line#*|}"
|
||||
keystring="${keystring#*|}" # drop the env-file field
|
||||
IFS='|' read -r -a fields <<<"$keystring"
|
||||
mapfile -t fields < <(_cfg_split_fields "$keystring")
|
||||
for field in "${fields[@]}"; do
|
||||
field="${field#"${field%%[![:space:]]*}"}"
|
||||
field="${field%"${field##*[![:space:]]}"}"
|
||||
if [ -n "$field" ]; then
|
||||
if [[ "$field" == "*"* ]]; then
|
||||
if [[ "$field" == "@"* ]]; then
|
||||
# Caption record (key position ">"): >|cond|caption|
|
||||
# @[KEY=alt1|alt2] Caption → cond "KEY=alt1|alt2"
|
||||
# @Caption → cond "" (always active)
|
||||
local cond="" cap=""
|
||||
if [[ "$field" == "@["*"]"* ]]; then
|
||||
cond="${field:2}"
|
||||
cond="${cond%%]*}"
|
||||
cap="${field#*]}"
|
||||
cap="${cap# }"
|
||||
else
|
||||
cap="${field#@}"
|
||||
cap="${cap# }"
|
||||
fi
|
||||
printf '%s\n' ">${_CS}${cond}${_CS}${cap}${_CS}"
|
||||
elif [[ "$field" == "*"* ]]; then
|
||||
case "$field" in
|
||||
*plugins*) _cfg_plugin_keys ;;
|
||||
*plugins*) _cfg_plugin_keys ;;
|
||||
*providers*)
|
||||
local ptag=""
|
||||
[[ "$field" == *"="* ]] && ptag="${field#*=}"
|
||||
_cfg_provider_keys "$ptag" ;;
|
||||
esac
|
||||
else
|
||||
_cfg_key_line "$field"
|
||||
@@ -294,53 +435,152 @@ _cfg_edit_one() {
|
||||
_cfg_post_write "$key"
|
||||
}
|
||||
|
||||
# Evaluate a caption condition against the env file: active iff KEY's current
|
||||
# value equals any listed alt, or an empty alt segment is present and the value
|
||||
# is unset/empty (trailing/double/leading pipe). Empty cond → always active.
|
||||
_cfg_cond_active() { # file cond
|
||||
[ -n "$2" ] || return 0
|
||||
local key alts cur alt hit=0 has_empty=0 oldIFS
|
||||
key="${2%%=*}"
|
||||
alts="${2#*=}"
|
||||
cur="$(cfg_value "$1" "$key")"
|
||||
case "$alts" in
|
||||
"|"*|*"||"*|*"|") has_empty=1 ;;
|
||||
esac
|
||||
oldIFS="$IFS"
|
||||
IFS='|'
|
||||
for alt in $alts; do
|
||||
if [ -n "$alt" ] && [ "$alt" = "$cur" ]; then hit=1; break; fi
|
||||
done
|
||||
IFS="$oldIFS"
|
||||
[ "$hit" -eq 1 ] && return 0
|
||||
[ "$has_empty" -eq 1 ] && [ -z "$cur" ] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# Word-wrap <text> to <width> columns, prefixing EVERY line with <indent>
|
||||
# (hanging indent). Breaks at spaces only, no hyphenation; over-long tokens
|
||||
# pass through unbroken.
|
||||
_cfg_wrap() { # text width indent
|
||||
local text="$1" width="$2" indent="$3"
|
||||
local line="" w
|
||||
for w in $text; do
|
||||
if [ -z "$line" ]; then
|
||||
line="$w"
|
||||
elif (( ${#line} + 1 + ${#w} <= width )); then
|
||||
line="$line $w"
|
||||
else
|
||||
printf '%s%s\n' "$indent" "$line"
|
||||
line="$w"
|
||||
fi
|
||||
done
|
||||
[ -n "$line" ] && printf '%s%s\n' "$indent" "$line"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Interactive numbered-menu editor for one scope. q quits; r re-renders.
|
||||
#
|
||||
# Rendering contract (menu-lib house pattern): the whole render block goes to
|
||||
# stderr — display only, nothing on stdout. Caption records ('>') group keys;
|
||||
# conditions are evaluated per render from the env file, so an edit flips group
|
||||
# emphasis on the very next redraw. Inactive groups are dimmed with a textual
|
||||
# reason — never hidden — so numbering stays stable across edits.
|
||||
cfg_ui() {
|
||||
local scope="$1" envfile file line
|
||||
local scope="$1" envfile file line idx
|
||||
envfile="$(cfg_scope_envfile "$scope")" || { warn "unknown config scope '$scope'"; return 1; }
|
||||
file="$CONFIG_DIR/$envfile"
|
||||
_cfg_scope="$scope"
|
||||
|
||||
local -a keys=()
|
||||
while IFS= read -r line; do
|
||||
if [ -n "$line" ]; then
|
||||
keys+=("$line")
|
||||
fi
|
||||
done < <(cfg_scope_keys "$scope")
|
||||
if [ ${#keys[@]} -eq 0 ]; then
|
||||
# Collect records: KEY|flags|desc|example for keys, >|cond|caption| for captions
|
||||
local -a recs=() nums=()
|
||||
mapfile -t recs < <(cfg_scope_keys "$scope")
|
||||
if [ ${#recs[@]} -eq 0 ]; then
|
||||
warn "no config keys declared for scope '$scope'"
|
||||
return 1
|
||||
fi
|
||||
# number→record map: numbers go to keys only, in static header order →
|
||||
# stable across renders and provider switches
|
||||
for idx in "${!recs[@]}"; do
|
||||
[[ "${recs[$idx]}" == ">"* ]] || nums+=("$idx")
|
||||
done
|
||||
|
||||
local choice i k f d e v
|
||||
# Wrap width clamped to 60–120 cols minus the 6-column hanging indent
|
||||
local W="${COLUMNS:-80}"
|
||||
(( W < 60 )) && W=60
|
||||
(( W > 120 )) && W=120
|
||||
local wrapW=$((W - 6))
|
||||
local rule
|
||||
rule="$(printf '─%.0s' $(seq 1 40))"
|
||||
|
||||
local choice k f d e v disp n dim pend_cap="" pend_cond="" ckey cval why
|
||||
while true; do
|
||||
echo
|
||||
echo "pos config — ${scope} (${envfile})"
|
||||
echo "------------------------------------"
|
||||
i=0
|
||||
for line in "${keys[@]}"; do
|
||||
i=$((i + 1))
|
||||
IFS='|' read -r k f d e <<<"$line"
|
||||
v="$(cfg_value "$file" "$k")"
|
||||
printf ' %2d) %-28s %s\n' "$i" "$k" "$(cfg_display "$v" "$f")"
|
||||
if [ -n "$d" ]; then
|
||||
printf ' %s\n' "$d"
|
||||
fi
|
||||
if [ -n "$e" ]; then
|
||||
printf ' e.g. %s\n' "$e"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
read -rp "Variable number [q to quit]: " choice || { echo; return 0; }
|
||||
{
|
||||
echo
|
||||
echo "${BOLD}pos config — ${scope} (${envfile})${RESET}"
|
||||
echo "${CYAN}${rule}${RESET}"
|
||||
n=0; dim=0; pend_cap=""; pend_cond=""
|
||||
for idx in "${!recs[@]}"; do
|
||||
# Caption records use \x1f (unit separator) to avoid collision
|
||||
# with | in alternation syntax; key records use | as before.
|
||||
if [[ "${recs[$idx]}" == ">"* ]]; then
|
||||
# Caption record: >\x1fcond\x1fcaption\x1f
|
||||
# Strip leading > and first \x1f, then split on next \x1f
|
||||
pend_cond="${recs[$idx]#>}"
|
||||
pend_cond="${pend_cond#$_CS}"
|
||||
pend_cond="${pend_cond%%$_CS*}"
|
||||
pend_cap="${recs[$idx]#>}"
|
||||
pend_cap="${pend_cap#$_CS}"
|
||||
pend_cap="${pend_cap#*$_CS}"
|
||||
pend_cap="${pend_cap%%$_CS*}"
|
||||
continue
|
||||
fi
|
||||
IFS='|' read -r k f d e <<<"${recs[$idx]}"
|
||||
if [ -n "$pend_cap" ]; then
|
||||
if _cfg_cond_active "$file" "$pend_cond"; then
|
||||
dim=0
|
||||
printf '\n%s ── %s%s\n' "$DIM" "$pend_cap" "$RESET"
|
||||
else
|
||||
dim=1
|
||||
ckey="${pend_cond%%=*}"
|
||||
cval="$(cfg_value "$file" "$ckey")"
|
||||
if [ -z "$cval" ]; then why="— inactive (${ckey} not set)"
|
||||
else why="— inactive while ${ckey}=${cval}"; fi
|
||||
printf '\n%s ── %s %s%s\n' "$DIM" "$pend_cap" "$why" "$RESET"
|
||||
fi
|
||||
pend_cap=""
|
||||
fi
|
||||
n=$((n + 1))
|
||||
v="$(cfg_value "$file" "$k")"
|
||||
disp="$(cfg_display "$v" "$f")"
|
||||
[ "$disp" = "(not set)" ] && disp="${DIM}(not set)${RESET}"
|
||||
if [ "$dim" -eq 1 ]; then
|
||||
printf '%s %2d) %-28s %s%s\n' "$DIM" "$n" "$k" "$disp" "$RESET"
|
||||
else
|
||||
printf ' %s%2d)%s %s%-28s%s %s\n' "$DIM" "$n" "$RESET" "$BOLD" "$k" "$RESET" "$disp"
|
||||
fi
|
||||
if [ -n "$d" ]; then
|
||||
[ "$dim" -eq 1 ] && printf '%s' "$DIM"
|
||||
_cfg_wrap "$d" "$wrapW" " "
|
||||
[ "$dim" -eq 1 ] && printf '%s' "$RESET"
|
||||
fi
|
||||
if [ -n "$e" ]; then
|
||||
printf '%s' "$DIM"
|
||||
_cfg_wrap "e.g. $e" "$wrapW" " "
|
||||
printf '%s' "$RESET"
|
||||
fi
|
||||
done
|
||||
echo
|
||||
read -rp "Number to edit [r=refresh, q=quit]: " choice || { echo; return 0; }
|
||||
} >&2
|
||||
case "$choice" in
|
||||
q|Q|quit|exit) echo; return 0 ;;
|
||||
r|R|refresh) continue ;;
|
||||
"") continue ;;
|
||||
*)
|
||||
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#keys[@]} )); then
|
||||
_cfg_edit_one "$file" "${keys[$((choice - 1))]}"
|
||||
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#nums[@]} )); then
|
||||
_cfg_edit_one "$file" "${recs[${nums[$((choice - 1))]}]}"
|
||||
else
|
||||
warn "invalid number '$choice' (1-${#keys[@]})"
|
||||
warn "invalid number '$choice' (1-${#nums[@]})"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
+198
-5
@@ -25,6 +25,8 @@
|
||||
# menu_run <title> <item...> numbered menu loop → chosen index
|
||||
# menu_pick <prompt> <item...> type-to-filter picker → chosen index
|
||||
# menu_ask_value <label> [default] prompted value → entered text
|
||||
# menu_read_value <label> raw-mode bracketed-paste reader
|
||||
# menu_redraw internal redraw (menu_read_value only)
|
||||
|
||||
# ── Colors (guarded fallbacks; a sourced common.sh wins) ──────
|
||||
CYAN="${CYAN:-}"
|
||||
@@ -150,15 +152,206 @@ menu_pick() {
|
||||
done
|
||||
}
|
||||
|
||||
# ── Raw-mode value reader (bracketed-paste safe) ───────────────
|
||||
# Reads ONE value from the terminal in raw mode with bracketed paste enabled,
|
||||
# so a multi-line CTRL+V paste is inserted LITERALLY — embedded newlines are
|
||||
# data, never line terminators — and can never leak into the shell or a later
|
||||
# prompt as leftover keystrokes. A plain bash `read` is line-oriented: it
|
||||
# consumes only the first pasted line and the remaining lines sit in the tty
|
||||
# queue, where the next prompt (or the shell after this script exits) treats
|
||||
# them as input/commands. That is the paste bug this reader exists to prevent.
|
||||
#
|
||||
# Editing (single-line typing behaves like a normal prompt):
|
||||
# Enter submit the value (outside a paste)
|
||||
# Backspace/DEL delete the character before the cursor
|
||||
# Left/Right move the cursor; Home/End jump to start/end
|
||||
# Delete delete the character at the cursor
|
||||
# Ctrl-U clear the whole value
|
||||
# Ctrl-D (empty) EOF — cancel · Ctrl-C/Z/\ — cancel · Up/Down — ignored
|
||||
# Inside a bracketed paste the above are inert: text (incl. newlines) is
|
||||
# inserted verbatim until the paste-end marker; a real Enter then submits.
|
||||
#
|
||||
# Display goes to stderr so callers may command-substitute the result:
|
||||
# rc 0 value on stdout · rc 1 cancel/EOF/non-tty.
|
||||
menu_read_value() {
|
||||
local label="$1"
|
||||
local val="" state="" chunk="" ch="" esc="" seq="" esc_c=""
|
||||
local paste=0 pos=0 submit=0 i=0 n=0
|
||||
|
||||
if ! state="$(stty -g 2>/dev/null)"; then
|
||||
# not a terminal — plain stdin read; no paste protection is possible
|
||||
IFS= read -r val || return 1
|
||||
[ -n "$val" ] && printf '%s' "$val"
|
||||
return 0
|
||||
fi
|
||||
if ! stty -icanon -echo -isig min 1 time 0 2>/dev/null; then
|
||||
stty "$state" 2>/dev/null
|
||||
IFS= read -r val || return 1
|
||||
[ -n "$val" ] && printf '%s' "$val"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local restore
|
||||
restore() {
|
||||
stty "$state" 2>/dev/null
|
||||
printf '\033[?2004l' >&2
|
||||
}
|
||||
trap 'restore; trap - INT TERM; return 1' INT TERM
|
||||
|
||||
printf '\033[?2004h' >&2
|
||||
printf '%s: ' "$label" >&2
|
||||
|
||||
# Next input byte as a 2-hex-digit string, returned via nameref. Uses
|
||||
# dd|od, NOT bash's read builtin: read's tty path self-interrupts on an ETX
|
||||
# byte even with ISIG disabled (SIGINTs the whole script on Ctrl-C, killing
|
||||
# a cmdsubst caller). One dd per input burst (VMIN=1 returns all queued
|
||||
# bytes), so pastes cost O(chunks), not O(per-byte forks). Runs in-place
|
||||
# (never in a $( ) subshell) so its chunk/offset state persists.
|
||||
# byte <hexvar> — rc 0 = byte in hexvar, rc 1 = EOF/short.
|
||||
local byte
|
||||
byte() {
|
||||
local -n _hex="$1"
|
||||
if [ "$i" -ge "$n" ]; then
|
||||
chunk="$(dd bs=4096 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n')"
|
||||
[ -n "$chunk" ] || return 1
|
||||
n=${#chunk}
|
||||
i=0
|
||||
fi
|
||||
_hex="${chunk:i:2}"
|
||||
i=$((i + 2))
|
||||
return 0
|
||||
}
|
||||
|
||||
while byte ch; do
|
||||
case "$ch" in
|
||||
1b)
|
||||
seq=""
|
||||
while byte esc; do
|
||||
printf -v esc_c '%b' "\\x$esc"
|
||||
seq+="$esc_c"
|
||||
case "$esc_c" in
|
||||
[A-Za-z~]) break ;;
|
||||
esac
|
||||
done
|
||||
case "$seq" in
|
||||
'[200~') paste=1 ;;
|
||||
'[201~') paste=0 ;;
|
||||
'[C') [ "$pos" -lt "${#val}" ] && { pos=$((pos + 1)); menu_redraw "$label" "$val" "$pos"; } ;;
|
||||
'[D') [ "$pos" -gt 0 ] && { pos=$((pos - 1)); menu_redraw "$label" "$val" "$pos"; } ;;
|
||||
'[H' | '[1~') pos=0; menu_redraw "$label" "$val" "$pos" ;;
|
||||
'[F' | '[4~') pos=${#val}; menu_redraw "$label" "$val" "$pos" ;;
|
||||
'[3~')
|
||||
if [ "$pos" -lt "${#val}" ]; then
|
||||
val="${val:0:pos}${val:pos+1}"
|
||||
menu_redraw "$label" "$val" "$pos"
|
||||
fi
|
||||
;;
|
||||
'[A' | '[B') : ;; # up/down: no history — ignore
|
||||
esac
|
||||
;;
|
||||
0a | 0d)
|
||||
if [ "$paste" -eq 1 ]; then
|
||||
# newline inside a paste is literal data (paste as text);
|
||||
# echo the line break so CRLF pastes render at col 0
|
||||
printf -v ch '%b' "\\x$ch"
|
||||
val="${val:0:pos}${ch}${val:pos}"
|
||||
pos=$((pos + 1))
|
||||
printf '%s' "$ch" >&2
|
||||
else
|
||||
submit=1
|
||||
break
|
||||
fi
|
||||
;;
|
||||
7f | 08) # Backspace/DEL
|
||||
if [ "$pos" -gt 0 ]; then
|
||||
val="${val:0:pos-1}${val:pos}"
|
||||
pos=$((pos - 1))
|
||||
menu_redraw "$label" "$val" "$pos"
|
||||
fi
|
||||
;;
|
||||
03 | 1a | 1c) # Ctrl-C / Ctrl-Z / Ctrl-\ — cancel
|
||||
submit=0
|
||||
break
|
||||
;;
|
||||
04) # Ctrl-D: EOF on empty → cancel
|
||||
if [ -z "$val" ]; then
|
||||
submit=0
|
||||
break
|
||||
fi
|
||||
;;
|
||||
15) # Ctrl-U: clear
|
||||
val=""; pos=0
|
||||
menu_redraw "$label" "$val" "$pos"
|
||||
;;
|
||||
*)
|
||||
printf -v ch '%b' "\\x$ch"
|
||||
val="${val:0:pos}${ch}${val:pos}"
|
||||
pos=$((pos + 1))
|
||||
if [ "$pos" -eq "${#val}" ]; then
|
||||
printf '%s' "$ch" >&2 # append in place — fast path
|
||||
else
|
||||
menu_redraw "$label" "$val" "$pos"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
trap - INT TERM
|
||||
restore
|
||||
printf '\n' >&2
|
||||
if [ "$submit" -eq 0 ]; then
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$val"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Internal: redraw the whole input block (menu_read_value only) ──
|
||||
# The value may span several terminal rows (multiline paste); redraw clears
|
||||
# below the block start and reprints label + value, then repositions the
|
||||
# cursor to (row, col) of $3. Columns are counted in characters — wide CJK
|
||||
# glyphs can be off by one column (display-only; the stored value is exact).
|
||||
menu_redraw() {
|
||||
local label="$1" val="$2" pos="$3"
|
||||
local nl="" r="" c="" last="" ec="" d="" up=""
|
||||
nl="${val//[^$'\n']/}"
|
||||
[ "${#nl}" -gt 0 ] && printf '\033[%dA' "${#nl}" >&2
|
||||
printf '\r\033[J' >&2
|
||||
printf '%s: ' "$label" >&2
|
||||
printf '%s' "$val" >&2
|
||||
# target row/col of the cursor
|
||||
last="${val:0:pos}"
|
||||
r="${last//[^$'\n']/}"; r="${#r}"
|
||||
last="${last##*$'\n'}"
|
||||
c="${#last}"
|
||||
# current cursor (end of block): end row = nl count; end col = after last
|
||||
# newline (or 0 when the value ends with a newline)
|
||||
ec=0; last="${val##*$'\n'}"
|
||||
case "$val" in
|
||||
*$'\n') ec=0 ;;
|
||||
*) ec="${#last}" ;;
|
||||
esac
|
||||
[ "${#nl}" -gt "$r" ] && printf '\033[%dA' $(( ${#nl} - r )) >&2
|
||||
d=$(( c - ec ))
|
||||
if [ "$d" -gt 0 ]; then
|
||||
printf '\033[%dC' "$d" >&2
|
||||
elif [ "$d" -lt 0 ]; then
|
||||
printf '\033[%dD' $(( -d )) >&2
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Prompted value with optional default ───────────────────────
|
||||
# Prints "<label> [<default>]: " (read -p sends prompts to stderr) and echoes
|
||||
# the entered value or the default when the answer is empty.
|
||||
# rc 0 value on stdout · rc 1 EOF, or empty answer with no default.
|
||||
# Prints "<label> [<default>]: " and echoes the entered value or the default
|
||||
# when the answer is empty. Uses the bracketed-paste-safe reader, so pasting
|
||||
# text — including multi-line pastes — inserts it literally instead of letting
|
||||
# leftover lines escape to the shell as commands.
|
||||
# rc 0 value on stdout · rc 1 EOF/cancel, or empty answer with no default.
|
||||
menu_ask_value() {
|
||||
local label="$1" def="${2:-}" val pr="$1"
|
||||
[ -n "$def" ] && pr="$pr [$def]"
|
||||
if ! read -rp "${pr}: " val; then
|
||||
return 1 # EOF — cancel
|
||||
if ! val="$(menu_read_value "$pr")"; then
|
||||
return 1 # EOF / cancel
|
||||
fi
|
||||
if [ -z "$val" ]; then
|
||||
[ -n "$def" ] || return 1
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
#
|
||||
# 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 gemini ask --last / pos ai openrouter ask --last will
|
||||
# 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
|
||||
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
# lib/registry.sh — shared query API for POS tool metadata headers.
|
||||
# Sourced opt-in by consumers that need tool metadata.
|
||||
# Populates bash arrays from "# POS_*:" headers in bin/pos-* files;
|
||||
# consumers call reg_scan once, then reg_list / reg_lookup / reg_each.
|
||||
#
|
||||
# API:
|
||||
# reg_scan [dir] scan pos-* files → populate arrays
|
||||
# reg_list sorted tool keys
|
||||
# reg_categories sorted unique category names
|
||||
# reg_tools_in <cat> tool keys in a category
|
||||
# reg_lookup <tool> <field> field: cat|desc|flags|subcmds|deps|examples
|
||||
# reg_config_scopes sorted config scope names
|
||||
# reg_config_keys <scope> key|flags|desc lines
|
||||
# reg_config_envfile <scope> env-file basename for a scope
|
||||
# reg_each <callback> cb(category, tool_key, description)
|
||||
# reg_tool_exists <tool> exit 0 if registered
|
||||
|
||||
# ── common.sh helpers (guarded — mirrors lib/config-ui.sh) ─────
|
||||
declare -F log >/dev/null || log() { echo "[+] $*"; }
|
||||
declare -F warn >/dev/null || warn() { echo "[!] $*"; }
|
||||
declare -F err >/dev/null || err() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
# ── tool directory detection ────────────────────────────────────
|
||||
# Repo: lib/registry.sh → ../bin
|
||||
# Install: /usr/local/bin/registry.sh → /usr/local/bin (same dir)
|
||||
_reg_tools_dir() {
|
||||
local dir
|
||||
dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../bin" 2>/dev/null && pwd)"
|
||||
if [ -d "$dir" ] && ls "$dir"/pos-* &>/dev/null; then
|
||||
echo "$dir"
|
||||
else
|
||||
dirname "${BASH_SOURCE[0]}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── data stores ─────────────────────────────────────────────────
|
||||
declare -a _reg_tools=()
|
||||
declare -A _reg_cat=()
|
||||
declare -A _reg_desc=()
|
||||
declare -A _reg_flags=()
|
||||
declare -A _reg_subcmds=()
|
||||
declare -A _reg_deps=()
|
||||
declare -A _reg_examples=()
|
||||
declare -a _reg_config_scopes=()
|
||||
declare -A _reg_config_keys=()
|
||||
|
||||
# ── reg_scan ────────────────────────────────────────────────────
|
||||
reg_scan() {
|
||||
local dir="${1:-$(_reg_tools_dir)}" f
|
||||
local LC_ALL_PREV="${LC_ALL:-}"
|
||||
export LC_ALL=C
|
||||
|
||||
_reg_tools=()
|
||||
# Clear all associative arrays
|
||||
for key in "${!_reg_cat[@]}"; do
|
||||
unset "_reg_cat[$key]" "_reg_desc[$key]" "_reg_flags[$key]"
|
||||
unset "_reg_subcmds[$key]" "_reg_deps[$key]" "_reg_examples[$key]"
|
||||
done
|
||||
_reg_config_scopes=()
|
||||
for scope in "${!_reg_config_keys[@]}"; do
|
||||
unset "_reg_config_keys[$scope]"
|
||||
done
|
||||
|
||||
local -A scope_seen=()
|
||||
|
||||
for f in "$dir"/pos-*; do
|
||||
[ -x "$f" ] || continue
|
||||
local name="${f##*/pos-}"
|
||||
local key cat
|
||||
key="$name"
|
||||
if [[ "$name" == *-* ]]; then
|
||||
cat="${name%%-*}"
|
||||
else
|
||||
cat=""
|
||||
fi
|
||||
|
||||
_reg_tools+=("$key")
|
||||
_reg_cat["$key"]="$cat"
|
||||
|
||||
# POS: — description (text after first "— ")
|
||||
local pos_line
|
||||
pos_line="$(sed -n '/^# POS: /{s/^# POS: //;p;q}' "$f" 2>/dev/null)"
|
||||
_reg_desc["$key"]="${pos_line#*— }"
|
||||
|
||||
# POS_FLAGS:
|
||||
_reg_flags["$key"]="$(sed -n '/^# POS_FLAGS: /{s/^# POS_FLAGS: //;p;q}' "$f" 2>/dev/null)"
|
||||
|
||||
# POS_SUBCMDS:
|
||||
_reg_subcmds["$key"]="$(sed -n '/^# POS_SUBCMDS: /{s/^# POS_SUBCMDS: //;p;q}' "$f" 2>/dev/null)"
|
||||
|
||||
# POS_DEPS:
|
||||
_reg_deps["$key"]="$(sed -n '/^# POS_DEPS: /{s/^# POS_DEPS: //;p;q}' "$f" 2>/dev/null)"
|
||||
|
||||
# POS_EXAMPLES: (may appear multiple times — join with newlines)
|
||||
local examples=""
|
||||
examples="$(sed -n '/^# POS_EXAMPLES: /{s/^# POS_EXAMPLES: //;p}' "$f" 2>/dev/null)"
|
||||
_reg_examples["$key"]="$examples"
|
||||
|
||||
# POS_CONFIG: (may appear multiple lines per file)
|
||||
local line
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
line="${line#*POS_CONFIG:}"
|
||||
local scope="${line%%|*}"
|
||||
scope="${scope// }"
|
||||
[ -n "$scope" ] || continue
|
||||
_reg_config_keys["$scope"]+="${_reg_config_keys[$scope]:+$'\n'}$line"
|
||||
if [ -z "${scope_seen[$scope]:-}" ]; then
|
||||
scope_seen["$scope"]=1
|
||||
_reg_config_scopes+=("$scope")
|
||||
fi
|
||||
done < <(grep '^# POS_CONFIG:' "$f" 2>/dev/null || true)
|
||||
done
|
||||
|
||||
# Sort tools
|
||||
mapfile -t _reg_tools < <(printf '%s\n' "${_reg_tools[@]}" | sort)
|
||||
# Sort config scopes
|
||||
mapfile -t _reg_config_scopes < <(printf '%s\n' "${_reg_config_scopes[@]}" | sort -u)
|
||||
|
||||
# Restore LC_ALL
|
||||
if [ -n "$LC_ALL_PREV" ]; then
|
||||
export LC_ALL="$LC_ALL_PREV"
|
||||
else
|
||||
unset LC_ALL
|
||||
fi
|
||||
}
|
||||
|
||||
# ── discovery ───────────────────────────────────────────────────
|
||||
reg_list() { printf '%s\n' "${_reg_tools[@]}"; }
|
||||
|
||||
reg_categories() {
|
||||
local -a cats=()
|
||||
local t cat _rc_key
|
||||
local -A _rc_seen=()
|
||||
for t in "${_reg_tools[@]}"; do
|
||||
cat="${_reg_cat[$t]}"
|
||||
if [ -z "$cat" ]; then
|
||||
_rc_key="__empty__"
|
||||
else
|
||||
_rc_key="$cat"
|
||||
fi
|
||||
if [ -z "${_rc_seen[$_rc_key]+x}" ]; then
|
||||
_rc_seen["$_rc_key"]=1
|
||||
cats+=("$cat")
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "${cats[@]}" | sort
|
||||
}
|
||||
|
||||
reg_tools_in() {
|
||||
local cat="$1" t
|
||||
for t in "${_reg_tools[@]}"; do
|
||||
[ "${_reg_cat[$t]}" = "$cat" ] && echo "$t"
|
||||
done
|
||||
}
|
||||
|
||||
# ── lookup ──────────────────────────────────────────────────────
|
||||
reg_lookup() {
|
||||
local tool="$1" field="$2"
|
||||
case "$field" in
|
||||
cat) echo "${_reg_cat[$tool]:-}" ;;
|
||||
desc) echo "${_reg_desc[$tool]:-}" ;;
|
||||
flags) echo "${_reg_flags[$tool]:-}" ;;
|
||||
subcmds) echo "${_reg_subcmds[$tool]:-}" ;;
|
||||
deps) echo "${_reg_deps[$tool]:-}" ;;
|
||||
examples) echo "${_reg_examples[$tool]:-}" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── config scope helpers ────────────────────────────────────────
|
||||
reg_config_scopes() { printf '%s\n' "${_reg_config_scopes[@]}"; }
|
||||
|
||||
reg_config_keys() {
|
||||
local scope="$1"
|
||||
echo "${_reg_config_keys[$scope]:-}"
|
||||
}
|
||||
|
||||
reg_config_envfile() {
|
||||
local scope="$1" line
|
||||
line="$(echo "${_reg_config_keys[$scope]:-}" | head -1)"
|
||||
[ -n "$line" ] || return 1
|
||||
line="${line#*|}" # drop scope
|
||||
local env="${line%%|*}"
|
||||
echo "${env// }"
|
||||
}
|
||||
|
||||
# ── iteration ───────────────────────────────────────────────────
|
||||
reg_each() {
|
||||
local cb="$1" t
|
||||
for t in "${_reg_tools[@]}"; do
|
||||
"$cb" "${_reg_cat[$t]}" "$t" "${_reg_desc[$t]}"
|
||||
done
|
||||
}
|
||||
|
||||
# ── convenience ─────────────────────────────────────────────────
|
||||
reg_tool_exists() {
|
||||
[ -n "${_reg_desc[$1]+x}" ]
|
||||
}
|
||||
@@ -39,6 +39,7 @@ PACKAGES=(
|
||||
python3 python3-pip rclone
|
||||
ffmpeg
|
||||
libqrencode4 libgtk-3-0 adb
|
||||
xdotool xclip
|
||||
)
|
||||
|
||||
spawn "apt update" sudo apt update
|
||||
|
||||
+42
-16
@@ -10,6 +10,8 @@ set -euo pipefail
|
||||
# - "# POS:" header line → one-line description
|
||||
# - "# POS_FLAGS:" line → flag completion list (flag-style tools only)
|
||||
# - "# POS_SUBCMDS:" line → subcommand completion list (multi-command tools)
|
||||
# - "# POS_DEPS:" line → runtime binary dependencies (optional)
|
||||
# - "# POS_EXAMPLES:" line → curated usage examples (optional, multi-line)
|
||||
|
||||
root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
mode="write"
|
||||
@@ -22,7 +24,7 @@ export LC_ALL=C
|
||||
ctx="$root/DOC/AGENT_Context_Project.md"
|
||||
comp="$root/completions/pos.bash"
|
||||
|
||||
# ── Collect tools: "cat|sub|desc|flags|subcmds" ────────────────
|
||||
# ── Collect tools: "cat|sub|desc|flags|subcmds|deps|examples" ──
|
||||
# Category-less tools (pos-<cat>, e.g. pos-config) get an empty cat.
|
||||
# tooldisp <cat> <sub> → display name (pos-config / pos-communication-telegram-sender).
|
||||
tooldisp() { printf 'pos-%s%s' "${1:+$1-}" "$2"; }
|
||||
@@ -42,31 +44,55 @@ for f in "$root"/bin/pos-*; do
|
||||
desc="${desc#*— }"
|
||||
flags="$(sed -n '/^# POS_FLAGS: /{s/^# POS_FLAGS: //;p;q}' "$f")"
|
||||
subcmds="$(sed -n '/^# POS_SUBCMDS: /{s/^# POS_SUBCMDS: //;p;q}' "$f")"
|
||||
tools+=("$cat|$sub|$desc|$flags|$subcmds")
|
||||
deps="$(sed -n '/^# POS_DEPS: /{s/^# POS_DEPS: //;p;q}' "$f")"
|
||||
examples="$(grep '^# POS_EXAMPLES:' "$f" 2>/dev/null | sed 's/^# POS_EXAMPLES:[[:space:]]*//' | awk 'NR>1{printf " · "}{printf "%s", $0}END{print ""}' || true)"
|
||||
tools+=("$cat|$sub|$desc|$flags|$subcmds|$deps|$examples")
|
||||
done
|
||||
mapfile -t tools < <(printf '%s\n' "${tools[@]}" | sort)
|
||||
|
||||
# ── Block generators (emit inner content only, no markers) ──────
|
||||
# Check whether any tool has non-empty deps or examples (for conditional columns)
|
||||
_has_deps_examples=0
|
||||
for t in "${tools[@]}"; do
|
||||
IFS='|' read -r _ _ _ _ _ _tdeps _texamples <<<"$t"
|
||||
if [ -n "$_tdeps" ] || [ -n "$_texamples" ]; then
|
||||
_has_deps_examples=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
gen_tree() {
|
||||
local width=0 cat sub desc flags name t
|
||||
local width=0 cat sub desc flags name t deps examples
|
||||
for t in "${tools[@]}"; do
|
||||
IFS='|' read -r cat sub desc flags subcmds <<<"$t"
|
||||
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
|
||||
name="$(tooldisp "$cat" "$sub")"
|
||||
[ ${#name} -gt "$width" ] && width=${#name}
|
||||
done
|
||||
for t in "${tools[@]}"; do
|
||||
IFS='|' read -r cat sub desc flags subcmds <<<"$t"
|
||||
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
|
||||
name="$(tooldisp "$cat" "$sub")"
|
||||
printf '│ ├── %-*s# %s\n' "$((width + 1))" "$name" "$desc"
|
||||
if [ -n "$deps" ]; then
|
||||
printf '│ %*s│ [deps: %s]\n' "" "" "$deps"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
gen_dispatch() {
|
||||
local cat sub desc flags t
|
||||
for t in "${tools[@]}"; do
|
||||
IFS='|' read -r cat sub desc flags subcmds <<<"$t"
|
||||
printf '| %s | %s | `%s` | %s |\n' "$cat" "$sub" "$(tooldisp "$cat" "$sub")" "$desc"
|
||||
done
|
||||
local cat sub desc flags t deps examples
|
||||
if [ "$_has_deps_examples" -eq 1 ]; then
|
||||
printf '| Category | Command | Script | Description | Deps | Examples |\n'
|
||||
printf '|----------|---------|--------|-------------|------|----------|\n'
|
||||
for t in "${tools[@]}"; do
|
||||
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
|
||||
printf '| %s | %s | `%s` | %s | %s | %s |\n' "$cat" "$sub" "$(tooldisp "$cat" "$sub")" "$desc" "$deps" "$(printf '%s' "$examples" | sed 's/ | / → /g')"
|
||||
done
|
||||
else
|
||||
for t in "${tools[@]}"; do
|
||||
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
|
||||
printf '| %s | %s | `%s` | %s |\n' "$cat" "$sub" "$(tooldisp "$cat" "$sub")" "$desc"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
gen_selfcontained() {
|
||||
@@ -83,10 +109,10 @@ gen_selfcontained() {
|
||||
}
|
||||
|
||||
gen_filetable() {
|
||||
local cat sub desc flags name t
|
||||
local cat sub desc flags name t deps examples
|
||||
printf '| `bin/pos` | %s | CLI dispatcher with smart arg matching + logging + category help |\n' "$(wc -l < "$root/bin/pos")"
|
||||
for t in "${tools[@]}"; do
|
||||
IFS='|' read -r cat sub desc flags subcmds <<<"$t"
|
||||
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
|
||||
name="bin/$(tooldisp "$cat" "$sub")"
|
||||
printf '| `%s` | %s | %s |\n' "$name" "$(wc -l < "$root/$name")" "$desc"
|
||||
done
|
||||
@@ -94,10 +120,10 @@ gen_filetable() {
|
||||
}
|
||||
|
||||
gen_posflags() {
|
||||
local cat sub desc flags t
|
||||
local cat sub desc flags t deps examples
|
||||
echo "declare -A _pos_flags"
|
||||
for t in "${tools[@]}"; do
|
||||
IFS='|' read -r cat sub desc flags subcmds <<<"$t"
|
||||
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
|
||||
[ -n "$flags" ] || continue
|
||||
printf '_pos_flags[%s]="%s"\n' "$(tooldisp "$cat" "$sub" | sed 's/^pos-//')" "$flags"
|
||||
done
|
||||
@@ -106,10 +132,10 @@ gen_posflags() {
|
||||
gen_possubcmds() {
|
||||
# Subcommand completion: "# POS_SUBCMDS:" list + nested sub-tools from
|
||||
# filenames (pos-<cat>-<sub>-<extra> → "extra" completes under <cat>-<sub>).
|
||||
local cat sub desc flags subcmds rest f t
|
||||
local cat sub desc flags subcmds deps examples rest f t
|
||||
echo "declare -A _pos_subcmds"
|
||||
for t in "${tools[@]}"; do
|
||||
IFS='|' read -r cat sub desc flags subcmds <<<"$t"
|
||||
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
|
||||
subcmds="${subcmds:-}"
|
||||
for f in "$root"/bin/"$(tooldisp "$cat" "$sub")"-*; do
|
||||
[ -x "$f" ] || continue
|
||||
|
||||
@@ -9,6 +9,9 @@ set -euo pipefail
|
||||
# lines (single source of truth for generated docs):
|
||||
# # POS: <category> <command> — one-line description
|
||||
# # POS_FLAGS: --flag1 --flag2 (flag-style tools only)
|
||||
# # POS_SUBCMDS: sub1 sub2 (multi-command tools only)
|
||||
# # POS_DEPS: binary1 binary2 (runtime deps, optional)
|
||||
# # POS_EXAMPLES: pos <tool> <args> | Description (optional)
|
||||
# 3. Exec bit: chmod +x bin/pos-<category>-<command>
|
||||
# 4. If it reads stdin (password/selection prompts), add it to
|
||||
# INTERACTIVE_CMDS in bin/pos or its prompt breaks under the log tee.
|
||||
|
||||
Reference in New Issue
Block a user