Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 1d5550a279 fix(openclaw): resolve bin invocation when launched via npm symlink + doc the correct invocation
Two related problems found during end-to-end install testing:

1. `isDirectRun()` in setup.ts compared `process.argv[1]` against
   `fileURLToPath(import.meta.url)`. When the bin is invoked through
   `node_modules/.bin/hindsight-openclaw-setup` (an npm-created symlink
   into `dist/setup.js`), these two paths differ: argv[1] is the symlink
   and import.meta.url is the resolved target. The equality check failed,
   `main()` never ran, and the command silently exited with status 0 and
   no output. Canonicalize both via `realpathSync` before comparing —
   same approach the backfill bin already uses (`isDirectExecution` in
   src/backfill.ts).

2. `openclaw plugins install @vectorize-io/hindsight-openclaw` unpacks
   the plugin into ~/.openclaw/extensions/ but does not put its bins on
   $PATH, so the README/docs instruction `hindsight-openclaw-setup` was
   misleading — users would get "command not found". Update the Quick
   Start in both README.md and hindsight-docs/docs-integrations/openclaw.md
   to invoke the wizard via `npx --package @vectorize-io/hindsight-openclaw
   hindsight-openclaw-setup`, matching the existing invocation shown for
   the hindsight-openclaw-backfill bin.
2026-04-13 10:22:43 +02:00
Nicolò Boschi 32f99074aa docs(openclaw): document hindsight-openclaw-setup wizard
The plugin's own README was updated to lead with the setup wizard when
the feature landed, but the docs site page (docs-integrations/openclaw.md)
was still showing a Quick Start driven entirely by raw `openclaw config
set` commands. Update the Quick Start to mirror the README flow: install
the plugin, run `hindsight-openclaw-setup`, start the gateway. Include
the three modes (Cloud / External API / Embedded) and the non-interactive
--mode flag variants for CI.

Also add pointer notes at the top of the "LLM Configuration" and
"External API (Advanced)" sections so readers who arrived there directly
know the wizard already covers those paths.

Extend the 0.6.0 (Unreleased) changelog entry with the wizard under
**Features** and regenerate the skill mirror.
2026-04-13 10:09:54 +02:00
Nicolò Boschi 54f9ce1dec fix(openclaw): smoke test — tolerate unrelated bundled-plugin diagnostics
In clean CI environments, `openclaw plugins doctor` can emit diagnostics
for bundled plugins (seen: "ollama: memory embedding provider already
registered") that have nothing to do with hindsight-openclaw. The
previous smoke-test check required the literal string "No plugin issues
detected" in doctor output, which treated those unrelated warnings as
failures.

Replace that check with two narrower ones: (a) `plugins doctor` must
exit zero, and (b) its output must not contain any line that mentions
hindsight together with fail/error/not-loaded. Unrelated bundled-plugin
warnings no longer fail the smoke test.
2026-04-13 10:05:00 +02:00
Nicolò Boschi bd0021655e chore(openclaw): point cloud mode at api.hindsight.vectorize.io, drop stale install.sh
- Replace the placeholder Hindsight Cloud URL with the real one,
  https://api.hindsight.vectorize.io, in setup-lib.ts and the three
  suites that hard-coded it (setup-lib.test.ts, setup.test.ts,
  scripts/smoke-test.sh).

- Delete hindsight-integrations/openclaw/install.sh. It predated
  `openclaw plugins install` and documented the pre-0.6.0 env-var flow
  ('export OPENAI_API_KEY', 'openclaw plugins enable'), which is
  superseded by the interactive/non-interactive hindsight-openclaw-setup
  wizard plus README quick start.
2026-04-13 09:53:01 +02:00
Nicolò Boschi 8a17cc138b feat(openclaw): non-interactive setup flags + smoke test + CI
- setup.ts now accepts --mode cloud|api|embedded plus mode-specific flags
  (--api-url, --token-env, --no-token, --provider, --api-key-env, --model,
  --config-path) to skip the interactive TUI. Interactive remains the
  default when no --mode is given. main() is guarded by an isDirectRun()
  check so importing from tests does not trigger the wizard.

- src/setup.test.ts adds 23 unit tests covering every flag, invalid input
  (unknown flags, missing values, conflicting --token-env + --no-token,
  mode requirements) and the full non-interactive write path for each
  mode including cross-mode state cleanup.

- scripts/smoke-test.sh is a new end-to-end install smoke test:
  * packs a fresh tarball (or uses an existing one passed in argv[1])
  * installs via `openclaw plugins install <tarball>` WITHOUT
    --dangerously-force-unsafe-install — fails loudly if the scanner
    reports any findings
  * asserts workspace deps (@vectorize-io/hindsight-all, hindsight-client)
    resolved from the npm registry into the extension's node_modules
  * runs `hindsight-openclaw-setup` non-interactively for all 4 mode
    variants (cloud default URL, external API no-auth, embedded openai
    with model override, embedded claude-code no-key) and asserts
    `openclaw config validate` + `openclaw plugins doctor` pass after each
  * runs 3 negative tests to assert bad flag combinations fail fast
  * backs up and restores ~/.openclaw/openclaw.json around the run

- .github/workflows/test.yml adds a smoke-openclaw-install job on
  ubuntu-latest that installs the published `openclaw` CLI, rebuilds the
  workspace deps, and runs scripts/smoke-test.sh. Gated by the same
  detect-changes outputs as build-openclaw-integration and added to the
  report-pr-status needs list.
2026-04-10 23:47:01 +02:00
Nicolò Boschi 36c0f3e25d feat(openclaw): interactive setup wizard with Cloud / API / Embedded modes
Ship a new `hindsight-openclaw-setup` bin that walks users through picking a
mode and writes the corresponding plugin config into openclaw.json:

- Cloud — managed Hindsight (default URL + token SecretRef)
- External API — user's own running Hindsight (URL + optional token SecretRef)
- Embedded daemon — local hindsight-all daemon (LLM provider + key SecretRef)

Pure config manipulation (mode application, SecretRef construction, atomic
save/load) lives in src/setup-lib.ts and is covered by 21 unit tests. The
src/setup.ts CLI entry is a thin @clack/prompts wrapper on top.

Mode switches correctly clear stale fields from the opposite modes so a
user flipping between e.g. Cloud and Embedded doesn't end up with a mixed
configuration. All credentials are always written as env-backed SecretRef
objects, never plaintext.

Scanner-safe: neither setup.ts nor setup-lib.ts imports subprocess APIs or
reads environment variables, so the new files don't reintroduce the
dangerous-exec / env-harvesting findings that #974 just cleared.
2026-04-10 19:07:21 +02:00
13 changed files with 1632 additions and 110 deletions
+56
View File
@@ -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
+43 -24
View File
@@ -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`.
+27 -17
View File
@@ -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
View File
@@ -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",
+3 -1
View File
@@ -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
View File
@@ -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`.