Compare commits

...

5 Commits

Author SHA1 Message Date
Your Name f0ef13827b fix: ai --last — prefer newer source (auto-capture beats stale pos logs)
gates / consistency-and-conventions (push) Failing after 14s
--last now compares mtime of pos dispatcher logs vs captured output
(last_cmd_output) and uses whichever is newer, instead of always
preferring pos logs even when they are hours old.
2026-08-25 08:49:36 -04:00
Your Name 4af097f5eb docs: sync POS/AGENT_Context/completions for share, vbox, ai features
gates / consistency-and-conventions (push) Successful in 2m28s
- POS.md: new openrouter rows, updated share/vbox entries
- AGENT_Context_Project.md: GEN tree/dispatch/filetable/docmap resync
- completions/pos.bash: new flags/subcommands for openrouter + capture
- bin/pos: INTERACTIVE_CMDS += ai-openrouter (stdin reader)
2026-08-25 08:40:05 -04:00
Your Name 476173ba83 feat: ai — gemini terse+render+last+session+machine, openrouter new tool, capture any command
gemini enhancements:
- built-in terse system prompt with troubleshooting clause + machine context
- markdown→terminal rendering (glow opportunistic + zero-dep awk fallback)
- --last: pos logs + captured output fallback, staleness warning, stderr annotations
- session default always on; --session override; answer separation on tty
- --full flag, --system wholesale override

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

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

shell hook (optional):
- lib/pos-ai-hook.sh: sourceable .bashrc snippet for auto-capture
- exec > >(tee ...) with 1 MB truncation
2026-08-25 08:39:55 -04:00
Your Name 0aaa25150c feat: vbox — categorized create UI with GPU/device/mount/port presets
- category hub with basket counts, review screen, single confirm
- GPU: nvidia-smi → /proc/driver/nvidia → vendor scan detection
- host devices: lsusb/tty/video/snd/lsblk + manual input, dedupe
- dir mounts with (system disk — careful) labels
- SHOULD tier: image/ports/cpus/mem, flag contract --gpu/--device/--dir/--port/--cpus/--memory/--network
- zero-flag run byte-identical to pre-edit
- cmd_unpersist not-found exit 0 → return 0 in both clients
- DOC/howto/docker.md: vbox categorized create section
2026-08-25 08:39:42 -04:00
Your Name 4173fc61e3 feat: share clients — picker enhancements, unmount fixes, confirm default-y convention
- mountpoint picker: synthetic (as on server) candidate + n=new mkdir flow
- unmount-by-pick via findmnt enumeration with confirm
- cmd_unmount idle-persisted branch exit 0 → return 0 + actionable guidance
- all menu handlers normalized … || true
- confirm() rewrite: default-y on Enter, EOF fail-closed, case-insensitive
- latent compose "Y" bug fixed
- DEV.md convention doc for confirm semantics
2026-08-25 08:39:29 -04:00
15 changed files with 2475 additions and 169 deletions
+23 -20
View File
@@ -10,19 +10,19 @@
<!-- GEN:START docmap -->
| ## 1. Project Overview | 2843 |
| ## 2. Directory Structure | 44196 |
| ## 3. Installation Flow | 197250 |
| ## 4. The `pos` CLI System | 251324 |
| ## 5. Shared Library — `lib/common.sh` | 325356 |
| ## 6. Docker Compose / ScaleTail | 357399 |
| ## 7. Optional Apps (`apps/`) | 400429 |
| ## 8. Entertainment Module | 430443 |
| ## 9. Systemd Services | 444455 |
| ## 10. Configuration Files | 456482 |
| ## 11. Coding Conventions | 483515 |
| ## 12. Development Workflow | 516568 |
| ## 13. Key File Quick Reference | 569635 |
| ## 14. Common Tasks for Agents | 636669 |
| ## 2. Directory Structure | 44197 |
| ## 3. Installation Flow | 198251 |
| ## 4. The `pos` CLI System | 252326 |
| ## 5. Shared Library — `lib/common.sh` | 327358 |
| ## 6. Docker Compose / ScaleTail | 359401 |
| ## 7. Optional Apps (`apps/`) | 402431 |
| ## 8. Entertainment Module | 432445 |
| ## 9. Systemd Services | 446457 |
| ## 10. Configuration Files | 458484 |
| ## 11. Coding Conventions | 485517 |
| ## 12. Development Workflow | 518570 |
| ## 13. Key File Quick Reference | 571638 |
| ## 14. Common Tasks for Agents | 639672 |
<!-- GEN:END docmap -->
## 1. Project Overview
@@ -61,7 +61,8 @@ Linux_post_install/
├── bin/ # CLI tools — installed to /usr/local/bin/
│ ├── pos # Main dispatcher — smart arg matching to pos-* scripts
<!-- GEN:START tree -->
│ ├── pos-ai-gemini # Chat with Google Gemini (ask, chat, models, sessions)
│ ├── pos-ai-gemini # Chat with Google Gemini (ask, capture, chat, models, sessions)
│ ├── pos-ai-openrouter # Chat with OpenRouter models (ask, capture, chat, models, sessions)
│ ├── 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)
@@ -267,7 +268,8 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst
| Category | Command | Script | Description |
|----------|---------|--------|-------------|
<!-- GEN:START dispatch -->
| ai | gemini | `pos-ai-gemini` | Chat with Google Gemini (ask, chat, models, sessions) |
| ai | gemini | `pos-ai-gemini` | Chat with Google Gemini (ask, capture, chat, models, sessions) |
| ai | openrouter | `pos-ai-openrouter` | Chat with OpenRouter models (ask, capture, chat, models, sessions) |
| communication | matrix-listener | `pos-communication-matrix-listener` | Matrix listener: map /command → bash, run them on room messages |
| communication | matrix-sender | `pos-communication-matrix-sender` | Send messages to a Matrix room via the client-server API (send, test, login) |
| communication | scrcpy | `pos-communication-scrcpy` | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) |
@@ -590,7 +592,8 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `features/usb-automount.sh` | 138 | USB automount feature (udev rule + flag-gated service) |
<!-- GEN:START filetable -->
| `bin/pos` | 295 | CLI dispatcher with smart arg matching + logging + category help |
| `bin/pos-ai-gemini` | 311 | Chat with Google Gemini (ask, chat, models, sessions) |
| `bin/pos-ai-gemini` | 586 | Chat with Google Gemini (ask, capture, chat, models, sessions) |
| `bin/pos-ai-openrouter` | 587 | Chat with OpenRouter models (ask, capture, chat, models, sessions) |
| `bin/pos-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) |
@@ -600,7 +603,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `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-stack` | 101 | Containers grouped by compose stack (project); standalone group; -a/--all includes stopped |
| `bin/pos-docker-vbox` | 261 | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) |
| `bin/pos-docker-vbox` | 1125 | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) |
| `bin/pos-entertainment-config` | 143 | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) |
| `bin/pos-entertainment-disable` | 32 | Disable a plugin's auto-trigger (remove it from ENABLED) |
| `bin/pos-entertainment-enable` | 49 | Enable an auto-trigger for a plugin on a schedule |
@@ -615,9 +618,9 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `bin/pos-network-hotspot` | 93 | Wi-Fi hotspot via create_ap + wihotspot-gui |
| `bin/pos-network-ip` | 69 | Show interfaces, routes, public IP + location |
| `bin/pos-network-scan` | 272 | Parallel ping sweep of CIDR |
| `bin/pos-share-nfs-client` | 343 | Mount NFS shares (ephemeral or persistent systemd mount units) |
| `bin/pos-share-nfs-client` | 504 | Mount NFS shares (ephemeral or persistent systemd mount units) |
| `bin/pos-share-nfs-server` | 245 | Manage the NFS kernel server (status, share/unshare exports, enable/disable) |
| `bin/pos-share-smb-client` | 576 | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) |
| `bin/pos-share-smb-client` | 764 | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) |
| `bin/pos-share-smb-server` | 441 | Manage the Samba server (status, share/unshare exports, users, enable/disable) |
| `bin/pos-share-usb-server` | 362 | USB Redirector server control (--ls, --share; prompts when args omitted) |
| `bin/pos-ssh-load-keys` | 31 | Load all SSH keys into the agent |
@@ -627,7 +630,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
| `bin/pos-system-schedule` | 151 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
| `bin/pos-config` | 80 | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) |
| `bin/pos-tree` | 112 | Show the pos CLI command tree: categories, commands, and subcommands |
| `completions/pos.bash` | 302 | Dynamic bash completion |
| `completions/pos.bash` | 305 | Dynamic bash completion |
<!-- GEN:END filetable -->
| `apps/install.sh` | 171 | App install/uninstall picker/orchestrator |
+5 -1
View File
@@ -72,7 +72,7 @@ Sourced by most scripts. Key functions:
| `run cmd` | Executes command, respects `$DRY_RUN` |
| `spawn "msg" cmd` | Animated braille spinner + elapsed time |
| `timer_start` / `timer_stop` | Elapsed time tracking |
| `confirm "prompt"` | y/N prompt with optional default |
| `confirm "prompt" [default]` | y/n prompt; Enter accepts the default (`y` when omitted) |
---
@@ -428,6 +428,10 @@ pos-communication-<platform> send <value> [--markdown] # exit 0 on delivery
then listing it in `NOTIFY_PLATFORM`. Platform keys map to tool names via `notify_sender_name()` in `lib/notify.sh` — the telegram platform key stays `telegram` but its tool is `pos-communication-telegram-sender`. `pos-communication-telegram-sender` already follows this (`--markdown` is an alias for `--parse-mode markdown`). No changes to `lib/notify.sh` are needed for a new platform.
### Confirmation prompts
`confirm()` rule: Enter accepts the displayed default; destructive call sites pass explicit `'n'`.
### Idempotency
Check before creating, use `>>` with grep guards, don't overwrite user configs.
+29 -5
View File
@@ -56,12 +56,14 @@ Category-less tools (`config`, `tree`) live outside any category and are documen
### ai
**File:** `bin/pos-ai-gemini`
**Purpose:** chat with Google Gemini via the REST API (`generativelanguage.googleapis.com`). One tool, three subcommands: `ask` (one-shot, scriptable), `chat` (interactive multi-turn REPL), and `models` (list `generateContent`-capable ids).
**Purpose:** chat with Google Gemini via the REST API (`generativelanguage.googleapis.com`). One tool, four subcommands: `ask` (scriptable, persistent session), `capture` (run a command and save its output for `--last`), `chat` (interactive multi-turn REPL), and `models` (list `generateContent`-capable ids).
| Command | Behavior |
|---------|----------|
| `pos ai gemini ask "<prompt>"` | One-shot; POSTs `generateContent` and prints **only** the answer text to stdout (pipe/script/Telegram-friendly). The prompt may also be piped in via stdin when no argument is given |
| `pos ai gemini chat` | Interactive REPL with multi-turn history (the `contents[]` array is appended per turn); `q`/`quit`/`exit` or Ctrl+C quit, `/reset` clears the history, empty input re-prompts |
| `pos ai gemini ask "<prompt>"` | POSTs `generateContent` and prints the answer text to stdout. The prompt may also be piped in via stdin when no argument is given. Runs in the persistent `default` session (`~/.local/share/linux_post_install/ai/default.json`, capped at 40 turns; `--session <name>` picks another). Terse by default: a built-in system instruction asks for commands-first minimal prose and to diagnose pasted errors/output with the fix first (`--system "<text>"` replaces it wholesale, `--full` skips it). With `--last`, the output of the most recent logged pos command or captured output (tail, max 4096 chars) is appended to the question. On a tty the answer is rendered as markdown (`glow` if installed, else a built-in renderer); non-tty stdout gets the raw markdown bytes unchanged |
| `pos ai gemini capture <cmd..>` | Run a command, capture its stdout+stderr to screen and to `~/.local/share/linux_post_install/last_cmd_output` for `--last`. Each capture overwrites the previous one. Returns the command's exit code |
| `pos ai gemini chat` | Interactive REPL with multi-turn history (the `contents[]` array is appended per turn and persisted to the session file — `default` unless `--session`); replies are rendered like `ask` on a tty; `q`/`quit`/`exit` or Ctrl+C quit, `/reset` clears the history, empty input re-prompts |
| `pos ai gemini sessions` | Lists session files with turn counts; `sessions reset <name>` clears one (e.g. `reset default`) |
| `pos ai gemini models` | Lists models that support `generateContent` and flags the configured default |
| `pos ai gemini --model <id> …` | Overrides the model for one invocation |
@@ -78,6 +80,28 @@ Precedence: `--model` flag > `AI_GEMINI_MODEL` env > config file > `gemini-2.5-f
**Messaging bridges:** the Telegram and Matrix listeners forward non-command messages starting with `ai ` (case-insensitive) to `pos ai gemini ask` and reply with the model's answer — see [communication → listener](#communication). The Telegram bridge uses one session per chat (`telegram-<chat id>`), the Matrix bridge one per room (`matrix-<room>`).
**File:** `bin/pos-ai-openrouter`
**Purpose:** chat with OpenRouter models via the REST API (`openrouter.ai`). One tool, four subcommands: `ask` (scriptable, persistent session), `capture` (run a command and save its output for `--last`), `chat` (interactive multi-turn REPL), and `sessions` (list/clear sessions). OpenRouter provides access to hundreds of models from different providers through a single OpenAI-compatible API.
| Command | Behavior |
|---------|----------|
| `pos ai openrouter ask "<prompt>"` | POSTs a chat completion and prints the answer text to stdout. The prompt may also be piped in via stdin when no argument is given. Runs in the persistent `default` session (`~/.local/share/linux_post_install/ai-openrouter/default.json`, capped at 40 turns; `--session <name>` picks another). Terse by default: a built-in system instruction asks for commands-first minimal prose and to diagnose pasted errors/output with the fix first (`--system "<text>"` replaces it wholesale, `--full` skips it). With `--last`, the output of the most recent logged pos command or captured output (tail, max 4096 chars) is appended to the question. On a tty the answer is rendered as markdown (`glow` if installed, else a built-in renderer); non-tty stdout gets the raw markdown bytes unchanged |
| `pos ai openrouter capture <cmd..>` | Run a command, capture its stdout+stderr to screen and to `~/.local/share/linux_post_install/last_cmd_output` for `--last`. Each capture overwrites the previous one. Returns the command's exit code |
| `pos ai openrouter chat` | Interactive REPL with multi-turn history (the `messages[]` array is appended per turn and persisted to the session file — `default` unless `--session`); replies are rendered like `ask` on a tty; `q`/`quit`/`exit` or Ctrl+C quit, `/reset` clears the history, empty input re-prompts |
| `pos ai openrouter sessions` | Lists session files with turn counts; `sessions reset <name>` clears one (e.g. `reset default`) |
| `pos ai openrouter --model <id> …` | Overrides the model for one invocation |
`pos ai openrouter` with no subcommand prints usage (never blocks on stdin). `ask`/`chat` time out after 60s per request; on a non-2xx response the API's `error.message` is shown and the tool exits non-zero.
**Configuration** (`~/.config/linux_post_install/ai-openrouter.env`, edit with `pos config ai-openrouter`):
| Key | Required | Default | Purpose |
|-----|----------|---------|---------|
| `OPENROUTER_API_KEY` | yes | — | API key from openrouter.ai (secret — masked in `pos config ai-openrouter`) |
| `OPENROUTER_MODEL` | no | `openrouter/auto` | Model id used by `ask`/`chat`/`models` |
Precedence: `--model` flag > `OPENROUTER_MODEL` env > config file > `openrouter/auto`. Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES).
### network
| Command | File | Purpose | Configuration |
@@ -200,12 +224,12 @@ Global config keys:
| Command | Behavior |
|---------|----------|
| `pos docker vbox create <name> [image] [--dir <path>]` | Creates a container from `ubuntu:22.04` (or the given image), bind-mounting `~/<name>` (or `--dir`, or `.` for cwd) as the working directory; prompts to enter immediately |
| `pos docker vbox create <name> [image] [--dir <path>]… [--device </dev/node>]… [--gpu] [--port H:C]… [--cpus N] [--memory SIZE] [--network MODE]` | Creates a container from `ubuntu:22.04` (or the given image), bind-mounting `~/<name>` (or the first `--dir`; repeatable for extra same-path mounts) as the working directory; optional flags add GPU (`--gpus all`), device passthrough, port publishes and cpu/memory limits; prompts to enter immediately |
| `pos docker vbox enter <name>` | Shell into the container (auto-starts it if stopped); detects the working dir from the container mounts |
| `pos docker vbox start/stop/rm <name>` | Start, stop, or force-remove the container |
| `pos docker vbox ls` | List vbox containers only (label filter) |
**`pos docker vbox menu`** — bare invocation on a terminal (or the explicit `menu` subcommand) opens an interactive hub wrapping these verbs: list, create (asks name/image/host directory, y/N before anything is pulled), enter (hands the terminal to the container shell — `exit` returns to the menu), start/stop (pick a VM), and remove (y/N confirm naming the VM; `rm -f` removes the container, the host folder is kept). Arguments stay scriptable; without a terminal the menu fails closed with a pointer to these subcommands.
**`pos docker vbox menu`** — bare invocation on a terminal (or the explicit `menu` subcommand) opens an interactive hub wrapping these verbs: list, create (categorized flow: name → category hub with live basket counts — image quick-picks, GPU/Nvidia with automatic toolkit/device-node detection, host devices, dir mounts, ports, CPU/RAM → review screen rendering the exact `docker create` plan before anything runs; 'n' returns to the hub with edits preserved), enter (hands the terminal to the container shell — `exit` returns to the menu), start/stop (pick a VM), and remove (y/N confirm naming the VM; `rm -f` removes the container, the host folder is kept). Arguments stay scriptable; without a terminal the menu fails closed with a pointer to these subcommands.
The standalone `vbox` command still works and forwards to `pos docker vbox` (see [Legacy wrappers](#legacy-wrappers)).
+121 -13
View File
@@ -1,18 +1,54 @@
# How-To: `pos ai`
Chat with Google Gemini — from the terminal and through the Telegram bot.
Tools: `gemini` (`ask`, `chat`, `models`).
Chat with AI models — Gemini, OpenRouter, and more — from the terminal and
through the Telegram bot.
Tools: `gemini` (`ask`, `chat`, `models`), `openrouter` (`ask`, `chat`, `sessions`).
| Tool | What it does |
|------|--------------|
| `pos ai gemini ask "<prompt>"` | One-shot answer to stdout (scriptable) |
| `pos ai gemini ask --session <name> "…"` | Same, but remembers prior turns |
| `pos ai gemini chat` | Interactive multi-turn conversation |
| `pos ai gemini ask "<prompt>"` | Answer to stdout (scriptable; terse by default, `--full` for long form). Runs inside the persistent **`default`** session — it remembers prior turns across invocations |
| `pos ai gemini ask --last "why did that fail?"` | Same, but also appends the output of the **most recent logged pos command or captured output** so the model can diagnose a real failure (stderr notes which source + staleness warning) |
| `pos ai gemini capture <cmd>` | Run any command, capture its output for `--last`, and show it on screen |
| `pos ai gemini ask --session <name> "…"` | Same, but uses a named session instead of `default` |
| `pos ai gemini chat` | Interactive multi-turn conversation (session `default` unless `--session`) |
| `pos ai gemini models` | List available model ids |
| `pos ai gemini sessions` | List/clear persistent sessions (`reset <name>`) |
| `pos ai gemini sessions` | List persistent sessions / clear one (`reset <name>`, e.g. `reset default`) |
Shared flags: `--model <id>` overrides the model; `--system "<text>"` adds a
system instruction to every turn (kept out of the session file).
Shared flags: `--model <id>` overrides the model; `--system "<text>"` sets the
system instruction for every turn (kept out of the session file) — it replaces
the built-in terse ask prompt wholesale; `--full` skips that built-in prompt
for long-form answers; `--last` attaches the latest pos command output or
captured output (tail, max 4096 chars) to the question and notes on stderr
which source was attached, its age, and a staleness warning once it is older
than an hour (`ask` only; stdout stays pure answer). Use `capture` to save
output from any command for `--last`.
Every `ask`/`chat` lands in a persistent session file under
`~/.local/share/linux_post_install/ai/<name>.json` (capped at 40 turns).
Terminal work accumulates in `default`; clear it with
`pos ai gemini sessions reset default`.
---
## Terse by default, rendered on screen
`ask` prepends a built-in system instruction telling the model to work like a
CLI assistant: lead with the exact commands, one-line explanations, no essays —
and when the message is a "how do I install/update/solve/edit X" question or
pastes an error/command output, diagnose it and lead with the fix command(s).
That prompt ends with one machine-context line (hostname, distro, kernel and
architecture detected on this box), so answers match the actual machine;
`--system "<text>"` swaps it wholesale; `--full` drops it for long-form output.
`chat` keeps its neutral behavior (only `--system` applies).
On a terminal, answers are rendered as markdown, separated from your prompt
line by one blank line: fenced code blocks stay
monospace (indented + dimmed), inline `` `code` `` turns yellow, `**bold**`
turns bold, headers become bold cyan, `---` becomes a thin rule. If `glow` is
installed it is used automatically; otherwise a small built-in renderer kicks
in — no extra dependency either way. When stdout is **not** a tty (pipes,
scripts, cron, the Telegram/Matrix bridges) the raw markdown bytes are printed
exactly as before (no added blank lines), so scripting stays byte-stable.
---
@@ -37,6 +73,47 @@ system instruction to every turn (kept out of the session file).
`ai.env` lives at `~/.config/linux_post_install/ai.env` (chmod 600); `pos config ai`
is the only place the key is written. The key is never printed by `pos`.
## OpenRouter — many providers, one key
[OpenRouter](https://openrouter.ai) gives access to hundreds of models from
different providers (Anthropic, OpenAI, Meta, Mistral, Google, …) through a
single OpenAI-compatible API. `pos ai openrouter` works identically to the
Gemini tool — same subcommands (`ask`, `chat`, `sessions`), same flags
(`--last`, `--system`, `--full`, `--session`), same terminal rendering and
machine context.
1. Get an API key from https://openrouter.ai/settings/keys.
2. Configure it:
```bash
pos config ai-openrouter # enter OPENROUTER_API_KEY
```
3. Test:
```bash
pos ai openrouter ask "hi"
```
The default model is `openrouter/auto` (OpenRouter picks the best available
provider automatically). Override with `--model provider/model-name`:
```bash
pos ai openrouter ask --model anthropic/claude-sonnet-4 "explain DNS"
```
Sessions are stored separately from Gemini's:
```
~/.local/share/linux_post_install/ai-openrouter/<name>.json
```
All features work the same way — `--last` for diagnosing failures, `--system`
for custom instructions, `--full` for long-form answers, persistent sessions,
tty markdown rendering, and machine context. The only difference is the backend
API.
## From the Telegram bot
Once `pos ai gemini ask` works, any non-command message starting with `ai ` is
@@ -57,7 +134,8 @@ you: ai --model gemini-2.5-flash explain a Raft consensus log
### Telegram memory & formatting
Each chat has its own persistent session (`telegram-<chat id>`), so the model
Each chat has its own persistent session (`telegram-<chat id>` — independent
of your terminal's `default` session), so the model
remembers the conversation; `ai /reset` clears it. The listener passes a system
prompt telling the model it is answering in a Telegram chat — so it uses emojis
and stays lively — and strips markdown (`**x**`, backticks, `#`, links…) from
@@ -73,23 +151,53 @@ you: ai check this details about my linux ← reply to the /status message
## Recipes
- **Diagnose the last failed pos run:** `pos ai gemini ask --last "why did that fail?"` — every non-interactive `pos <cmd>` logs its output to `~/.local/share/linux_post_install/logs/`; `--last` attaches the newest one (tail, max 4096 chars, errors at the bottom kept) and says on stderr which log it grabbed (name, age, first line). Older than an hour? You get a `[!]` staleness warning — the newest log may predate your current problem, so pipe the fresh failure in instead
- **Pipe arbitrary output in:** `failing-cmd 2>&1 | pos ai gemini ask how do I fix this`
- **Answer from a file:** `pos ai gemini ask "$(cat notes.txt)"`
- **Pipe into it:** `echo "fix this: $(cat error.log)" | pos ai gemini ask`
- **Answer in a cron job:** `pos ai gemini ask "summarize today's git log" > /tmp/ai_digest.txt`
- **Long-form on demand:** `pos ai gemini ask --full "compare ext4 and zfs in depth"`
- **Forget what the terminal asked:** `pos ai gemini sessions reset default`
- **Change the default model:**
```bash
pos config ai # set AI_GEMINI_MODEL, or:
AI_GEMINI_MODEL=gemini-2.5-flash pos ai gemini ask "hi"
```
## Capturing any command's output for --last
By default, `--last` reads from pos dispatcher logs (only pos commands). To analyze
output from **any** command (`pip install`, `apt upgrade`, `make`, etc.):
**Option A — explicit capture:**
```bash
pos ai gemini capture pip install xyz
pos ai gemini ask --last "what happened"
```
The `capture` subcommand runs the command, shows its output on screen, and saves it
for `--last`. Each `capture` overwrites the previous one (latest only).
**Option B — automatic capture (shell hook):**
```bash
# Add to ~/.bashrc:
source /usr/local/bin/pos-ai-hook.sh
```
After sourcing, every command's output is silently captured. Then just run any
command and `--last` picks it up automatically. Captures up to 1 MB (oldest
truncated). To disable: `unset __POS_CAPTURE_ACTIVE`.
## How it works
- `ask` POSTs `contents:[{role:user, parts:[{text:"…"}]}]` to
- `ask` POSTs `contents:[…]` (prior turns of the active session plus the new
user turn) to
`https://generativelanguage.googleapis.com/v1beta/models/<model>:generateContent`
with the key in the `x-goog-api-key` header, and prints
`.candidates[0].content.parts[].text` — nothing else.
- `chat` keeps the whole conversation in memory as a growing `contents[]` array,
so later turns have earlier context. `/reset` drops it.
- Sessions live as one JSON file per name under
`~/.local/share/linux_post_install/ai/` (`default.json` unless `--session`);
each turn is appended and the file is pruned to the last 40 turns.
- `chat` keeps the whole conversation in memory as a growing `contents[]`
array (seeded from the session file), so later turns have earlier context.
`/reset` drops it (and empties the session file).
- On a non-2xx response the API's `error.message` is shown and the exit code is
non-zero — so scripts can rely on `ask` failing loudly.
+14
View File
@@ -136,6 +136,8 @@ pos docker vbox create lab1 # default dir ~/lab1
pos docker vbox create lab1 --dir . # files land in cwd
pos docker vbox create lab1 --dir /mnt/data/lab1
pos docker vbox create kali kalilinux/kali-rolling # custom image
pos docker vbox create ai --gpu --cpus 4 --memory 8g
pos docker vbox create iot --device /dev/ttyUSB0 --port 8080:80
pos docker vbox enter lab1
pos docker vbox stop lab1
pos docker vbox start lab1
@@ -143,6 +145,16 @@ pos docker vbox rm lab1
pos docker vbox ls
```
**Interactive create:** bare `pos docker vbox` (or the menu's "Create a VM")
walks a name prompt → category hub → review screen that renders the exact
`docker create` plan before anything is pulled; confirming runs the same
`create` verb as the CLI. Categories: image quick-picks, GPU/Nvidia (offers
`--gpus all` when the Nvidia container toolkit is present, explicit device
nodes otherwise, info line when no GPU exists), host devices (USB, serial,
video/sound, disks — system disks labelled), extra host-dir mounts, port
publishes, CPU/RAM limits. Quitting or EOF at any point discards — nothing is
created without an explicit `y` at the review.
**Recipe:** a disposable browsing/download box:
```bash
pos docker vbox create dl --dir /mnt/data/dl
@@ -154,6 +166,8 @@ pos docker vbox rm dl # container gone, files kept
- `enter` needs a shell/SSH-capable image; `kalilinux/kali-rolling` works.
- If files "disappear" after `rm`, check you used `--dir` on a real path — the
container image changes are lost, only the mounted dir persists.
- `--gpu` needs `nvidia-container-toolkit`; without it, pass explicit nodes
instead (`--device /dev/nvidia0 --device /dev/nvidiactl --device /dev/nvidia-uvm`).
---
+15 -3
View File
@@ -153,8 +153,14 @@ up — a down/unreachable NFS server can't break boot (with fstab it could).
**Interactive menu:** run `pos share nfs client` with no args for a menu
(mount / persist / unmount / unpersist / list). Mountpoints are offered from
existing mount-layout candidates with manual entry as fallback; unmount and
unpersist tolerate already-absent targets instead of erroring.
existing mount-layout candidates with manual entry as fallback — the picker
also accepts the server-side export path as a "(as on server)" pick when it
differs from your local layout, and `n=new` creates a fresh directory in
place (y/N confirmed; a failure just returns to the picker). Unmount lists
the active NFS mounts as `<mountpoint> ← <source>` picks and asks for
confirmation before unmounting (with a typed fallback when nothing is
mounted); unmount and unpersist tolerate already-absent targets instead of
erroring.
**Troubleshooting:**
- "mount.nfs not found" → `nfs-common` isn't installed; `sudo apt install nfs-common`
@@ -285,7 +291,13 @@ fstab it could). `enable --now` arms the automount immediately.
(enumerate / mount / persist / unmount / unpersist / list). Enter the server,
an empty user tries guest enumeration first (with an auth retry on denial),
then shares and mountpoints are offered as pickers with manual fallback —
the account you authenticated with is reused for the mount.
the account you authenticated with is reused for the mount. The mountpoint
picker accepts `n=new` to create a fresh directory in place (y/N confirmed;
a failure just returns to the picker); when the server is this machine, its
underlying share directory is offered as a "(as on server)" pick too.
Unmount lists the active CIFS mounts as `<mountpoint> ← <source>` picks and
asks for confirmation before unmounting (typed fallback when nothing is
mounted).
---
+1 -1
View File
@@ -259,7 +259,7 @@ MAIN_LOG="$LOG_DIR/pos.log"
log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; }
# Commands that read from stdin interactively — only log invocation
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai-gemini system-schedule entertainment-config config"
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup share-usb-server share-smb-server share-smb-client share-nfs-client share-nfs-server communication-telegram-listener communication-matrix-listener ai-gemini ai-openrouter system-schedule entertainment-config config"
for ((i=n-1; i>=0; i--)); do
cmd="pos"
+331 -46
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: ai gemini — Chat with Google Gemini (ask, chat, models, sessions)
# POS_SUBCMDS: ask chat models sessions
# POS_FLAGS: --model --session --system
# POS: ai gemini — Chat with Google Gemini (ask, capture, chat, models, sessions)
# POS_SUBCMDS: ask capture chat models sessions
# POS_FLAGS: --model --session --system --full --last
# POS_CONFIG: ai | ai.env | AI_GEMINI_API_KEY=secret:API key from aistudio.google.com | AI_GEMINI_MODEL=:Model id (default gemini-2.5-flash)
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
@@ -11,50 +11,86 @@ CONFIG_FILE="$HOME/.config/linux_post_install/ai.env"
API="https://generativelanguage.googleapis.com/v1beta"
DEFAULT_MODEL="gemini-2.5-flash"
SESSION_DIR="$HOME/.local/share/linux_post_install/ai"
SESSION=""
DISPATCH_LOG_DIR="$HOME/.local/share/linux_post_install/logs" # bin/pos per-run logs
LAST_CMD_OUTPUT_FILE="$HOME/.local/share/linux_post_install/last_cmd_output" # --last fallback for any command
OS_RELEASE_FILE="${OS_RELEASE_FILE:-/etc/os-release}" # read-only test seam (DEV.md env-overridable paths)
SESSION="default"
SYSTEM_PROMPT=""
MAX_SESSION_TURNS=40
LAST_LOG_MAX_BYTES=4096
LAST_LOG_STALE_SECS=3600 # --last: warn when the attached log is older than this
# Built-in terse ask prompt. cmd_ask appends a machine-context clause
# (see machine_context) unless --system replaces it or --full drops everything.
DEFAULT_SYSTEM_PROMPT="You assist a user working in a Linux/Unix CLI terminal. Be extremely terse: lead with the exact command(s) to run; one-line explanations max; short bullets only when necessary; no greetings, no closing offers, no essays. The user's message may be an install/update/solve/edit question ('how do I …') and/or may paste a problem, error, or command output: diagnose it from that and lead with the fix command(s)."
usage() {
cat <<EOF
Usage: pos ai gemini <subcommand> [--model <id>] [--session <name>] [--system <text>]
Usage: pos ai gemini <subcommand> [--model <id>] [--session <name>] [--system <text>] [--full] [--last]
Chat with Google Gemini via the REST API (generativelanguage.googleapis.com).
Subcommands:
ask "<prompt>" One-shot answer; prints ONLY the answer text to stdout
(pipe/script/Telegram-friendly). The prompt may also be
piped in via stdin when no argument is given. With
--session, previous turns are sent as context.
chat Interactive multi-turn conversation.
ask "<prompt>" Answer; prints the answer text to stdout. The prompt may
also be piped in via stdin when no argument is given.
Runs inside the persistent 'default' session (prior turns
are sent as context); --session <name> picks another.
capture <cmd..> Run a command, capture its output for --last, and show it.
Each capture overwrites the previous one (latest only).
chat Interactive multi-turn conversation (session 'default'
unless --session is given).
models List models that support generateContent.
sessions List persistent sessions / clear one:
'sessions' and 'sessions reset <name>'.
Options:
--model <id> Override the model for this invocation.
--session <name> Persistent memory: ask/chat remember prior turns in
--session <name> Use a named persistent session instead of 'default':
~/.local/share/linux_post_install/ai/<name>.json
(capped at $MAX_SESSION_TURNS turns). ask without
--session stays one-shot.
(capped at $MAX_SESSION_TURNS turns).
--system <text> System instruction sent with every turn (kept out of the
session file), e.g. "Reply like a friendly Telegram chat".
session file); replaces the built-in terse ask prompt
wholesale, e.g. "Reply like a friendly Telegram chat".
--full Skip the built-in terse prompt — long-form answers.
--last ask only: attach the most recent pos dispatcher log or
captured output (tail, max $LAST_LOG_MAX_BYTES chars) so
the model can diagnose a real failure. Sources in priority
order: (1) newest pos log, (2) captured output from
'capture'. Notes on stderr which source was attached and
its age; warns when stale (>60 min).
-h|--help This help.
Config: $CONFIG_FILE (edit with 'pos config ai')
AI_GEMINI_API_KEY API key from aistudio.google.com (required)
AI_GEMINI_MODEL Model id (default $DEFAULT_MODEL)
Notes:
ask is terse by default: a built-in system instruction tells the model to
lead with the exact commands and keep prose minimal — and to diagnose pasted
errors/output with a fix first. That prompt ends with one machine-context
line (hostname, distro, kernel, arch detected on this box) so answers fit
the actual machine; --system replaces it wholesale, --full drops it all.
Every ask/chat lands in a persistent session ('default' unless --session);
clear it with 'pos ai gemini sessions reset default'. On a terminal the
answer is rendered as markdown (glow if installed, else a built-in
renderer); when stdout is not a tty (pipes, scripts, Telegram bridges) the
raw markdown is printed unchanged.
Examples:
pos ai gemini ask "Explain DNS in one line"
pos ai gemini ask "check disk space on /"
pos ai gemini ask --full "Explain DNS in depth"
echo "summarize this log" | pos ai gemini ask
failing-cmd 2>&1 | pos ai gemini ask how do I fix this
pos ai gemini ask --last "why did that fail?" # attach last output
pos ai gemini capture pip install xyz # capture any command
pos ai gemini ask --last "what happened?" # after capture
pos ai gemini chat
pos ai gemini models
pos ai gemini ask --model gemini-2.5-flash "hi"
pos ai gemini ask --system "Reply like a pirate" "explain chmod"
pos ai gemini ask --session work "my name is joe"
pos ai gemini ask --session work "what is my name?" # remembers
pos ai gemini sessions
pos ai gemini sessions reset work
pos ai gemini sessions reset default # forget default memory
EOF
exit 0
}
@@ -91,10 +127,72 @@ resolve_model() {
fi
}
# ── --last: attach the most recent pos command output ───────────
# bin/pos logs every non-interactive run to DISPATCH_LOG_DIR/<ts>_pos_<cmd>.log
# (ai-gemini itself is interactive-logged, so it never creates its own output
# log). The <ts> prefix is zero-padded sortable, so name-descending = newest;
# mtime alone would tie-flake for same-second runs. pos.log is the invocation
# index, not command output — excluded.
newest_pos_log() {
[ -d "$DISPATCH_LOG_DIR" ] || return 1
local f
while IFS= read -r f; do
[ -s "$f" ] && { printf '%s' "$f"; return 0; }
done < <(ls "$DISPATCH_LOG_DIR"/*.log 2>/dev/null | LC_ALL=C sort -r | grep -v '/pos\.log$')
return 1
}
# stdout = the context block body for log $1: its END kept (errors live at
# the bottom), head-truncated to LAST_LOG_MAX_BYTES with a marker.
last_log_context() {
local raw
raw="$(tail -c "$LAST_LOG_MAX_BYTES" "$1")"
# a byte cut can split a multibyte char — drop invalid sequences when possible
if command -v iconv >/dev/null 2>&1; then
raw="$(printf '%s' "$raw" | iconv -c -f utf-8 -t utf-8 2>/dev/null || printf '%s' "$raw")"
fi
if [ "$(wc -c <"$1")" -gt "$LAST_LOG_MAX_BYTES" ]; then
printf '[…truncated…]\n%s' "$raw"
else
printf '%s' "$raw"
fi
}
# ── --last transparency (stderr-only; stdout stays pure answer) ──
# Seconds → human age: just now / Nm / Nh / Nd.
human_age() {
local s="$1"
[ "$s" -lt 0 ] && s=0
if [ "$s" -lt 60 ]; then printf 'just now'
elif [ "$s" -lt 3600 ]; then printf '%sm' "$((s / 60))"
elif [ "$s" -lt 86400 ]; then printf '%sh' "$((s / 3600))"
else printf '%sd' "$((s / 86400))"
fi
}
# Tell the user WHICH pos log got attached and how fresh it is — on STDERR,
# so a misread of an ancient log as the current failure is visible before
# the model answers. $1 = log file (as recorded by last_log_context).
last_log_annotate() {
local f="$1" age_s age line
age_s=$(( $(date +%s) - $(stat -c %Y "$f") ))
[ "$age_s" -lt 0 ] && age_s=0
age="$(human_age "$age_s")"
printf '[i] attaching last pos output — %s (%s)\n' "$(basename "$f")" "$age" >&2
# preview: first meaningful line of the log (blank lines skipped)
line="$(grep -m1 '[^[:space:]]' "$f" 2>/dev/null || true)"
if [ -n "$line" ]; then
printf '[i] "%.100s"\n' "$line" >&2
fi
if [ "$age_s" -gt "$LAST_LOG_STALE_SECS" ]; then
printf '[!] that log is %s old and may not match your current problem. For a FRESH failure of any command: failing-cmd 2>&1 | pos ai gemini ask "what happened"\n' "$age" >&2
fi
}
# ── Persistent session memory ───────────────────────────────────
# History lives as a Gemini "contents" JSON document per session name under
# SESSION_DIR. Names are sanitized to [A-Za-z0-9_-]; ask/chat only touch the
# session layer when --session is given (otherwise they stay stateless).
# SESSION_DIR. Names are sanitized to [A-Za-z0-9_-]; ask/chat always run in a
# session ('default' unless --session names another).
session_file() {
local name="${1:-$SESSION}"
name="${name//[^A-Za-z0-9_-]/_}"
@@ -156,29 +254,204 @@ gemini_generate() {
printf '%s' "$body_out" | jq -r '[.candidates[0].content.parts[]?.text] | join("")'
}
# ── Terminal markdown rendering (tty-only; raw bytes otherwise) ──
# On a tty: one blank line separates the answer from the prompt line above,
# then fenced code indents+dims, `code`→yellow, **bold**/__bold__→bold,
# #-headers (1-4)→bold cyan with #'s stripped, --- rules→thin rule, list
# markers kept. glow(1) is used opportunistically when installed. The answer
# ends with exactly one trailing newline.
# stdout not a tty (pipes/scripts/Telegram bridges) → RAW markdown unchanged
# (nothing added: single trailing newline only).
render_markdown() {
local text="$1"
if [ ! -t 1 ]; then
printf '%s\n' "$text"
return 0
fi
local rendered prog
# The awk program lives in a variable: its backtick regexes would be
# parsed as command substitution inside $( … ). $() strips any
# renderer-added trailing newlines; the final printf re-adds exactly
# one, plus the leading blank separator line.
prog='
BEGIN {
e = sprintf("%c", 27)
R = e "[0m"; DIM = e "[2m"; B = e "[1m"
YEL = e "[33m"; CYA = e "[1;36m"
RULE = ""
for (i = 0; i < 60; i++) RULE = RULE "─"
RULE = DIM RULE R
}
/^```/ { fence = !fence; next }
fence { printf "%s %s%s\n", DIM, $0, R; next }
/^#{1,4} / {
sub(/^#{1,4} +/, "")
printf "%s%s%s\n", CYA, $0, R
next
}
/^(-{3,}|\*{3,}|_{3,})$/ { print RULE; next }
{
line = $0
out = ""; rest = line
while (match(rest, /`[^`]*`/)) { # inline code first: keeps ** literal in backticks
out = out substr(rest, 1, RSTART - 1) YEL \
substr(rest, RSTART + 1, RLENGTH - 2) R
rest = substr(rest, RSTART + RLENGTH)
}
line = out rest
out = ""; rest = line
while (match(rest, /\*\*[^*]+\*\*/)) {
out = out substr(rest, 1, RSTART - 1) B \
substr(rest, RSTART + 2, RLENGTH - 4) R
rest = substr(rest, RSTART + RLENGTH)
}
line = out rest
out = ""; rest = line
while (match(rest, /__[^_]+__/)) {
out = out substr(rest, 1, RSTART - 1) B \
substr(rest, RSTART + 2, RLENGTH - 4) R
rest = substr(rest, RSTART + RLENGTH)
}
print out rest
}
'
if command -v glow >/dev/null 2>&1; then
rendered="$(printf '%s\n' "$text" | glow -)"
else
rendered="$(printf '%s\n' "$text" | awk "$prog")"
fi
printf '\n%s\n' "$rendered"
}
# ── Machine context appended to the built-in default prompt ─────
# One compact clause so default answers fit the actual box. Collected
# best-effort: hostnamectl(1) first (single call), then /etc/os-release +
# uname(1) fill any gaps. Every source is optional and failures are
# ignored — with nothing resolvable the clause is omitted entirely.
mc_clean() {
# stdin→stdout: strip ANSI color sequences, drop control chars (emoji,
# CR…), collapse all whitespace runs to single spaces, trim both ends.
# (tr handles the control ranges: sed lacks \xHH inside [brackets].)
sed -e 's/\x1b\[[0-9;]*[A-Za-z]//g' \
-e 's/[[:space:]][[:space:]]*/ /g' \
| tr -d '\000-\010\013-\037\177' \
| sed -e 's/^ //; s/ $//'
}
machine_context() {
local raw line key val h="" o="" k="" a="" part out=""
# Preferred single source: one hostnamectl status call.
if command -v hostnamectl >/dev/null 2>&1; then
raw="$(hostnamectl status 2>/dev/null || true)"
while IFS= read -r line; do
key="$(printf '%s' "${line%%:*}" | tr -d '[:space:]')"
val="${line#*:}"
case "$key" in
Statichostname|Transienthostname|Hostname)
[ -z "$h" ] && h="$val" ;;
OperatingSystem)
[ -z "$o" ] && o="$val" ;;
Kernel)
[ -z "$k" ] && k="$val" ;;
Architecture)
[ -z "$a" ] && a="$val" ;;
esac
done <<< "$raw"
fi
# Fallback/complement for the distro: os-release (PRETTY_NAME, else
# NAME + VERSION_ID). Sourced in a subshell — its vars stay local.
if [ -z "$o" ] && [ -r "$OS_RELEASE_FILE" ]; then
o="$(
. "$OS_RELEASE_FILE" 2>/dev/null || true
if [ -n "${PRETTY_NAME:-}" ]; then
printf '%s' "$PRETTY_NAME"
elif [ -n "${NAME:-}" ]; then
printf '%s' "${NAME}${VERSION_ID:+ (${VERSION_ID})}"
fi
)"
fi
# Fallback/complement for kernel + arch: uname(1).
[ -n "$k" ] || k="$(uname -sr 2>/dev/null || true)"
[ -n "$a" ] || a="$(uname -m 2>/dev/null || true)"
h="$(printf '%s' "$h" | mc_clean)"
o="$(printf '%s' "$o" | mc_clean)"
k="$(printf '%s' "$k" | mc_clean)"
a="$(printf '%s' "$a" | mc_clean)"
# Normalize to the bare release: both hostnamectl and uname -sr report
# "Linux <rel>".
case "$k" in "Linux "*) k="${k#Linux }" ;; esac
local out=""
for part in "$h" "$o" "${k:+kernel $k}" "$a"; do
[ -n "$part" ] || continue
if [ -n "$out" ]; then out="$out, $part"; else out="$part"; fi
done
[ -n "$out" ] || return 0
printf 'Machine context (answers must fit this box): %s.' "$out"
}
cmd_capture() {
[ $# -gt 0 ] || err "usage: pos ai gemini capture <command> [args...]"
mkdir -p "$(dirname "$LAST_CMD_OUTPUT_FILE")"
"$@" 2>&1 | tee "$LAST_CMD_OUTPUT_FILE"
local rc=${PIPESTATUS[0]}
printf '[captured → %s]\n' "$LAST_CMD_OUTPUT_FILE" >&2
return $rc
}
cmd_ask() {
local prompt="" contents out
local prompt="" contents out system ctx mc
if [ $# -gt 0 ]; then
prompt="$*"
elif [ ! -t 0 ]; then
prompt="$(cat)"
fi
[ -n "$prompt" ] || err "No prompt given — usage: pos ai gemini ask \"<prompt>\""
require_key
if [ -n "$SESSION" ]; then
contents="$(session_load)"
contents="$(session_push "$contents" user "$prompt")"
else
contents="$(jq -nc --arg t "$prompt" '{contents:[{role:"user",parts:[{text:$t}]}]}')"
# --last: append the most recent logged pos command output AFTER the
# question, so the model diagnoses the real failure.
if [ "$LAST_MODE" -eq 1 ]; then
local log_file="" pos_log="" captured_log=""
pos_log="$(newest_pos_log 2>/dev/null)" || true
[ -s "$LAST_CMD_OUTPUT_FILE" ] && captured_log="$LAST_CMD_OUTPUT_FILE"
# Use whichever source is newer (auto-capture beats stale pos logs)
if [ -n "$pos_log" ] && [ -n "$captured_log" ]; then
local pos_age=$(( $(date +%s) - $(stat -c %Y "$pos_log") ))
local cap_age=$(( $(date +%s) - $(stat -c %Y "$captured_log") ))
if [ "$cap_age" -lt "$pos_age" ]; then
log_file="$captured_log"
else
log_file="$pos_log"
fi
elif [ -n "$captured_log" ]; then
log_file="$captured_log"
else
log_file="$pos_log"
fi
[ -n "$log_file" ] || err "No recent output found — run 'pos ai gemini capture <cmd>' first, or pipe: cmd 2>&1 | pos ai gemini ask \"what happened\""
last_log_annotate "$log_file"
ctx="$(last_log_context "$log_file")"
prompt="$prompt"$'\n\n[last command output:]\n'"$ctx"
fi
if ! out="$(gemini_generate "$(resolve_model)" "$contents" "$SYSTEM_PROMPT" 2>&1)"; then
require_key
# Terse by default: user --system replaces the built-in prompt wholesale;
# --full skips everything (built-in text AND machine context). The
# default prompt carries a machine-context clause so answers fit this
# box; with no detectable facts it is omitted.
system="$SYSTEM_PROMPT"
if [ -z "$system" ] && [ "$FULL_MODE" -eq 0 ]; then
mc="$(machine_context)"
[ -n "$mc" ] && mc=" $mc"
system="$DEFAULT_SYSTEM_PROMPT$mc"
fi
# Persistent session memory ('default' unless --session).
contents="$(session_load)"
contents="$(session_push "$contents" user "$prompt")"
if ! out="$(gemini_generate "$(resolve_model)" "$contents" "$system" 2>&1)"; then
err "$out"
fi
if [ -n "$SESSION" ]; then
contents="$(session_push "$contents" model "$out")"
session_save "$contents"
fi
printf '%s\n' "$out"
contents="$(session_push "$contents" model "$out")"
session_save "$contents"
render_markdown "$out"
}
cmd_chat() {
@@ -186,12 +459,8 @@ cmd_chat() {
local model contents text answer
model="$(resolve_model)"
require_key
if [ -n "$SESSION" ]; then
contents="$(session_load)"
printf 'session: %s (resumed %s prior turns)\n' "$SESSION" "$(printf '%s' "$contents" | jq -r '.contents | length')"
else
contents='{"contents":[]}'
fi
contents="$(session_load)"
printf 'session: %s (resumed %s prior turns)\n' "$SESSION" "$(printf '%s' "$contents" | jq -r '.contents | length')"
trap 'echo; echo "bye"; exit 0' INT
echo "Gemini · ${model} — type a message; q=quit, /reset=clear history"
while true; do
@@ -202,7 +471,7 @@ cmd_chat() {
q|Q|quit|exit) echo; echo "bye"; return 0 ;;
/reset)
contents='{"contents":[]}'
[ -n "$SESSION" ] && session_save "$contents"
session_save "$contents"
echo "[history cleared]"
continue ;;
esac
@@ -212,8 +481,13 @@ cmd_chat() {
continue
fi
contents="$(session_push "$contents" model "$answer")"
[ -n "$SESSION" ] && session_save "$contents"
printf '\n%s\n\n' "$answer"
session_save "$contents"
# The bare '> ' prompt has no newline: this one closes the prompt
# line, and render_markdown's tty-only blank line then shows as the
# visible gap before the answer. On a pipe both bytes are preserved.
printf '\n'
render_markdown "$answer"
printf '\n\n'
done
echo
return 0
@@ -276,6 +550,8 @@ cmd_models() {
# ── Parse flags + subcommand ────────────────────────────────────
MODEL_OVERRIDE=""
FULL_MODE=0
LAST_MODE=0
cmd=""
args=()
while [ $# -gt 0 ]; do
@@ -290,6 +566,10 @@ while [ $# -gt 0 ]; do
--system)
[ $# -ge 2 ] || err "--system needs a value"
SYSTEM_PROMPT="$2"; shift 2 ;;
--full)
FULL_MODE=1; shift ;;
--last)
LAST_MODE=1; shift ;;
-*) err "Unknown option '$1' (see --help)" ;;
*)
if [ -z "$cmd" ]; then
@@ -301,11 +581,16 @@ while [ $# -gt 0 ]; do
esac
done
if [ "$LAST_MODE" -eq 1 ] && [ "${cmd:-}" != "ask" ]; then
err "--last only applies to 'ask' — for capturing output use 'capture': pos ai gemini capture <cmd>"
fi
case "${cmd:-}" in
"") usage ;;
ask) cmd_ask "${args[@]}" ;;
chat) cmd_chat "${args[@]}" ;;
models) cmd_models "${args[@]}" ;;
sessions) cmd_sessions "${args[@]}" ;;
*) err "Unknown ai gemini subcommand '$cmd' (see --help)" ;;
"") usage ;;
ask) cmd_ask "${args[@]}" ;;
capture) cmd_capture "${args[@]}" ;;
chat) cmd_chat "${args[@]}" ;;
models) cmd_models "${args[@]}" ;;
sessions) cmd_sessions "${args[@]}" ;;
*) err "Unknown ai gemini subcommand '$cmd' (see --help)" ;;
esac
+597
View File
@@ -0,0 +1,597 @@
#!/usr/bin/env bash
set -euo pipefail
# POS: ai openrouter — Chat with OpenRouter models (ask, capture, chat, models, sessions)
# POS_SUBCMDS: ask capture chat models sessions
# POS_FLAGS: --model --session --system --full --last
# POS_CONFIG: ai-openrouter | ai-openrouter.env | OPENROUTER_API_KEY=secret:API key from openrouter.ai (https://openrouter.ai/settings/keys) | OPENROUTER_MODEL=:Model id (default openrouter/auto)
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
CONFIG_FILE="$HOME/.config/linux_post_install/ai-openrouter.env"
API="https://openrouter.ai/api/v1"
DEFAULT_MODEL="openrouter/auto"
SESSION_DIR="$HOME/.local/share/linux_post_install/ai-openrouter"
DISPATCH_LOG_DIR="$HOME/.local/share/linux_post_install/logs" # bin/pos per-run logs
LAST_CMD_OUTPUT_FILE="$HOME/.local/share/linux_post_install/last_cmd_output" # --last fallback for any command
OS_RELEASE_FILE="${OS_RELEASE_FILE:-/etc/os-release}" # read-only test seam (DEV.md env-overridable paths)
SESSION="default"
SYSTEM_PROMPT=""
MAX_SESSION_TURNS=40
LAST_LOG_MAX_BYTES=4096
LAST_LOG_STALE_SECS=3600 # --last: warn when the attached log is older than this
# Built-in terse ask prompt. cmd_ask appends a machine-context clause
# (see machine_context) unless --system replaces it or --full drops everything.
DEFAULT_SYSTEM_PROMPT="You assist a user working in a Linux/Unix CLI terminal. Be extremely terse: lead with the exact command(s) to run; one-line explanations max; short bullets only when necessary; no greetings, no closing offers, no essays. The user's message may be an install/update/solve/edit question ('how do I …') and/or may paste a problem, error, or command output: diagnose it from that and lead with the fix command(s)."
usage() {
cat <<EOF
Usage: pos ai openrouter <subcommand> [--model <id>] [--session <name>] [--system <text>] [--full] [--last]
Chat with OpenRouter models via the REST API (openrouter.ai).
Subcommands:
ask "<prompt>" Answer; prints the answer text to stdout. The prompt may
also be piped in via stdin when no argument is given.
Runs inside the persistent 'default' session (prior turns
are sent as context); --session <name> picks another.
capture <cmd..> Run a command, capture its output for --last, and show it.
Each capture overwrites the previous one (latest only).
chat Interactive multi-turn conversation (session 'default'
unless --session is given).
models List available models and flag the configured default.
sessions List persistent sessions / clear one:
'sessions' and 'sessions reset <name>'.
Options:
--model <id> Override the model for this invocation.
--session <name> Use a named persistent session instead of 'default':
~/.local/share/linux_post_install/ai-openrouter/<name>.json
(capped at $MAX_SESSION_TURNS turns).
--system <text> System instruction sent with every turn (kept out of the
session file); replaces the built-in terse ask prompt
wholesale, e.g. "Reply like a friendly Telegram chat".
--full Skip the built-in terse prompt — long-form answers.
--last ask only: attach the most recent pos dispatcher log or
captured output (tail, max $LAST_LOG_MAX_BYTES chars) so
the model can diagnose a real failure. Sources in priority
order: (1) newest pos log, (2) captured output from
'capture'. Notes on stderr which source was attached and
its age; warns when stale (>60 min).
-h|--help This help.
Config: $CONFIG_FILE (edit with 'pos config ai-openrouter')
OPENROUTER_API_KEY API key from openrouter.ai (required)
OPENROUTER_MODEL Model id (default $DEFAULT_MODEL)
Notes:
ask is terse by default: a built-in system instruction tells the model to
lead with the exact commands and keep prose minimal — and to diagnose pasted
errors/output with a fix first. That prompt ends with one machine-context
line (hostname, distro, kernel, arch detected on this box) so answers fit
the actual machine; --system replaces it wholesale, --full drops it all.
Every ask/chat lands in a persistent session ('default' unless --session);
clear it with 'pos ai openrouter sessions reset default'. On a terminal the
answer is rendered as markdown (glow if installed, else a built-in
renderer); when stdout is not a tty (pipes, scripts, Telegram bridges) the
raw markdown is printed unchanged.
Examples:
pos ai openrouter ask "check disk space on /"
pos ai openrouter ask --full "Explain DNS in depth"
echo "summarize this log" | pos ai openrouter ask
failing-cmd 2>&1 | pos ai openrouter ask how do I fix this
pos ai openrouter ask --last "why did that fail?" # attach last output
pos ai openrouter capture pip install xyz # capture any command
pos ai openrouter ask --last "what happened?" # after capture
pos ai openrouter chat
pos ai openrouter ask --model anthropic/claude-sonnet-4 "hi"
pos ai openrouter ask --system "Reply like a pirate" "explain chmod"
pos ai openrouter ask --session work "my name is joe"
pos ai openrouter ask --session work "what is my name?" # remembers
pos ai openrouter sessions
pos ai openrouter sessions reset default # forget default memory
EOF
exit 0
}
# ── ai-openrouter.env loader (same pattern as telegram.env) ────────
load_config() {
[ -f "$CONFIG_FILE" ] || return 0
local k v
while IFS='=' read -r k v; do
[ -n "$k" ] || continue
case "$k" in
\#*) continue ;;
esac
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
v="${v//$'\r'/}"
if [ -z "${!k:-}" ]; then
export "$k"="$v"
fi
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
}
require_key() {
load_config
[ -n "${OPENROUTER_API_KEY:-}" ] || err "No OpenRouter API key — run 'pos config ai-openrouter'"
}
resolve_model() {
if [ -n "${MODEL_OVERRIDE:-}" ]; then
printf '%s' "$MODEL_OVERRIDE"
elif [ -n "${OPENROUTER_MODEL:-}" ]; then
printf '%s' "$OPENROUTER_MODEL"
else
printf '%s' "$DEFAULT_MODEL"
fi
}
# ── --last: attach the most recent pos command output ───────────
# bin/pos logs every non-interactive run to DISPATCH_LOG_DIR/<ts>_pos_<cmd>.log
# (ai-openrouter itself is interactive-logged, so it never creates its own output
# log). The <ts> prefix is zero-padded sortable, so name-descending = newest;
# mtime alone would tie-flake for same-second runs. pos.log is the invocation
# index, not command output — excluded.
newest_pos_log() {
[ -d "$DISPATCH_LOG_DIR" ] || return 1
local f
while IFS= read -r f; do
[ -s "$f" ] && { printf '%s' "$f"; return 0; }
done < <(ls "$DISPATCH_LOG_DIR"/*.log 2>/dev/null | LC_ALL=C sort -r | grep -v '/pos\.log$')
return 1
}
# stdout = the context block body for log $1: its END kept (errors live at
# the bottom), head-truncated to LAST_LOG_MAX_BYTES with a marker.
last_log_context() {
local raw
raw="$(tail -c "$LAST_LOG_MAX_BYTES" "$1")"
# a byte cut can split a multibyte char — drop invalid sequences when possible
if command -v iconv >/dev/null 2>&1; then
raw="$(printf '%s' "$raw" | iconv -c -f utf-8 -t utf-8 2>/dev/null || printf '%s' "$raw")"
fi
if [ "$(wc -c <"$1")" -gt "$LAST_LOG_MAX_BYTES" ]; then
printf '[…truncated…]\n%s' "$raw"
else
printf '%s' "$raw"
fi
}
# ── --last transparency (stderr-only; stdout stays pure answer) ──
# Seconds → human age: just now / Nm / Nh / Nd.
human_age() {
local s="$1"
[ "$s" -lt 0 ] && s=0
if [ "$s" -lt 60 ]; then printf 'just now'
elif [ "$s" -lt 3600 ]; then printf '%sm' "$((s / 60))"
elif [ "$s" -lt 86400 ]; then printf '%sh' "$((s / 3600))"
else printf '%sd' "$((s / 86400))"
fi
}
# Tell the user WHICH pos log got attached and how fresh it is — on STDERR,
# so a misread of an ancient log as the current failure is visible before
# the model answers. $1 = log file (as recorded by last_log_context).
last_log_annotate() {
local f="$1" age_s age line
age_s=$(( $(date +%s) - $(stat -c %Y "$f") ))
[ "$age_s" -lt 0 ] && age_s=0
age="$(human_age "$age_s")"
printf '[i] attaching last pos output — %s (%s)\n' "$(basename "$f")" "$age" >&2
# preview: first meaningful line of the log (blank lines skipped)
line="$(grep -m1 '[^[:space:]]' "$f" 2>/dev/null || true)"
if [ -n "$line" ]; then
printf '[i] "%.100s"\n' "$line" >&2
fi
if [ "$age_s" -gt "$LAST_LOG_STALE_SECS" ]; then
printf '[!] that log is %s old and may not match your current problem. For a FRESH failure of any command: failing-cmd 2>&1 | pos ai openrouter ask "what happened"\n' "$age" >&2
fi
}
# ── Persistent session memory ───────────────────────────────────
# History lives as an OpenAI-style "messages" JSON document per session name under
# SESSION_DIR. Names are sanitized to [A-Za-z0-9_-]; ask/chat always run in a
# session ('default' unless --session names another).
session_file() {
local name="${1:-$SESSION}"
name="${name//[^A-Za-z0-9_-]/_}"
printf '%s/%s.json' "$SESSION_DIR" "$name"
}
session_load() {
[ -n "$SESSION" ] || { printf '{"messages":[]}'; return 0; }
local f
f="$(session_file)"
if [ -s "$f" ] && jq -e '.messages' "$f" >/dev/null 2>&1; then
cat "$f"
else
printf '{"messages":[]}'
fi
}
session_save() {
[ -n "$SESSION" ] || return 0
local f tmp
f="$(session_file)"
mkdir -p "$SESSION_DIR"
tmp="$(mktemp)"
printf '%s\n' "$1" >"$tmp"
mv "$tmp" "$f"
chmod 600 "$f"
}
# Append a turn and prune to the last MAX_SESSION_TURNS entries. stdout = JSON.
session_push() {
local messages="$1" role="$2" text="$3"
printf '%s' "$messages" | jq -c --arg r "$role" --arg t "$text" \
'.messages += [{role:$r, content:$t}] | .messages |= .[-'"$MAX_SESSION_TURNS"':]'
}
# One OpenRouter chat completion call. $1 = model, $2 = messages JSON, $3 = optional
# system instruction (prepended as a system message, not stored in the session).
# stdout = the answer text on success; an error message on failure (exit 1).
openrouter_generate() {
local model="$1" messages_json="$2" system="${3:-}" body resp code body_out errmsg
if [ -n "$system" ]; then
body="$(printf '%s' "$messages_json" | jq -c --arg s "$system" \
'[{role:"system",content:$s}] + .messages')"
else
body="$(printf '%s' "$messages_json" | jq -c '.messages')"
fi
body="$(printf '%s' "$body" | jq -nc --arg m "$model" --argjson msgs "$body" \
'{model:$m, messages:$msgs}')"
resp="$(curl -sS -m 60 -X POST "${API}/chat/completions" \
-H "Authorization: Bearer ${OPENROUTER_API_KEY}" \
-H "Content-Type: application/json" \
-H "HTTP-Referer: https://github.com/admin/Linux_post_install" \
--write-out $'\n%{http_code}' \
--data "$body")" || { echo "request failed (curl exit $?)" >&2; return 1; }
code="${resp##*$'\n'}"
body_out="${resp%$'\n'*}"
if [ "$code" != "200" ]; then
errmsg="$(printf '%s' "$body_out" | jq -r '.error.message // empty' 2>/dev/null || true)"
echo "API error $code${errmsg:+: $errmsg}" >&2
return 1
fi
printf '%s' "$body_out" | jq -r '.choices[0].message.content // ""'
}
# ── Terminal markdown rendering (tty-only; raw bytes otherwise) ──
# On a tty: one blank line separates the answer from the prompt line above,
# then fenced code indents+dims, `code`→yellow, **bold**/__bold__→bold,
# #-headers (1-4)→bold cyan with #'s stripped, --- rules→thin rule, list
# markers kept. glow(1) is used opportunistically when installed. The answer
# ends with exactly one trailing newline.
# stdout not a tty (pipes/scripts/Telegram bridges) → RAW markdown unchanged
# (nothing added: single trailing newline only).
render_markdown() {
local text="$1"
if [ ! -t 1 ]; then
printf '%s\n' "$text"
return 0
fi
local rendered prog
# The awk program lives in a variable: its backtick regexes would be
# parsed as command substitution inside $( … ). $() strips any
# renderer-added trailing newlines; the final printf re-adds exactly
# one, plus the leading blank separator line.
prog='
BEGIN {
e = sprintf("%c", 27)
R = e "[0m"; DIM = e "[2m"; B = e "[1m"
YEL = e "[33m"; CYA = e "[1;36m"
RULE = ""
for (i = 0; i < 60; i++) RULE = RULE "─"
RULE = DIM RULE R
}
/^```/ { fence = !fence; next }
fence { printf "%s %s%s\n", DIM, $0, R; next }
/^#{1,4} / {
sub(/^#{1,4} +/, "")
printf "%s%s%s\n", CYA, $0, R
next
}
/^(-{3,}|\*{3,}|_{3,})$/ { print RULE; next }
{
line = $0
out = ""; rest = line
while (match(rest, /`[^`]*`/)) { # inline code first: keeps ** literal in backticks
out = out substr(rest, 1, RSTART - 1) YEL \
substr(rest, RSTART + 1, RLENGTH - 2) R
rest = substr(rest, RSTART + RLENGTH)
}
line = out rest
out = ""; rest = line
while (match(rest, /\*\*[^*]+\*\*/)) {
out = out substr(rest, 1, RSTART - 1) B \
substr(rest, RSTART + 2, RLENGTH - 4) R
rest = substr(rest, RSTART + RLENGTH)
}
line = out rest
out = ""; rest = line
while (match(rest, /__[^_]+__/)) {
out = out substr(rest, 1, RSTART - 1) B \
substr(rest, RSTART + 2, RLENGTH - 4) R
rest = substr(rest, RSTART + RLENGTH)
}
print out rest
}
'
if command -v glow >/dev/null 2>&1; then
rendered="$(printf '%s\n' "$text" | glow -)"
else
rendered="$(printf '%s\n' "$text" | awk "$prog")"
fi
printf '\n%s\n' "$rendered"
}
# ── Machine context appended to the built-in default prompt ─────
# One compact clause so default answers fit the actual box. Collected
# best-effort: hostnamectl(1) first (single call), then /etc/os-release +
# uname(1) fill any gaps. Every source is optional and failures are
# ignored — with nothing resolvable the clause is omitted entirely.
mc_clean() {
# stdin→stdout: strip ANSI color sequences, drop control chars (emoji,
# CR…), collapse all whitespace runs to single spaces, trim both ends.
# (tr handles the control ranges: sed lacks \xHH inside [brackets].)
sed -e 's/\x1b\[[0-9;]*[A-Za-z]//g' \
-e 's/[[:space:]][[:space:]]*/ /g' \
| tr -d '\000-\010\013-\037\177' \
| sed -e 's/^ //; s/ $//'
}
machine_context() {
local raw line key val h="" o="" k="" a="" part out=""
# Preferred single source: one hostnamectl status call.
if command -v hostnamectl >/dev/null 2>&1; then
raw="$(hostnamectl status 2>/dev/null || true)"
while IFS= read -r line; do
key="$(printf '%s' "${line%%:*}" | tr -d '[:space:]')"
val="${line#*:}"
case "$key" in
Statichostname|Transienthostname|Hostname)
[ -z "$h" ] && h="$val" ;;
OperatingSystem)
[ -z "$o" ] && o="$val" ;;
Kernel)
[ -z "$k" ] && k="$val" ;;
Architecture)
[ -z "$a" ] && a="$val" ;;
esac
done <<< "$raw"
fi
# Fallback/complement for the distro: os-release (PRETTY_NAME, else
# NAME + VERSION_ID). Sourced in a subshell — its vars stay local.
if [ -z "$o" ] && [ -r "$OS_RELEASE_FILE" ]; then
o="$(
. "$OS_RELEASE_FILE" 2>/dev/null || true
if [ -n "${PRETTY_NAME:-}" ]; then
printf '%s' "$PRETTY_NAME"
elif [ -n "${NAME:-}" ]; then
printf '%s' "${NAME}${VERSION_ID:+ (${VERSION_ID})}"
fi
)"
fi
# Fallback/complement for kernel + arch: uname(1).
[ -n "$k" ] || k="$(uname -sr 2>/dev/null || true)"
[ -n "$a" ] || a="$(uname -m 2>/dev/null || true)"
h="$(printf '%s' "$h" | mc_clean)"
o="$(printf '%s' "$o" | mc_clean)"
k="$(printf '%s' "$k" | mc_clean)"
a="$(printf '%s' "$a" | mc_clean)"
# Normalize to the bare release: both hostnamectl and uname -sr report
# "Linux <rel>".
case "$k" in "Linux "*) k="${k#Linux }" ;; esac
local out=""
for part in "$h" "$o" "${k:+kernel $k}" "$a"; do
[ -n "$part" ] || continue
if [ -n "$out" ]; then out="$out, $part"; else out="$part"; fi
done
[ -n "$out" ] || return 0
printf 'Machine context (answers must fit this box): %s.' "$out"
}
cmd_capture() {
[ $# -gt 0 ] || err "usage: pos ai openrouter capture <command> [args...]"
mkdir -p "$(dirname "$LAST_CMD_OUTPUT_FILE")"
"$@" 2>&1 | tee "$LAST_CMD_OUTPUT_FILE"
local rc=${PIPESTATUS[0]}
printf '[captured → %s]\n' "$LAST_CMD_OUTPUT_FILE" >&2
return $rc
}
cmd_ask() {
local prompt="" messages out system ctx mc
if [ $# -gt 0 ]; then
prompt="$*"
elif [ ! -t 0 ]; then
prompt="$(cat)"
fi
[ -n "$prompt" ] || err "No prompt given — usage: pos ai openrouter ask \"<prompt>\""
# --last: append the most recent logged pos command output AFTER the
# question, so the model diagnoses the real failure.
if [ "$LAST_MODE" -eq 1 ]; then
local log_file="" pos_log="" captured_log=""
pos_log="$(newest_pos_log 2>/dev/null)" || true
[ -s "$LAST_CMD_OUTPUT_FILE" ] && captured_log="$LAST_CMD_OUTPUT_FILE"
# Use whichever source is newer (auto-capture beats stale pos logs)
if [ -n "$pos_log" ] && [ -n "$captured_log" ]; then
local pos_age=$(( $(date +%s) - $(stat -c %Y "$pos_log") ))
local cap_age=$(( $(date +%s) - $(stat -c %Y "$captured_log") ))
if [ "$cap_age" -lt "$pos_age" ]; then
log_file="$captured_log"
else
log_file="$pos_log"
fi
elif [ -n "$captured_log" ]; then
log_file="$captured_log"
else
log_file="$pos_log"
fi
[ -n "$log_file" ] || err "No recent output found — run 'pos ai openrouter capture <cmd>' first, or pipe: cmd 2>&1 | pos ai openrouter ask \"what happened\""
last_log_annotate "$log_file"
ctx="$(last_log_context "$log_file")"
prompt="$prompt"$'\n\n[last command output:]\n'"$ctx"
fi
require_key
# Terse by default: user --system replaces the built-in prompt wholesale;
# --full skips everything (built-in text AND machine context). The
# default prompt carries a machine-context clause so answers fit this
# box; with no detectable facts it is omitted.
system="$SYSTEM_PROMPT"
if [ -z "$system" ] && [ "$FULL_MODE" -eq 0 ]; then
mc="$(machine_context)"
[ -n "$mc" ] && mc=" $mc"
system="$DEFAULT_SYSTEM_PROMPT$mc"
fi
# Persistent session memory ('default' unless --session).
messages="$(session_load)"
messages="$(session_push "$messages" user "$prompt")"
if ! out="$(openrouter_generate "$(resolve_model)" "$messages" "$system" 2>&1)"; then
err "$out"
fi
messages="$(session_push "$messages" assistant "$out")"
session_save "$messages"
render_markdown "$out"
}
cmd_chat() {
[ $# -eq 0 ] || err "Unexpected argument for chat: $*"
local model messages text answer
model="$(resolve_model)"
require_key
messages="$(session_load)"
printf 'session: %s (resumed %s prior turns)\n' "$SESSION" "$(printf '%s' "$messages" | jq -r '.messages | length')"
trap 'echo; echo "bye"; exit 0' INT
echo "OpenRouter · ${model} — type a message; q=quit, /reset=clear history"
while true; do
printf '> '
IFS= read -r text || break
case "$text" in
"" ) continue ;;
q|Q|quit|exit) echo; echo "bye"; return 0 ;;
/reset)
messages='{"messages":[]}'
session_save "$messages"
echo "[history cleared]"
continue ;;
esac
messages="$(session_push "$messages" user "$text")"
if ! answer="$(openrouter_generate "$model" "$messages" "$SYSTEM_PROMPT" 2>&1)"; then
warn "AI error: $answer"
continue
fi
messages="$(session_push "$messages" assistant "$answer")"
session_save "$messages"
# The bare '> ' prompt has no newline: this one closes the prompt
# line, and render_markdown's tty-only blank line then shows as the
# visible gap before the answer. On a pipe both bytes are preserved.
printf '\n'
render_markdown "$answer"
printf '\n\n'
done
echo
return 0
}
cmd_sessions() {
local action="${1:-list}" name f n
case "$action" in
list|"")
[ -d "$SESSION_DIR" ] || { echo "no sessions"; return 0; }
local found=0
for f in "$SESSION_DIR"/*.json; do
[ -f "$f" ] || continue
found=1
n="$(jq -r '.messages | length' "$f" 2>/dev/null || echo 0)"
printf ' %-32s %s turns\n' "$(basename "$f" .json)" "${n:-0}"
done
[ "$found" -eq 1 ] || echo "no sessions"
;;
reset)
[ $# -ge 2 ] || err "usage: pos ai openrouter sessions reset <name>"
name="$2"
if rm -f "$(session_file "$name")"; then
ok "session '$name' cleared"
fi
;;
*) err "Unknown sessions subcommand '$action' (list | reset <name>)" ;;
esac
}
cmd_models() {
[ $# -eq 0 ] || err "Unexpected argument for models: $*"
local model resp code body m
model="$(resolve_model)"
require_key
resp="$(curl -sS -m 30 "${API}/models" \
-H "Authorization: Bearer ${OPENROUTER_API_KEY}" \
--write-out $'\n%{http_code}')" || err "request failed (curl exit $?)"
code="${resp##*$'\n'}"
body="${resp%$'\n'*}"
if [ "$code" != "200" ]; then
err "API error $code: $(printf '%s' "$body" | jq -r '.error.message // empty')"
fi
local list
list="$(printf '%s' "$body" | jq -r '.data[]?.id' | sort)"
echo "OpenRouter models:"
while IFS= read -r m; do
[ -n "$m" ] || continue
if [ "$m" = "$model" ]; then
printf ' %-48s <- default\n' "$m"
else
printf ' %-48s\n' "$m"
fi
done <<< "$list"
if ! grep -qxF "$model" <<< "$list"; then
warn "configured default '$model' is not in the list — set OPENROUTER_MODEL"
fi
}
# ── Parse flags + subcommand ────────────────────────────────────
MODEL_OVERRIDE=""
FULL_MODE=0
LAST_MODE=0
cmd=""
args=()
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage ;;
--model)
[ $# -ge 2 ] || err "--model needs a value"
MODEL_OVERRIDE="$2"; shift 2 ;;
--session)
[ $# -ge 2 ] || err "--session needs a value"
SESSION="$2"; shift 2 ;;
--system)
[ $# -ge 2 ] || err "--system needs a value"
SYSTEM_PROMPT="$2"; shift 2 ;;
--full)
FULL_MODE=1; shift ;;
--last)
LAST_MODE=1; shift ;;
-*) err "Unknown option '$1' (see --help)" ;;
*)
if [ -z "$cmd" ]; then
cmd="$1"
else
args+=("$1")
fi
shift ;;
esac
done
if [ "$LAST_MODE" -eq 1 ] && [ "${cmd:-}" != "ask" ]; then
err "--last only applies to 'ask' — for capturing output use 'capture': pos ai openrouter capture <cmd>"
fi
case "${cmd:-}" in
"") usage ;;
ask) cmd_ask "${args[@]}" ;;
capture) cmd_capture "${args[@]}" ;;
chat) cmd_chat "${args[@]}" ;;
models) cmd_models "${args[@]}" ;;
sessions) cmd_sessions "${args[@]}" ;;
*) err "Unknown ai openrouter subcommand '$cmd' (see --help)" ;;
esac
+881 -17
View File
@@ -2,13 +2,14 @@
set -euo pipefail
# POS: docker vbox — Disposable Docker-based VMs (create/enter/start/stop/rm/ls)
# POS_SUBCMDS: create enter stop start rm ls menu
# POS_FLAGS: --dir --gpu --device --port --cpus --memory --network
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
source "$(dirname "$0")/../lib/menu-lib.sh" 2>/dev/null || source "$(dirname "$0")/menu-lib.sh"
usage() {
cat <<EOF
Usage:
pos docker vbox create <name> [image] [--dir <path>]
pos docker vbox create <name> [image] [options]
pos docker vbox enter <name>
pos docker vbox stop <name>
pos docker vbox start <name>
@@ -18,20 +19,33 @@ Usage:
Manage disposable Docker containers as lightweight VMs.
Bare \`pos docker vbox\` on a terminal (or \`pos docker vbox menu\`) opens an
interactive menu wrapping these verbs; arguments stay scriptable.
interactive menu wrapping these verbs; arguments stay scriptable. The
interactive create flow picks image, GPU, host devices, dir mounts, ports
and CPU/RAM from categorized menus, shows a review screen, then runs the
same \`create\` verb shown below.
Each container gets a bind-mounted host directory so files persist
on the host even after the container is removed.
Options:
--dir <path> Use custom directory instead of default ~/<name>
Use "." for current directory
--dir <path> Bind-mount a host directory at the same path inside
the VM (repeatable). The first --dir is the VM's
working directory and defaults to ~/<name>;
use "." for the current directory.
--gpu Full GPU access (--gpus all)
--device </dev/node> Pass a host device through to the VM (repeatable)
--port HOST:CONTAINER Publish a port mapping (repeatable, e.g. 8080:80)
--cpus N Limit CPUs (e.g. 2 or 1.5; default: Docker default)
--memory SIZE Limit memory (e.g. 512m, 2g; default: Docker default)
--network MODE Docker network mode (bridge/host/none)
Examples:
pos docker vbox create lab1
pos docker vbox create lab1 --dir .
pos docker vbox create lab1 --dir /mnt/data/lab1
pos docker vbox create kali kalilinux/kali-rolling
pos docker vbox create ai --gpu --cpus 4 --memory 8g
pos docker vbox create iot --device /dev/ttyUSB0 --port 8080:80
pos docker vbox enter lab1
pos docker vbox stop lab1
pos docker vbox start lab1
@@ -70,19 +84,796 @@ menu_pick_vm() { # $1 = prompt → picked VM name on stdout · rc 1 = cancelle
printf '%s\n' "${vms[$((idx - 1))]}"
}
menu_vbox_create() {
local name image dir
name="$(menu_ask_value "VM name")" || return 0
image="$(menu_ask_value "Image" "ubuntu:22.04")" || return 0
dir="$(menu_ask_value "Host directory (empty = ~/$name)")" || true
if [ -n "${dir:-}" ]; then
confirm "Create VM '$name' from $image (host dir: $dir)?" n \
|| { log "Cancelled"; return 0; }
menu_self create "$name" "$image" --dir "$dir"
# ── Categorized interactive create ────────────────────────────────────────
# Pure sugar over the scripted 'create' verb (single execution path): this
# flow composes arguments through Name → category hub → Review, then re-enters
# 'menu_self create …'. All state lives in C_*/CC_* globals for the lifetime
# of the flow only.
#
# Quit/EOF discipline (binding): nothing is ever created unless the review
# confirm is answered 'y', and EVERY teardown ends in ONE
# "[!] setup discarded — nothing was created" line, back at the main vbox
# menu, exit clean. Helpers never print the line themselves — they unwind
# with rc 77, and the orchestrator converts that into the single print.
# menu_run/menu_pick/confirm report a typed quit and a dead stream
# identically (rc 1), and no probe can always tell them apart, so
# arbitration is streak-based: TWO consecutive quit signals with no
# successful interaction between them tear down (a vanished terminal fails
# every read instantly; a live user quitting twice gets decisive teardown),
# while a single quit keeps gentle per-site handling (hub quit asks first).
# Raw `read` calls need no arbitration — their rc 1 IS a definitive EOF.
# Confirmed discards (hub quit answered 'y') are self-explanatory and silent;
# at the NAME prompt an empty answer and a dead stream are indistinguishable
# (menu_ask_value has no default there), so both tear down loudly.
C_discarded="setup discarded — nothing was created"
cc_log() { # stderr twin of log() — capture-safe inside $( )
printf '%s\n' "${GREEN}[+]${RESET} $*" >&2
}
cc_warn() { # stderr twin of warn() — capture-safe inside $( )
printf '%s\n' "${YELLOW}[!]${RESET} $*" >&2
}
cc_info() { # stderr [i] info line
printf '%s\n' "${CYAN:-}[i]${RESET} $*" >&2
}
cc_die() { # print the discard line once; caller returns 77
# Best-effort emit: on a vanished terminal even this write fails (EIO)
# and must not trip set -e — the cleanup contract is exit-clean.
cc_warn "$C_discarded" || true
}
# Quit-vs-EOF arbitration: menu_run/menu_pick/confirm report a typed quit and
# a dead input stream identically (rc 1), and no read/write probe can always
# distinguish them (bash `read -t` reports success on a dead pty; write-EIO is
# kernel-dependent). Rule instead: TWO consecutive quit signals with no
# successful interaction in between tear the setup down — a dead terminal
# fails every read instantly, so it tears down at once; a live user quitting
# twice in a row gets the same decisive treatment; a single quit keeps the
# gentle per-site handling. Successful interactions reset the streak.
cc_quit_tick() { # rc 0 = streak reached ⇒ caller tears down · rc 1 = keep going
C_quits=$(( ${C_quits:-0} + 1 ))
[ "${C_quits}" -ge 2 ]
}
cc_quit_reset() { C_quits=0; }
cc_reset() {
C_image="ubuntu:22.04"
C_image_set=0 # 0 = Image never visited (hub shows "(default)")
C_gpu_mode="" # "" | all | nodes
C_gpu_devs=() # explicit Nvidia/exotic nodes (mode=nodes)
C_devs=() # host devices basket (raw paths)
C_mounts=() # host dir mounts basket (validated absolutes)
C_ports=() # ports basket (HOST:CONTAINER)
C_cpus=""
C_mem=""
}
# ── device helpers ────────────────────────────────────────────────────────
cc_perm_suffix() { # unreadable-but-present nodes stay offered (dockerd is root)
[ -r "$1" ] && return 0
printf '%s' " (perm-restricted for you — dockerd may still access)"
}
cc_node_label() { # <node> → display label on stdout · rc 1 = unusable, drop
[ -e "$1" ] || return 1
stat "$1" &>/dev/null || return 1
printf '%s%s' "$1" "$(cc_perm_suffix "$1")"
}
cc_root_disk() { # basename of the disk hosting / — best effort, may fail
local src pk
src="$(findmnt -n -o SOURCE / 2>/dev/null)"
[ -n "$src" ] || return 1
pk="$(lsblk -sno PKNAME "$src" 2>/dev/null | tail -n1)"
[ -n "$pk" ] || pk="${src##*/}"
printf '%s' "$pk"
}
cc_is_system_disk() { # <node> rc 0 iff it is (a partition of) the root disk
local root base="${1##*/}"
root="$(cc_root_disk)" || return 1
[ "$base" = "$root" ] && return 0
[[ "$base" =~ ^${root}(p[0-9]+|[0-9]+)$ ]] && return 0
return 1
}
# Candidate host devices → parallel arrays (names + labels). Every source is
# optional: a missing binary means one info line, never an error. Block
# devices list ALL disks, system disks labelled — never hidden.
cc_collect_devices() { # $1 names-out · $2 labels-out (global array names)
local -n o_names="$1"
local -n o_labels="$2"
local line bus dev node lbl typ size rm
o_names=()
o_labels=()
if command -v lsusb &>/dev/null; then
while IFS= read -r line; do
[[ "$line" =~ ^Bus\ ([0-9]+)\ Device\ ([0-9]+): ]] || continue
bus="$(printf '%03d' "${BASH_REMATCH[1]}")"
dev="$(printf '%03d' "${BASH_REMATCH[2]}")"
node="/dev/bus/usb/$bus/$dev"
lbl="$(cc_node_label "$node")" || continue
o_names+=("$node")
o_labels+=("$lbl")
done < <(lsusb 2>/dev/null)
else
confirm "Create VM '$name' from $image?" n || { log "Cancelled"; return 0; }
menu_self create "$name" "$image"
cc_info "lsusb not available — skipping USB device scan"
fi
for node in /dev/ttyUSB* /dev/ttyACM* /dev/video*; do
[ -e "$node" ] || continue
lbl="$(cc_node_label "$node")" || continue
o_names+=("$node")
o_labels+=("$lbl")
done
# sound offered as ONE entry (fine-grained snd nodes are out of scope)
if [ -e /dev/snd ]; then
o_names+=("/dev/snd")
o_labels+=("/dev/snd (sound subsystem)")
fi
if command -v lsblk &>/dev/null; then
while read -r node typ size rm; do
[ -e "$node" ] || continue
stat "$node" &>/dev/null || continue
lbl="$node ($typ, $size"
[ "$rm" = "1" ] && lbl+=", removable"
cc_is_system_disk "$node" && lbl+=", system disk — careful"
lbl+="$(cc_perm_suffix "$node")"
lbl+=")"
o_names+=("$node")
o_labels+=("$lbl")
done < <(lsblk -rnpo NAME,TYPE,SIZE,RM 2>/dev/null)
else
cc_info "lsblk not available — skipping block-device scan"
fi
}
# ── GPU / Nvidia detection (cheap-first, once per category entry) ──────────
cc_nvidia_nodes() { # fills C_nv_nodes · rc 0 iff /dev/nvidiactl anchor exists
C_nv_nodes=()
local f
[ -e /dev/nvidiactl ] || return 1
for f in /dev/nvidia*; do
[ -e "$f" ] || continue
[[ "${f##*/}" =~ ^nvidia[0-9]+$ ]] || continue
cc_node_label "$f" >/dev/null && C_nv_nodes+=("$f")
done
for f in /dev/nvidiactl /dev/nvidia-uvm; do
[ -e "$f" ] || continue
cc_node_label "$f" >/dev/null && C_nv_nodes+=("$f")
done
[ "${#C_nv_nodes[@]}" -gt 0 ]
}
cc_gpu_toolkit() { # rc 0 iff an Nvidia container runtime is plausibly present
[[ "$(docker info --format '{{json .Runtimes}}' 2>/dev/null)" == *nvidia* ]] && return 0
command -v nvidia-container-runtime &>/dev/null && return 0
command -v nvidia-container-ctk &>/dev/null
}
# ── shared basket mechanics (loop-of-single-picks, no lib changes) ────────
# Baskets are addressed by GLOBAL array name, passed verbatim down the chain
# and bound one hop deep with local -n (never nameref-to-nameref).
cc_add_loop() { # $1 basket-name · $2 noun · $3 picker prompt · $4 manual prompt
local -n basket="$1"
local noun="$2" add_prompt="$3" manual_prompt="$4"
local idx sel p q skip joined ci clbl
local -a items disp
while true; do
if [ "${#basket[@]}" -gt 0 ]; then
joined="$(
IFS=','
echo "${basket[*]}"
)"
printf '[i] %s — selected: %d: %s\n' "$noun" "${#basket[@]}" "$joined" >&2
else
printf '[i] %s — selected: 0: (none yet)\n' "$noun" >&2
fi
items=()
disp=()
for p in "${CC_CAND_NAMES[@]}"; do
skip=0
for q in "${basket[@]}"; do
[ "$q" = "$p" ] && {
skip=1
break
}
done
[ "$skip" -eq 1 ] && continue # already selected → excluded
items+=("$p")
clbl=""
for ((ci = 0; ci < ${#CC_CAND_NAMES[@]}; ci++)); do
if [ "${CC_CAND_NAMES[$ci]}" = "$p" ]; then
clbl="${CC_CAND_LABELS[$ci]}"
break
fi
done
disp+=("${clbl:-$p}")
done
items+=("__manual__")
disp+=("Type a device path manually")
items+=("__done__")
disp+=("✓ Done adding")
if ! idx="$(menu_pick "$add_prompt" "${disp[@]}")"; then
cc_quit_tick && return 77 # second consecutive quit ⇒ teardown
return 0 # back/Done ends the loop, basket kept
fi
cc_quit_reset
sel="${items[$((idx - 1))]}"
case "$sel" in
__done__)
return 0
;;
__manual__)
while true; do
if ! read -rp "$manual_prompt: " p; then
return 77 # raw read rc 1 = definitive EOF
fi
[ -z "$p" ] && break # empty = cancel
[[ "$p" == /dev/* ]] || {
cc_warn "device path must start with /dev/ — try again"
continue
}
skip=0
for q in "${basket[@]}"; do
[ "$q" = "$p" ] && skip=1
done
if [ "$skip" -eq 1 ]; then
cc_warn "$p is already selected — skipped"
break
fi
if ! stat "$p" &>/dev/null; then
cc_warn "cannot stat $p — skipped"
break
fi
basket+=("$p")
cc_log "added $p — pick another, or choose ✓ Done"
break
done
;;
*)
basket+=("$sel")
cc_log "added $sel — pick another, or choose ✓ Done"
;;
esac
done
}
cc_remove_one() { # $1 basket-name · $2 remove prompt
local -n basket="$1"
local rm_prompt="$2"
local idx p i
local -a disp keep
if [ "${#basket[@]}" -eq 0 ]; then
cc_log "nothing selected yet"
return 0
fi
disp=()
for p in "${basket[@]}"; do disp+=("$p"); done
if ! idx="$(menu_pick "$rm_prompt" "${disp[@]}")"; then
cc_quit_tick && return 77
return 0
fi
cc_quit_reset
p="${basket[$((idx - 1))]}"
keep=()
for ((i = 0; i < ${#basket[@]}; i++)); do
[ "$i" -ne $((idx - 1)) ] && keep+=("${basket[i]}")
done
basket=("${keep[@]}")
cc_log "removed $p"
}
cc_clear_basket() { # $1 basket-name · $2 noun phrase ("device(s)")
local -n basket="$1"
local word="$2" n="${#basket[@]}"
if [ "$n" -eq 0 ]; then
cc_log "nothing selected yet"
return 0
fi
if ! confirm "Remove all $n selected $word?" n; then
cc_quit_tick && return 77
return 0
fi
cc_quit_reset
basket=()
cc_log "all $n $word removed"
}
# Shared basket shape: Add … / Remove one (N selected) / Clear all / Back.
# $5 = add function; remaining args forwarded to it verbatim.
cc_basket_menu() { # $1 basket-name · $2 noun · $3 remove-prompt · $4 clear-word · $5 add-fn …
local -n bref="$1"
local bname="$1" noun="$2" rm_prompt="$3" clear_word="$4" add_fn="$5"
shift 5
local idx
while true; do
if [ "${#bref[@]}" -eq 0 ]; then
"$add_fn" "$bname" "$@" || return 77
return 0 # empty basket → straight to adding
fi
if ! idx="$(menu_run "$noun" \
"Add …" \
"Remove one (${#bref[@]} selected)" \
"Clear all")"; then
cc_quit_tick && return 77
return 0 # Back
fi
cc_quit_reset
case "$idx" in
1) "$add_fn" "$bname" "$@" || return 77 ;;
2) cc_remove_one "$bname" "$rm_prompt" || return 77 ;;
3) cc_clear_basket "$bname" "$clear_word" || return 77 ;;
esac
done
}
# Typed-value add loop (mounts, ports). Raw read — NOT menu_ask_value — so an
# EMPTY answer ("done") stays distinguishable from EOF (discard everything).
# Validator contract: $1 raw input · rest = basket values · normalized value
# on stdout · rc 1 = rejected (diagnostics printed by the validator).
cc_add_typed_loop() { # $1 basket-name · $2 prompt · $3 validator fn
local -n basket="$1"
local prompt="$2" vfn="$3"
local v norm
local -a vals
while true; do
if ! read -rp "$prompt: " v; then
return 77 # raw read rc 1 = definitive EOF
fi
[ -z "$v" ] && return 0 # empty = done
vals=("${basket[@]}")
if norm="$("$vfn" "$v" ${vals[@]+"${vals[@]}"})"; then
basket+=("$norm")
fi
done
}
cc_validate_mount() { # stdout: normalized absolute path · rc 1 = rejected
local raw="$1" v
shift
v="${raw/#\~/$HOME}"
case "$v" in
/*) ;;
*) cc_warn "not an absolute path: $raw — try again"; return 1 ;;
esac
[ -d "$v" ] || {
cc_warn "directory not found: $raw — try again"
return 1
}
local q
for q in "$@"; do
[ "$q" = "$v" ] && {
cc_warn "$v is already mounted — skipped"
return 1
}
done
printf '%s\n' "$v"
cc_log "will mount $v:$v"
}
cc_validate_port() { # stdout: validated HOST:CONTAINER · rc 1 = rejected
local v="$1"
shift
[[ "$v" =~ ^[0-9]+(:[0-9]+){1,2}$ ]] || {
cc_warn "not a HOST:CONTAINER pair: $v — try again (e.g. 8080:80)"
return 1
}
local hp="${v%%:*}" q hq
for q in "$@"; do
hq="${q%%:*}"
[ "$hq" = "$hp" ] && {
cc_warn "host port $hp already mapped — rejected"
return 1
}
done
printf '%s\n' "$v"
cc_log "will publish $v"
}
# ── categories ────────────────────────────────────────────────────────────
cc_category_image() {
local -a picks=("ubuntu:22.04" "ubuntu:24.04" "debian:12" "kalilinux/kali-rolling" "archlinux" "fedora:latest" "alpine:latest" "Other (type image ref)")
local idx ref
while true; do
if ! idx="$(menu_pick "Pick image" "${picks[@]}")"; then
cc_quit_tick && return 77
return 0 # back keeps the current image
fi
cc_quit_reset
if [ "$idx" -lt "${#picks[@]}" ]; then
C_image="${picks[$((idx - 1))]}"
C_image_set=1
cc_log "image set to $C_image"
return 0
fi
if ! ref="$(menu_ask_value "Image ref")"; then
cc_quit_tick && return 77
continue # cancelled typing → picker again
fi
cc_quit_reset
if [[ "$ref" =~ [[:space:]] ]]; then
cc_warn "not a valid image ref — spaces not allowed"
continue
fi
C_image="$ref"
C_image_set=1
cc_log "image set to $C_image"
return 0
done
}
cc_category_gpu() {
local nodes_present=0 toolkit_present=0 pci_line="" choice sem nl
if cc_nvidia_nodes; then nodes_present=1; fi
if cc_gpu_toolkit; then toolkit_present=1; fi
if command -v lspci &>/dev/null; then
pci_line="$(lspci 2>/dev/null | grep -Ei 'vga|3d controller' | grep -i nvidia | head -n1)" || pci_line=""
[ -n "$pci_line" ] && cc_log "Nvidia hardware: ${pci_line#* }"
fi
if [ "$nodes_present" -eq 0 ]; then
# Path C — no GPU found: informational, never an error, never blocking.
# Category stays enterable via the manual escape hatch.
cc_info "no Nvidia driver/GPU detected on this host — skipping GPU setup"
cc_info "an exotic device path can still be added manually"
CC_CAND_NAMES=()
CC_CAND_LABELS=()
cc_add_loop C_gpu_devs "GPU (manual)" \
"Pick device to ADD" \
"Device path (must start with /dev/, empty = cancel)" || return 77
[ "${#C_gpu_devs[@]}" -gt 0 ] && C_gpu_mode="nodes"
return 0
fi
local i_all="Full GPU access (--gpus all)"
local i_nodes="Explicit Nvidia device nodes"
local i_none="none (clears GPU configuration)"
local -a opts=() sems=()
if [ "$toolkit_present" -eq 1 ]; then
# Path A — full support: recommend --gpus all (highlight ≠ preselect).
cc_info "Nvidia container toolkit detected — --gpus all available"
opts=("$i_all (recommended)" "$i_nodes" "$i_none")
sems=("all" "nodes" "none")
else
# Path B — nodes without toolkit: steer to explicit nodes first.
cc_info "nvidia-container-toolkit not detected — --gpus all will likely fail; offering explicit device nodes instead (install nvidia-container-toolkit for CUDA workloads)"
opts=("$i_nodes (recommended here)" "Configure anyway (--gpus all)" "$i_none")
sems=("nodes" "all" "none")
fi
case "$C_gpu_mode" in
all) opts[0]+=" (current)" ;;
nodes) opts[1]+=" (current)" ;;
*) opts[2]+=" (current)" ;;
esac
if ! choice="$(menu_run "GPU / Nvidia" "${opts[@]}")"; then
cc_quit_tick && return 77
return 0
fi
cc_quit_reset
sem="${sems[$((choice - 1))]}"
case "$sem" in
all)
C_gpu_mode="all"
C_gpu_devs=()
cc_log "GPU: --gpus all"
;;
nodes)
C_gpu_mode="nodes"
C_gpu_devs=()
cc_info "a working set is usually /dev/nvidia0 + /dev/nvidiactl + /dev/nvidia-uvm"
CC_CAND_NAMES=("${C_nv_nodes[@]}")
CC_CAND_LABELS=()
for nl in "${C_nv_nodes[@]}"; do
lbl="$(cc_node_label "$nl")" || lbl="$nl" # raced-away node: fall back to the bare path
CC_CAND_LABELS+=("$lbl")
done
cc_add_loop C_gpu_devs "Nvidia devices" \
"Pick device to ADD" \
"Device path (must start with /dev/, empty = cancel)" || return 77
[ "${#C_gpu_devs[@]}" -eq 0 ] && C_gpu_mode="" # nothing picked ⇒ back to unconfigured
;;
none)
C_gpu_mode=""
C_gpu_devs=()
cc_log "GPU configuration cleared"
;;
esac
return 0
}
cc_category_devices() {
cc_collect_devices CC_CAND_NAMES CC_CAND_LABELS
if [ "${#CC_CAND_NAMES[@]}" -eq 0 ]; then
cc_info "no candidate devices found — type a path manually"
fi
cc_basket_menu C_devs "Host devices" \
"Remove which device?" "device(s)" cc_add_loop \
"Host devices" \
"Pick device to ADD" \
"Device path (must start with /dev/, empty = cancel)"
}
cc_category_mounts() {
cc_basket_menu C_mounts "Host dir mounts" \
"Remove which mount?" "mount(s)" cc_add_typed_loop \
"Host directory to mount (empty = done)" cc_validate_mount
}
cc_category_ports() {
cc_basket_menu C_ports "Ports" \
"Remove which port?" "port mapping(s)" cc_add_typed_loop \
"Publish port HOST:CONTAINER (empty = done)" cc_validate_port
}
cc_category_resources() {
local v
cc_info "press Enter to keep Docker defaults"
while true; do
v=""
[ -n "$C_cpus" ] && v=" (current: $C_cpus)"
if ! read -rp "--cpus$v — blank = Docker default: " v; then
return 77 # raw read rc 1 = definitive EOF
fi
if [ -z "$v" ]; then
C_cpus=""
break
fi
[[ "$v" =~ ^[0-9]+(\.[0-9]+)?$ ]] || {
cc_warn "CPUs must be a number (e.g. 2 or 1.5)"
continue
}
C_cpus="$v"
break
done
while true; do
v=""
[ -n "$C_mem" ] && v=" (current: $C_mem)"
if ! read -rp "--memory$v — blank = Docker default (e.g. 512m, 2g): " v; then
return 77 # raw read rc 1 = definitive EOF
fi
if [ -z "$v" ]; then
C_mem=""
break
fi
[[ "$v" =~ ^[0-9]+(b|k|m|g|mb|gb)?$ ]] || {
cc_warn "memory formats: 500b, 100k, 512m, 2g (mb/gb accepted)"
continue
}
C_mem="$v"
break
done
cc_log "resources: ${C_cpus:-Docker default} cpu(s), ${C_mem:-Docker default} memory"
return 0
}
# ── compose + review ──────────────────────────────────────────────────────
cc_compose() { # fills CC_ARGS (argv for menu_self) + CC_FLAGS_TXT (display)
# The UI never asks for the primary dir — it stays ~/<name> (the verb's
# default). Pass it as the FIRST --dir explicitly, so mount baskets map
# to the repeatable --dir form without displacing the working directory.
# It must exist before the verb resolves it (cd && pwd), so create it here.
mkdir -p "$HOME/$C_name"
CC_ARGS=(create "$C_name" "$C_image" --dir "$HOME/$C_name")
CC_FLAGS_TXT=""
local d p m
if [ "$C_gpu_mode" = "all" ]; then
CC_ARGS+=(--gpu)
CC_FLAGS_TXT="--gpus all"
fi
for d in "${C_gpu_devs[@]}"; do
CC_ARGS+=(--device "$d")
CC_FLAGS_TXT+=" --device $d"
done
for d in "${C_devs[@]}"; do
CC_ARGS+=(--device "$d")
CC_FLAGS_TXT+=" --device $d"
done
for p in "${C_ports[@]}"; do
CC_ARGS+=(--port "$p")
CC_FLAGS_TXT+=" --port $p"
done
[ -n "$C_cpus" ] && {
CC_ARGS+=(--cpus "$C_cpus")
CC_FLAGS_TXT+=" --cpus $C_cpus"
}
[ -n "$C_mem" ] && {
CC_ARGS+=(--memory "$C_mem")
CC_FLAGS_TXT+=" --memory $C_mem"
}
for m in "${C_mounts[@]}"; do
CC_ARGS+=(--dir "$m")
CC_FLAGS_TXT+=" --dir $m"
done
}
cc_render_group() { # $1 label · rest = rendered values · truncates at 6 rows
local label="$1"
shift
local max=6 shown=0 v n="$#"
if [ "$n" -eq 0 ]; then
printf ' %-10s %s\n' "$label" "none" >&2
return 0
fi
for v in "$@"; do
[ "$shown" -lt "$max" ] || break
if [ "$shown" -eq 0 ]; then
printf ' %-10s %s\n' "$label" "$v" >&2
else
printf ' %-10s %s\n' "" "$v" >&2
fi
shown=$((shown + 1))
done
if [ "$n" -gt "$max" ]; then
printf ' %-10s … (+%d more)\n' "" "$((n - max))" >&2
fi
}
cc_review() { # rc 0 = confirmed (CC_ARGS ready) · rc 1 = back to hub · rc 77 = discarded
local -a gpu_vals=() dev_vals=() mnt_vals=() port_vals=() rv=()
local d p m res
case "$C_gpu_mode" in
all) gpu_vals+=("--gpus all") ;;
nodes)
for d in "${C_gpu_devs[@]}"; do gpu_vals+=("--device $d"); done
;;
esac
for d in "${C_devs[@]}"; do dev_vals+=("--device $d"); done
for m in "${C_mounts[@]}"; do mnt_vals+=("$m:$m"); done
for p in "${C_ports[@]}"; do port_vals+=("-p $p"); done
{
echo
echo "${CYAN}════════════════════════════════════════════${RESET}"
echo "${CYAN} Create VM '$C_name' — review plan${RESET}"
echo "${CYAN}════════════════════════════════════════════${RESET}"
} >&2
cc_render_group "image" "$C_image"
cc_render_group "host dir" "~/$C_name (bind-mounted at same path, cwd inside VM)"
cc_render_group "gpus" ${gpu_vals[@]+"${gpu_vals[@]}"}
cc_render_group "devices" ${dev_vals[@]+"${dev_vals[@]}"}
cc_render_group "mounts" ${mnt_vals[@]+"${mnt_vals[@]}"}
cc_render_group "ports" ${port_vals[@]+"${port_vals[@]}"}
[ -n "$C_cpus" ] && rv+=("--cpus $C_cpus")
[ -n "$C_mem" ] && rv+=("--memory $C_mem")
if [ "${#rv[@]}" -gt 0 ]; then
res="$(
IFS=' '
echo "${rv[*]}"
)"
else
res="Docker defaults"
fi
cc_render_group "cpu/ram" "$res"
cc_render_group "network" "Docker default (bridge)"
cc_compose
{
echo "----------------------------------------" >&2
echo " docker create -it --name $C_name --label linux_post_install.vbox=true \\" >&2
echo " ${CC_FLAGS_TXT} $C_image bash" >&2
}
# A CUDA image is ever only a hint — never forced, never auto-applied.
if [ -n "$C_gpu_mode" ] && [[ "$C_image" != *cuda* && "$C_image" != nvidia/* ]]; then
cc_info "tip: for CUDA inside the VM try an image like nvidia/cuda:12.4-base-ubuntu22.04 (set it under Image & distro)"
fi
if confirm "Create?" n; then
cc_quit_reset
return 0
fi
cc_quit_tick && return 77 # second consecutive quit ⇒ teardown
return 1 # 'n' → back to the hub, edits preserved
}
cc_hub_items() { # rebuilds CC_ITEMS with live basket counts
local img gpu dev mnt prt res
if [ "$C_image_set" -eq 1 ]; then
img="$C_image"
else
img="$C_image (default)"
fi
case "$C_gpu_mode" in
all) gpu="--gpus all" ;;
nodes) gpu="${#C_gpu_devs[@]} node(s)" ;;
*) gpu="not configured" ;;
esac
if [ "${#C_devs[@]}" -gt 0 ]; then
dev="${#C_devs[@]} selected"
else
dev="none"
fi
if [ "${#C_mounts[@]}" -gt 0 ]; then
mnt="~/$C_name (auto) + ${#C_mounts[@]} more"
else
mnt="~/$C_name (auto)"
fi
if [ "${#C_ports[@]}" -gt 0 ]; then
prt="${#C_ports[@]} mapped"
else
prt="none"
fi
local -a rv=()
[ -n "$C_cpus" ] && rv+=("cpus=$C_cpus")
[ -n "$C_mem" ] && rv+=("mem=$C_mem")
if [ "${#rv[@]}" -gt 0 ]; then
res="$(
IFS=' '
echo "${rv[*]}"
)"
else
res="Docker defaults"
fi
CC_ITEMS=(
"Image & distro ....... $img"
"GPU / Nvidia ......... $gpu"
"Host devices ......... $dev"
"Host dir mounts ...... $mnt"
"Ports ................ $prt"
"CPU / RAM ............ $res"
"Review & create"
)
}
menu_vbox_create() {
local name choice
local crc=0 rrc=0
# §1: an EMPTY answer at the identity prompt backs out with nothing
# created. menu_ask_value has no default here, so an empty answer and a
# dead stream are indistinguishable (both rc 1) — both take the loud
# teardown (one discard line), matching §3's "EOF at ANY depth" rule.
if ! name="$(menu_ask_value "VM name")"; then
cc_die
return 0
fi
C_name="$name"
cc_reset
cc_quit_reset
while true; do
cc_hub_items
if ! choice="$(menu_run "Configure VM '$C_name' — capabilities" "${CC_ITEMS[@]}")"; then
cc_quit_tick && { cc_die; return 0; } # dead input / second quit
if confirm "Discard this VM setup?" n; then
return 0 # confirmed discard → main menu
fi
continue # single quit → guarded redraw
fi
cc_quit_reset
# Categories honour a strict rc contract: 0 = normal · 77 = EOF
# teardown somewhere below. Calls are ||-captured because a bare
# nonzero from a simple command would trip set -e before the check.
crc=0
case "$choice" in
1) cc_category_image || crc=$? ;;
2) cc_category_gpu || crc=$? ;;
3) cc_category_devices || crc=$? ;;
4) cc_category_mounts || crc=$? ;;
5) cc_category_ports || crc=$? ;;
6) cc_category_resources || crc=$? ;;
esac
[ "$crc" -eq 77 ] && { cc_die; return 0; } # EOF teardown somewhere below
if [ "$choice" -eq 7 ]; then
rrc=0
cc_review || rrc=$?
case "$rrc" in
77)
cc_die
return 0
;;
0)
menu_self "${CC_ARGS[@]}"
return 0
;;
*) continue ;; # 'n' → hub with edits preserved
esac
fi
done
}
menu_vbox_enter() {
@@ -160,17 +951,68 @@ case "$cmd" in
name="${2:-}"
[ -z "$name" ] && usage
# Parse remaining args: [image] [--dir <path>]
# Parse remaining args: [image] [--dir <path>]… [--device </dev/node>]…
# [--gpu] [--port HOST:CONTAINER]… [--cpus N] [--memory SIZE] [--network MODE]
# The new flags are additive; an invocation that uses none of them
# behaves byte-identically to the pre-categorized verb.
image="ubuntu:22.04"
custom_dir=""
extra_dirs=()
want_gpu=0
devices=()
ports=()
cpus=""
memory=""
network=""
shift 2 || true
while [ $# -gt 0 ]; do
case "$1" in
--dir)
[ -z "${2:-}" ] && { echo "Missing value for --dir"; exit 1; }
custom_dir="$2"
if [ -z "$custom_dir" ]; then
custom_dir="$2"
else
extra_dirs+=("$2")
fi
shift 2
;;
--gpu)
want_gpu=1
shift
;;
--device)
[ -z "${2:-}" ] && { echo "Missing value for --device"; exit 1; }
[[ "$2" == /dev/* ]] || { echo "[!] --device expects a path under /dev/: $2"; exit 1; }
devices+=("$2")
shift 2
;;
--port)
[ -z "${2:-}" ] && { echo "Missing value for --port"; exit 1; }
[[ "$2" =~ ^[0-9]+(:[0-9]+){1,2}$ ]] || { echo "[!] --port expects HOST:CONTAINER (e.g. 8080:80): $2"; exit 1; }
ports+=("$2")
shift 2
;;
--cpus)
[ -z "${2:-}" ] && { echo "Missing value for --cpus"; exit 1; }
[[ "$2" =~ ^[0-9]+(\.[0-9]+)?$ ]] || { echo "[!] --cpus expects a number (e.g. 2 or 1.5): $2"; exit 1; }
cpus="$2"
shift 2
;;
--memory)
[ -z "${2:-}" ] && { echo "Missing value for --memory"; exit 1; }
[[ "$2" =~ ^[0-9]+(b|k|m|g|mb|gb)?$ ]] || { echo "[!] --memory expects a size (e.g. 512m or 2g): $2"; exit 1; }
memory="$2"
shift 2
;;
--network)
[ -z "${2:-}" ] && { echo "Missing value for --network"; exit 1; }
network="$2"
shift 2
;;
-*)
echo "[!] Unknown option: $1 — see 'pos docker vbox --help'"
exit 1
;;
*)
image="$1"
shift
@@ -191,6 +1033,26 @@ case "$cmd" in
mkdir -p "$lab_dir"
echo "[+] Lab directory: $lab_dir"
# Additional bind mounts (repeated --dir): same-path convention like
# the primary lab dir.
mount_flags=()
for d in "${extra_dirs[@]}"; do
mnt_dir="$(cd "$d" 2>/dev/null && pwd)" || { echo "[!] Directory not found: $d"; exit 1; }
mkdir -p "$mnt_dir"
echo "[+] Bind mount: $mnt_dir:$mnt_dir"
mount_flags+=(-v "$mnt_dir:$mnt_dir")
done
# Optional flags in a fixed order: GPU → devices → ports → resources
# → network. Mounts are appended after the primary -v below.
create_flags=()
[ "$want_gpu" -eq 1 ] && create_flags+=(--gpus all)
for d in "${devices[@]}"; do create_flags+=(--device "$d"); done
for p in "${ports[@]}"; do create_flags+=(-p "$p"); done
[ -n "$cpus" ] && create_flags+=(--cpus "$cpus")
[ -n "$memory" ] && create_flags+=(--memory "$memory")
[ -n "$network" ] && create_flags+=(--network "$network")
echo "[+] Pulling image: $image"
docker pull "$image"
@@ -199,7 +1061,9 @@ case "$cmd" in
-it \
--name "$name" \
--label linux_post_install.vbox=true \
${create_flags[@]+"${create_flags[@]}"} \
-v "$lab_dir:$lab_dir" \
${mount_flags[@]+"${mount_flags[@]}"} \
-w "$lab_dir" \
"$image" \
bash >/dev/null
+185 -24
View File
@@ -21,7 +21,8 @@ Mount and manage NFS shares from remote servers (nfs-common).
Commands:
mount <server:export> <local-dir> One-shot mount (creates local-dir if needed)
unmount <local-dir> Unmount the share
unmount <local-dir> Unmount the share (idempotent: rc 0 when
nothing is mounted)
list Show active NFS mounts
persist <server:export> <local-dir> Persistent mount via a systemd .mount unit
(ordered after network-online.target)
@@ -72,13 +73,36 @@ cmd_unmount() {
validate_dir "$where"
if ! findmnt -r -n -o TARGET -t nfs,nfs4 2>/dev/null | grep -qxF "$where"; then
log "$where is not mounted as NFS — nothing to do"
# Idempotent no-op — rc 0 whether or not a unit exists. A persisted
# boot-time .mount that is not currently mounted usually means the
# unit failed or was stopped; say so instead of a bare nothing-to-do.
if persisted_nfs_at "$where"; then
warn "$where has a persistent NFS mount unit (${PERSISTED_UNIT}) — not currently mounted."
log "Check it: systemctl status ${PERSISTED_UNIT%.mount} — or remove the persistence: menu option 5 (pos share nfs-client unpersist $where)"
else
log "$where is not mounted as NFS — nothing to do"
fi
return 0
fi
sudo umount "$where"
log "Unmounted $where"
}
# Persisted Type=nfs/nfs4 unit declaring Where=<path>? Sets PERSISTED_UNIT.
persisted_nfs_at() { # <path> — rc 0 persisted · rc 1 not persisted
local uf
PERSISTED_UNIT=""
for uf in "${UNIT_DIR}"/*.mount; do
[ -f "$uf" ] || continue
grep -q '^Type=nfs' "$uf" || continue
if [ "$(sed -n 's/^Where=//p' "$uf")" = "$1" ]; then
PERSISTED_UNIT="$(basename "$uf")"
return 0
fi
done
return 1
}
cmd_list() {
local out
if out="$(findmnt -t nfs,nfs4 2>/dev/null)" && [ "$(grep -c . <<<"$out")" -gt 1 ]; then
@@ -175,8 +199,17 @@ cmd_unpersist() {
unit_file="${UNIT_DIR}/${unit}"
if [ ! -f "$unit_file" ]; then
warn "No systemd mount unit for $where (${unit})"
exit 0
# Idempotent no-op — rc 0 whether or not anything is configured.
# Return, not exit: a bogus typed path reached from the menu must
# not kill the whole session. Mirror of cmd_unmount's guidance for
# a persisted unit under a non-escape-derived filename.
if persisted_nfs_at "$where"; then
warn "$where has a persistent NFS mount unit (${PERSISTED_UNIT}) under a non-standard unit name."
log "Check it: systemctl status ${PERSISTED_UNIT%.mount} — or find it in the list: pos share nfs-client list"
else
warn "No systemd mount unit for $where (${unit})"
fi
return 0
fi
sudo systemctl disable "$unit" 2>/dev/null || true
@@ -188,6 +221,105 @@ cmd_unpersist() {
}
# ── Interactive menu flows ─────────────────────────────────────
# Mountpoint picker — local port of the share_pick primitive with exactly
# two deltas: the hint line offers `n=new`, and typing n runs the
# create-new-dir flow below. share_pick cannot intercept `n` (it filters on
# it) and lib/menu-lib.sh is shared, so the fork lives here. Rendering of
# numbered picks / text filter / 0=back is byte-identical to menu_pick.
# stdout: chosen item text (or the freshly created dir) · rc 1 = back/cancel.
pick_mountpoint() {
local prompt="$1"; shift
local -a items=("$@")
if [ "${#items[@]}" -eq 0 ]; then
return 1
fi
if ! [ -t 0 ]; then
printf '[!] Interactive picker needs a terminal.\n' >&2
return 1
fi
local filter="" ans i n total=${#items[@]} made
local -a shown=()
while true; do
shown=()
for ((i = 0; i < total; i++)); do
if [ -z "$filter" ] || [[ "${items[$i],,}" == *"${filter,,}"* ]]; then
shown+=("${items[$i]}")
fi
done
n=${#shown[@]}
{
echo
if [ -n "$filter" ]; then
printf -- "-- %d of %d match '%s' --\n" "$n" "$total" "$filter"
else
printf -- "-- %d available --\n" "$total"
fi
if [ "$n" -eq 0 ]; then
printf '[!] no matches — enter nothing or / to clear the filter\n' >&2
else
for ((i = 0; i < n; i++)); do
printf ' %2d) %s\n' $((i + 1)) "${shown[$i]}"
done
fi
} >&2
if ! read -rp "${prompt} [1-${n}], n=new, text=filter, 0=back " ans; then
return 1 # EOF — cancel
fi
case "$ans" in
"") [ -z "$filter" ] || filter="" ; continue ;;
"/") filter="" ; continue ;;
0 | q | Q | b | B) return 1 ;;
n | N)
made="$(ask_new_mountpoint)" && { echo "$made"; return 0; }
continue # declined/invalid/mkdir-failed → redraw
;;
*[!0-9]*)
filter="$ans"
continue
;;
*)
if (( ans >= 1 && ans <= n )); then
echo "${shown[$((ans - 1))]}"
return 0
fi
echo "Unknown choice." >&2
;;
esac
done
}
# Create-new-dir flow behind the picker's `n` key. Validates the shape
# (absolute, no trailing slash), confirm-gates the creation, then mkdir -p.
# Any decline, invalid input, EOF or mkdir failure is a warning + rc 1 —
# the picker redraws, the tool never aborts.
ask_new_mountpoint() { # stdout: created dir · rc 1 = cancelled/failed
# NOTE: runs inside $( ) from the picker — every display line MUST go to
# stderr (menu-lib contract: display → stderr, result → stdout).
local dir
dir="$(share_ask_value "New mountpoint (absolute path)")" || return 1
case "$dir" in
/*) ;;
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;;
esac
case "$dir" in
*/) warn "'$dir' must not end with a slash" >&2; return 1 ;;
esac
case "$dir" in
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
warn "Refusing system path as mountpoint" >&2
return 1
;;
esac
confirm "Create mountpoint ${dir}?" n || return 1
if ! run sudo mkdir -p "$dir"; then
warn "Could not create ${dir}" >&2
return 1
fi
log "Created mount point $dir" >&2
echo "$dir"
}
menu_pick_export() { # <host> — stdout: server:export · rc 1 cancelled
local host="$1" idx exp
local -a exports=()
@@ -207,13 +339,26 @@ menu_pick_export() { # <host> — stdout: server:export · rc 1 cancelled
esac
}
menu_ask_mountpoint() { # stdout: absolute path · rc 1 cancelled
local idx dir cand
local -a cands=()
if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then
if idx="$(share_pick "Mountpoint" "${cands[@]}")"; then
cand="${cands[$((idx - 1))]}"
dir="${cand%% (*}" # strip "(mounted fstype)" annotation
menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancelled
local srv="${1:-}" idx res cand dir known=0
local -a cands=() dirs=()
mapfile -t cands < <(share_folder_candidates)
for cand in "${cands[@]}"; do
dirs+=("${cand%% (*}") # bare path (strip "(mounted fstype)" note)
done
# Same-as-server suggestion: unless the server-side path already exists
# among the local candidates, append it as a synthetic pick so mounting
# at a mirrored path is a normal selection.
if [ -n "$srv" ] && [ "${#dirs[@]}" -gt 0 ] &&
printf '%s\n' "${dirs[@]}" | grep -qxF -- "$srv"; then
known=1
fi
if [ -n "$srv" ] && [ "$known" -eq 0 ]; then
cands+=("${srv} (as on server)")
fi
if [ "${#cands[@]}" -gt 0 ]; then
if res="$(pick_mountpoint "Mountpoint" "${cands[@]}")"; then
dir="${res%% (*}" # strip "(as on server)"/mount note
case "$dir" in
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
warn "Refusing system path as mountpoint"
@@ -242,7 +387,7 @@ menu_mount() {
fi
what="$(menu_pick_export "$host")" || return 1
where="$(menu_ask_mountpoint)" || return 1
where="$(menu_ask_mountpoint "${what#*:}")" || return 1 # ${what#*:} = server-side export path
if [ "$mode" = "persist" ]; then
cmd_persist "$what" "$where"
@@ -252,16 +397,30 @@ menu_mount() {
}
menu_unmount() {
local idx where
local -a targets=()
if mapfile -t targets < <(findmnt -r -n -o TARGET -t nfs,nfs4 2>/dev/null | tail -n +2) &&
[ "${#targets[@]}" -gt 0 ]; then
idx="$(share_pick "Unmount which NFS mount?" "${targets[@]}")" || return 1
where="${targets[$((idx - 1))]}"
else
local idx row src where i
local -a tgts=() srcs=() items=()
# Same enumeration source as the `list` view (findmnt -t nfs,nfs4),
# reduced to TARGET|SOURCE rows.
while IFS= read -r row; do
[ -n "$row" ] || continue
tgts+=("${row%%|*}")
srcs+=("${row#*|}")
done < <(findmnt -rn -o TARGET,SOURCE -t nfs,nfs4 2>/dev/null |
awk '{ src=$NF; $NF=""; sub(/[ \t]+$/, ""); print $0 "|" src }')
if [ "${#tgts[@]}" -eq 0 ]; then
log "No active NFS mounts"
where="$(share_ask_value "Local mountpoint to unmount")" || return 1
[ -n "$where" ] || return 1
cmd_unmount "$where"
return 0
fi
for ((i = 0; i < ${#tgts[@]}; i++)); do
items+=("${tgts[$i]} ← ${srcs[$i]}")
done
idx="$(share_pick "Unmount which NFS mount?" "${items[@]}")" || return 1
where="${tgts[$((idx - 1))]}"
src="${srcs[$((idx - 1))]}"
confirm "Unmount ${where} (from ${src})?" n || { log "Cancelled"; return 1; }
cmd_unmount "$where"
}
@@ -296,11 +455,13 @@ run_menu() {
"Unmount a mounted share" \
"Remove a persistent mount")" || return 0
case "$choice" in
1) menu_mount ephemeral ;;
2) menu_mount persist ;;
3) cmd_list ;;
4) menu_unmount ;;
5) menu_unpersist ;;
# Handlers return nonzero on cancel/back — normalized here so a
# cancel can never reach set -e and kill the whole session.
1) menu_mount ephemeral || true ;;
2) menu_mount persist || true ;;
3) cmd_list || true ;;
4) menu_unmount || true ;;
5) menu_unpersist || true ;;
esac
done
}
+217 -29
View File
@@ -10,6 +10,7 @@ source "$(dirname "$0")/../lib/share-lib.sh" 2>/dev/null || source "$(dirname "$
SMB_CREDS_DIR="${SMB_CREDS_DIR:-/etc/samba/credentials}"
UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}"
SMB_PORT="${SMB_PORT:-445}"
SMB_CONF="${SMB_CONF:-/etc/samba/smb.conf}"
command -v mount.cifs &>/dev/null || err "mount.cifs not found (install cifs-utils)"
command -v systemd-escape &>/dev/null || err "systemd-escape not found"
@@ -21,7 +22,8 @@ Mount and manage SMB/CIFS shares from remote servers (cifs-utils).
Commands:
mount <//server/share> <local-dir> [user] One-shot mount (creates local-dir if needed)
unmount <local-dir> Unmount the share (idempotent)
unmount <local-dir> Unmount the share (idempotent: rc 0 when
nothing is mounted)
list Show active + persistent SMB mounts
persist <//server/share> <local-dir> [user] Persistent mount via systemd .mount + .automount
units (mounts on first access — never blocks boot)
@@ -103,6 +105,23 @@ mounted_src() {
findmnt -rnf -t cifs -o SOURCE,TARGET 2>/dev/null | awk -v t="$1" '$2 == t {print $1; exit}'
}
# Persisted Type=cifs unit declaring Where=<path>? An idle automount never
# appears in findmnt, so this is the only way to tell "idle automount" apart
# from "nothing configured". Sets PERSISTED_UNIT to the unit file basename.
persisted_smb_at() { # <path> — rc 0 persisted · rc 1 not persisted
local uf
PERSISTED_UNIT=""
for uf in "${UNIT_DIR}"/*.mount; do
[ -f "$uf" ] || continue
grep -q '^Type=cifs$' "$uf" || continue
if [ "$(sed -n 's/^Where=//p' "$uf")" = "$1" ]; then
PERSISTED_UNIT="$(basename "$uf")"
return 0
fi
done
return 1
}
# Create the mountpoint if needed; refuse to silently shadow a non-empty dir.
ensure_mountpoint() {
local where="$1"
@@ -243,13 +262,19 @@ cmd_unmount() { # <local-dir>
src="$(mounted_src "$where")"
if [ -z "$src" ]; then
log "Nothing mounted at $where"
exit 0
# Idempotent no-op — rc 0 whether or not anything is configured.
if persisted_smb_at "$where"; then
warn "$where is a persisted automount — not currently mounted."
log "Access it once (e.g.: ls $where) to auto-mount it, or remove the persistence first: menu option 5 (pos share smb-client unpersist $where)"
else
log "Nothing mounted at $where"
fi
return 0
fi
if ! out="$(sudo umount "$where" 2>&1)"; then
if grep -qE "busy|in use" <<<"$out"; then
warn "$where is busy (${src})"
confirm "Force a lazy unmount now?" y && { run sudo umount -l "$where"; log "Lazy-unmounted $where"; exit 0; }
confirm "Force a lazy unmount now?" y && { run sudo umount -l "$where"; log "Lazy-unmounted $where"; return 0; }
err "Still mounted. Find the blocker: sudo lsof +D $where (or fuser -vm $where)"
fi
err "Unmount failed: $out"
@@ -410,8 +435,18 @@ cmd_unpersist() { # <local-dir>
auto_file="${UNIT_DIR}/${auto_unit}"
if [ ! -f "$unit_file" ] && [ ! -f "$auto_file" ]; then
log "No persistent SMB mount for $where — nothing to do"
exit 0
# Idempotent no-op — rc 0 whether or not anything is configured.
# Return, not exit: a bogus typed path reached from the menu must
# not kill the whole session. A persisted unit can also live under
# a non-escape-derived filename (Where= still matches) — point at
# it instead of a bare nothing-to-do.
if persisted_smb_at "$where"; then
warn "$where has a persistent SMB mount unit (${PERSISTED_UNIT}) under a non-standard unit name."
log "Check it: systemctl status ${PERSISTED_UNIT%.mount} — or find it in the list: pos share smb-client list"
else
log "No persistent SMB mount for $where — nothing to do"
fi
return 0
fi
sudo systemctl disable "$auto_unit" 2>/dev/null || true
@@ -432,13 +467,149 @@ cmd_unpersist() { # <local-dir>
}
# ── Interactive menu flows ─────────────────────────────────────
menu_ask_mountpoint() { # stdout: absolute path · rc 1 cancelled
local idx dir cand
local -a cands=()
if mapfile -t cands < <(share_folder_candidates) && [ "${#cands[@]}" -gt 0 ]; then
if idx="$(share_pick "Mountpoint" "${cands[@]}")"; then
cand="${cands[$((idx - 1))]}"
dir="${cand%% (*}" # strip "(mounted fstype)" annotation
# Best-effort resolution of the directory BEHIND share <$2> on host <$1>.
# The underlying path of a remote SMB share is not remotely discoverable;
# it is only locally knowable when THIS machine is the server (testparm
# answers from the local config). Anything else stays unresolved (rc 1)
# and the mountpoint picker silently skips the "(as on server)" suggestion.
smb_server_path() { # <host> <share> — stdout: server-side dir · rc 1 = unresolved
local host="${1,,}" share="$2" name p ips=""
local -a names=("localhost" "127.0.0.1" "::1" "$(hostname)")
names+=("$(hostname -f 2>/dev/null || true)")
ips="$(hostname -I 2>/dev/null)" || true
# shellcheck disable=SC2086 — $ips is an intentional space-split IP list
for name in "${names[@]}" ${ips}; do
if [ "$name" = "$host" ]; then
share_require_bin testparm "" || return 1
[ -f "$SMB_CONF" ] || return 1
p="$(testparm -s --parameter-name=path --section-name="$share" "$SMB_CONF" 2>/dev/null)" || return 1
[ -n "$p" ] || return 1
printf '%s\n' "$p"
return 0
fi
done
return 1
}
# Mountpoint picker — local port of the share_pick primitive with exactly
# two deltas: the hint line offers `n=new`, and typing n runs the
# create-new-dir flow below. share_pick cannot intercept `n` (it filters on
# it) and lib/menu-lib.sh is shared, so the fork lives here. Rendering of
# numbered picks / text filter / 0=back is byte-identical to menu_pick.
# stdout: chosen item text (or the freshly created dir) · rc 1 = back/cancel.
pick_mountpoint() {
local prompt="$1"; shift
local -a items=("$@")
if [ "${#items[@]}" -eq 0 ]; then
return 1
fi
if ! [ -t 0 ]; then
printf '[!] Interactive picker needs a terminal.\n' >&2
return 1
fi
local filter="" ans i n total=${#items[@]} made
local -a shown=()
while true; do
shown=()
for ((i = 0; i < total; i++)); do
if [ -z "$filter" ] || [[ "${items[$i],,}" == *"${filter,,}"* ]]; then
shown+=("${items[$i]}")
fi
done
n=${#shown[@]}
{
echo
if [ -n "$filter" ]; then
printf -- "-- %d of %d match '%s' --\n" "$n" "$total" "$filter"
else
printf -- "-- %d available --\n" "$total"
fi
if [ "$n" -eq 0 ]; then
printf '[!] no matches — enter nothing or / to clear the filter\n' >&2
else
for ((i = 0; i < n; i++)); do
printf ' %2d) %s\n' $((i + 1)) "${shown[$i]}"
done
fi
} >&2
if ! read -rp "${prompt} [1-${n}], n=new, text=filter, 0=back " ans; then
return 1 # EOF — cancel
fi
case "$ans" in
"") [ -z "$filter" ] || filter="" ; continue ;;
"/") filter="" ; continue ;;
0 | q | Q | b | B) return 1 ;;
n | N)
made="$(ask_new_mountpoint)" && { echo "$made"; return 0; }
continue # declined/invalid/mkdir-failed → redraw
;;
*[!0-9]*)
filter="$ans"
continue
;;
*)
if (( ans >= 1 && ans <= n )); then
echo "${shown[$((ans - 1))]}"
return 0
fi
echo "Unknown choice." >&2
;;
esac
done
}
# Create-new-dir flow behind the picker's `n` key. Validates the shape
# (absolute, no trailing slash), confirm-gates the creation, then mkdir -p.
# Any decline, invalid input, EOF or mkdir failure is a warning + rc 1 —
# the picker redraws, the tool never aborts.
ask_new_mountpoint() { # stdout: created dir · rc 1 = cancelled/failed
# NOTE: runs inside $( ) from the picker — every display line MUST go to
# stderr (menu-lib contract: display → stderr, result → stdout).
local dir
dir="$(share_ask_value "New mountpoint (absolute path)")" || return 1
case "$dir" in
/*) ;;
*) warn "'$dir' is not an absolute path — must start with /" >&2; return 1 ;;
esac
case "$dir" in
*/) warn "'$dir' must not end with a slash" >&2; return 1 ;;
esac
case "$dir" in
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
warn "Refusing system path as mountpoint" >&2
return 1
;;
esac
confirm "Create mountpoint ${dir}?" n || return 1
if ! run sudo mkdir -p "$dir"; then
warn "Could not create ${dir}" >&2
return 1
fi
log "Created mount point $dir" >&2
echo "$dir"
}
menu_ask_mountpoint() { # [server_path] — stdout: absolute path · rc 1 cancelled
local srv="${1:-}" idx res cand dir known=0
local -a cands=() dirs=()
mapfile -t cands < <(share_folder_candidates)
for cand in "${cands[@]}"; do
dirs+=("${cand%% (*}") # bare path (strip "(mounted fstype)" note)
done
# Same-as-server suggestion: unless the server-side path already exists
# among the local candidates, append it as a synthetic pick so mounting
# at a mirrored path is a normal selection.
if [ -n "$srv" ] && [ "${#dirs[@]}" -gt 0 ] &&
printf '%s\n' "${dirs[@]}" | grep -qxF -- "$srv"; then
known=1
fi
if [ -n "$srv" ] && [ "$known" -eq 0 ]; then
cands+=("${srv} (as on server)")
fi
if [ "${#cands[@]}" -gt 0 ]; then
if res="$(pick_mountpoint "Mountpoint" "${cands[@]}")"; then
dir="${res%% (*}" # strip "(as on server)"/mount note
case "$dir" in
/etc|/boot|/bin|/sbin|/lib|/lib64|/usr|/var|/root|/home/*/.ssh*)
warn "Refusing system path as mountpoint"
@@ -479,7 +650,11 @@ menu_mount() { # ephemeral|persist
user="${SMB_AUTH_USER:-$user}"
what="//${host}/${share}"
where="$(menu_ask_mountpoint)" || return 1
# Underlying server-side dir, when knowable (this machine is the server);
# empty → picker silently skips the "(as on server)" suggestion.
local srv_path=""
srv_path="$(smb_server_path "$host" "$share")" || srv_path=""
where="$(menu_ask_mountpoint "$srv_path")" || return 1
if [ "$mode" = "persist" ]; then
cmd_persist "$what" "$where" "$user"
@@ -489,19 +664,30 @@ menu_mount() { # ephemeral|persist
}
menu_unmount() {
local idx row where
local -a targets=()
if mapfile -t rows < <(findmnt -rnf -t cifs -o SOURCE,TARGET 2>/dev/null) &&
[ "${#rows[@]}" -gt 0 ]; then
for row in "${rows[@]}"; do
targets+=("${row##* }") # last field of raw mode = TARGET
done
idx="$(share_pick "Unmount which SMB mount?" "${targets[@]}")" || return 1
where="${targets[$((idx - 1))]}"
else
local idx row src where i
local -a tgts=() srcs=() items=()
# Same enumeration source as the `list` view's active section
# (findmnt -t cifs), reduced to TARGET|SOURCE rows.
while IFS= read -r row; do
[ -n "$row" ] || continue
tgts+=("${row%%|*}")
srcs+=("${row#*|}")
done < <(findmnt -rn -o TARGET,SOURCE -t cifs 2>/dev/null |
awk '{ src=$NF; $NF=""; sub(/[ \t]+$/, ""); print $0 "|" src }')
if [ "${#tgts[@]}" -eq 0 ]; then
log "No active SMB mounts"
where="$(share_ask_value "Local mountpoint to unmount")" || return 1
[ -n "$where" ] || return 1
cmd_unmount "$where"
return 0
fi
for ((i = 0; i < ${#tgts[@]}; i++)); do
items+=("${tgts[$i]} ← ${srcs[$i]}")
done
idx="$(share_pick "Unmount which SMB mount?" "${items[@]}")" || return 1
where="${tgts[$((idx - 1))]}"
src="${srcs[$((idx - 1))]}"
confirm "Unmount ${where} (from ${src})?" n || { log "Cancelled"; return 1; }
cmd_unmount "$where"
}
@@ -535,11 +721,13 @@ run_menu() {
"Unmount a mounted share" \
"Remove a persistent mount")" || return 0
case "$choice" in
1) menu_mount ephemeral ;;
2) menu_mount persist ;;
3) cmd_list ;;
4) menu_unmount ;;
5) menu_unpersist ;;
# Handlers return nonzero on cancel/back — normalized here so a
# cancel can never reach set -e and kill the whole session.
1) menu_mount ephemeral || true ;;
2) menu_mount persist || true ;;
3) cmd_list || true ;;
4) menu_unmount || true ;;
5) menu_unpersist || true ;;
esac
done
}
+6 -3
View File
@@ -3,11 +3,13 @@
# Install: source this file in ~/.bashrc or place in /etc/bash_completion.d/
# GEN:START posflags
declare -A _pos_flags
_pos_flags[ai-gemini]="--model --session --system"
_pos_flags[ai-gemini]="--model --session --system --full --last"
_pos_flags[ai-openrouter]="--model --session --system --full --last"
_pos_flags[communication-matrix-listener]="--enable --disable --status --run"
_pos_flags[communication-telegram-listener]="--enable --disable --status --sync-commands --run"
_pos_flags[communication-telegram-sender]="--type --caption --parse-mode --no-preview --token --chat-id --markdown"
_pos_flags[docker-stack]="-a --all"
_pos_flags[docker-vbox]="--dir --gpu --device --port --cpus --memory --network"
_pos_flags[entertainment-send]="--print --markdown"
_pos_flags[media-mp3]="--output --no-playlist --cookies --by-artist --dry-run"
_pos_flags[media-mp4]="--format --best --worst --output --no-playlist --cookies --dry-run"
@@ -23,7 +25,8 @@ _pos_flags[tree]="--depth"
# GEN:END posflags
# GEN:START possubcmds
declare -A _pos_subcmds
_pos_subcmds[ai-gemini]="ask chat models sessions"
_pos_subcmds[ai-gemini]="ask capture chat models sessions"
_pos_subcmds[ai-openrouter]="ask capture chat models sessions"
_pos_subcmds[communication-matrix-sender]="send test login"
_pos_subcmds[communication-scrcpy]="devices record tcpip connect push pull screenshot info"
_pos_subcmds[communication-telegram-sender]="send test"
@@ -40,7 +43,7 @@ _pos_subcmds[system-backup]="menu"
_pos_subcmds[system-schedule]="run list config enable disable status migrate menu"
# 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 ai-openrouter compose entertainment matrix notify scrcpy system telegram ytsync)
# GEN:END posconfigscopes
_pos() {
+18 -7
View File
@@ -117,15 +117,26 @@ spawn() {
}
# ── Confirmation prompt ────────────────────────────────────────
# confirm <prompt> [default] — Enter accepts the DISPLAYED DEFAULT ('y'
# when omitted); explicit y/Y or n/N overrides; anything else (invalid
# input, EOF/closed stdin) denies. EOF fails closed and rc-safely ($yn is
# pre-initialized, so no set -u surprise on shells where read leaves it
# unset). Destructive call sites pass explicit 'n'.
confirm() {
local prompt="$1" default="${2:-y}" yn
if [ "$default" = "y" ]; then
read -rp "${prompt} [Y/n]: " yn
[[ -z "$yn" || "$yn" =~ ^[Yy] ]]
else
read -rp "${prompt} [y/N]: " yn
[[ "$yn" =~ ^[Yy] ]]
local prompt="$1" default="${2:-y}" hint="[y/N]" yn=""
local d="${default,,}"
if [ "$d" = "y" ]; then
hint="[Y/n]"
fi
if ! read -rp "${prompt} ${hint}: " yn; then
return 1 # EOF / closed stdin — deny
fi
case "$yn" in
[Yy]) return 0 ;;
[Nn]) return 1 ;;
"") [ "$d" = "y" ] ;; # Enter → the displayed default
*) return 1 ;; # invalid input — deny
esac
}
# ── system.env loader ──────────────────────────────────────────
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Optional shell hook for pos ai * --last: auto-captures terminal output.
# Usage: add to ~/.bashrc:
# source /usr/local/bin/pos-ai-hook.sh
# — or —
# source /path/to/Linux_post_install/lib/pos-ai-hook.sh
#
# After sourcing, every command's stdout+stderr is silently tee'd to
# ~/.local/share/linux_post_install/last_cmd_output (truncated at 1 MB).
# Then pos ai gemini ask --last / pos ai openrouter ask --last will
# pick it up automatically — no 'capture' subcommand needed.
# To disable: unset __POS_CAPTURE_ACTIVE
__POS_CAPTURE_FILE="${HOME}/.local/share/linux_post_install/last_cmd_output"
__POS_CAPTURE_MAX=${__POS_CAPTURE_MAX:-1048576} # 1 MB, override with env
# Truncate if oversized (keep last half)
if [ -f "$__POS_CAPTURE_FILE" ]; then
__sz=$(stat -c%s "$__POS_CAPTURE_FILE" 2>/dev/null || echo 0)
if [ "$__sz" -gt "$__POS_CAPTURE_MAX" ]; then
tail -c $((__POS_CAPTURE_MAX / 2)) "$__POS_CAPTURE_FILE" > "${__POS_CAPTURE_FILE}.tmp" 2>/dev/null
mv -- "${__POS_CAPTURE_FILE}.tmp" "$__POS_CAPTURE_FILE"
fi
else
: > "$__POS_CAPTURE_FILE"
fi
# Only activate in interactive terminals, not already redirected
if [ -t 1 ] && [ -t 2 ] && [ -z "${__POS_CAPTURE_ACTIVE:-}" ]; then
export __POS_CAPTURE_ACTIVE=1
exec > >(tee -a "$__POS_CAPTURE_FILE" 2>&1) 2>&1
fi