From 1849dd91d933e7a7befcd0eb5ce94f160f33bb86 Mon Sep 17 00:00:00 2001 From: Christian Korndoerfer Date: Mon, 19 Oct 2026 17:35:17 +0200 Subject: [PATCH 1/8] CAP-4819 feat: perspective CLI with agent SKILL.md A `perspective` command for the External REST API and the hosted MCP server, built for humans and coding agents alike: JSON on stdout, JSON errors on stderr, stable exit codes. - auth: browser OAuth (PKCE, loopback redirect) for MCP tools, API key (stdin, validated) for REST; flag > env > file precedence; tokens refresh automatically; credentials file is 0600. - REST: workspaces list, funnels list, contacts list/get/create/set, metrics kpi/chart/insight/subtypes. - tools list/describe/call: generic access to every MCP tool, with a minimal stateless JSON-RPC client (JSON and SSE replies). - skill show/install and skills/perspective/SKILL.md. - Vitest suite and a CI workflow on Node 22 and 24. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 26 + AGENTS.md | 14 + CLAUDE.md | 1 + README.md | 95 +++ package-lock.json | 1563 +++++++++++++++++++++++++++++++++++ package.json | 33 + skills/perspective/SKILL.md | 105 +++ src/cli.ts | 50 ++ src/command.ts | 23 + src/commands/auth.ts | 120 +++ src/commands/contacts.ts | 165 ++++ src/commands/metrics.ts | 139 ++++ src/commands/skill.ts | 39 + src/commands/tools.ts | 87 ++ src/commands/workspaces.ts | 51 ++ src/config.ts | 54 ++ src/context.ts | 72 ++ src/errors.ts | 86 ++ src/input.ts | 77 ++ src/mcp.ts | 159 ++++ src/oauth.ts | 191 +++++ src/output.ts | 32 + src/rest.ts | 80 ++ test/clients.test.ts | 131 +++ test/units.test.ts | 131 +++ tsconfig.build.json | 8 + tsconfig.json | 15 + 27 files changed, 3547 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 skills/perspective/SKILL.md create mode 100644 src/cli.ts create mode 100644 src/command.ts create mode 100644 src/commands/auth.ts create mode 100644 src/commands/contacts.ts create mode 100644 src/commands/metrics.ts create mode 100644 src/commands/skill.ts create mode 100644 src/commands/tools.ts create mode 100644 src/commands/workspaces.ts create mode 100644 src/config.ts create mode 100644 src/context.ts create mode 100644 src/errors.ts create mode 100644 src/input.ts create mode 100644 src/mcp.ts create mode 100644 src/oauth.ts create mode 100644 src/output.ts create mode 100644 src/rest.ts create mode 100644 test/clients.test.ts create mode 100644 test/units.test.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..094db44 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node: [22, 24] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm test + - run: node dist/cli.js --version diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..95be76f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# perspective-cli — agent notes + +- TypeScript, ESM, Node ≥ 22.12. Runtime dependency: `commander` only. Keep it that way; + the MCP client is hand-rolled on purpose (the hosted server is stateless Streamable HTTP, + so one POST per JSON-RPC call), avoiding the SDK's server-side dependency tree. +- Contract that must not break: stdout is JSON only (human text goes to stderr via + `info()`); errors are `CliError` → JSON on stderr; exit codes in `src/errors.ts` are never + renumbered. +- Every command goes through `action()` in `src/command.ts`. +- Two credentials: API key (`x-perspective-api-key`) for REST `/v1`, OAuth bearer for + `/mcp`. The server does not accept one for the other. +- Changing a command means updating `skills/perspective/SKILL.md`, `README.md`, and the + developer docs (`developer-docs/docs/cli/`). +- Verify with `npm run typecheck`, `npm test` and `npm run build`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fded5dc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See [AGENTS.md](./AGENTS.md). diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc23678 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# Perspective CLI + +`perspective` drives [Perspective](https://perspective.co) from the shell. It covers the +[External REST API](https://developers.perspective.co/api-reference/overview) and every tool of +the [MCP server](https://developers.perspective.co/mcp/overview), and it is built to be driven +by both people and coding agents: output is JSON, errors are JSON, and exit codes are stable. + +Full docs: + +## Install + +Requires Node.js ≥ 22.12. + +```bash +npm install -g github:Perspective-Software/perspective-cli +perspective --help +``` + +## Authenticate + +Two credentials, one per API surface: + +| Command | Credential | Unlocks | +|---|---|---| +| `perspective auth login` | OAuth (browser login, refreshed automatically) | `tools …` (MCP: funnels, sequences, brands, stats, …) | +| `perspective auth login --with-api-key` | API key from Settings → API Keys (read from stdin) | `workspaces`, `funnels`, `contacts`, `metrics` | + +```bash +perspective auth login # opens the browser +printf %s "$KEY" | perspective auth login --with-api-key # stores and validates an API key +perspective auth status +``` + +Credentials are stored in `~/.config/perspective/credentials.json` (mode 0600). In CI, set +`PERSPECTIVE_API_KEY` and/or `PERSPECTIVE_TOKEN` instead. Precedence is flag → env → file. + +## Use + +```bash +perspective funnels list --status online +perspective contacts list --all +perspective contacts create --email ada@example.com --first-name Ada +perspective metrics kpi conversion_rate --days 7 +perspective metrics chart visitor_devices --from 2026-09-01 --to 2026-10-01 + +perspective tools list --scope funnel-build +perspective tools describe create_funnel +perspective tools call get_funnel_stats -a funnelId= +perspective tools call create_funnel --input @brief.json +``` + +Output is pretty-printed at a terminal and compact when piped (`--pretty` / `--no-pretty`). + +### Exit codes + +| Code | Meaning | +|---|---| +| 0 | success | +| 1 | API or tool error | +| 2 | usage error (bad flags/arguments, HTTP 400) | +| 3 | not authenticated (HTTP 401) | +| 4 | forbidden (HTTP 403) | +| 5 | not found (HTTP 404) | +| 6 | rate limited (HTTP 429, 100 requests/minute) | +| 7 | network error | + +Errors go to stderr as `{"error":{"code","message","status?","hint?","details?"}}`. + +## Agents + +[`skills/perspective/SKILL.md`](skills/perspective/SKILL.md) teaches coding agents (Claude Code and +anything else that reads Agent Skills) how to use the CLI safely. Install it with: + +```bash +perspective skill install # ~/.claude/skills/perspective/SKILL.md +perspective skill install --project # ./.claude/skills/perspective/SKILL.md +perspective skill show # print it +``` + +## Development + +```bash +npm install # also builds dist/ via `prepare` +npm test +npm run typecheck +npm run dev -- funnels list # run from source (Node's type stripping) +perspective --base-url http://localhost:3000 … # point at a local External API +``` + +Layout: `src/cli.ts` (entry) · `src/commands/*` (one file per command group) · `src/rest.ts` +(REST client) · `src/mcp.ts` (stateless MCP JSON-RPC client) · `src/oauth.ts` (PKCE loopback +login) · `src/context.ts` (credential resolution) · `skills/perspective/SKILL.md`. + +When you add or change a command, update `SKILL.md` and the docs in +`Perspective-Software/developer-docs` (`docs/cli/`) in the same change. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2ab8baa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1563 @@ +{ + "name": "@perspective-software/cli", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@perspective-software/cli", + "version": "0.1.0", + "license": "UNLICENSED", + "dependencies": { + "commander": "^15.0.0" + }, + "bin": { + "perspective": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "typescript": "^7.0.2", + "vitest": "^5.0.5" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..86eab1e --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "@perspective-software/cli", + "version": "0.1.0", + "description": "Command-line interface for the Perspective External API and MCP server", + "private": true, + "license": "UNLICENSED", + "type": "module", + "bin": { + "perspective": "dist/cli.js" + }, + "files": [ + "dist", + "skills" + ], + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "node --experimental-strip-types src/cli.ts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "prepare": "npm run build" + }, + "dependencies": { + "commander": "^15.0.0" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "typescript": "^7.0.2", + "vitest": "^5.0.5" + } +} diff --git a/skills/perspective/SKILL.md b/skills/perspective/SKILL.md new file mode 100644 index 0000000..cb44226 --- /dev/null +++ b/skills/perspective/SKILL.md @@ -0,0 +1,105 @@ +--- +name: perspective +description: Operate a Perspective account (perspective.co funnel builder) from the shell with the `perspective` CLI — find funnels, read and write CRM contacts, pull funnel KPIs/charts/insights, and call any Perspective MCP tool (create/edit/publish AI funnels, email sequences, brands, email stats). Use when the user asks about their Perspective funnels, leads/contacts, conversion metrics or sequences, or wants to script Perspective. +--- + +# Perspective CLI + +`perspective` wraps the Perspective External API (REST, `/v1`) and the hosted +Perspective MCP server. Every command prints **JSON on stdout**; errors print +**JSON on stderr** with a non-zero exit code. Pipe into `jq` freely. + +Docs: https://developers.perspective.co/cli/overview + +## 0. Check setup first + +```bash +perspective auth status +``` + +- `rest.authenticated: false` → REST commands (`workspaces`, `funnels`, `contacts`, `metrics`) will fail. + Ask the user for an API key (Perspective → Settings → API Keys, admins only), then + `printf %s "$KEY" | perspective auth login --with-api-key` or export `PERSPECTIVE_API_KEY`. +- `mcp.authenticated: false` → `tools` commands will fail. The user must run + `perspective auth login` themselves (opens a browser). You cannot complete + that flow headlessly; in CI use `PERSPECTIVE_TOKEN`. + +Never print, log or commit an API key or token. + +## 1. Find IDs + +Almost everything takes a `funnelId`. Get it first: + +```bash +perspective funnels list --name "webinar" # flat list: id, name, status, workspaceId +perspective workspaces list # nested: workspaces → campaigns +``` + +## 2. REST commands (API key) + +```bash +# Contacts +perspective contacts list --limit 100 --sort email --order asc +perspective contacts list --all # every page, {data, meta.total} +perspective contacts get +perspective contacts create --email a@b.co --first-name Ada -s address.city=Berlin +perspective contacts set status qualified # any field or custom property + +# Metrics — default window is the last 30 days in local time +perspective metrics kpi conversion_rate +perspective metrics kpi new_contacts --from 2026-09-01 --to 2026-10-01 +perspective metrics chart page_to_page_conversion_rate --ab-test variant +perspective metrics insight question_1234 --days 7 +perspective metrics subtypes # valid kpi/chart names +``` + +Pagination for `contacts list` is page-based: read `meta.hasNext`, bump `--page`, or just use `--all`. + +## 3. MCP tools (OAuth login) — everything else + +Funnel creation/editing/publishing, email sequences, brands, domains, senders, +email stats and image uploads exist **only** as MCP tools. Discover, then call: + +```bash +perspective tools list # names + descriptions +perspective tools list --scope analytics # scopes: all, funnel-build, sequences, analytics, crm +perspective tools describe create_funnel # full JSON input schema — read it before calling +perspective tools call list_brands +perspective tools call get_funnel_stats -a funnelId= -a dateFrom=2026-10-01T00:00:00Z +perspective tools call create_funnel --input @brief.json +echo '{"jobId":"…"}' | perspective tools call get_funnel_job_status --input - +``` + +- `-a key=value` values are parsed as JSON when valid (`-a limit=20`, `-a noBrand=true`); dots nest (`-a data.name=Spring`). +- `--input` takes inline JSON, `@file`, or `-` (stdin); `-a` overrides it. +- Output is the tool's structured result, or its text parsed as JSON. `--raw` gives the MCP content array. +- A tool-level failure exits 1 with `error.code: "tool_error"` and the tool's message in `error.details`. + +### Recipe: build an AI funnel + +1. `tools call list_brands` → pick a brand with the user (or confirm they want none). +2. `tools describe create_funnel` → follow the description; it asks for a rich brief. +3. `tools call create_funnel --input @brief.json` → returns `jobId`, `funnelId`. +4. Poll `tools call get_funnel_job_status -a jobId=` (long-polls ~20 s) until completed. +5. Edits: `tools call update_funnel -a funnelId= -a prompt="…"`. Publishing: `update_funnel_metadata`. + +## Exit codes + +| Code | `error.code` | Meaning / what to do | +|---|---|---| +| 0 | — | Success | +| 1 | `error`, `tool_error` | API or tool error — read `error.message` / `error.details` | +| 2 | `usage` | Bad arguments or 400 from the API — fix the call (see `error.hint`) | +| 3 | `unauthenticated` | Missing/invalid credentials — see step 0 | +| 4 | `forbidden` | Key lacks the permission (`crm:read`, `crm:write`, `metrics:read`) or plan | +| 5 | `not_found` | Wrong funnel/contact/tool ID — re-discover IDs | +| 6 | `rate_limited` | 100 requests/minute — wait and retry; prefer `--all` over many small calls | +| 7 | `network` | API unreachable | + +## Rules for agents + +- **Read before you write.** `contacts create`, `contacts set`, and tools that create, update, publish, unpublish or send act on a live account: they can trigger automations and emails to real people, and change public pages. Confirm with the user before running them unless they explicitly asked for that exact change. Use `--skip-automation` when the user wants data changes without side effects. +- Prefer the narrowest command: REST commands for contacts/metrics, `tools` only for what REST lacks. +- Use `tools describe ` instead of guessing argument names; schemas change. +- Dates are ISO 8601. Metrics windows: `--from/--to`, or `--days N` (default 30). +- Global flags: `--base-url`, `--api-key`, `--token`, `--pretty`/`--no-pretty`. diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..aa83052 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { Command, CommanderError } from 'commander'; +import { registerAuth } from './commands/auth.ts'; +import { registerContacts } from './commands/contacts.ts'; +import { registerMetrics } from './commands/metrics.ts'; +import { registerSkill } from './commands/skill.ts'; +import { registerTools } from './commands/tools.ts'; +import { registerWorkspaces } from './commands/workspaces.ts'; +import { ExitCode } from './errors.ts'; +import { printError } from './output.ts'; + +const { version } = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +) as { version: string }; + +function buildProgram(): Command { + const program = new Command('perspective') + .description( + 'Perspective from the command line. Output is JSON on stdout; errors are JSON on stderr.\n' + + 'Docs: https://developers.perspective.co/cli/overview', + ) + .version(version) + .option('--base-url ', 'API origin (env PERSPECTIVE_BASE_URL)', undefined) + .option('--api-key ', 'REST API key (env PERSPECTIVE_API_KEY)') + .option('--token ', 'OAuth access token for MCP tools (env PERSPECTIVE_TOKEN)') + .option('--pretty', 'indent JSON output (default when stdout is a terminal)') + .option('--no-pretty', 'compact JSON output (default when piped)') + .showHelpAfterError() + .exitOverride(); + + registerAuth(program); + registerWorkspaces(program); + registerContacts(program); + registerMetrics(program); + registerTools(program); + registerSkill(program); + return program; +} + +try { + await buildProgram().parseAsync(process.argv); +} catch (err) { + if (err instanceof CommanderError) { + // Commander already printed help/version/the usage message. + process.exitCode = err.exitCode === 0 ? ExitCode.OK : ExitCode.USAGE; + } else { + process.exitCode = printError(err); + } +} diff --git a/src/command.ts b/src/command.ts new file mode 100644 index 0000000..24d39ce --- /dev/null +++ b/src/command.ts @@ -0,0 +1,23 @@ +import type { Command } from 'commander'; +import { Context, type GlobalOptions } from './context.ts'; +import { printError, printResult } from './output.ts'; + +type Handler = (ctx: Context, ...args: any[]) => Promise; + +/** + * Wraps a command handler: builds the Context from global flags, prints the + * returned value as JSON on stdout, and turns any error into a JSON error on + * stderr plus a documented exit code. + */ +export function action(handler: Handler) { + return async (...args: unknown[]): Promise => { + const command = args[args.length - 1] as Command; + const globals = command.optsWithGlobals(); + const ctx = new Context(globals); + try { + printResult(await handler(ctx, ...args.slice(0, -1)), { pretty: globals.pretty }); + } catch (err) { + process.exitCode = printError(err, { pretty: globals.pretty }); + } + }; +} diff --git a/src/commands/auth.ts b/src/commands/auth.ts new file mode 100644 index 0000000..80b53df --- /dev/null +++ b/src/commands/auth.ts @@ -0,0 +1,120 @@ +import { spawn } from 'node:child_process'; +import type { Command } from 'commander'; +import { action } from '../command.ts'; +import { clearConfig, configPath, saveConfig } from '../config.ts'; +import { CliError } from '../errors.ts'; +import { readTextArg } from '../input.ts'; +import { login } from '../oauth.ts'; +import { info } from '../output.ts'; +import { RestClient } from '../rest.ts'; + +function openBrowser(url: string): void { + const [cmd, args] = + process.platform === 'darwin' + ? ['open', [url]] + : process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', url]] + : ['xdg-open', [url]]; + const child = spawn(cmd, args, { stdio: 'ignore', detached: true }); + child.on('error', () => {}); + child.unref(); +} + +export function mask(secret: string): string { + return secret.length <= 8 ? '••••' : `${secret.slice(0, 4)}…${secret.slice(-4)}`; +} + +export function registerAuth(program: Command): void { + const auth = program.command('auth').description('Log in, inspect and clear credentials'); + + auth.command('login') + .description( + 'Log in with your Perspective account in the browser (OAuth; enables `tools`). ' + + 'With --with-api-key, store an API key instead (enables REST commands).', + ) + .option('--with-api-key [key]', 'store an API key; omit the value (or pass -) to read it from stdin') + .option('--no-browser', 'print the login URL instead of opening a browser') + .action( + action(async (ctx, opts: { withApiKey?: string | true; browser: boolean }) => { + const baseUrl = await ctx.baseUrl(); + const config = await ctx.stored(); + + if (opts.withApiKey !== undefined) { + const raw = opts.withApiKey === true ? '-' : opts.withApiKey; + const key = (await readTextArg(raw)).trim(); + if (!key) throw new CliError('usage', 'Empty API key'); + // Validate before storing: a cheap authenticated read. + await new RestClient(baseUrl, key).request({ path: '/workspaces' }); + config.apiKey = key; + await saveConfig(config, ctx.env); + return { ok: true, method: 'api_key', apiKey: mask(key), configPath: configPath(ctx.env) }; + } + + config.oauth = await login({ + baseUrl, + onAuthorizeUrl: (url) => { + info(`Open this URL to log in:\n\n ${url}\n`); + if (opts.browser) openBrowser(url); + info('Waiting for the browser…'); + }, + }); + await saveConfig(config, ctx.env); + return { + ok: true, + method: 'oauth', + expiresAt: config.oauth.expiresAt ? new Date(config.oauth.expiresAt).toISOString() : null, + configPath: configPath(ctx.env), + }; + }), + ); + + auth.command('status') + .description('Show which credentials are configured and where they come from (no network calls)') + .action( + action(async (ctx) => { + const config = await ctx.stored(); + const keySource = ctx.opts.apiKey + ? 'flag' + : ctx.env.PERSPECTIVE_API_KEY + ? 'env' + : config.apiKey + ? 'config' + : null; + const key = ctx.opts.apiKey ?? ctx.env.PERSPECTIVE_API_KEY ?? config.apiKey; + const tokenSource = ctx.opts.token + ? 'flag' + : ctx.env.PERSPECTIVE_TOKEN + ? 'env' + : config.oauth + ? 'config' + : null; + return { + baseUrl: await ctx.baseUrl(), + configPath: configPath(ctx.env), + rest: { authenticated: Boolean(key), source: keySource, apiKey: key ? mask(key) : null }, + mcp: { + authenticated: Boolean(tokenSource), + source: tokenSource, + expiresAt: + tokenSource === 'config' && config.oauth?.expiresAt + ? new Date(config.oauth.expiresAt).toISOString() + : null, + refreshable: tokenSource === 'config' ? Boolean(config.oauth?.refreshToken) : null, + }, + }; + }), + ); + + auth.command('token') + .description('Print a valid OAuth access token (refreshing it if needed), e.g. for curl') + .action(action(async (ctx) => ({ accessToken: await ctx.accessToken() }))); + + auth.command('logout') + .description('Delete stored credentials (environment variables are not affected)') + .action( + action(async (ctx) => { + await clearConfig(ctx.env); + return { ok: true, removed: configPath(ctx.env) }; + }), + ); +} diff --git a/src/commands/contacts.ts b/src/commands/contacts.ts new file mode 100644 index 0000000..f08e27d --- /dev/null +++ b/src/commands/contacts.ts @@ -0,0 +1,165 @@ +import { Option, type Command } from 'commander'; +import { action } from '../command.ts'; +import { usage } from '../errors.ts'; +import { applyAssignments, collect, parseInteger, readJsonObject } from '../input.ts'; +import { RestClient } from '../rest.ts'; + +const SORT_FIELDS = ['ps_converted_at', 'email', 'firstName', 'lastName']; +/** Server-side cap on `limit`. */ +const MAX_LIMIT = 100; + +interface ContactsPage { + data: unknown[]; + meta: { total: number; page: number; limit: number; hasNext: boolean }; +} + +export function registerContacts(program: Command): void { + const contacts = program.command('contacts').description("A funnel's CRM contacts (REST, needs an API key)"); + + contacts + .command('list') + .description('List contacts of a funnel, newest first') + .argument('') + .option('--page ', 'page number, 0-based', parseInteger('page', 0), 0) + .option('--limit ', `page size, 1–${MAX_LIMIT}`, parseInteger('limit', 1, MAX_LIMIT), 50) + .addOption(new Option('--sort ', 'sort field').choices(SORT_FIELDS).default('ps_converted_at')) + .addOption(new Option('--order ', 'sort direction').choices(['asc', 'desc']).default('desc')) + .option('--all', 'follow pages until the end and return every contact') + .action( + action( + async ( + ctx, + funnelId: string, + opts: { page: number; limit: number; sort: string; order: string; all?: boolean }, + ) => { + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + const fetchPage = (page: number, limit: number) => + rest.request({ + path: `/funnels/${encodeURIComponent(funnelId)}/contacts`, + query: { + page, + limit, + sortField: opts.sort, + sortOrder: opts.order === 'asc' ? 1 : -1, + }, + }); + + if (!opts.all) return fetchPage(opts.page, opts.limit); + + const all: unknown[] = []; + let page = 0; + let result: ContactsPage; + do { + result = await fetchPage(page++, MAX_LIMIT); + all.push(...result.data); + } while (result.meta.hasNext); + return { data: all, meta: { total: result.meta.total } }; + }, + ), + ); + + contacts + .command('get') + .description('Get one contact') + .argument('') + .argument('') + .action( + action(async (ctx, funnelId: string, contactId: string) => { + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + return rest.request({ + path: `/funnels/${encodeURIComponent(funnelId)}/contacts/${encodeURIComponent(contactId)}`, + }); + }), + ); + + contacts + .command('create') + .description('Create a contact. Needs --email or --phone.') + .argument('') + .option('--email ') + .option('--phone ') + .option('--first-name ') + .option('--last-name ') + .option('--status ', 'one of the statuses configured for the funnel') + .option('--website ') + .option('--birthday ') + .option('-s, --set ', 'any body field, repeatable; dots nest (address.city=Berlin)', collect, []) + .option('--data ', 'full request body as JSON, @file or - for stdin; flags override it') + .option('--skip-automation', 'do not trigger automations for the initial status') + .action( + action( + async ( + ctx, + funnelId: string, + opts: { + email?: string; + phone?: string; + firstName?: string; + lastName?: string; + status?: string; + website?: string; + birthday?: string; + set: string[]; + data?: string; + skipAutomation?: boolean; + }, + ) => { + const body = opts.data ? await readJsonObject(opts.data, '--data') : {}; + const flags = { + email: opts.email, + phone: opts.phone, + firstName: opts.firstName, + lastName: opts.lastName, + status: opts.status, + website: opts.website, + birthday: opts.birthday, + }; + for (const [key, value] of Object.entries(flags)) if (value !== undefined) body[key] = value; + applyAssignments(body, opts.set); + if (opts.skipAutomation) body.skipAutomationTrigger = true; + if (!body.email && !body.phone) throw usage('A contact needs --email or --phone'); + + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + return rest.request({ + method: 'POST', + path: `/funnels/${encodeURIComponent(funnelId)}/contacts`, + body, + }); + }, + ), + ); + + contacts + .command('set') + .description( + 'Set one field on a contact. Standard fields (firstName, email, status, city, …) or any custom property name.', + ) + .argument('') + .argument('') + .argument('') + .argument('') + .option('--skip-automation', 'do not trigger automations for this change') + .action( + action( + async ( + ctx, + funnelId: string, + contactId: string, + field: string, + value: string, + opts: { skipAutomation?: boolean }, + ) => { + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + return rest.request({ + method: 'PUT', + path: `/funnels/${encodeURIComponent(funnelId)}/contacts/${encodeURIComponent(contactId)}/values`, + body: { + fieldName: field, + value, + ...(opts.skipAutomation && { skipAutomationTrigger: true }), + }, + }); + }, + ), + ); +} diff --git a/src/commands/metrics.ts b/src/commands/metrics.ts new file mode 100644 index 0000000..53c42f1 --- /dev/null +++ b/src/commands/metrics.ts @@ -0,0 +1,139 @@ +import { Option, type Command } from 'commander'; +import { action } from '../command.ts'; +import { usage } from '../errors.ts'; +import { parseInteger } from '../input.ts'; +import { RestClient } from '../rest.ts'; + +export const KPI_SUBTYPES = [ + 'kpi_conversion_rate', + 'kpi_completion_rate', + 'kpi_average_time_on_page', + 'kpi_time_to_completion', + 'kpi_new_contacts', + 'kpi_total_sessions', + 'kpi_messages_sent', + 'kpi_messages_delivery_rate', + 'kpi_messages_open_rate', +]; + +export const CHART_SUBTYPES = [ + 'chart_page_to_page_conversion_rate', + 'chart_activity_by_daytime', + 'chart_contacts_over_time', + 'chart_visitor_devices', + 'chart_top_utm_sources', + 'chart_time_on_page', + 'chart_button_clicks', +]; + +/** Accepts `conversion_rate`, `conversion-rate` or `kpi_conversion_rate`. */ +export function normalizeSubtype(raw: string, prefix: 'kpi' | 'chart', valid: string[]): string { + const snake = raw.trim().toLowerCase().replace(/-/g, '_'); + const full = snake.startsWith(`${prefix}_`) ? snake : `${prefix}_${snake}`; + if (!valid.includes(full)) { + throw usage(`Unknown ${prefix} subtype "${raw}"`, `Valid: ${valid.join(', ')}`); + } + return full; +} + +interface WindowOptions { + from?: string; + to?: string; + days: number; + tzOffset?: string; +} + +function parseDate(name: string, value: string): Date { + const date = new Date(value); + if (Number.isNaN(date.getTime())) throw usage(`--${name} is not a valid date: "${value}"`); + return date; +} + +/** `--from/--to` win; otherwise the window is the last `--days` days ending now. */ +export function resolveWindow(opts: WindowOptions, now = new Date()) { + const to = opts.to ? parseDate('to', opts.to) : now; + const from = opts.from ? parseDate('from', opts.from) : new Date(to.getTime() - opts.days * 86_400_000); + if (from >= to) throw usage('--from must be before --to'); + // The API wants minutes east of UTC (UTC+2 → "120"); getTimezoneOffset is minutes west. + const offset = opts.tzOffset ?? String(-now.getTimezoneOffset()); + return { from: from.toISOString(), to: to.toISOString(), offset }; +} + +function withWindow(cmd: Command): Command { + return cmd + .option('--from ', 'start of the period (ISO 8601)') + .option('--to ', 'end of the period (ISO 8601), default now') + .option('--days ', 'period length when --from is not given', parseInteger('days', 1, 3650), 30) + .option('--tz-offset ', 'timezone offset in minutes east of UTC, default: local timezone'); +} + +export function registerMetrics(program: Command): void { + const metrics = program + .command('metrics') + .description('Funnel analytics: KPIs, charts and question insights (REST, needs an API key)'); + + withWindow( + metrics + .command('kpi') + .description('One KPI for a funnel, e.g. conversion_rate, new_contacts, total_sessions') + .argument('') + .argument(''), + ).action( + action(async (ctx, funnelId: string, subtype: string, opts: WindowOptions) => { + const kpi = normalizeSubtype(subtype, 'kpi', KPI_SUBTYPES); + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + return rest.request({ + path: `/funnels/${encodeURIComponent(funnelId)}/metrics/kpis/${kpi}`, + query: resolveWindow(opts), + }); + }), + ); + + withWindow( + metrics + .command('chart') + .description('Chart data for a funnel, e.g. page_to_page_conversion_rate, visitor_devices') + .argument('') + .argument('') + .addOption( + new Option('--ab-test ', 'A/B filter (page_to_page_conversion_rate only)').choices([ + 'all', + 'original', + 'variant', + ]), + ), + ).action( + action(async (ctx, funnelId: string, subtype: string, opts: WindowOptions & { abTest?: string }) => { + const chart = normalizeSubtype(subtype, 'chart', CHART_SUBTYPES); + if (opts.abTest && chart !== 'chart_page_to_page_conversion_rate') { + throw usage('--ab-test only works with page_to_page_conversion_rate'); + } + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + return rest.request({ + path: `/funnels/${encodeURIComponent(funnelId)}/metrics/charts/${chart}`, + query: { ...resolveWindow(opts), abTest: opts.abTest }, + }); + }), + ); + + withWindow( + metrics + .command('insight') + .description('Answers to one question/survey element (insightId as shown in the builder, e.g. question_1234)') + .argument('') + .argument(''), + ).action( + action(async (ctx, funnelId: string, insightId: string, opts: WindowOptions) => { + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + return rest.request({ + path: `/funnels/${encodeURIComponent(funnelId)}/metrics/insights/${encodeURIComponent(insightId)}`, + query: resolveWindow(opts), + }); + }), + ); + + metrics + .command('subtypes') + .description('List valid KPI and chart subtypes (offline)') + .action(action(async () => ({ kpi: KPI_SUBTYPES, chart: CHART_SUBTYPES }))); +} diff --git a/src/commands/skill.ts b/src/commands/skill.ts new file mode 100644 index 0000000..108c315 --- /dev/null +++ b/src/commands/skill.ts @@ -0,0 +1,39 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Command } from 'commander'; +import { action } from '../command.ts'; + +/** Same relative path from src/commands and dist/commands. */ +export const SKILL_SOURCE = fileURLToPath(new URL('../../skills/perspective/SKILL.md', import.meta.url)); + +export function registerSkill(program: Command): void { + const skill = program.command('skill').description('The agent skill (SKILL.md) that teaches coding agents to use this CLI'); + + skill + .command('show') + .description('Print SKILL.md (markdown, not JSON)') + .action(async () => { + process.stdout.write(await readFile(SKILL_SOURCE, 'utf8')); + }); + + skill + .command('install') + .description('Install the skill for Claude Code: ~/.claude/skills/perspective (or ./.claude/skills with --project)') + .option('--project', 'install into the current project instead of your user directory') + .option('--dir ', 'install into /perspective/SKILL.md (any skills directory)') + .action( + action(async (_ctx, opts: { project?: boolean; dir?: string }) => { + const root = opts.dir + ? resolve(opts.dir) + : opts.project + ? resolve('.claude', 'skills') + : join(homedir(), '.claude', 'skills'); + const target = join(root, 'perspective', 'SKILL.md'); + await mkdir(join(root, 'perspective'), { recursive: true }); + await writeFile(target, await readFile(SKILL_SOURCE, 'utf8')); + return { ok: true, installed: target }; + }), + ); +} diff --git a/src/commands/tools.ts b/src/commands/tools.ts new file mode 100644 index 0000000..a687c99 --- /dev/null +++ b/src/commands/tools.ts @@ -0,0 +1,87 @@ +import { Option, type Command } from 'commander'; +import { action } from '../command.ts'; +import { CliError } from '../errors.ts'; +import { applyAssignments, collect, readJsonObject } from '../input.ts'; +import { MCP_SCOPES, McpClient, unwrapToolResult, type McpScope } from '../mcp.ts'; +import type { Context } from '../context.ts'; + +async function client(ctx: Context, scope: McpScope): Promise { + return new McpClient(await ctx.baseUrl(), await ctx.accessToken(), scope); +} + +const scopeOption = () => + new Option('--scope ', 'MCP tool scope').choices([...MCP_SCOPES]).default('all'); + +export function registerTools(program: Command): void { + const tools = program + .command('tools') + .description( + 'Call any tool of the Perspective MCP server — funnels, sequences, brands, stats, CRM, images (needs `auth login`)', + ); + + tools + .command('list') + .description('List available tools (name, title, description)') + .addOption(scopeOption()) + .option('--schemas', 'include each input schema') + .action( + action(async (ctx, opts: { scope: McpScope; schemas?: boolean }) => { + const list = await (await client(ctx, opts.scope)).listTools(); + return { + data: list.map((t) => ({ + name: t.name, + ...(t.title && { title: t.title }), + description: t.description, + ...(t.annotations?.readOnlyHint !== undefined && { readOnly: t.annotations.readOnlyHint }), + ...(opts.schemas && { inputSchema: t.inputSchema }), + })), + }; + }), + ); + + tools + .command('describe') + .description('Show one tool with its full input schema') + .argument('') + .addOption(scopeOption()) + .action( + action(async (ctx, name: string, opts: { scope: McpScope }) => { + const list = await (await client(ctx, opts.scope)).listTools(); + const tool = list.find((t) => t.name === name); + if (!tool) { + throw new CliError('not_found', `No tool named "${name}" in scope "${opts.scope}"`, { + hint: 'Run `perspective tools list` to see available tools.', + }); + } + return tool; + }), + ); + + tools + .command('call') + .description('Call a tool. Arguments come from --input and/or repeated -a key=value (values parsed as JSON when valid).') + .argument('') + .option('-i, --input ', 'arguments object as JSON, @file or - for stdin') + .option('-a, --arg ', 'one argument, repeatable; dots nest (brand.name=Acme)', collect, []) + .option('--raw', 'print the raw MCP result (content array) instead of the unwrapped value') + .addOption(scopeOption()) + .action( + action( + async ( + ctx, + name: string, + opts: { input?: string; arg: string[]; raw?: boolean; scope: McpScope }, + ) => { + const args = opts.input ? await readJsonObject(opts.input, '--input') : {}; + applyAssignments(args, opts.arg); + const result = await (await client(ctx, opts.scope)).callTool(name, args); + if (result.isError) { + throw new CliError('tool_error', `Tool ${name} returned an error`, { + details: unwrapToolResult(result), + }); + } + return opts.raw ? result : unwrapToolResult(result); + }, + ), + ); +} diff --git a/src/commands/workspaces.ts b/src/commands/workspaces.ts new file mode 100644 index 0000000..6fd2e63 --- /dev/null +++ b/src/commands/workspaces.ts @@ -0,0 +1,51 @@ +import type { Command } from 'commander'; +import { action } from '../command.ts'; +import { RestClient } from '../rest.ts'; + +interface Workspace { + id: string; + name: string; + campaigns: { id: string; name: string; status: string }[]; +} + +export function flattenFunnels(workspaces: Workspace[]) { + return workspaces.flatMap((ws) => + ws.campaigns.map((c) => ({ ...c, workspaceId: ws.id, workspaceName: ws.name })), + ); +} + +export function registerWorkspaces(program: Command): void { + program + .command('workspaces') + .description('Workspaces and the funnels in them') + .command('list') + .description('List workspaces with their funnels (campaigns)') + .action( + action(async (ctx) => { + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + return rest.request({ path: '/workspaces' }); + }), + ); + + program + .command('funnels') + .description('Funnels across all workspaces') + .command('list') + .description('List every funnel as a flat list — the quickest way to find a funnel ID') + .option('--status ', 'only funnels with this status (online|offline)') + .option('--name ', 'only funnels whose name contains this text (case-insensitive)') + .action( + action(async (ctx, opts: { status?: string; name?: string }) => { + const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); + const { data } = await rest.request<{ data: Workspace[] }>({ path: '/workspaces' }); + const needle = opts.name?.toLowerCase(); + return { + data: flattenFunnels(data).filter( + (f) => + (!opts.status || f.status === opts.status) && + (!needle || f.name.toLowerCase().includes(needle)), + ), + }; + }), + ); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..d82d10c --- /dev/null +++ b/src/config.ts @@ -0,0 +1,54 @@ +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export const DEFAULT_BASE_URL = 'https://api.perspective.co'; + +export interface StoredOAuth { + accessToken: string; + refreshToken?: string; + /** Epoch milliseconds. */ + expiresAt?: number; + clientId: string; +} + +export interface StoredConfig { + baseUrl?: string; + apiKey?: string; + oauth?: StoredOAuth; +} + +export function configDir(env: NodeJS.ProcessEnv = process.env): string { + if (env.PERSPECTIVE_CONFIG_DIR) return env.PERSPECTIVE_CONFIG_DIR; + const xdg = env.XDG_CONFIG_HOME || join(homedir(), '.config'); + return join(xdg, 'perspective'); +} + +export function configPath(env: NodeJS.ProcessEnv = process.env): string { + return join(configDir(env), 'credentials.json'); +} + +export async function loadConfig(env: NodeJS.ProcessEnv = process.env): Promise { + try { + return JSON.parse(await readFile(configPath(env), 'utf8')) as StoredConfig; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; + throw err; + } +} + +/** The file holds secrets: the directory is 0700 and the file 0600. */ +export async function saveConfig( + config: StoredConfig, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const dir = configDir(env); + await mkdir(dir, { recursive: true, mode: 0o700 }); + const path = configPath(env); + await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + await chmod(path, 0o600); +} + +export async function clearConfig(env: NodeJS.ProcessEnv = process.env): Promise { + await rm(configPath(env), { force: true }); +} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..ed68760 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,72 @@ +import { CliError } from './errors.ts'; +import { DEFAULT_BASE_URL, loadConfig, saveConfig, type StoredConfig } from './config.ts'; +import { isExpired, refresh } from './oauth.ts'; + +export interface GlobalOptions { + baseUrl?: string; + apiKey?: string; + token?: string; + pretty?: boolean; +} + +/** + * Resolves credentials per call. Precedence, highest first: + * command-line flag → environment variable → stored credentials file. + */ +export class Context { + readonly opts: GlobalOptions; + readonly env: NodeJS.ProcessEnv; + private config?: StoredConfig; + + constructor(opts: GlobalOptions, env: NodeJS.ProcessEnv = process.env) { + this.opts = opts; + this.env = env; + } + + async stored(): Promise { + this.config ??= await loadConfig(this.env); + return this.config; + } + + async baseUrl(): Promise { + const url = + this.opts.baseUrl ?? + this.env.PERSPECTIVE_BASE_URL ?? + (await this.stored()).baseUrl ?? + DEFAULT_BASE_URL; + return url.replace(/\/+$/, ''); + } + + /** API key for the REST API (`/v1`). */ + async apiKey(): Promise { + const key = this.opts.apiKey ?? this.env.PERSPECTIVE_API_KEY ?? (await this.stored()).apiKey; + if (!key) { + throw new CliError('unauthenticated', 'No API key configured', { + hint: + 'REST commands need an API key. Create one under Settings → API Keys, then run ' + + '`perspective auth login --with-api-key` (reads the key from stdin) or set PERSPECTIVE_API_KEY.', + }); + } + return key; + } + + /** OAuth access token for the MCP server (`/mcp`). Refreshes and persists when expired. */ + async accessToken(): Promise { + const explicit = this.opts.token ?? this.env.PERSPECTIVE_TOKEN; + if (explicit) return explicit; + + const config = await this.stored(); + if (!config.oauth) { + throw new CliError('unauthenticated', 'Not logged in', { + hint: + 'MCP-backed commands (`perspective tools …`) need an OAuth session. ' + + 'Run `perspective auth login`, or set PERSPECTIVE_TOKEN.', + }); + } + if (isExpired(config.oauth)) { + config.oauth = await refresh(await this.baseUrl(), config.oauth); + await saveConfig(config, this.env); + } + return config.oauth.accessToken; + } +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..4e35476 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,86 @@ +/** + * Exit codes are part of the CLI's contract — scripts and agents branch on + * them — so they never change meaning. Add new codes, never renumber. + */ +export const ExitCode = { + OK: 0, + ERROR: 1, + USAGE: 2, + UNAUTHENTICATED: 3, + FORBIDDEN: 4, + NOT_FOUND: 5, + RATE_LIMITED: 6, + NETWORK: 7, +} as const; + +export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]; + +export type ErrorCode = + | 'error' + | 'usage' + | 'unauthenticated' + | 'forbidden' + | 'not_found' + | 'rate_limited' + | 'network' + | 'tool_error'; + +const EXIT_BY_CODE: Record = { + error: ExitCode.ERROR, + tool_error: ExitCode.ERROR, + usage: ExitCode.USAGE, + unauthenticated: ExitCode.UNAUTHENTICATED, + forbidden: ExitCode.FORBIDDEN, + not_found: ExitCode.NOT_FOUND, + rate_limited: ExitCode.RATE_LIMITED, + network: ExitCode.NETWORK, +}; + +export class CliError extends Error { + readonly code: ErrorCode; + readonly status?: number; + readonly hint?: string; + readonly details?: unknown; + + constructor( + code: ErrorCode, + message: string, + opts: { status?: number; hint?: string; details?: unknown } = {}, + ) { + super(message); + this.name = 'CliError'; + this.code = code; + this.status = opts.status; + this.hint = opts.hint; + this.details = opts.details; + } + + get exitCode(): ExitCodeValue { + return EXIT_BY_CODE[this.code]; + } + + toJSON(): Record { + return { + error: { + code: this.code, + message: this.message, + ...(this.status !== undefined && { status: this.status }), + ...(this.hint && { hint: this.hint }), + ...(this.details !== undefined && { details: this.details }), + }, + }; + } +} + +export function errorCodeForStatus(status: number): ErrorCode { + if (status === 401) return 'unauthenticated'; + if (status === 403) return 'forbidden'; + if (status === 404) return 'not_found'; + if (status === 429) return 'rate_limited'; + if (status === 400 || status === 422) return 'usage'; + return 'error'; +} + +export function usage(message: string, hint?: string): CliError { + return new CliError('usage', message, { hint }); +} diff --git a/src/input.ts b/src/input.ts new file mode 100644 index 0000000..5105a2d --- /dev/null +++ b/src/input.ts @@ -0,0 +1,77 @@ +import { readFile } from 'node:fs/promises'; +import { usage } from './errors.ts'; + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString('utf8'); +} + +/** Reads text from a literal, `@path` (file) or `-` (stdin). */ +export async function readTextArg(value: string): Promise { + if (value === '-') return readStdin(); + if (value.startsWith('@')) return readFile(value.slice(1), 'utf8'); + return value; +} + +/** Parses a JSON object from a literal, `@path` or `-`. */ +export async function readJsonObject(value: string, what = 'input'): Promise> { + const text = await readTextArg(value); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + throw usage(`${what} is not valid JSON: ${(err as Error).message}`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw usage(`${what} must be a JSON object`); + } + return parsed as Record; +} + +/** + * `key=value` → [key, value]. The value is parsed as JSON when it is valid + * JSON (numbers, booleans, arrays, objects, quoted strings) and kept as a + * plain string otherwise. Dotted keys nest: `address.city=Berlin`. + */ +export function parseAssignment(raw: string): [string[], unknown] { + const eq = raw.indexOf('='); + if (eq <= 0) throw usage(`Expected key=value, got "${raw}"`); + const key = raw.slice(0, eq); + const text = raw.slice(eq + 1); + let value: unknown = text; + try { + value = JSON.parse(text); + } catch { + // plain string + } + return [key.split('.'), value]; +} + +export function applyAssignments(target: Record, assignments: string[]): Record { + for (const raw of assignments) { + const [path, value] = parseAssignment(raw); + let node = target; + for (const segment of path.slice(0, -1)) { + const next = node[segment]; + if (!next || typeof next !== 'object' || Array.isArray(next)) node[segment] = {}; + node = node[segment] as Record; + } + node[path[path.length - 1]!] = value; + } + return target; +} + +export function collect(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + +export function parseInteger(name: string, min: number, max = Number.MAX_SAFE_INTEGER) { + return (value: string): number => { + const n = Number(value); + if (!Number.isInteger(n) || n < min || n > max) { + throw usage(`--${name} must be an integer between ${min} and ${max}`); + } + return n; + }; +} diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..95cf156 --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,159 @@ +import { CliError, errorCodeForStatus } from './errors.ts'; + +/** + * Minimal MCP client for the hosted Perspective server. + * + * The server runs the Streamable HTTP transport in stateless mode (no session + * id, a fresh server per POST), so every JSON-RPC call is one independent + * POST and no `initialize` handshake is needed. Replies arrive either as a + * JSON body or as a single-message SSE stream; both are handled. + */ + +export const MCP_SCOPES = ['all', 'funnel-build', 'sequences', 'analytics', 'crm'] as const; +export type McpScope = (typeof MCP_SCOPES)[number]; + +const PROTOCOL_VERSION = '2025-06-18'; + +export interface McpTool { + name: string; + title?: string; + description?: string; + inputSchema?: unknown; + annotations?: Record; +} + +export interface McpContent { + type: string; + text?: string; + [key: string]: unknown; +} + +export interface McpCallResult { + content?: McpContent[]; + structuredContent?: unknown; + isError?: boolean; +} + +interface JsonRpcResponse { + jsonrpc: '2.0'; + id?: number | string | null; + result?: T; + error?: { code: number; message: string; data?: unknown }; +} + +export function isMcpScope(value: string): value is McpScope { + return (MCP_SCOPES as readonly string[]).includes(value); +} + +/** Extracts the JSON-RPC response from an SSE body (`event: message\ndata: {...}`). */ +export function parseSse(body: string, id: number): JsonRpcResponse | undefined { + for (const event of body.split(/\r?\n\r?\n/)) { + const data = event + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trimStart()) + .join('\n'); + if (!data) continue; + const message = JSON.parse(data) as JsonRpcResponse; + if (message.id === id) return message; + } + return undefined; +} + +export class McpClient { + private readonly endpoint: string; + private readonly token: string; + private readonly fetchImpl: typeof fetch; + private nextId = 1; + + constructor(baseUrl: string, token: string, scope: McpScope = 'all', fetchImpl: typeof fetch = fetch) { + this.endpoint = scope === 'all' ? `${baseUrl}/mcp` : `${baseUrl}/mcp/${scope}`; + this.token = token; + this.fetchImpl = fetchImpl; + } + + async rpc(method: string, params?: Record): Promise { + const id = this.nextId++; + let res: Response; + try { + res = await this.fetchImpl(this.endpoint, { + method: 'POST', + headers: { + authorization: `Bearer ${this.token}`, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': PROTOCOL_VERSION, + 'user-agent': 'perspective-cli', + }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, ...(params && { params }) }), + }); + } catch (err) { + throw new CliError('network', `Could not reach ${this.endpoint}: ${(err as Error).message}`); + } + + const text = await res.text(); + if (!res.ok) { + let message = `HTTP ${res.status} ${res.statusText}`; + try { + const body = JSON.parse(text) as { error_description?: string; message?: string; error?: unknown }; + message = body.error_description ?? body.message ?? (typeof body.error === 'string' ? body.error : message); + } catch { + // keep the status line + } + const code = errorCodeForStatus(res.status); + throw new CliError(code, message, { + status: res.status, + hint: code === 'unauthenticated' ? 'Run `perspective auth login`.' : undefined, + }); + } + + const contentType = res.headers.get('content-type') ?? ''; + const message = contentType.includes('text/event-stream') + ? parseSse(text, id) + : (JSON.parse(text) as JsonRpcResponse); + + if (!message) throw new CliError('error', `No response to ${method} from the MCP server`); + if (message.error) { + throw new CliError(message.error.code === -32602 ? 'usage' : 'error', message.error.message, { + details: message.error.data, + }); + } + return message.result as T; + } + + async listTools(): Promise { + const tools: McpTool[] = []; + let cursor: string | undefined; + do { + const page = await this.rpc<{ tools: McpTool[]; nextCursor?: string }>( + 'tools/list', + cursor ? { cursor } : undefined, + ); + tools.push(...page.tools); + cursor = page.nextCursor; + } while (cursor); + return tools; + } + + callTool(name: string, args: Record): Promise { + return this.rpc('tools/call', { name, arguments: args }); + } +} + +/** + * Reduces a tool result to the most useful JSON value: structured content if + * the tool returned it, otherwise the text parts — each parsed as JSON when + * it is JSON. Non-text parts (images, resources) are passed through. + */ +export function unwrapToolResult(result: McpCallResult): unknown { + if (result.structuredContent !== undefined) return result.structuredContent; + const parts = (result.content ?? []).map((part) => { + if (part.type !== 'text' || part.text === undefined) return part; + try { + return JSON.parse(part.text) as unknown; + } catch { + return part.text; + } + }); + return parts.length === 1 ? parts[0] : parts; +} diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..168991e --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,191 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { CliError } from './errors.ts'; +import type { StoredOAuth } from './config.ts'; + +/** + * OAuth 2.1 authorization-code flow with PKCE against the External API's + * OAuth proxy (/oauth/register, /oauth/authorize, /oauth/token), which + * fronts Auth0. The redirect goes to a one-shot loopback server + * (RFC 8252); the proxy allowlists http://127.0.0.1 for exactly this. + */ + +const SCOPE = 'openid offline_access'; +const CALLBACK_PATH = '/callback'; +const LOGIN_TIMEOUT_MS = 5 * 60_000; +/** Refresh this long before the token actually expires. */ +const EXPIRY_SKEW_MS = 60_000; + +export function base64url(buf: Buffer): string { + return buf.toString('base64url'); +} + +export function createPkcePair(): { verifier: string; challenge: string } { + const verifier = base64url(randomBytes(32)); + const challenge = base64url(createHash('sha256').update(verifier).digest()); + return { verifier, challenge }; +} + +interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in?: number; +} + +function toStored(token: TokenResponse, clientId: string, previous?: StoredOAuth): StoredOAuth { + return { + accessToken: token.access_token, + // Auth0 only rotates the refresh token when rotation is enabled; keep the old one otherwise. + refreshToken: token.refresh_token ?? previous?.refreshToken, + expiresAt: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined, + clientId, + }; +} + +async function postToken(baseUrl: string, params: Record): Promise { + let res: Response; + try { + res = await fetch(`${baseUrl}/oauth/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(params).toString(), + }); + } catch (err) { + throw new CliError('network', `Could not reach ${baseUrl}: ${(err as Error).message}`); + } + const body = (await res.json().catch(() => ({}))) as TokenResponse & { + error?: string; + error_description?: string; + }; + if (!res.ok || !body.access_token) { + throw new CliError( + 'unauthenticated', + `Token request failed: ${body.error_description ?? body.error ?? res.statusText}`, + { status: res.status, hint: 'Run `perspective auth login` again.' }, + ); + } + return body; +} + +async function registerClient(baseUrl: string, redirectUri: string): Promise { + const res = await fetch(`${baseUrl}/oauth/register`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ redirect_uris: [redirectUri], client_name: 'Perspective CLI' }), + }); + const body = (await res.json().catch(() => ({}))) as { client_id?: string; error?: string }; + if (!res.ok || !body.client_id) { + throw new CliError('error', `Client registration failed: ${body.error ?? res.statusText}`, { + status: res.status, + }); + } + return body.client_id; +} + +function listen(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port)); + }); +} + +const PAGE = (title: string, body: string) => + `${title}` + + `` + + `

${title}

${body}

`; + +export interface LoginOptions { + baseUrl: string; + /** Called with the authorize URL; opens a browser or prints it. */ + onAuthorizeUrl: (url: string) => void | Promise; +} + +export async function login({ baseUrl, onAuthorizeUrl }: LoginOptions): Promise { + const state = base64url(randomBytes(16)); + const { verifier, challenge } = createPkcePair(); + + let settle!: { resolve: (code: string) => void; reject: (err: Error) => void }; + const codePromise = new Promise((resolve, reject) => { + settle = { resolve, reject }; + }); + // A callback can fail before we await the promise; don't let that become an unhandled rejection. + codePromise.catch(() => {}); + + const server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://127.0.0.1'); + if (url.pathname !== CALLBACK_PATH) { + res.writeHead(404).end(); + return; + } + const error = url.searchParams.get('error'); + const code = url.searchParams.get('code'); + res.setHeader('content-type', 'text/html; charset=utf-8'); + if (url.searchParams.get('state') !== state) { + res.writeHead(400).end(PAGE('Login failed', 'State mismatch. Close this tab and retry.')); + settle.reject(new CliError('unauthenticated', 'OAuth state mismatch')); + } else if (error || !code) { + const description = url.searchParams.get('error_description') ?? error ?? 'no code'; + res.writeHead(400).end(PAGE('Login failed', 'You can close this tab.')); + settle.reject(new CliError('unauthenticated', `Authorization failed: ${description}`)); + } else { + res.writeHead(200).end( + PAGE('Logged in to Perspective', 'You can close this tab and return to the terminal.'), + ); + settle.resolve(code); + } + }); + + const port = await listen(server); + const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`; + const timer = setTimeout( + () => settle.reject(new CliError('unauthenticated', 'Timed out waiting for browser login')), + LOGIN_TIMEOUT_MS, + ); + + try { + const clientId = await registerClient(baseUrl, redirectUri); + const authorize = new URL(`${baseUrl}/oauth/authorize`); + authorize.search = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: SCOPE, + state, + code_challenge: challenge, + code_challenge_method: 'S256', + }).toString(); + await onAuthorizeUrl(authorize.toString()); + + const code = await codePromise; + const token = await postToken(baseUrl, { + grant_type: 'authorization_code', + code, + code_verifier: verifier, + client_id: clientId, + redirect_uri: redirectUri, + }); + return toStored(token, clientId); + } finally { + clearTimeout(timer); + server.close(); + } +} + +export function isExpired(oauth: StoredOAuth, now = Date.now()): boolean { + return oauth.expiresAt !== undefined && oauth.expiresAt - EXPIRY_SKEW_MS <= now; +} + +export async function refresh(baseUrl: string, oauth: StoredOAuth): Promise { + if (!oauth.refreshToken) { + throw new CliError('unauthenticated', 'Session expired', { + hint: 'Run `perspective auth login` again.', + }); + } + const token = await postToken(baseUrl, { + grant_type: 'refresh_token', + refresh_token: oauth.refreshToken, + client_id: oauth.clientId, + }); + return toStored(token, oauth.clientId, oauth); +} diff --git a/src/output.ts b/src/output.ts new file mode 100644 index 0000000..3c0f48b --- /dev/null +++ b/src/output.ts @@ -0,0 +1,32 @@ +import { CliError } from './errors.ts'; + +export interface OutputOptions { + pretty?: boolean; +} + +/** Pretty-print for humans at a terminal, compact JSON when piped (agents, jq). */ +function indentFor(opts: OutputOptions, stream: NodeJS.WriteStream): number | undefined { + const pretty = opts.pretty ?? stream.isTTY === true; + return pretty ? 2 : undefined; +} + +export function printResult(value: unknown, opts: OutputOptions = {}): void { + if (value === undefined) return; + process.stdout.write(`${JSON.stringify(value, null, indentFor(opts, process.stdout))}\n`); +} + +export function printError(err: unknown, opts: OutputOptions = {}): number { + const cliError = + err instanceof CliError + ? err + : new CliError('error', err instanceof Error ? err.message : String(err)); + process.stderr.write( + `${JSON.stringify(cliError.toJSON(), null, indentFor(opts, process.stderr))}\n`, + ); + return cliError.exitCode; +} + +/** Human-facing progress text. Always stderr, so stdout stays pure JSON. */ +export function info(message: string): void { + process.stderr.write(`${message}\n`); +} diff --git a/src/rest.ts b/src/rest.ts new file mode 100644 index 0000000..144a352 --- /dev/null +++ b/src/rest.ts @@ -0,0 +1,80 @@ +import { CliError, errorCodeForStatus } from './errors.ts'; + +export type Query = Record; + +export interface RestRequest { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + path: string; + query?: Query; + body?: unknown; +} + +const USER_AGENT = 'perspective-cli'; + +/** Client for the REST API under /v1. Returns the parsed JSON body as-is ({data} / {data, meta}). */ +export class RestClient { + readonly baseUrl: string; + private readonly apiKey: string; + private readonly fetchImpl: typeof fetch; + + constructor(baseUrl: string, apiKey: string, fetchImpl: typeof fetch = fetch) { + this.baseUrl = baseUrl; + this.apiKey = apiKey; + this.fetchImpl = fetchImpl; + } + + url(path: string, query: Query = {}): string { + const url = new URL(`/v1${path}`, this.baseUrl); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + return url.toString(); + } + + async request({ method = 'GET', path, query, body }: RestRequest): Promise { + let res: Response; + try { + res = await this.fetchImpl(this.url(path, query), { + method, + headers: { + 'x-perspective-api-key': this.apiKey, + accept: 'application/json', + 'user-agent': USER_AGENT, + ...(body !== undefined && { 'content-type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (err) { + throw new CliError('network', `Could not reach ${this.baseUrl}: ${(err as Error).message}`); + } + + const text = await res.text(); + let parsed: unknown = undefined; + try { + parsed = text ? JSON.parse(text) : undefined; + } catch { + parsed = text; + } + + if (!res.ok) throw httpError(res, parsed); + return parsed as T; + } +} + +function httpError(res: Response, body: unknown): CliError { + const message = + (body && typeof body === 'object' && 'error' in body && typeof body.error === 'string' + ? body.error + : undefined) ?? `HTTP ${res.status} ${res.statusText}`; + const code = errorCodeForStatus(res.status); + const retryAfter = res.headers.get('retry-after') ?? res.headers.get('ratelimit-reset'); + const hint = + code === 'unauthenticated' + ? 'Check your API key (`perspective auth status`).' + : code === 'forbidden' + ? 'The API key lacks the permission this endpoint needs (e.g. crm:read, metrics:read).' + : code === 'rate_limited' + ? `Rate limit is 100 requests/minute.${retryAfter ? ` Retry in ${retryAfter}s.` : ''}` + : undefined; + return new CliError(code, message, { status: res.status, hint }); +} diff --git a/test/clients.test.ts b/test/clients.test.ts new file mode 100644 index 0000000..e9c3256 --- /dev/null +++ b/test/clients.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { CliError, ExitCode } from '../src/errors.ts'; +import { McpClient, parseSse, unwrapToolResult } from '../src/mcp.ts'; +import { RestClient } from '../src/rest.ts'; + +type Call = { url: string; init: RequestInit }; + +function fakeFetch(respond: (call: Call) => Response) { + const calls: Call[] = []; + const impl = (async (url: string | URL, init: RequestInit = {}) => { + const call = { url: String(url), init }; + calls.push(call); + return respond(call); + }) as typeof fetch; + return { impl, calls }; +} + +const json = (status: number, body: unknown, headers: Record = {}) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } }); + +describe('RestClient', () => { + it('sends the api key header and builds /v1 URLs with query params', async () => { + const { impl, calls } = fakeFetch(() => json(200, { data: [] })); + const rest = new RestClient('https://api.example.com', 'k_123', impl); + await rest.request({ path: '/funnels/f1/contacts', query: { page: 2, limit: undefined } }); + expect(calls[0]!.url).toBe('https://api.example.com/v1/funnels/f1/contacts?page=2'); + expect((calls[0]!.init.headers as Record)['x-perspective-api-key']).toBe('k_123'); + }); + + it.each([ + [401, 'unauthenticated', ExitCode.UNAUTHENTICATED], + [403, 'forbidden', ExitCode.FORBIDDEN], + [404, 'not_found', ExitCode.NOT_FOUND], + [429, 'rate_limited', ExitCode.RATE_LIMITED], + [400, 'usage', ExitCode.USAGE], + [500, 'error', ExitCode.ERROR], + ])('maps HTTP %i to %s', async (status, code, exit) => { + const { impl } = fakeFetch(() => json(status, { error: 'boom', status })); + const rest = new RestClient('https://api.example.com', 'k', impl); + const err = (await rest.request({ path: '/x' }).catch((e: unknown) => e)) as CliError; + expect(err).toBeInstanceOf(CliError); + expect(err.code).toBe(code); + expect(err.exitCode).toBe(exit); + expect(err.message).toBe('boom'); + }); + + it('reports network failures with the network code', async () => { + const impl = (async () => { + throw new TypeError('fetch failed'); + }) as typeof fetch; + const err = (await new RestClient('https://api.example.com', 'k', impl) + .request({ path: '/x' }) + .catch((e: unknown) => e)) as CliError; + expect(err.code).toBe('network'); + }); +}); + +describe('parseSse', () => { + it('finds the response with the matching id', () => { + const body = 'event: message\ndata: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}\n\n'; + expect(parseSse(body, 7)?.result).toEqual({ ok: true }); + expect(parseSse(body, 8)).toBeUndefined(); + }); +}); + +describe('McpClient', () => { + it('posts JSON-RPC to the scoped endpoint with a bearer token and parses SSE replies', async () => { + const { impl, calls } = fakeFetch(({ init }) => { + const { id } = JSON.parse(String(init.body)) as { id: number }; + return new Response( + `event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id, result: { tools: [{ name: 'list_brands' }] } })}\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ); + }); + const mcp = new McpClient('https://api.example.com', 'tok', 'crm', impl); + expect(await mcp.listTools()).toEqual([{ name: 'list_brands' }]); + expect(calls[0]!.url).toBe('https://api.example.com/mcp/crm'); + expect((calls[0]!.init.headers as Record).authorization).toBe('Bearer tok'); + expect(JSON.parse(String(calls[0]!.init.body))).toMatchObject({ method: 'tools/list' }); + }); + + it('follows tools/list pagination', async () => { + const { impl } = fakeFetch(({ init }) => { + const { id, params } = JSON.parse(String(init.body)) as { id: number; params?: { cursor?: string } }; + const result = params?.cursor + ? { tools: [{ name: 'b' }] } + : { tools: [{ name: 'a' }], nextCursor: 'c1' }; + return json(200, { jsonrpc: '2.0', id, result }); + }); + const tools = await new McpClient('https://x', 't', 'all', impl).listTools(); + expect(tools.map((t) => t.name)).toEqual(['a', 'b']); + }); + + it('surfaces JSON-RPC errors', async () => { + const { impl } = fakeFetch(({ init }) => { + const { id } = JSON.parse(String(init.body)) as { id: number }; + return json(200, { jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params' } }); + }); + const err = (await new McpClient('https://x', 't', 'all', impl) + .callTool('x', {}) + .catch((e: unknown) => e)) as CliError; + expect(err.code).toBe('usage'); + expect(err.message).toBe('Invalid params'); + }); + + it('maps a 401 with an OAuth error body to unauthenticated', async () => { + const { impl } = fakeFetch(() => json(401, { error: 'unauthorized', error_description: 'Invalid token' })); + const err = (await new McpClient('https://x', 't', 'all', impl).listTools().catch((e: unknown) => e)) as CliError; + expect(err.code).toBe('unauthenticated'); + expect(err.message).toBe('Invalid token'); + }); +}); + +describe('unwrapToolResult', () => { + it('prefers structured content', () => { + expect(unwrapToolResult({ structuredContent: { a: 1 }, content: [{ type: 'text', text: 'x' }] })).toEqual({ a: 1 }); + }); + it('parses a single JSON text part', () => { + expect(unwrapToolResult({ content: [{ type: 'text', text: '{"id":"f1"}' }] })).toEqual({ id: 'f1' }); + }); + it('keeps non-JSON text and returns arrays for multiple parts', () => { + expect( + unwrapToolResult({ + content: [ + { type: 'text', text: 'hello' }, + { type: 'text', text: '[1]' }, + ], + }), + ).toEqual(['hello', [1]]); + }); +}); diff --git a/test/units.test.ts b/test/units.test.ts new file mode 100644 index 0000000..868aee7 --- /dev/null +++ b/test/units.test.ts @@ -0,0 +1,131 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mask } from '../src/commands/auth.ts'; +import { KPI_SUBTYPES, normalizeSubtype, resolveWindow } from '../src/commands/metrics.ts'; +import { flattenFunnels } from '../src/commands/workspaces.ts'; +import { configPath, loadConfig, saveConfig } from '../src/config.ts'; +import { Context } from '../src/context.ts'; +import { CliError } from '../src/errors.ts'; +import { applyAssignments, parseAssignment } from '../src/input.ts'; +import { createPkcePair, isExpired } from '../src/oauth.ts'; + +describe('assignments', () => { + it('parses JSON values and falls back to strings', () => { + expect(parseAssignment('limit=20')).toEqual([['limit'], 20]); + expect(parseAssignment('noBrand=true')).toEqual([['noBrand'], true]); + expect(parseAssignment('name=Spring sale')).toEqual([['name'], 'Spring sale']); + expect(parseAssignment('q=a=b')).toEqual([['q'], 'a=b']); + }); + + it('nests dotted keys and merges into existing objects', () => { + const target = { data: { name: 'x' } } as Record; + applyAssignments(target, ['data.prompt=hi', 'data.noBrand=true']); + expect(target).toEqual({ data: { name: 'x', prompt: 'hi', noBrand: true } }); + }); + + it('rejects malformed input as a usage error', () => { + expect(() => parseAssignment('novalue')).toThrow(CliError); + }); +}); + +describe('metrics helpers', () => { + it('normalizes subtype spellings', () => { + expect(normalizeSubtype('conversion-rate', 'kpi', KPI_SUBTYPES)).toBe('kpi_conversion_rate'); + expect(normalizeSubtype('kpi_new_contacts', 'kpi', KPI_SUBTYPES)).toBe('kpi_new_contacts'); + expect(() => normalizeSubtype('nope', 'kpi', KPI_SUBTYPES)).toThrow(/Unknown kpi subtype/); + }); + + it('defaults to the last N days and honours explicit bounds', () => { + const now = new Date('2026-10-19T12:00:00.000Z'); + const w = resolveWindow({ days: 7, tzOffset: '0' }, now); + expect(w).toEqual({ from: '2026-10-12T12:00:00.000Z', to: '2026-10-19T12:00:00.000Z', offset: '0' }); + const explicit = resolveWindow({ from: '2026-09-01', to: '2026-10-01', days: 30, tzOffset: '120' }, now); + expect(explicit.from).toBe('2026-09-01T00:00:00.000Z'); + expect(explicit.offset).toBe('120'); + expect(() => resolveWindow({ from: '2026-10-02', to: '2026-10-01', days: 30 }, now)).toThrow(/before/); + }); +}); + +describe('flattenFunnels', () => { + it('lifts campaigns out of workspaces', () => { + expect( + flattenFunnels([{ id: 'w1', name: 'Main', campaigns: [{ id: 'f1', name: 'Quiz', status: 'online' }] }]), + ).toEqual([{ id: 'f1', name: 'Quiz', status: 'online', workspaceId: 'w1', workspaceName: 'Main' }]); + }); +}); + +describe('oauth helpers', () => { + it('creates an S256 PKCE pair', () => { + const { verifier, challenge } = createPkcePair(); + expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(challenge).toBe(createHash('sha256').update(verifier).digest('base64url')); + }); + + it('treats tokens as expired one minute early', () => { + expect(isExpired({ accessToken: 'a', clientId: 'c', expiresAt: 1_000_000 }, 1_000_000 - 30_000)).toBe(true); + expect(isExpired({ accessToken: 'a', clientId: 'c', expiresAt: 1_000_000 }, 1_000_000 - 120_000)).toBe(false); + expect(isExpired({ accessToken: 'a', clientId: 'c' })).toBe(false); + }); + + it('masks secrets', () => { + expect(mask('abcd1234efgh')).toBe('abcd…efgh'); + expect(mask('short')).toBe('••••'); + }); +}); + +describe('config + context', () => { + afterEach(() => vi.unstubAllGlobals()); + + async function tempEnv(): Promise { + return { PERSPECTIVE_CONFIG_DIR: await mkdtemp(join(tmpdir(), 'pcli-')) }; + } + + it('stores credentials with 0600 permissions', async () => { + const env = await tempEnv(); + await saveConfig({ apiKey: 'k' }, env); + expect((await stat(configPath(env))).mode & 0o777).toBe(0o600); + expect(await loadConfig(env)).toEqual({ apiKey: 'k' }); + }); + + it('resolves flag > env > file', async () => { + const env = await tempEnv(); + await saveConfig({ apiKey: 'from-file', baseUrl: 'https://file.example' }, env); + expect(await new Context({}, env).apiKey()).toBe('from-file'); + expect(await new Context({}, { ...env, PERSPECTIVE_API_KEY: 'from-env' }).apiKey()).toBe('from-env'); + expect(await new Context({ apiKey: 'from-flag' }, { ...env, PERSPECTIVE_API_KEY: 'from-env' }).apiKey()).toBe( + 'from-flag', + ); + expect(await new Context({ baseUrl: 'https://flag.example/' }, env).baseUrl()).toBe('https://flag.example'); + }); + + it('fails with unauthenticated when nothing is configured', async () => { + const env = await tempEnv(); + await expect(new Context({}, env).apiKey()).rejects.toMatchObject({ code: 'unauthenticated' }); + await expect(new Context({}, env).accessToken()).rejects.toMatchObject({ code: 'unauthenticated' }); + }); + + it('refreshes an expired token and persists it', async () => { + const env = await tempEnv(); + await saveConfig( + { oauth: { accessToken: 'old', refreshToken: 'r1', expiresAt: Date.now() - 1000, clientId: 'c' } }, + env, + ); + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ access_token: 'new', expires_in: 3600 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + expect(await new Context({ baseUrl: 'https://api.example.com' }, env).accessToken()).toBe('new'); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://api.example.com/oauth/token'); + expect(String(init.body)).toContain('grant_type=refresh_token'); + const saved = await loadConfig(env); + expect(saved.oauth).toMatchObject({ accessToken: 'new', refreshToken: 'r1', clientId: 'c' }); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..3d32a63 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fbb4fd0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rewriteRelativeImportExtensions": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["src", "test"] +} From 72fd66a0cd24561980369c823d40a92e55dee060 Mon Sep 17 00:00:00 2001 From: Christian Korndoerfer Date: Mon, 19 Oct 2026 21:48:38 +0200 Subject: [PATCH 2/8] CAP-4819 refactor: make the CLI an External API wrapper only Drops the MCP client, the OAuth browser login and the `tools` commands. `auth login` now stores an API key: it prompts on a terminal, reads stdin when piped, or takes --api-key, and validates the key first. `auth token`, --token and PERSPECTIVE_TOKEN are gone. SKILL.md, README and AGENTS.md describe the API-only scope. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 12 +-- README.md | 35 +++---- package.json | 2 +- skills/perspective/SKILL.md | 75 +++++--------- src/cli.ts | 5 +- src/commands/auth.ts | 107 +++++++------------- src/commands/contacts.ts | 2 +- src/commands/metrics.ts | 2 +- src/commands/tools.ts | 87 ---------------- src/config.ts | 9 -- src/context.ts | 30 +----- src/errors.ts | 4 +- src/mcp.ts | 159 ------------------------------ src/oauth.ts | 191 ------------------------------------ src/output.ts | 5 - src/rest.ts | 2 +- test/clients.test.ts | 76 -------------- test/units.test.ts | 42 +------- 18 files changed, 88 insertions(+), 757 deletions(-) delete mode 100644 src/commands/tools.ts delete mode 100644 src/mcp.ts delete mode 100644 src/oauth.ts diff --git a/AGENTS.md b/AGENTS.md index 95be76f..e640572 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,14 +1,12 @@ # perspective-cli — agent notes -- TypeScript, ESM, Node ≥ 22.12. Runtime dependency: `commander` only. Keep it that way; - the MCP client is hand-rolled on purpose (the hosted server is stateless Streamable HTTP, - so one POST per JSON-RPC call), avoiding the SDK's server-side dependency tree. -- Contract that must not break: stdout is JSON only (human text goes to stderr via - `info()`); errors are `CliError` → JSON on stderr; exit codes in `src/errors.ts` are never +- TypeScript, ESM, Node ≥ 22.12. Runtime dependency: `commander` only. Keep it that way. +- Scope: a wrapper for the External API (`/v1`, `x-perspective-api-key`) and nothing else. + The MCP server is a separate product for chat assistants; don't add MCP or OAuth here. +- Contract that must not break: stdout is JSON only (prompts and other human text go to + stderr); errors are `CliError` → JSON on stderr; exit codes in `src/errors.ts` are never renumbered. - Every command goes through `action()` in `src/command.ts`. -- Two credentials: API key (`x-perspective-api-key`) for REST `/v1`, OAuth bearer for - `/mcp`. The server does not accept one for the other. - Changing a command means updating `skills/perspective/SKILL.md`, `README.md`, and the developer docs (`developer-docs/docs/cli/`). - Verify with `npm run typecheck`, `npm test` and `npm run build`. diff --git a/README.md b/README.md index cc23678..a791b89 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Perspective CLI -`perspective` drives [Perspective](https://perspective.co) from the shell. It covers the -[External REST API](https://developers.perspective.co/api-reference/overview) and every tool of -the [MCP server](https://developers.perspective.co/mcp/overview), and it is built to be driven -by both people and coding agents: output is JSON, errors are JSON, and exit codes are stable. +`perspective` drives [Perspective](https://perspective.co) from the shell. It wraps the +[External API](https://developers.perspective.co/api-reference/overview) and is built to be +driven by both people and coding agents: output is JSON, errors are JSON, and exit codes are +stable. Full docs: @@ -18,21 +18,16 @@ perspective --help ## Authenticate -Two credentials, one per API surface: - -| Command | Credential | Unlocks | -|---|---|---| -| `perspective auth login` | OAuth (browser login, refreshed automatically) | `tools …` (MCP: funnels, sequences, brands, stats, …) | -| `perspective auth login --with-api-key` | API key from Settings → API Keys (read from stdin) | `workspaces`, `funnels`, `contacts`, `metrics` | +The CLI uses an API key from Perspective → Settings → API Keys. ```bash -perspective auth login # opens the browser -printf %s "$KEY" | perspective auth login --with-api-key # stores and validates an API key +perspective auth login # prompts for the key, validates it, stores it +printf %s "$KEY" | perspective auth login # or pipe it in perspective auth status ``` -Credentials are stored in `~/.config/perspective/credentials.json` (mode 0600). In CI, set -`PERSPECTIVE_API_KEY` and/or `PERSPECTIVE_TOKEN` instead. Precedence is flag → env → file. +The key is stored in `~/.config/perspective/credentials.json` (mode 0600). In CI, set +`PERSPECTIVE_API_KEY` instead. Precedence is `--api-key` flag → env → file. ## Use @@ -42,11 +37,6 @@ perspective contacts list --all perspective contacts create --email ada@example.com --first-name Ada perspective metrics kpi conversion_rate --days 7 perspective metrics chart visitor_devices --from 2026-09-01 --to 2026-10-01 - -perspective tools list --scope funnel-build -perspective tools describe create_funnel -perspective tools call get_funnel_stats -a funnelId= -perspective tools call create_funnel --input @brief.json ``` Output is pretty-printed at a terminal and compact when piped (`--pretty` / `--no-pretty`). @@ -56,7 +46,7 @@ Output is pretty-printed at a terminal and compact when piped (`--pretty` / `--n | Code | Meaning | |---|---| | 0 | success | -| 1 | API or tool error | +| 1 | API error | | 2 | usage error (bad flags/arguments, HTTP 400) | | 3 | not authenticated (HTTP 401) | | 4 | forbidden (HTTP 403) | @@ -64,7 +54,7 @@ Output is pretty-printed at a terminal and compact when piped (`--pretty` / `--n | 6 | rate limited (HTTP 429, 100 requests/minute) | | 7 | network error | -Errors go to stderr as `{"error":{"code","message","status?","hint?","details?"}}`. +Errors go to stderr as `{"error":{"code","message","status?","hint?"}}`. ## Agents @@ -88,8 +78,7 @@ perspective --base-url http://localhost:3000 … # point at a local External A ``` Layout: `src/cli.ts` (entry) · `src/commands/*` (one file per command group) · `src/rest.ts` -(REST client) · `src/mcp.ts` (stateless MCP JSON-RPC client) · `src/oauth.ts` (PKCE loopback -login) · `src/context.ts` (credential resolution) · `skills/perspective/SKILL.md`. +(API client) · `src/context.ts` (credential resolution) · `skills/perspective/SKILL.md`. When you add or change a command, update `SKILL.md` and the docs in `Perspective-Software/developer-docs` (`docs/cli/`) in the same change. diff --git a/package.json b/package.json index 86eab1e..d066980 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@perspective-software/cli", "version": "0.1.0", - "description": "Command-line interface for the Perspective External API and MCP server", + "description": "Command-line interface for the Perspective External API", "private": true, "license": "UNLICENSED", "type": "module", diff --git a/skills/perspective/SKILL.md b/skills/perspective/SKILL.md index cb44226..81f13f7 100644 --- a/skills/perspective/SKILL.md +++ b/skills/perspective/SKILL.md @@ -1,13 +1,13 @@ --- name: perspective -description: Operate a Perspective account (perspective.co funnel builder) from the shell with the `perspective` CLI — find funnels, read and write CRM contacts, pull funnel KPIs/charts/insights, and call any Perspective MCP tool (create/edit/publish AI funnels, email sequences, brands, email stats). Use when the user asks about their Perspective funnels, leads/contacts, conversion metrics or sequences, or wants to script Perspective. +description: Read and write a Perspective account (perspective.co funnel builder) from the shell with the `perspective` CLI, a wrapper for the Perspective External API — list workspaces and funnels, read and write CRM contacts, and pull funnel KPIs, charts and question insights. Use when the user asks about their Perspective funnels, leads/contacts or conversion metrics, or wants to script Perspective. --- # Perspective CLI -`perspective` wraps the Perspective External API (REST, `/v1`) and the hosted -Perspective MCP server. Every command prints **JSON on stdout**; errors print -**JSON on stderr** with a non-zero exit code. Pipe into `jq` freely. +`perspective` wraps the Perspective External API (`https://api.perspective.co/v1`). +Every command prints **JSON on stdout**; errors print **JSON on stderr** with a +non-zero exit code. Pipe into `jq` freely. Docs: https://developers.perspective.co/cli/overview @@ -17,14 +17,12 @@ Docs: https://developers.perspective.co/cli/overview perspective auth status ``` -- `rest.authenticated: false` → REST commands (`workspaces`, `funnels`, `contacts`, `metrics`) will fail. - Ask the user for an API key (Perspective → Settings → API Keys, admins only), then - `printf %s "$KEY" | perspective auth login --with-api-key` or export `PERSPECTIVE_API_KEY`. -- `mcp.authenticated: false` → `tools` commands will fail. The user must run - `perspective auth login` themselves (opens a browser). You cannot complete - that flow headlessly; in CI use `PERSPECTIVE_TOKEN`. +If `authenticated` is `false`, every other command fails with exit code 3. Ask the +user for an API key (Perspective → Settings → API Keys, admins only). They can run +`perspective auth login` themselves (it prompts for the key), or you can pipe it in: +`printf %s "$KEY" | perspective auth login`. `PERSPECTIVE_API_KEY` works too. -Never print, log or commit an API key or token. +Never print, log or commit an API key. ## 1. Find IDs @@ -35,71 +33,46 @@ perspective funnels list --name "webinar" # flat list: id, name, status perspective workspaces list # nested: workspaces → campaigns ``` -## 2. REST commands (API key) +## 2. Contacts ```bash -# Contacts perspective contacts list --limit 100 --sort email --order asc perspective contacts list --all # every page, {data, meta.total} perspective contacts get perspective contacts create --email a@b.co --first-name Ada -s address.city=Berlin perspective contacts set status qualified # any field or custom property - -# Metrics — default window is the last 30 days in local time -perspective metrics kpi conversion_rate -perspective metrics kpi new_contacts --from 2026-09-01 --to 2026-10-01 -perspective metrics chart page_to_page_conversion_rate --ab-test variant -perspective metrics insight question_1234 --days 7 -perspective metrics subtypes # valid kpi/chart names ``` -Pagination for `contacts list` is page-based: read `meta.hasNext`, bump `--page`, or just use `--all`. +Pagination is page-based: read `meta.hasNext`, bump `--page`, or just use `--all`. +`-s key=value` values are parsed as JSON when valid (`-s score=5`); dots nest (`-s address.city=Berlin`). -## 3. MCP tools (OAuth login) — everything else +## 3. Metrics -Funnel creation/editing/publishing, email sequences, brands, domains, senders, -email stats and image uploads exist **only** as MCP tools. Discover, then call: +The default window is the last 30 days in local time. ```bash -perspective tools list # names + descriptions -perspective tools list --scope analytics # scopes: all, funnel-build, sequences, analytics, crm -perspective tools describe create_funnel # full JSON input schema — read it before calling -perspective tools call list_brands -perspective tools call get_funnel_stats -a funnelId= -a dateFrom=2026-10-01T00:00:00Z -perspective tools call create_funnel --input @brief.json -echo '{"jobId":"…"}' | perspective tools call get_funnel_job_status --input - +perspective metrics kpi conversion_rate +perspective metrics kpi new_contacts --from 2026-09-01 --to 2026-10-01 +perspective metrics chart page_to_page_conversion_rate --ab-test variant +perspective metrics insight question_1234 --days 7 +perspective metrics subtypes # valid kpi/chart names ``` -- `-a key=value` values are parsed as JSON when valid (`-a limit=20`, `-a noBrand=true`); dots nest (`-a data.name=Spring`). -- `--input` takes inline JSON, `@file`, or `-` (stdin); `-a` overrides it. -- Output is the tool's structured result, or its text parsed as JSON. `--raw` gives the MCP content array. -- A tool-level failure exits 1 with `error.code: "tool_error"` and the tool's message in `error.details`. - -### Recipe: build an AI funnel - -1. `tools call list_brands` → pick a brand with the user (or confirm they want none). -2. `tools describe create_funnel` → follow the description; it asks for a rich brief. -3. `tools call create_funnel --input @brief.json` → returns `jobId`, `funnelId`. -4. Poll `tools call get_funnel_job_status -a jobId=` (long-polls ~20 s) until completed. -5. Edits: `tools call update_funnel -a funnelId= -a prompt="…"`. Publishing: `update_funnel_metadata`. - ## Exit codes | Code | `error.code` | Meaning / what to do | |---|---|---| | 0 | — | Success | -| 1 | `error`, `tool_error` | API or tool error — read `error.message` / `error.details` | +| 1 | `error` | API error — read `error.message` | | 2 | `usage` | Bad arguments or 400 from the API — fix the call (see `error.hint`) | -| 3 | `unauthenticated` | Missing/invalid credentials — see step 0 | +| 3 | `unauthenticated` | Missing/invalid API key — see step 0 | | 4 | `forbidden` | Key lacks the permission (`crm:read`, `crm:write`, `metrics:read`) or plan | -| 5 | `not_found` | Wrong funnel/contact/tool ID — re-discover IDs | +| 5 | `not_found` | Wrong funnel or contact ID — re-discover IDs | | 6 | `rate_limited` | 100 requests/minute — wait and retry; prefer `--all` over many small calls | | 7 | `network` | API unreachable | ## Rules for agents -- **Read before you write.** `contacts create`, `contacts set`, and tools that create, update, publish, unpublish or send act on a live account: they can trigger automations and emails to real people, and change public pages. Confirm with the user before running them unless they explicitly asked for that exact change. Use `--skip-automation` when the user wants data changes without side effects. -- Prefer the narrowest command: REST commands for contacts/metrics, `tools` only for what REST lacks. -- Use `tools describe ` instead of guessing argument names; schemas change. +- **Read before you write.** `contacts create` and `contacts set` act on a live account and can trigger automations and emails to real people. Confirm with the user before running them unless they explicitly asked for that exact change. Pass `--skip-automation` to either command when the user wants data without side effects. - Dates are ISO 8601. Metrics windows: `--from/--to`, or `--days N` (default 30). -- Global flags: `--base-url`, `--api-key`, `--token`, `--pretty`/`--no-pretty`. +- Global flags: `--base-url`, `--api-key`, `--pretty`/`--no-pretty`. diff --git a/src/cli.ts b/src/cli.ts index aa83052..fb1b0d0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,7 +5,6 @@ import { registerAuth } from './commands/auth.ts'; import { registerContacts } from './commands/contacts.ts'; import { registerMetrics } from './commands/metrics.ts'; import { registerSkill } from './commands/skill.ts'; -import { registerTools } from './commands/tools.ts'; import { registerWorkspaces } from './commands/workspaces.ts'; import { ExitCode } from './errors.ts'; import { printError } from './output.ts'; @@ -22,8 +21,7 @@ function buildProgram(): Command { ) .version(version) .option('--base-url ', 'API origin (env PERSPECTIVE_BASE_URL)', undefined) - .option('--api-key ', 'REST API key (env PERSPECTIVE_API_KEY)') - .option('--token ', 'OAuth access token for MCP tools (env PERSPECTIVE_TOKEN)') + .option('--api-key ', 'API key (env PERSPECTIVE_API_KEY)') .option('--pretty', 'indent JSON output (default when stdout is a terminal)') .option('--no-pretty', 'compact JSON output (default when piped)') .showHelpAfterError() @@ -33,7 +31,6 @@ function buildProgram(): Command { registerWorkspaces(program); registerContacts(program); registerMetrics(program); - registerTools(program); registerSkill(program); return program; } diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 80b53df..e673175 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,79 +1,60 @@ -import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; import type { Command } from 'commander'; import { action } from '../command.ts'; import { clearConfig, configPath, saveConfig } from '../config.ts'; import { CliError } from '../errors.ts'; import { readTextArg } from '../input.ts'; -import { login } from '../oauth.ts'; -import { info } from '../output.ts'; import { RestClient } from '../rest.ts'; -function openBrowser(url: string): void { - const [cmd, args] = - process.platform === 'darwin' - ? ['open', [url]] - : process.platform === 'win32' - ? ['cmd', ['/c', 'start', '', url]] - : ['xdg-open', [url]]; - const child = spawn(cmd, args, { stdio: 'ignore', detached: true }); - child.on('error', () => {}); - child.unref(); -} - export function mask(secret: string): string { return secret.length <= 8 ? '••••' : `${secret.slice(0, 4)}…${secret.slice(-4)}`; } +/** Prompts on stderr without echoing what is typed. */ +function promptSecret(question: string): Promise { + return new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true }); + const internal = rl as unknown as { _writeToOutput: (text: string) => void }; + process.stderr.write(question); + internal._writeToOutput = () => {}; + rl.question('', (answer) => { + rl.close(); + process.stderr.write('\n'); + resolve(answer); + }); + }); +} + export function registerAuth(program: Command): void { - const auth = program.command('auth').description('Log in, inspect and clear credentials'); + const auth = program.command('auth').description('Store, inspect and clear your API key'); auth.command('login') .description( - 'Log in with your Perspective account in the browser (OAuth; enables `tools`). ' + - 'With --with-api-key, store an API key instead (enables REST commands).', + 'Store an API key (Settings → API Keys). Prompts for it on a terminal, reads it from stdin ' + + 'when piped, or takes it from --api-key. The key is validated before it is saved.', ) - .option('--with-api-key [key]', 'store an API key; omit the value (or pass -) to read it from stdin') - .option('--no-browser', 'print the login URL instead of opening a browser') .action( - action(async (ctx, opts: { withApiKey?: string | true; browser: boolean }) => { - const baseUrl = await ctx.baseUrl(); + action(async (ctx) => { + const raw = + ctx.opts.apiKey ?? + (process.stdin.isTTY ? await promptSecret('API key: ') : await readTextArg('-')); + const key = raw.trim(); + if (!key) throw new CliError('usage', 'Empty API key'); + // Validate before storing: a cheap authenticated read. + await new RestClient(await ctx.baseUrl(), key).request({ path: '/workspaces' }); const config = await ctx.stored(); - - if (opts.withApiKey !== undefined) { - const raw = opts.withApiKey === true ? '-' : opts.withApiKey; - const key = (await readTextArg(raw)).trim(); - if (!key) throw new CliError('usage', 'Empty API key'); - // Validate before storing: a cheap authenticated read. - await new RestClient(baseUrl, key).request({ path: '/workspaces' }); - config.apiKey = key; - await saveConfig(config, ctx.env); - return { ok: true, method: 'api_key', apiKey: mask(key), configPath: configPath(ctx.env) }; - } - - config.oauth = await login({ - baseUrl, - onAuthorizeUrl: (url) => { - info(`Open this URL to log in:\n\n ${url}\n`); - if (opts.browser) openBrowser(url); - info('Waiting for the browser…'); - }, - }); + config.apiKey = key; await saveConfig(config, ctx.env); - return { - ok: true, - method: 'oauth', - expiresAt: config.oauth.expiresAt ? new Date(config.oauth.expiresAt).toISOString() : null, - configPath: configPath(ctx.env), - }; + return { ok: true, apiKey: mask(key), configPath: configPath(ctx.env) }; }), ); auth.command('status') - .description('Show which credentials are configured and where they come from (no network calls)') + .description('Show whether an API key is configured and where it comes from (no network calls)') .action( action(async (ctx) => { const config = await ctx.stored(); - const keySource = ctx.opts.apiKey + const source = ctx.opts.apiKey ? 'flag' : ctx.env.PERSPECTIVE_API_KEY ? 'env' @@ -81,36 +62,18 @@ export function registerAuth(program: Command): void { ? 'config' : null; const key = ctx.opts.apiKey ?? ctx.env.PERSPECTIVE_API_KEY ?? config.apiKey; - const tokenSource = ctx.opts.token - ? 'flag' - : ctx.env.PERSPECTIVE_TOKEN - ? 'env' - : config.oauth - ? 'config' - : null; return { + authenticated: Boolean(key), + source, + apiKey: key ? mask(key) : null, baseUrl: await ctx.baseUrl(), configPath: configPath(ctx.env), - rest: { authenticated: Boolean(key), source: keySource, apiKey: key ? mask(key) : null }, - mcp: { - authenticated: Boolean(tokenSource), - source: tokenSource, - expiresAt: - tokenSource === 'config' && config.oauth?.expiresAt - ? new Date(config.oauth.expiresAt).toISOString() - : null, - refreshable: tokenSource === 'config' ? Boolean(config.oauth?.refreshToken) : null, - }, }; }), ); - auth.command('token') - .description('Print a valid OAuth access token (refreshing it if needed), e.g. for curl') - .action(action(async (ctx) => ({ accessToken: await ctx.accessToken() }))); - auth.command('logout') - .description('Delete stored credentials (environment variables are not affected)') + .description('Delete the stored API key (environment variables are not affected)') .action( action(async (ctx) => { await clearConfig(ctx.env); diff --git a/src/commands/contacts.ts b/src/commands/contacts.ts index f08e27d..4007951 100644 --- a/src/commands/contacts.ts +++ b/src/commands/contacts.ts @@ -14,7 +14,7 @@ interface ContactsPage { } export function registerContacts(program: Command): void { - const contacts = program.command('contacts').description("A funnel's CRM contacts (REST, needs an API key)"); + const contacts = program.command('contacts').description("A funnel's CRM contacts"); contacts .command('list') diff --git a/src/commands/metrics.ts b/src/commands/metrics.ts index 53c42f1..09aa1ec 100644 --- a/src/commands/metrics.ts +++ b/src/commands/metrics.ts @@ -70,7 +70,7 @@ function withWindow(cmd: Command): Command { export function registerMetrics(program: Command): void { const metrics = program .command('metrics') - .description('Funnel analytics: KPIs, charts and question insights (REST, needs an API key)'); + .description('Funnel analytics: KPIs, charts and question insights'); withWindow( metrics diff --git a/src/commands/tools.ts b/src/commands/tools.ts deleted file mode 100644 index a687c99..0000000 --- a/src/commands/tools.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Option, type Command } from 'commander'; -import { action } from '../command.ts'; -import { CliError } from '../errors.ts'; -import { applyAssignments, collect, readJsonObject } from '../input.ts'; -import { MCP_SCOPES, McpClient, unwrapToolResult, type McpScope } from '../mcp.ts'; -import type { Context } from '../context.ts'; - -async function client(ctx: Context, scope: McpScope): Promise { - return new McpClient(await ctx.baseUrl(), await ctx.accessToken(), scope); -} - -const scopeOption = () => - new Option('--scope ', 'MCP tool scope').choices([...MCP_SCOPES]).default('all'); - -export function registerTools(program: Command): void { - const tools = program - .command('tools') - .description( - 'Call any tool of the Perspective MCP server — funnels, sequences, brands, stats, CRM, images (needs `auth login`)', - ); - - tools - .command('list') - .description('List available tools (name, title, description)') - .addOption(scopeOption()) - .option('--schemas', 'include each input schema') - .action( - action(async (ctx, opts: { scope: McpScope; schemas?: boolean }) => { - const list = await (await client(ctx, opts.scope)).listTools(); - return { - data: list.map((t) => ({ - name: t.name, - ...(t.title && { title: t.title }), - description: t.description, - ...(t.annotations?.readOnlyHint !== undefined && { readOnly: t.annotations.readOnlyHint }), - ...(opts.schemas && { inputSchema: t.inputSchema }), - })), - }; - }), - ); - - tools - .command('describe') - .description('Show one tool with its full input schema') - .argument('') - .addOption(scopeOption()) - .action( - action(async (ctx, name: string, opts: { scope: McpScope }) => { - const list = await (await client(ctx, opts.scope)).listTools(); - const tool = list.find((t) => t.name === name); - if (!tool) { - throw new CliError('not_found', `No tool named "${name}" in scope "${opts.scope}"`, { - hint: 'Run `perspective tools list` to see available tools.', - }); - } - return tool; - }), - ); - - tools - .command('call') - .description('Call a tool. Arguments come from --input and/or repeated -a key=value (values parsed as JSON when valid).') - .argument('') - .option('-i, --input ', 'arguments object as JSON, @file or - for stdin') - .option('-a, --arg ', 'one argument, repeatable; dots nest (brand.name=Acme)', collect, []) - .option('--raw', 'print the raw MCP result (content array) instead of the unwrapped value') - .addOption(scopeOption()) - .action( - action( - async ( - ctx, - name: string, - opts: { input?: string; arg: string[]; raw?: boolean; scope: McpScope }, - ) => { - const args = opts.input ? await readJsonObject(opts.input, '--input') : {}; - applyAssignments(args, opts.arg); - const result = await (await client(ctx, opts.scope)).callTool(name, args); - if (result.isError) { - throw new CliError('tool_error', `Tool ${name} returned an error`, { - details: unwrapToolResult(result), - }); - } - return opts.raw ? result : unwrapToolResult(result); - }, - ), - ); -} diff --git a/src/config.ts b/src/config.ts index d82d10c..ba28c34 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,18 +4,9 @@ import { join } from 'node:path'; export const DEFAULT_BASE_URL = 'https://api.perspective.co'; -export interface StoredOAuth { - accessToken: string; - refreshToken?: string; - /** Epoch milliseconds. */ - expiresAt?: number; - clientId: string; -} - export interface StoredConfig { baseUrl?: string; apiKey?: string; - oauth?: StoredOAuth; } export function configDir(env: NodeJS.ProcessEnv = process.env): string { diff --git a/src/context.ts b/src/context.ts index ed68760..e8a29bb 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,11 +1,9 @@ import { CliError } from './errors.ts'; -import { DEFAULT_BASE_URL, loadConfig, saveConfig, type StoredConfig } from './config.ts'; -import { isExpired, refresh } from './oauth.ts'; +import { DEFAULT_BASE_URL, loadConfig, type StoredConfig } from './config.ts'; export interface GlobalOptions { baseUrl?: string; apiKey?: string; - token?: string; pretty?: boolean; } @@ -37,36 +35,16 @@ export class Context { return url.replace(/\/+$/, ''); } - /** API key for the REST API (`/v1`). */ + /** API key for the External API (`/v1`). */ async apiKey(): Promise { const key = this.opts.apiKey ?? this.env.PERSPECTIVE_API_KEY ?? (await this.stored()).apiKey; if (!key) { throw new CliError('unauthenticated', 'No API key configured', { hint: - 'REST commands need an API key. Create one under Settings → API Keys, then run ' + - '`perspective auth login --with-api-key` (reads the key from stdin) or set PERSPECTIVE_API_KEY.', + 'Commands need an API key. Create one under Settings → API Keys, then run ' + + '`perspective auth login` or set PERSPECTIVE_API_KEY.', }); } return key; } - - /** OAuth access token for the MCP server (`/mcp`). Refreshes and persists when expired. */ - async accessToken(): Promise { - const explicit = this.opts.token ?? this.env.PERSPECTIVE_TOKEN; - if (explicit) return explicit; - - const config = await this.stored(); - if (!config.oauth) { - throw new CliError('unauthenticated', 'Not logged in', { - hint: - 'MCP-backed commands (`perspective tools …`) need an OAuth session. ' + - 'Run `perspective auth login`, or set PERSPECTIVE_TOKEN.', - }); - } - if (isExpired(config.oauth)) { - config.oauth = await refresh(await this.baseUrl(), config.oauth); - await saveConfig(config, this.env); - } - return config.oauth.accessToken; - } } diff --git a/src/errors.ts b/src/errors.ts index 4e35476..de77946 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -22,12 +22,10 @@ export type ErrorCode = | 'forbidden' | 'not_found' | 'rate_limited' - | 'network' - | 'tool_error'; + | 'network'; const EXIT_BY_CODE: Record = { error: ExitCode.ERROR, - tool_error: ExitCode.ERROR, usage: ExitCode.USAGE, unauthenticated: ExitCode.UNAUTHENTICATED, forbidden: ExitCode.FORBIDDEN, diff --git a/src/mcp.ts b/src/mcp.ts deleted file mode 100644 index 95cf156..0000000 --- a/src/mcp.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { CliError, errorCodeForStatus } from './errors.ts'; - -/** - * Minimal MCP client for the hosted Perspective server. - * - * The server runs the Streamable HTTP transport in stateless mode (no session - * id, a fresh server per POST), so every JSON-RPC call is one independent - * POST and no `initialize` handshake is needed. Replies arrive either as a - * JSON body or as a single-message SSE stream; both are handled. - */ - -export const MCP_SCOPES = ['all', 'funnel-build', 'sequences', 'analytics', 'crm'] as const; -export type McpScope = (typeof MCP_SCOPES)[number]; - -const PROTOCOL_VERSION = '2025-06-18'; - -export interface McpTool { - name: string; - title?: string; - description?: string; - inputSchema?: unknown; - annotations?: Record; -} - -export interface McpContent { - type: string; - text?: string; - [key: string]: unknown; -} - -export interface McpCallResult { - content?: McpContent[]; - structuredContent?: unknown; - isError?: boolean; -} - -interface JsonRpcResponse { - jsonrpc: '2.0'; - id?: number | string | null; - result?: T; - error?: { code: number; message: string; data?: unknown }; -} - -export function isMcpScope(value: string): value is McpScope { - return (MCP_SCOPES as readonly string[]).includes(value); -} - -/** Extracts the JSON-RPC response from an SSE body (`event: message\ndata: {...}`). */ -export function parseSse(body: string, id: number): JsonRpcResponse | undefined { - for (const event of body.split(/\r?\n\r?\n/)) { - const data = event - .split(/\r?\n/) - .filter((line) => line.startsWith('data:')) - .map((line) => line.slice(5).trimStart()) - .join('\n'); - if (!data) continue; - const message = JSON.parse(data) as JsonRpcResponse; - if (message.id === id) return message; - } - return undefined; -} - -export class McpClient { - private readonly endpoint: string; - private readonly token: string; - private readonly fetchImpl: typeof fetch; - private nextId = 1; - - constructor(baseUrl: string, token: string, scope: McpScope = 'all', fetchImpl: typeof fetch = fetch) { - this.endpoint = scope === 'all' ? `${baseUrl}/mcp` : `${baseUrl}/mcp/${scope}`; - this.token = token; - this.fetchImpl = fetchImpl; - } - - async rpc(method: string, params?: Record): Promise { - const id = this.nextId++; - let res: Response; - try { - res = await this.fetchImpl(this.endpoint, { - method: 'POST', - headers: { - authorization: `Bearer ${this.token}`, - 'content-type': 'application/json', - accept: 'application/json, text/event-stream', - 'mcp-protocol-version': PROTOCOL_VERSION, - 'user-agent': 'perspective-cli', - }, - body: JSON.stringify({ jsonrpc: '2.0', id, method, ...(params && { params }) }), - }); - } catch (err) { - throw new CliError('network', `Could not reach ${this.endpoint}: ${(err as Error).message}`); - } - - const text = await res.text(); - if (!res.ok) { - let message = `HTTP ${res.status} ${res.statusText}`; - try { - const body = JSON.parse(text) as { error_description?: string; message?: string; error?: unknown }; - message = body.error_description ?? body.message ?? (typeof body.error === 'string' ? body.error : message); - } catch { - // keep the status line - } - const code = errorCodeForStatus(res.status); - throw new CliError(code, message, { - status: res.status, - hint: code === 'unauthenticated' ? 'Run `perspective auth login`.' : undefined, - }); - } - - const contentType = res.headers.get('content-type') ?? ''; - const message = contentType.includes('text/event-stream') - ? parseSse(text, id) - : (JSON.parse(text) as JsonRpcResponse); - - if (!message) throw new CliError('error', `No response to ${method} from the MCP server`); - if (message.error) { - throw new CliError(message.error.code === -32602 ? 'usage' : 'error', message.error.message, { - details: message.error.data, - }); - } - return message.result as T; - } - - async listTools(): Promise { - const tools: McpTool[] = []; - let cursor: string | undefined; - do { - const page = await this.rpc<{ tools: McpTool[]; nextCursor?: string }>( - 'tools/list', - cursor ? { cursor } : undefined, - ); - tools.push(...page.tools); - cursor = page.nextCursor; - } while (cursor); - return tools; - } - - callTool(name: string, args: Record): Promise { - return this.rpc('tools/call', { name, arguments: args }); - } -} - -/** - * Reduces a tool result to the most useful JSON value: structured content if - * the tool returned it, otherwise the text parts — each parsed as JSON when - * it is JSON. Non-text parts (images, resources) are passed through. - */ -export function unwrapToolResult(result: McpCallResult): unknown { - if (result.structuredContent !== undefined) return result.structuredContent; - const parts = (result.content ?? []).map((part) => { - if (part.type !== 'text' || part.text === undefined) return part; - try { - return JSON.parse(part.text) as unknown; - } catch { - return part.text; - } - }); - return parts.length === 1 ? parts[0] : parts; -} diff --git a/src/oauth.ts b/src/oauth.ts deleted file mode 100644 index 168991e..0000000 --- a/src/oauth.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { createHash, randomBytes } from 'node:crypto'; -import { createServer, type Server } from 'node:http'; -import type { AddressInfo } from 'node:net'; -import { CliError } from './errors.ts'; -import type { StoredOAuth } from './config.ts'; - -/** - * OAuth 2.1 authorization-code flow with PKCE against the External API's - * OAuth proxy (/oauth/register, /oauth/authorize, /oauth/token), which - * fronts Auth0. The redirect goes to a one-shot loopback server - * (RFC 8252); the proxy allowlists http://127.0.0.1 for exactly this. - */ - -const SCOPE = 'openid offline_access'; -const CALLBACK_PATH = '/callback'; -const LOGIN_TIMEOUT_MS = 5 * 60_000; -/** Refresh this long before the token actually expires. */ -const EXPIRY_SKEW_MS = 60_000; - -export function base64url(buf: Buffer): string { - return buf.toString('base64url'); -} - -export function createPkcePair(): { verifier: string; challenge: string } { - const verifier = base64url(randomBytes(32)); - const challenge = base64url(createHash('sha256').update(verifier).digest()); - return { verifier, challenge }; -} - -interface TokenResponse { - access_token: string; - refresh_token?: string; - expires_in?: number; -} - -function toStored(token: TokenResponse, clientId: string, previous?: StoredOAuth): StoredOAuth { - return { - accessToken: token.access_token, - // Auth0 only rotates the refresh token when rotation is enabled; keep the old one otherwise. - refreshToken: token.refresh_token ?? previous?.refreshToken, - expiresAt: token.expires_in ? Date.now() + token.expires_in * 1000 : undefined, - clientId, - }; -} - -async function postToken(baseUrl: string, params: Record): Promise { - let res: Response; - try { - res = await fetch(`${baseUrl}/oauth/token`, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams(params).toString(), - }); - } catch (err) { - throw new CliError('network', `Could not reach ${baseUrl}: ${(err as Error).message}`); - } - const body = (await res.json().catch(() => ({}))) as TokenResponse & { - error?: string; - error_description?: string; - }; - if (!res.ok || !body.access_token) { - throw new CliError( - 'unauthenticated', - `Token request failed: ${body.error_description ?? body.error ?? res.statusText}`, - { status: res.status, hint: 'Run `perspective auth login` again.' }, - ); - } - return body; -} - -async function registerClient(baseUrl: string, redirectUri: string): Promise { - const res = await fetch(`${baseUrl}/oauth/register`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ redirect_uris: [redirectUri], client_name: 'Perspective CLI' }), - }); - const body = (await res.json().catch(() => ({}))) as { client_id?: string; error?: string }; - if (!res.ok || !body.client_id) { - throw new CliError('error', `Client registration failed: ${body.error ?? res.statusText}`, { - status: res.status, - }); - } - return body.client_id; -} - -function listen(server: Server): Promise { - return new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port)); - }); -} - -const PAGE = (title: string, body: string) => - `${title}` + - `` + - `

${title}

${body}

`; - -export interface LoginOptions { - baseUrl: string; - /** Called with the authorize URL; opens a browser or prints it. */ - onAuthorizeUrl: (url: string) => void | Promise; -} - -export async function login({ baseUrl, onAuthorizeUrl }: LoginOptions): Promise { - const state = base64url(randomBytes(16)); - const { verifier, challenge } = createPkcePair(); - - let settle!: { resolve: (code: string) => void; reject: (err: Error) => void }; - const codePromise = new Promise((resolve, reject) => { - settle = { resolve, reject }; - }); - // A callback can fail before we await the promise; don't let that become an unhandled rejection. - codePromise.catch(() => {}); - - const server = createServer((req, res) => { - const url = new URL(req.url ?? '/', 'http://127.0.0.1'); - if (url.pathname !== CALLBACK_PATH) { - res.writeHead(404).end(); - return; - } - const error = url.searchParams.get('error'); - const code = url.searchParams.get('code'); - res.setHeader('content-type', 'text/html; charset=utf-8'); - if (url.searchParams.get('state') !== state) { - res.writeHead(400).end(PAGE('Login failed', 'State mismatch. Close this tab and retry.')); - settle.reject(new CliError('unauthenticated', 'OAuth state mismatch')); - } else if (error || !code) { - const description = url.searchParams.get('error_description') ?? error ?? 'no code'; - res.writeHead(400).end(PAGE('Login failed', 'You can close this tab.')); - settle.reject(new CliError('unauthenticated', `Authorization failed: ${description}`)); - } else { - res.writeHead(200).end( - PAGE('Logged in to Perspective', 'You can close this tab and return to the terminal.'), - ); - settle.resolve(code); - } - }); - - const port = await listen(server); - const redirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`; - const timer = setTimeout( - () => settle.reject(new CliError('unauthenticated', 'Timed out waiting for browser login')), - LOGIN_TIMEOUT_MS, - ); - - try { - const clientId = await registerClient(baseUrl, redirectUri); - const authorize = new URL(`${baseUrl}/oauth/authorize`); - authorize.search = new URLSearchParams({ - response_type: 'code', - client_id: clientId, - redirect_uri: redirectUri, - scope: SCOPE, - state, - code_challenge: challenge, - code_challenge_method: 'S256', - }).toString(); - await onAuthorizeUrl(authorize.toString()); - - const code = await codePromise; - const token = await postToken(baseUrl, { - grant_type: 'authorization_code', - code, - code_verifier: verifier, - client_id: clientId, - redirect_uri: redirectUri, - }); - return toStored(token, clientId); - } finally { - clearTimeout(timer); - server.close(); - } -} - -export function isExpired(oauth: StoredOAuth, now = Date.now()): boolean { - return oauth.expiresAt !== undefined && oauth.expiresAt - EXPIRY_SKEW_MS <= now; -} - -export async function refresh(baseUrl: string, oauth: StoredOAuth): Promise { - if (!oauth.refreshToken) { - throw new CliError('unauthenticated', 'Session expired', { - hint: 'Run `perspective auth login` again.', - }); - } - const token = await postToken(baseUrl, { - grant_type: 'refresh_token', - refresh_token: oauth.refreshToken, - client_id: oauth.clientId, - }); - return toStored(token, oauth.clientId, oauth); -} diff --git a/src/output.ts b/src/output.ts index 3c0f48b..fd45c50 100644 --- a/src/output.ts +++ b/src/output.ts @@ -25,8 +25,3 @@ export function printError(err: unknown, opts: OutputOptions = {}): number { ); return cliError.exitCode; } - -/** Human-facing progress text. Always stderr, so stdout stays pure JSON. */ -export function info(message: string): void { - process.stderr.write(`${message}\n`); -} diff --git a/src/rest.ts b/src/rest.ts index 144a352..380d9a6 100644 --- a/src/rest.ts +++ b/src/rest.ts @@ -11,7 +11,7 @@ export interface RestRequest { const USER_AGENT = 'perspective-cli'; -/** Client for the REST API under /v1. Returns the parsed JSON body as-is ({data} / {data, meta}). */ +/** Client for the External API under /v1. Returns the parsed JSON body as-is ({data} / {data, meta}). */ export class RestClient { readonly baseUrl: string; private readonly apiKey: string; diff --git a/test/clients.test.ts b/test/clients.test.ts index e9c3256..b1d5b02 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import { CliError, ExitCode } from '../src/errors.ts'; -import { McpClient, parseSse, unwrapToolResult } from '../src/mcp.ts'; import { RestClient } from '../src/rest.ts'; type Call = { url: string; init: RequestInit }; @@ -54,78 +53,3 @@ describe('RestClient', () => { expect(err.code).toBe('network'); }); }); - -describe('parseSse', () => { - it('finds the response with the matching id', () => { - const body = 'event: message\ndata: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}\n\n'; - expect(parseSse(body, 7)?.result).toEqual({ ok: true }); - expect(parseSse(body, 8)).toBeUndefined(); - }); -}); - -describe('McpClient', () => { - it('posts JSON-RPC to the scoped endpoint with a bearer token and parses SSE replies', async () => { - const { impl, calls } = fakeFetch(({ init }) => { - const { id } = JSON.parse(String(init.body)) as { id: number }; - return new Response( - `event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id, result: { tools: [{ name: 'list_brands' }] } })}\n\n`, - { status: 200, headers: { 'content-type': 'text/event-stream' } }, - ); - }); - const mcp = new McpClient('https://api.example.com', 'tok', 'crm', impl); - expect(await mcp.listTools()).toEqual([{ name: 'list_brands' }]); - expect(calls[0]!.url).toBe('https://api.example.com/mcp/crm'); - expect((calls[0]!.init.headers as Record).authorization).toBe('Bearer tok'); - expect(JSON.parse(String(calls[0]!.init.body))).toMatchObject({ method: 'tools/list' }); - }); - - it('follows tools/list pagination', async () => { - const { impl } = fakeFetch(({ init }) => { - const { id, params } = JSON.parse(String(init.body)) as { id: number; params?: { cursor?: string } }; - const result = params?.cursor - ? { tools: [{ name: 'b' }] } - : { tools: [{ name: 'a' }], nextCursor: 'c1' }; - return json(200, { jsonrpc: '2.0', id, result }); - }); - const tools = await new McpClient('https://x', 't', 'all', impl).listTools(); - expect(tools.map((t) => t.name)).toEqual(['a', 'b']); - }); - - it('surfaces JSON-RPC errors', async () => { - const { impl } = fakeFetch(({ init }) => { - const { id } = JSON.parse(String(init.body)) as { id: number }; - return json(200, { jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params' } }); - }); - const err = (await new McpClient('https://x', 't', 'all', impl) - .callTool('x', {}) - .catch((e: unknown) => e)) as CliError; - expect(err.code).toBe('usage'); - expect(err.message).toBe('Invalid params'); - }); - - it('maps a 401 with an OAuth error body to unauthenticated', async () => { - const { impl } = fakeFetch(() => json(401, { error: 'unauthorized', error_description: 'Invalid token' })); - const err = (await new McpClient('https://x', 't', 'all', impl).listTools().catch((e: unknown) => e)) as CliError; - expect(err.code).toBe('unauthenticated'); - expect(err.message).toBe('Invalid token'); - }); -}); - -describe('unwrapToolResult', () => { - it('prefers structured content', () => { - expect(unwrapToolResult({ structuredContent: { a: 1 }, content: [{ type: 'text', text: 'x' }] })).toEqual({ a: 1 }); - }); - it('parses a single JSON text part', () => { - expect(unwrapToolResult({ content: [{ type: 'text', text: '{"id":"f1"}' }] })).toEqual({ id: 'f1' }); - }); - it('keeps non-JSON text and returns arrays for multiple parts', () => { - expect( - unwrapToolResult({ - content: [ - { type: 'text', text: 'hello' }, - { type: 'text', text: '[1]' }, - ], - }), - ).toEqual(['hello', [1]]); - }); -}); diff --git a/test/units.test.ts b/test/units.test.ts index 868aee7..40c4951 100644 --- a/test/units.test.ts +++ b/test/units.test.ts @@ -1,8 +1,7 @@ -import { createHash } from 'node:crypto'; import { mkdtemp, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { mask } from '../src/commands/auth.ts'; import { KPI_SUBTYPES, normalizeSubtype, resolveWindow } from '../src/commands/metrics.ts'; import { flattenFunnels } from '../src/commands/workspaces.ts'; @@ -10,7 +9,6 @@ import { configPath, loadConfig, saveConfig } from '../src/config.ts'; import { Context } from '../src/context.ts'; import { CliError } from '../src/errors.ts'; import { applyAssignments, parseAssignment } from '../src/input.ts'; -import { createPkcePair, isExpired } from '../src/oauth.ts'; describe('assignments', () => { it('parses JSON values and falls back to strings', () => { @@ -57,19 +55,7 @@ describe('flattenFunnels', () => { }); }); -describe('oauth helpers', () => { - it('creates an S256 PKCE pair', () => { - const { verifier, challenge } = createPkcePair(); - expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/); - expect(challenge).toBe(createHash('sha256').update(verifier).digest('base64url')); - }); - - it('treats tokens as expired one minute early', () => { - expect(isExpired({ accessToken: 'a', clientId: 'c', expiresAt: 1_000_000 }, 1_000_000 - 30_000)).toBe(true); - expect(isExpired({ accessToken: 'a', clientId: 'c', expiresAt: 1_000_000 }, 1_000_000 - 120_000)).toBe(false); - expect(isExpired({ accessToken: 'a', clientId: 'c' })).toBe(false); - }); - +describe('auth', () => { it('masks secrets', () => { expect(mask('abcd1234efgh')).toBe('abcd…efgh'); expect(mask('short')).toBe('••••'); @@ -77,8 +63,6 @@ describe('oauth helpers', () => { }); describe('config + context', () => { - afterEach(() => vi.unstubAllGlobals()); - async function tempEnv(): Promise { return { PERSPECTIVE_CONFIG_DIR: await mkdtemp(join(tmpdir(), 'pcli-')) }; } @@ -104,28 +88,6 @@ describe('config + context', () => { it('fails with unauthenticated when nothing is configured', async () => { const env = await tempEnv(); await expect(new Context({}, env).apiKey()).rejects.toMatchObject({ code: 'unauthenticated' }); - await expect(new Context({}, env).accessToken()).rejects.toMatchObject({ code: 'unauthenticated' }); }); - it('refreshes an expired token and persists it', async () => { - const env = await tempEnv(); - await saveConfig( - { oauth: { accessToken: 'old', refreshToken: 'r1', expiresAt: Date.now() - 1000, clientId: 'c' } }, - env, - ); - const fetchMock = vi.fn(async () => - new Response(JSON.stringify({ access_token: 'new', expires_in: 3600 }), { - status: 200, - headers: { 'content-type': 'application/json' }, - }), - ); - vi.stubGlobal('fetch', fetchMock); - - expect(await new Context({ baseUrl: 'https://api.example.com' }, env).accessToken()).toBe('new'); - const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; - expect(url).toBe('https://api.example.com/oauth/token'); - expect(String(init.body)).toContain('grant_type=refresh_token'); - const saved = await loadConfig(env); - expect(saved.oauth).toMatchObject({ accessToken: 'new', refreshToken: 'r1', clientId: 'c' }); - }); }); From b74fc08bd0e004bc35394b9569ed3e78f3d2ac0f Mon Sep 17 00:00:00 2001 From: Christian Korndoerfer Date: Mon, 19 Oct 2026 22:09:40 +0200 Subject: [PATCH 3/8] CAP-4819 fix: keep the API key prompt visible in auth login readline redrew the line (cursor to column 1, clear to end of screen), wiping "API key: " right after it was printed. Read the key in raw mode instead, explain where to get one before prompting, and exit 130 on Ctrl+C. Co-Authored-By: Claude Opus 5.5 --- src/commands/auth.ts | 54 +++++++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/src/commands/auth.ts b/src/commands/auth.ts index e673175..d3e24d1 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,4 +1,3 @@ -import { createInterface } from 'node:readline'; import type { Command } from 'commander'; import { action } from '../command.ts'; import { clearConfig, configPath, saveConfig } from '../config.ts'; @@ -10,18 +9,41 @@ export function mask(secret: string): string { return secret.length <= 8 ? '••••' : `${secret.slice(0, 4)}…${secret.slice(-4)}`; } -/** Prompts on stderr without echoing what is typed. */ +/** + * Prompts on stderr without echoing what is typed. Reads the terminal in raw + * mode: readline would redraw the line and wipe the prompt. + */ function promptSecret(question: string): Promise { + const stdin = process.stdin; + process.stderr.write(question); + stdin.setRawMode(true); + stdin.setEncoding('utf8'); + stdin.resume(); return new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true }); - const internal = rl as unknown as { _writeToOutput: (text: string) => void }; - process.stderr.write(question); - internal._writeToOutput = () => {}; - rl.question('', (answer) => { - rl.close(); + let value = ''; + const finish = () => { + stdin.off('data', onData); + stdin.setRawMode(false); + stdin.pause(); process.stderr.write('\n'); - resolve(answer); - }); + }; + const onData = (chunk: string) => { + for (const char of chunk) { + if (char === '\r' || char === '\n' || char === '\u0004') { + finish(); + resolve(value); + return; + } + if (char === '\u0003') { + // Ctrl+C: raw mode swallows SIGINT, so exit the way the shell expects. + finish(); + process.exit(130); + } + if (char === '\u007f' || char === '\b') value = value.slice(0, -1); + else value += char; + } + }; + stdin.on('data', onData); }); } @@ -35,9 +57,15 @@ export function registerAuth(program: Command): void { ) .action( action(async (ctx) => { - const raw = - ctx.opts.apiKey ?? - (process.stdin.isTTY ? await promptSecret('API key: ') : await readTextArg('-')); + let raw = ctx.opts.apiKey; + if (raw === undefined && process.stdin.isTTY) { + process.stderr.write( + 'Paste your Perspective API key (create one under Settings → API Keys in the app).\n' + + 'Input is hidden.\n\n', + ); + raw = await promptSecret('API key: '); + } + raw ??= await readTextArg('-'); const key = raw.trim(); if (!key) throw new CliError('usage', 'Empty API key'); // Validate before storing: a cheap authenticated read. From 578abbca31d63140ba19befea096868314392752 Mon Sep 17 00:00:00 2001 From: Christian Korndoerfer Date: Mon, 19 Oct 2026 22:42:23 +0200 Subject: [PATCH 4/8] CAP-4819 fix: funnel status is draft/live/offline, clearer 403 hint Tested against production: the API reports `live`, not `online` (the swagger enum is wrong), so `funnels list --status online` silently returned nothing. --status now only accepts draft, live or offline. A wrong funnel ID returns 403 "User has no access to campaign"; the hint blamed only key permissions. It now suggests checking the ID too. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/commands/workspaces.ts | 4 ++-- src/rest.ts | 3 ++- test/units.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a791b89..b5aaaeb 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The key is stored in `~/.config/perspective/credentials.json` (mode 0600). In CI ## Use ```bash -perspective funnels list --status online +perspective funnels list --status live perspective contacts list --all perspective contacts create --email ada@example.com --first-name Ada perspective metrics kpi conversion_rate --days 7 diff --git a/src/commands/workspaces.ts b/src/commands/workspaces.ts index 6fd2e63..937b252 100644 --- a/src/commands/workspaces.ts +++ b/src/commands/workspaces.ts @@ -1,4 +1,4 @@ -import type { Command } from 'commander'; +import { Option, type Command } from 'commander'; import { action } from '../command.ts'; import { RestClient } from '../rest.ts'; @@ -32,7 +32,7 @@ export function registerWorkspaces(program: Command): void { .description('Funnels across all workspaces') .command('list') .description('List every funnel as a flat list — the quickest way to find a funnel ID') - .option('--status ', 'only funnels with this status (online|offline)') + .addOption(new Option('--status ', 'only funnels with this status').choices(['draft', 'live', 'offline'])) .option('--name ', 'only funnels whose name contains this text (case-insensitive)') .action( action(async (ctx, opts: { status?: string; name?: string }) => { diff --git a/src/rest.ts b/src/rest.ts index 380d9a6..f1ddc61 100644 --- a/src/rest.ts +++ b/src/rest.ts @@ -72,7 +72,8 @@ function httpError(res: Response, body: unknown): CliError { code === 'unauthenticated' ? 'Check your API key (`perspective auth status`).' : code === 'forbidden' - ? 'The API key lacks the permission this endpoint needs (e.g. crm:read, metrics:read).' + ? 'Either the funnel ID is wrong or not in a workspace this key can access (check `perspective funnels list`), ' + + 'or the key lacks the permission this endpoint needs (crm:read, crm:write, metrics:read).' : code === 'rate_limited' ? `Rate limit is 100 requests/minute.${retryAfter ? ` Retry in ${retryAfter}s.` : ''}` : undefined; diff --git a/test/units.test.ts b/test/units.test.ts index 40c4951..99df5b1 100644 --- a/test/units.test.ts +++ b/test/units.test.ts @@ -50,8 +50,8 @@ describe('metrics helpers', () => { describe('flattenFunnels', () => { it('lifts campaigns out of workspaces', () => { expect( - flattenFunnels([{ id: 'w1', name: 'Main', campaigns: [{ id: 'f1', name: 'Quiz', status: 'online' }] }]), - ).toEqual([{ id: 'f1', name: 'Quiz', status: 'online', workspaceId: 'w1', workspaceName: 'Main' }]); + flattenFunnels([{ id: 'w1', name: 'Main', campaigns: [{ id: 'f1', name: 'Quiz', status: 'live' }] }]), + ).toEqual([{ id: 'f1', name: 'Quiz', status: 'live', workspaceId: 'w1', workspaceName: 'Main' }]); }); }); From 3c09c1103ab8037cd108627ff3b89a5a38b0e0eb Mon Sep 17 00:00:00 2001 From: Christian Korndoerfer Date: Mon, 19 Oct 2026 22:49:03 +0200 Subject: [PATCH 5/8] CAP-4819 feat: --fields, --dry-run for contact writes, retry on 429 - --fields (global): keep only the listed fields of each result; dots reach into nested objects and keep the dotted name; meta is kept, and missing fields are null so every item has the same keys. - --dry-run on `contacts create` and `contacts set`: print the method, URL and body instead of sending; needs no API key. - RestClient retries HTTP 429 up to 3 times, waiting Retry-After (or RateLimit-Reset, else 2^n s), at most a minute. A 429 means the request was not processed, so this is safe for writes too. SKILL.md now tells agents to dry-run writes and show the request before confirming, and to use --fields on large lists. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++++ skills/perspective/SKILL.md | 17 +++++++++-- src/cli.ts | 1 + src/command.ts | 5 ++-- src/commands/contacts.ts | 25 ++++++++++------ src/context.ts | 1 + src/output.ts | 33 ++++++++++++++++++++ src/rest.ts | 60 +++++++++++++++++++++++++++---------- test/clients.test.ts | 43 ++++++++++++++++++++++++-- test/units.test.ts | 36 ++++++++++++++++++++++ 10 files changed, 195 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index b5aaaeb..30fa06a 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,17 @@ The key is stored in `~/.config/perspective/credentials.json` (mode 0600). In CI ```bash perspective funnels list --status live perspective contacts list --all +perspective contacts list --all --fields id,email,status +perspective contacts create --email ada@example.com --first-name Ada --dry-run perspective contacts create --email ada@example.com --first-name Ada perspective metrics kpi conversion_rate --days 7 perspective metrics chart visitor_devices --from 2026-09-01 --to 2026-10-01 ``` Output is pretty-printed at a terminal and compact when piped (`--pretty` / `--no-pretty`). +`--fields a,b,c.d` keeps only those fields of each result. `--dry-run` on `contacts create` +and `contacts set` prints the request instead of sending it. On HTTP 429 the CLI waits +(`Retry-After`, at most a minute) and retries up to 3 times. ### Exit codes diff --git a/skills/perspective/SKILL.md b/skills/perspective/SKILL.md index 81f13f7..ad93646 100644 --- a/skills/perspective/SKILL.md +++ b/skills/perspective/SKILL.md @@ -44,6 +44,7 @@ perspective contacts set status qualified # any field o ``` Pagination is page-based: read `meta.hasNext`, bump `--page`, or just use `--all`. +The CLI waits and retries by itself when it hits the rate limit. `-s key=value` values are parsed as JSON when valid (`-s score=5`); dots nest (`-s address.city=Berlin`). ## 3. Metrics @@ -58,6 +59,16 @@ perspective metrics insight question_1234 --days 7 perspective metrics subtypes # valid kpi/chart names ``` +## Keep output small + +Contact lists can be large. Ask only for the fields you need — `--fields` works on +every command, keeps `meta`, and dots reach into nested objects: + +```bash +perspective contacts list --all --fields id,email,status,properties.city +perspective funnels list --status live --fields id,name +``` + ## Exit codes | Code | `error.code` | Meaning / what to do | @@ -68,11 +79,11 @@ perspective metrics subtypes # valid kpi/chart names | 3 | `unauthenticated` | Missing/invalid API key — see step 0 | | 4 | `forbidden` | Key lacks the permission (`crm:read`, `crm:write`, `metrics:read`) or plan | | 5 | `not_found` | Wrong funnel or contact ID — re-discover IDs | -| 6 | `rate_limited` | 100 requests/minute — wait and retry; prefer `--all` over many small calls | +| 6 | `rate_limited` | 100 requests/minute; the CLI already retried — wait a minute; prefer `--all` over many small calls | | 7 | `network` | API unreachable | ## Rules for agents -- **Read before you write.** `contacts create` and `contacts set` act on a live account and can trigger automations and emails to real people. Confirm with the user before running them unless they explicitly asked for that exact change. Pass `--skip-automation` to either command when the user wants data without side effects. +- **Read before you write.** `contacts create` and `contacts set` act on a live account and can trigger automations and emails to real people. Run them with `--dry-run` first, show the user the printed request, and send it only after they confirm — unless they explicitly asked for that exact change. Pass `--skip-automation` to either command when the user wants data without side effects. - Dates are ISO 8601. Metrics windows: `--from/--to`, or `--days N` (default 30). -- Global flags: `--base-url`, `--api-key`, `--pretty`/`--no-pretty`. +- Global flags: `--fields`, `--base-url`, `--api-key`, `--pretty`/`--no-pretty`. diff --git a/src/cli.ts b/src/cli.ts index fb1b0d0..8052db8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -22,6 +22,7 @@ function buildProgram(): Command { .version(version) .option('--base-url ', 'API origin (env PERSPECTIVE_BASE_URL)', undefined) .option('--api-key ', 'API key (env PERSPECTIVE_API_KEY)') + .option('--fields ', 'only these fields of each result, comma-separated; dots nest (id,email,properties.city)') .option('--pretty', 'indent JSON output (default when stdout is a terminal)') .option('--no-pretty', 'compact JSON output (default when piped)') .showHelpAfterError() diff --git a/src/command.ts b/src/command.ts index 24d39ce..a99cce7 100644 --- a/src/command.ts +++ b/src/command.ts @@ -1,6 +1,6 @@ import type { Command } from 'commander'; import { Context, type GlobalOptions } from './context.ts'; -import { printError, printResult } from './output.ts'; +import { printError, printResult, selectFields } from './output.ts'; type Handler = (ctx: Context, ...args: any[]) => Promise; @@ -15,7 +15,8 @@ export function action(handler: Handler) { const globals = command.optsWithGlobals(); const ctx = new Context(globals); try { - printResult(await handler(ctx, ...args.slice(0, -1)), { pretty: globals.pretty }); + const result = await handler(ctx, ...args.slice(0, -1)); + printResult(globals.fields ? selectFields(result, globals.fields) : result, { pretty: globals.pretty }); } catch (err) { process.exitCode = printError(err, { pretty: globals.pretty }); } diff --git a/src/commands/contacts.ts b/src/commands/contacts.ts index 4007951..73f086f 100644 --- a/src/commands/contacts.ts +++ b/src/commands/contacts.ts @@ -2,12 +2,19 @@ import { Option, type Command } from 'commander'; import { action } from '../command.ts'; import { usage } from '../errors.ts'; import { applyAssignments, collect, parseInteger, readJsonObject } from '../input.ts'; -import { RestClient } from '../rest.ts'; +import type { Context } from '../context.ts'; +import { RestClient, type RestRequest } from '../rest.ts'; const SORT_FIELDS = ['ps_converted_at', 'email', 'firstName', 'lastName']; /** Server-side cap on `limit`. */ const MAX_LIMIT = 100; +/** Sends a write, or with --dry-run returns what would be sent. A dry run needs no API key. */ +async function send(ctx: Context, dryRun: boolean | undefined, req: RestRequest): Promise { + if (dryRun) return { dryRun: true, request: new RestClient(await ctx.baseUrl(), '').describe(req) }; + return new RestClient(await ctx.baseUrl(), await ctx.apiKey()).request(req); +} + interface ContactsPage { data: unknown[]; meta: { total: number; page: number; limit: number; hasNext: boolean }; @@ -86,6 +93,7 @@ export function registerContacts(program: Command): void { .option('-s, --set ', 'any body field, repeatable; dots nest (address.city=Berlin)', collect, []) .option('--data ', 'full request body as JSON, @file or - for stdin; flags override it') .option('--skip-automation', 'do not trigger automations for the initial status') + .option('--dry-run', 'print the request instead of sending it') .action( action( async ( @@ -102,6 +110,7 @@ export function registerContacts(program: Command): void { set: string[]; data?: string; skipAutomation?: boolean; + dryRun?: boolean; }, ) => { const body = opts.data ? await readJsonObject(opts.data, '--data') : {}; @@ -119,8 +128,7 @@ export function registerContacts(program: Command): void { if (opts.skipAutomation) body.skipAutomationTrigger = true; if (!body.email && !body.phone) throw usage('A contact needs --email or --phone'); - const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); - return rest.request({ + return send(ctx, opts.dryRun, { method: 'POST', path: `/funnels/${encodeURIComponent(funnelId)}/contacts`, body, @@ -139,6 +147,7 @@ export function registerContacts(program: Command): void { .argument('') .argument('') .option('--skip-automation', 'do not trigger automations for this change') + .option('--dry-run', 'print the request instead of sending it') .action( action( async ( @@ -147,10 +156,9 @@ export function registerContacts(program: Command): void { contactId: string, field: string, value: string, - opts: { skipAutomation?: boolean }, - ) => { - const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); - return rest.request({ + opts: { skipAutomation?: boolean; dryRun?: boolean }, + ) => + send(ctx, opts.dryRun, { method: 'PUT', path: `/funnels/${encodeURIComponent(funnelId)}/contacts/${encodeURIComponent(contactId)}/values`, body: { @@ -158,8 +166,7 @@ export function registerContacts(program: Command): void { value, ...(opts.skipAutomation && { skipAutomationTrigger: true }), }, - }); - }, + }), ), ); } diff --git a/src/context.ts b/src/context.ts index e8a29bb..9ad6618 100644 --- a/src/context.ts +++ b/src/context.ts @@ -4,6 +4,7 @@ import { DEFAULT_BASE_URL, loadConfig, type StoredConfig } from './config.ts'; export interface GlobalOptions { baseUrl?: string; apiKey?: string; + fields?: string; pretty?: boolean; } diff --git a/src/output.ts b/src/output.ts index fd45c50..ec67bef 100644 --- a/src/output.ts +++ b/src/output.ts @@ -10,6 +10,39 @@ function indentFor(opts: OutputOptions, stream: NodeJS.WriteStream): number | un return pretty ? 2 : undefined; } +function pick(item: unknown, paths: string[][]): unknown { + if (!item || typeof item !== 'object' || Array.isArray(item)) return item; + const out: Record = {}; + for (const path of paths) { + let value: unknown = item; + for (const key of path) { + value = value && typeof value === 'object' ? (value as Record)[key] : undefined; + } + out[path.join('.')] = value ?? null; + } + return out; +} + +/** + * Keeps only the listed fields of `data` (each item when it is a list); `meta` + * stays. Dotted paths reach into nested objects and keep the dotted name as the + * key, e.g. `properties.city`. Missing fields come back as null so every item + * has the same keys. + */ +export function selectFields(value: unknown, fields: string): unknown { + const paths = fields + .split(',') + .map((field) => field.trim()) + .filter(Boolean) + .map((field) => field.split('.')); + if (paths.length === 0) return value; + const apply = (data: unknown) => (Array.isArray(data) ? data.map((item) => pick(item, paths)) : pick(data, paths)); + if (value && typeof value === 'object' && !Array.isArray(value) && 'data' in value) { + return { ...value, data: apply((value as { data: unknown }).data) }; + } + return apply(value); +} + export function printResult(value: unknown, opts: OutputOptions = {}): void { if (value === undefined) return; process.stdout.write(`${JSON.stringify(value, null, indentFor(opts, process.stdout))}\n`); diff --git a/src/rest.ts b/src/rest.ts index f1ddc61..17c0f8e 100644 --- a/src/rest.ts +++ b/src/rest.ts @@ -11,16 +11,36 @@ export interface RestRequest { const USER_AGENT = 'perspective-cli'; +export interface RestClientOptions { + /** Retries after HTTP 429. The request was not processed, so retrying is safe for writes too. */ + maxRetries?: number; + sleep?: (ms: number) => Promise; +} + +const MAX_RETRY_WAIT_MS = 60_000; + +/** Waits as long as Retry-After (or RateLimit-Reset) says, else 2^attempt seconds; never more than a minute. */ +export function retryDelayMs(res: Response, attempt: number): number { + const header = res.headers.get('retry-after') ?? res.headers.get('ratelimit-reset'); + const seconds = header !== null && header.trim() !== '' ? Number(header) : NaN; + const ms = Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 2 ** attempt * 1000; + return Math.min(ms, MAX_RETRY_WAIT_MS); +} + /** Client for the External API under /v1. Returns the parsed JSON body as-is ({data} / {data, meta}). */ export class RestClient { readonly baseUrl: string; private readonly apiKey: string; private readonly fetchImpl: typeof fetch; + private readonly maxRetries: number; + private readonly sleep: (ms: number) => Promise; - constructor(baseUrl: string, apiKey: string, fetchImpl: typeof fetch = fetch) { + constructor(baseUrl: string, apiKey: string, fetchImpl: typeof fetch = fetch, options: RestClientOptions = {}) { this.baseUrl = baseUrl; this.apiKey = apiKey; this.fetchImpl = fetchImpl; + this.maxRetries = options.maxRetries ?? 3; + this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); } url(path: string, query: Query = {}): string { @@ -31,21 +51,31 @@ export class RestClient { return url.toString(); } + /** What `request` would send, without sending it (for --dry-run). The API key is left out. */ + describe({ method = 'GET', path, query, body }: RestRequest): Record { + return { method, url: this.url(path, query), ...(body !== undefined && { body }) }; + } + async request({ method = 'GET', path, query, body }: RestRequest): Promise { let res: Response; - try { - res = await this.fetchImpl(this.url(path, query), { - method, - headers: { - 'x-perspective-api-key': this.apiKey, - accept: 'application/json', - 'user-agent': USER_AGENT, - ...(body !== undefined && { 'content-type': 'application/json' }), - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - } catch (err) { - throw new CliError('network', `Could not reach ${this.baseUrl}: ${(err as Error).message}`); + for (let attempt = 0; ; attempt++) { + try { + res = await this.fetchImpl(this.url(path, query), { + method, + headers: { + 'x-perspective-api-key': this.apiKey, + accept: 'application/json', + 'user-agent': USER_AGENT, + ...(body !== undefined && { 'content-type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch (err) { + throw new CliError('network', `Could not reach ${this.baseUrl}: ${(err as Error).message}`); + } + if (res.status !== 429 || attempt >= this.maxRetries) break; + await res.body?.cancel(); + await this.sleep(retryDelayMs(res, attempt)); } const text = await res.text(); @@ -75,7 +105,7 @@ function httpError(res: Response, body: unknown): CliError { ? 'Either the funnel ID is wrong or not in a workspace this key can access (check `perspective funnels list`), ' + 'or the key lacks the permission this endpoint needs (crm:read, crm:write, metrics:read).' : code === 'rate_limited' - ? `Rate limit is 100 requests/minute.${retryAfter ? ` Retry in ${retryAfter}s.` : ''}` + ? `Rate limit is 100 requests/minute, and the CLI already retried.${retryAfter ? ` Retry in ${retryAfter}s.` : ''}` : undefined; return new CliError(code, message, { status: res.status, hint }); } diff --git a/test/clients.test.ts b/test/clients.test.ts index b1d5b02..0583f21 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { CliError, ExitCode } from '../src/errors.ts'; -import { RestClient } from '../src/rest.ts'; +import { RestClient, retryDelayMs } from '../src/rest.ts'; type Call = { url: string; init: RequestInit }; @@ -35,7 +35,7 @@ describe('RestClient', () => { [500, 'error', ExitCode.ERROR], ])('maps HTTP %i to %s', async (status, code, exit) => { const { impl } = fakeFetch(() => json(status, { error: 'boom', status })); - const rest = new RestClient('https://api.example.com', 'k', impl); + const rest = new RestClient('https://api.example.com', 'k', impl, { maxRetries: 0 }); const err = (await rest.request({ path: '/x' }).catch((e: unknown) => e)) as CliError; expect(err).toBeInstanceOf(CliError); expect(err.code).toBe(code); @@ -53,3 +53,42 @@ describe('RestClient', () => { expect(err.code).toBe('network'); }); }); + +describe('RestClient rate limiting', () => { + it('retries a 429 after the Retry-After delay and returns the next response', async () => { + let n = 0; + const { impl, calls } = fakeFetch(() => + n++ === 0 ? json(429, { error: 'Too many requests' }, { 'retry-after': '7' }) : json(200, { data: 'ok' }), + ); + const waits: number[] = []; + const rest = new RestClient('https://x', 'k', impl, { sleep: async (ms) => void waits.push(ms) }); + expect(await rest.request({ path: '/x' })).toEqual({ data: 'ok' }); + expect(calls).toHaveLength(2); + expect(waits).toEqual([7000]); + }); + + it('gives up after maxRetries with rate_limited', async () => { + const { impl, calls } = fakeFetch(() => json(429, { error: 'Too many requests' })); + const rest = new RestClient('https://x', 'k', impl, { maxRetries: 2, sleep: async () => {} }); + const err = (await rest.request({ path: '/x' }).catch((e: unknown) => e)) as CliError; + expect(err.code).toBe('rate_limited'); + expect(calls).toHaveLength(3); + }); + + it('backs off exponentially without a header and caps the wait at a minute', () => { + expect(retryDelayMs(new Response(null, { status: 429 }), 0)).toBe(1000); + expect(retryDelayMs(new Response(null, { status: 429 }), 2)).toBe(4000); + expect(retryDelayMs(new Response(null, { status: 429, headers: { 'retry-after': '600' } }), 0)).toBe(60_000); + }); + + it('describes a request without the API key', () => { + const rest = new RestClient('https://api.example.com', 'secret'); + const described = rest.describe({ method: 'PUT', path: '/funnels/f/contacts/c/values', body: { fieldName: 'status', value: 'won' } }); + expect(described).toEqual({ + method: 'PUT', + url: 'https://api.example.com/v1/funnels/f/contacts/c/values', + body: { fieldName: 'status', value: 'won' }, + }); + expect(JSON.stringify(described)).not.toContain('secret'); + }); +}); diff --git a/test/units.test.ts b/test/units.test.ts index 99df5b1..1704e80 100644 --- a/test/units.test.ts +++ b/test/units.test.ts @@ -9,6 +9,7 @@ import { configPath, loadConfig, saveConfig } from '../src/config.ts'; import { Context } from '../src/context.ts'; import { CliError } from '../src/errors.ts'; import { applyAssignments, parseAssignment } from '../src/input.ts'; +import { selectFields } from '../src/output.ts'; describe('assignments', () => { it('parses JSON values and falls back to strings', () => { @@ -91,3 +92,38 @@ describe('config + context', () => { }); }); + +describe('selectFields', () => { + const page = { + data: [ + { id: 'c1', email: 'a@x.co', status: 'new', properties: { city: 'Berlin' } }, + { id: 'c2', email: 'b@x.co' }, + ], + meta: { total: 2 }, + }; + + it('keeps the listed fields of each item and the meta', () => { + expect(selectFields(page, 'id,email')).toEqual({ + data: [ + { id: 'c1', email: 'a@x.co' }, + { id: 'c2', email: 'b@x.co' }, + ], + meta: { total: 2 }, + }); + }); + + it('reaches into nested objects and fills missing fields with null', () => { + expect(selectFields(page, 'id, properties.city')).toEqual({ + data: [ + { id: 'c1', 'properties.city': 'Berlin' }, + { id: 'c2', 'properties.city': null }, + ], + meta: { total: 2 }, + }); + }); + + it('works on a single object and on results without a data envelope', () => { + expect(selectFields({ data: { type: 'kpi', value: 3 } }, 'value')).toEqual({ data: { value: 3 } }); + expect(selectFields({ kpi: ['a'], chart: ['b'] }, 'kpi')).toEqual({ kpi: ['a'] }); + }); +}); From 376d0c343237360725d19702a035bd47bd97d4c8 Mon Sep 17 00:00:00 2001 From: Christian Korndoerfer Date: Mon, 19 Oct 2026 23:49:57 +0200 Subject: [PATCH 6/8] CAP-4819 docs(skill): 403 can mean a wrong funnel ID; list status values Co-Authored-By: Claude Opus 5.5 --- skills/perspective/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/perspective/SKILL.md b/skills/perspective/SKILL.md index ad93646..5a04fc5 100644 --- a/skills/perspective/SKILL.md +++ b/skills/perspective/SKILL.md @@ -29,7 +29,7 @@ Never print, log or commit an API key. Almost everything takes a `funnelId`. Get it first: ```bash -perspective funnels list --name "webinar" # flat list: id, name, status, workspaceId +perspective funnels list --name "webinar" # flat list: id, name, status (draft|live|offline), workspaceId perspective workspaces list # nested: workspaces → campaigns ``` @@ -77,7 +77,7 @@ perspective funnels list --status live --fields id,name | 1 | `error` | API error — read `error.message` | | 2 | `usage` | Bad arguments or 400 from the API — fix the call (see `error.hint`) | | 3 | `unauthenticated` | Missing/invalid API key — see step 0 | -| 4 | `forbidden` | Key lacks the permission (`crm:read`, `crm:write`, `metrics:read`) or plan | +| 4 | `forbidden` | Funnel ID wrong or in another workspace (re-check with `funnels list`), or the key lacks a permission (`crm:read`, `crm:write`, `metrics:read`) | | 5 | `not_found` | Wrong funnel or contact ID — re-discover IDs | | 6 | `rate_limited` | 100 requests/minute; the CLI already retried — wait a minute; prefer `--all` over many small calls | | 7 | `network` | API unreachable | From f991bf494c91b40066bd58b3237a8ce7b8e282a6 Mon Sep 17 00:00:00 2001 From: Christian Korndoerfer Date: Tue, 20 Oct 2026 00:00:25 +0200 Subject: [PATCH 7/8] CAP-4819 feat: several KPIs per call, JSON usage errors, KPI units - `metrics kpi ` takes several KPI names or `all`. One KPI still prints the API response as-is; several print {data: [...]} in the order given (requests run in parallel). - Parse errors (unknown option or command, missing argument, invalid choice) are now JSON on stderr with exit 2, like every other error. Help and version output are unchanged. - SKILL.md documents what each KPI value means (percent, seconds, count; the time KPIs are medians, checked in perspective-metrics), tells agents to fetch total_sessions before ranking rates, and that insight IDs must come from the user. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + skills/perspective/SKILL.md | 18 ++++++++++++++++++ src/cli.ts | 18 ++++++++++++++---- src/commands/metrics.ts | 30 ++++++++++++++++++++++-------- test/units.test.ts | 19 ++++++++++++++++++- 5 files changed, 73 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 30fa06a..955f20b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ perspective contacts list --all --fields id,email,status perspective contacts create --email ada@example.com --first-name Ada --dry-run perspective contacts create --email ada@example.com --first-name Ada perspective metrics kpi conversion_rate --days 7 +perspective metrics kpi all --fields type,value,count perspective metrics chart visitor_devices --from 2026-09-01 --to 2026-10-01 ``` diff --git a/skills/perspective/SKILL.md b/skills/perspective/SKILL.md index 5a04fc5..db64d01 100644 --- a/skills/perspective/SKILL.md +++ b/skills/perspective/SKILL.md @@ -53,12 +53,30 @@ The default window is the last 30 days in local time. ```bash perspective metrics kpi conversion_rate +perspective metrics kpi conversion_rate total_sessions # several → {data: [...]}, each with its type +perspective metrics kpi all --fields type,value,count # every KPI in one call perspective metrics kpi new_contacts --from 2026-09-01 --to 2026-10-01 perspective metrics chart page_to_page_conversion_rate --ab-test variant perspective metrics insight question_1234 --days 7 perspective metrics subtypes # valid kpi/chart names ``` +What a KPI's `value` means: + +| KPI | `value` | `count` | +|---|---|---| +| `conversion_rate`, `completion_rate` | % of sessions (0–100) | conversions / completions | +| `messages_delivery_rate`, `messages_open_rate` | % of messages (0–100) | the events behind the rate | +| `average_time_on_page` | seconds (average over pages of each page's median) | — | +| `time_to_completion` | seconds, median from first page to completion | — | +| `new_contacts`, `total_sessions`, `messages_sent` | count | — | + +A rate on a handful of sessions means little: before you rank or compare funnels, +fetch `total_sessions` with it (`kpi conversion_rate total_sessions`) and +mention low sample sizes. `metrics insight` needs the question's element ID from +the funnel builder; there is no way to list them, so ask the user. An unknown +ID returns an empty result, not an error. + ## Keep output small Contact lists can be large. Ask only for the fields you need — `--fields` works on diff --git a/src/cli.ts b/src/cli.ts index 8052db8..15da2e7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,7 +6,7 @@ import { registerContacts } from './commands/contacts.ts'; import { registerMetrics } from './commands/metrics.ts'; import { registerSkill } from './commands/skill.ts'; import { registerWorkspaces } from './commands/workspaces.ts'; -import { ExitCode } from './errors.ts'; +import { CliError, ExitCode } from './errors.ts'; import { printError } from './output.ts'; const { version } = JSON.parse( @@ -25,7 +25,9 @@ function buildProgram(): Command { .option('--fields ', 'only these fields of each result, comma-separated; dots nest (id,email,properties.city)') .option('--pretty', 'indent JSON output (default when stdout is a terminal)') .option('--no-pretty', 'compact JSON output (default when piped)') - .showHelpAfterError() + // Parse errors (unknown option, missing argument, invalid choice) are printed as JSON + // in the catch below; commander's own text would break the stderr contract. + .configureOutput({ outputError: () => {} }) .exitOverride(); registerAuth(program); @@ -40,8 +42,16 @@ try { await buildProgram().parseAsync(process.argv); } catch (err) { if (err instanceof CommanderError) { - // Commander already printed help/version/the usage message. - process.exitCode = err.exitCode === 0 ? ExitCode.OK : ExitCode.USAGE; + if (err.exitCode === 0 || err.code === 'commander.help') { + // Help or version text, already printed by commander. + process.exitCode = err.exitCode === 0 ? ExitCode.OK : ExitCode.USAGE; + } else { + process.exitCode = printError( + new CliError('usage', err.message.replace(/^error: /, '').replace(/\s*\n\s*/g, ' '), { + hint: 'Run the command with --help to see its arguments and options.', + }), + ); + } } else { process.exitCode = printError(err); } diff --git a/src/commands/metrics.ts b/src/commands/metrics.ts index 09aa1ec..8f34b6b 100644 --- a/src/commands/metrics.ts +++ b/src/commands/metrics.ts @@ -36,6 +36,12 @@ export function normalizeSubtype(raw: string, prefix: 'kpi' | 'chart', valid: st return full; } +/** Resolves KPI names (`all` = every KPI), keeping the given order and dropping repeats. */ +export function resolveKpis(raw: string[]): string[] { + const names = raw.flatMap((name) => (name.trim().toLowerCase() === 'all' ? KPI_SUBTYPES : [name])); + return [...new Set(names.map((name) => normalizeSubtype(name, 'kpi', KPI_SUBTYPES)))]; +} + interface WindowOptions { from?: string; to?: string; @@ -75,17 +81,25 @@ export function registerMetrics(program: Command): void { withWindow( metrics .command('kpi') - .description('One KPI for a funnel, e.g. conversion_rate, new_contacts, total_sessions') + .description( + 'KPIs for a funnel, e.g. conversion_rate total_sessions, or `all`. One KPI prints the API response ' + + 'as-is; several print {data: [...]} in the order given, each with its `type`.', + ) .argument('') - .argument(''), + .argument(''), ).action( - action(async (ctx, funnelId: string, subtype: string, opts: WindowOptions) => { - const kpi = normalizeSubtype(subtype, 'kpi', KPI_SUBTYPES); + action(async (ctx, funnelId: string, subtypes: string[], opts: WindowOptions) => { + const kpis = resolveKpis(subtypes); + const query = resolveWindow(opts); const rest = new RestClient(await ctx.baseUrl(), await ctx.apiKey()); - return rest.request({ - path: `/funnels/${encodeURIComponent(funnelId)}/metrics/kpis/${kpi}`, - query: resolveWindow(opts), - }); + const fetchKpi = (kpi: string) => + rest.request<{ data: unknown }>({ + path: `/funnels/${encodeURIComponent(funnelId)}/metrics/kpis/${kpi}`, + query, + }); + if (kpis.length === 1) return fetchKpi(kpis[0]!); + const results = await Promise.all(kpis.map(fetchKpi)); + return { data: results.map((result) => result.data) }; }), ); diff --git a/test/units.test.ts b/test/units.test.ts index 1704e80..2d7bcd9 100644 --- a/test/units.test.ts +++ b/test/units.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { mask } from '../src/commands/auth.ts'; -import { KPI_SUBTYPES, normalizeSubtype, resolveWindow } from '../src/commands/metrics.ts'; +import { KPI_SUBTYPES, normalizeSubtype, resolveKpis, resolveWindow } from '../src/commands/metrics.ts'; import { flattenFunnels } from '../src/commands/workspaces.ts'; import { configPath, loadConfig, saveConfig } from '../src/config.ts'; import { Context } from '../src/context.ts'; @@ -127,3 +127,20 @@ describe('selectFields', () => { expect(selectFields({ kpi: ['a'], chart: ['b'] }, 'kpi')).toEqual({ kpi: ['a'] }); }); }); + +describe('resolveKpis', () => { + it('normalizes names, keeps their order and drops repeats', () => { + expect(resolveKpis(['total_sessions', 'kpi_conversion_rate', 'total-sessions'])).toEqual([ + 'kpi_total_sessions', + 'kpi_conversion_rate', + ]); + }); + + it('expands all to every KPI', () => { + expect(resolveKpis(['all'])).toEqual(KPI_SUBTYPES); + }); + + it('rejects unknown names with the valid list', () => { + expect(() => resolveKpis(['conversion_rate', 'bogus'])).toThrow(CliError); + }); +}); From f602152cf533bf5d67146fa05d7c88e77c5f98c3 Mon Sep 17 00:00:00 2001 From: Christian Korndoerfer Date: Tue, 20 Oct 2026 16:44:47 +0200 Subject: [PATCH 8/8] CAP-4819 fix: address CodeRabbit review - 30 s request timeout per attempt, reported as a network error (exit 7) - empty base URL counts as unset; an invalid one is a usage error, not network - a broken credentials file is a CliError that points to auth logout - --tz-offset must be an integer - --set keeps integers a double can't hold exactly (long IDs) as text - skill show returns {path, content} JSON; skill install replaces SKILL.md atomically - SKILL.md: skill commands work without an API key Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- skills/perspective/SKILL.md | 3 ++- src/commands/metrics.ts | 10 ++++++--- src/commands/skill.ts | 18 ++++++++++------ src/config.ts | 17 ++++++++++++++- src/context.ts | 12 ++++++++--- src/input.ts | 15 ++++++++++---- src/rest.ts | 16 ++++++++++++--- test/clients.test.ts | 13 ++++++++++++ test/units.test.ts | 41 +++++++++++++++++++++++++++++++++---- 10 files changed, 121 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 955f20b..808ad44 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ anything else that reads Agent Skills) how to use the CLI safely. Install it wit ```bash perspective skill install # ~/.claude/skills/perspective/SKILL.md perspective skill install --project # ./.claude/skills/perspective/SKILL.md -perspective skill show # print it +perspective skill show # print it (JSON; | jq -r .content) ``` ## Development diff --git a/skills/perspective/SKILL.md b/skills/perspective/SKILL.md index db64d01..f39e6ba 100644 --- a/skills/perspective/SKILL.md +++ b/skills/perspective/SKILL.md @@ -17,7 +17,8 @@ Docs: https://developers.perspective.co/cli/overview perspective auth status ``` -If `authenticated` is `false`, every other command fails with exit code 3. Ask the +If `authenticated` is `false`, every command that calls the API fails with exit code 3 +(`skill show` and `skill install` work without a key). Ask the user for an API key (Perspective → Settings → API Keys, admins only). They can run `perspective auth login` themselves (it prompts for the key), or you can pipe it in: `printf %s "$KEY" | perspective auth login`. `PERSPECTIVE_API_KEY` works too. diff --git a/src/commands/metrics.ts b/src/commands/metrics.ts index 8f34b6b..758189e 100644 --- a/src/commands/metrics.ts +++ b/src/commands/metrics.ts @@ -46,7 +46,7 @@ interface WindowOptions { from?: string; to?: string; days: number; - tzOffset?: string; + tzOffset?: number; } function parseDate(name: string, value: string): Date { @@ -61,7 +61,7 @@ export function resolveWindow(opts: WindowOptions, now = new Date()) { const from = opts.from ? parseDate('from', opts.from) : new Date(to.getTime() - opts.days * 86_400_000); if (from >= to) throw usage('--from must be before --to'); // The API wants minutes east of UTC (UTC+2 → "120"); getTimezoneOffset is minutes west. - const offset = opts.tzOffset ?? String(-now.getTimezoneOffset()); + const offset = String(opts.tzOffset ?? -now.getTimezoneOffset()); return { from: from.toISOString(), to: to.toISOString(), offset }; } @@ -70,7 +70,11 @@ function withWindow(cmd: Command): Command { .option('--from ', 'start of the period (ISO 8601)') .option('--to ', 'end of the period (ISO 8601), default now') .option('--days ', 'period length when --from is not given', parseInteger('days', 1, 3650), 30) - .option('--tz-offset ', 'timezone offset in minutes east of UTC, default: local timezone'); + .option( + '--tz-offset ', + 'timezone offset in minutes east of UTC, default: local timezone', + parseInteger('tz-offset'), + ); } export function registerMetrics(program: Command): void { diff --git a/src/commands/skill.ts b/src/commands/skill.ts index 108c315..c64265f 100644 --- a/src/commands/skill.ts +++ b/src/commands/skill.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,10 +13,8 @@ export function registerSkill(program: Command): void { skill .command('show') - .description('Print SKILL.md (markdown, not JSON)') - .action(async () => { - process.stdout.write(await readFile(SKILL_SOURCE, 'utf8')); - }); + .description('Print SKILL.md as {path, content}; `| jq -r .content` gives the markdown') + .action(action(async () => ({ path: SKILL_SOURCE, content: await readFile(SKILL_SOURCE, 'utf8') }))); skill .command('install') @@ -32,7 +30,15 @@ export function registerSkill(program: Command): void { : join(homedir(), '.claude', 'skills'); const target = join(root, 'perspective', 'SKILL.md'); await mkdir(join(root, 'perspective'), { recursive: true }); - await writeFile(target, await readFile(SKILL_SOURCE, 'utf8')); + // Write a temp file and rename it over the target, so a failed write never leaves half a skill. + const temp = `${target}.${process.pid}.tmp`; + try { + await writeFile(temp, await readFile(SKILL_SOURCE, 'utf8')); + await rename(temp, target); + } catch (err) { + await rm(temp, { force: true }); + throw err; + } return { ok: true, installed: target }; }), ); diff --git a/src/config.ts b/src/config.ts index ba28c34..efa6287 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import { CliError } from './errors.ts'; export const DEFAULT_BASE_URL = 'https://api.perspective.co'; @@ -20,12 +21,26 @@ export function configPath(env: NodeJS.ProcessEnv = process.env): string { } export async function loadConfig(env: NodeJS.ProcessEnv = process.env): Promise { + const path = configPath(env); + let text: string; try { - return JSON.parse(await readFile(configPath(env), 'utf8')) as StoredConfig; + text = await readFile(path, 'utf8'); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; throw err; } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + parsed = undefined; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new CliError('error', `${path} is not a JSON object`, { + hint: 'Run `perspective auth logout` to delete it, then `perspective auth login`.', + }); + } + return parsed as StoredConfig; } /** The file holds secrets: the directory is 0700 and the file 0600. */ diff --git a/src/context.ts b/src/context.ts index 9ad6618..e73863d 100644 --- a/src/context.ts +++ b/src/context.ts @@ -28,11 +28,17 @@ export class Context { } async baseUrl(): Promise { + // `||`, not `??`: an empty value (e.g. an unset CI variable) means "not set". const url = - this.opts.baseUrl ?? - this.env.PERSPECTIVE_BASE_URL ?? - (await this.stored()).baseUrl ?? + this.opts.baseUrl || + this.env.PERSPECTIVE_BASE_URL || + (await this.stored()).baseUrl || DEFAULT_BASE_URL; + if (!URL.canParse(url)) { + throw new CliError('usage', `Invalid base URL "${url}"`, { + hint: 'Check --base-url, PERSPECTIVE_BASE_URL or the baseUrl in the credentials file.', + }); + } return url.replace(/\/+$/, ''); } diff --git a/src/input.ts b/src/input.ts index 5105a2d..6766ffe 100644 --- a/src/input.ts +++ b/src/input.ts @@ -45,6 +45,10 @@ export function parseAssignment(raw: string): [string[], unknown] { } catch { // plain string } + // Keep integers that a double can't hold exactly (e.g. long IDs) as text. + if (typeof value === 'number' && /^\s*-?\d+\s*$/.test(text) && BigInt(text.trim()) !== BigInt(value)) { + value = text; + } return [key.split('.'), value]; } @@ -66,11 +70,14 @@ export function collect(value: string, previous: string[] = []): string[] { return [...previous, value]; } -export function parseInteger(name: string, min: number, max = Number.MAX_SAFE_INTEGER) { +/** Without `min`, any integer is accepted. */ +export function parseInteger(name: string, min?: number, max = Number.MAX_SAFE_INTEGER) { return (value: string): number => { - const n = Number(value); - if (!Number.isInteger(n) || n < min || n > max) { - throw usage(`--${name} must be an integer between ${min} and ${max}`); + const n = value.trim() === '' ? NaN : Number(value); + if (!Number.isSafeInteger(n) || (min !== undefined && (n < min || n > max))) { + throw usage( + min === undefined ? `--${name} must be an integer` : `--${name} must be an integer between ${min} and ${max}`, + ); } return n; }; diff --git a/src/rest.ts b/src/rest.ts index 17c0f8e..ef0ec1e 100644 --- a/src/rest.ts +++ b/src/rest.ts @@ -15,6 +15,8 @@ export interface RestClientOptions { /** Retries after HTTP 429. The request was not processed, so retrying is safe for writes too. */ maxRetries?: number; sleep?: (ms: number) => Promise; + /** Per attempt, including reading the body. */ + timeoutMs?: number; } const MAX_RETRY_WAIT_MS = 60_000; @@ -34,12 +36,14 @@ export class RestClient { private readonly fetchImpl: typeof fetch; private readonly maxRetries: number; private readonly sleep: (ms: number) => Promise; + private readonly timeoutMs: number; constructor(baseUrl: string, apiKey: string, fetchImpl: typeof fetch = fetch, options: RestClientOptions = {}) { this.baseUrl = baseUrl; this.apiKey = apiKey; this.fetchImpl = fetchImpl; this.maxRetries = options.maxRetries ?? 3; + this.timeoutMs = options.timeoutMs ?? 30_000; this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); } @@ -58,7 +62,9 @@ export class RestClient { async request({ method = 'GET', path, query, body }: RestRequest): Promise { let res: Response; + let text: string; for (let attempt = 0; ; attempt++) { + const signal = AbortSignal.timeout(this.timeoutMs); try { res = await this.fetchImpl(this.url(path, query), { method, @@ -69,16 +75,20 @@ export class RestClient { ...(body !== undefined && { 'content-type': 'application/json' }), }, body: body === undefined ? undefined : JSON.stringify(body), + signal, }); + if (res.status !== 429 || attempt >= this.maxRetries) { + text = await res.text(); + break; + } } catch (err) { - throw new CliError('network', `Could not reach ${this.baseUrl}: ${(err as Error).message}`); + const reason = signal.aborted ? `no response within ${this.timeoutMs / 1000}s` : (err as Error).message; + throw new CliError('network', `Could not reach ${this.baseUrl}: ${reason}`); } - if (res.status !== 429 || attempt >= this.maxRetries) break; await res.body?.cancel(); await this.sleep(retryDelayMs(res, attempt)); } - const text = await res.text(); let parsed: unknown = undefined; try { parsed = text ? JSON.parse(text) : undefined; diff --git a/test/clients.test.ts b/test/clients.test.ts index 0583f21..69844d1 100644 --- a/test/clients.test.ts +++ b/test/clients.test.ts @@ -92,3 +92,16 @@ describe('RestClient rate limiting', () => { expect(JSON.stringify(described)).not.toContain('secret'); }); }); + +describe('RestClient timeout', () => { + it('fails with the network code when the API does not answer', async () => { + const impl = ((_url: string, init: RequestInit) => + new Promise((_, reject) => init.signal!.addEventListener('abort', () => reject(init.signal!.reason)))) as typeof fetch; + const err = (await new RestClient('https://x', 'k', impl, { timeoutMs: 20 }) + .request({ path: '/workspaces' }) + .catch((e) => e)) as CliError; + expect(err.code).toBe('network'); + expect(err.exitCode).toBe(ExitCode.NETWORK); + expect(err.message).toMatch(/no response within/); + }); +}); diff --git a/test/units.test.ts b/test/units.test.ts index 2d7bcd9..21b690f 100644 --- a/test/units.test.ts +++ b/test/units.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, stat } from 'node:fs/promises'; +import { mkdtemp, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -8,7 +8,7 @@ import { flattenFunnels } from '../src/commands/workspaces.ts'; import { configPath, loadConfig, saveConfig } from '../src/config.ts'; import { Context } from '../src/context.ts'; import { CliError } from '../src/errors.ts'; -import { applyAssignments, parseAssignment } from '../src/input.ts'; +import { applyAssignments, parseAssignment, parseInteger } from '../src/input.ts'; import { selectFields } from '../src/output.ts'; describe('assignments', () => { @@ -39,9 +39,9 @@ describe('metrics helpers', () => { it('defaults to the last N days and honours explicit bounds', () => { const now = new Date('2026-10-19T12:00:00.000Z'); - const w = resolveWindow({ days: 7, tzOffset: '0' }, now); + const w = resolveWindow({ days: 7, tzOffset: 0 }, now); expect(w).toEqual({ from: '2026-10-12T12:00:00.000Z', to: '2026-10-19T12:00:00.000Z', offset: '0' }); - const explicit = resolveWindow({ from: '2026-09-01', to: '2026-10-01', days: 30, tzOffset: '120' }, now); + const explicit = resolveWindow({ from: '2026-09-01', to: '2026-10-01', days: 30, tzOffset: 120 }, now); expect(explicit.from).toBe('2026-09-01T00:00:00.000Z'); expect(explicit.offset).toBe('120'); expect(() => resolveWindow({ from: '2026-10-02', to: '2026-10-01', days: 30 }, now)).toThrow(/before/); @@ -144,3 +144,36 @@ describe('resolveKpis', () => { expect(() => resolveKpis(['conversion_rate', 'bogus'])).toThrow(CliError); }); }); + +describe('review fixes', () => { + it('keeps integers a double cannot hold as text, other numbers as numbers', () => { + expect(parseAssignment('id=12345678901234567890')).toEqual([['id'], '12345678901234567890']); + expect(parseAssignment('n=1e3')).toEqual([['n'], 1000]); + expect(parseAssignment('n=1.0')).toEqual([['n'], 1]); + expect(parseAssignment('n=-42')).toEqual([['n'], -42]); + }); + + it('parses --tz-offset as an integer', () => { + const parse = parseInteger('tz-offset'); + expect(parse('-300')).toBe(-300); + for (const bad of ['abc', '+2h', '1.5', '']) expect(() => parse(bad)).toThrow(/integer/); + }); + + it('reports a broken credentials file as a CliError pointing to logout', async () => { + for (const content of ['{nope', 'null', '[]']) { + const dir = await mkdtemp(join(tmpdir(), 'perspective-cli-')); + await writeFile(join(dir, 'credentials.json'), content); + const err = (await loadConfig({ PERSPECTIVE_CONFIG_DIR: dir }).catch((e) => e)) as CliError; + expect(err).toBeInstanceOf(CliError); + expect(err.hint).toMatch(/auth logout/); + } + }); + + it('treats an empty base URL as unset and rejects an invalid one', async () => { + const dir = await mkdtemp(join(tmpdir(), 'perspective-cli-')); + const env = { PERSPECTIVE_CONFIG_DIR: dir, PERSPECTIVE_BASE_URL: '' }; + expect(await new Context({}, env).baseUrl()).toBe('https://api.perspective.co'); + const err = (await new Context({ baseUrl: 'not a url' }, env).baseUrl().catch((e) => e)) as CliError; + expect(err.code).toBe('usage'); + }); +});