Compare commits

..

19 Commits

Author SHA1 Message Date
Your Name 072a8e72c1 feat: pos media grab — smart URL classifier for auto-download
gates / consistency-and-conventions (push) Successful in 2m14s
New tool that classifies URLs by domain and delegates to pos media mp3
(audio) or pos media mp4 (video). Listener gains URL detection step
between prefix map and AI bridge — bare URLs auto-download.

Domain rules: music.youtube.com/soundcloud/bandcamp → mp3,
youtube/vimeo/twitch → mp4 --best, unknown → configurable default.

28 test cases / 70 assertions / 0 failed.
make gen && make check green, make lint 0 FAIL / 0 WARN.
2026-09-04 10:48:49 -04:00
Your Name a4761df3f6 feat: telegram listener text-prefix map — <word> <text> routes to a mapped app
gates / consistency-and-conventions (push) Successful in 2m3s
Generalizes the Telegram listener with a configurable text-prefix map
(telegram_prefixes.env): any 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".

Routing order per message: text-prefix map → built-in Gemini ai bridge →
/command map → Unknown command. A mapped word shadows the Gemini bridge.

The prefix verb is reworked: bare = list map + bridge word; 'prefix
<word> <cmd...>' = map; 'prefix <word>' = show; 'prefix -r <word>' = remove.
The Gemini trigger word itself is now set via 'pos config telegram'
(TELEGRAM_AI_PREFIX).

Also extracted run_and_reply() to share the /command-map (60s) and
prefix (120s) execution semantics; fixed a latent set -e abort on
invalid templates in prefix_map_set's check_syntax call.

Verified: 27/27 routing-harness assertions, full CLI verb suite,
dispatch smoke, pos config telegram render, bash -n, make gen && make check,
make lint 0 FAIL / 0 WARN, shellcheck -S style (0 new findings).
2026-09-04 08:10:59 -04:00
Your Name e6fa0a4ee9 feat: configurable AI-bridge trigger word for telegram listener
gates / consistency-and-conventions (push) Successful in 1m50s
The listener's "ai " bridge prefix was hard-coded. Messages starting
with <prefix> + space (case-insensitive, literal match) are now
forwarded to Gemini; default stays "ai".

- TELEGRAM_AI_PREFIX in telegram.env (default ai), hot-reloaded per
  message like the command map — no daemon restart needed
- New 'prefix' verb: pos communication telegram listener prefix [word]
  (validated [A-Za-z0-9][A-Za-z0-9_-]*; writes telegram.env chmod 600)
- Field added to the telegram # POS_CONFIG: scope (sender header) so
  'pos config telegram' edits it too
- --status shows the current prefix; usage + POS_SUBCMDS: prefix
  (completions regenerated)
- Matching via scoped nocasematch + quoted-literal =~ prefix;
  ai_bridge_prefix() precedence: env file > env var > default ai

Verified: routing harness (default/custom/case-insensitive/reset/
fallback/unknown-command) green, CLI verb tests, dispatch smoke,
pos config render, gates 0 FAIL 0 WARN.
2026-08-27 11:04:37 -04:00
Your Name 4306a53fef fix: paste-safe multi-line value input in pos ai alias Insert Prompt
gates / consistency-and-conventions (push) Successful in 1m54s
menu_ask_value used line-oriented read -rp: a multiline Ctrl+V paste
flooded the tty queue, read consumed only the first line, and the rest
executed as commands later (or were eaten by a later prompt).

- lib/menu-lib.sh: new menu_read_value() raw-mode bracketed-paste
  reader (stty -icanon -echo -isig, \e[?2004h/l, literal newlines inside
  [200~..[201~, Enter submits outside paste, edit keys, cancel on
  Ctrl-D-empty/Ctrl-C/Z/\, terminal restored via trap). Bytes via
  dd|od|tr chunks, not bash read: read self-interrupts on ETX from a
  tty even with ISIG disabled.
- bin/pos-ai-alias: prompt encode/decode (backslash, newline) with
  load/save wiring; newline-safe truncate; edit wizard Enter keeps the
  full original prompt (no more silent >80-char truncation).

Verified via pty harnesses: multiline + single-line paste captured
verbatim with nothing executed, Ctrl-D/Ctrl-C cancel cleanly, full
create/list/show/edit E2E, round-trips byte-exact. Gates: make gen &&
make check, make lint 0 FAIL 0 WARN.
2026-08-27 04:44:13 -04:00
Your Name 300b742ac8 feat: alias trust flag — auto-execute agent commands without confirmation
gates / consistency-and-conventions (push) Successful in 1m29s
Add an optional5th 'trusted' field to aliases
(name|provider|session|prompt|trusted). Trusted aliases pass --trust to
pos ai, which makes _prompt_run_command auto-execute the agent's detected
commands without the Y/n confirmation (command still printed for audit).

- bin/pos-ai: new --trust global flag; _prompt_run_command takes trusted
  arg and skips the prompt when set; POS_FLAGS + usage updated
- bin/pos-ai-alias: _ALIAS_TRUSTED array, 5-field env format (backward
  compat: missing field defaults to untrusted), Trust column in table,
  trust row in show, trust step (5/5) in create wizard with security
  warning, trust toggle (4/4) with diff tag in edit wizard, wrapper
  scripts get --trust when alias is trusted
- completions/pos.bash + gen docs updated

Gates: make gen && make check && make lint = 0 FAIL, 0 WARN
2026-08-27 03:27:09 -04:00
Your Name 59935dc5ef fix: alias create fails with empty-name collision due to dynamic scoping bug
gates / consistency-and-conventions (push) Failing after 9s
_alias_load() used 'name' as its while-read loop variable, which — via
bash dynamic scoping — clobbered the caller's local 'name'. When _alias_create
passed 'searcher', _alias_load overwrote it to '' (last env-file line's name),
making _wrapper_path produce '~/.local/bin/' (the directory itself). Since
directories always exist, [ -e ] triggered a spurious 'already exists' error.

Fix: rename _alias_load loop vars to _ln/_lp/_ls/_lp2/_lr (local), breaking
the dynamic-scope collision. Reproduced and verified with a test harness.

Gates: make gen && make check && make lint = 0 FAIL, 0 WARN
2026-08-27 02:41:57 -04:00
Your Name e969234ca5 feat: command registry, alias wrapper scripts, config-ui readability
gates / consistency-and-conventions (push) Successful in 1m28s
- lib/registry.sh: shared query API over POS_* headers (reg_scan, reg_list,
  reg_lookup, reg_tools_in, reg_each, reg_config_scopes/keys). Replaces
  per-consumer sed/grep header parsing.

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

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

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

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

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

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

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

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

Fix: _alias_find() return 1 crashed under set -e; changed to return 0
since -1 sentinel is the not-found signal, not the exit code.
2026-08-26 06:00:01 -04:00
Your Name a5c19e842d revert: remove e(dit) option from AI command prompt
gates / consistency-and-conventions (push) Successful in 1m40s
Keep only Y/n (run or skip). The edit feature was unreliable across
different terminal contexts (tee pipes, SSH, CLI). May revisit later.
2026-08-26 04:58:08 -04:00
Your Name d84a35efce fix: read -e -i stores into variable directly, not stdout
gates / consistency-and-conventions (push) Successful in 2m34s
edited="\$(read ...)" was always empty because read writes to a variable
name, not stdout. Changed to: read -e -p "Command: " -i "\$flat" edited
which stores directly into \$edited.
2026-08-26 04:42:18 -04:00
Your Name 9564880ebf fix: AI command edit - flatten multi-line for readline
gates / consistency-and-conventions (push) Successful in 1m47s
read -e -i only handles single-line text. Multi-line commands (docker
install etc) broke it. Now flattens newlines to spaces before pre-filling
the readline buffer. User sees a single editable line.
2026-08-26 04:37:52 -04:00
Your Name d0299d3f98 feat: AI command edit via clipboard + xdotool fallback
gates / consistency-and-conventions (push) Successful in 2m14s
- _inject_command tries: xclip/wl-copy (clipboard) -> xdotool (typing) -> tmux -> history
- Clipboard is primary: user pastes with Ctrl+Shift+V
- preinstall.sh: add xdotool and xclip to PACKAGES
2026-08-26 04:13:36 -04:00
Your Name c1f1c4109f feat: AI command prompt adds e(dit) option with keyboard simulation
gates / consistency-and-conventions (push) Successful in 2m11s
- e: xdotool type (X11/Wayland) -> tmux send-keys -> history fallback
- Command appears on active terminal line for editing before Enter
- Y/Enter: execute, n: add to history
2026-08-26 03:51:16 -04:00
Your Name 710b626f47 feat: AI command prompt - run or edit detected shell commands
gates / consistency-and-conventions (push) Successful in 2m6s
- _extract_commands() parses bash/sh/shell fenced code blocks
- _prompt_run_command() prompts [Y/n] via /dev/tty after AI response
- Y/Enter: execute via run helper (respects DRY_RUN)
- n: command added to history (press up-arrow to recall, edit, run)
- Integrated in both cmd_ask() and cmd_chat()
- Skipped when output is piped/redirected
2026-08-26 03:26:11 -04:00
Your Name 1c19c0de59 fix: _cfg_provider_keys path resolution for installed layout
gates / consistency-and-conventions (push) Successful in 1m47s
Try both repo (../lib/ai-providers/) and installed (./ai-providers/) paths.
Installed layout copies ai-providers/ to same dir as config-ui.sh.
2026-08-26 03:07:29 -04:00
Your Name e0c9ba384a feat: dynamic provider config — pos config ai auto-discovers provider keys
gates / consistency-and-conventions (push) Successful in 1m30s
- lib/ai-providers/*.sh declare # PROVIDER_CONFIG: headers
- lib/config-ui.sh: _cfg_provider_keys() scans providers at runtime
- bin/pos-ai: POS_CONFIG uses *providers marker (no hardcoded keys)
- Adding a new provider auto-populates config UI — no main tool edits needed
2026-08-26 02:58:45 -04:00
32 changed files with 2340 additions and 197 deletions
+2 -2
View File
@@ -14,8 +14,8 @@ CRITICAL: real guidance lives in DOC/. When you encounter a reference below, use
## Quick facts
- **Tool model:** `bin/pos-<category>-<command>`, or **category-less** `bin/pos-<cmd>` for dispatcher/dev-level commands that fit no category (`pos-config`, `pos-tree`) — they dispatch like any tool and show with an empty category in the generated tables. `bin/pos` dispatches by longest-prefix arg matching. New tools are auto-discovered but must be executable (`100755`) and carry a `# POS: <cat> <cmd> — <desc>` header right after the shebang; `make gen` only uses the text after the first `— ` (the leading words are convention-only), so keep the one-line description concise. `# POS_FLAGS:` / `# POS_SUBCMDS:` / `# POS_CONFIG:` headers feed tab-completion and the `pos config` scope registry. A missing `# POS:` header hard-fails `make gen`. Legacy `bin/wr-*`, `mp3`, `mp4`, `vbox`, `ssh-load-all` are thin forwarders to `pos` — keep them that way.
- **Categories:** `ai`, `communication`, `docker`, `entertainment`, `media`, `network`, `share` (usb, nfs, smb), `ssh`, `system`, plus category-less `config`/`tree`. `pos tree` (bin/pos-tree) is the authoritative structure — it derives the hierarchy from `bin/pos-*` filenames + `# POS:`/`# POS_SUBCMDS:` headers.
- **Tool model:** `bin/pos-<category>-<command>`, or **category-less** `bin/pos-<cmd>` for dispatcher/dev-level commands that fit no category (`pos-config`, `pos-tree`) — they dispatch like any tool and show with an empty category in the generated tables. `bin/pos` dispatches by longest-prefix arg matching. New tools are auto-discovered but must be executable (`100755`) and carry a `# POS: <cat> <cmd> — <desc>` header right after the shebang; `make gen` only uses the text after the first `— ` (the leading words are convention-only), so keep the one-line description concise. `# POS_FLAGS:` / `# POS_SUBCMDS:` / `# POS_CONFIG:` headers feed tab-completion and the `pos config` scope registry; optional `# POS_DEPS: <binary…>` declares space-separated runtime binaries the tool hard-requires via `command -v` guards, and optional `# POS_EXAMPLES: <command> | <description>` adds curated usage examples, one per line. `lib/registry.sh` is the shared query API over all `POS_*` headers — consumers source it (`reg_scan` + `reg_list`/`reg_lookup`/…) instead of re-implementing sed/grep header parsing; new consumers should prefer it. A missing `# POS:` header hard-fails `make gen`. Legacy `bin/wr-*`, `mp3`, `mp4`, `vbox`, `ssh-load-all` are thin forwarders to `pos` — keep them that way.
- **Categories:** `ai`, `communication`, `docker`, `entertainment`, `media`, `network`, `share` (usb, nfs, smb), `ssh`, `system`, plus category-less `config`/`tree`. `pos tree` (bin/pos-tree) is the authoritative structure — it derives the hierarchy from `bin/pos-*` filenames + `# POS:`/`# POS_SUBCMDS:` headers, reads the metadata through `lib/registry.sh`, and annotates each command's declared `# POS_DEPS:`.
- **Generated code:** blocks between `GEN:START`/`GEN:END` markers in `DOC/AGENT_Context_Project.md` (tree, dispatch, selfcontained, filetable, docmap) and `completions/pos.bash` (flags, subcmds, config scopes) are `make gen` output — never hand-edit them. Generators must be **byte-order deterministic** (sort with `LC_ALL=C`, as `scripts/gen-docs.sh` does) or CI's `git diff --exit-code` trips on a locale that collates differently. After touching `bin/pos-*`, run `make gen`, then `make check`, then `make lint` (definition of done: check green + lint ends `0 FAIL, 0 WARN`). `make check` (`scripts/check-sync.sh`) is the self-consistency gate — bash -n + exec-bit check + doc-sync + dispatch smoke; `make lint` (`scripts/lint-conventions.sh`) is the convention gate — it enforces every rule in this file (shebang/strict-mode, exec bits, `# POS:` headers, `-h|--help` present and after deps guards, stdin-readers in `INTERACTIVE_CMDS`, POS.md coverage, plugin/app/unit/wrapper/secrets/env-seam classes — see `DOC/DEV.md → Convention Lint Gate`). Hand-maintained, not gen-checked: `DOC/POS.md`, the line-count rows above the filetable marker in `DOC/AGENT_Context_Project.md` (the non-`pos-*` files — `install.sh`, `preinstall.sh`, `postinstall.sh`, `lib/*`, `features/*`; bump a row's count only when that file's length changes), `bin/pos` usage() EXAMPLES, root README. CI (`.gitea/workflows/lint.yml`, job `gates`) runs the same four commands on every push to main and PR, then records the result as a git tag on the commit: `ci-ok/<sha>` or `ci-fail/<sha>` (pushes only — query with `scripts/ci-status.sh [--wait] [<sha>]`; exit 0 green / 1 red / 2 pending). A red run means gen drift or a gate failure and is a merge-blocker; still run the gates locally too (lint isn't in the pre-commit hook).
- **Stdin gotcha:** any tool that reads stdin must be added to `INTERACTIVE_CMDS` in `bin/pos` — otherwise the logging `tee` pipe hangs on (or swallows) the prompt.
- **Deps:** apt packages → `PACKAGES` array in `preinstall.sh`; non-apt/manual installers (e.g. `usbsrv`) → `command -v <bin> || err "…"` guard inside the tool, never in PACKAGES. Hotspot binaries (`create_ap`, `wihotspot*`) are prebuilt in `x64_bin/` (or `arm64_bin/`) and copied by `install.sh` — not apt packages.
+6
View File
@@ -42,6 +42,8 @@ summary (newest last).
## Done
- **2026-09-04** — `pos media grab` (`bin/pos-media-grab`) — auto-download a URL as audio or video. Classifies by domain (YouTube Music/SoundCloud/Bandcamp → mp3; YouTube/Vimeo/Twitch → mp4) with `--audio`/`--video` overrides and `GRAB_DEFAULT` config (`pos config grab`, default `video`) for unknown domains; `--best` default for video (non-interactive, `--worst` override); all flags (`--output`, `--no-playlist`, `--cookies`, `--dry-run`) forwarded to mp3/mp4; prints a clean summary (🎵/🎬 title, duration, path, size). Telegram listener (`bin/pos-communication-telegram-listener`) gains `url_detect` + a URL routing step between the prefix map and AI bridge — bare http(s) URLs route to `pos media grab --best` (600s timeout). Verified: `/tmp/opencode/media-grab-test/run-tests.sh` 28 cases / 70 assertions green; `bash -n` on both files; `make gen && make check` green; `make lint` 0 FAIL / 0 WARN.
- **2026-08-21** — Share suite interactive layer (`lib/share-lib.sh` + menu modes for all five `pos share *` tools): bare invocation now opens an EOF-safe looping menu instead of printing usage. New `lib/share-lib.sh` (436 lines) owns the shared primitives — `share_menu_guard`/`share_menu_run`/`share_pick`/`share_ask_value` (quit on EOF so non-tty callers can't hang), `share_require_bin`/`share_port_probe`/`share_service_active`/`share_path_probe` rc-only probes, `share_usb_records` (blank-line-record parser for usbsrv listings), `share_smb_shares` (smbclient `-g` Disk enumeration incl. guest→auth retry) + `share_usb_devices`/`share_usb_clients`, `share_nfs_exports` (showmount), `share_folder_candidates` (bounded-probe scan of mounted targets + conventional roots; container overlay/tmpfs/nsfs excluded via findmnt; clients build their own mountpoint pickers on top), and advisories `share_ufw_blocks_ports`+`share_offer_fix`. Tools keep every legacy flag/subcommand byte-compatible (verbatim command bodies, thin menu layer on top): nfs-server gains a client-spec presets picker + inactive-service/UFW offers, nfs-client gains idempotent unmount/unpersist (already-absent = report, rc0) + persist-verify-with-rollback + replace-confirm, smb-server gets UFW offer + menu tree, smb-client gets enumerate→pick→mount with account reuse (`SMB_AUTH_USER` contract), usb-server picker-first with raw-listing manual-entry fallback when the server listing is unreadable. New seams: `EXPORTS_FILE` (nfs-server), `UNIT_DIR` (nfs-client); `bin/pos` INTERACTIVE_CMDS += both stdin-reading share tools; install.sh lib list += share-lib.sh; preinstall.sh += smbclient (smb-client enumeration dep). Docs: POS.md share rows/detail, howto/share.md per-tool Interactive-menu notes, SCRIPTS.md phase table + new `## lib/share-lib.sh` section, DEV.md lib row + env-seam registry, AGENT_Context hand-maintained spots (lib table row 436, Phase-2 prose). Verified: 80-case stub battery vs recaptured deterministic golden — only the 9 documented intentional deltas differ (additive help lines, seam-path strings in messages, missing-dep message delta, unmount-idle FLAGGED→rc0, unpersist round-trip now works, usbs-bare usage→menu guard); 12/12 PTY tests (menus open/quit non-tty, filter/zero-match/default/cancel picker semantics, full nfs-server share flow writes the export line, usb-server share via pickers + down-server fallback, smb-client guest-enumerate→manual-share flow); real `/etc/exports` + `/etc/systemd/system` md5-verified untouched; `make gen && make check` green, `make lint` 0 FAIL / 0 WARN.
- **2026-08-21** — Refreshed `AGENTS.md` against the codebase: HOWTO category list corrected to match `DOC/howto/*` (ai/share/schedule, no bare "usb"); CI bullet now states only verifiable facts (`lint.yml` job `gates`, push-to-main/PR, `ci-ok/<sha>`/`ci-fail/<sha>` result tags) instead of the uncheckable act-runner naming; new **Doc conflicts** bullet encoding the `MAINTENANCE.md → Phase 0` authority order and `templates/*.sh` as required starting points. Every other claim re-verified against `scripts/{gen-docs,check-sync,lint-conventions}.sh`, `bin/pos` (dispatch loop, INTERACTIVE_CMDS), `.gitignore`/`.gitmodules`, `lib/config-ui.sh`; gates green before and after.
- **2026-08-15** — `pos docker stack` (`bin/pos-docker-stack`) — containers grouped by their Docker Compose project. Each stack is a section (project name, sorted) with lines `container-name status ports`; containers with no compose project land in a `Standalone` section at the end; ends with `Stacks: N containers: N standalone: N`. Running only by default, `-a|--all` includes stopped/exited (like `docker ps -a`). Status colored on a terminal (`Up*` green, `Exited*`/`Dead*`/`Created*` red, `Paused*`/`Restarting*` yellow); exit 0 also when no containers. Data via `docker ps` with `--format '{{.Names}}{{"\u001f"}}{{.Label "com.docker.compose.project"}}{{"\u001f"}}{{.Status}}{{"\u001f"}}{{.Ports}}'` (compose v2 sets the project label; `{{"\u001f"}}` escapes in the Go template), parsed with `awk -F'\x1f'` + `IFS=$'\x1f' read` everywhere — tab/pipe delimiters are IFS whitespace or inside values, so `\x1f` (DEV.md:213 gotcha); dash padding via `sed` not `tr` (tr corrupts multi-byte `─`). Deps guard (`docker`) before `--help`; no stdin → not in `INTERACTIVE_CMDS`; `# POS_FLAGS: -a --all`. Docs: POS.md docker row + detail, howto/docker.md table + section, `bin/pos` usage EXAMPLES, AGENT_Context §14 row. Verified: stub-PATH suite `/tmp/opencode/docker-stack-test/run-tests.sh` 23/23 (grouping, sorted stacks, `-a` shows exited, standalone, empty daemon rc=0, colored status, missing docker rc=1, `--help` after deps guard); live runs against the real daemon (affine/audiobookshelf/convertx/gitea stacks, `affine_migration_job Exited (0)` + `lab1 Exited (137)` under `-a`); dispatch via `pos docker stack`; `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
@@ -134,3 +136,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 60120 cols, whole render block → stderr per menu-lib house pattern, honest prompt `Number to edit [r=refresh, q=quit]:`); masking/edit flow byte-compatible, no per-scope branches. `bin/pos-ai` line-6 header adopted to the caption/tag syntax (single-line change). Verified: stub-PATH harness (`HOME=/tmp/…`, `CONFIG_DIR` seam, argv-capturing `pos` shim) covering %q quoting round-trips (quotes/backticks/`$()`/%/unicode), staleness kill-test, orphan retraction, collision-refusal matrix, legacy migration (marker + foreign), PATH-absent warning, non-tty guard, idempotent double-sync; rendered-output diffs vs Designer mockups for `ai` AND old-format `system`; gates `make gen && make check && make lint` 0 FAIL / 0 WARN.
- **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.
+79 -68
View File
@@ -10,19 +10,19 @@
<!-- GEN:START docmap -->
| ## 1. Project Overview | 2843 |
| ## 2. Directory Structure | 44199 |
| ## 3. Installation Flow | 200253 |
| ## 4. The `pos` CLI System | 254330 |
| ## 5. Shared Library — `lib/common.sh` | 331362 |
| ## 6. Docker Compose / ScaleTail | 363405 |
| ## 7. Optional Apps (`apps/`) | 406435 |
| ## 8. Entertainment Module | 436449 |
| ## 9. Systemd Services | 450461 |
| ## 10. Configuration Files | 462488 |
| ## 11. Coding Conventions | 489521 |
| ## 12. Development Workflow | 522574 |
| ## 13. Key File Quick Reference | 575644 |
| ## 14. Common Tasks for Agents | 645678 |
| ## 2. Directory Structure | 44205 |
| ## 3. Installation Flow | 206259 |
| ## 4. The `pos` CLI System | 260338 |
| ## 5. Shared Library — `lib/common.sh` | 339370 |
| ## 6. Docker Compose / ScaleTail | 371413 |
| ## 7. Optional Apps (`apps/`) | 414443 |
| ## 8. Entertainment Module | 444457 |
| ## 9. Systemd Services | 458469 |
| ## 10. Configuration Files | 470496 |
| ## 11. Coding Conventions | 497529 |
| ## 12. Development Workflow | 530582 |
| ## 13. Key File Quick Reference | 583655 |
| ## 14. Common Tasks for Agents | 656689 |
<!-- 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-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)
@@ -78,12 +80,15 @@ Linux_post_install/
│ ├── pos-entertainment-enable # Enable an auto-trigger for a plugin on a schedule
│ ├── pos-entertainment-send # Run a public-API plugin and send its output via the configured notify platforms
│ ├── pos-entertainment-status # Show enabled plugins and scheduler state
│ ├── pos-media-grab # Auto-download URL as audio or video (classify + route)
│ ├── 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,6 +99,7 @@ Linux_post_install/
│ ├── pos-share-usb-server # USB Redirector server control (--ls, --share; prompts when args omitted)
│ ├── pos-ssh-load-keys # Load all SSH keys into the agent
│ ├── pos-system-backup # Encrypted (AES-256) folder snapshots (tar + gpg)
│ │ [deps: tar]
│ ├── pos-system-firewall # Interactive UFW management
│ ├── pos-system-health # Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL
│ ├── pos-system-schedule # Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently
@@ -207,7 +213,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)
@@ -267,49 +273,51 @@ 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` | Forward to pos ai --provider gemini (backward compat) |
| ai | openrouter | `pos-ai-openrouter` | Forward to pos ai --provider openrouter (backward compat) |
| communication | matrix-listener | `pos-communication-matrix-listener` | Matrix listener: map /command → bash, run them on room messages |
| communication | matrix-sender | `pos-communication-matrix-sender` | Send messages to a Matrix room via the client-server API (send, test, login) |
| communication | scrcpy | `pos-communication-scrcpy` | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) |
| communication | telegram-listener | `pos-communication-telegram-listener` | Telegram bot listener: map /command → bash, run them on chat messages |
| communication | telegram-sender | `pos-communication-telegram-sender` | Send Telegram messages/files/links/stickers via Bot API (send, test) |
| docker | compose | `pos-docker-compose` | Docker Compose service manager (ls/up/down/restart/logs/update/config) |
| docker | health | `pos-docker-health` | One-glance container health dashboard (exits 1 if unhealthy) |
| docker | ps | `pos-docker-ps` | Enhanced container overview (health, IPs, ports, uptime) |
| docker | stack | `pos-docker-stack` | Containers grouped by compose stack (project); standalone group; -a/--all includes stopped |
| docker | vbox | `pos-docker-vbox` | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) |
| entertainment | config | `pos-entertainment-config` | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) |
| entertainment | disable | `pos-entertainment-disable` | Disable a plugin's auto-trigger (remove it from ENABLED) |
| entertainment | enable | `pos-entertainment-enable` | Enable an auto-trigger for a plugin on a schedule |
| entertainment | send | `pos-entertainment-send` | Run a public-API plugin and send its output via the configured notify platforms |
| entertainment | status | `pos-entertainment-status` | Show enabled plugins and scheduler state |
| media | mp3 | `pos-media-mp3` | Download audio as MP3 (yt-dlp) |
| media | mp4 | `pos-media-mp4` | Download video as MP4 (smart/interactive format select) |
| media | sync | `pos-media-sync` | Incremental Music → USB sync (mp3/mp4, add/update only) |
| media | ytsync | `pos-media-ytsync` | Incrementally sync YouTube channels/playlists into ~/Videos |
| network | checkport | `pos-network-checkport` | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view |
| network | download | `pos-network-download` | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) |
| network | hotspot | `pos-network-hotspot` | Wi-Fi hotspot via create_ap + wihotspot-gui |
| network | ip | `pos-network-ip` | Show interfaces, routes, public IP + location |
| network | scan | `pos-network-scan` | Parallel ping sweep of CIDR |
| share | nfs-client | `pos-share-nfs-client` | Mount NFS shares (ephemeral or persistent systemd mount units) |
| share | nfs-server | `pos-share-nfs-server` | Manage the NFS kernel server (status, share/unshare exports, enable/disable) |
| share | smb-client | `pos-share-smb-client` | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) |
| share | smb-server | `pos-share-smb-server` | Manage the Samba server (status, share/unshare exports, users, enable/disable) |
| share | usb-server | `pos-share-usb-server` | USB Redirector server control (--ls, --share; prompts when args omitted) |
| ssh | load-keys | `pos-ssh-load-keys` | Load all SSH keys into the agent |
| system | backup | `pos-system-backup` | Encrypted (AES-256) folder snapshots (tar + gpg) |
| system | firewall | `pos-system-firewall` | Interactive UFW management |
| system | health | `pos-system-health` | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL |
| system | schedule | `pos-system-schedule` | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
| system | uninstall | `pos-system-uninstall` | Remove pos toolkit binaries, services, shell integration, config, and data |
| | ai | `pos-ai` | AI assistant: ask, chat, sessions, capture, models, providers |
| | config | `pos-config` | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) |
| | tree | `pos-tree` | Show the pos CLI command tree: categories, commands, and subcommands |
| Category | Command | Script | Description | Deps | Examples |
|----------|---------|--------|-------------|------|----------|
| ai | alias | `pos-ai-alias` | manage AI agent aliases | | |
| ai | gemini | `pos-ai-gemini` | Forward to pos ai --provider gemini (backward compat) | | |
| ai | openrouter | `pos-ai-openrouter` | Forward to pos ai --provider openrouter (backward compat) | | |
| communication | matrix-listener | `pos-communication-matrix-listener` | Matrix listener: map /command → bash, run them on room messages | | |
| communication | matrix-sender | `pos-communication-matrix-sender` | Send messages to a Matrix room via the client-server API (send, test, login) | | |
| communication | scrcpy | `pos-communication-scrcpy` | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) | | |
| communication | telegram-listener | `pos-communication-telegram-listener` | Telegram bot listener: map /command → bash 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 | grab | `pos-media-grab` | Auto-download URL as audio or video (classify + route) | | |
| 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
@@ -588,24 +596,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` | 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` | 805 | 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) |
@@ -613,12 +623,13 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `bin/pos-entertainment-enable` | 49 | Enable an auto-trigger for a plugin on a schedule |
| `bin/pos-entertainment-send` | 95 | Run a public-API plugin and send its output via the configured notify platforms |
| `bin/pos-entertainment-status` | 62 | Show enabled plugins and scheduler state |
| `bin/pos-media-grab` | 227 | Auto-download URL as audio or video (classify + route) |
| `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 |
@@ -628,15 +639,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` | 415 | Remove pos toolkit binaries, services, shell integration, config, and data |
| `bin/pos-ai` | 645 | AI assistant: ask, chat, sessions, capture, models, providers |
| `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` | 306 | Dynamic bash completion |
| `bin/pos-tree` | 118 | Show the pos CLI command tree: categories, commands, and subcommands |
| `completions/pos.bash` | 309 | Dynamic bash completion |
<!-- GEN:END filetable -->
| `apps/install.sh` | 171 | App install/uninstall picker/orchestrator |
+4 -2
View File
@@ -31,7 +31,7 @@ Each phase is independent and runs only if the corresponding script exists.
|-----------|---------|-------------|
| `bin/` | Daily-use CLI tools and wrappers | `/usr/local/bin/` |
| `apps/<category>/` | Optional desktop app installers | run on demand |
| `lib/` | Shared libraries: `common.sh` (helpers), `flags.sh` (feature flags), `notify.sh` (multi-platform alerting), `entertainment-lib.sh` (entertainment scheduling + last-run state), `entertainment-plugin-lib.sh` (message-safe plugin helpers), `scheduler-lib.sh` (system scheduler), `user-timers-lib.sh` (shared systemd user timer machinery), `config-ui.sh` (interactive config UI), `menu-lib.sh` (category-neutral menu primitives: guard/looping menu/filter picker/prompt), `share-lib.sh` (share-suite domain probes/listings + compat shims to menu-lib) | sourced at build time |
| `lib/` | Shared libraries: `common.sh` (helpers), `flags.sh` (feature flags), `notify.sh` (multi-platform alerting), `registry.sh` (shared query API for POS tool metadata headers), `entertainment-lib.sh` (entertainment scheduling + last-run state), `entertainment-plugin-lib.sh` (message-safe plugin helpers), `scheduler-lib.sh` (system scheduler), `user-timers-lib.sh` (shared systemd user timer machinery), `config-ui.sh` (interactive config UI), `menu-lib.sh` (category-neutral menu primitives: guard/looping menu/filter picker/prompt), `share-lib.sh` (share-suite domain probes/listings + compat shims to menu-lib) | sourced at build time |
| `config/` | Gitignored user config files | `~/.config/<app>/` (via postinstall) |
| `entertainment/` | Public-API plugins for the entertainment module | `/usr/local/bin` (via install.sh Phase 2) |
| `compose/` | ScaleTail templates (git submodule) | `/usr/local/share/linux_post_install/scale-tail` |
@@ -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`.
+20 -3
View File
@@ -70,6 +70,14 @@ Category-less tools (`config`, `tree`) live outside any category and are documen
| `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>']` |
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`.
@@ -229,6 +237,7 @@ The standalone `vbox` command still works and forwards to `pos docker vbox` (see
|---------|------|---------|---------------|
| `pos media mp3 <url>` | `bin/pos-media-mp3` | Download audio as MP3 via yt-dlp, with thumbnail + metadata | Output to `~/Music/%(title)s.%(ext)s`, `--audio-quality 0` |
| `pos media mp4 <url>` | `bin/pos-media-mp4` | Download video via yt-dlp with **interactive format selection** | Lists formats (`yt-dlp -F`), asks for a format ID, saves to `~/Videos/` |
| `pos media grab <url>` | `bin/pos-media-grab` | Auto-download a URL as audio or video (classify + route to mp3/mp4) | Domain-based classification (YouTube Music/SoundCloud/Bandcamp → audio; YouTube/Vimeo/Twitch → video); `--audio`/`--video` force the mode; `--best` default for video (non-interactive); prints a clean summary (🎵/🎬 title, path, size). Config: `GRAB_DEFAULT` (`pos config grab`, default `video`) for unknown domains |
| `pos media sync [--mp3\|--mp4]` | `bin/pos-media-sync` | Incremental Music → USB sync (add/update only — never deletes) | Copies mp3/mp4 from `$HOME/Music` (or `--source <dir>`) into `<usb>/Music/`, preserving the tree; missing or changed (size/mtime) files are copied, identical ones skipped. Same USB detection as `pos system backup` (lsblk TRAN + lsusb/by-id, mount offer for unmounted sticks, multi-stick picker). `--mp3`/`--mp4` filter by extension, neither = both; `--dry-run` previews. Config: `MEDIA_SYNC_SOURCE`, `MEDIA_SYNC_DEST`, shared `USB_MOUNT_BASE`/`USB_BYID` from `~/.config/linux_post_install/system.env`. Result notified via `lib/notify.sh`. Bare invocation on a terminal (or the `menu` subcommand) opens an interactive menu wrapping these actions (sync now mp3+mp4, dry-run preview, mp3-only, mp4-only, change source folder); flags stay scriptable |
| `pos media ytsync [add\|sync\|list\|remove]` | `bin/pos-media-ytsync` | Incremental YouTube channel/playlist sync — first run asks for a URL (bare invocation = interactive menu; empty state goes straight to the prompt), repeat runs fetch only new videos | One yt-dlp call per new video (`bestvideo*+bestaudio/best` → MP4, metadata/chapters/thumbnail embedded, `--no-overwrites`, `--windows-filenames --trim-filenames 120`); per-source `--download-archive` (`~/.local/share/linux_post_install/ytsync/archive/<slug>.txt`) makes runs crash-safe and idempotent; registry tracks slug/type/url/subdir. Verbs never prompt (scheduler/timer safe); non-tty interactive entry prints a guard line and exits 0. `--dry-run` probes + plans with zero writes. Notify digest only when new>0 or failed>0 via `lib/notify.sh`. Config: `YTSYNC_VIDEOS_DIR`, `YTSYNC_EXTRA_ARGS` via `pos config ytsync`; automate with `pos system schedule` (`COMMAND=pos media ytsync sync`, `NOTIFY=never`) |
@@ -299,7 +308,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` |
@@ -327,12 +336,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 (132 chars) — commands like `/Status` or `/my-cmd` are skipped from the menu with a warning but still resolve when typed.
@@ -477,6 +492,8 @@ Feature-flag management CLIs (see [SCRIPTS.md → lib/flags.sh](SCRIPTS.md#libfl
`pos config` is the interactive editor for the tools' runtime config (see [DEV.md](DEV.md#config-files) and §10 of AGENT_Context). Every tool exposes its configuration by declaring a `# POS_CONFIG:` header; `pos config` reads those at runtime — it knows nothing about the variables themselves. Values live in `~/.config/linux_post_install/<scope>.env` (chmod 600).
Headers may also declare **group captions**: `@Caption` starts a visual group, and `@[KEY=v1|v2] Caption` makes the group conditional — while `KEY`'s current value matches none of the listed alternatives, the group stays visible but dimmed with a textual reason (`— inactive while KEY=…`), so row numbering never changes mid-session. Wildcards can be tagged: `*providers=<tag>` pulls keys from a single AI provider adapter instead of all of them. The listing renders uniformly for every scope (bold title/keys, dim numbers/examples/placeholders, word-wrapped descriptions); at the prompt type a number to edit, `r` to refresh, or `q` to quit.
| Command | Purpose |
|---------|---------|
| `pos config` | Scope picker (on a TTY), otherwise the scope list |
+16 -1
View File
@@ -12,6 +12,12 @@ Everything that runs during the bootstrap install: `install.sh`, `preinstall.sh`
- [lib/common.sh — shared library](#libcommonsh--shared-library)
- [lib/flags.sh — feature flags](#libflagssh--feature-flags)
- [lib/notify.sh — multi-platform alerting](#libnotifysh--multi-platform-alerting)
- [lib/entertainment-lib.sh — entertainment module](#libentertainmentlibsh--entertainment-module)
- [lib/user-timers-lib.sh — shared systemd user timers](#libusertimerslibsh--shared-systemd-user-timers)
- [lib/usb-lib.sh — shared USB-storage detection](#libusblibsh--shared-usb-storage-detection)
- [lib/share-lib.sh — share-suite domain layer + compat shims](#sharelibsh--share-suite-domain-layer--compat-shims)
- [lib/menu-lib.sh — category-neutral menu primitives](#libmenulibsh--category-neutral-menu-primitives)
- [lib/registry.sh — tool metadata query API](#libregistrysh--tool-metadata-query-api)
- [features/autostart.sh — boot-time feature](#featuresautostartsh--boot-time-feature)
- [features/usb-automount.sh — USB automount feature](#featuresusb-automountsh--usb-automount-feature)
- [x64_bin/ — precompiled binaries](#x64_bin--precompiled-binaries)
@@ -38,7 +44,7 @@ The phases:
| # | Phase | Script/action |
|---|-------|----------------|
| 1 | preinstall | `preinstall.sh` — apt packages + yt-dlp |
| 2 | scripts | Copies `bin/*``/usr/local/bin/` (755), `lib/common.sh` + `lib/flags.sh` + `lib/notify.sh` + `lib/entertainment-lib.sh` + `lib/entertainment-plugin-lib.sh` + `lib/scheduler-lib.sh` + `lib/config-ui.sh` + `lib/user-timers-lib.sh` + `lib/usb-lib.sh` + `lib/share-lib.sh` + `lib/menu-lib.sh``/usr/local/bin/` (644). Copies precompiled arch binaries from `x64_bin/` (or `arm64_bin/`) → `/usr/local/bin/`. With `--feature`: also installs `features/*` (see below) |
| 2 | scripts | Copies `bin/*``/usr/local/bin/` (755), `lib/common.sh` + `lib/flags.sh` + `lib/notify.sh` + `lib/entertainment-lib.sh` + `lib/entertainment-plugin-lib.sh` + `lib/scheduler-lib.sh` + `lib/config-ui.sh` + `lib/user-timers-lib.sh` + `lib/usb-lib.sh` + `lib/share-lib.sh` + `lib/menu-lib.sh` + `lib/registry.sh``/usr/local/bin/` (644). Copies precompiled arch binaries from `x64_bin/` (or `arm64_bin/`) → `/usr/local/bin/`. With `--feature`: also installs `features/*` (see below) |
| 3 | postinstall | `postinstall.sh` — PATH, completion, SSH keys, systemd |
| 4 | scalepoint | Shallow-clones ScaleTail templates to `/usr/local/share/linux_post_install/scale-tail` |
| 5 (opt) | apps | `apps/install.sh` when `--apps` (interactive) or `--full` (all, non-interactive) |
@@ -228,6 +234,15 @@ Sourced by `bin/pos-entertainment-send|config|enable|disable|status` (after `lib
---
## lib/registry.sh — tool metadata query API
**File:** `lib/registry.sh` (installed to `/usr/local/bin/registry.sh`)
**Purpose:** the one query API over the tools' `# POS_*:` metadata headers, so consumers source it instead of re-implementing sed/grep header scans. `reg_scan [dir]` reads every executable `pos-*` file once — sorted under `LC_ALL=C`, and cheap enough to call lazily (plain dispatch paths skip it entirely); each tool's key is its filename after `pos-` with the category split off at the first dash (category-less tools carry an empty category). The populated stores serve `reg_list`, `reg_categories`, `reg_tools_in` and `reg_lookup <tool> <field>` with fields `cat|desc|flags|subcmds|deps|examples` (`deps`/`examples` come from the optional `# POS_DEPS:` / `# POS_EXAMPLES:` headers); the multi-line `# POS_CONFIG:` registry gets its own helpers (`reg_config_scopes`, `reg_config_keys`, `reg_config_envfile`); `reg_each <callback>` iterates every tool calling `cb(category, tool_key, description)`; `reg_tool_exists` is the membership probe. Like `lib/config-ui.sh` it defines guarded `log`/`warn`/`err` fallbacks so it sources cleanly without `lib/common.sh`; no shebang and never executed (installed 644).
Sourced by `bin/pos-tree` (tree rendering incl. the `[deps: …]` annotations) and by `bin/pos` `_pos_category_help()` for `pos <category> --help` (lazy load there, so plain dispatch never pays the scan cost). `scripts/gen-docs.sh` predates the registry and keeps parsing the same headers independently for its generated blocks; new consumers should prefer the registry.
---
## features/autostart.sh — boot-time feature
**File:** `features/autostart.sh` (installed to `/usr/local/bin/autostart.sh` by `./install.sh --feature`)
+21 -6
View File
@@ -150,6 +150,19 @@ 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 ask`); only the owner chat is served, so your key stays private.
Set a different model per message:
@@ -162,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…)
@@ -237,9 +251,10 @@ truncated). To disable: `unset __POS_CAPTURE_ACTIVE`.
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`.
---
+15
View File
@@ -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
+55 -3
View File
@@ -1,11 +1,13 @@
# How-To: `pos media`
Download audio and video from the web via `yt-dlp`, sync your library to a
USB stick, and keep YouTube channels incrementally up to date.
Tools: `mp3`, `mp4`, `sync`, `ytsync`.
Download audio and video from the web via `yt-dlp`, auto-classify URLs,
sync your library to a USB stick, and keep YouTube channels incrementally
up to date.
Tools: `grab`, `mp3`, `mp4`, `sync`, `ytsync`.
| Tool | What it does |
|------|--------------|
| `pos media grab` | Auto-classify URL and download as audio or video |
| `pos media mp3` | Download audio, convert to MP3 |
| `pos media mp4` | Download video with smart/interactive format selection |
| `pos media sync` | Incrementally copy `~/Music` onto a USB stick (mp3/mp4) |
@@ -88,6 +90,56 @@ video+audio and merges them.
---
## `pos media grab` — auto-classify URL and download
```bash
pos media grab <url>
```
Smart URL classifier that routes to `pos media mp3` or `pos media mp4`
automatically based on the domain. Send a URL from your phone via Telegram and
the bot downloads it to the right place without you thinking about it.
**Classification rules:**
| Domain | Routes to | Why |
|--------|-----------|-----|
| `music.youtube.com` | mp3 | Audio streaming |
| `soundcloud.com` | mp3 | Audio-first platform |
| `bandcamp.com` | mp3 | Audio-first platform |
| `youtube.com` / `youtu.be` | mp4 | Video content |
| `vimeo.com` / `twitch.tv` | mp4 | Video platforms |
| Everything else | mp4 (default) | Safe fallback |
Override the classification with `--audio` or `--video`. The default for
unrecognized domains is `video` — change it with `pos config grab` or set
`GRAB_DEFAULT=audio` in `~/.config/linux_post_install/grab.env`.
```bash
pos media grab https://music.youtube.com/watch?v=abc # → ~/Music
pos media grab https://youtube.com/watch?v=xyz # → ~/Videos
pos media grab --audio https://vimeo.com/123 # force mp3
pos media grab --worst https://youtu.be/abc # lowest quality
pos media grab --dry-run https://soundcloud.com/artist/track # preview only
```
Non-interactive by design — `pos media mp4` receives `--best` by default so it
never prompts for a format (critical for Telegram bot context where there's no
TTY). Pass `--worst` if you want the smallest file.
| Flag | Meaning |
|------|---------|
| `--audio` | Force audio (mp3) download |
| `--video` | Force video (mp4) download |
| `--best` | Best quality for video (default) |
| `--worst` | Lowest quality for video |
| `-o, --output <dir>` | Output directory (passed to mp3/mp4) |
| `--no-playlist` | Download only the single video |
| `--cookies <file>` | Netscape cookies.txt for age-gated content |
| `--dry-run` | Print the command that would run, don't execute |
---
## `pos media sync` — music onto a USB stick
```bash
+11
View File
@@ -275,6 +275,17 @@ Recommended fix: add rows to the POS.md command table (and cross-check HOWTO for
Verification: lint WARNs gone; `grep` shows each tool in POS.md.
Fix (2026-08-14): the tools were documented by command name but not by filename (the lint references basenames). Added `**File:** bin/pos-config` (config section), `**File:** bin/pos-tree` (tree section), and a file list on the entertainment section header covering `bin/pos-entertainment-{config,enable,disable,status}`. Verified: lint 0 WARN. HOWTO already covers the entertainment group via `pos entertainment *` command forms.
### M-024
Status: VERIFIED
Severity: LOW
Category: docs
Files: AGENTS.md:17-18; DOC/SCRIPTS.md (Phase-2 lib list, TOC, new lib section)
Evidence: the command-registry feature landed (`lib/registry.sh`, 199 lines; optional `# POS_DEPS:`/`# POS_EXAMPLES:` headers already codified in `templates/pos-tool.sh:13-14` and `DOC/DEV.md:126-134`) but three docs kept describing the old reality: AGENTS.md Quick facts enumerated only `POS_FLAGS`/`SUBCMDS`/`CONFIG` with no mention of the shared query API; DOC/SCRIPTS.md's Phase-2 lib list omitted `registry.sh` and had no section for it (its TOC also lacked five pre-existing lib sections).
Expected: docs describe what IS — code + `# POS:` headers are ground truth (Phase 0 rule 4).
Recommended fix: sync the three drifted docs to implemented reality; no code/template/completion changes.
Verification: `make gen` produces zero diff beyond pre-existing work; `make check` green; `make lint` 0 FAIL / 0 WARN; `grep -n "POS_DEPS"` hits AGENTS.md, DEV.md, SCRIPTS.md.
Fix (2026-08-26): template `templates/pos-tool.sh` now documents the optional `# POS_DEPS:`/`# POS_EXAMPLES:` headers (pre-existing); `lib/registry.sh` added as the shared query API over all `POS_*` headers (`reg_scan` + `reg_list`/`reg_lookup`/…) — AGENTS.md Tool-model + Categories bullets updated, DOC/SCRIPTS.md got the lib-list row (install.sh:143 order), a per-lib reference section, and a completed TOC. Consumers were already migrated (`bin/pos-tree`, `bin/pos` `_pos_category_help()`); lint unchanged (0 FAIL / 0 WARN).
### P3 — intentional / legacy (no action)
- install.sh:123,135,155,185 — installer writes to /usr/local/bin are its purpose; no seam needed (lint excludes install scripts).
- network-download RPC_SECRET at :150 — generated at runtime (`/dev/urandom`), not a committed secret.
+19 -12
View File
@@ -66,21 +66,27 @@ _pos_category_exists() {
}
_pos_category_help() {
local cat="$1" f
local files=() s d
for f in "$self"/pos-"$cat"-*; do
[ -x "$f" ] || continue
files+=("${f##*/pos-$cat-}")
done
local cat="$1" s s2 extra d sc deps
# Lazy registry load — plain dispatch paths never pay the scan cost.
source "$self/../lib/registry.sh" 2>/dev/null || source "$self/registry.sh"
reg_scan "$self"
local files=()
local t
while IFS= read -r t; do
[ -n "$t" ] || continue
files+=("${t#"$cat"-}")
done < <(reg_tools_in "$cat")
mapfile -t files < <(printf '%s\n' "${files[@]}" | sort -u)
local -A desc subcmds
local sc
local -A desc subcmds _deps
for s in "${files[@]}"; do
d="$(sed -n '/^# POS: /{s/^# POS: //;p;q}' "$self/pos-$cat-$s" 2>/dev/null)"
[ -n "$d" ] && desc["$s"]="${d#*— }"
sc="$(sed -n '/^# POS_SUBCMDS: /{s/^# POS_SUBCMDS: //;p;q}' "$self/pos-$cat-$s" 2>/dev/null)"
d="$(reg_lookup "$cat-$s" desc)"
[ -n "$d" ] && desc["$s"]="$d"
sc="$(reg_lookup "$cat-$s" subcmds)"
[ -n "$sc" ] && subcmds["$s"]="$sc"
deps="$(reg_lookup "$cat-$s" deps)"
[ -n "$deps" ] && _deps["$s"]="$deps"
done
# Nested sub-tools: pos-<cat>-<a>-<b> lists "b" under <a>.
@@ -113,6 +119,7 @@ _pos_category_help() {
done
[ "$is_nested" -eq 1 ] && continue
printf ' %-28s%s\n' "$s" "${desc[$s]:-}"
[ -n "${_deps[$s]:-}" ] && printf ' [deps: %s]\n' "${_deps[$s]}"
for c in ${subcmds[$s]:-}; do
printf ' %s %s\n' "$s" "$c"
done
@@ -259,7 +266,7 @@ MAIN_LOG="$LOG_DIR/pos.log"
log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; }
# Commands that read from stdin interactively — only log invocation
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup system-uninstall share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai ai-gemini ai-openrouter system-schedule entertainment-config config"
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup system-uninstall share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai ai-gemini ai-openrouter ai-alias system-schedule entertainment-config config"
for ((i=n-1; i>=0; i--)); do
cmd="pos"
+65 -7
View File
@@ -2,8 +2,8 @@
set -euo pipefail
# POS: ai ask — AI assistant: ask, chat, sessions, capture, models, providers
# POS_SUBCMDS: ask chat sessions capture models providers
# POS_FLAGS: --provider --model --session --system --full --last
# POS_CONFIG: ai | ai.env | AI_PROVIDER=:Provider (gemini or openrouter, default gemini) | AI_GEMINI_API_KEY=secret:Gemini API key from aistudio.google.com | OPENROUTER_API_KEY=secret:OpenRouter API key from openrouter.ai | AI_MODEL=:Model id (default per provider) | AI_SYSTEM_PROMPT=:Custom system prompt (overrides built-in, empty to reset)
# 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"
@@ -37,7 +37,7 @@ 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]
Usage: pos ai [subcommand] [--provider <name>] [--model <id>] [--session <name>] [--system <text>] [--full] [--last] [--trust]
AI assistant with pluggable providers (gemini, openrouter).
@@ -72,15 +72,16 @@ Options:
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_GEMINI_API_KEY Gemini API key from aistudio.google.com
OPENROUTER_API_KEY OpenRouter API key from openrouter.ai
AI_MODEL Model id (default depends on provider)
AI_SYSTEM_PROMPT Custom system prompt (overrides built-in; empty to reset)
OPENROUTER_MODEL Legacy: OpenRouter model fallback
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
@@ -355,6 +356,52 @@ render_markdown() {
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' \
@@ -473,6 +520,10 @@ cmd_ask() {
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() {
@@ -506,6 +557,10 @@ cmd_chat() {
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
@@ -576,6 +631,7 @@ cmd_providers() {
MODEL_OVERRIDE=""
FULL_MODE=0
LAST_MODE=0
TRUST_MODE=0
PROVIDER=""
cmd=""
args=()
@@ -598,6 +654,8 @@ while [ $# -gt 0 ]; do
FULL_MODE=1; shift ;;
--last)
LAST_MODE=1; shift ;;
--trust)
TRUST_MODE=1; shift ;;
-*) err "Unknown option '$1' (see --help)" ;;
*)
if [ -z "$cmd" ]; then
+760
View File
@@ -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
+266 -27
View File
@@ -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 ──────────────────────────────────────────────
@@ -453,21 +655,69 @@ strip_markdown() {
printf '%s' "$t"
}
# Detect a bare URL in message text. Extracts the first http(s) URL.
# Returns 0 + prints the URL on success, 1 if no URL found.
url_detect() {
local text="$1"
local url=""
if [[ "$text" =~ (https?://[^[:space:]]+) ]]; then
url="${BASH_REMATCH[1]}"
url="${url%%[,.\)!?:;]}"
url="${url%%\>*}"
[ -n "$url" ] || return 1
printf '%s' "$url"
return 0
fi
return 1
}
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
# URL detect: bare HTTP(S) URLs → pos media grab
local grab_url
if grab_url="$(url_detect "$text")"; then
log "grab: $grab_url"
run_and_reply "pos media grab --best \"$grab_url\"" "$msg_id" 600
return
fi
# 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 +727,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 +739,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 +750,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 +799,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
+1 -1
View File
@@ -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
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: docker ps — Enhanced container overview (health, IPs, ports, uptime)
# POS_DEPS: docker
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
usage() {
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: media grab — Auto-download URL as audio or video (classify + route)
# POS_FLAGS: --audio --video --best --worst --output --no-playlist --cookies --dry-run
# POS_CONFIG: grab | grab.env | GRAB_DEFAULT=:Default mode for unknown domains (video or audio, default video)
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
# Load grab.env config (env-seam: GRAB_DEFAULT)
load_grab_config() {
local f="$HOME/.config/linux_post_install/grab.env" k v
[ -f "$f" ] || return 0
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$f" || true)
}
load_grab_config
# ── URL classification ─────────────────────────────────────────
classify_url() {
local url="$1" mode="${GRAB_DEFAULT:-video}"
case "$url" in
*music.youtube.com*) echo "audio" ;;
*soundcloud.com*) echo "audio" ;;
*bandcamp.com*) echo "audio" ;;
*youtube.com*|*youtu.be*) echo "video" ;;
*vimeo.com*) echo "video" ;;
*twitch.tv*) echo "video" ;;
*) echo "$mode" ;;
esac
}
usage() {
cat <<EOF
Usage: pos media grab [options] <url>
Auto-download a URL as audio or video. Classifies the domain and delegates
to 'pos media mp3' (audio) or 'pos media mp4' (video).
Options:
--audio Force audio (mp3) download
--video Force video (mp4) download
--best Best quality for video (default for non-interactive)
--worst Lowest quality for video
-o, --output <dir> Output directory (passed to mp3/mp4)
--no-playlist Download only the single video
--cookies <file> Netscape cookies.txt for age-gated content
--dry-run Print the command that would run, don't execute
-h, --help This help
Examples:
pos media grab https://music.youtube.com/watch?v=abc
pos media grab https://youtube.com/watch?v=xyz
pos media grab --audio https://vimeo.com/123
pos media grab --worst https://youtu.be/abc
pos media grab --dry-run https://soundcloud.com/artist/track
EOF
exit 0
}
# ── Arg parsing ────────────────────────────────────────────────
URL=""
FORCE_AUDIO=0
FORCE_VIDEO=0
BEST=0
WORST=0
DRY_RUN=0
EXTRA_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage ;;
--audio) FORCE_AUDIO=1; shift ;;
--video) FORCE_VIDEO=1; shift ;;
--best) BEST=1; shift ;;
--worst) WORST=1; shift ;;
-o|--output)
[ $# -ge 2 ] || err "pos media grab: --output needs a value"
EXTRA_ARGS+=(--output "$2"); shift 2 ;;
--no-playlist) EXTRA_ARGS+=(--no-playlist); shift ;;
--cookies)
[ $# -ge 2 ] || err "pos media grab: --cookies needs a value"
EXTRA_ARGS+=(--cookies "$2"); shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-*) err "pos media grab: Unknown option: $1 (see --help)" ;;
*)
[ -z "$URL" ] && URL="$1" && shift || err "pos media grab: Unexpected argument: $1" ;;
esac
done
[ -n "$URL" ] || usage
# Validate URL scheme
case "$URL" in
http://*|https://*) ;;
*) err "pos media grab: not a valid URL: $URL (must start with http:// or https://)" ;;
esac
# Validate mutually exclusive overrides
[ "$FORCE_AUDIO" -eq 1 ] && [ "$FORCE_VIDEO" -eq 1 ] && \
err "pos media grab: --audio and --video are mutually exclusive"
[ "$BEST" -eq 1 ] && [ "$WORST" -eq 1 ] && \
err "pos media grab: --best and --worst are mutually exclusive"
# ── Classification ─────────────────────────────────────────────
mode=""
if [ "$FORCE_AUDIO" -eq 1 ]; then
mode="audio"
elif [ "$FORCE_VIDEO" -eq 1 ]; then
mode="video"
else
mode="$(classify_url "$URL")"
fi
# ── Build delegated command ────────────────────────────────────
DELEGATE_ARGS=()
if [ "$mode" = "audio" ]; then
DELEGATE_ARGS=(pos media mp3 "${EXTRA_ARGS[@]}")
else
# mp4 route: --best by default (non-interactive), --worst if user passes it
if [ "$WORST" -eq 1 ]; then
DELEGATE_ARGS=(pos media mp4 --worst "${EXTRA_ARGS[@]}")
else
DELEGATE_ARGS=(pos media mp4 --best "${EXTRA_ARGS[@]}")
fi
fi
# ── Dry run ────────────────────────────────────────────────────
if [ "$DRY_RUN" -eq 1 ]; then
echo "${DELEGATE_ARGS[*]} $URL"
exit 0
fi
# ── Execute ────────────────────────────────────────────────────
output=""
rc=0
if output=$("${DELEGATE_ARGS[@]}" "$URL" 2>&1); then
rc=0
else
rc=$?
fi
if [ "$rc" -ne 0 ]; then
# Summarize stderr for the user
summary="$(printf '%s' "$output" | grep -i 'error\|fail' | head -1 || true)"
[ -z "$summary" ] && summary="exit code $rc"
err "pos media grab: ❌ Download failed: $summary"
fi
# ── Metadata + summary ────────────────────────────────────────
title=""
duration=""
file_path=""
# Determine expected output directory
if [ "$mode" = "audio" ]; then
out_dir="$HOME/Music"
for (( i=0; i<${#EXTRA_ARGS[@]}; i++ )); do
if [ "${EXTRA_ARGS[$i]}" = "--output" ] && [ $(( i + 1 )) -lt ${#EXTRA_ARGS[@]} ]; then
out_dir="${EXTRA_ARGS[$(( i + 1 ))]}"
break
fi
done
file_ext="mp3"
else
out_dir="$HOME/Videos"
for (( i=0; i<${#EXTRA_ARGS[@]}; i++ )); do
if [ "${EXTRA_ARGS[$i]}" = "--output" ] && [ $(( i + 1 )) -lt ${#EXTRA_ARGS[@]} ]; then
out_dir="${EXTRA_ARGS[$(( i + 1 ))]}"
break
fi
done
file_ext="mp4"
fi
# Fetch metadata (fast, no download)
if command -v yt-dlp &>/dev/null; then
meta="$(yt-dlp --print title --print duration_string --no-warnings "$URL" 2>/dev/null || true)"
title="$(printf '%s' "$meta" | sed -n '1p')"
duration="$(printf '%s' "$meta" | sed -n '2p')"
fi
# Find the downloaded file (most recent matching extension in out_dir)
if [ -d "$out_dir" ]; then
file_path="$(find "$out_dir" -maxdepth 1 -name "*.$file_ext" -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | head -1 | cut -d' ' -f2- || true)"
fi
# Build summary
[ -z "$title" ] && title="$(basename "$URL" | sed 's/[?#].*//')"
[ -z "$duration" ] && duration="?"
if [ "$mode" = "audio" ]; then
emoji="🎵"
else
emoji="🎬"
fi
echo "$emoji Downloaded: $title ($duration)"
if [ -n "$file_path" ] && [ -f "$file_path" ]; then
file_size="$(stat --printf='%s' "$file_path" 2>/dev/null || echo "0")"
# Format size in human-readable form
if [ "$file_size" -ge 1073741824 ]; then
size_human="$(awk "BEGIN { printf \"%.1f GB\", $file_size / 1073741824 }")"
elif [ "$file_size" -ge 1048576 ]; then
size_human="$(awk "BEGIN { printf \"%.1f MB\", $file_size / 1048576 }")"
elif [ "$file_size" -ge 1024 ]; then
size_human="$(awk "BEGIN { printf \"%.1f KB\", $file_size / 1024 }")"
else
size_human="${file_size} B"
fi
# Show path relative to HOME
rel_path="${file_path/#$HOME/\~}"
echo "📁 $rel_path ($size_human)"
else
echo "📁 $out_dir/ ($file_ext)"
fi
+3
View File
@@ -3,6 +3,9 @@ set -euo pipefail
# POS: media sync — Incremental Music → USB sync (mp3/mp4, add/update only)
# POS_FLAGS: --mp3 --mp4 --source --dry-run
# POS_SUBCMDS: menu
# POS_DEPS: lsblk jq
# POS_EXAMPLES: pos media sync --mp3 | Sync only MP3 files to USB
# POS_EXAMPLES: pos media sync --mp4 --dry-run | Preview MP4 sync without copying
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
+4
View File
@@ -3,6 +3,10 @@ set -euo pipefail
# POS: network download — aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits)
# POS_SUBCMDS: start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch restart retry replace menu
# POS_FLAGS: --dir --out --split --seed --force --upload --gid --tmux
# POS_DEPS: aria2c jq curl
# POS_EXAMPLES: pos network download add https://example.com/file.zip | Enqueue an HTTP download (auto-starts daemon)
# POS_EXAMPLES: pos network download status | Daemon health + global transfer stats
# POS_EXAMPLES: pos network download watch | Live progress view
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
+1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
# POS_FLAGS: --service --no-encrypt
# POS_SUBCMDS: menu
# POS_CONFIG: notify | notify.env | NOTIFY_PLATFORM=:Comma-separated notify platforms (default telegram) — shared by backup, firewall, share nfs client/server
# POS_DEPS: tar
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
+20
View File
@@ -95,6 +95,15 @@ scan_tier1() {
# User-local binaries
[ -f "$HOME/.local/bin/pos-ai-hook.sh" ] && found+=("$HOME/.local/bin/pos-ai-hook.sh")
# pos ai alias wrapper scripts (marker-managed)
if [ -d "$HOME/.local/bin" ]; then
local awf
for awf in "$HOME/.local/bin"/*; do
[ -f "$awf" ] || continue
grep -q 'Managed by pos ai alias' "$awf" 2>/dev/null && found+=("$awf")
done
fi
# Completion file
[ -f /usr/local/share/bash-completion/completions/pos.bash ] && found+=("/usr/local/share/bash-completion/completions/pos.bash")
@@ -260,6 +269,17 @@ remove_tier1() {
# User-local binaries
[ -f "$HOME/.local/bin/pos-ai-hook.sh" ] && { rm -f "$HOME/.local/bin/pos-ai-hook.sh" && count=$((count+1)); }
# pos ai alias wrapper scripts (marker-managed only — foreign files untouched)
if [ -d "$HOME/.local/bin" ]; then
local arwf
for arwf in "$HOME/.local/bin"/*; do
[ -f "$arwf" ] || continue
if grep -q 'Managed by pos ai alias' "$arwf" 2>/dev/null; then
rm -f "$arwf" && count=$((count+1))
fi
done
fi
# Completion file
[ -f /usr/local/share/bash-completion/completions/pos.bash ] && { rm -f /usr/local/share/bash-completion/completions/pos.bash && count=$((count+1)); }
+12 -6
View File
@@ -4,6 +4,7 @@ set -euo pipefail
# POS_FLAGS: --depth
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/registry.sh" 2>/dev/null || source "$(dirname "$0")/registry.sh"
usage() {
cat <<EOF
@@ -44,12 +45,17 @@ add() {
meta["$path"]="$type|$desc"
}
for f in "$self"/pos-*; do
[ -x "$f" ] || continue
name="${f##*/pos-}"
desc="$(sed -n '/^# POS: /{s/^# POS: //;p;q}' "$f")"
desc="${desc#*— }"
sc="$(sed -n '/^# POS_SUBCMDS: /{s/^# POS_SUBCMDS: //;p;q}' "$f")"
reg_scan "$self"
for tool_key in $(reg_list); do
cat="$(reg_lookup "$tool_key" cat)"
desc="$(reg_lookup "$tool_key" desc)"
sc="$(reg_lookup "$tool_key" subcmds)"
deps="$(reg_lookup "$tool_key" deps)"
name="${tool_key}"
if [ -n "$deps" ]; then
desc="$desc [deps: $deps]"
fi
words=(${name//-/ })
parent="/"
for ((i=0; i<${#words[@]}; i++)); do
+6 -3
View File
@@ -9,6 +9,7 @@ _pos_flags[communication-telegram-sender]="--type --caption --parse-mode --no-pr
_pos_flags[docker-stack]="-a --all"
_pos_flags[docker-vbox]="--dir --gpu --device --port --cpus --memory --network"
_pos_flags[entertainment-send]="--print --markdown"
_pos_flags[media-grab]="--audio --video --best --worst --output --no-playlist --cookies --dry-run"
_pos_flags[media-mp3]="--output --no-playlist --cookies --by-artist --dry-run"
_pos_flags[media-mp4]="--format --best --worst --output --no-playlist --cookies --dry-run"
_pos_flags[media-sync]="--mp3 --mp4 --source --dry-run"
@@ -20,15 +21,17 @@ _pos_flags[share-usb-server]="--ls --ls-shared --share --unshare --auto-share --
_pos_flags[system-backup]="--service --no-encrypt"
_pos_flags[system-schedule]="--dry-run"
_pos_flags[system-uninstall]="--yes --config --data"
_pos_flags[ai]="--provider --model --session --system --full --last"
_pos_flags[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-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,10 +44,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 gemini openrouter"
_pos_subcmds[ai]="ask chat sessions capture models providers alias gemini openrouter"
# GEN:END possubcmds
# GEN:START posconfigscopes
declare -a _pos_config_scopes=(ai compose entertainment matrix notify scrcpy system telegram ytsync)
declare -a _pos_config_scopes=(ai compose entertainment grab matrix notify scrcpy system telegram ytsync)
# GEN:END posconfigscopes
_pos() {
+1 -1
View File
@@ -140,7 +140,7 @@ 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 "
+4
View File
@@ -3,6 +3,10 @@
# 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'; }
+4
View File
@@ -3,6 +3,10 @@
# 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'; }
+274 -34
View File
@@ -5,12 +5,22 @@
# (mirrors lib/notify.sh). Sourced opt-in by bin/pos-config.
#
# Header grammar — one "# POS_CONFIG:" line per scope a tool exposes:
# # POS_CONFIG: <scope> | <env-file> | <KEY>=<flags>:<desc>[::<example>] | ... | *plugins
# # POS_CONFIG: <scope> | <env-file> | <field> | ... | *plugins
# <env-file> basename of the config file under ~/.config/linux_post_install/
# <field> := <KEY>=<flags>:<desc>[::<example>]
# | @<caption> group caption (unconditional)
# | @[<KEY>=<alt>[|…]] <caption> conditional group caption —
# active iff KEY's current value
# equals a listed alt; an empty
# alt segment ("gemini|") means
# "or unset (= default)"
# <flags> secret (masked display + stty -echo input) | digits | num | float
# <example> optional value format hint shown in the editor, e.g. "weather,5m joke,10m"
# *plugins marker: also list every key declared by the installed
# entertainment plugins' "# POS_KEYS:" headers (dynamic)
# *providers[=<tag>] marker: keys from lib/ai-providers/*.sh adapters;
# with =<tag>, only from <tag>.sh (zero match → warn + the
# preceding caption is suppressed)
# Example:
# # POS_CONFIG: telegram | telegram.env | TELEGRAM_BOT_TOKEN=secret:Bot token | TELEGRAM_CHAT_ID=digits:Numeric chat id
#
@@ -27,8 +37,18 @@ declare -F warn >/dev/null || warn() { echo "[!] $*"; }
declare -F err >/dev/null || err() { echo "ERROR: $*" >&2; exit 1; }
declare -F ok >/dev/null || ok() { echo " OK $*"; }
# Color tokens (guarded — mirrors lib/menu-lib.sh): degrade to plain text when
# common.sh didn't define them, never an error on standalone sourcing.
BOLD="${BOLD:-}"
DIM="${DIM:-}"
CYAN="${CYAN:-}"
RESET="${RESET:-}"
_cfg_scope="" # scope being edited (drives the post-write hook)
declare -A _cfg_seen=() # key dedupe registry for cfg_scope_keys
_CS=$'\x1f' # unit-separator for caption records — never in env
# names or alt strings, avoids collision with | in
# alternation syntax (AI_PROVIDER=gemini|)
# ── tool directory ─────────────────────────────────────────────────
# Repo layout: lib/config-ui.sh → tools live in ../bin.
@@ -86,6 +106,29 @@ cfg_scope_envfile() {
return 1
}
# Split a POS_CONFIG keystring into fields on "|", IGNORING separators inside
# [...] condition brackets (caption conditions legitimately contain pipes,
# e.g. @[AI_PROVIDER=gemini|]). Byte-identical output to IFS='|' splitting for
# any string without brackets — fully backward compatible.
_cfg_split_fields() { # $1=keystring → one field per line
local s="$1" cur="" i ch depth=0
for ((i = 0; i < ${#s}; i++)); do
ch="${s:i:1}"
if [ "$ch" = "[" ]; then
depth=$((depth + 1))
elif [ "$ch" = "]" ] && [ "$depth" -gt 0 ]; then
depth=$((depth - 1))
fi
if [ "$ch" = "|" ] && [ "$depth" -eq 0 ]; then
printf '%s\n' "$cur"
cur=""
else
cur+="$ch"
fi
done
printf '%s\n' "$cur"
}
# One key field → "KEY|flags|description|example" (deduped via _cfg_seen).
# The optional example is "desc::example" — a literal "::" separates the
# value-format hint from the description.
@@ -136,6 +179,85 @@ _cfg_plugin_keys() {
return 0
}
# Emit the "# PROVIDER_CONFIG:" keys of ONE adapter file (helper for
# _cfg_provider_keys; keeps the tag-filter path and the all-adapters path DRY).
_cfg_provider_file() {
local pfile="$1" line key desc flags rest
while IFS= read -r line; do
[ -n "$line" ] || continue
# Format: KEY=flags:description (same as POS_CONFIG key fields)
key="${line%%=*}"
[ -n "$key" ] || continue
[ -n "${_cfg_seen[$key]:-}" ] && continue
_cfg_seen[$key]=1
rest="${line#*=}" flags="" desc=""
if [[ "$rest" == *":"* ]]; then
flags="${rest%%:*}"
desc="${rest#*:}"
else
flags="$rest"
fi
printf '%s|%s|%s|\n' "$key" "$flags" "$desc"
done < <(grep '^# PROVIDER_CONFIG:' "$pfile" 2>/dev/null | sed 's/^.*# PROVIDER_CONFIG:[[:space:]]*//' || true)
return 0
}
# "*providers" expansion: keys declared by the installed AI provider
# adapters' "# PROVIDER_CONFIG:" headers (lib/ai-providers/*.sh).
# Optional <tag> argument restricts to <tag>.sh; an explicit tag matching zero
# adapters warns once (stderr) — silent emptiness would hide authoring errors,
# and the preceding caption is suppressed by cfg_ui's lazy flush. Bare
# *providers stays silent, exactly as today.
declare -A _CFG_TAG_WARNED=()
_cfg_provider_keys() {
local want_tag="${1:-}"
local pdir line key desc flags
# Repo layout: lib/config-ui.sh → ../lib/ai-providers/
# Installed layout: /usr/local/bin/config-ui.sh → ./ai-providers/
pdir=""
local candidate
for candidate in \
"$(dirname "${BASH_SOURCE[0]}")/../lib/ai-providers" \
"$(dirname "${BASH_SOURCE[0]}")/ai-providers"; do
if [ -d "$candidate" ]; then
pdir="$(cd "$candidate" 2>/dev/null && pwd)"
break
fi
done
[ -n "$pdir" ] || return 0
if [ -n "$want_tag" ]; then
local matched=0 pfile
for pfile in "$pdir"/*.sh; do
[ -f "$pfile" ] || continue
[ "$(basename "$pfile" .sh)" = "$want_tag" ] || continue
matched=1
_cfg_provider_file "$pfile"
done
if [ "$matched" -eq 0 ] && [ -z "${_CFG_TAG_WARNED[$want_tag]:-}" ]; then
_CFG_TAG_WARNED["$want_tag"]=1
printf '[!] config scope: *providers=%s matched no adapter in %s\n' "$want_tag" "$pdir" >&2
fi
return 0
fi
while IFS= read -r line; do
[ -n "$line" ] || continue
# Format: KEY=flags:description (same as POS_CONFIG key fields)
key="${line%%=*}"
[ -n "$key" ] || continue
[ -n "${_cfg_seen[$key]:-}" ] && continue
_cfg_seen[$key]=1
rest="${line#*=}" flags="" desc=""
if [[ "$rest" == *":"* ]]; then
flags="${rest%%:*}"
desc="${rest#*:}"
else
flags="$rest"
fi
printf '%s|%s|%s|\n' "$key" "$flags" "$desc"
done < <(grep '^# PROVIDER_CONFIG:' "$pdir"/*.sh 2>/dev/null | sed 's/^.*# PROVIDER_CONFIG:[[:space:]]*//' || true)
return 0
}
# Declared keys for a scope: "KEY|flags|description" lines, deduped.
cfg_scope_keys() {
local scope="$1" dir line s keystring field
@@ -148,14 +270,33 @@ cfg_scope_keys() {
[ "$s" = "$scope" ] || continue
keystring="${line#*|}"
keystring="${keystring#*|}" # drop the env-file field
IFS='|' read -r -a fields <<<"$keystring"
mapfile -t fields < <(_cfg_split_fields "$keystring")
for field in "${fields[@]}"; do
field="${field#"${field%%[![:space:]]*}"}"
field="${field%"${field##*[![:space:]]}"}"
if [ -n "$field" ]; then
if [[ "$field" == "*"* ]]; then
if [[ "$field" == "@"* ]]; then
# Caption record (key position ">"): >|cond|caption|
# @[KEY=alt1|alt2] Caption → cond "KEY=alt1|alt2"
# @Caption → cond "" (always active)
local cond="" cap=""
if [[ "$field" == "@["*"]"* ]]; then
cond="${field:2}"
cond="${cond%%]*}"
cap="${field#*]}"
cap="${cap# }"
else
cap="${field#@}"
cap="${cap# }"
fi
printf '%s\n' ">${_CS}${cond}${_CS}${cap}${_CS}"
elif [[ "$field" == "*"* ]]; then
case "$field" in
*plugins*) _cfg_plugin_keys ;;
*plugins*) _cfg_plugin_keys ;;
*providers*)
local ptag=""
[[ "$field" == *"="* ]] && ptag="${field#*=}"
_cfg_provider_keys "$ptag" ;;
esac
else
_cfg_key_line "$field"
@@ -294,53 +435,152 @@ _cfg_edit_one() {
_cfg_post_write "$key"
}
# Evaluate a caption condition against the env file: active iff KEY's current
# value equals any listed alt, or an empty alt segment is present and the value
# is unset/empty (trailing/double/leading pipe). Empty cond → always active.
_cfg_cond_active() { # file cond
[ -n "$2" ] || return 0
local key alts cur alt hit=0 has_empty=0 oldIFS
key="${2%%=*}"
alts="${2#*=}"
cur="$(cfg_value "$1" "$key")"
case "$alts" in
"|"*|*"||"*|*"|") has_empty=1 ;;
esac
oldIFS="$IFS"
IFS='|'
for alt in $alts; do
if [ -n "$alt" ] && [ "$alt" = "$cur" ]; then hit=1; break; fi
done
IFS="$oldIFS"
[ "$hit" -eq 1 ] && return 0
[ "$has_empty" -eq 1 ] && [ -z "$cur" ] && return 0
return 1
}
# Word-wrap <text> to <width> columns, prefixing EVERY line with <indent>
# (hanging indent). Breaks at spaces only, no hyphenation; over-long tokens
# pass through unbroken.
_cfg_wrap() { # text width indent
local text="$1" width="$2" indent="$3"
local line="" w
for w in $text; do
if [ -z "$line" ]; then
line="$w"
elif (( ${#line} + 1 + ${#w} <= width )); then
line="$line $w"
else
printf '%s%s\n' "$indent" "$line"
line="$w"
fi
done
[ -n "$line" ] && printf '%s%s\n' "$indent" "$line"
return 0
}
# Interactive numbered-menu editor for one scope. q quits; r re-renders.
#
# Rendering contract (menu-lib house pattern): the whole render block goes to
# stderr — display only, nothing on stdout. Caption records ('>') group keys;
# conditions are evaluated per render from the env file, so an edit flips group
# emphasis on the very next redraw. Inactive groups are dimmed with a textual
# reason — never hidden — so numbering stays stable across edits.
cfg_ui() {
local scope="$1" envfile file line
local scope="$1" envfile file line idx
envfile="$(cfg_scope_envfile "$scope")" || { warn "unknown config scope '$scope'"; return 1; }
file="$CONFIG_DIR/$envfile"
_cfg_scope="$scope"
local -a keys=()
while IFS= read -r line; do
if [ -n "$line" ]; then
keys+=("$line")
fi
done < <(cfg_scope_keys "$scope")
if [ ${#keys[@]} -eq 0 ]; then
# Collect records: KEY|flags|desc|example for keys, >|cond|caption| for captions
local -a recs=() nums=()
mapfile -t recs < <(cfg_scope_keys "$scope")
if [ ${#recs[@]} -eq 0 ]; then
warn "no config keys declared for scope '$scope'"
return 1
fi
# number→record map: numbers go to keys only, in static header order →
# stable across renders and provider switches
for idx in "${!recs[@]}"; do
[[ "${recs[$idx]}" == ">"* ]] || nums+=("$idx")
done
local choice i k f d e v
# Wrap width clamped to 60120 cols minus the 6-column hanging indent
local W="${COLUMNS:-80}"
(( W < 60 )) && W=60
(( W > 120 )) && W=120
local wrapW=$((W - 6))
local rule
rule="$(printf '─%.0s' $(seq 1 40))"
local choice k f d e v disp n dim pend_cap="" pend_cond="" ckey cval why
while true; do
echo
echo "pos config — ${scope} (${envfile})"
echo "------------------------------------"
i=0
for line in "${keys[@]}"; do
i=$((i + 1))
IFS='|' read -r k f d e <<<"$line"
v="$(cfg_value "$file" "$k")"
printf ' %2d) %-28s %s\n' "$i" "$k" "$(cfg_display "$v" "$f")"
if [ -n "$d" ]; then
printf ' %s\n' "$d"
fi
if [ -n "$e" ]; then
printf ' e.g. %s\n' "$e"
fi
done
echo
read -rp "Variable number [q to quit]: " choice || { echo; return 0; }
{
echo
echo "${BOLD}pos config — ${scope} (${envfile})${RESET}"
echo "${CYAN}${rule}${RESET}"
n=0; dim=0; pend_cap=""; pend_cond=""
for idx in "${!recs[@]}"; do
# Caption records use \x1f (unit separator) to avoid collision
# with | in alternation syntax; key records use | as before.
if [[ "${recs[$idx]}" == ">"* ]]; then
# Caption record: >\x1fcond\x1fcaption\x1f
# Strip leading > and first \x1f, then split on next \x1f
pend_cond="${recs[$idx]#>}"
pend_cond="${pend_cond#$_CS}"
pend_cond="${pend_cond%%$_CS*}"
pend_cap="${recs[$idx]#>}"
pend_cap="${pend_cap#$_CS}"
pend_cap="${pend_cap#*$_CS}"
pend_cap="${pend_cap%%$_CS*}"
continue
fi
IFS='|' read -r k f d e <<<"${recs[$idx]}"
if [ -n "$pend_cap" ]; then
if _cfg_cond_active "$file" "$pend_cond"; then
dim=0
printf '\n%s ── %s%s\n' "$DIM" "$pend_cap" "$RESET"
else
dim=1
ckey="${pend_cond%%=*}"
cval="$(cfg_value "$file" "$ckey")"
if [ -z "$cval" ]; then why="— inactive (${ckey} not set)"
else why="— inactive while ${ckey}=${cval}"; fi
printf '\n%s ── %s %s%s\n' "$DIM" "$pend_cap" "$why" "$RESET"
fi
pend_cap=""
fi
n=$((n + 1))
v="$(cfg_value "$file" "$k")"
disp="$(cfg_display "$v" "$f")"
[ "$disp" = "(not set)" ] && disp="${DIM}(not set)${RESET}"
if [ "$dim" -eq 1 ]; then
printf '%s %2d) %-28s %s%s\n' "$DIM" "$n" "$k" "$disp" "$RESET"
else
printf ' %s%2d)%s %s%-28s%s %s\n' "$DIM" "$n" "$RESET" "$BOLD" "$k" "$RESET" "$disp"
fi
if [ -n "$d" ]; then
[ "$dim" -eq 1 ] && printf '%s' "$DIM"
_cfg_wrap "$d" "$wrapW" " "
[ "$dim" -eq 1 ] && printf '%s' "$RESET"
fi
if [ -n "$e" ]; then
printf '%s' "$DIM"
_cfg_wrap "e.g. $e" "$wrapW" " "
printf '%s' "$RESET"
fi
done
echo
read -rp "Number to edit [r=refresh, q=quit]: " choice || { echo; return 0; }
} >&2
case "$choice" in
q|Q|quit|exit) echo; return 0 ;;
r|R|refresh) continue ;;
"") continue ;;
*)
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#keys[@]} )); then
_cfg_edit_one "$file" "${keys[$((choice - 1))]}"
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#nums[@]} )); then
_cfg_edit_one "$file" "${recs[${nums[$((choice - 1))]}]}"
else
warn "invalid number '$choice' (1-${#keys[@]})"
warn "invalid number '$choice' (1-${#nums[@]})"
fi
;;
esac
+198 -5
View File
@@ -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
+199
View File
@@ -0,0 +1,199 @@
# lib/registry.sh — shared query API for POS tool metadata headers.
# Sourced opt-in by consumers that need tool metadata.
# Populates bash arrays from "# POS_*:" headers in bin/pos-* files;
# consumers call reg_scan once, then reg_list / reg_lookup / reg_each.
#
# API:
# reg_scan [dir] scan pos-* files → populate arrays
# reg_list sorted tool keys
# reg_categories sorted unique category names
# reg_tools_in <cat> tool keys in a category
# reg_lookup <tool> <field> field: cat|desc|flags|subcmds|deps|examples
# reg_config_scopes sorted config scope names
# reg_config_keys <scope> key|flags|desc lines
# reg_config_envfile <scope> env-file basename for a scope
# reg_each <callback> cb(category, tool_key, description)
# reg_tool_exists <tool> exit 0 if registered
# ── common.sh helpers (guarded — mirrors lib/config-ui.sh) ─────
declare -F log >/dev/null || log() { echo "[+] $*"; }
declare -F warn >/dev/null || warn() { echo "[!] $*"; }
declare -F err >/dev/null || err() { echo "ERROR: $*" >&2; exit 1; }
# ── tool directory detection ────────────────────────────────────
# Repo: lib/registry.sh → ../bin
# Install: /usr/local/bin/registry.sh → /usr/local/bin (same dir)
_reg_tools_dir() {
local dir
dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../bin" 2>/dev/null && pwd)"
if [ -d "$dir" ] && ls "$dir"/pos-* &>/dev/null; then
echo "$dir"
else
dirname "${BASH_SOURCE[0]}"
fi
}
# ── data stores ─────────────────────────────────────────────────
declare -a _reg_tools=()
declare -A _reg_cat=()
declare -A _reg_desc=()
declare -A _reg_flags=()
declare -A _reg_subcmds=()
declare -A _reg_deps=()
declare -A _reg_examples=()
declare -a _reg_config_scopes=()
declare -A _reg_config_keys=()
# ── reg_scan ────────────────────────────────────────────────────
reg_scan() {
local dir="${1:-$(_reg_tools_dir)}" f
local LC_ALL_PREV="${LC_ALL:-}"
export LC_ALL=C
_reg_tools=()
# Clear all associative arrays
for key in "${!_reg_cat[@]}"; do
unset "_reg_cat[$key]" "_reg_desc[$key]" "_reg_flags[$key]"
unset "_reg_subcmds[$key]" "_reg_deps[$key]" "_reg_examples[$key]"
done
_reg_config_scopes=()
for scope in "${!_reg_config_keys[@]}"; do
unset "_reg_config_keys[$scope]"
done
local -A scope_seen=()
for f in "$dir"/pos-*; do
[ -x "$f" ] || continue
local name="${f##*/pos-}"
local key cat
key="$name"
if [[ "$name" == *-* ]]; then
cat="${name%%-*}"
else
cat=""
fi
_reg_tools+=("$key")
_reg_cat["$key"]="$cat"
# POS: — description (text after first "— ")
local pos_line
pos_line="$(sed -n '/^# POS: /{s/^# POS: //;p;q}' "$f" 2>/dev/null)"
_reg_desc["$key"]="${pos_line#*— }"
# POS_FLAGS:
_reg_flags["$key"]="$(sed -n '/^# POS_FLAGS: /{s/^# POS_FLAGS: //;p;q}' "$f" 2>/dev/null)"
# POS_SUBCMDS:
_reg_subcmds["$key"]="$(sed -n '/^# POS_SUBCMDS: /{s/^# POS_SUBCMDS: //;p;q}' "$f" 2>/dev/null)"
# POS_DEPS:
_reg_deps["$key"]="$(sed -n '/^# POS_DEPS: /{s/^# POS_DEPS: //;p;q}' "$f" 2>/dev/null)"
# POS_EXAMPLES: (may appear multiple times — join with newlines)
local examples=""
examples="$(sed -n '/^# POS_EXAMPLES: /{s/^# POS_EXAMPLES: //;p}' "$f" 2>/dev/null)"
_reg_examples["$key"]="$examples"
# POS_CONFIG: (may appear multiple lines per file)
local line
while IFS= read -r line; do
[ -n "$line" ] || continue
line="${line#*POS_CONFIG:}"
local scope="${line%%|*}"
scope="${scope// }"
[ -n "$scope" ] || continue
_reg_config_keys["$scope"]+="${_reg_config_keys[$scope]:+$'\n'}$line"
if [ -z "${scope_seen[$scope]:-}" ]; then
scope_seen["$scope"]=1
_reg_config_scopes+=("$scope")
fi
done < <(grep '^# POS_CONFIG:' "$f" 2>/dev/null || true)
done
# Sort tools
mapfile -t _reg_tools < <(printf '%s\n' "${_reg_tools[@]}" | sort)
# Sort config scopes
mapfile -t _reg_config_scopes < <(printf '%s\n' "${_reg_config_scopes[@]}" | sort -u)
# Restore LC_ALL
if [ -n "$LC_ALL_PREV" ]; then
export LC_ALL="$LC_ALL_PREV"
else
unset LC_ALL
fi
}
# ── discovery ───────────────────────────────────────────────────
reg_list() { printf '%s\n' "${_reg_tools[@]}"; }
reg_categories() {
local -a cats=()
local t cat _rc_key
local -A _rc_seen=()
for t in "${_reg_tools[@]}"; do
cat="${_reg_cat[$t]}"
if [ -z "$cat" ]; then
_rc_key="__empty__"
else
_rc_key="$cat"
fi
if [ -z "${_rc_seen[$_rc_key]+x}" ]; then
_rc_seen["$_rc_key"]=1
cats+=("$cat")
fi
done
printf '%s\n' "${cats[@]}" | sort
}
reg_tools_in() {
local cat="$1" t
for t in "${_reg_tools[@]}"; do
[ "${_reg_cat[$t]}" = "$cat" ] && echo "$t"
done
}
# ── lookup ──────────────────────────────────────────────────────
reg_lookup() {
local tool="$1" field="$2"
case "$field" in
cat) echo "${_reg_cat[$tool]:-}" ;;
desc) echo "${_reg_desc[$tool]:-}" ;;
flags) echo "${_reg_flags[$tool]:-}" ;;
subcmds) echo "${_reg_subcmds[$tool]:-}" ;;
deps) echo "${_reg_deps[$tool]:-}" ;;
examples) echo "${_reg_examples[$tool]:-}" ;;
*) return 1 ;;
esac
}
# ── config scope helpers ────────────────────────────────────────
reg_config_scopes() { printf '%s\n' "${_reg_config_scopes[@]}"; }
reg_config_keys() {
local scope="$1"
echo "${_reg_config_keys[$scope]:-}"
}
reg_config_envfile() {
local scope="$1" line
line="$(echo "${_reg_config_keys[$scope]:-}" | head -1)"
[ -n "$line" ] || return 1
line="${line#*|}" # drop scope
local env="${line%%|*}"
echo "${env// }"
}
# ── iteration ───────────────────────────────────────────────────
reg_each() {
local cb="$1" t
for t in "${_reg_tools[@]}"; do
"$cb" "${_reg_cat[$t]}" "$t" "${_reg_desc[$t]}"
done
}
# ── convenience ─────────────────────────────────────────────────
reg_tool_exists() {
[ -n "${_reg_desc[$1]+x}" ]
}
+1
View File
@@ -39,6 +39,7 @@ PACKAGES=(
python3 python3-pip rclone
ffmpeg
libqrencode4 libgtk-3-0 adb
xdotool xclip
)
spawn "apt update" sudo apt update
+42 -16
View File
@@ -10,6 +10,8 @@ set -euo pipefail
# - "# POS:" header line → one-line description
# - "# POS_FLAGS:" line → flag completion list (flag-style tools only)
# - "# POS_SUBCMDS:" line → subcommand completion list (multi-command tools)
# - "# POS_DEPS:" line → runtime binary dependencies (optional)
# - "# POS_EXAMPLES:" line → curated usage examples (optional, multi-line)
root="$(cd "$(dirname "$0")/.." && pwd)"
mode="write"
@@ -22,7 +24,7 @@ export LC_ALL=C
ctx="$root/DOC/AGENT_Context_Project.md"
comp="$root/completions/pos.bash"
# ── Collect tools: "cat|sub|desc|flags|subcmds" ────────────────
# ── Collect tools: "cat|sub|desc|flags|subcmds|deps|examples" ──
# Category-less tools (pos-<cat>, e.g. pos-config) get an empty cat.
# tooldisp <cat> <sub> → display name (pos-config / pos-communication-telegram-sender).
tooldisp() { printf 'pos-%s%s' "${1:+$1-}" "$2"; }
@@ -42,31 +44,55 @@ for f in "$root"/bin/pos-*; do
desc="${desc#*— }"
flags="$(sed -n '/^# POS_FLAGS: /{s/^# POS_FLAGS: //;p;q}' "$f")"
subcmds="$(sed -n '/^# POS_SUBCMDS: /{s/^# POS_SUBCMDS: //;p;q}' "$f")"
tools+=("$cat|$sub|$desc|$flags|$subcmds")
deps="$(sed -n '/^# POS_DEPS: /{s/^# POS_DEPS: //;p;q}' "$f")"
examples="$(grep '^# POS_EXAMPLES:' "$f" 2>/dev/null | sed 's/^# POS_EXAMPLES:[[:space:]]*//' | awk 'NR>1{printf " · "}{printf "%s", $0}END{print ""}' || true)"
tools+=("$cat|$sub|$desc|$flags|$subcmds|$deps|$examples")
done
mapfile -t tools < <(printf '%s\n' "${tools[@]}" | sort)
# ── Block generators (emit inner content only, no markers) ──────
# Check whether any tool has non-empty deps or examples (for conditional columns)
_has_deps_examples=0
for t in "${tools[@]}"; do
IFS='|' read -r _ _ _ _ _ _tdeps _texamples <<<"$t"
if [ -n "$_tdeps" ] || [ -n "$_texamples" ]; then
_has_deps_examples=1
break
fi
done
gen_tree() {
local width=0 cat sub desc flags name t
local width=0 cat sub desc flags name t deps examples
for t in "${tools[@]}"; do
IFS='|' read -r cat sub desc flags subcmds <<<"$t"
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
name="$(tooldisp "$cat" "$sub")"
[ ${#name} -gt "$width" ] && width=${#name}
done
for t in "${tools[@]}"; do
IFS='|' read -r cat sub desc flags subcmds <<<"$t"
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
name="$(tooldisp "$cat" "$sub")"
printf '│ ├── %-*s# %s\n' "$((width + 1))" "$name" "$desc"
if [ -n "$deps" ]; then
printf '│ %*s│ [deps: %s]\n' "" "" "$deps"
fi
done
}
gen_dispatch() {
local cat sub desc flags t
for t in "${tools[@]}"; do
IFS='|' read -r cat sub desc flags subcmds <<<"$t"
printf '| %s | %s | `%s` | %s |\n' "$cat" "$sub" "$(tooldisp "$cat" "$sub")" "$desc"
done
local cat sub desc flags t deps examples
if [ "$_has_deps_examples" -eq 1 ]; then
printf '| Category | Command | Script | Description | Deps | Examples |\n'
printf '|----------|---------|--------|-------------|------|----------|\n'
for t in "${tools[@]}"; do
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
printf '| %s | %s | `%s` | %s | %s | %s |\n' "$cat" "$sub" "$(tooldisp "$cat" "$sub")" "$desc" "$deps" "$(printf '%s' "$examples" | sed 's/ | / → /g')"
done
else
for t in "${tools[@]}"; do
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
printf '| %s | %s | `%s` | %s |\n' "$cat" "$sub" "$(tooldisp "$cat" "$sub")" "$desc"
done
fi
}
gen_selfcontained() {
@@ -83,10 +109,10 @@ gen_selfcontained() {
}
gen_filetable() {
local cat sub desc flags name t
local cat sub desc flags name t deps examples
printf '| `bin/pos` | %s | CLI dispatcher with smart arg matching + logging + category help |\n' "$(wc -l < "$root/bin/pos")"
for t in "${tools[@]}"; do
IFS='|' read -r cat sub desc flags subcmds <<<"$t"
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
name="bin/$(tooldisp "$cat" "$sub")"
printf '| `%s` | %s | %s |\n' "$name" "$(wc -l < "$root/$name")" "$desc"
done
@@ -94,10 +120,10 @@ gen_filetable() {
}
gen_posflags() {
local cat sub desc flags t
local cat sub desc flags t deps examples
echo "declare -A _pos_flags"
for t in "${tools[@]}"; do
IFS='|' read -r cat sub desc flags subcmds <<<"$t"
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
[ -n "$flags" ] || continue
printf '_pos_flags[%s]="%s"\n' "$(tooldisp "$cat" "$sub" | sed 's/^pos-//')" "$flags"
done
@@ -106,10 +132,10 @@ gen_posflags() {
gen_possubcmds() {
# Subcommand completion: "# POS_SUBCMDS:" list + nested sub-tools from
# filenames (pos-<cat>-<sub>-<extra> → "extra" completes under <cat>-<sub>).
local cat sub desc flags subcmds rest f t
local cat sub desc flags subcmds deps examples rest f t
echo "declare -A _pos_subcmds"
for t in "${tools[@]}"; do
IFS='|' read -r cat sub desc flags subcmds <<<"$t"
IFS='|' read -r cat sub desc flags subcmds deps examples <<<"$t"
subcmds="${subcmds:-}"
for f in "$root"/bin/"$(tooldisp "$cat" "$sub")"-*; do
[ -x "$f" ] || continue
+3
View File
@@ -9,6 +9,9 @@ set -euo pipefail
# lines (single source of truth for generated docs):
# # POS: <category> <command> — one-line description
# # POS_FLAGS: --flag1 --flag2 (flag-style tools only)
# # POS_SUBCMDS: sub1 sub2 (multi-command tools only)
# # POS_DEPS: binary1 binary2 (runtime deps, optional)
# # POS_EXAMPLES: pos <tool> <args> | Description (optional)
# 3. Exec bit: chmod +x bin/pos-<category>-<command>
# 4. If it reads stdin (password/selection prompts), add it to
# INTERACTIVE_CMDS in bin/pos or its prompt breaks under the log tee.