Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5550a279 | ||
|
|
32f99074aa | ||
|
|
54f9ce1dec | ||
|
|
bd0021655e | ||
|
|
8a17cc138b | ||
|
|
36c0f3e25d |
@@ -271,6 +271,61 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm test
|
||||
|
||||
smoke-openclaw-install:
|
||||
needs: [detect-changes, build-openclaw-integration]
|
||||
if: >-
|
||||
github.event_name != 'pull_request_review' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
|
||||
needs.detect-changes.outputs.clients-ts == 'true' ||
|
||||
needs.detect-changes.outputs.all-npm == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
# Install the openclaw CLI globally. The smoke test exercises the real
|
||||
# `openclaw plugins install` / `openclaw config set` / `openclaw plugins
|
||||
# doctor` commands — not the in-repo integration tests — so a real CLI
|
||||
# must be on PATH.
|
||||
- name: Install openclaw CLI
|
||||
run: npm install -g openclaw
|
||||
|
||||
- name: Verify openclaw CLI
|
||||
run: openclaw --version
|
||||
|
||||
# openclaw depends on the workspace packages via published version
|
||||
# ranges (^0.1.0 / ^0.5.0), not file: paths, so the smoke test's
|
||||
# `openclaw plugins install <tarball>` resolves them straight from the
|
||||
# npm registry. These builds are just for `npm pack` / local unit
|
||||
# tests, not for resolving the plugin's runtime deps.
|
||||
- name: Install root workspace dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build hindsight-client (openclaw dep)
|
||||
run: npm run build --workspace=hindsight-clients/typescript
|
||||
|
||||
- name: Build hindsight-all-npm (openclaw dep)
|
||||
run: npm run build --workspace=hindsight-all-npm
|
||||
|
||||
- name: Install openclaw dependencies
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm ci
|
||||
|
||||
- name: Run openclaw install smoke test
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: ./scripts/smoke-test.sh
|
||||
|
||||
test-claude-code-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -2554,6 +2609,7 @@ jobs:
|
||||
- build-api-python-versions
|
||||
- build-typescript-client
|
||||
- build-openclaw-integration
|
||||
- smoke-openclaw-install
|
||||
- test-claude-code-integration
|
||||
- test-codex-integration
|
||||
- build-ai-sdk-integration
|
||||
|
||||
@@ -20,41 +20,53 @@ This plugin integrates [hindsight-embed](https://vectorize.io/hindsight/cli), a
|
||||
openclaw plugins install @vectorize-io/hindsight-openclaw
|
||||
```
|
||||
|
||||
**Step 2: Configure the LLM provider used for memory extraction**
|
||||
**Step 2: Run the setup wizard**
|
||||
|
||||
The plugin reads configuration from OpenClaw's plugin config — set it
|
||||
non-interactively with `openclaw config set`:
|
||||
`openclaw plugins install` unpacks the plugin into `~/.openclaw/extensions/`
|
||||
but does not put its bins on `PATH`. Run the wizard through `npx` instead —
|
||||
it resolves the bin out of the published package:
|
||||
|
||||
```bash
|
||||
# Option A — OpenAI (set llmApiKey as a SecretRef so the value comes from
|
||||
# the OPENAI_API_KEY environment variable at runtime instead of being
|
||||
# stored in plaintext on disk)
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider openai
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.llmApiKey \
|
||||
--ref-source env --ref-provider default --ref-id OPENAI_API_KEY
|
||||
|
||||
# Option B — Anthropic
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider anthropic
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.llmApiKey \
|
||||
--ref-source env --ref-provider default --ref-id ANTHROPIC_API_KEY
|
||||
|
||||
# Option C — Claude Code (no API key needed)
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider claude-code
|
||||
|
||||
# Option D — OpenAI Codex (no API key needed)
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider openai-codex
|
||||
npx --package @vectorize-io/hindsight-openclaw hindsight-openclaw-setup
|
||||
```
|
||||
|
||||
The wizard walks you through picking one of three install modes:
|
||||
|
||||
- **Cloud** — managed Hindsight at `https://api.hindsight.vectorize.io`. Prompts for the env var that holds your cloud API token. No local setup needed.
|
||||
- **External API** — your own running Hindsight deployment. Prompts for the URL and, optionally, the env var that holds an auth token.
|
||||
- **Embedded daemon** — spawns a local `hindsight-embed` daemon on this machine. Prompts for the LLM provider (OpenAI / Anthropic / Gemini / Groq / Claude Code / OpenAI Codex / Ollama) and the env var that holds the API key.
|
||||
|
||||
Credentials are always written as [`SecretRef`](#llm-configuration) objects that reference an environment variable — the key itself never ends up in plaintext on disk.
|
||||
|
||||
For CI and scripted setups the wizard also runs non-interactively:
|
||||
|
||||
```bash
|
||||
# Cloud
|
||||
npx --package @vectorize-io/hindsight-openclaw hindsight-openclaw-setup \
|
||||
--mode cloud --token-env HINDSIGHT_CLOUD_TOKEN
|
||||
|
||||
# External API
|
||||
npx --package @vectorize-io/hindsight-openclaw hindsight-openclaw-setup \
|
||||
--mode api --api-url https://mcp.hindsight.example.com --no-token
|
||||
|
||||
# Embedded daemon with OpenAI
|
||||
npx --package @vectorize-io/hindsight-openclaw hindsight-openclaw-setup \
|
||||
--mode embedded --provider openai --api-key-env OPENAI_API_KEY
|
||||
|
||||
# Embedded daemon with Claude Code (no API key needed)
|
||||
npx --package @vectorize-io/hindsight-openclaw hindsight-openclaw-setup \
|
||||
--mode embedded --provider claude-code
|
||||
```
|
||||
|
||||
Run `npx --package @vectorize-io/hindsight-openclaw hindsight-openclaw-setup --help` for the full flag list.
|
||||
|
||||
**Step 3: Start OpenClaw**
|
||||
|
||||
```bash
|
||||
openclaw gateway
|
||||
```
|
||||
|
||||
The plugin will automatically:
|
||||
- Start a local Hindsight daemon (port 9077)
|
||||
- Capture conversations after each turn
|
||||
- Inject relevant memories before agent responses
|
||||
The plugin will automatically capture conversations after each turn and inject relevant memories before agent responses.
|
||||
|
||||
**Important:** The LLM you configure above is **only for memory extraction** (background processing). Your main OpenClaw agent can use any model you configure separately.
|
||||
|
||||
@@ -179,6 +191,10 @@ By default, the plugin retains `user` and `assistant` messages after each turn.
|
||||
|
||||
### LLM Configuration
|
||||
|
||||
> If you used `hindsight-openclaw-setup` in Quick Start, this section is
|
||||
> already handled for you — read on if you want to edit `openclaw.json`
|
||||
> directly or switch to a file/exec secret source.
|
||||
|
||||
Configure the memory-extraction LLM via OpenClaw's plugin config. API keys
|
||||
should be stored as `SecretRef` values so they're resolved from env vars,
|
||||
mounted files, or `exec`-style secret managers (Vault, etc.) at runtime
|
||||
@@ -236,6 +252,9 @@ in your OpenClaw config — see `openclaw config set --help` for the
|
||||
|
||||
### External API (Advanced)
|
||||
|
||||
> `hindsight-openclaw-setup --mode api --api-url <url>` covers this path
|
||||
> interactively — this section documents the underlying config fields.
|
||||
|
||||
Connect to a remote Hindsight API server instead of running a local daemon. This is useful for:
|
||||
|
||||
- **Shared memory** across multiple OpenClaw instances
|
||||
|
||||
@@ -19,6 +19,8 @@ import PageHero from '@site/src/components/PageHero';
|
||||
|
||||
**Features**
|
||||
|
||||
- Added `hindsight-openclaw-setup`, an interactive setup wizard that walks users through picking one of three install modes — **Cloud** (managed Hindsight at `https://api.hindsight.vectorize.io`), **External API** (your own running Hindsight deployment), or **Embedded daemon** (local `hindsight-embed` daemon). The wizard writes a valid plugin config with env-backed `SecretRef` credentials and no plaintext secrets on disk.
|
||||
- `hindsight-openclaw-setup` also runs non-interactively via `--mode cloud|api|embedded` plus mode-specific flags (`--api-url`, `--token-env`, `--no-token`, `--provider`, `--api-key-env`, `--model`) for CI and scripted installs.
|
||||
- Added the `llmApiKey` plugin config field, marked as a sensitive field so OpenClaw resolves it as a `SecretRef` from env, file, or exec sources.
|
||||
- Added the `llmBaseUrl` plugin config field for OpenAI-compatible endpoint overrides (OpenRouter, Azure OpenAI, vLLM, etc.).
|
||||
- Marked `hindsightApiToken` as a sensitive field — it can now be configured as a `SecretRef` the same way as `llmApiKey`.
|
||||
|
||||
@@ -8,29 +8,39 @@ Biomimetic long-term memory for [OpenClaw](https://openclaw.ai) using [Hindsight
|
||||
# 1. Install the plugin
|
||||
openclaw plugins install @vectorize-io/hindsight-openclaw
|
||||
|
||||
# 2. Configure the LLM provider used for memory extraction.
|
||||
|
||||
# Option A — OpenAI (or any OpenAI-compatible provider)
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider openai
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.llmApiKey \
|
||||
--ref-source env --ref-provider default --ref-id OPENAI_API_KEY
|
||||
|
||||
# Option B — Claude Code (no API key needed)
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider claude-code
|
||||
|
||||
# Option C — OpenAI Codex (no API key needed)
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider openai-codex
|
||||
# 2. Run the interactive setup wizard
|
||||
npx --package @vectorize-io/hindsight-openclaw hindsight-openclaw-setup
|
||||
|
||||
# 3. Start OpenClaw
|
||||
openclaw gateway
|
||||
```
|
||||
|
||||
That's it! The plugin will automatically start capturing and recalling memories.
|
||||
`hindsight-openclaw-setup` walks you through picking one of three modes:
|
||||
|
||||
`llmApiKey` is marked sensitive — `openclaw config set ... --ref-source env` writes a
|
||||
SecretRef that resolves the value from your `OPENAI_API_KEY` environment variable at
|
||||
runtime, so the key is never stored in plaintext on disk. `--ref-source file` and
|
||||
`--ref-source exec` are also supported for mounted-secret and Vault-style setups.
|
||||
- **Cloud** — managed Hindsight. Pick an API token env var, done.
|
||||
- **External API** — your own running Hindsight deployment. Prompts for the URL and optional token.
|
||||
- **Embedded daemon** — spawns a local `hindsight-embed` daemon on this machine. Prompts for the LLM provider (OpenAI / Anthropic / Gemini / Groq / Claude Code / Codex / Ollama) and the env var that holds the API key.
|
||||
|
||||
Credentials are always written as `SecretRef` objects that reference an environment variable — the key itself never ends up in plaintext on disk. `--ref-source file` and `--ref-source exec` are also supported by OpenClaw for mounted-secret and Vault-style setups; once you want to use them, set them via `openclaw config set` (see below).
|
||||
|
||||
### Manual configuration (without the wizard)
|
||||
|
||||
The wizard is a convenience wrapper — all of the same fields can be set directly with `openclaw config set`:
|
||||
|
||||
```bash
|
||||
# Embedded daemon with OpenAI
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider openai
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.llmApiKey \
|
||||
--ref-source env --ref-provider default --ref-id OPENAI_API_KEY
|
||||
|
||||
# Or: Claude Code (no API key needed)
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.llmProvider claude-code
|
||||
|
||||
# Or: point at an external Hindsight API
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.hindsightApiUrl https://mcp.hindsight.example.com
|
||||
openclaw config set plugins.entries.hindsight-openclaw.config.hindsightApiToken \
|
||||
--ref-source env --ref-id HINDSIGHT_API_TOKEN
|
||||
```
|
||||
|
||||
## Migrating from 0.5.x
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Installing Hindsight Memory Plugin for OpenClaw..."
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
INSTALL_DIR="$HOME/.openclaw/extensions/hindsight-openclaw"
|
||||
|
||||
# Check Node version
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "❌ Node.js not found. Please install Node.js 22+"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the plugin
|
||||
echo "📦 Building plugin..."
|
||||
cd "$SCRIPT_DIR"
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# Deploy to Clawdbot extensions
|
||||
echo "📂 Deploying to $INSTALL_DIR..."
|
||||
rm -rf "$INSTALL_DIR"
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
# Copy files
|
||||
cp -r dist package.json openclaw.plugin.json README.md "$INSTALL_DIR/"
|
||||
|
||||
# Install dependencies in deployed location
|
||||
echo "📥 Installing dependencies..."
|
||||
cd "$INSTALL_DIR"
|
||||
npm install
|
||||
|
||||
echo ""
|
||||
echo "✅ Hindsight Memory Plugin installed successfully!"
|
||||
echo ""
|
||||
echo "📋 Next steps:"
|
||||
echo ""
|
||||
echo "1. Make sure you have an OpenAI API key set:"
|
||||
echo " export OPENAI_API_KEY=\"sk-your-key-here\""
|
||||
echo ""
|
||||
echo "2. Enable the plugin:"
|
||||
echo " openclaw plugins enable hindsight-openclaw"
|
||||
echo ""
|
||||
echo "3. Start OpenClaw:"
|
||||
echo " openclaw gateway"
|
||||
echo ""
|
||||
echo "On first start, uvx will automatically download hindsight-embed (no manual install needed)"
|
||||
+64
-19
@@ -9,11 +9,13 @@
|
||||
"version": "0.5.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vectorize-io/hindsight-all": "file:../../hindsight-all-npm",
|
||||
"@vectorize-io/hindsight-client": "file:../../hindsight-clients/typescript"
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@vectorize-io/hindsight-all": "^0.1.0",
|
||||
"@vectorize-io/hindsight-client": "^0.5.0"
|
||||
},
|
||||
"bin": {
|
||||
"hindsight-openclaw-backfill": "dist/backfill.js"
|
||||
"hindsight-openclaw-backfill": "dist/backfill.js",
|
||||
"hindsight-openclaw-setup": "dist/setup.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
@@ -25,20 +27,6 @@
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"../../hindsight-all-npm": {
|
||||
"name": "@vectorize-io/hindsight-all",
|
||||
"version": "0.5.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"../../hindsight-clients/typescript": {
|
||||
"name": "@vectorize-io/hindsight-client",
|
||||
"version": "0.5.0",
|
||||
@@ -53,6 +41,28 @@
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/core": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz",
|
||||
"integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-wrap-ansi": "^0.1.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz",
|
||||
"integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/core": "1.2.0",
|
||||
"fast-string-width": "^1.1.0",
|
||||
"fast-wrap-ansi": "^0.1.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
|
||||
@@ -916,8 +926,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vectorize-io/hindsight-all": {
|
||||
"resolved": "../../hindsight-all-npm",
|
||||
"link": true
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vectorize-io/hindsight-all/-/hindsight-all-0.1.0.tgz",
|
||||
"integrity": "sha512-7UDlnmerKla1YZm/vVEFO9C98s8APM2QCLxByuM8qBZ97DqxB9WEFxZ8flApwAlvk0kmoBFTAsRxsGlAQAd/gg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vectorize-io/hindsight-client": {
|
||||
"resolved": "../../hindsight-clients/typescript",
|
||||
@@ -1166,6 +1181,30 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-string-truncated-width": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz",
|
||||
"integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-string-width": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz",
|
||||
"integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-string-truncated-width": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-wrap-ansi": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz",
|
||||
"integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-string-width": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1636,6 +1675,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"hindsight-openclaw-backfill": "dist/backfill.js"
|
||||
"hindsight-openclaw-backfill": "dist/backfill.js",
|
||||
"hindsight-openclaw-setup": "dist/setup.js"
|
||||
},
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
@@ -44,6 +45,7 @@
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@vectorize-io/hindsight-client": "^0.5.0",
|
||||
"@vectorize-io/hindsight-all": "^0.1.0"
|
||||
},
|
||||
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# End-to-end smoke test for the Hindsight OpenClaw plugin.
|
||||
#
|
||||
# What it verifies:
|
||||
# 1. The plugin tarball installs cleanly via `openclaw plugins install`
|
||||
# WITHOUT --dangerously-force-unsafe-install (install scanner reports
|
||||
# zero findings).
|
||||
# 2. Workspace deps (@vectorize-io/hindsight-all, hindsight-client) resolve
|
||||
# from the npm registry into the extracted extension's node_modules.
|
||||
# 3. The non-interactive `hindsight-openclaw-setup` wizard writes a valid
|
||||
# openclaw.json plugin config for each of the three modes.
|
||||
# 4. `openclaw config validate` + `openclaw plugins doctor` pass after each
|
||||
# setup run.
|
||||
# 5. The wizard rejects invalid flag combinations with a non-zero exit code.
|
||||
#
|
||||
# What it does NOT do:
|
||||
# - Start an openclaw gateway or run agent turns. Those are covered by the
|
||||
# existing integration tests (`npm run test:integration`) which exercise
|
||||
# the plugin's hook handlers directly against a real Hindsight API via a
|
||||
# mock MoltbotPluginAPI. This script targets the install / config path
|
||||
# that integration tests can't cover (because they bypass the CLI).
|
||||
#
|
||||
# Safety:
|
||||
# - Backs up ~/.openclaw/openclaw.json before any mutation and restores it
|
||||
# on exit (success or failure).
|
||||
# - Removes any pre-existing ~/.openclaw/extensions/hindsight-openclaw dir
|
||||
# at start so it runs from a clean slate.
|
||||
# - Intended for CI and local dev. In CI the backup/restore is effectively
|
||||
# a no-op because there's no prior state.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/smoke-test.sh # packs a fresh tarball
|
||||
# ./scripts/smoke-test.sh <tarball-path> # uses an existing tarball
|
||||
#
|
||||
# Requirements: openclaw CLI on PATH, node, npm.
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
OPENCLAW_HOME="${OPENCLAW_HOME:-$HOME/.openclaw}"
|
||||
CONFIG_PATH="$OPENCLAW_HOME/openclaw.json"
|
||||
EXT_DIR="$OPENCLAW_HOME/extensions/hindsight-openclaw"
|
||||
HINDSIGHT_API_URL="${HINDSIGHT_API_URL:-http://127.0.0.1:7777}"
|
||||
CONFIG_BACKUP=""
|
||||
EXT_BACKUP=""
|
||||
TARBALL=""
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { printf "${GREEN}[smoke-test]${NC} %s\n" "$*"; }
|
||||
warn() { printf "${YELLOW}[smoke-test]${NC} %s\n" "$*" >&2; }
|
||||
fail() { printf "${RED}[smoke-test FAIL]${NC} %s\n" "$*" >&2; exit 1; }
|
||||
|
||||
require() {
|
||||
command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
log "cleaning up"
|
||||
# Always uninstall / remove the smoke-test install first so we start from a
|
||||
# known-clean state before attempting restore.
|
||||
yes 2>/dev/null | openclaw plugins uninstall hindsight-openclaw >/dev/null 2>&1 || true
|
||||
rm -rf "$EXT_DIR"
|
||||
|
||||
# Restore the user's openclaw.json if we backed it up.
|
||||
if [[ -n "$CONFIG_BACKUP" && -f "$CONFIG_BACKUP" ]]; then
|
||||
mv "$CONFIG_BACKUP" "$CONFIG_PATH"
|
||||
log "restored $CONFIG_PATH from backup"
|
||||
fi
|
||||
# Restore the extension dir if we backed it up — keeps config and files
|
||||
# consistent on dev machines that had the plugin installed pre-run.
|
||||
if [[ -n "$EXT_BACKUP" && -d "$EXT_BACKUP" ]]; then
|
||||
mv "$EXT_BACKUP" "$EXT_DIR"
|
||||
log "restored $EXT_DIR from backup"
|
||||
fi
|
||||
|
||||
# Clean up the tarball only if we packed it ourselves.
|
||||
if [[ -n "$TARBALL" && "$TARBALL" == "$PLUGIN_DIR"/vectorize-io-hindsight-openclaw-*.tgz ]]; then
|
||||
rm -f "$TARBALL"
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
run_setup_mode() {
|
||||
local label="$1"
|
||||
shift
|
||||
log "running setup → $label"
|
||||
if ! node "$EXT_DIR/dist/setup.js" --config-path "$CONFIG_PATH" "$@" >/dev/null; then
|
||||
fail "hindsight-openclaw-setup --mode failed for: $label"
|
||||
fi
|
||||
if ! openclaw config validate >/dev/null 2>&1; then
|
||||
openclaw config validate >&2 || true
|
||||
fail "openclaw config validate failed after: $label"
|
||||
fi
|
||||
# `openclaw plugins doctor` can print diagnostics for UNRELATED bundled
|
||||
# plugins (e.g. ollama double-registration in clean CI envs). Only fail if
|
||||
# doctor surfaces something that specifically names hindsight, or if the
|
||||
# command itself exits non-zero.
|
||||
local doctor_out
|
||||
if ! doctor_out="$(openclaw plugins doctor 2>&1)"; then
|
||||
printf '%s\n' "$doctor_out" >&2
|
||||
fail "openclaw plugins doctor exited non-zero after: $label"
|
||||
fi
|
||||
if printf '%s' "$doctor_out" | grep -iE 'hindsight.*(fail|error|not loaded)|(fail|error).*hindsight' >/dev/null; then
|
||||
printf '%s\n' "$doctor_out" >&2
|
||||
fail "plugins doctor reported hindsight-specific issues after: $label"
|
||||
fi
|
||||
log " ✓ $label → config valid + doctor clean"
|
||||
}
|
||||
|
||||
get_config_value() {
|
||||
openclaw config get "$1" 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
main() {
|
||||
require openclaw
|
||||
require node
|
||||
require npm
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
# Back up any existing openclaw.json + extension dir (no-op in CI).
|
||||
if [[ -f "$CONFIG_PATH" ]]; then
|
||||
CONFIG_BACKUP="$CONFIG_PATH.smoke-test-backup"
|
||||
cp "$CONFIG_PATH" "$CONFIG_BACKUP"
|
||||
log "backed up $CONFIG_PATH → $CONFIG_BACKUP"
|
||||
fi
|
||||
if [[ -d "$EXT_DIR" ]]; then
|
||||
EXT_BACKUP="$EXT_DIR.smoke-test-backup"
|
||||
rm -rf "$EXT_BACKUP"
|
||||
mv "$EXT_DIR" "$EXT_BACKUP"
|
||||
log "backed up $EXT_DIR → $EXT_BACKUP"
|
||||
fi
|
||||
|
||||
# Start from a clean slate after backups are in place.
|
||||
log "clearing any pre-existing hindsight-openclaw install"
|
||||
yes 2>/dev/null | openclaw plugins uninstall hindsight-openclaw >/dev/null 2>&1 || true
|
||||
rm -rf "$EXT_DIR"
|
||||
|
||||
# Pack the tarball unless one was provided.
|
||||
if [[ $# -gt 0 && -n "$1" ]]; then
|
||||
TARBALL="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
|
||||
log "using provided tarball: $TARBALL"
|
||||
else
|
||||
log "packing plugin tarball…"
|
||||
(
|
||||
cd "$PLUGIN_DIR"
|
||||
npm run clean --silent
|
||||
npm run build --silent
|
||||
npm pack --silent >/dev/null
|
||||
)
|
||||
TARBALL="$(ls -t "$PLUGIN_DIR"/vectorize-io-hindsight-openclaw-*.tgz | head -1)"
|
||||
log "packed: $TARBALL"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Phase 1 — tarball install (no --dangerously-force-unsafe-install)
|
||||
# -------------------------------------------------------------------------
|
||||
log "installing plugin from tarball (no --dangerously-force-unsafe-install)…"
|
||||
local install_log
|
||||
install_log="$(mktemp)"
|
||||
if ! openclaw plugins install "$TARBALL" >"$install_log" 2>&1; then
|
||||
cat "$install_log" >&2
|
||||
fail "openclaw plugins install failed"
|
||||
fi
|
||||
if grep -qi "dangerous code patterns detected" "$install_log"; then
|
||||
cat "$install_log" >&2
|
||||
fail "install scanner reported dangerous code patterns"
|
||||
fi
|
||||
if ! grep -q "Installed plugin: hindsight-openclaw" "$install_log"; then
|
||||
cat "$install_log" >&2
|
||||
fail "plugin install did not report success"
|
||||
fi
|
||||
rm -f "$install_log"
|
||||
log "✓ scanner-clean install succeeded"
|
||||
|
||||
# Confirm deps resolved from npm.
|
||||
if [[ ! -d "$EXT_DIR/node_modules/@vectorize-io/hindsight-all" ]]; then
|
||||
fail "expected @vectorize-io/hindsight-all in installed extension"
|
||||
fi
|
||||
if [[ ! -d "$EXT_DIR/node_modules/@vectorize-io/hindsight-client" ]]; then
|
||||
fail "expected @vectorize-io/hindsight-client in installed extension"
|
||||
fi
|
||||
log "✓ workspace deps resolved from registry"
|
||||
|
||||
[[ -f "$EXT_DIR/dist/setup.js" ]] || fail "setup.js missing in installed extension"
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Phase 2 — non-interactive setup for each mode
|
||||
# -------------------------------------------------------------------------
|
||||
run_setup_mode "cloud (default URL)" \
|
||||
--mode cloud --token-env HINDSIGHT_CLOUD_TOKEN
|
||||
[[ "$(get_config_value plugins.entries.hindsight-openclaw.config.hindsightApiUrl)" == "https://api.hindsight.vectorize.io" ]] \
|
||||
|| fail "cloud mode: hindsightApiUrl not set to the default URL"
|
||||
|
||||
run_setup_mode "external API (no auth)" \
|
||||
--mode api --api-url "$HINDSIGHT_API_URL" --no-token
|
||||
[[ "$(get_config_value plugins.entries.hindsight-openclaw.config.hindsightApiUrl)" == "$HINDSIGHT_API_URL" ]] \
|
||||
|| fail "api mode: hindsightApiUrl did not roundtrip"
|
||||
|
||||
run_setup_mode "embedded (openai with model override)" \
|
||||
--mode embedded --provider openai --api-key-env OPENAI_API_KEY --model gpt-4o-mini
|
||||
[[ "$(get_config_value plugins.entries.hindsight-openclaw.config.llmProvider)" == "openai" ]] \
|
||||
|| fail "embedded mode: llmProvider not set to openai"
|
||||
[[ "$(get_config_value plugins.entries.hindsight-openclaw.config.llmModel)" == "gpt-4o-mini" ]] \
|
||||
|| fail "embedded mode: llmModel not set to gpt-4o-mini"
|
||||
|
||||
run_setup_mode "embedded (claude-code, no key)" \
|
||||
--mode embedded --provider claude-code
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Phase 3 — negative tests: bad CLI args must fail fast
|
||||
# -------------------------------------------------------------------------
|
||||
log "verifying setup wizard rejects bad args…"
|
||||
if node "$EXT_DIR/dist/setup.js" --config-path "$CONFIG_PATH" --mode cloud 2>/dev/null; then
|
||||
fail "cloud mode without --token-env should have failed"
|
||||
fi
|
||||
if node "$EXT_DIR/dist/setup.js" --config-path "$CONFIG_PATH" --mode api --api-url "$HINDSIGHT_API_URL" --token-env TOK --no-token 2>/dev/null; then
|
||||
fail "--token-env + --no-token together should have failed"
|
||||
fi
|
||||
if node "$EXT_DIR/dist/setup.js" --config-path "$CONFIG_PATH" --mode embedded --provider openai 2>/dev/null; then
|
||||
fail "openai without --api-key-env should have failed"
|
||||
fi
|
||||
log "✓ bad args rejected"
|
||||
|
||||
log "🎉 all smoke tests passed"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
HINDSIGHT_CLOUD_URL,
|
||||
PLUGIN_ID,
|
||||
applyApiMode,
|
||||
applyCloudMode,
|
||||
applyEmbeddedMode,
|
||||
defaultApiKeyEnvVar,
|
||||
ensurePluginConfig,
|
||||
envSecretRef,
|
||||
isValidEnvVarName,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
summarizeApi,
|
||||
summarizeCloud,
|
||||
summarizeEmbedded,
|
||||
type OpenClawConfigShape,
|
||||
} from './setup-lib.js';
|
||||
|
||||
describe('isValidEnvVarName', () => {
|
||||
it('accepts UPPER_SNAKE_CASE', () => {
|
||||
expect(isValidEnvVarName('OPENAI_API_KEY')).toBe(true);
|
||||
expect(isValidEnvVarName('HINDSIGHT_CLOUD_TOKEN')).toBe(true);
|
||||
expect(isValidEnvVarName('A')).toBe(true);
|
||||
});
|
||||
it('rejects lowercase, leading digits, empty, and undefined', () => {
|
||||
expect(isValidEnvVarName('lowercase')).toBe(false);
|
||||
expect(isValidEnvVarName('1LEADING_DIGIT')).toBe(false);
|
||||
expect(isValidEnvVarName('')).toBe(false);
|
||||
expect(isValidEnvVarName(undefined)).toBe(false);
|
||||
expect(isValidEnvVarName('has-dash')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultApiKeyEnvVar', () => {
|
||||
it('UPPERs and snake_cases the provider id', () => {
|
||||
expect(defaultApiKeyEnvVar('openai')).toBe('OPENAI_API_KEY');
|
||||
expect(defaultApiKeyEnvVar('claude-code')).toBe('CLAUDE_CODE_API_KEY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('envSecretRef', () => {
|
||||
it('builds a default-provider env SecretRef', () => {
|
||||
expect(envSecretRef('OPENAI_API_KEY')).toEqual({
|
||||
source: 'env',
|
||||
provider: 'default',
|
||||
id: 'OPENAI_API_KEY',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensurePluginConfig', () => {
|
||||
it('initializes the hindsight-openclaw entry on an empty config', () => {
|
||||
const cfg: OpenClawConfigShape = {};
|
||||
const pc = ensurePluginConfig(cfg);
|
||||
expect(cfg.plugins?.entries?.[PLUGIN_ID]).toEqual({ enabled: true, config: {} });
|
||||
expect(pc).toBe(cfg.plugins?.entries?.[PLUGIN_ID]?.config);
|
||||
});
|
||||
|
||||
it('preserves existing config values and forces enabled=true', () => {
|
||||
const cfg: OpenClawConfigShape = {
|
||||
plugins: {
|
||||
entries: {
|
||||
[PLUGIN_ID]: {
|
||||
enabled: false,
|
||||
config: { llmProvider: 'openai' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const pc = ensurePluginConfig(cfg);
|
||||
expect(cfg.plugins?.entries?.[PLUGIN_ID]?.enabled).toBe(true);
|
||||
expect(pc.llmProvider).toBe('openai');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyCloudMode', () => {
|
||||
it('writes the default URL and a SecretRef, stripping local LLM state', () => {
|
||||
const pc: Record<string, unknown> = {
|
||||
llmProvider: 'openai',
|
||||
llmApiKey: { source: 'env', provider: 'default', id: 'OPENAI_API_KEY' },
|
||||
llmModel: 'gpt-4o-mini',
|
||||
llmBaseUrl: 'https://openrouter.ai/api/v1',
|
||||
};
|
||||
applyCloudMode(pc, { tokenEnvVar: 'HINDSIGHT_CLOUD_TOKEN' });
|
||||
expect(pc.hindsightApiUrl).toBe(HINDSIGHT_CLOUD_URL);
|
||||
expect(pc.hindsightApiToken).toEqual({
|
||||
source: 'env',
|
||||
provider: 'default',
|
||||
id: 'HINDSIGHT_CLOUD_TOKEN',
|
||||
});
|
||||
expect(pc.llmProvider).toBeUndefined();
|
||||
expect(pc.llmApiKey).toBeUndefined();
|
||||
expect(pc.llmModel).toBeUndefined();
|
||||
expect(pc.llmBaseUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('honours an overridden apiUrl', () => {
|
||||
const pc: Record<string, unknown> = {};
|
||||
applyCloudMode(pc, {
|
||||
apiUrl: 'https://cloud.example.com',
|
||||
tokenEnvVar: 'CLOUD_TOKEN',
|
||||
});
|
||||
expect(pc.hindsightApiUrl).toBe('https://cloud.example.com');
|
||||
expect((pc.hindsightApiToken as { id: string }).id).toBe('CLOUD_TOKEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyApiMode', () => {
|
||||
it('writes the URL without a token when none is provided', () => {
|
||||
const pc: Record<string, unknown> = {
|
||||
llmProvider: 'openai',
|
||||
hindsightApiToken: { source: 'env', provider: 'default', id: 'STALE_TOKEN' },
|
||||
};
|
||||
applyApiMode(pc, { apiUrl: 'https://mcp.example.com' });
|
||||
expect(pc.hindsightApiUrl).toBe('https://mcp.example.com');
|
||||
expect(pc.hindsightApiToken).toBeUndefined();
|
||||
expect(pc.llmProvider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes a SecretRef when a token env var is provided', () => {
|
||||
const pc: Record<string, unknown> = {};
|
||||
applyApiMode(pc, { apiUrl: 'https://mcp.example.com', tokenEnvVar: 'MY_TOKEN' });
|
||||
expect(pc.hindsightApiToken).toEqual({
|
||||
source: 'env',
|
||||
provider: 'default',
|
||||
id: 'MY_TOKEN',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an empty token env var as "no token"', () => {
|
||||
const pc: Record<string, unknown> = {};
|
||||
applyApiMode(pc, { apiUrl: 'https://mcp.example.com', tokenEnvVar: ' ' });
|
||||
expect(pc.hindsightApiToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyEmbeddedMode', () => {
|
||||
it('writes llmProvider + SecretRef for providers that require a key', () => {
|
||||
const pc: Record<string, unknown> = {
|
||||
hindsightApiUrl: 'https://stale.example.com',
|
||||
hindsightApiToken: { source: 'env', provider: 'default', id: 'STALE' },
|
||||
};
|
||||
applyEmbeddedMode(pc, { llmProvider: 'openai', apiKeyEnvVar: 'OPENAI_API_KEY' });
|
||||
expect(pc.llmProvider).toBe('openai');
|
||||
expect(pc.llmApiKey).toEqual({
|
||||
source: 'env',
|
||||
provider: 'default',
|
||||
id: 'OPENAI_API_KEY',
|
||||
});
|
||||
expect(pc.hindsightApiUrl).toBeUndefined();
|
||||
expect(pc.hindsightApiToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits llmApiKey for no-key providers like claude-code', () => {
|
||||
const pc: Record<string, unknown> = { llmApiKey: { source: 'env', provider: 'default', id: 'STALE' } };
|
||||
applyEmbeddedMode(pc, { llmProvider: 'claude-code' });
|
||||
expect(pc.llmProvider).toBe('claude-code');
|
||||
expect(pc.llmApiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when a key-requiring provider is given without an env var name', () => {
|
||||
const pc: Record<string, unknown> = {};
|
||||
expect(() => applyEmbeddedMode(pc, { llmProvider: 'openai' })).toThrow(/requires an apiKeyEnvVar/);
|
||||
});
|
||||
|
||||
it('persists llmModel when provided and clears it when absent', () => {
|
||||
const pc: Record<string, unknown> = { llmModel: 'legacy-model' };
|
||||
applyEmbeddedMode(pc, { llmProvider: 'ollama', llmModel: 'llama3' });
|
||||
expect(pc.llmModel).toBe('llama3');
|
||||
|
||||
applyEmbeddedMode(pc, { llmProvider: 'ollama' });
|
||||
expect(pc.llmModel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarize*', () => {
|
||||
it('produces human-readable mode summaries', () => {
|
||||
expect(summarizeCloud({ tokenEnvVar: 'HINDSIGHT_CLOUD_TOKEN' })).toBe(
|
||||
'Cloud → https://api.hindsight.vectorize.io (token from ${HINDSIGHT_CLOUD_TOKEN})',
|
||||
);
|
||||
expect(summarizeApi({ apiUrl: 'https://api.example.com', tokenEnvVar: 'T' })).toBe(
|
||||
'External API → https://api.example.com (authenticated)',
|
||||
);
|
||||
expect(summarizeApi({ apiUrl: 'https://api.example.com' })).toBe(
|
||||
'External API → https://api.example.com (no auth)',
|
||||
);
|
||||
expect(summarizeEmbedded({ llmProvider: 'openai', apiKeyEnvVar: 'X' })).toBe(
|
||||
'Embedded daemon → openai (key via SecretRef)',
|
||||
);
|
||||
expect(summarizeEmbedded({ llmProvider: 'claude-code' })).toBe(
|
||||
'Embedded daemon → claude-code',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig / saveConfig', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'hindsight-openclaw-setup-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns an empty object when the config file does not exist', async () => {
|
||||
const cfg = await loadConfig(join(tmpDir, 'missing.json'));
|
||||
expect(cfg).toEqual({});
|
||||
});
|
||||
|
||||
it('round-trips a config via atomic save and load', async () => {
|
||||
const path = join(tmpDir, 'openclaw.json');
|
||||
const cfg: OpenClawConfigShape = {
|
||||
plugins: {
|
||||
entries: {
|
||||
[PLUGIN_ID]: {
|
||||
enabled: true,
|
||||
config: { llmProvider: 'openai' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
await saveConfig(path, cfg);
|
||||
const roundtrip = await loadConfig(path);
|
||||
expect(roundtrip).toEqual(cfg);
|
||||
// File should end in a newline (cosmetic — nice for diffs/editors).
|
||||
const raw = await readFile(path, 'utf8');
|
||||
expect(raw.endsWith('\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('creates the parent directory if it does not exist', async () => {
|
||||
const path = join(tmpDir, 'nested', 'subdir', 'openclaw.json');
|
||||
await saveConfig(path, { hello: 'world' });
|
||||
const roundtrip = await loadConfig(path);
|
||||
expect(roundtrip).toEqual({ hello: 'world' });
|
||||
});
|
||||
|
||||
it('does not leave the .tmp file behind on success', async () => {
|
||||
const path = join(tmpDir, 'openclaw.json');
|
||||
await saveConfig(path, {});
|
||||
const raw = await readFile(path, 'utf8');
|
||||
expect(raw).toContain('{}');
|
||||
// Ensure the rename cleaned up the temp file.
|
||||
await expect(
|
||||
readFile(`${path}.tmp-1`, 'utf8').catch(() => 'missing'),
|
||||
).resolves.toBe('missing');
|
||||
});
|
||||
|
||||
it('throws a useful error when the config file is invalid JSON', async () => {
|
||||
const path = join(tmpDir, 'bad.json');
|
||||
await writeFile(path, '{ not json', 'utf8');
|
||||
await expect(loadConfig(path)).rejects.toThrow(/Failed to read/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Pure helpers behind the Hindsight OpenClaw setup wizard. Kept separate from
|
||||
* setup.ts (the @clack/prompts entry point) so the mechanical bits are easy to
|
||||
* unit test without simulating an interactive terminal.
|
||||
*
|
||||
* Scanner-safe: imports no subprocess APIs and does not read any environment
|
||||
* variable. All config writing is an atomic rename over the OpenClaw config JSON.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir, rename } from 'fs/promises';
|
||||
import { homedir } from 'os';
|
||||
import { join, dirname } from 'path';
|
||||
|
||||
export const PLUGIN_ID = 'hindsight-openclaw';
|
||||
|
||||
/**
|
||||
* Default Hindsight Cloud endpoint. Update this when the hosted service URL is
|
||||
* finalized, or users can override it at the prompt.
|
||||
*/
|
||||
export const HINDSIGHT_CLOUD_URL = 'https://api.hindsight.vectorize.io';
|
||||
|
||||
export const DEFAULT_OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
export interface SecretRef {
|
||||
source: 'env' | 'file' | 'exec';
|
||||
provider: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface PluginEntry {
|
||||
enabled?: boolean;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface OpenClawConfigShape {
|
||||
plugins?: {
|
||||
entries?: Record<string, PluginEntry>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type SetupMode = 'cloud' | 'api' | 'embedded';
|
||||
|
||||
export const NO_KEY_PROVIDERS: ReadonlySet<string> = new Set([
|
||||
'claude-code',
|
||||
'openai-codex',
|
||||
'ollama',
|
||||
]);
|
||||
|
||||
export async function loadConfig(path: string): Promise<OpenClawConfigShape> {
|
||||
try {
|
||||
const raw = await readFile(path, 'utf8');
|
||||
return JSON.parse(raw) as OpenClawConfigShape;
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return {};
|
||||
throw new Error(
|
||||
`Failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveConfig(path: string, cfg: OpenClawConfigShape): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const serialized = `${JSON.stringify(cfg, null, 2)}\n`;
|
||||
const tmpPath = `${path}.tmp-${Date.now()}`;
|
||||
await writeFile(tmpPath, serialized, 'utf8');
|
||||
await rename(tmpPath, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a `plugins.entries["hindsight-openclaw"].config` object exists, set
|
||||
* `enabled: true`, and return the mutable config record. Idempotent — safe to
|
||||
* call against a fresh or already-configured OpenClaw config.
|
||||
*/
|
||||
export function ensurePluginConfig(cfg: OpenClawConfigShape): Record<string, unknown> {
|
||||
const plugins = (cfg.plugins ??= {});
|
||||
const entries = (plugins.entries ??= {});
|
||||
const entry = (entries[PLUGIN_ID] ??= { enabled: true });
|
||||
entry.enabled = true;
|
||||
return (entry.config ??= {});
|
||||
}
|
||||
|
||||
export function envSecretRef(id: string): SecretRef {
|
||||
return { source: 'env', provider: 'default', id };
|
||||
}
|
||||
|
||||
export function clearCloudFields(pluginConfig: Record<string, unknown>): void {
|
||||
delete pluginConfig.hindsightApiUrl;
|
||||
delete pluginConfig.hindsightApiToken;
|
||||
}
|
||||
|
||||
export function clearLocalLlmFields(pluginConfig: Record<string, unknown>): void {
|
||||
delete pluginConfig.llmProvider;
|
||||
delete pluginConfig.llmModel;
|
||||
delete pluginConfig.llmApiKey;
|
||||
delete pluginConfig.llmBaseUrl;
|
||||
}
|
||||
|
||||
const ENV_VAR_RE = /^[A-Z][A-Z0-9_]*$/;
|
||||
|
||||
export function isValidEnvVarName(value: string | undefined): boolean {
|
||||
return !!value && ENV_VAR_RE.test(value.trim());
|
||||
}
|
||||
|
||||
export function defaultApiKeyEnvVar(provider: string): string {
|
||||
return `${provider.toUpperCase().replace(/-/g, '_')}_API_KEY`;
|
||||
}
|
||||
|
||||
export interface CloudSetupInput {
|
||||
apiUrl?: string;
|
||||
tokenEnvVar: string;
|
||||
}
|
||||
|
||||
export interface ApiSetupInput {
|
||||
apiUrl: string;
|
||||
tokenEnvVar?: string;
|
||||
}
|
||||
|
||||
export interface EmbeddedSetupInput {
|
||||
llmProvider: string;
|
||||
apiKeyEnvVar?: string;
|
||||
llmModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the Cloud mode to a plugin config in place: sets `hindsightApiUrl` and
|
||||
* a `hindsightApiToken` SecretRef, strips any leftover local-LLM fields so we
|
||||
* don't carry stale credentials across mode switches.
|
||||
*/
|
||||
export function applyCloudMode(
|
||||
pluginConfig: Record<string, unknown>,
|
||||
input: CloudSetupInput,
|
||||
): void {
|
||||
clearLocalLlmFields(pluginConfig);
|
||||
pluginConfig.hindsightApiUrl = (input.apiUrl ?? HINDSIGHT_CLOUD_URL).trim();
|
||||
pluginConfig.hindsightApiToken = envSecretRef(input.tokenEnvVar.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the external-API mode to a plugin config in place: sets a required
|
||||
* `hindsightApiUrl`, optional `hindsightApiToken` SecretRef, and strips any
|
||||
* leftover local-LLM fields so mode switches don't carry stale state.
|
||||
*/
|
||||
export function applyApiMode(
|
||||
pluginConfig: Record<string, unknown>,
|
||||
input: ApiSetupInput,
|
||||
): void {
|
||||
clearLocalLlmFields(pluginConfig);
|
||||
pluginConfig.hindsightApiUrl = input.apiUrl.trim();
|
||||
if (input.tokenEnvVar && input.tokenEnvVar.trim().length > 0) {
|
||||
pluginConfig.hindsightApiToken = envSecretRef(input.tokenEnvVar.trim());
|
||||
} else {
|
||||
delete pluginConfig.hindsightApiToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the embedded-daemon mode to a plugin config in place: sets
|
||||
* `llmProvider`, optional `llmApiKey` SecretRef, optional `llmModel`, and
|
||||
* strips any external-API settings so mode switches don't carry stale state.
|
||||
*/
|
||||
export function applyEmbeddedMode(
|
||||
pluginConfig: Record<string, unknown>,
|
||||
input: EmbeddedSetupInput,
|
||||
): void {
|
||||
clearCloudFields(pluginConfig);
|
||||
pluginConfig.llmProvider = input.llmProvider;
|
||||
if (NO_KEY_PROVIDERS.has(input.llmProvider)) {
|
||||
delete pluginConfig.llmApiKey;
|
||||
} else {
|
||||
if (!input.apiKeyEnvVar) {
|
||||
throw new Error(`llmProvider "${input.llmProvider}" requires an apiKeyEnvVar`);
|
||||
}
|
||||
pluginConfig.llmApiKey = envSecretRef(input.apiKeyEnvVar.trim());
|
||||
}
|
||||
if (input.llmModel && input.llmModel.trim().length > 0) {
|
||||
pluginConfig.llmModel = input.llmModel.trim();
|
||||
} else {
|
||||
delete pluginConfig.llmModel;
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeCloud(input: CloudSetupInput): string {
|
||||
const url = (input.apiUrl ?? HINDSIGHT_CLOUD_URL).trim();
|
||||
return `Cloud → ${url} (token from \${${input.tokenEnvVar.trim()}})`;
|
||||
}
|
||||
|
||||
export function summarizeApi(input: ApiSetupInput): string {
|
||||
const suffix = input.tokenEnvVar ? ' (authenticated)' : ' (no auth)';
|
||||
return `External API → ${input.apiUrl.trim()}${suffix}`;
|
||||
}
|
||||
|
||||
export function summarizeEmbedded(input: EmbeddedSetupInput): string {
|
||||
const keyHint = NO_KEY_PROVIDERS.has(input.llmProvider) ? '' : ' (key via SecretRef)';
|
||||
return `Embedded daemon → ${input.llmProvider}${keyHint}`;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtemp, rm, readFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { parseCliArgs, runNonInteractive } from './setup.js';
|
||||
import { PLUGIN_ID, type OpenClawConfigShape } from './setup-lib.js';
|
||||
|
||||
describe('parseCliArgs', () => {
|
||||
it('returns defaults for no args', () => {
|
||||
const args = parseCliArgs([]);
|
||||
expect(args).toEqual({ help: false, noToken: false });
|
||||
});
|
||||
|
||||
it('parses --help', () => {
|
||||
expect(parseCliArgs(['--help']).help).toBe(true);
|
||||
expect(parseCliArgs(['-h']).help).toBe(true);
|
||||
});
|
||||
|
||||
it('parses --config-path and positional config path', () => {
|
||||
expect(parseCliArgs(['--config-path', '/tmp/a.json']).configPath).toBe('/tmp/a.json');
|
||||
expect(parseCliArgs(['/tmp/b.json']).positional).toBe('/tmp/b.json');
|
||||
});
|
||||
|
||||
it('parses cloud-mode flags', () => {
|
||||
const args = parseCliArgs([
|
||||
'--mode', 'cloud',
|
||||
'--api-url', 'https://cloud.example.com',
|
||||
'--token-env', 'HINDSIGHT_CLOUD_TOKEN',
|
||||
]);
|
||||
expect(args).toMatchObject({
|
||||
mode: 'cloud',
|
||||
apiUrl: 'https://cloud.example.com',
|
||||
tokenEnv: 'HINDSIGHT_CLOUD_TOKEN',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses api-mode flags with --no-token', () => {
|
||||
const args = parseCliArgs([
|
||||
'--mode', 'api',
|
||||
'--api-url', 'https://mcp.example.com',
|
||||
'--no-token',
|
||||
]);
|
||||
expect(args).toMatchObject({
|
||||
mode: 'api',
|
||||
apiUrl: 'https://mcp.example.com',
|
||||
noToken: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses embedded-mode flags', () => {
|
||||
const args = parseCliArgs([
|
||||
'--mode', 'embedded',
|
||||
'--provider', 'openai',
|
||||
'--api-key-env', 'OPENAI_API_KEY',
|
||||
'--model', 'gpt-4o-mini',
|
||||
]);
|
||||
expect(args).toMatchObject({
|
||||
mode: 'embedded',
|
||||
provider: 'openai',
|
||||
apiKeyEnv: 'OPENAI_API_KEY',
|
||||
model: 'gpt-4o-mini',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid --mode', () => {
|
||||
expect(() => parseCliArgs(['--mode', 'bogus'])).toThrow(/invalid --mode/);
|
||||
});
|
||||
|
||||
it('rejects unknown flags', () => {
|
||||
expect(() => parseCliArgs(['--what-is-this'])).toThrow(/unknown argument/);
|
||||
});
|
||||
|
||||
it('rejects flags missing a value', () => {
|
||||
expect(() => parseCliArgs(['--mode'])).toThrow(/missing value for --mode/);
|
||||
expect(() => parseCliArgs(['--api-url'])).toThrow(/missing value for --api-url/);
|
||||
});
|
||||
|
||||
it('rejects extra positional args', () => {
|
||||
expect(() => parseCliArgs(['/tmp/a.json', '/tmp/b.json'])).toThrow(/extra positional/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runNonInteractive', () => {
|
||||
let tmpDir: string;
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'hindsight-openclaw-setup-cli-'));
|
||||
configPath = join(tmpDir, 'openclaw.json');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function readBack(): Promise<OpenClawConfigShape> {
|
||||
return JSON.parse(await readFile(configPath, 'utf8')) as OpenClawConfigShape;
|
||||
}
|
||||
|
||||
it('writes a cloud-mode config with the default URL', async () => {
|
||||
const args = parseCliArgs(['--mode', 'cloud', '--token-env', 'HINDSIGHT_CLOUD_TOKEN']);
|
||||
const result = await runNonInteractive(args, configPath);
|
||||
expect(result.summary).toContain('Cloud');
|
||||
const cfg = await readBack();
|
||||
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
|
||||
expect(pc.hindsightApiUrl).toBe('https://api.hindsight.vectorize.io');
|
||||
expect(pc.hindsightApiToken).toEqual({
|
||||
source: 'env',
|
||||
provider: 'default',
|
||||
id: 'HINDSIGHT_CLOUD_TOKEN',
|
||||
});
|
||||
expect(cfg.plugins?.entries?.[PLUGIN_ID]?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('writes a cloud-mode config with a custom URL', async () => {
|
||||
const args = parseCliArgs([
|
||||
'--mode', 'cloud',
|
||||
'--api-url', 'https://hindsight.custom.example.com',
|
||||
'--token-env', 'MY_TOKEN',
|
||||
]);
|
||||
await runNonInteractive(args, configPath);
|
||||
const cfg = await readBack();
|
||||
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
|
||||
expect(pc.hindsightApiUrl).toBe('https://hindsight.custom.example.com');
|
||||
expect((pc.hindsightApiToken as { id: string }).id).toBe('MY_TOKEN');
|
||||
});
|
||||
|
||||
it('rejects cloud mode without --token-env', async () => {
|
||||
const args = parseCliArgs(['--mode', 'cloud']);
|
||||
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/--token-env/);
|
||||
});
|
||||
|
||||
it('rejects cloud mode with a bad token env var name', async () => {
|
||||
const args = parseCliArgs(['--mode', 'cloud', '--token-env', 'bad-name']);
|
||||
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/UPPER_SNAKE_CASE/);
|
||||
});
|
||||
|
||||
it('writes an api-mode config without token', async () => {
|
||||
const args = parseCliArgs([
|
||||
'--mode', 'api',
|
||||
'--api-url', 'https://mcp.example.com',
|
||||
'--no-token',
|
||||
]);
|
||||
await runNonInteractive(args, configPath);
|
||||
const cfg = await readBack();
|
||||
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
|
||||
expect(pc.hindsightApiUrl).toBe('https://mcp.example.com');
|
||||
expect(pc.hindsightApiToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes an api-mode config with token', async () => {
|
||||
const args = parseCliArgs([
|
||||
'--mode', 'api',
|
||||
'--api-url', 'https://mcp.example.com',
|
||||
'--token-env', 'HINDSIGHT_API_TOKEN',
|
||||
]);
|
||||
await runNonInteractive(args, configPath);
|
||||
const cfg = await readBack();
|
||||
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
|
||||
expect((pc.hindsightApiToken as { id: string }).id).toBe('HINDSIGHT_API_TOKEN');
|
||||
});
|
||||
|
||||
it('rejects api mode without --api-url', async () => {
|
||||
const args = parseCliArgs(['--mode', 'api']);
|
||||
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/--api-url/);
|
||||
});
|
||||
|
||||
it('rejects api mode with conflicting --token-env and --no-token', async () => {
|
||||
const args = parseCliArgs([
|
||||
'--mode', 'api',
|
||||
'--api-url', 'https://mcp.example.com',
|
||||
'--token-env', 'FOO',
|
||||
'--no-token',
|
||||
]);
|
||||
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/cannot both be set/);
|
||||
});
|
||||
|
||||
it('writes an embedded-mode config for openai', async () => {
|
||||
const args = parseCliArgs([
|
||||
'--mode', 'embedded',
|
||||
'--provider', 'openai',
|
||||
'--api-key-env', 'OPENAI_API_KEY',
|
||||
'--model', 'gpt-4o-mini',
|
||||
]);
|
||||
await runNonInteractive(args, configPath);
|
||||
const cfg = await readBack();
|
||||
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
|
||||
expect(pc.llmProvider).toBe('openai');
|
||||
expect((pc.llmApiKey as { id: string }).id).toBe('OPENAI_API_KEY');
|
||||
expect(pc.llmModel).toBe('gpt-4o-mini');
|
||||
expect(pc.hindsightApiUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes an embedded-mode config for a no-key provider', async () => {
|
||||
const args = parseCliArgs(['--mode', 'embedded', '--provider', 'claude-code']);
|
||||
await runNonInteractive(args, configPath);
|
||||
const cfg = await readBack();
|
||||
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
|
||||
expect(pc.llmProvider).toBe('claude-code');
|
||||
expect(pc.llmApiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects embedded mode without --provider', async () => {
|
||||
const args = parseCliArgs(['--mode', 'embedded']);
|
||||
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/--provider/);
|
||||
});
|
||||
|
||||
it('rejects embedded mode with a key-requiring provider but no --api-key-env', async () => {
|
||||
const args = parseCliArgs(['--mode', 'embedded', '--provider', 'openai']);
|
||||
await expect(runNonInteractive(args, configPath)).rejects.toThrow(/--api-key-env/);
|
||||
});
|
||||
|
||||
it('clears stale fields when switching between modes', async () => {
|
||||
// First write an embedded-mode config
|
||||
await runNonInteractive(
|
||||
parseCliArgs(['--mode', 'embedded', '--provider', 'openai', '--api-key-env', 'OPENAI_API_KEY']),
|
||||
configPath,
|
||||
);
|
||||
let cfg = await readBack();
|
||||
expect(cfg.plugins?.entries?.[PLUGIN_ID]?.config?.llmProvider).toBe('openai');
|
||||
|
||||
// Now switch to cloud mode — local LLM fields should be gone
|
||||
await runNonInteractive(
|
||||
parseCliArgs(['--mode', 'cloud', '--token-env', 'HINDSIGHT_CLOUD_TOKEN']),
|
||||
configPath,
|
||||
);
|
||||
cfg = await readBack();
|
||||
const pc = cfg.plugins?.entries?.[PLUGIN_ID]?.config ?? {};
|
||||
expect(pc.llmProvider).toBeUndefined();
|
||||
expect(pc.llmApiKey).toBeUndefined();
|
||||
expect(pc.hindsightApiUrl).toBe('https://api.hindsight.vectorize.io');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,510 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Setup wizard for the Hindsight OpenClaw plugin.
|
||||
*
|
||||
* Two modes of operation:
|
||||
*
|
||||
* 1. Interactive — no flags, just run `hindsight-openclaw-setup`. Walks the
|
||||
* user through picking a mode (Cloud / External API / Embedded daemon)
|
||||
* via @clack/prompts and writes openclaw.json.
|
||||
*
|
||||
* 2. Non-interactive — pass `--mode cloud|api|embedded` plus the relevant
|
||||
* flags for that mode. No prompts, intended for CI and scripted installs.
|
||||
*
|
||||
* Scanner-safe: does not import subprocess APIs and does not read environment
|
||||
* variables directly. Pure config manipulation lives in setup-lib.ts.
|
||||
*/
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import { realpathSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import {
|
||||
DEFAULT_OPENCLAW_CONFIG_PATH,
|
||||
HINDSIGHT_CLOUD_URL,
|
||||
NO_KEY_PROVIDERS,
|
||||
type ApiSetupInput,
|
||||
type CloudSetupInput,
|
||||
type EmbeddedSetupInput,
|
||||
type SetupMode,
|
||||
applyApiMode,
|
||||
applyCloudMode,
|
||||
applyEmbeddedMode,
|
||||
defaultApiKeyEnvVar,
|
||||
ensurePluginConfig,
|
||||
isValidEnvVarName,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
summarizeApi,
|
||||
summarizeCloud,
|
||||
summarizeEmbedded,
|
||||
} from './setup-lib.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ParsedCliArgs {
|
||||
help: boolean;
|
||||
configPath?: string;
|
||||
mode?: SetupMode;
|
||||
apiUrl?: string;
|
||||
tokenEnv?: string;
|
||||
noToken: boolean;
|
||||
provider?: string;
|
||||
apiKeyEnv?: string;
|
||||
model?: string;
|
||||
positional?: string;
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
'Usage: hindsight-openclaw-setup [options] [config-path]',
|
||||
'',
|
||||
'Interactive mode (no flags): walks through a TUI picker for Cloud /',
|
||||
'External API / Embedded daemon and writes the resulting plugin config',
|
||||
`to ${DEFAULT_OPENCLAW_CONFIG_PATH} (or the positional config-path arg).`,
|
||||
'',
|
||||
'Non-interactive mode: pass --mode and the relevant flags to skip the',
|
||||
'TUI. Suitable for CI and scripted setups.',
|
||||
'',
|
||||
'Options:',
|
||||
' --config-path <path> Path to openclaw.json (default: ~/.openclaw/openclaw.json)',
|
||||
' --mode <mode> cloud | api | embedded (enables non-interactive mode)',
|
||||
'',
|
||||
'Cloud mode:',
|
||||
` --api-url <url> Override the Hindsight Cloud URL (default: ${HINDSIGHT_CLOUD_URL})`,
|
||||
' --token-env <VAR> Env var holding the cloud API token (required)',
|
||||
'',
|
||||
'External API mode:',
|
||||
' --api-url <url> Hindsight API URL (required)',
|
||||
' --token-env <VAR> Env var holding the API token (optional)',
|
||||
' --no-token Explicitly disable token auth',
|
||||
'',
|
||||
'Embedded mode:',
|
||||
` --provider <id> LLM provider: ${['openai', 'anthropic', 'gemini', 'groq', ...NO_KEY_PROVIDERS].join(' | ')}`,
|
||||
' --api-key-env <VAR> Env var holding the LLM API key (required unless provider needs no key)',
|
||||
' --model <id> Optional model override (otherwise uses the provider default)',
|
||||
'',
|
||||
' -h, --help Show this help',
|
||||
'',
|
||||
'Examples:',
|
||||
' hindsight-openclaw-setup',
|
||||
' hindsight-openclaw-setup --mode cloud --token-env HINDSIGHT_CLOUD_TOKEN',
|
||||
' hindsight-openclaw-setup --mode api --api-url https://mcp.hindsight.example.com --no-token',
|
||||
' hindsight-openclaw-setup --mode embedded --provider openai --api-key-env OPENAI_API_KEY',
|
||||
' hindsight-openclaw-setup --mode embedded --provider claude-code',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function parseCliArgs(argv: string[]): ParsedCliArgs {
|
||||
const args: ParsedCliArgs = { help: false, noToken: false };
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = () => {
|
||||
const value = argv[++i];
|
||||
if (value === undefined) {
|
||||
throw new Error(`missing value for ${arg}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
switch (arg) {
|
||||
case '-h':
|
||||
case '--help':
|
||||
args.help = true;
|
||||
break;
|
||||
case '--config-path':
|
||||
args.configPath = next();
|
||||
break;
|
||||
case '--mode': {
|
||||
const value = next();
|
||||
if (value !== 'cloud' && value !== 'api' && value !== 'embedded') {
|
||||
throw new Error(`invalid --mode: ${value} (expected cloud | api | embedded)`);
|
||||
}
|
||||
args.mode = value;
|
||||
break;
|
||||
}
|
||||
case '--api-url':
|
||||
args.apiUrl = next();
|
||||
break;
|
||||
case '--token-env':
|
||||
args.tokenEnv = next();
|
||||
break;
|
||||
case '--no-token':
|
||||
args.noToken = true;
|
||||
break;
|
||||
case '--provider':
|
||||
args.provider = next();
|
||||
break;
|
||||
case '--api-key-env':
|
||||
args.apiKeyEnv = next();
|
||||
break;
|
||||
case '--model':
|
||||
args.model = next();
|
||||
break;
|
||||
default:
|
||||
if (arg.startsWith('-')) {
|
||||
throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
if (args.positional) {
|
||||
throw new Error(`unexpected extra positional argument: ${arg}`);
|
||||
}
|
||||
args.positional = arg;
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Non-interactive execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildCloudInput(args: ParsedCliArgs): CloudSetupInput {
|
||||
if (!args.tokenEnv) {
|
||||
throw new Error('--mode cloud requires --token-env <VAR>');
|
||||
}
|
||||
if (!isValidEnvVarName(args.tokenEnv)) {
|
||||
throw new Error(`--token-env must be an UPPER_SNAKE_CASE env var name, got: ${args.tokenEnv}`);
|
||||
}
|
||||
return {
|
||||
apiUrl: args.apiUrl,
|
||||
tokenEnvVar: args.tokenEnv,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApiInput(args: ParsedCliArgs): ApiSetupInput {
|
||||
if (!args.apiUrl) {
|
||||
throw new Error('--mode api requires --api-url <url>');
|
||||
}
|
||||
if (args.tokenEnv && args.noToken) {
|
||||
throw new Error('--token-env and --no-token cannot both be set');
|
||||
}
|
||||
if (args.tokenEnv && !isValidEnvVarName(args.tokenEnv)) {
|
||||
throw new Error(`--token-env must be an UPPER_SNAKE_CASE env var name, got: ${args.tokenEnv}`);
|
||||
}
|
||||
return {
|
||||
apiUrl: args.apiUrl,
|
||||
tokenEnvVar: args.tokenEnv,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmbeddedInput(args: ParsedCliArgs): EmbeddedSetupInput {
|
||||
if (!args.provider) {
|
||||
throw new Error('--mode embedded requires --provider <id>');
|
||||
}
|
||||
const needsKey = !NO_KEY_PROVIDERS.has(args.provider);
|
||||
if (needsKey) {
|
||||
if (!args.apiKeyEnv) {
|
||||
throw new Error(
|
||||
`--provider ${args.provider} requires --api-key-env <VAR> (providers that need no key: ${[...NO_KEY_PROVIDERS].join(', ')})`,
|
||||
);
|
||||
}
|
||||
if (!isValidEnvVarName(args.apiKeyEnv)) {
|
||||
throw new Error(`--api-key-env must be an UPPER_SNAKE_CASE env var name, got: ${args.apiKeyEnv}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
llmProvider: args.provider,
|
||||
apiKeyEnvVar: args.apiKeyEnv,
|
||||
llmModel: args.model,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runNonInteractive(
|
||||
args: ParsedCliArgs,
|
||||
configPath: string,
|
||||
): Promise<{ summary: string; configPath: string }> {
|
||||
if (!args.mode) {
|
||||
throw new Error('runNonInteractive called without --mode');
|
||||
}
|
||||
|
||||
const cfg = await loadConfig(configPath);
|
||||
const pluginConfig = ensurePluginConfig(cfg);
|
||||
|
||||
let summary: string;
|
||||
if (args.mode === 'cloud') {
|
||||
const input = buildCloudInput(args);
|
||||
applyCloudMode(pluginConfig, input);
|
||||
summary = summarizeCloud(input);
|
||||
} else if (args.mode === 'api') {
|
||||
const input = buildApiInput(args);
|
||||
applyApiMode(pluginConfig, input);
|
||||
summary = summarizeApi(input);
|
||||
} else {
|
||||
const input = buildEmbeddedInput(args);
|
||||
applyEmbeddedMode(pluginConfig, input);
|
||||
summary = summarizeEmbedded(input);
|
||||
}
|
||||
|
||||
await saveConfig(configPath, cfg);
|
||||
return { summary, configPath };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive (TUI) execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const validateEnvVar = (value: string | undefined): string | undefined =>
|
||||
isValidEnvVarName(value) ? undefined : 'Must be an UPPER_SNAKE_CASE env var name';
|
||||
|
||||
const validateRequired =
|
||||
(msg: string) =>
|
||||
(value: string | undefined): string | undefined =>
|
||||
value && value.trim().length > 0 ? undefined : msg;
|
||||
|
||||
function assertNotCancelled<T>(value: T | symbol): asserts value is T {
|
||||
if (p.isCancel(value)) {
|
||||
p.cancel('Setup cancelled.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function promptCloud(pluginConfig: Record<string, unknown>): Promise<string> {
|
||||
const useDefaultUrl = await p.confirm({
|
||||
message: `Use the default Hindsight Cloud URL (${HINDSIGHT_CLOUD_URL})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
assertNotCancelled(useDefaultUrl);
|
||||
|
||||
let apiUrl: string | undefined;
|
||||
if (!useDefaultUrl) {
|
||||
const custom = await p.text({
|
||||
message: 'Hindsight Cloud URL',
|
||||
placeholder: HINDSIGHT_CLOUD_URL,
|
||||
validate: validateRequired('URL is required'),
|
||||
});
|
||||
assertNotCancelled(custom);
|
||||
apiUrl = custom;
|
||||
}
|
||||
|
||||
const tokenEnvVar = await p.text({
|
||||
message: 'Environment variable holding your Hindsight Cloud API token',
|
||||
placeholder: 'HINDSIGHT_CLOUD_TOKEN',
|
||||
initialValue: 'HINDSIGHT_CLOUD_TOKEN',
|
||||
validate: validateEnvVar,
|
||||
});
|
||||
assertNotCancelled(tokenEnvVar);
|
||||
|
||||
const input = { apiUrl, tokenEnvVar };
|
||||
applyCloudMode(pluginConfig, input);
|
||||
return summarizeCloud(input);
|
||||
}
|
||||
|
||||
async function promptApi(pluginConfig: Record<string, unknown>): Promise<string> {
|
||||
const apiUrl = await p.text({
|
||||
message: 'Hindsight API URL',
|
||||
placeholder: 'https://mcp.hindsight.example.com',
|
||||
validate: validateRequired('URL is required'),
|
||||
});
|
||||
assertNotCancelled(apiUrl);
|
||||
|
||||
const needsToken = await p.confirm({
|
||||
message: 'Does this API require an auth token?',
|
||||
initialValue: false,
|
||||
});
|
||||
assertNotCancelled(needsToken);
|
||||
|
||||
let tokenEnvVar: string | undefined;
|
||||
if (needsToken) {
|
||||
const value = await p.text({
|
||||
message: 'Environment variable holding the API token',
|
||||
placeholder: 'HINDSIGHT_API_TOKEN',
|
||||
initialValue: 'HINDSIGHT_API_TOKEN',
|
||||
validate: validateEnvVar,
|
||||
});
|
||||
assertNotCancelled(value);
|
||||
tokenEnvVar = value;
|
||||
}
|
||||
|
||||
const input = { apiUrl, tokenEnvVar };
|
||||
applyApiMode(pluginConfig, input);
|
||||
return summarizeApi(input);
|
||||
}
|
||||
|
||||
async function promptEmbedded(pluginConfig: Record<string, unknown>): Promise<string> {
|
||||
const provider = await p.select({
|
||||
message: 'LLM provider used by the Hindsight memory daemon',
|
||||
options: [
|
||||
{ value: 'openai', label: 'OpenAI', hint: 'API key required' },
|
||||
{ value: 'anthropic', label: 'Anthropic', hint: 'API key required' },
|
||||
{ value: 'gemini', label: 'Gemini', hint: 'API key required' },
|
||||
{ value: 'groq', label: 'Groq', hint: 'API key required' },
|
||||
{
|
||||
value: 'claude-code',
|
||||
label: 'Claude Code',
|
||||
hint: 'no API key needed (uses Claude Code CLI auth)',
|
||||
},
|
||||
{
|
||||
value: 'openai-codex',
|
||||
label: 'OpenAI Codex',
|
||||
hint: 'no API key needed (uses codex auth login)',
|
||||
},
|
||||
{ value: 'ollama', label: 'Ollama', hint: 'no API key needed (local models)' },
|
||||
],
|
||||
});
|
||||
assertNotCancelled(provider);
|
||||
const llmProvider = provider as string;
|
||||
|
||||
let apiKeyEnvVar: string | undefined;
|
||||
if (!NO_KEY_PROVIDERS.has(llmProvider)) {
|
||||
const defaultEnvId = defaultApiKeyEnvVar(llmProvider);
|
||||
const envId = await p.text({
|
||||
message: `Environment variable holding your ${llmProvider} API key`,
|
||||
placeholder: defaultEnvId,
|
||||
initialValue: defaultEnvId,
|
||||
validate: validateEnvVar,
|
||||
});
|
||||
assertNotCancelled(envId);
|
||||
apiKeyEnvVar = envId;
|
||||
}
|
||||
|
||||
const overrideModel = await p.confirm({
|
||||
message: 'Override the default model?',
|
||||
initialValue: false,
|
||||
});
|
||||
assertNotCancelled(overrideModel);
|
||||
|
||||
let llmModel: string | undefined;
|
||||
if (overrideModel) {
|
||||
const value = await p.text({
|
||||
message: 'Model id',
|
||||
placeholder: 'gpt-4o-mini',
|
||||
validate: validateRequired('Model id is required'),
|
||||
});
|
||||
assertNotCancelled(value);
|
||||
llmModel = value;
|
||||
}
|
||||
|
||||
const input = { llmProvider, apiKeyEnvVar, llmModel };
|
||||
applyEmbeddedMode(pluginConfig, input);
|
||||
return summarizeEmbedded(input);
|
||||
}
|
||||
|
||||
async function runInteractive(configPath: string): Promise<{ summary: string; configPath: string }> {
|
||||
p.intro('🦞 Hindsight Memory setup for OpenClaw');
|
||||
p.log.info(`Config file: ${configPath}`);
|
||||
|
||||
const cfg = await loadConfig(configPath);
|
||||
const pluginConfig = ensurePluginConfig(cfg);
|
||||
|
||||
const mode = await p.select({
|
||||
message: 'How do you want to run Hindsight?',
|
||||
options: [
|
||||
{ value: 'cloud', label: 'Cloud', hint: 'managed Hindsight, no local setup' },
|
||||
{ value: 'api', label: 'External API', hint: 'your own running Hindsight deployment' },
|
||||
{
|
||||
value: 'embedded',
|
||||
label: 'Embedded daemon',
|
||||
hint: 'spawn a local hindsight daemon on this machine',
|
||||
},
|
||||
],
|
||||
});
|
||||
assertNotCancelled(mode);
|
||||
|
||||
let summary: string;
|
||||
if ((mode as SetupMode) === 'cloud') {
|
||||
summary = await promptCloud(pluginConfig);
|
||||
} else if ((mode as SetupMode) === 'api') {
|
||||
summary = await promptApi(pluginConfig);
|
||||
} else {
|
||||
summary = await promptEmbedded(pluginConfig);
|
||||
}
|
||||
|
||||
const spin = p.spinner();
|
||||
spin.start('Writing configuration');
|
||||
await saveConfig(configPath, cfg);
|
||||
spin.stop(`Saved to ${configPath}`);
|
||||
|
||||
p.note(
|
||||
[
|
||||
summary,
|
||||
'',
|
||||
'Next steps:',
|
||||
' 1. Ensure any referenced env vars are exported in the shell that runs the gateway.',
|
||||
' 2. Restart the gateway: openclaw gateway restart',
|
||||
' 3. Verify config: openclaw config validate',
|
||||
].join('\n'),
|
||||
'Hindsight Memory configured',
|
||||
);
|
||||
p.outro('Done.');
|
||||
return { summary, configPath };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let args: ParsedCliArgs;
|
||||
try {
|
||||
args = parseCliArgs(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
console.error(`hindsight-openclaw-setup: ${err instanceof Error ? err.message : err}`);
|
||||
console.error();
|
||||
console.error(usage());
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (args.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
|
||||
const configPath = args.configPath ?? args.positional ?? DEFAULT_OPENCLAW_CONFIG_PATH;
|
||||
|
||||
if (args.mode) {
|
||||
// Non-interactive path for scripts and CI.
|
||||
try {
|
||||
const { summary } = await runNonInteractive(args, configPath);
|
||||
console.log(`Hindsight Memory configured: ${summary}`);
|
||||
console.log(`Saved to ${configPath}`);
|
||||
} catch (err) {
|
||||
console.error(`hindsight-openclaw-setup: ${err instanceof Error ? err.message : err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Interactive path (default).
|
||||
await runInteractive(configPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only run `main()` when this file is the Node entry point. Importing it from
|
||||
* a test (or any other module) should not trigger the interactive wizard.
|
||||
*
|
||||
* When invoked through `node_modules/.bin/hindsight-openclaw-setup` (npm-created
|
||||
* symlink), `process.argv[1]` points at the symlink while `import.meta.url`
|
||||
* resolves to the real file. Canonicalize both via `realpath` so the check
|
||||
* still matches — otherwise `main()` never runs on bin invocations and the
|
||||
* command silently exits with no output.
|
||||
*/
|
||||
function canonicalize(path: string): string {
|
||||
const resolved = resolve(path);
|
||||
try {
|
||||
return realpathSync(resolved);
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectRun(): boolean {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
try {
|
||||
return canonicalize(entry) === canonicalize(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirectRun()) {
|
||||
main().catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`hindsight-openclaw-setup failed: ${msg}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import PageHero from '@site/src/components/PageHero';
|
||||
|
||||
**Features**
|
||||
|
||||
- Added `hindsight-openclaw-setup`, an interactive setup wizard that walks users through picking one of three install modes — **Cloud** (managed Hindsight at `https://api.hindsight.vectorize.io`), **External API** (your own running Hindsight deployment), or **Embedded daemon** (local `hindsight-embed` daemon). The wizard writes a valid plugin config with env-backed `SecretRef` credentials and no plaintext secrets on disk.
|
||||
- `hindsight-openclaw-setup` also runs non-interactively via `--mode cloud|api|embedded` plus mode-specific flags (`--api-url`, `--token-env`, `--no-token`, `--provider`, `--api-key-env`, `--model`) for CI and scripted installs.
|
||||
- Added the `llmApiKey` plugin config field, marked as a sensitive field so OpenClaw resolves it as a `SecretRef` from env, file, or exec sources.
|
||||
- Added the `llmBaseUrl` plugin config field for OpenAI-compatible endpoint overrides (OpenRouter, Azure OpenAI, vLLM, etc.).
|
||||
- Marked `hindsightApiToken` as a sensitive field — it can now be configured as a `SecretRef` the same way as `llmApiKey`.
|
||||
|
||||
Reference in New Issue
Block a user