diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..a782540 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,85 @@ +name: Bug report +description: Something in toreva/kit (SDK, MCP server tools, type schemas) is broken or behaves unexpectedly. +title: "[bug] " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for the report. First-response SLA is under 4 hours during AEST business hours, under 24 hours otherwise. + See [`cdx/docs/triage-protocol.md`](https://github.com/toreva/cdx/blob/main/docs/triage-protocol.md). + + If this is a **production-down** or **security** issue, also email `dev@toreva.io` with subject prefix `URGENT — production`. + + If your question is "how do I…?" rather than "this is broken", post in [Discussions](https://github.com/toreva/kit/discussions) instead — gets faster, less formal answers. + - type: input + id: kit-version + attributes: + label: kit / package version + description: e.g. `@toreva/mcp 0.1.2`. Run `npm ls @toreva/mcp` or check your `package.json`. + placeholder: "@toreva/mcp 0.1.2" + validations: + required: true + - type: input + id: mcp-client + attributes: + label: MCP client (if relevant) + description: e.g. Claude desktop 0.7.x, Cursor 0.42.x, custom client. Leave blank if SDK-only bug. + placeholder: "Claude desktop 0.7.4" + - type: input + id: os + attributes: + label: OS + version + placeholder: "macOS 14.5" + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened + description: One paragraph. What did you do, what did the system do. + placeholder: | + I called `place_order(wallet_id="...", asset="SOL", side="buy", amount=10)` from Claude desktop and got a 5xx with body `{"error": "..."}`. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What you expected + placeholder: | + Per DEC-001 §MCP, `place_order` should return a receipt envelope with a real Solana tx signature. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Minimal reproduction + description: Code, commands, or step-by-step. Smaller is better. + render: shell + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs / error output + description: Redact any OAuth tokens or wallet addresses you don't want public. (We can never recover what you've already posted publicly.) + render: shell + - type: dropdown + id: severity + attributes: + label: Severity (your view) + options: + - "P0 — production down / financial loss imminent" + - "P1 — blocking my development, no workaround" + - "P2 — blocking my development, workaround exists" + - "P3 — annoying, not blocking" + default: 2 + validations: + required: true + - type: checkboxes + id: regulated-claim-check + attributes: + label: Regulated-claim check + description: Tick if your bug touches Earn / Stake / Balance / yield / custody / "is this safe?" — we'll route via `compliance-agent` first. + options: + - label: This bug touches a regulated-financial-product surface (Earn, Stake, Balance, yield, return, custody, safety-of-funds claim). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..110c906 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: General Q&A and how-to questions + url: https://github.com/toreva/kit/discussions + about: For "how do I…?" questions, design discussion, and showing off what you've built. Faster turn-around than Issues. + - name: Compliance-sensitive or private questions + url: mailto:dev@toreva.io + about: For anything you'd rather not post publicly (employer policy, regulated-product questions, security reports). + - name: Triage protocol & SLAs + url: https://github.com/toreva/cdx/blob/main/docs/triage-protocol.md + about: How we route issues and what response time to expect. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..be69131 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,59 @@ +name: Feature request +description: A primitive, tool, type schema, or capability you'd want kit / MCP to expose. +title: "[feature] " +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for the suggestion. First-response SLA is under 4 hours during AEST business hours, under 24 hours otherwise. + + If you're not sure whether what you want already exists, post in [Discussions](https://github.com/toreva/kit/discussions) first — saves us both a round trip. + + Doctrine reference for what's locked vs open: [`po/docs/decisions/DEC-001-wallet-mode-architecture.md`](https://github.com/toreva/po/blob/main/docs/decisions/DEC-001-wallet-mode-architecture.md). The MCP tool surface in §"Connect integration mechanic — MCP" is the v0.2 lock; we're collecting feedback for v0.3. + - type: textarea + id: workflow + attributes: + label: What real workflow are you trying to build? + description: Spec-language preferred. "I want my agent to X, then Y, then Z." Concrete beats abstract. + validations: + required: true + - type: textarea + id: blocker + attributes: + label: What's blocking you today + description: What can't you do with the current kit / MCP surface? Be specific — tool name, missing parameter, missing return field, etc. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed shape (optional) + description: If you have a tool signature / type / API in mind, sketch it. We may converge on something different but your starting point helps. + render: typescript + - type: textarea + id: alternatives + attributes: + label: Alternatives you've tried + description: Workarounds in your own code, in another tool, in a competitor. Tells us how urgent the gap is. + - type: dropdown + id: scope-guess + attributes: + label: Where do you think this belongs (your guess) + description: We'll route correctly even if you guess wrong; this just helps speed up routing. + options: + - "kit / MCP tool surface" + - "kit / SDK type schemas" + - "gateway / MCP server runtime" + - "investment-product / strategy semantics" + - "pricing / rate card" + - "I genuinely don't know" + default: 5 + validations: + required: true + - type: checkboxes + id: regulated-claim-check + attributes: + label: Regulated-claim check + options: + - label: This feature touches a regulated-financial-product surface (Earn, Stake, Balance, yield, return, custody). I understand the response will route via `compliance-agent` first. diff --git a/.github/ISSUE_TEMPLATE/integration-help.yml b/.github/ISSUE_TEMPLATE/integration-help.yml new file mode 100644 index 0000000..0242ca5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/integration-help.yml @@ -0,0 +1,68 @@ +name: Integration help +description: You're trying to wire Toreva into your client / framework / workflow and stuck. +title: "[help] " +labels: ["question", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Welcome. If your question is "how do I install / authenticate / make my first tool call work", you're in the right place. + + For lighter-weight Q&A, [Discussions](https://github.com/toreva/kit/discussions) gets faster turn-around. Issues are best when you've already tried and hit a wall worth tracking. + + Canonical install reference: [`kit/README.md`](https://github.com/toreva/kit/blob/main/README.md) and the per-skill docs under [`kit/skills/`](https://github.com/toreva/kit/tree/main/skills). + - type: input + id: client + attributes: + label: What client / framework / tool are you trying to integrate from? + placeholder: "Claude desktop / Cursor / LangChain / custom Python / something else" + validations: + required: true + - type: input + id: kit-version + attributes: + label: kit / package version + placeholder: "@toreva/mcp 0.1.2" + - type: textarea + id: goal + attributes: + label: What's your goal — in one sentence + description: "I want my agent to X" — the workflow you want to enable, not the technical step. + placeholder: "I want my Cursor session to be able to check my Toreva balance and place a small SOL trade when I tell it to." + validations: + required: true + - type: textarea + id: tried + attributes: + label: What you've tried so far + description: Doc links you've followed, commands you've run, error messages you've hit. Copy-paste exact strings — paraphrase loses signal. + render: shell + validations: + required: true + - type: textarea + id: stuck-on + attributes: + label: Where you're stuck + description: One paragraph. The current symptom and the gap between current state and goal. + validations: + required: true + - type: dropdown + id: stage + attributes: + label: At what stage of integration are you stuck? + options: + - "Install — can't get the MCP server / SDK installed" + - "Auth — install works but token / OAuth / delegation isn't accepted" + - "First tool call — auth works but tool calls fail" + - "Specific tool semantics — most tools work but one is behaving wrong" + - "Receipts / explanation — tool calls work but I can't make sense of the receipts" + - "Beyond setup — performance / scale / reliability" + default: 0 + validations: + required: true + - type: checkboxes + id: regulated-claim-check + attributes: + label: Regulated-claim check + options: + - label: My question touches a regulated-financial-product surface (Earn, Stake, Balance, yield, return, custody). I understand parts of my answer may route via `compliance-agent`. diff --git a/.gitignore b/.gitignore index 954f316..7cccb0a 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,67 @@ vite.config.ts.timestamp-* # AI memory (private — do not expose in public repos) .memory/ + +# ───── IAM-managed sensitivity-tier baseline (auto-appended by onboard-agent.sh) ───── +# Sensitivity tier: open-public +# Used by: repos that are public OR will be public OR are read by external integrators +# (e.g. kit, soon docs). +# Rule: NEVER let anything sensitive land in these repos. +# Owner: iam-agent (validated on every onboard-agent.sh run for open-public tier). + +# --- secrets / credentials (never in any repo, especially open-public) --- +.env +.env.* +*.pem +*.key +*.p12 +*.pfx +secrets/ +credentials.json +gcp-key.json +service-account*.json +*.cert +*.crt + +# --- internal dispatch artefacts (never publish to integrators) --- +intake/responses/*.md +intake/responses/*.err +intake/processed/ + +# --- internal correspondence / non-public memory --- +MEMORY.local.md +*.local.md +.claude/memory/ + +# --- build artefacts --- +node_modules/ +dist/ +build/ +.turbo/ +.next/ +.cache/ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# --- editor / OS --- +.DS_Store +.idea/ +.vscode/ +*.swp +*~ + +# --- logs (may include sensitive request bodies) --- +*.log +logs/ + +# --- test artefacts that may contain sample customer data --- +test-fixtures/private/ +test-data/customer-*/ + +# --- internal-only docs (never published) --- +docs/internal/ +INTERNAL-*.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..9354c51 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,40 @@ +# kit — Architecture + +**Agent:** `kit-agent` · **Repo:** `kit` · **Domain:** `customer-external` + +> **TODO (kit-agent):** This file is a stub. Fill it before promoting +> from `dormant` to `active`. The fleet audit checks for stub-level +> content and flags it as a G2.3 gap. + +## What this system does + +> One paragraph, ELI10. What problem does this agent solve? Who is it for? + +## How it fits into Toreva + +> Diagram or bullet list. Upstream inputs (other agents this consumes from). +> Downstream outputs (other agents this produces for). Bus topics published. +> Bus topics subscribed. + +## Internal shape + +> Module / package / service breakdown. Where does state live? What's +> permanent (in DB / GCS / GitHub)? What's ephemeral (in memory / tmp)? + +## Invariants + +> Things that MUST be true. E.g. "We never sign on behalf of a user." +> "Every action publishes a receipt envelope." "Air-gap = zero, ever." + +## Failure modes + +> Known failure scenarios + how the system degrades. Fail-open vs fail-closed. + +## External dependencies + +> Third-party services, GCP services, on-chain programs. + +## Decisions log + +See [`docs/decisions/`](./docs/decisions/) for material architectural +decisions (DEC- or ADR- documents). diff --git a/BACKUP.md b/BACKUP.md new file mode 100644 index 0000000..e1b2ba9 --- /dev/null +++ b/BACKUP.md @@ -0,0 +1,52 @@ +# kit — Backup, restore, persistence + +**Agent:** `kit-agent` · **Repo:** `kit` · **Domain:** `customer-external` + +What's permanent for this agent, where it lives, and how it survives +catastrophic loss of the operator's laptop, local filesystem, or GitHub. + +## Source-of-truth map + +| Artefact | Permanent home | Ephemeral copy | Recoverable from | +|---|---|---|---| +| Code | GitHub `/` | local `~/toreva_vs/` | GitHub remote | +| `MEMORY.md` | GitHub | local | GitHub + memory-archive sweep | +| `intake/processed/` (audit trail) | GitHub | local | GitHub remote | +| `intake/responses/` (working drafts) | local only (gitignored on `open-public`) | local | NOT recoverable — by design | +| Bus envelopes | `toreva-prod.coordinator_audit_prod.bus_events` (BigQuery) | none | BQ time-travel + GCS export | +| Runtime SA credentials | GCP Secret Manager | runtime SA key cache | IAC dispatch to re-issue | +| Per-agent GH identity | GitHub Org settings + GCP Secret Manager | runtime cache | IAC + IAM joint dispatch | + +## Recovery scenarios + +### Scenario A — operator laptop dies + +1. Provision new device. +2. Re-clone `~/toreva_vs/` from GitHub. +3. `MEMORY.md` survives (GitHub-resident). +4. `intake/responses/` (work-in-progress drafts) is **not recoverable** for + `open-public` tier (gitignored to prevent leakage); `normal`/`hardened` + tier may be recoverable via GitHub if committed. +5. Re-launch supervisor: `coordinator/scripts/supervisor.sh restart kit-agent`. + +### Scenario B — local filesystem corruption + +Same as Scenario A. + +### Scenario C — GitHub repo deleted / corrupted / org-level outage + +1. IAC dispatches a restore from the most recent GitHub-org-level backup + (target: GCS bucket `gs://toreva-prod-iam-backups///`). +2. Frequency: nightly snapshot via IAC's GitHub-backup workflow. **Not yet + provisioned for every agent — see G2.13.** +3. Re-push to a fresh GitHub repo if the original cannot be restored. + +### Scenario D — Bus-history dataset corruption + +Coordinator-owned. Out of this agent's scope. See coordinator's runbook. + +## What is intentionally NOT permanent + +- `intake/responses/` work-in-progress drafts for `open-public` tier +- Local `node_modules/`, `__pycache__/`, build outputs +- Anything in `*.local.md` files diff --git a/README.md b/README.md index 843f644..33457b1 100644 --- a/README.md +++ b/README.md @@ -8,37 +8,70 @@ Your agent decides. Toreva executes. Every action receipted. ## Install +The fastest path — wires Toreva into your MCP-aware client (Claude +Desktop, OpenClaw, Cursor) and authenticates you in two commands: + ```bash -npm install @toreva/sdk -npm install -g @toreva/cli +npx toreva init --client=claude-desktop # or openclaw | cursor +npx toreva login ``` -### MCP server (stdio) +`toreva init` writes the Toreva MCP server stanza into your client's +config file. `toreva login` runs the gateway's device-code flow and +stores the resulting token at `~/.config/toreva/config.json` (chmod +600). + +Restart your MCP client and verify: ```bash -RELAY_AUTH_TOKEN=your_token npx @toreva/mcp +npx toreva doctor ``` -### MCP server (remote) +You should see three `[ OK ]` lines: `config_present`, `auth_token`, +`mcp_call`. -No install needed — connect directly: +Per-client snippets live in [`examples/`](./examples/) — one folder per +supported client (`claude-desktop`, `openclaw`, `cursor`). +### Direct package installs (advanced) + +```bash +npm install @toreva/sdk # TypeScript client library +npm install -g @toreva/cli # global `toreva` binary ``` -https://gateway.toreva.com/mcp + +### MCP server (stdio, run-it-yourself) + +```bash +TOREVA_AUTH_TOKEN=your_token npx @toreva/mcp ``` -## Authentication +### MCP server (remote, no install) -All commands — including read-only queries — require a `RELAY_AUTH_TOKEN`. +``` +https://mcp.toreva.com +``` + +## Authentication -Set it as an environment variable: +`toreva login` is the standard path. For CI / power users, set +`TOREVA_AUTH_TOKEN` directly to skip the device-code flow: ```bash -export RELAY_AUTH_TOKEN=your_token +export TOREVA_AUTH_TOKEN=your_token +npx toreva login # writes the token to ~/.config/toreva/config.json ``` Request a token at [toreva.com/docs](https://toreva.com/docs). +### Environment variables + +| Var | Default | Purpose | +| --- | --- | --- | +| `TOREVA_MCP_URL` | `https://mcp.toreva.com` | Gateway URL | +| `TOREVA_AUTH_TOKEN` | — | Skip device-code flow, persist this token | +| `TOREVA_CONFIG_DIR` | `~/.config/toreva` | Override on-disk config dir | + ## Perps tools | Tool | Fee | What it does | diff --git a/bin/toreva b/bin/toreva new file mode 100755 index 0000000..b780014 --- /dev/null +++ b/bin/toreva @@ -0,0 +1,21 @@ +#!/usr/bin/env node +// Repo-root shim for `npx toreva` against this monorepo. +// Published `npx @toreva/cli` runs packages/cli/dist/index.js directly via its own bin. +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { existsSync } from 'node:fs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const cliDist = join(here, '..', 'packages', 'cli', 'dist', 'index.js'); + +if (!existsSync(cliDist)) { + console.error( + `[toreva] Built CLI not found at ${cliDist}.\n` + + `Run \`pnpm -C packages/cli build\` first, or install the published \`@toreva/cli\`.` + ); + process.exit(1); +} + +const res = spawnSync(process.execPath, [cliDist, ...process.argv.slice(2)], { stdio: 'inherit' }); +process.exit(res.status ?? 1); diff --git a/examples/claude-desktop/README.md b/examples/claude-desktop/README.md new file mode 100644 index 0000000..2af5305 --- /dev/null +++ b/examples/claude-desktop/README.md @@ -0,0 +1,37 @@ +# Claude Desktop — Toreva MCP + +Quick start (one command): + +```bash +npx toreva init --client=claude-desktop +npx toreva login +``` + +Then quit + relaunch Claude Desktop. + +## Manual install + +If you'd rather edit the config yourself, copy the snippet from +[`claude_desktop_config.json`](./claude_desktop_config.json) into your +Claude Desktop config file: + +- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +- Windows: `%APPDATA%\Claude\claude_desktop_config.json` +- Linux: `~/.config/Claude/claude_desktop_config.json` + +If the file already has an `mcpServers` block, merge `toreva` into it +rather than replacing the whole block. + +## First-run check + +```bash +npx toreva doctor +``` + +Should report `[ OK ]` for `config_present`, `auth_token`, and +`mcp_call`. + +## Override the gateway URL + +Set `TOREVA_MCP_URL` before `toreva init` (or edit the `env` block in the +config snippet) to point at a non-prod gateway. diff --git a/examples/claude-desktop/claude_desktop_config.json b/examples/claude-desktop/claude_desktop_config.json new file mode 100644 index 0000000..8e90449 --- /dev/null +++ b/examples/claude-desktop/claude_desktop_config.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "toreva": { + "command": "npx", + "args": ["-y", "@toreva/mcp"], + "env": { + "TOREVA_MCP_URL": "https://mcp.toreva.com" + } + } + } +} diff --git a/examples/cursor/README.md b/examples/cursor/README.md new file mode 100644 index 0000000..378bbe5 --- /dev/null +++ b/examples/cursor/README.md @@ -0,0 +1,20 @@ +# Cursor — Toreva MCP + +```bash +npx toreva init --client=cursor +npx toreva login +``` + +Cursor picks up MCP server changes on the next reload of the agent. + +## Manual install + +Copy [`mcp.json`](./mcp.json) to `~/.cursor/mcp.json`. Merge the `toreva` +entry into any existing `mcpServers` block rather than replacing the +file. + +## Verify + +```bash +npx toreva doctor +``` diff --git a/examples/cursor/mcp.json b/examples/cursor/mcp.json new file mode 100644 index 0000000..8e90449 --- /dev/null +++ b/examples/cursor/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "toreva": { + "command": "npx", + "args": ["-y", "@toreva/mcp"], + "env": { + "TOREVA_MCP_URL": "https://mcp.toreva.com" + } + } + } +} diff --git a/examples/openclaw/README.md b/examples/openclaw/README.md new file mode 100644 index 0000000..2747b76 --- /dev/null +++ b/examples/openclaw/README.md @@ -0,0 +1,20 @@ +# OpenClaw — Toreva MCP + +```bash +npx toreva init --client=openclaw +npx toreva login +``` + +Restart OpenClaw after the install completes. + +## Manual install + +Copy [`mcp.json`](./mcp.json) to `~/.config/openclaw/mcp.json`. If the +file already exists, merge the `toreva` entry into the existing +`mcpServers` block. + +## Verify + +```bash +npx toreva doctor +``` diff --git a/examples/openclaw/mcp.json b/examples/openclaw/mcp.json new file mode 100644 index 0000000..8e90449 --- /dev/null +++ b/examples/openclaw/mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "toreva": { + "command": "npx", + "args": ["-y", "@toreva/mcp"], + "env": { + "TOREVA_MCP_URL": "https://mcp.toreva.com" + } + } + } +} diff --git a/package.json b/package.json index 946488a..2d3230f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "description": "toreva kit monorepo", "license": "MIT", "packageManager": "pnpm@10.0.0", + "bin": { + "toreva": "bin/toreva" + }, "scripts": { "build": "turbo run build", "typecheck": "turbo run typecheck", diff --git a/packages/cli/src/__tests__/doctor.test.ts b/packages/cli/src/__tests__/doctor.test.ts new file mode 100644 index 0000000..963139f --- /dev/null +++ b/packages/cli/src/__tests__/doctor.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { formatReport, runDoctor } from '../commands/doctor.js'; + +function fetchReturning(status: number): typeof fetch { + return (async () => + ({ + ok: status >= 200 && status < 300, + status, + statusText: `status-${status}`, + json: async () => ({}), + }) as unknown as Response) as unknown as typeof fetch; +} + +function fetchThrowing(message: string): typeof fetch { + return (async () => { + throw new Error(message); + }) as unknown as typeof fetch; +} + +describe('runDoctor', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'toreva-doctor-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function envBase(): NodeJS.ProcessEnv { + return { + ...process.env, + TOREVA_CONFIG_DIR: dir, + TOREVA_MCP_URL: 'https://mcp.example.com', + }; + } + + function writeConfig(token: string | undefined): void { + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'config.json'), + JSON.stringify({ + mcpUrl: 'https://mcp.example.com', + authToken: token, + issuedAt: '2026-04-26T00:00:00Z', + }) + ); + } + + it('reports OK when config + token + reachable gateway all healthy', async () => { + writeConfig('tok-1'); + const report = await runDoctor({ fetch: fetchReturning(200) }, envBase()); + expect(report.ok).toBe(true); + expect(report.checks.map((c) => c.status)).toEqual(['ok', 'ok', 'ok']); + }); + + it('reports config_present error when no config file', async () => { + const report = await runDoctor({ fetch: fetchReturning(200) }, envBase()); + expect(report.ok).toBe(false); + const cfg = report.checks.find((c) => c.name === 'config_present'); + expect(cfg?.status).toBe('error'); + }); + + it('reports auth_token error when config exists but no token', async () => { + writeConfig(undefined); + const report = await runDoctor({ fetch: fetchReturning(200) }, envBase()); + const tok = report.checks.find((c) => c.name === 'auth_token'); + expect(tok?.status).toBe('error'); + // mcp_call should be skipped (warn) when no token. + const call = report.checks.find((c) => c.name === 'mcp_call'); + expect(call?.status).toBe('warn'); + }); + + it('reports mcp_call error when gateway rejects token', async () => { + writeConfig('bad'); + const report = await runDoctor({ fetch: fetchReturning(401) }, envBase()); + const call = report.checks.find((c) => c.name === 'mcp_call'); + expect(call?.status).toBe('error'); + expect(call?.message).toMatch(/rejected token/); + expect(report.ok).toBe(false); + }); + + it('reports mcp_call error when network unreachable', async () => { + writeConfig('tok-1'); + const report = await runDoctor({ fetch: fetchThrowing('ENOTFOUND') }, envBase()); + const call = report.checks.find((c) => c.name === 'mcp_call'); + expect(call?.status).toBe('error'); + expect(call?.message).toMatch(/ENOTFOUND/); + }); + + it('formatReport renders status tags', () => { + const text = formatReport({ + ok: false, + checks: [ + { name: 'a', status: 'ok', message: 'fine' }, + { name: 'b', status: 'warn', message: 'meh' }, + { name: 'c', status: 'error', message: 'bad' }, + ], + }); + expect(text).toMatch(/\[ OK \] a/); + expect(text).toMatch(/\[WARN\] b/); + expect(text).toMatch(/\[FAIL\] c/); + expect(text).toMatch(/One or more checks failed/); + }); +}); diff --git a/packages/cli/src/__tests__/init.test.ts b/packages/cli/src/__tests__/init.test.ts new file mode 100644 index 0000000..0f139b5 --- /dev/null +++ b/packages/cli/src/__tests__/init.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runInit, parseInitArgs, buildServerStanza } from '../commands/init.js'; + +describe('parseInitArgs', () => { + it('extracts --client', () => { + expect(parseInitArgs(['--client=cursor'])).toEqual({ client: 'cursor' }); + }); + + it('throws when --client missing', () => { + expect(() => parseInitArgs([])).toThrow(/Missing --client/); + }); +}); + +describe('buildServerStanza', () => { + it('produces stdio command + TOREVA_MCP_URL env', () => { + const stanza = buildServerStanza('https://mcp.example.com') as { + command: string; + args: string[]; + env: Record; + }; + expect(stanza.command).toBe('npx'); + expect(stanza.args).toEqual(['-y', '@toreva/mcp']); + expect(stanza.env.TOREVA_MCP_URL).toBe('https://mcp.example.com'); + }); +}); + +describe('runInit', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'toreva-init-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function envFor(client: 'claude-desktop' | 'openclaw' | 'cursor', overrides: Record = {}) { + const path = join(dir, `${client}.json`); + const map = { + 'claude-desktop': 'TOREVA_CLAUDE_DESKTOP_CONFIG', + openclaw: 'TOREVA_OPENCLAW_CONFIG', + cursor: 'TOREVA_CURSOR_CONFIG', + } as const; + return { + configPath: path, + env: { + ...process.env, + TOREVA_HOME: dir, + TOREVA_MCP_URL: 'https://mcp.example.com', + [map[client]]: path, + ...overrides, + } as NodeJS.ProcessEnv, + }; + } + + it('writes a fresh claude-desktop config when none exists', () => { + const { configPath, env } = envFor('claude-desktop'); + const res = runInit('claude-desktop', env, 'darwin'); + expect(res.created).toBe(true); + expect(res.configPath).toBe(configPath); + expect(existsSync(configPath)).toBe(true); + + const written = JSON.parse(readFileSync(configPath, 'utf-8')) as { + mcpServers: Record }>; + }; + expect(written.mcpServers.toreva.command).toBe('npx'); + expect(written.mcpServers.toreva.args).toEqual(['-y', '@toreva/mcp']); + expect(written.mcpServers.toreva.env.TOREVA_MCP_URL).toBe('https://mcp.example.com'); + }); + + it('preserves existing servers when adding toreva', () => { + const { configPath, env } = envFor('cursor'); + writeFileSync( + configPath, + JSON.stringify({ mcpServers: { existing: { command: 'foo' } }, otherKey: 42 }) + ); + const res = runInit('cursor', env, 'darwin'); + expect(res.created).toBe(false); + + const written = JSON.parse(readFileSync(configPath, 'utf-8')) as { + mcpServers: Record; + otherKey: number; + }; + expect(written.mcpServers.existing).toEqual({ command: 'foo' }); + expect(written.mcpServers.toreva).toBeDefined(); + expect(written.otherKey).toBe(42); + }); + + it('overwrites a previous toreva entry without duplicating', () => { + const { configPath, env } = envFor('openclaw'); + writeFileSync( + configPath, + JSON.stringify({ mcpServers: { toreva: { command: 'old' } } }) + ); + runInit('openclaw', env, 'linux'); + const written = JSON.parse(readFileSync(configPath, 'utf-8')) as { + mcpServers: { toreva: { command: string } }; + }; + expect(written.mcpServers.toreva.command).toBe('npx'); + }); + + it('rejects unsupported clients', () => { + expect(() => runInit('vscode')).toThrow(/Unsupported --client/); + }); + + it('refuses to overwrite invalid JSON', () => { + const { configPath, env } = envFor('cursor'); + writeFileSync(configPath, 'not json'); + expect(() => runInit('cursor', env, 'darwin')).toThrow(/not valid JSON/); + }); +}); diff --git a/packages/cli/src/__tests__/login.test.ts b/packages/cli/src/__tests__/login.test.ts new file mode 100644 index 0000000..78aa639 --- /dev/null +++ b/packages/cli/src/__tests__/login.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, existsSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runLogin } from '../commands/login.js'; + +interface MockResponse { + status: number; + body: unknown; +} + +function makeFetch(plan: MockResponse[]): typeof fetch { + let i = 0; + const calls: { url: string; init?: RequestInit }[] = []; + const fn = (async (url: string, init?: RequestInit) => { + calls.push({ url, init }); + const next = plan[Math.min(i, plan.length - 1)]; + i++; + return { + ok: next.status >= 200 && next.status < 300, + status: next.status, + statusText: `status-${next.status}`, + json: async () => next.body, + } as unknown as Response; + }) as unknown as typeof fetch; + (fn as unknown as { calls: typeof calls }).calls = calls; + return fn; +} + +describe('runLogin', () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'toreva-login-')); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function envBase(): NodeJS.ProcessEnv { + return { + ...process.env, + TOREVA_CONFIG_DIR: dir, + TOREVA_MCP_URL: 'https://mcp.example.com', + TOREVA_AUTH_TOKEN: '', + }; + } + + it('completes device-code flow when gateway returns token', async () => { + const fetchMock = makeFetch([ + { + status: 200, + body: { + device_code: 'dc-1', + user_code: 'ABCD-EFGH', + verification_uri: 'https://gateway.example.com/device', + expires_in: 60, + interval: 0, + }, + }, + { status: 428, body: { error: 'authorization_pending' } }, + { status: 200, body: { access_token: 'tok-1', token_type: 'bearer', expires_in: 3600 } }, + ]); + + const logs: string[] = []; + const res = await runLogin({ fetch: fetchMock, log: (m) => logs.push(m) }, envBase()); + + expect(res.authToken).toBe('tok-1'); + expect(res.mcpUrl).toBe('https://mcp.example.com'); + expect(existsSync(res.configPath)).toBe(true); + + const written = JSON.parse(readFileSync(res.configPath, 'utf-8')) as { + mcpUrl: string; + authToken: string; + issuedAt: string; + }; + expect(written.authToken).toBe('tok-1'); + expect(written.mcpUrl).toBe('https://mcp.example.com'); + expect(written.issuedAt).toBeTruthy(); + + const mode = statSync(res.configPath).mode & 0o777; + expect(mode).toBe(0o600); + + expect(logs.join('\n')).toMatch(/ABCD-EFGH/); + }); + + it('skips device-code flow when TOREVA_AUTH_TOKEN is set', async () => { + const fetchMock = makeFetch([{ status: 500, body: {} }]); + const env = { ...envBase(), TOREVA_AUTH_TOKEN: 'paste-me' }; + const res = await runLogin({ fetch: fetchMock }, env); + expect(res.authToken).toBe('paste-me'); + // fetch should never have been called. + expect((fetchMock as unknown as { calls: unknown[] }).calls).toHaveLength(0); + }); + + it('throws on malformed device-code response', async () => { + const fetchMock = makeFetch([{ status: 200, body: { foo: 'bar' } }]); + await expect(runLogin({ fetch: fetchMock }, envBase())).rejects.toThrow(/malformed device-code/); + }); + + it('throws on gateway 5xx during token poll', async () => { + const fetchMock = makeFetch([ + { + status: 200, + body: { + device_code: 'dc-1', + user_code: 'X', + verification_uri: 'https://x', + interval: 0, + }, + }, + { status: 502, body: {} }, + ]); + await expect(runLogin({ fetch: fetchMock }, envBase())).rejects.toThrow(/Gateway error 502/); + }); +}); diff --git a/packages/cli/src/clients.ts b/packages/cli/src/clients.ts new file mode 100644 index 0000000..1789785 --- /dev/null +++ b/packages/cli/src/clients.ts @@ -0,0 +1,63 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export type SupportedClient = 'claude-desktop' | 'openclaw' | 'cursor'; + +export const SUPPORTED_CLIENTS: SupportedClient[] = ['claude-desktop', 'openclaw', 'cursor']; + +export interface ClientTarget { + /** Human-readable client name. */ + label: string; + /** Absolute path to the client's MCP config file. */ + configPath: string; + /** Logical key the connector should be registered under. */ + serverKey: string; +} + +export function isSupportedClient(value: string): value is SupportedClient { + return (SUPPORTED_CLIENTS as string[]).includes(value); +} + +/** + * Resolve the on-disk config path for a given MCP-aware client. + * + * Paths follow the documented defaults for each client: + * - Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) + * - OpenClaw: ~/.config/openclaw/mcp.json + * - Cursor: ~/.cursor/mcp.json + * + * Override roots via env vars for tests: + * TOREVA_HOME — overrides homedir() + * TOREVA_CLAUDE_DESKTOP_CONFIG / TOREVA_OPENCLAW_CONFIG / TOREVA_CURSOR_CONFIG — full path overrides + */ +export function resolveClient( + client: SupportedClient, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): ClientTarget { + const home = env.TOREVA_HOME || homedir(); + + switch (client) { + case 'claude-desktop': { + const override = env.TOREVA_CLAUDE_DESKTOP_CONFIG; + const path = + override || + (platform === 'darwin' + ? join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json') + : platform === 'win32' + ? join(env.APPDATA || join(home, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json') + : join(home, '.config', 'Claude', 'claude_desktop_config.json')); + return { label: 'Claude Desktop', configPath: path, serverKey: 'toreva' }; + } + case 'openclaw': { + const override = env.TOREVA_OPENCLAW_CONFIG; + const path = override || join(home, '.config', 'openclaw', 'mcp.json'); + return { label: 'OpenClaw', configPath: path, serverKey: 'toreva' }; + } + case 'cursor': { + const override = env.TOREVA_CURSOR_CONFIG; + const path = override || join(home, '.cursor', 'mcp.json'); + return { label: 'Cursor', configPath: path, serverKey: 'toreva' }; + } + } +} diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 0000000..e872817 --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,115 @@ +import { existsSync } from 'node:fs'; +import { getConfigPath, getMcpUrl, readConfig } from '../config.js'; + +export type CheckStatus = 'ok' | 'warn' | 'error'; + +export interface DoctorCheck { + name: string; + status: CheckStatus; + message: string; +} + +export interface DoctorReport { + ok: boolean; + checks: DoctorCheck[]; +} + +export interface DoctorDeps { + fetch?: typeof fetch; +} + +/** + * Run end-to-end install / token / first-call diagnostics. + * + * Returns a structured report rather than logging directly so that tests + * can assert on the individual checks. The CLI wrapper prints them. + */ +export async function runDoctor( + deps: DoctorDeps = {}, + env: NodeJS.ProcessEnv = process.env +): Promise { + const checks: DoctorCheck[] = []; + const doFetch = deps.fetch ?? fetch; + const mcpUrl = getMcpUrl(env); + const configPath = getConfigPath(env); + + // 1. Config file exists. + const cfgExists = existsSync(configPath); + checks.push({ + name: 'config_present', + status: cfgExists ? 'ok' : 'error', + message: cfgExists + ? `Found config at ${configPath}` + : `No config at ${configPath} — run \`toreva login\``, + }); + + const cfg = cfgExists ? readConfig(env) : null; + + // 2. Auth token present. + const hasToken = Boolean(cfg?.authToken); + checks.push({ + name: 'auth_token', + status: hasToken ? 'ok' : 'error', + message: hasToken + ? `Token issued at ${cfg?.issuedAt ?? 'unknown'}` + : 'No auth token — run `toreva login`', + }); + + // 3. MCP URL reachable + first call works. + // We hit a /healthz-style endpoint; gateway team will provide the canonical + // probe, but until then a HEAD/GET against the base MCP URL is the cheapest + // smoke test. If a token is present we attach it so we exercise auth too. + if (hasToken) { + try { + const res = await doFetch(`${mcpUrl}/healthz`, { + method: 'GET', + headers: { Authorization: `Bearer ${cfg!.authToken}` }, + }); + if (res.ok) { + checks.push({ + name: 'mcp_call', + status: 'ok', + message: `Gateway healthy at ${mcpUrl} (${res.status})`, + }); + } else if (res.status === 401 || res.status === 403) { + checks.push({ + name: 'mcp_call', + status: 'error', + message: `Gateway rejected token (${res.status}) — re-run \`toreva login\``, + }); + } else { + checks.push({ + name: 'mcp_call', + status: 'warn', + message: `Gateway returned ${res.status} — service may be degraded`, + }); + } + } catch (err) { + checks.push({ + name: 'mcp_call', + status: 'error', + message: `Cannot reach ${mcpUrl}: ${(err as Error).message}`, + }); + } + } else { + checks.push({ + name: 'mcp_call', + status: 'warn', + message: 'Skipped — no token. Login first.', + }); + } + + const ok = checks.every((c) => c.status === 'ok'); + return { ok, checks }; +} + +export function formatReport(report: DoctorReport): string { + const lines: string[] = []; + for (const c of report.checks) { + const tag = c.status === 'ok' ? '[ OK ]' : c.status === 'warn' ? '[WARN]' : '[FAIL]'; + lines.push(`${tag} ${c.name}: ${c.message}`); + } + lines.push(''); + lines.push(report.ok ? 'All checks passed.' : 'One or more checks failed.'); + return lines.join('\n'); +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts new file mode 100644 index 0000000..499cc4b --- /dev/null +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,105 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { isSupportedClient, resolveClient, SUPPORTED_CLIENTS, type SupportedClient } from '../clients.js'; +import { getMcpUrl } from '../config.js'; + +export interface InitResult { + client: SupportedClient; + configPath: string; + serverKey: string; + mcpUrl: string; + created: boolean; +} + +/** + * Build the MCP server stanza to inject into a client config. + * + * Both Claude Desktop and the popular MCP clients accept either: + * - { command, args, env } (stdio) — used here via npx @toreva/mcp + * - { url } (HTTP/SSE) + * + * We default to stdio via npx so the user doesn't need to keep a + * gateway URL alive locally; the MCP package itself talks to the + * Toreva gateway over HTTP. + */ +export function buildServerStanza(mcpUrl: string): Record { + return { + command: 'npx', + args: ['-y', '@toreva/mcp'], + env: { + TOREVA_MCP_URL: mcpUrl, + }, + }; +} + +interface ParsedConfig { + raw: Record; +} + +function parseConfigFile(path: string): ParsedConfig { + if (!existsSync(path)) { + return { raw: {} }; + } + const text = readFileSync(path, 'utf-8').trim(); + if (!text) return { raw: {} }; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (err) { + throw new Error( + `Existing config at ${path} is not valid JSON — refusing to overwrite. Fix it manually and retry. (${(err as Error).message})` + ); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`Existing config at ${path} is not a JSON object — refusing to overwrite.`); + } + return { raw: parsed as Record }; +} + +export function runInit( + client: string, + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): InitResult { + if (!isSupportedClient(client)) { + throw new Error( + `Unsupported --client=${client}. Supported: ${SUPPORTED_CLIENTS.join(', ')}` + ); + } + + const target = resolveClient(client, env, platform); + const mcpUrl = getMcpUrl(env); + const stanza = buildServerStanza(mcpUrl); + + const created = !existsSync(target.configPath); + const { raw } = parseConfigFile(target.configPath); + + const existing = (raw.mcpServers as Record | undefined) ?? {}; + raw.mcpServers = { ...existing, [target.serverKey]: stanza }; + + const dir = dirname(target.configPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(target.configPath, JSON.stringify(raw, null, 2) + '\n'); + + return { + client, + configPath: target.configPath, + serverKey: target.serverKey, + mcpUrl, + created, + }; +} + +export function parseInitArgs(args: string[]): { client: string } { + let client = ''; + for (const arg of args) { + const [k, v] = arg.split('=', 2); + if (k === '--client' && v) client = v; + } + if (!client) { + throw new Error( + `Missing --client=. Supported: ${SUPPORTED_CLIENTS.join(', ')}` + ); + } + return { client }; +} diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts new file mode 100644 index 0000000..c597dc2 --- /dev/null +++ b/packages/cli/src/commands/login.ts @@ -0,0 +1,117 @@ +import { getMcpUrl, writeConfig } from '../config.js'; + +export interface LoginResult { + authToken: string; + mcpUrl: string; + configPath: string; + issuedAt: string; +} + +export interface LoginDeps { + /** Override fetch for tests / different runtimes. */ + fetch?: typeof fetch; + /** Output sink — used for the device-code prompt; replaced in tests. */ + log?: (msg: string) => void; +} + +interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + expires_in?: number; + interval?: number; +} + +interface TokenResponse { + access_token: string; + token_type?: string; + expires_in?: number; +} + +/** + * Execute the gateway-equivalent OAuth device-code flow. + * + * We call the gateway's /auth/device endpoint to start the flow, present + * the user-facing code to the user, then poll /auth/token until the user + * confirms in their browser. The resulting token is written to the local + * config file (chmod 600) for use by the MCP server stanza and by `toreva + * doctor`. + * + * Until gateway ships the real endpoint shape we treat the response as + * provisional and only require `access_token` on success — all other + * fields are optional. Tests inject a mocked fetch. + */ +export async function runLogin( + deps: LoginDeps = {}, + env: NodeJS.ProcessEnv = process.env +): Promise { + const log = deps.log ?? ((m: string) => console.log(m)); + const doFetch = deps.fetch ?? fetch; + const mcpUrl = getMcpUrl(env); + + // Allow direct token paste for power users / CI. + if (env.TOREVA_AUTH_TOKEN) { + const issuedAt = new Date().toISOString(); + const path = writeConfig( + { mcpUrl, authToken: env.TOREVA_AUTH_TOKEN, issuedAt }, + env + ); + return { authToken: env.TOREVA_AUTH_TOKEN, mcpUrl, configPath: path, issuedAt }; + } + + // Step 1: device-code request. + const deviceRes = await doFetch(`${mcpUrl}/auth/device`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client: 'toreva-cli' }), + }); + if (!deviceRes.ok) { + throw new Error(`Failed to start device-code flow: ${deviceRes.status} ${deviceRes.statusText}`); + } + const device = (await deviceRes.json()) as DeviceCodeResponse; + if (!device.device_code || !device.user_code || !device.verification_uri) { + throw new Error('Gateway returned malformed device-code response'); + } + + log( + `\nVisit ${device.verification_uri}\nEnter code: ${device.user_code}\n\nWaiting for confirmation...` + ); + + // Step 2: poll /auth/token until issued. + const intervalMs = (device.interval ?? 2) * 1000; + const expiresMs = (device.expires_in ?? 300) * 1000; + const deadline = Date.now() + expiresMs; + + while (Date.now() < deadline) { + const tokenRes = await doFetch(`${mcpUrl}/auth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: device.device_code, + }), + }); + + if (tokenRes.status === 200) { + const json = (await tokenRes.json()) as TokenResponse; + if (!json.access_token) { + throw new Error('Gateway returned 200 but no access_token'); + } + const issuedAt = new Date().toISOString(); + const path = writeConfig( + { mcpUrl, authToken: json.access_token, issuedAt }, + env + ); + return { authToken: json.access_token, mcpUrl, configPath: path, issuedAt }; + } + + // 428/400/authorization_pending → keep polling. + if (tokenRes.status >= 500) { + throw new Error(`Gateway error ${tokenRes.status} during token poll`); + } + + await new Promise((r) => setTimeout(r, intervalMs)); + } + + throw new Error('Login timed out — re-run `toreva login` to try again.'); +} diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 0000000..c42a34c --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,48 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; + +/** + * Default Toreva MCP gateway URL. Override with TOREVA_MCP_URL env var. + */ +export const DEFAULT_MCP_URL = 'https://mcp.toreva.com'; + +export interface TorevaConfig { + mcpUrl: string; + authToken?: string; + issuedAt?: string; +} + +export function getConfigDir(env: NodeJS.ProcessEnv = process.env): string { + if (env.TOREVA_CONFIG_DIR) return env.TOREVA_CONFIG_DIR; + return join(homedir(), '.config', 'toreva'); +} + +export function getConfigPath(env: NodeJS.ProcessEnv = process.env): string { + return join(getConfigDir(env), 'config.json'); +} + +export function getMcpUrl(env: NodeJS.ProcessEnv = process.env): string { + return env.TOREVA_MCP_URL || DEFAULT_MCP_URL; +} + +export function readConfig(env: NodeJS.ProcessEnv = process.env): TorevaConfig | null { + const path = getConfigPath(env); + if (!existsSync(path)) return null; + try { + const raw = readFileSync(path, 'utf-8'); + return JSON.parse(raw) as TorevaConfig; + } catch { + return null; + } +} + +export function writeConfig(config: TorevaConfig, env: NodeJS.ProcessEnv = process.env): string { + const dir = getConfigDir(env); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + const path = getConfigPath(env); + writeFileSync(path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); + return path; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1087152..4b42f40 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,33 +1,95 @@ #!/usr/bin/env node import { runPerpsCommand } from './commands/perps.js'; import { runScanCommand } from './commands/scan.js'; +import { parseInitArgs, runInit } from './commands/init.js'; +import { runLogin } from './commands/login.js'; +import { formatReport, runDoctor } from './commands/doctor.js'; +import { SUPPORTED_CLIENTS } from './clients.js'; + +const USAGE = `Usage: toreva [args] + +Setup commands: + toreva init --client=<${SUPPORTED_CLIENTS.join('|')}> + Install Toreva MCP connector into a client config + toreva login Authenticate via the Toreva gateway (device-code flow) + toreva doctor Verify install + token + first MCP call + +Power-user commands: + toreva scan [prompt] + toreva perps [jsonPayload] + +Environment: + TOREVA_MCP_URL Override gateway URL (default: https://mcp.toreva.com) + TOREVA_AUTH_TOKEN Skip device-code flow and persist this token directly + TOREVA_CONFIG_DIR Override the on-disk config directory +`; async function main(): Promise { const [command, ...args] = process.argv.slice(2); - if (command === 'scan') { - const [wallet = '', prompt = 'scan wallet'] = args; - await runScanCommand(wallet, prompt); - return; - } + switch (command) { + case undefined: + case '-h': + case '--help': + case 'help': { + console.log(USAGE); + return; + } + + case 'init': { + const { client } = parseInitArgs(args); + const res = runInit(client); + console.log( + `${res.created ? 'Created' : 'Updated'} ${res.client} config at ${res.configPath}\n` + + `Registered MCP server "${res.serverKey}" pointing at ${res.mcpUrl}\n` + + `Next: run \`toreva login\` to issue a token, then restart your MCP client.` + ); + return; + } - if (command === 'perps') { - const [toolName = 'toreva_perps_query_markets', payload = '{}'] = args; - let parsed: Record; - try { - parsed = JSON.parse(payload) as Record; - } catch { - console.error(`Invalid JSON payload: ${payload}`); + case 'login': { + const res = await runLogin(); + console.log( + `\nAuthenticated. Token written to ${res.configPath} (issued ${res.issuedAt}).` + ); + return; + } + + case 'doctor': { + const report = await runDoctor(); + console.log(formatReport(report)); + if (!report.ok) process.exit(1); + return; + } + + case 'scan': { + const [wallet = '', prompt = 'scan wallet'] = args; + await runScanCommand(wallet, prompt); + return; + } + + case 'perps': { + const [toolName = 'toreva_perps_query_markets', payload = '{}'] = args; + let parsed: Record; + try { + parsed = JSON.parse(payload) as Record; + } catch { + console.error(`Invalid JSON payload: ${payload}`); + process.exit(1); + } + await runPerpsCommand(toolName as never, parsed); + return; + } + + default: { + console.error(`Unknown command: ${command}\n`); + console.error(USAGE); process.exit(1); } - await runPerpsCommand(toolName as never, parsed); - return; } - - console.log('Usage: toreva [args]'); } main().catch((error) => { - console.error(error); + console.error(error instanceof Error ? error.message : error); process.exit(1); });