From fe277e1ef21cd5e6b4f10217d166aa892b1d7955 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:57:06 -0700 Subject: [PATCH] docs: REPL / interactive shell design (#92) (#96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: design document for openhuman REPL / interactive shell (#92) Design-first writeup covering problem statement, UX sketch with example sessions (skills + non-skill flows), architecture showing how the REPL reuses existing invoke_method/controller registry/RuntimeEngine without duplicating logic, safety rules for secret redaction, and phased implementation milestones (MVP → skills commands → script/batch mode). Closes #92 Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update dependencies and enhance onboarding logic - Updated Cargo.lock and Cargo.toml to include new dependencies: `clipboard-win`, `endian-type`, `error-code`, `fd-lock`, `home`, `nibble_vec`, `radix_trie`, `rustyline`, and `unicode-segmentation`. - Enhanced the OnboardingOverlay component to wait for user profile loading before checking onboarding status, improving user experience during onboarding. - Adjusted dependency versions and added features for `rustyline` in Cargo.toml. * feat(repl): implement interactive REPL for OpenHuman core - Added a new REPL module to provide an interactive shell for users, allowing command execution and evaluation. - Integrated REPL functionality into the CLI, enabling commands like `openhuman repl` and support for options such as `--eval` and `--batch`. - Enhanced command parsing and execution flow to maintain consistency with existing JSON-RPC server logic, ensuring no duplication of functionality. - Updated CLI help documentation to include REPL usage instructions. - Introduced Apple certificate import and code signing steps in the GitHub Actions workflow for macOS, enhancing sidecar security. This commit lays the groundwork for a more interactive user experience and strengthens the security of the sidecar binary. * refactor(repl): remove tool_warning_shown field from ReplState - Eliminated the `tool_warning_shown` boolean field from the `ReplState` struct, simplifying the state management within the REPL module. - Updated the constructor to reflect the removal of this field, ensuring consistency in the initialization of `ReplState`. * refactor(build): simplify Tauri build command in workflow - Removed unnecessary parameters from the Tauri build command in the GitHub Actions workflow, streamlining the build process. - Improved readability of the build configuration by eliminating redundant options. * chore(tauri): update build targets in tauri.conf.json - Changed the build targets from a single string to an array, specifying individual target formats for improved clarity and flexibility in the build process. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 48 +- Cargo.lock | 89 ++ Cargo.toml | 1 + app/src-tauri/tauri.conf.json | 2 +- app/src/components/OnboardingOverlay.tsx | 12 +- docs/design-repl.md | 404 +++++++++ src/core/cli.rs | 7 + src/core/jsonrpc.rs | 2 +- src/core/mod.rs | 1 + src/core/repl.rs | 996 +++++++++++++++++++++++ 11 files changed, 1552 insertions(+), 12 deletions(-) create mode 100644 docs/design-repl.md create mode 100644 src/core/repl.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e98c9f49..3d696c629 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -124,6 +124,6 @@ jobs: working-directory: app run: | TAURI_CONFIG_OVERRIDE='{"bundle":{"createUpdaterArtifacts":"never"},"plugins":{"updater":{"active":false}}}' - yarn tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles none -- -- --bin OpenHuman + yarn tauri build -c "$TAURI_CONFIG_OVERRIDE" env: NODE_ENV: production diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 650734717..b9d3363e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -221,10 +221,10 @@ jobs: args: --target x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu artifact_suffix: ubuntu - - platform: windows-latest - args: --target x86_64-pc-windows-msvc - target: x86_64-pc-windows-msvc - artifact_suffix: windows + # - platform: windows-latest + # args: --target x86_64-pc-windows-msvc + # target: x86_64-pc-windows-msvc + # artifact_suffix: windows env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: @@ -418,6 +418,46 @@ jobs: MATRIX_TARGET: ${{ matrix.settings.target }} SIDECAR_BASE: ${{ steps.core-paths.outputs.sidecar_base }} + - name: Import Apple certificate for sidecar signing + if: matrix.settings.platform == 'macos-latest' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERTIFICATE_PATH" + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + security import "$CERTIFICATE_PATH" \ + -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + security set-key-partition-list \ + -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain-db + + - name: Codesign sidecar binary with hardened runtime (macOS) + if: matrix.settings.platform == 'macos-latest' + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + MATRIX_TARGET: ${{ matrix.settings.target }} + SIDECAR_BASE: ${{ steps.core-paths.outputs.sidecar_base }} + run: | + SIDECAR_PATH="app/src-tauri/binaries/${SIDECAR_BASE}-${MATRIX_TARGET}" + echo "Signing sidecar: $SIDECAR_PATH" + codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" "$SIDECAR_PATH" + codesign --verify --verbose "$SIDECAR_PATH" + echo "Sidecar signed successfully" + - name: Resolve standalone core CLI artifact path id: cli-paths shell: bash diff --git a/Cargo.lock b/Cargo.lock index 5fd9cfb8c..5e34dec4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -816,6 +816,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.58" @@ -1516,6 +1525,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "engineioxide" version = "0.15.2" @@ -1602,6 +1617,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "esp-idf-part" version = "0.6.0" @@ -1764,6 +1785,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2237,6 +2269,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "hostname" version = "0.4.2" @@ -3622,6 +3663,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.26.4" @@ -3909,6 +3959,7 @@ dependencies = [ "rusqlite", "rustls", "rustls-pki-types", + "rustyline", "schemars", "serde", "serde-big-array", @@ -4762,6 +4813,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.5" @@ -5352,6 +5413,28 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "15.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix 0.29.0", + "radix_trie", + "unicode-segmentation", + "unicode-width 0.2.2", + "utf8parse", + "windows-sys 0.59.0", +] + [[package]] name = "ruzstd" version = "0.8.2" @@ -6663,6 +6746,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + [[package]] name = "unicode-width" version = "0.1.14" diff --git a/Cargo.toml b/Cargo.toml index cafa56686..541563b83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ console = "0.16" glob = "0.3" regex = "1.10" hostname = "0.4.2" +rustyline = { version = "15", features = ["with-file-history"] } rustls = { version = "0.23", features = ["ring"] } rustls-pki-types = "1.14.0" tokio-rustls = "0.26.4" diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index d4d34efd1..7f7c5d43b 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -29,7 +29,7 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": ["app", "dmg", "deb", "nsis", "msi", "appimage", "updater"], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/app/src/components/OnboardingOverlay.tsx b/app/src/components/OnboardingOverlay.tsx index 1b76a353b..ea28e6ccb 100644 --- a/app/src/components/OnboardingOverlay.tsx +++ b/app/src/components/OnboardingOverlay.tsx @@ -15,17 +15,19 @@ import { * when the user has not completed onboarding. * * Checks both Redux `isOnboarded` and the workspace flag file. + * Waits for the user profile to load before making a decision. */ const OnboardingOverlay = () => { const token = useAppSelector(state => state.auth.token); const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete); + const user = useAppSelector(state => state.user.user); const isOnboarded = useAppSelector(selectIsOnboarded); const [hasWorkspaceFlag, setHasWorkspaceFlag] = useState(null); const [dismissed, setDismissed] = useState(false); - // Check workspace flag on mount and when onboarding state changes + // Check workspace flag once user is loaded useEffect(() => { - if (!token || !isAuthBootstrapComplete) return; + if (!token || !isAuthBootstrapComplete || !user?._id) return; let mounted = true; const check = async () => { @@ -42,14 +44,14 @@ const OnboardingOverlay = () => { return () => { mounted = false; }; - }, [token, isAuthBootstrapComplete, isOnboarded]); + }, [token, isAuthBootstrapComplete, user?._id, isOnboarded]); const handleComplete = useCallback(() => { setDismissed(true); }, []); - // Don't show if not logged in or bootstrap not complete - if (!token || !isAuthBootstrapComplete) return null; + // Don't show if not logged in, bootstrap not complete, or user not loaded + if (!token || !isAuthBootstrapComplete || !user?._id) return null; // Still loading workspace flag if (hasWorkspaceFlag === null) return null; diff --git a/docs/design-repl.md b/docs/design-repl.md new file mode 100644 index 000000000..b5fa26035 --- /dev/null +++ b/docs/design-repl.md @@ -0,0 +1,404 @@ +# Design: `openhuman repl` — Interactive Shell for Core Flows & Skills + +**Issue**: [tinyhumansai/openhuman#92](https://github.com/tinyhumansai/openhuman/issues/92) +**Status**: Design (pre-implementation) +**Date**: 2026-03-30 + +--- + +## 1. Problem Statement + +Validating core behavior today requires either the **full Tauri stack** (UI + sidecar) or **hand-crafted JSON-RPC / curl commands**. Both are slow for iteration, especially for: + +- **Skill authors** debugging QuickJS execution, tool wiring, and sandbox boundaries. +- **Core developers** exercising RPC controllers during development. +- **QA / onboarding** running reproducible smoke checks from a terminal. + +A lightweight, terminal-first **read-eval-print loop** would make all three workflows significantly faster. + +### Target Users + +| User | Need | +|------|------| +| **Skill author** | Discover, start, inspect, and call skill tools without a UI | +| **Core developer** | Exercise any registered RPC method, inspect config/health | +| **QA / CI script** | Batch-run a sequence of commands, assert outputs | +| **New contributor** | Follow onboarding docs ("paste this in the REPL to see X") | + +### Non-Goals (v1) + +- **Shipping to end users** as a primary interface — this is a dev/test tool. +- **Chat / LLM interaction** — no agentic inference loop; use the desktop app. +- **Remote connections** — the REPL drives the local core directly (in-process), not a remote server. +- **Full TUI** (curses, panels, split panes) — keep it a single-line REPL with good completion. +- **Plugin authoring within the REPL** — skills are authored in JS files, not typed live. +- **Replacing `openhuman run`** — the server subcommand stays as-is; the REPL is a peer. + +--- + +## 2. Command / UX Sketch + +### Starting the REPL + +```bash +openhuman repl # interactive mode +openhuman repl --verbose # debug logging enabled +openhuman repl --eval 'health snapshot' # evaluate one command, print, exit +echo 'config get' | openhuman repl --batch # stdin batch mode (no prompt) +``` + +### Prompt + +``` +openhuman> _ +``` + +Prompt changes to show context when relevant: + +``` +openhuman> skill start gmail + skill:gmail running (3 tools) + +openhuman> _ +``` + +### Example Session: Listing and Invoking a Skill + +``` +openhuman> help +Commands: + [--param value ...] Call any registered controller + call [json] Raw JSON-RPC method call + skill list List discovered skills + skill start Start a skill instance + skill stop Stop a running skill + skill status Inspect runtime state + skill tools List tools from a running skill + skill call [json-args] Invoke a skill tool + schema [namespace] Show controller schemas + namespaces List all namespaces + env Show workspace & runtime paths + .verbose on|off Toggle debug logging + .json on|off Toggle raw JSON output + .time on|off Toggle timing display + exit | quit | Ctrl-D Exit + +openhuman> skill list + ID NAME STATUS TOOLS + gmail Gmail pending - + notion Notion pending - + calendar Google Calendar pending - + +openhuman> skill start gmail + skill:gmail initializing... + skill:gmail running (3 tools) + +openhuman> skill tools gmail + TOOL DESCRIPTION + gmail__search_emails Search emails by query + gmail__send_email Send an email + gmail__get_thread Get full thread by ID + +openhuman> skill call gmail search_emails {"query": "from:alice", "max_results": 5} + { + "content": [{ "type": "text", "text": "[...results...]" }], + "is_error": false + } + (234ms) + +openhuman> skill stop gmail + skill:gmail stopped +``` + +### Example Session: Non-Skill Flow (Config + Health) + +``` +openhuman> config get + { + "workspace_dir": "/Users/dev/.openhuman/workspace", + "model_settings": { "model_id": "neocortex-mk1", ... }, + ... + } + +openhuman> health snapshot + { + "uptime_secs": 42, + "skills_running": 1, + ... + } + +openhuman> config get_runtime_flags + { + "browser_allow_all": false, + "local_ai_enabled": false, + ... + } +``` + +### Example: Raw JSON-RPC Style + +``` +openhuman> call openhuman.encrypt_secret {"plaintext": "my-api-key"} + { + "ciphertext": "enc:v1:..." + } +``` + +### Example: Scriptable / Batch Mode + +```bash +# One-liner for CI +openhuman repl --eval 'health snapshot' | jq '.uptime_secs' + +# Batch script +cat <<'EOF' | openhuman repl --batch +config get_runtime_flags +skill list +health snapshot +EOF +``` + +--- + +## 3. Architecture + +### 3.1 How the REPL Reuses Core Code + +The REPL is **not** a second implementation. It drives the **same code paths** the JSON-RPC server uses: + +``` +┌────────────────────────────┐ +│ openhuman repl │ +│ (rustyline read loop) │ +│ │ +│ parse_line() │ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ Skill shorthand? │─yes──┼──► RuntimeEngine API (in-process) +│ │ (skill list, etc) │ │ engine.discover_skills() +│ └────────┬─────────┘ │ engine.start_skill() +│ │ no │ engine.call_tool() +│ ▼ │ engine.list_skills() +│ ┌──────────────────┐ │ +│ │ Meta command? │─yes──┼──► .verbose, .json, env, help (local) +│ │ (.verbose, help) │ │ +│ └────────┬─────────┘ │ +│ │ no │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ namespace func │──────┼──► invoke_method(state, method, params) +│ │ or call │ │ ↓ (same path as JSON-RPC server) +│ └──────────────────┘ │ all::try_invoke_registered_rpc() +│ │ → domain handler +└────────────────────────────┘ +``` + +**Key reuse points in existing code:** + +| What | Where | How REPL uses it | +|------|-------|-----------------| +| Controller registry | `src/core/all.rs` | `all_controller_schemas()`, `schema_for_rpc_method()`, `try_invoke_registered_rpc()` | +| Method invocation | `src/core/jsonrpc.rs` | `invoke_method(state, method, params)` — identical call to server | +| Param parsing & validation | `src/core/cli.rs` + `all.rs` | `parse_function_params()`, `validate_params()` | +| Schema grouping / help | `src/core/cli.rs` | `grouped_schemas()`, `print_namespace_help()` | +| Skills runtime | `src/openhuman/skills/qjs_engine.rs` | `RuntimeEngine` — `discover_skills()`, `start_skill()`, `call_tool()`, `list_skills()` | +| Skill bootstrap | `src/core/jsonrpc.rs` | `bootstrap_skill_runtime()` — reused to init QuickJS engine | +| Default state | `src/core/jsonrpc.rs` | `default_state()` → `AppState` | + +**No logic duplication.** The REPL is a thin input/output layer on top of the same internal APIs. Skill policy, parameter validation, secret redaction — all handled by the existing layers. + +### 3.2 Workspace and Skills Registry Interaction + +On startup the REPL: + +1. Resolves workspace from `OPENHUMAN_WORKSPACE` env or `~/.openhuman` (same as server). +2. Calls `bootstrap_skill_runtime()` to initialize the `RuntimeEngine`, skills data dir, cron/ping schedulers — identical to server startup but **without** binding an HTTP port. +3. Skills source dir is resolved the same way (bundled `skills/skills/` or workspace). +4. All `skill *` commands go through the global `RuntimeEngine` singleton (set by `set_global_engine()`). + +``` +openhuman repl + ├─ init logging (RUST_LOG or --verbose) + ├─ bootstrap_skill_runtime() ← same as server + │ ├─ resolve workspace + │ ├─ create RuntimeEngine + │ ├─ set_global_engine() + │ └─ start cron + ping schedulers + ├─ create Tokio runtime + ├─ create rustyline Editor (history, completer) + └─ loop { readline → parse → invoke → print } +``` + +### 3.3 QuickJS Runtime Lifecycle + +Skills run in QuickJS via `QjsSkillInstance`. The REPL shares the **same lifecycle** as the server: + +- `skill start ` → `RuntimeEngine::start_skill()` → spawns QuickJS isolate + message loop. +- `skill call ` → `RuntimeEngine::call_tool()` → sends `SkillMessage::CallTool` to the instance's mpsc channel → JS executes → returns `ToolCallResult`. +- `skill stop ` → sends `SkillMessage::Stop` → QuickJS context dropped. +- On REPL exit → all running instances are stopped (graceful shutdown). + +No separate QuickJS management code is needed. + +### 3.4 New Code Location + +``` +src/core/ + repl.rs # REPL loop, line parsing, completion, formatting + cli.rs # Add "repl" match arm in run_from_cli_args() + mod.rs # Add `pub mod repl;` +``` + +Single new file (`repl.rs`, ~300-500 lines estimated). The `cli.rs` change is a one-line match arm. + +### 3.5 New Dependency + +```toml +# Cargo.toml +rustyline = { version = "15", features = ["with-file-history"] } +``` + +`rustyline` provides: line editing, history (persisted to `~/.openhuman/repl_history`), tab completion, Ctrl-C/Ctrl-D handling, and cross-platform terminal support (including Windows). + +--- + +## 4. Safety: Secrets, Tokens, and PII + +### Principles + +1. **No secrets in REPL output.** The REPL displays results from `invoke_method()` which already passes through the same code as the server. Existing RPC handlers are responsible for not returning raw secrets. + +2. **Input redaction.** The REPL must **not** log user-typed input at `info` level or above if it may contain secrets (e.g., `--api_key`, `--plaintext`). Debug logging of input is gated behind `--verbose` / `RUST_LOG=debug`. + +3. **History file exclusions.** Lines matching sensitive patterns are **not written** to the history file: + - Any line containing `api_key`, `token`, `secret`, `password`, `plaintext`, `mnemonic` + - Raw JSON with fields named `*key`, `*token`, `*secret` + +4. **Skill output.** `call_tool()` returns `ToolCallResult { content, is_error }`. The REPL prints `content` as-is. Skill authors are responsible for not leaking credentials in tool output (same as in the desktop app). The REPL adds a one-line warning on first `skill call`: + ``` + note: skill tool output is printed verbatim; ensure skills do not emit secrets + ``` + +5. **No JWT / session tokens.** The REPL operates **in-process** with no auth layer. There are no session tokens to leak. Backend API calls (if any skill makes them) use credentials stored in the skills data dir, not typed interactively. + +### Implementation + +```rust +fn should_skip_history(line: &str) -> bool { + let lower = line.to_lowercase(); + const SENSITIVE: &[&str] = &[ + "api_key", "token", "secret", "password", + "plaintext", "mnemonic", "private_key", + ]; + SENSITIVE.iter().any(|s| lower.contains(s)) +} +``` + +--- + +## 5. Tab Completion + +`rustyline` supports custom completers. The REPL provides context-aware completion: + +| Position | Completes | +|----------|-----------| +| First word | Namespace names, `call`, `skill`, `schema`, `namespaces`, `env`, `help`, `exit` | +| After namespace | Function names within that namespace | +| After `skill` | `list`, `start`, `stop`, `status`, `tools`, `call` | +| After `skill start/stop/status/tools` | Discovered skill IDs | +| After `skill call ` | Tool names from that skill's snapshot | +| After function name | `--param_name` flags from schema | + +Completion data is derived from `all_controller_schemas()` and `RuntimeEngine::list_skills()` — no extra state. + +--- + +## 6. Output Modes + +| Mode | Default | Toggle | Behavior | +|------|---------|--------|----------| +| **Pretty** | on | `.json off` | Colored, indented JSON with field highlights | +| **Raw JSON** | off | `.json on` | Machine-parseable `serde_json::to_string_pretty` | +| **Timing** | off | `.time on` | Appends `(Xms)` after each result | +| **Verbose** | off | `.verbose on` | Sets `RUST_LOG=debug` for the process | + +In `--batch` / `--eval` mode, output defaults to raw JSON (machine-friendly). + +--- + +## 7. Error Handling + +``` +openhuman> config update_model_settings + error: missing required param 'model_id' + hint: openhuman config update_model_settings --help + +openhuman> skill start nonexistent + error: skill 'nonexistent' not found + hint: run `skill list` to see discovered skills + +openhuman> skill call gmail bad_tool {} + error: tool 'bad_tool' not found in skill 'gmail' + hint: run `skill tools gmail` to see available tools +``` + +Errors print to stderr (red if tty), results to stdout. This makes `--eval` / `--batch` output clean for piping. + +--- + +## 8. Follow-Up: Implementation Milestones + +### Phase 1 — MVP REPL (single PR) + +- [ ] `openhuman repl` subcommand with rustyline loop +- [ ] Namespace/function dispatch via `invoke_method()` +- [ ] `call [json]` for raw RPC +- [ ] `help`, `schema`, `namespaces`, `env`, `exit` +- [ ] `.json`, `.verbose`, `.time` toggles +- [ ] History file with sensitive-line exclusion +- [ ] Basic tab completion (namespaces + functions) +- [ ] `--eval` single-command mode +- [ ] Unit tests for line parsing and completion + +**Issue**: to be created after design approval. + +### Phase 2 — Skills-Focused Commands + +- [ ] `skill list/start/stop/status/tools/call` shorthand commands +- [ ] Tab completion for skill IDs and tool names +- [ ] Skill event streaming (print `skill-state-changed` events inline) +- [ ] `skill setup ` interactive setup flow +- [ ] Skill output formatting (tool result → readable text) + +**Issue**: to be created after Phase 1 merges. + +### Phase 3 — Script Mode & CI + +- [ ] `--batch` stdin mode (one command per line, raw JSON output) +- [ ] Exit codes: 0 = all ok, 1 = any command failed +- [ ] `--eval` supports semicolon-separated commands +- [ ] Example scripts in `docs/` for common workflows +- [ ] CI integration example (smoke test in GitHub Actions) + +**Issue**: to be created after Phase 2. + +--- + +## Appendix: Alternatives Considered + +### A. REPL as a wrapper around HTTP (like curl) + +**Rejected.** Adds network overhead, requires `openhuman run` to be running, and can't access the skill runtime's in-process state without the server. In-process invocation is simpler and faster. + +### B. Embed a full Lua/Python scripting layer + +**Rejected for v1.** Over-engineered for the stated goals. The `--eval` / `--batch` modes give enough scriptability. Can revisit if demand appears. + +### C. TUI with panels (like `lazygit`) + +**Rejected for v1.** Adds significant complexity (UI framework, layout, event handling). A line-based REPL covers the stated use cases. A TUI could be built on top later if warranted. + +### D. Use `clap` derive macros for REPL parsing + +**Rejected.** `clap` is designed for one-shot CLI parsing, not interactive loops. The existing hand-rolled parser in `cli.rs` is a better fit; the REPL reuses `parse_function_params()` directly. diff --git a/src/core/cli.rs b/src/core/cli.rs index 2a4a8cfca..c9791dc63 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -31,6 +31,7 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { match args[0].as_str() { "run" | "serve" => run_server_command(&args[1..]), "call" => run_call_command(&args[1..]), + "repl" | "shell" => crate::core::repl::run_repl(&args[1..]), namespace => run_namespace_command(namespace, &args[1..], &grouped), } } @@ -270,6 +271,11 @@ fn parse_function_params( Ok(out) } +/// Public alias for REPL param parsing (same logic, no duplication). +pub fn parse_input_value_for_repl(ty: &TypeSchema, raw: &str) -> Result { + parse_input_value(ty, raw) +} + fn parse_input_value(ty: &TypeSchema, raw: &str) -> Result { match ty { TypeSchema::String => Ok(Value::String(raw.to_string())), @@ -322,6 +328,7 @@ fn print_general_help(grouped: &BTreeMap>) { println!("OpenHuman core CLI\n"); println!("Usage:"); println!(" openhuman run [--port ] [--jsonrpc-only] [--verbose]"); + println!(" openhuman repl [--verbose] [--eval ''] [--batch]"); println!(" openhuman call --method [--params '']"); println!(" openhuman [--param value ...]\n"); println!("Available namespaces:"); diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 8faa62cfc..1dda96488 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -286,7 +286,7 @@ pub async fn run_server(port: Option, socketio_enabled: bool) -> anyhow::Re /// Initialize the QuickJS skill runtime and register it globally so RPC /// handlers (`openhuman.skills_*`) can reach it. -async fn bootstrap_skill_runtime() { +pub async fn bootstrap_skill_runtime() { use crate::openhuman::skills::qjs_engine::{set_global_engine, RuntimeEngine}; use std::sync::Arc; diff --git a/src/core/mod.rs b/src/core/mod.rs index ae8f39e8f..49c263af7 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -6,6 +6,7 @@ pub mod cli; pub mod dispatch; pub mod jsonrpc; pub mod logging; +pub mod repl; pub mod rpc_log; pub mod socketio; pub mod types; diff --git a/src/core/repl.rs b/src/core/repl.rs new file mode 100644 index 000000000..9afa94913 --- /dev/null +++ b/src/core/repl.rs @@ -0,0 +1,996 @@ +//! Interactive REPL (read-eval-print loop) for the OpenHuman core. +//! +//! Drives the same code paths as the JSON-RPC server — no logic duplication. +//! See `docs/design-repl.md` for the full design document. + +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::io; +use std::path::PathBuf; +use std::time::Instant; + +use rustyline::completion::{Completer, Pair}; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::history::FileHistory; +use rustyline::validate::Validator; +use rustyline::{CompletionType, Config, Editor, Helper}; +use serde_json::{Map, Value}; + +use crate::core::all; +use crate::core::jsonrpc::{default_state, invoke_method, parse_json_params}; +use crate::core::ControllerSchema; + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Run the REPL. Called from `cli.rs` when the user invokes `openhuman repl`. +pub fn run_repl(args: &[String]) -> anyhow::Result<()> { + let opts = parse_repl_args(args)?; + + if opts.help { + print_repl_usage(); + return Ok(()); + } + + crate::core::logging::init_for_cli_run(opts.verbose); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + + // Bootstrap the skill runtime (same as the server) so skill commands work. + rt.block_on(async { crate::core::jsonrpc::bootstrap_skill_runtime().await }); + + if let Some(expr) = &opts.eval { + // --eval: run a single command, print result, exit. + let mut state = ReplState::new(opts.verbose); + state.json_mode = true; // machine-friendly by default for --eval + let exit = rt.block_on(async { eval_line(&mut state, expr).await }); + std::process::exit(if exit == LoopAction::Exit { 1 } else { 0 }); + } + + if opts.batch { + // --batch: read stdin line-by-line, raw JSON output, exit. + let mut state = ReplState::new(opts.verbose); + state.json_mode = true; + let mut had_error = false; + let stdin = io::stdin(); + let mut line = String::new(); + loop { + line.clear(); + match stdin.read_line(&mut line) { + Ok(0) => break, + Ok(_) => {} + Err(e) => { + eprintln!("error reading stdin: {e}"); + had_error = true; + break; + } + } + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let action = rt.block_on(async { eval_line(&mut state, trimmed).await }); + if action == LoopAction::Exit { + had_error = true; + } + } + std::process::exit(if had_error { 1 } else { 0 }); + } + + // Interactive mode. + run_interactive(&rt, opts.verbose) +} + +// --------------------------------------------------------------------------- +// Argument parsing +// --------------------------------------------------------------------------- + +struct ReplOpts { + verbose: bool, + eval: Option, + batch: bool, + help: bool, +} + +fn parse_repl_args(args: &[String]) -> anyhow::Result { + let mut opts = ReplOpts { + verbose: false, + eval: None, + batch: false, + help: false, + }; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-v" | "--verbose" => { + opts.verbose = true; + i += 1; + } + "--eval" => { + let val = args + .get(i + 1) + .ok_or_else(|| anyhow::anyhow!("missing value for --eval"))?; + opts.eval = Some(val.clone()); + i += 2; + } + "--batch" => { + opts.batch = true; + i += 1; + } + "-h" | "--help" => { + opts.help = true; + i += 1; + } + other => return Err(anyhow::anyhow!("unknown repl arg: {other}")), + } + } + Ok(opts) +} + +fn print_repl_usage() { + println!("Usage: openhuman repl [OPTIONS]"); + println!(); + println!("Options:"); + println!(" --verbose, -v Enable debug logging"); + println!(" --eval '' Evaluate one command, print result, and exit"); + println!(" --batch Read commands from stdin (one per line), raw JSON output"); + println!(" -h, --help Show this help"); + println!(); + println!("Examples:"); + println!(" openhuman repl"); + println!(" openhuman repl --verbose"); + println!(" openhuman repl --eval 'health snapshot'"); + println!(" echo 'config get' | openhuman repl --batch"); +} + +// --------------------------------------------------------------------------- +// REPL state & toggles +// --------------------------------------------------------------------------- + +struct ReplState { + json_mode: bool, + show_time: bool, + verbose: bool, +} + +impl ReplState { + fn new(verbose: bool) -> Self { + Self { + json_mode: false, + show_time: false, + verbose, + } + } +} + +// --------------------------------------------------------------------------- +// Interactive loop +// --------------------------------------------------------------------------- + +fn run_interactive(rt: &tokio::runtime::Runtime, verbose: bool) -> anyhow::Result<()> { + let config = Config::builder() + .completion_type(CompletionType::List) + .auto_add_history(false) // we handle history manually for sensitive-line filtering + .build(); + + let helper = ReplHelper::new(); + let mut rl: Editor = Editor::with_config(config)?; + rl.set_helper(Some(helper)); + + // Load history from ~/.openhuman/repl_history (ignore errors on first run). + let history_path = history_file_path(); + let _ = rl.load_history(&history_path); + + let mut state = ReplState::new(verbose); + + println!( + "OpenHuman interactive shell (v{})", + env!("CARGO_PKG_VERSION") + ); + println!("Type `help` for commands, `exit` or Ctrl-D to quit.\n"); + + loop { + let prompt = "openhuman> "; + match rl.readline(prompt) { + Ok(line) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + // Add to history unless it looks sensitive. + if !should_skip_history(trimmed) { + let _ = rl.add_history_entry(trimmed); + } + + let action = rt.block_on(async { eval_line(&mut state, trimmed).await }); + if action == LoopAction::Quit { + break; + } + } + Err(ReadlineError::Interrupted) => { + // Ctrl-C: clear line, continue. + println!("^C"); + } + Err(ReadlineError::Eof) => { + // Ctrl-D: exit. + println!("exit"); + break; + } + Err(err) => { + eprintln!("readline error: {err}"); + break; + } + } + } + + let _ = rl.save_history(&history_path); + Ok(()) +} + +// --------------------------------------------------------------------------- +// History file path +// --------------------------------------------------------------------------- + +fn history_file_path() -> PathBuf { + let base = std::env::var("OPENHUMAN_WORKSPACE") + .map(PathBuf::from) + .unwrap_or_else(|_| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".openhuman") + }); + let _ = std::fs::create_dir_all(&base); + base.join("repl_history") +} + +// --------------------------------------------------------------------------- +// Sensitive-line filter (history exclusion) +// --------------------------------------------------------------------------- + +const SENSITIVE_PATTERNS: &[&str] = &[ + "api_key", + "token", + "secret", + "password", + "plaintext", + "mnemonic", + "private_key", +]; + +fn should_skip_history(line: &str) -> bool { + let lower = line.to_lowercase(); + SENSITIVE_PATTERNS.iter().any(|s| lower.contains(s)) +} + +// --------------------------------------------------------------------------- +// Command evaluation +// --------------------------------------------------------------------------- + +#[derive(Debug, PartialEq, Eq)] +enum LoopAction { + Continue, + Quit, + Exit, // for --eval error signalling +} + +async fn eval_line(state: &mut ReplState, line: &str) -> LoopAction { + let parts = shell_split(line); + if parts.is_empty() { + return LoopAction::Continue; + } + + let first = parts[0].as_str(); + + // Meta commands (dot-prefixed toggles). + if first.starts_with('.') { + handle_meta_command(state, first, &parts[1..]); + return LoopAction::Continue; + } + + match first { + "exit" | "quit" => return LoopAction::Quit, + "help" => { + print_help(); + return LoopAction::Continue; + } + "namespaces" => { + print_namespaces(); + return LoopAction::Continue; + } + "schema" => { + print_schema(parts.get(1).map(|s| s.as_str())); + return LoopAction::Continue; + } + "env" => { + print_env(); + return LoopAction::Continue; + } + "call" => { + return eval_raw_call(state, &parts[1..]).await; + } + _ => {} + } + + // Namespace + function dispatch. + eval_namespace_function(state, &parts).await +} + +// --------------------------------------------------------------------------- +// Meta commands (.json, .verbose, .time) +// --------------------------------------------------------------------------- + +fn handle_meta_command(state: &mut ReplState, cmd: &str, args: &[String]) { + let toggle = args.first().map(|s| s.as_str()); + match cmd { + ".json" => { + state.json_mode = parse_toggle(toggle, state.json_mode); + eprintln!( + " json mode: {}", + if state.json_mode { "on" } else { "off" } + ); + } + ".verbose" => { + let new_val = parse_toggle(toggle, state.verbose); + state.verbose = new_val; + if new_val { + std::env::set_var("RUST_LOG", "debug"); + } else { + std::env::set_var("RUST_LOG", "info"); + } + eprintln!( + " verbose: {} (note: log filter change takes effect on next log init)", + if new_val { "on" } else { "off" } + ); + } + ".time" => { + state.show_time = parse_toggle(toggle, state.show_time); + eprintln!(" timing: {}", if state.show_time { "on" } else { "off" }); + } + other => { + eprintln!(" unknown meta command: {other}"); + eprintln!(" available: .json, .verbose, .time"); + } + } +} + +fn parse_toggle(arg: Option<&str>, current: bool) -> bool { + match arg { + Some("on" | "true" | "1") => true, + Some("off" | "false" | "0") => false, + _ => !current, // toggle + } +} + +// --------------------------------------------------------------------------- +// `call [json]` — raw JSON-RPC-style invocation +// --------------------------------------------------------------------------- + +async fn eval_raw_call(state: &mut ReplState, args: &[String]) -> LoopAction { + if args.is_empty() { + eprintln!(" usage: call [json-params]"); + eprintln!(" example: call openhuman.health_snapshot {{}}"); + return LoopAction::Continue; + } + + let method = &args[0]; + let params_str = args.get(1).map(|s| s.as_str()).unwrap_or("{}"); + let params = match parse_json_params(params_str) { + Ok(p) => p, + Err(e) => { + eprintln!(" error: {e}"); + return LoopAction::Exit; + } + }; + + invoke_and_print(state, method, params).await +} + +// --------------------------------------------------------------------------- +// Namespace/function dispatch +// --------------------------------------------------------------------------- + +async fn eval_namespace_function(state: &mut ReplState, parts: &[String]) -> LoopAction { + let grouped = grouped_schemas(); + let namespace = parts[0].as_str(); + + let Some(schemas) = grouped.get(namespace) else { + eprintln!(" error: unknown command '{namespace}'"); + eprintln!(" hint: type `help` for available commands, or `namespaces` to list all"); + return LoopAction::Exit; + }; + + if parts.len() < 2 { + // Print functions in this namespace. + eprintln!(" functions in '{namespace}':"); + for s in schemas { + eprintln!(" {} — {}", s.function, s.description); + } + return LoopAction::Continue; + } + + let function = parts[1].as_str(); + let Some(schema) = schemas.iter().find(|s| s.function == function) else { + eprintln!(" error: unknown function '{namespace} {function}'"); + eprintln!(" hint: type `{namespace}` to see available functions"); + return LoopAction::Exit; + }; + + // Parse --key value params from remaining args. + let params = match parse_cli_params(schema, &parts[2..]) { + Ok(p) => Value::Object(p), + Err(e) => { + eprintln!(" error: {e}"); + show_function_hint(schema); + return LoopAction::Exit; + } + }; + + let method = match all::rpc_method_from_parts(namespace, function) { + Some(m) => m, + None => { + eprintln!(" error: no registered handler for '{namespace}.{function}'"); + return LoopAction::Exit; + } + }; + + invoke_and_print(state, &method, params).await +} + +fn show_function_hint(schema: &ControllerSchema) { + if schema.inputs.is_empty() { + return; + } + let params: Vec = schema + .inputs + .iter() + .map(|i| { + if i.required { + format!("--{} ", i.name) + } else { + format!("[--{} ]", i.name) + } + }) + .collect(); + eprintln!( + " usage: {} {} {}", + schema.namespace, + schema.function, + params.join(" ") + ); +} + +// --------------------------------------------------------------------------- +// Param parsing (reuses cli.rs logic via the schema) +// --------------------------------------------------------------------------- + +fn parse_cli_params( + schema: &ControllerSchema, + args: &[String], +) -> Result, String> { + // If there's a single arg that looks like JSON, parse it directly. + if args.len() == 1 && args[0].starts_with('{') { + let val: Value = + serde_json::from_str(&args[0]).map_err(|e| format!("invalid JSON: {e}"))?; + return match val { + Value::Object(map) => { + all::validate_params(schema, &map)?; + Ok(map) + } + _ => Err("expected JSON object".to_string()), + }; + } + + // Otherwise parse --key value pairs. + let mut out = Map::new(); + let mut i = 0; + while i < args.len() { + let raw = &args[i]; + if !raw.starts_with("--") { + return Err(format!("expected --, got '{raw}'")); + } + let key = raw.trim_start_matches("--").replace('-', "_"); + let Some(spec) = schema.inputs.iter().find(|input| input.name == key) else { + return Err(format!( + "unknown param '--{key}' for {}.{}", + schema.namespace, schema.function + )); + }; + let raw_value = args + .get(i + 1) + .ok_or_else(|| format!("missing value for --{key}"))?; + let value = crate::core::cli::parse_input_value_for_repl(&spec.ty, raw_value)?; + out.insert(key, value); + i += 2; + } + + all::validate_params(schema, &out)?; + Ok(out) +} + +// --------------------------------------------------------------------------- +// Invoke + print result +// --------------------------------------------------------------------------- + +async fn invoke_and_print(state: &mut ReplState, method: &str, params: Value) -> LoopAction { + let started = Instant::now(); + + match invoke_method(default_state(), method, params).await { + Ok(value) => { + print_value(state, &value); + if state.show_time { + eprintln!(" ({}ms)", started.elapsed().as_millis()); + } + LoopAction::Continue + } + Err(e) => { + eprintln!(" error: {e}"); + LoopAction::Exit + } + } +} + +fn print_value(state: &ReplState, value: &Value) { + if state.json_mode { + match serde_json::to_string_pretty(value) { + Ok(s) => println!("{s}"), + Err(e) => eprintln!(" serialization error: {e}"), + } + } else { + // Pretty mode: indented JSON with 2-space indent. + match serde_json::to_string_pretty(value) { + Ok(s) => { + for line in s.lines() { + println!(" {line}"); + } + } + Err(e) => eprintln!(" serialization error: {e}"), + } + } +} + +// --------------------------------------------------------------------------- +// Built-in commands: help, namespaces, schema, env +// --------------------------------------------------------------------------- + +fn print_help() { + println!("Commands:"); + println!(" [--param value ...] Call any registered controller"); + println!(" List functions in a namespace"); + println!(" call [json] Raw JSON-RPC method call"); + println!(" schema [namespace] Show controller schemas"); + println!(" namespaces List all namespaces"); + println!(" env Show workspace & runtime paths"); + println!(" .verbose on|off Toggle debug logging"); + println!(" .json on|off Toggle raw JSON output"); + println!(" .time on|off Toggle timing display"); + println!(" help Show this help"); + println!(" exit | quit | Ctrl-D Exit"); +} + +fn print_namespaces() { + let grouped = grouped_schemas(); + println!(" Namespaces ({} total):", grouped.len()); + for (ns, schemas) in &grouped { + let desc = all::namespace_description(ns).unwrap_or("(no description)"); + println!(" {ns:<24} {desc} ({} functions)", schemas.len()); + } +} + +fn print_schema(namespace: Option<&str>) { + let grouped = grouped_schemas(); + match namespace { + Some(ns) => { + if let Some(schemas) = grouped.get(ns) { + for s in schemas { + println!(" {}.{}", s.namespace, s.function); + println!(" {}", s.description); + if !s.inputs.is_empty() { + println!(" inputs:"); + for i in &s.inputs { + let req = if i.required { "*" } else { " " }; + println!(" {req} --{:<20} {}", i.name, i.comment); + } + } + if !s.outputs.is_empty() { + println!(" outputs:"); + for o in &s.outputs { + println!(" {:<20} {}", o.name, o.comment); + } + } + println!(); + } + } else { + eprintln!(" unknown namespace '{ns}'"); + } + } + None => { + let all = all::all_controller_schemas(); + println!( + " {} registered controllers across {} namespaces", + all.len(), + grouped.len() + ); + println!(" use `schema ` for details"); + } + } +} + +fn print_env() { + let workspace = std::env::var("OPENHUMAN_WORKSPACE").unwrap_or_else(|_| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".openhuman") + .to_string_lossy() + .to_string() + }); + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "(unknown)".to_string()); + + println!(" workspace: {workspace}"); + println!(" cwd: {cwd}"); + println!(" version: {}", env!("CARGO_PKG_VERSION")); + println!( + " rust_log: {}", + std::env::var("RUST_LOG").unwrap_or_else(|_| "(unset)".to_string()) + ); +} + +// --------------------------------------------------------------------------- +// Tab completion +// --------------------------------------------------------------------------- + +struct ReplHelper { + namespaces: Vec, + schemas: BTreeMap>, +} + +impl ReplHelper { + fn new() -> Self { + let schemas = grouped_schemas(); + let namespaces: Vec = schemas.keys().cloned().collect(); + Self { + namespaces, + schemas, + } + } +} + +impl Helper for ReplHelper {} +impl Validator for ReplHelper {} +impl Highlighter for ReplHelper { + fn highlight_prompt<'b, 's: 'b, 'p: 'b>( + &'s self, + prompt: &'p str, + _default: bool, + ) -> Cow<'b, str> { + Cow::Borrowed(prompt) + } +} +impl Hinter for ReplHelper { + type Hint = String; + fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option { + None + } +} + +impl Completer for ReplHelper { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &rustyline::Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + let text = &line[..pos]; + let parts: Vec<&str> = text.split_whitespace().collect(); + + // If the line ends with whitespace, we're completing the next word. + let trailing_space = text.ends_with(' ') || text.ends_with('\t'); + + match (parts.len(), trailing_space) { + // Empty or first word being typed. + (0, _) | (1, false) => { + let prefix = parts.first().copied().unwrap_or(""); + let start = text.len() - prefix.len(); + let mut candidates: Vec = Vec::new(); + + // Built-in commands. + for cmd in &[ + "call", + "help", + "namespaces", + "schema", + "env", + "exit", + "quit", + ".json", + ".verbose", + ".time", + ] { + if cmd.starts_with(prefix) { + candidates.push(Pair { + display: cmd.to_string(), + replacement: cmd.to_string(), + }); + } + } + + // Namespace names. + for ns in &self.namespaces { + if ns.starts_with(prefix) { + candidates.push(Pair { + display: ns.clone(), + replacement: ns.clone(), + }); + } + } + + Ok((start, candidates)) + } + + // Second word: complete function names within the namespace. + (1, true) | (2, false) => { + let ns = parts[0]; + let prefix = if trailing_space { + "" + } else { + parts.get(1).copied().unwrap_or("") + }; + let start = text.len() - prefix.len(); + + if let Some(schemas) = self.schemas.get(ns) { + let candidates: Vec = schemas + .iter() + .filter(|s| s.function.starts_with(prefix)) + .map(|s| Pair { + display: format!("{} — {}", s.function, s.description), + replacement: s.function.to_string(), + }) + .collect(); + Ok((start, candidates)) + } else { + Ok((pos, vec![])) + } + } + + // Third+ word: complete --param flags. + _ => { + let ns = parts[0]; + let func = parts.get(1).copied().unwrap_or(""); + let prefix = if trailing_space { + "" + } else { + parts.last().copied().unwrap_or("") + }; + + if !prefix.is_empty() && !prefix.starts_with('-') { + return Ok((pos, vec![])); + } + + let start = text.len() - prefix.len(); + let prefix_stripped = prefix.trim_start_matches('-'); + + if let Some(schemas) = self.schemas.get(ns) { + if let Some(schema) = schemas.iter().find(|s| s.function == func) { + let candidates: Vec = schema + .inputs + .iter() + .filter(|i| i.name.starts_with(prefix_stripped)) + .map(|i| { + let flag = format!("--{}", i.name); + let req = if i.required { " (required)" } else { "" }; + Pair { + display: format!("{flag}{req} — {}", i.comment), + replacement: flag, + } + }) + .collect(); + return Ok((start, candidates)); + } + } + Ok((pos, vec![])) + } + } + } +} + +// --------------------------------------------------------------------------- +// Shell-like splitting (respects quoted strings and JSON braces) +// --------------------------------------------------------------------------- + +fn shell_split(input: &str) -> Vec { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut brace_depth: usize = 0; + let mut bracket_depth: usize = 0; + + for ch in input.chars() { + match ch { + '\'' if !in_double_quote && brace_depth == 0 && bracket_depth == 0 => { + in_single_quote = !in_single_quote; + } + '"' if !in_single_quote && brace_depth == 0 && bracket_depth == 0 => { + in_double_quote = !in_double_quote; + } + '{' if !in_single_quote && !in_double_quote => { + brace_depth += 1; + current.push(ch); + } + '}' if !in_single_quote && !in_double_quote && brace_depth > 0 => { + brace_depth -= 1; + current.push(ch); + } + '[' if !in_single_quote && !in_double_quote => { + bracket_depth += 1; + current.push(ch); + } + ']' if !in_single_quote && !in_double_quote && bracket_depth > 0 => { + bracket_depth -= 1; + current.push(ch); + } + ' ' | '\t' + if !in_single_quote + && !in_double_quote + && brace_depth == 0 + && bracket_depth == 0 => + { + if !current.is_empty() { + parts.push(std::mem::take(&mut current)); + } + } + _ => { + current.push(ch); + } + } + } + if !current.is_empty() { + parts.push(current); + } + parts +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn grouped_schemas() -> BTreeMap> { + let mut grouped: BTreeMap> = BTreeMap::new(); + for schema in all::all_controller_schemas() { + grouped + .entry(schema.namespace.to_string()) + .or_default() + .push(schema); + } + for schemas in grouped.values_mut() { + schemas.sort_by_key(|s| s.function); + } + grouped +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- shell_split --------------------------------------------------------- + + #[test] + fn shell_split_simple() { + assert_eq!(shell_split("health snapshot"), vec!["health", "snapshot"]); + } + + #[test] + fn shell_split_with_flags() { + assert_eq!( + shell_split("config set --key value"), + vec!["config", "set", "--key", "value"] + ); + } + + #[test] + fn shell_split_json_braces() { + assert_eq!( + shell_split(r#"call openhuman.health_snapshot {"verbose": true}"#), + vec!["call", "openhuman.health_snapshot", r#"{"verbose": true}"#] + ); + } + + #[test] + fn shell_split_nested_json() { + assert_eq!( + shell_split(r#"call method {"a": {"b": 1}}"#), + vec!["call", "method", r#"{"a": {"b": 1}}"#] + ); + } + + #[test] + fn shell_split_single_quotes() { + assert_eq!( + shell_split("call method 'hello world'"), + vec!["call", "method", "hello world"] + ); + } + + #[test] + fn shell_split_double_quotes() { + assert_eq!( + shell_split(r#"call method "hello world""#), + vec!["call", "method", "hello world"] + ); + } + + #[test] + fn shell_split_empty() { + assert!(shell_split("").is_empty()); + assert!(shell_split(" ").is_empty()); + } + + // -- should_skip_history ------------------------------------------------- + + #[test] + fn skip_history_api_key() { + assert!(should_skip_history("encrypt secret --plaintext my-api-key")); + } + + #[test] + fn skip_history_token() { + assert!(should_skip_history( + r#"call auth.store_session {"token": "abc"}"# + )); + } + + #[test] + fn skip_history_mnemonic() { + assert!(should_skip_history("some command with mnemonic words")); + } + + #[test] + fn skip_history_private_key() { + assert!(should_skip_history("import --private_key 0xabc")); + } + + #[test] + fn skip_history_safe_command() { + assert!(!should_skip_history("health snapshot")); + } + + #[test] + fn skip_history_safe_config() { + assert!(!should_skip_history("config get")); + } + + // -- parse_toggle -------------------------------------------------------- + + #[test] + fn toggle_on() { + assert!(parse_toggle(Some("on"), false)); + assert!(parse_toggle(Some("true"), false)); + assert!(parse_toggle(Some("1"), false)); + } + + #[test] + fn toggle_off() { + assert!(!parse_toggle(Some("off"), true)); + assert!(!parse_toggle(Some("false"), true)); + assert!(!parse_toggle(Some("0"), true)); + } + + #[test] + fn toggle_flip() { + assert!(parse_toggle(None, false)); + assert!(!parse_toggle(None, true)); + } +}