diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..c54071d --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "onecli", + "owner": { + "name": "OneCLI" + }, + "metadata": { + "description": "OneCLI gateway plugins for Cursor, Codex, and Claude Code." + }, + "plugins": [ + { + "name": "onecli", + "source": "plugins/cursor", + "description": "Route agent HTTPS traffic through the OneCLI gateway with injected credentials." + } + ] +} diff --git a/README.md b/README.md index 5cedd00..eb13af6 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ Connect AI coding agents to external APIs without managing credentials. The OneCLI gateway injects stored credentials into outbound requests automatically, so you don't need API keys in your environment or OAuth flows in your terminal. -This repo ships the OneCLI gateway plugin for two agent platforms from a single shared codebase: +This repo ships the OneCLI gateway plugin for three agent platforms from a single shared codebase: | Plugin | Platform | Path | |--------|----------|------| | **onecli** for Claude Code | [Claude Code](https://claude.com/claude-code) | [`plugins/claude/`](plugins/claude/) | | **onecli** for Codex | [OpenAI Codex](https://developers.openai.com/codex) | [`plugins/codex/`](plugins/codex/) | +| **onecli** for Cursor | [Cursor](https://cursor.com) | [`plugins/cursor/`](plugins/cursor/) | **Supported services**: GitHub, Gmail, Google Calendar, Google Drive, Google Docs, Google Sheets, Jira, Confluence, AWS, Datadog, Notion, Cloudflare, Todoist, Outlook, Microsoft Word, YouTube, and more. @@ -15,12 +16,16 @@ This repo ships the OneCLI gateway plugin for two agent platforms from a single All HTTPS traffic from the agent routes through the OneCLI gateway (`HTTPS_PROXY`), which intercepts requests and injects the right credentials (OAuth tokens, API keys, AWS SigV4 signatures). If a service isn't connected yet, the gateway returns a `connect_url` the agent shows you. Policy rules (block, rate limit, manual approval) are enforced at the gateway on every request. -The two plugins share the same runtime but activate differently, matching what each platform's hooks can do. +The three plugins share the same runtime but activate differently, matching what each platform's hooks can do. On Claude Code, the `SessionStart` hook fetches the gateway config once, writes live exports to `~/.onecli/env.sh`, and wires `BASH_ENV` so every Bash command picks them up. A `SessionEnd` hook cleans up. On Codex, hooks run as child processes and cannot mutate the session environment, and there is no `SessionEnd` event. The `SessionStart` hook therefore writes `~/.onecli/env.sh` as a **credential-free loader**; a conservative `PreToolUse` hook auto-sources it for outbound Bash commands (such as `curl`, `gh`, `git push`, `npm install`), fetching fresh gateway exports per command via `bin/onecli-codex-env.mjs`. Cleanup is an explicit skill (`onecli-cleanup`), deliberately not wired to the turn-scoped `Stop` event. +On Cursor, the `sessionStart` hook writes the same loader, fetches gateway config when an API key exists, and returns session-scoped `env` exports. A conservative `preToolUse` hook (Shell matcher) auto-sources the loader as a fallback when session env is not inherited. `sessionEnd` cleans up automatically. + +See [`docs/hook-activation.md`](docs/hook-activation.md) for why activation differs per platform, why `preToolUse` uses a command allowlist (shared with Codex), and the deprecation path toward session-only activation. + ## Install on Claude Code From the Claude Code Directory: **Customize → Directory → Plugins**, search **OneCLI**, click **Install**. Or from the marketplace in this repo: @@ -41,6 +46,14 @@ codex plugin add onecli@onecli Start a new thread, then invoke the `onecli-setup` skill (`@onecli:onecli-setup`). See [`plugins/codex/README.md`](plugins/codex/README.md). +## Install on Cursor + +```bash +ln -sf "$(pwd)/plugins/cursor" ~/.cursor/plugins/local/onecli +``` + +Reload Cursor, then invoke the `onecli-setup` skill. See [`plugins/cursor/README.md`](plugins/cursor/README.md). + ## Repo layout ``` @@ -48,10 +61,13 @@ src/ TypeScript sources (single source of truth) shared/runtime.mts gateway config, key resolution, CA bundle, quoting, probing claude/ Claude Code hooks codex/ Codex hooks + env helper + cursor/ Cursor hooks + env helper plugins/ claude/ self-contained Claude Code plugin (built hooks committed) codex/ self-contained Codex plugin (built hooks committed) + cursor/ self-contained Cursor plugin (built hooks committed) .claude-plugin/marketplace.json Claude Code marketplace → ./plugins/claude +.cursor-plugin/marketplace.json Cursor marketplace → ./plugins/cursor .claude-plugin/plugin.json root compatibility shim (see below) hooks/hooks.json hooks for the root shim .agents/plugins/marketplace.json Codex marketplace → ./plugins/codex @@ -67,7 +83,7 @@ Both platforms copy only the plugin directory into their install cache, so each ```bash npm install npm run typecheck # tsc over src/ -npm run build # tsup: src/ → plugins/claude/hooks, plugins/codex/{hooks,bin} +npm run build # tsup: src/ → plugins/{claude,codex,cursor}/{hooks,bin} npm run test # python3 tests/test_workflows.py ``` diff --git a/docs/hook-activation.md b/docs/hook-activation.md new file mode 100644 index 0000000..5f748f6 --- /dev/null +++ b/docs/hook-activation.md @@ -0,0 +1,90 @@ +# Hook activation across platforms + +Why OneCLI uses different hook strategies on Claude Code, Codex, and Cursor — and where `preToolUse` / `PreToolUse` fits today. + +## Summary + +| Platform | Primary activation | `preToolUse` role | +| -------- | ------------------ | ----------------- | +| **Claude Code** | `sessionStart` + `BASH_ENV` | None — every Bash subprocess auto-sources `~/.onecli/env.sh` | +| **Codex** | `sessionStart` loader + `PreToolUse` rewrite | Required — hooks cannot persist session env | +| **Cursor** | `sessionStart` env + `preToolUse` rewrite | Fallback — session env exists but is not reliably inherited by all shells/subagents | + +All platforms route HTTPS through `HTTPS_PROXY` once the gateway env is active. Credentials never live in the agent context. + +--- + +## Why three different models? + +Each agent platform exposes different hook capabilities: + +**Claude Code** can write live exports to `~/.onecli/env.sh` and set `BASH_ENV` in `~/.claude/settings.json`. Every Bash invocation sources the file automatically. No per-command hook is needed. + +**Codex and Cursor** run hooks as child processes. They cannot permanently mutate the parent session environment the way Claude's `BASH_ENV` wiring does. Instead: + +1. `sessionStart` writes a **credential-free loader** at `~/.onecli/env.sh`. +2. Sourcing the loader runs `onecli-*-env.mjs`, which fetches fresh gateway exports from OneCLI Cloud for that command. +3. `preToolUse` / `PreToolUse` rewrites selected Shell/Bash commands to prefix: + `. "$HOME/.onecli/env.sh" && ` + +**Cursor-specific:** `sessionStart` also returns session-scoped `env` (including `HTTPS_PROXY`). When Cursor propagates that env to all agent shells, gateway routing works without rewrites. In practice, parent shells and some subagent contexts still miss it — so `preToolUse` remains the reliable path (verified via `api.github.com/rate_limit`: `60` = direct, `~11400` = gateway). + +Implementation: [`src/codex/pre-tool-use.mts`](../src/codex/pre-tool-use.mts) and [`src/cursor/pre-tool-use.mts`](../src/cursor/pre-tool-use.mts) share the same `shouldRewrite()` logic today (allowlist, not yet extracted to `src/shared/`). + +--- + +## Why the conservative command allowlist? + +Sourcing the loader is not free — it calls OneCLI Cloud on each rewrite. The allowlist limits rewrites to commands likely to hit the network: + +- HTTP clients with URLs: `curl`, `wget`, … +- Service CLIs: `gh`, network `aws` / `git` / `terraform` / package-manager subcommands +- Interpreters when the command line contains a URL: `node`, `python`, `npx`, … + +Local-only commands are skipped (`git status`, `terraform fmt`, `aws configure`, …). Tests in `tests/test_workflows.py` lock this contract. + +**Gap:** Unknown network CLIs (`stripe`, `flyctl`, `kubectl`, …) are not auto-rewritten unless they match URL heuristics. Agents can still prefix manually: + +```bash +. ~/.onecli/env.sh && +``` + +Skills document this fallback (`onecli-gateway`). + +--- + +## Deprecation path: session-only activation + +**Goal:** Rely on `sessionStart` (or platform-equivalent persistent env injection) and remove `preToolUse` / `PreToolUse` rewrites. + +| Milestone | Cursor | Codex | +| --------- | ------ | ----- | +| **Today** | Hybrid: session env + conservative `preToolUse` | Loader + conservative `PreToolUse` | +| **Cursor step 1** | Cursor reliably applies `sessionStart` `env` to all Shell/subagent processes | — | +| **Cursor step 2** | Drop `preToolUse`; keep loader + skills for manual edge cases | — | +| **Codex blocker** | — | No session env mutation API; needs platform change or `onecli run` wrapper | +| **End state** | Claude-like: one fetch at session start, universal proxy env | Same, if Codex adds session env or official `BASH_ENV` equivalent | + +`preToolUse` is **intentionally a compatibility layer**, not the long-term design for Cursor. Remove it once session env propagation is verified across parent agents, subagents, and background shells. + +Signals that step 1 is done: + +- `HTTPS_PROXY` set in shell without rewrite markers (`ONECLI_CURSOR_AUTOSOURCED`) +- `python3 tests/verify_cursor_agent_gateway.py` passes in parent agent Shell (not only subagents) +- No regression in OneCLI dashboard Activity for plain `curl` / `gh` calls + +--- + +## Future options (not implemented) + +Documented for later if the allowlist becomes painful to maintain: + +1. **Shared module** — Extract `shouldRewrite()` to `src/shared/pre-tool-use.mts`; thin Codex/Cursor wrappers for I/O format only. + +2. **Inverse blocklist** — Rewrite all Shell commands except known-local ones (`ls`, `git status`, `npm test`, …). Broader coverage; more loader invocations on local work. + +3. **Cached loader exports** — Cache gateway config for N seconds per session inside `onecli-*-env.mjs` so default-open rewrite (option 2) does not hit OneCLI Cloud on every command. + +4. **Platform wrapper** — `onecli run cursor` / `onecli run codex` exports gateway env to the whole process tree (same short-circuit as today when `HTTPS_PROXY` is already a OneCLI proxy). + +None of these are required for the initial Cursor plugin merge. The allowlist matches Codex behavior and is covered by tests. diff --git a/package-lock.json b/package-lock.json index 6a0b83e..a1b6d18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { - "name": "onecli-plugin", + "name": "onecli", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "onecli-plugin", + "name": "onecli", "version": "0.1.0", + "license": "Apache-2.0", "devDependencies": { "@types/node": "^22.0.0", "tsup": "^8.5.1", diff --git a/package.json b/package.json index 6e129c4..7c34947 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "scripts": { "build": "tsup", "typecheck": "tsc -p tsconfig.json", - "test": "python3 tests/test_workflows.py" + "test": "python3 tests/test_workflows.py", + "test:live-cursor": "python3 tests/test_cursor_live_e2e.py" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/plugins/codex/README.md b/plugins/codex/README.md index 2bfc25a..fcf271d 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -40,6 +40,8 @@ Codex hooks run as child processes, so they cannot mutate the Codex session envi . ~/.onecli/env.sh && curl -s "https://api.github.com/user" ``` +See [`docs/hook-activation.md`](../../docs/hook-activation.md) for why this allowlist exists, how it compares to Claude's `BASH_ENV` model, and future options. + If the helper fails (no API key, OneCLI Cloud unreachable, proxy down), the command still runs without the gateway, and the reason is printed to stderr. Codex documents `SessionStart`, `PreToolUse`, `SubagentStart`/`SubagentStop`, and turn-scoped `Stop`, but no true `SessionEnd`. Cleanup is therefore not automatic: use the `onecli-cleanup` skill (or `hooks/session-end.mjs`) for explicit deactivation or uninstall. Wiring it to `Stop` would remove the gateway loader after every turn. diff --git a/plugins/cursor/.cursor-plugin/marketplace.json b/plugins/cursor/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..24da20a --- /dev/null +++ b/plugins/cursor/.cursor-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "onecli-local", + "owner": { + "name": "OneCLI" + }, + "metadata": { + "description": "Local marketplace for the OneCLI Cursor gateway plugin." + }, + "plugins": [ + { + "name": "onecli", + "source": ".", + "description": "Route agent HTTPS traffic through the OneCLI gateway with injected credentials." + } + ] +} diff --git a/plugins/cursor/.cursor-plugin/plugin.json b/plugins/cursor/.cursor-plugin/plugin.json new file mode 100644 index 0000000..634cecd --- /dev/null +++ b/plugins/cursor/.cursor-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "name": "onecli", + "displayName": "OneCLI Gateway", + "version": "0.2.4", + "description": "Connect Cursor to external APIs through the OneCLI gateway without local service credential management.", + "author": { + "name": "OneCLI", + "email": "plugins@onecli.sh" + }, + "homepage": "https://onecli.sh", + "repository": "https://github.com/onecli/onecli-plugin", + "license": "Apache-2.0", + "keywords": [ + "onecli", + "gateway", + "proxy", + "credentials", + "cursor", + "cursor-plugin" + ], + "category": "developer-tools", + "skills": "./skills/", + "rules": "./rules/", + "hooks": "./hooks/hooks.json" +} diff --git a/plugins/cursor/INSTALL.md b/plugins/cursor/INSTALL.md new file mode 100644 index 0000000..54f9811 --- /dev/null +++ b/plugins/cursor/INSTALL.md @@ -0,0 +1,76 @@ +# Install OneCLI for Cursor (local repo) + +For a step-by-step guide, see [CURSOR_SETUP.md](./docs/CURSOR_SETUP.md). Screenshots are in [PR #7](https://github.com/onecli/onecli-plugin/pull/7). + +## 1. Build the plugin + +From the `onecli-plugin` repo root: + +```bash +npm install +npm run build +``` + +## 2. Add via Customize → Plugins → + Add + +1. Open **Customize** (left sidebar) → **Plugins** tab → **+ Add** +2. Select this folder: + + ``` + onecli-plugin/plugins/cursor + ``` + + Cursor expects `.cursor-plugin/marketplace.json` in the folder you pick (now included). + +3. Install the **onecli** plugin from the local marketplace entry. + +## 3. Enable third-party extensibility + +In **Cursor Settings → Features**, turn on: + +**Include third-party Plugins, Skills, and other configs** + +Without this, plugin hooks may not register (skills/rules can still load). + +## 4. Reload and start a new Agent session + +- `Cmd+Shift+P` → **Developer: Reload Window** +- Open a **new** Agent composer chat (so `sessionStart` runs) + +## 5. Configure OneCLI (once) + +Invoke the **onecli-setup** skill, or ensure `~/.onecli/credentials/api-key` exists. + +## 6. Verify hooks are loaded + +**Settings → Hooks** should list: + +- `sessionStart` → `node ./hooks/session-start.mjs` +- `preToolUse` (Shell) → `node ./hooks/pre-tool-use.mjs` +- `sessionEnd` → `node ./hooks/session-end.mjs` + +If plugin hooks are missing (known Cursor bug), use the project-level fallback in your workspace: + +```bash +# from secrets-leak-protection repo root +./onecli-plugin/plugins/cursor/scripts/install-project-hooks.sh +``` + +## 7. Verify gateway routing (subagent-safe) + +Ask any Agent to run **only**: + +```bash +curl -s https://api.github.com/rate_limit | python3 -c "import json,sys; print(json.load(sys.stdin)['resources']['core']['limit'])" +``` + +**Expected when gateway is active:** `11400` (or similar high GitHub App limit) +**Direct / unauthenticated:** `60` + +Then check **OneCLI dashboard → Activity** for a new `GET api.github.com/rate_limit` row. + +Or run the automated check from repo root: + +```bash +python3.12 onecli-plugin/tests/verify_cursor_agent_gateway.py +``` diff --git a/plugins/cursor/README.md b/plugins/cursor/README.md new file mode 100644 index 0000000..a0bacb7 --- /dev/null +++ b/plugins/cursor/README.md @@ -0,0 +1,56 @@ +# OneCLI Plugin for Cursor + +Connect Cursor Agent to external APIs through the OneCLI gateway without managing service credentials locally. The gateway injects stored credentials (OAuth tokens, API keys, AWS SigV4 signatures) at the proxy boundary. + +## Installation + +See [INSTALL.md](./INSTALL.md) for install steps and [docs/CURSOR_SETUP.md](./docs/CURSOR_SETUP.md) for the walkthrough (screenshots in [PR #7](https://github.com/onecli/onecli-plugin/pull/7)). + +Quick symlink fallback: + +```bash +ln -sf "$(pwd)/plugins/cursor" ~/.cursor/plugins/local/onecli +``` + +## Runtime behavior + +Cursor hooks use a hybrid activation model (see [`docs/hook-activation.md`](../../docs/hook-activation.md) for the full rationale and deprecation path): + +- **`sessionStart`** writes `~/.onecli/env.sh` as a non-secret loader, fetches gateway config when an API key exists, and returns session-scoped `env` exports plus `additional_context`. +- **`preToolUse` (Shell)** conservatively rewrites outbound network commands to auto-source the loader when session env is not inherited (same allowlist as Codex — not Cursor-specific). +- **`sessionEnd`** removes the loader file automatically. + +`preToolUse` is a **compatibility fallback**. The intended end state is session-only activation once Cursor reliably applies `sessionStart` env to all agent shells and subagents. + +Manual sourcing remains available: + +```bash +. ~/.onecli/env.sh && curl -s "https://api.github.com/user" +``` + +If the helper fails (no API key, OneCLI Cloud unreachable, proxy down), the command still runs without the gateway and the reason is printed to stderr. + +## Skills + +| Skill | Purpose | +| ----- | ------- | +| `onecli-setup` | Configure the OneCLI API key | +| `onecli-status` | Show gateway status and connected services | +| `onecli-gateway` | Core gateway usage rules for the agent | +| `onecli-providers` | Reference of supported services and endpoints | +| `integration-architect` | Design multi-service API workflows | +| `onecli-cleanup` | Explicit deactivation / uninstall cleanup | + +## Development + +Hook scripts (`hooks/*.mjs`) and the env helper (`bin/onecli-cursor-env.mjs`) are built artifacts. Edit the TypeScript sources in `../../src/cursor/` and run `npm run build` from the repo root. + +```bash +npm install +npm run build +npm run test +``` + +## License + +Apache-2.0 diff --git a/plugins/cursor/bin/onecli-cursor-env.mjs b/plugins/cursor/bin/onecli-cursor-env.mjs new file mode 100755 index 0000000..70a093f --- /dev/null +++ b/plugins/cursor/bin/onecli-cursor-env.mjs @@ -0,0 +1,236 @@ +#!/usr/bin/env node + +// src/shared/runtime.mts +import { execSync } from "child_process"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { createConnection } from "net"; +import { homedir } from "os"; +import { dirname, join } from "path"; +var DEFAULT_API_HOST = "https://app.onecli.sh"; +var KEYCHAIN_SERVICE = "onecli-api-key"; +var SYSTEM_CA_PATHS = [ + "/etc/ssl/cert.pem", + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem" +]; +var CA_ENV_KEYS = [ + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "AWS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "DENO_CERT" +]; +function userHome() { + return process.env.HOME || homedir(); +} +function onecliPaths(home = userHome()) { + const onecliDir = join(home, ".onecli"); + return { + home, + onecliDir, + envPath: join(onecliDir, "env.sh"), + caBundlePath: join(onecliDir, "ca-bundle.pem"), + credentialsPath: join(onecliDir, "credentials", "api-key"), + configPath: join(onecliDir, "config.json"), + pluginAuthPath: join(home, ".config", "onecli-plugin", "auth.json") + }; +} +function safeReadFile(path) { + try { + return readFileSync(path, "utf-8").trim(); + } catch { + return null; + } +} +function safeReadJson(path) { + const content = safeReadFile(path); + if (!content) return null; + try { + return JSON.parse(content); + } catch { + return null; + } +} +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} +function ensurePrivateDir(path) { + mkdirSync(path, { recursive: true, mode: 448 }); + try { + chmodSync(path, 448); + } catch { + } +} +function writeFilePrivate(path, content) { + ensurePrivateDir(dirname(path)); + writeFileSync(path, content, { mode: 384 }); + chmodSync(path, 384); +} +function resolveApiKey(paths = onecliPaths()) { + if (process.env.ONECLI_API_KEY) return process.env.ONECLI_API_KEY; + const fileKey = safeReadFile(paths.credentialsPath); + if (fileKey) return fileKey; + if (process.platform === "darwin") { + try { + const result = execSync( + `security find-generic-password -s "${KEYCHAIN_SERVICE}" -w 2>/dev/null`, + { encoding: "utf-8", timeout: 3e3 } + ).trim(); + if (result) return result; + } catch { + } + } + const pluginAuth = safeReadJson(paths.pluginAuthPath); + if (pluginAuth?.apiKey) return pluginAuth.apiKey; + return null; +} +function resolveApiHost(paths = onecliPaths()) { + if (process.env.ONECLI_API_HOST) return process.env.ONECLI_API_HOST; + const config = safeReadJson(paths.configPath); + if (config?.["api-host"]) return config["api-host"]; + return DEFAULT_API_HOST; +} +async function fetchContainerConfig(apiHost, apiKey, opts = {}) { + try { + const response = await fetch(`${apiHost}/api/container-config`, { + headers: { Authorization: `Bearer ${apiKey}`, ...opts.headers }, + signal: AbortSignal.timeout(1e4) + }); + if (response.status === 401) return { ok: false, reason: "unauthorized" }; + if (!response.ok) return { ok: false, reason: "http", status: response.status }; + return { ok: true, config: await response.json() }; + } catch (err) { + const message = err instanceof Error ? err.message : "unknown error"; + return { ok: false, reason: "network", message }; + } +} +function parseProxyUrl(proxyUrl) { + try { + const url = new URL(proxyUrl); + const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80; + return { host: url.hostname, port }; + } catch { + return null; + } +} +function probeProxy(host, port, timeoutMs = 3e3) { + return new Promise((resolve) => { + const socket = createConnection({ host, port, timeout: timeoutMs }, () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + resolve(false); + }); + socket.on("timeout", () => { + socket.destroy(); + resolve(false); + }); + }); +} +function writeCABundle(gatewayCert, paths = onecliPaths()) { + let systemCAs = ""; + for (const caPath of SYSTEM_CA_PATHS) { + const content = safeReadFile(caPath); + if (content) { + systemCAs = content; + break; + } + } + const bundle = systemCAs ? `${systemCAs} +${gatewayCert}` : gatewayCert; + const existing = safeReadFile(paths.caBundlePath); + if (existing !== bundle) { + writeFilePrivate(paths.caBundlePath, bundle); + } else { + chmodSync(paths.caBundlePath, 384); + } +} +function buildEnvLines(config, opts) { + const env = config && typeof config.env === "object" && config.env !== null ? config.env : {}; + const lines = []; + for (const [key, value] of Object.entries(env)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (CA_ENV_KEYS.includes(key)) continue; + lines.push(`export ${key}=${shellQuote(value)}`); + } + if (config?.caCertificate) { + for (const key of CA_ENV_KEYS) { + lines.push(`export ${key}=${shellQuote(opts.caBundlePath)}`); + } + } + lines.push("export GIT_TERMINAL_PROMPT=0"); + lines.push("export GIT_HTTP_PROXY_AUTHMETHOD=basic"); + lines.push("export NODE_USE_ENV_PROXY=1"); + return lines; +} + +// src/cursor/onecli-cursor-env.mts +function fail(message) { + process.stderr.write(`onecli: ${message} +`); + process.exit(1); +} +function parseFormat(argv) { + const idx = argv.indexOf("--format"); + if (idx === -1) return "sh"; + return argv[idx + 1] ?? "sh"; +} +async function main() { + const format = parseFormat(process.argv.slice(2)); + if (format !== "sh") { + fail(`unsupported output format '${format}'.`); + } + const paths = onecliPaths(); + const sessionId = process.env.ONECLI_CURSOR_SESSION_ID || ""; + const apiKey = resolveApiKey(paths); + if (!apiKey) { + fail("no API key configured. Use the onecli-setup skill, then retry. This command runs without the gateway."); + } + const apiHost = resolveApiHost(paths); + const headers = {}; + if (sessionId) headers["X-OneCLI-Cursor-Session-Id"] = sessionId; + const result = await fetchContainerConfig(apiHost, apiKey, { headers }); + if (!result.ok) { + if (result.reason === "unauthorized") { + fail("API key is invalid or expired. Use the onecli-setup skill to reconfigure."); + } + if (result.reason === "http") { + fail(`gateway config request returned HTTP ${result.status}. Use the onecli-status skill to diagnose.`); + } + fail(`could not reach OneCLI Cloud (${result.message}). This command runs without the gateway.`); + } + const config = result.config; + const proxyUrl = config.env.HTTPS_PROXY || config.env.https_proxy; + if (proxyUrl) { + const parsed = parseProxyUrl(proxyUrl); + if (parsed) { + const reachable = await probeProxy(parsed.host, parsed.port); + if (!reachable) { + fail( + `gateway proxy unreachable at ${parsed.host}:${parsed.port}. Your API key may target a different environment. This command runs without the gateway.` + ); + } + } + } + if (config.caCertificate) { + writeCABundle(config.caCertificate, paths); + } + const lines = []; + if (sessionId) { + lines.push(`export ONECLI_CURSOR_SESSION_ID=${shellQuote(sessionId)}`); + } + lines.push(...buildEnvLines(config, { caBundlePath: paths.caBundlePath })); + process.stdout.write(lines.join("\n") + "\n"); +} +main().catch((err) => { + process.stderr.write( + `onecli: env helper error: ${err instanceof Error ? err.message : String(err)} +` + ); + process.exit(1); +}); diff --git a/plugins/cursor/docs/CURSOR_SETUP.md b/plugins/cursor/docs/CURSOR_SETUP.md new file mode 100644 index 0000000..bf0ef7d --- /dev/null +++ b/plugins/cursor/docs/CURSOR_SETUP.md @@ -0,0 +1,99 @@ +# Cursor setup guide + +Step-by-step install and verification for the OneCLI Gateway plugin in Cursor. + +> Screenshots for each step are attached in [PR #7](https://github.com/onecli/onecli-plugin/pull/7) (not stored in the repo). + +## Prerequisites + +- OneCLI API key configured (`onecli-setup` skill or `~/.onecli/credentials/api-key`) +- Node.js 18+ (for hook scripts) +- Built plugin artifacts: + +```bash +npm install +npm run build +``` + +## Step 1 — Open Customize and add from local repo + +1. Click **Customize** in the Cursor sidebar. +2. Open the **Plugins** tab. +3. Click **+ Add** → **From Local Repo**. +4. Select the `plugins/cursor` folder (must contain `.cursor-plugin/marketplace.json`). + +## Step 2 — Install from the local marketplace + +Under **Onecli Local**, click **Add** on **OneCLI Gateway**. + +After install, the plugin shows **✓ Added**. + +## Step 3 — Enable third-party extensibility + +In **Cursor Settings → Features**, enable: + +**Include third-party Plugins, Skills, and other configs** + +Without this, plugin hooks may not register (skills and rules can still load). + +## Step 4 — Reload and start a new Agent session + +- `Cmd+Shift+P` → **Developer: Reload Window** +- Open a **new** Agent composer chat (triggers `sessionStart`) + +## Step 5 — Confirm plugin contents + +Open the installed **OneCLI Gateway** plugin detail page. You should see: + +| Component | Count | Purpose | +| --------- | ----- | ------- | +| Skills | 6 | Setup, status, gateway usage, providers, integrations, cleanup | +| Rules | 1 | Always-on outbound API guidance via `HTTPS_PROXY` | +| Hooks | 3 | `sessionStart`, `preToolUse` (Shell), `sessionEnd` | + +Also check **Settings → Hooks** for the three hook entries. + +## Step 6 — Verify gateway routing + +### Automated check + +```bash +python3.12 tests/verify_cursor_agent_gateway.py +``` + +**Gateway active:** `core_limit` ≈ `11400`, `ok: true` +**Direct / hooks missing:** `core_limit: 60` + +### Manual check (subagent-safe) + +Ask an Agent to run only: + +```bash +curl -s https://api.github.com/rate_limit | python3 -c "import json,sys; print(json.load(sys.stdin)['resources']['core']['limit'])" +``` + +`HTTPS_PROXY` stays unset in the shell — the `preToolUse` hook rewrites each Shell command to auto-source `~/.onecli/env.sh`. + +### Dashboard Activity + +Open your OneCLI project **Activity** tab. Successful gateway traffic appears as `GET api.github.com/...` rows with HTTP 200. + +> **Note:** Requests may be labeled with your project's default agent name (e.g. "Codex") — that is expected and does not mean the wrong plugin is running. + +## Project-level hooks fallback + +If plugin hooks do not appear after install (known Cursor bug), run from your workspace root: + +```bash +./onecli-plugin/plugins/cursor/scripts/install-project-hooks.sh . +``` + +This writes `.cursor/hooks.json` pointing at the built hook scripts. + +## Example subagent verification prompt + +``` +Use the OneCLI Gateway plugin in one concrete, useful way that shows why it's valuable in this workspace. +``` + +A successful run fetches GitHub API data via plain `curl` (no manual proxy setup) and returns `rate_limit` `core.limit` > 1000. diff --git a/plugins/cursor/hooks/hooks.json b/plugins/cursor/hooks/hooks.json new file mode 100644 index 0000000..e856aad --- /dev/null +++ b/plugins/cursor/hooks/hooks.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "node ./hooks/session-start.mjs", + "timeout": 30 + } + ], + "preToolUse": [ + { + "command": "node ./hooks/pre-tool-use.mjs", + "matcher": "Shell", + "timeout": 5 + } + ], + "sessionEnd": [ + { + "command": "node ./hooks/session-end.mjs", + "timeout": 10 + } + ] + } +} diff --git a/plugins/cursor/hooks/pre-tool-use.mjs b/plugins/cursor/hooks/pre-tool-use.mjs new file mode 100755 index 0000000..a5176f4 --- /dev/null +++ b/plugins/cursor/hooks/pre-tool-use.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +// src/cursor/pre-tool-use.mts +import { existsSync } from "fs"; +import { basename } from "path"; + +// src/shared/runtime.mts +import { execSync } from "child_process"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { createConnection } from "net"; +import { homedir } from "os"; +import { dirname, join } from "path"; +function userHome() { + return process.env.HOME || homedir(); +} +function onecliPaths(home = userHome()) { + const onecliDir = join(home, ".onecli"); + return { + home, + onecliDir, + envPath: join(onecliDir, "env.sh"), + caBundlePath: join(onecliDir, "ca-bundle.pem"), + credentialsPath: join(onecliDir, "credentials", "api-key"), + configPath: join(onecliDir, "config.json"), + pluginAuthPath: join(home, ".config", "onecli-plugin", "auth.json") + }; +} +function isOnecliProxy(value) { + if (!value) return false; + return value.includes("onecli") || value.includes(":10255") || value.includes("aoc_"); +} +async function readHookInput() { + if (process.stdin.isTTY) return {}; + let raw = ""; + for await (const chunk of process.stdin) { + raw += chunk; + } + const trimmed = raw.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + process.stderr.write("onecli: ignored invalid hook stdin.\n"); + return {}; + } +} + +// src/cursor/pre-tool-use.mts +var URL_PATTERN = /\b(?:https?:\/\/|ssh:\/\/|git@)/i; +var PACKAGE_NETWORK_COMMANDS = /* @__PURE__ */ new Set([ + "add", + "audit", + "ci", + "info", + "install", + "outdated", + "publish", + "search", + "update", + "view" +]); +var GIT_NETWORK_COMMANDS = /* @__PURE__ */ new Set([ + "clone", + "fetch", + "ls-remote", + "pull", + "push", + "remote", + "submodule" +]); +var TERRAFORM_NETWORK_COMMANDS = /* @__PURE__ */ new Set([ + "apply", + "destroy", + "get", + "import", + "init", + "plan", + "refresh" +]); +var AWS_LOCAL_COMMANDS = /* @__PURE__ */ new Set(["configure", "help"]); +function splitCommandPrefix(command) { + const trimmed = command.trimStart(); + const match = trimmed.match(/^([A-Za-z0-9_./-]+)(?:\s+([A-Za-z0-9_./:-]+))?/); + if (!match) return { name: "", arg: "" }; + return { + name: basename(match[1]), + arg: match[2] || "" + }; +} +function isAlreadyHandled(command) { + return command.includes(".onecli/env.sh") || command.includes("ONECLI_CURSOR_AUTOSOURCED") || command.includes("HTTPS_PROXY=") || command.includes("https_proxy="); +} +function isPackageManager(name) { + return ["npm", "pnpm", "yarn", "bun"].includes(name); +} +function isPythonPackageTool(name) { + return ["pip", "pip3"].includes(name); +} +function shouldRewrite(command) { + const { name, arg } = splitCommandPrefix(command); + if (!name) return false; + if (["curl", "wget", "http", "https"].includes(name)) { + return URL_PATTERN.test(command); + } + if (name === "gh") { + return true; + } + if (name === "aws") { + return Boolean(arg) && !arg.startsWith("-") && !AWS_LOCAL_COMMANDS.has(arg); + } + if (name === "terraform" || name === "tofu") { + return TERRAFORM_NETWORK_COMMANDS.has(arg); + } + if (name === "git") { + return GIT_NETWORK_COMMANDS.has(arg); + } + if (isPackageManager(name) || isPythonPackageTool(name)) { + return PACKAGE_NETWORK_COMMANDS.has(arg); + } + if (["node", "python", "python3", "deno", "bunx", "npx"].includes(name)) { + return URL_PATTERN.test(command); + } + return false; +} +function rewrittenCommand(command) { + return `ONECLI_CURSOR_AUTOSOURCED=1; . "$HOME/.onecli/env.sh" && ${command}`; +} +function writeRewrite(command) { + process.stdout.write( + JSON.stringify({ + permission: "allow", + updated_input: { + command: rewrittenCommand(command) + } + }) + ); +} +async function main() { + const input = await readHookInput(); + const toolInput = input.tool_input; + const command = toolInput?.command; + if (input.tool_name !== "Shell" || typeof command !== "string") return; + if (isOnecliProxy(process.env.HTTPS_PROXY)) return; + if (!existsSync(onecliPaths().envPath)) return; + if (isAlreadyHandled(command)) return; + if (!shouldRewrite(command)) return; + writeRewrite(command); +} +main().catch((err) => { + process.stderr.write( + `onecli: pre-tool-use hook error: ${err instanceof Error ? err.message : String(err)} +` + ); +}); diff --git a/plugins/cursor/hooks/session-end.mjs b/plugins/cursor/hooks/session-end.mjs new file mode 100755 index 0000000..e844d8b --- /dev/null +++ b/plugins/cursor/hooks/session-end.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +// src/cursor/session-end.mts +import { unlinkSync } from "fs"; + +// src/shared/runtime.mts +import { execSync } from "child_process"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { createConnection } from "net"; +import { homedir } from "os"; +import { dirname, join } from "path"; +function userHome() { + return process.env.HOME || homedir(); +} +function onecliPaths(home = userHome()) { + const onecliDir = join(home, ".onecli"); + return { + home, + onecliDir, + envPath: join(onecliDir, "env.sh"), + caBundlePath: join(onecliDir, "ca-bundle.pem"), + credentialsPath: join(onecliDir, "credentials", "api-key"), + configPath: join(onecliDir, "config.json"), + pluginAuthPath: join(home, ".config", "onecli-plugin", "auth.json") + }; +} + +// src/cursor/session-end.mts +function removeEnvFile() { + try { + unlinkSync(onecliPaths().envPath); + } catch { + } +} +removeEnvFile(); +process.stderr.write("onecli: gateway session cleaned up.\n"); diff --git a/plugins/cursor/hooks/session-start.mjs b/plugins/cursor/hooks/session-start.mjs new file mode 100755 index 0000000..5aa520c --- /dev/null +++ b/plugins/cursor/hooks/session-start.mjs @@ -0,0 +1,298 @@ +#!/usr/bin/env node + +// src/cursor/session-start.mts +import { dirname as dirname2, join as join2 } from "path"; +import { fileURLToPath } from "url"; + +// src/shared/runtime.mts +import { execSync } from "child_process"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { createConnection } from "net"; +import { homedir } from "os"; +import { dirname, join } from "path"; +var DEFAULT_API_HOST = "https://app.onecli.sh"; +var KEYCHAIN_SERVICE = "onecli-api-key"; +var SYSTEM_CA_PATHS = [ + "/etc/ssl/cert.pem", + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem" +]; +var CA_ENV_KEYS = [ + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "AWS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "DENO_CERT" +]; +function userHome() { + return process.env.HOME || homedir(); +} +function onecliPaths(home = userHome()) { + const onecliDir = join(home, ".onecli"); + return { + home, + onecliDir, + envPath: join(onecliDir, "env.sh"), + caBundlePath: join(onecliDir, "ca-bundle.pem"), + credentialsPath: join(onecliDir, "credentials", "api-key"), + configPath: join(onecliDir, "config.json"), + pluginAuthPath: join(home, ".config", "onecli-plugin", "auth.json") + }; +} +function safeReadFile(path) { + try { + return readFileSync(path, "utf-8").trim(); + } catch { + return null; + } +} +function safeReadJson(path) { + const content = safeReadFile(path); + if (!content) return null; + try { + return JSON.parse(content); + } catch { + return null; + } +} +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} +function ensurePrivateDir(path) { + mkdirSync(path, { recursive: true, mode: 448 }); + try { + chmodSync(path, 448); + } catch { + } +} +function writeFilePrivate(path, content) { + ensurePrivateDir(dirname(path)); + writeFileSync(path, content, { mode: 384 }); + chmodSync(path, 384); +} +function isOnecliProxy(value) { + if (!value) return false; + return value.includes("onecli") || value.includes(":10255") || value.includes("aoc_"); +} +function resolveApiKey(paths = onecliPaths()) { + if (process.env.ONECLI_API_KEY) return process.env.ONECLI_API_KEY; + const fileKey = safeReadFile(paths.credentialsPath); + if (fileKey) return fileKey; + if (process.platform === "darwin") { + try { + const result = execSync( + `security find-generic-password -s "${KEYCHAIN_SERVICE}" -w 2>/dev/null`, + { encoding: "utf-8", timeout: 3e3 } + ).trim(); + if (result) return result; + } catch { + } + } + const pluginAuth = safeReadJson(paths.pluginAuthPath); + if (pluginAuth?.apiKey) return pluginAuth.apiKey; + return null; +} +function resolveApiHost(paths = onecliPaths()) { + if (process.env.ONECLI_API_HOST) return process.env.ONECLI_API_HOST; + const config = safeReadJson(paths.configPath); + if (config?.["api-host"]) return config["api-host"]; + return DEFAULT_API_HOST; +} +async function fetchContainerConfig(apiHost, apiKey, opts = {}) { + try { + const response = await fetch(`${apiHost}/api/container-config`, { + headers: { Authorization: `Bearer ${apiKey}`, ...opts.headers }, + signal: AbortSignal.timeout(1e4) + }); + if (response.status === 401) return { ok: false, reason: "unauthorized" }; + if (!response.ok) return { ok: false, reason: "http", status: response.status }; + return { ok: true, config: await response.json() }; + } catch (err) { + const message = err instanceof Error ? err.message : "unknown error"; + return { ok: false, reason: "network", message }; + } +} +function parseProxyUrl(proxyUrl) { + try { + const url = new URL(proxyUrl); + const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80; + return { host: url.hostname, port }; + } catch { + return null; + } +} +function probeProxy(host, port, timeoutMs = 3e3) { + return new Promise((resolve) => { + const socket = createConnection({ host, port, timeout: timeoutMs }, () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + resolve(false); + }); + socket.on("timeout", () => { + socket.destroy(); + resolve(false); + }); + }); +} +function writeCABundle(gatewayCert, paths = onecliPaths()) { + let systemCAs = ""; + for (const caPath of SYSTEM_CA_PATHS) { + const content = safeReadFile(caPath); + if (content) { + systemCAs = content; + break; + } + } + const bundle = systemCAs ? `${systemCAs} +${gatewayCert}` : gatewayCert; + const existing = safeReadFile(paths.caBundlePath); + if (existing !== bundle) { + writeFilePrivate(paths.caBundlePath, bundle); + } else { + chmodSync(paths.caBundlePath, 384); + } +} +async function readHookInput() { + if (process.stdin.isTTY) return {}; + let raw = ""; + for await (const chunk of process.stdin) { + raw += chunk; + } + const trimmed = raw.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + process.stderr.write("onecli: ignored invalid hook stdin.\n"); + return {}; + } +} + +// src/cursor/session-start.mts +var SCRIPT_DIR = dirname2(fileURLToPath(import.meta.url)); +var PLUGIN_ROOT = process.env.PLUGIN_ROOT || dirname2(SCRIPT_DIR); +var HELPER_PATH = join2(PLUGIN_ROOT, "bin", "onecli-cursor-env.mjs"); +var NODE_PATH = process.execPath; +var ACTIVE_CONTEXT = [ + "OneCLI Gateway active. Call external APIs directly (plain curl/gh); requests route through the gateway and credentials are injected automatically. Never add Authorization headers.", + "On errors: connect_url \u2192 show it to the user and retry after they connect; blocked_by_policy \u2192 report the rule, do not circumvent; rate_limited \u2192 wait retry_after_secs. Details: onecli-gateway skill." +].join(" "); +var SETUP_CONTEXT = "OneCLI Gateway: installed but not configured. Tell the user to invoke the onecli-setup skill. Do not attempt external API requests until configured."; +function buildLoaderContent({ pluginRoot, helperPath, nodePath, sessionId }) { + const sessionLine = sessionId ? `export ONECLI_CURSOR_SESSION_ID=${shellQuote(sessionId)}` : "unset ONECLI_CURSOR_SESSION_ID"; + return [ + "# Generated by the OneCLI Cursor plugin.", + "# This loader contains no gateway credential; sourcing it fetches fresh exports.", + `export ONECLI_CURSOR_PLUGIN_ROOT=${shellQuote(pluginRoot)}`, + sessionLine, + `eval "$(ONECLI_CURSOR_PLUGIN_ROOT="$ONECLI_CURSOR_PLUGIN_ROOT" ONECLI_CURSOR_SESSION_ID="\${ONECLI_CURSOR_SESSION_ID:-}" ${shellQuote(nodePath)} ${shellQuote(helperPath)} --format sh)"`, + "" + ].join("\n"); +} +function writeLoaderFile(sessionId) { + const paths = onecliPaths(); + const content = buildLoaderContent({ + pluginRoot: PLUGIN_ROOT, + helperPath: HELPER_PATH, + nodePath: NODE_PATH, + sessionId + }); + writeFilePrivate(paths.envPath, content); + return paths.envPath; +} +function buildEnvObject(config, caBundlePath) { + const raw = config && typeof config.env === "object" && config.env !== null ? config.env : {}; + const env = {}; + for (const [key, value] of Object.entries(raw)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (CA_ENV_KEYS.includes(key)) continue; + env[key] = value; + } + if (config?.caCertificate) { + for (const key of CA_ENV_KEYS) { + env[key] = caBundlePath; + } + } + env.GIT_TERMINAL_PROMPT = "0"; + env.GIT_HTTP_PROXY_AUTHMETHOD = "basic"; + env.NODE_USE_ENV_PROXY = "1"; + return env; +} +function emit(output) { + if (Object.keys(output).length > 0) { + process.stdout.write(JSON.stringify(output)); + } +} +async function main() { + const hookInput = await readHookInput(); + const sessionId = typeof hookInput.session_id === "string" ? hookInput.session_id : ""; + const paths = onecliPaths(); + const envPath = writeLoaderFile(sessionId); + if (isOnecliProxy(process.env.HTTPS_PROXY)) { + process.stderr.write("onecli: gateway already active.\n"); + emit({ additional_context: ACTIVE_CONTEXT }); + return; + } + const apiKey = resolveApiKey(paths); + if (!apiKey) { + process.stderr.write("onecli: no API key found.\n"); + emit({ additional_context: SETUP_CONTEXT }); + return; + } + const apiHost = resolveApiHost(paths); + const headers = {}; + if (sessionId) headers["X-OneCLI-Cursor-Session-Id"] = sessionId; + const result = await fetchContainerConfig(apiHost, apiKey, { headers }); + if (!result.ok) { + if (result.reason === "unauthorized") { + emit({ + additional_context: "OneCLI: API key is invalid or expired. Tell the user to run the onecli-setup skill. External API requests will fail this session." + }); + return; + } + emit({ + additional_context: "OneCLI gateway config could not be fetched. Use the onecli-status skill to diagnose. The loader file is ready for per-command sourcing." + }); + process.stderr.write(`onecli: gateway loader written to ${envPath}. +`); + return; + } + const config = result.config; + const proxyUrl = config.env.HTTPS_PROXY || config.env.https_proxy; + if (proxyUrl) { + const parsed = parseProxyUrl(proxyUrl); + if (parsed) { + const reachable = await probeProxy(parsed.host, parsed.port); + if (!reachable) { + emit({ + additional_context: "OneCLI gateway proxy is unreachable. Use the onecli-status skill to diagnose. Per-command sourcing may still work once the proxy is available." + }); + process.stderr.write(`onecli: gateway loader written to ${envPath}. +`); + return; + } + } + } + if (config.caCertificate) { + writeCABundle(config.caCertificate, paths); + } + const env = buildEnvObject(config, paths.caBundlePath); + if (sessionId) { + env.ONECLI_CURSOR_SESSION_ID = sessionId; + } + process.stderr.write(`onecli: gateway loader written to ${envPath}. +`); + emit({ env, additional_context: ACTIVE_CONTEXT }); +} +main().catch((err) => { + process.stderr.write( + `onecli: plugin error: ${err instanceof Error ? err.message : String(err)} +` + ); +}); diff --git a/plugins/cursor/rules/onecli-gateway.mdc b/plugins/cursor/rules/onecli-gateway.mdc new file mode 100644 index 0000000..20586ea --- /dev/null +++ b/plugins/cursor/rules/onecli-gateway.mdc @@ -0,0 +1,18 @@ +--- +description: OneCLI gateway usage rules for outbound API calls through HTTPS_PROXY +alwaysApply: true +--- + +# OneCLI Gateway (Cursor) + +Outbound HTTPS traffic from shell commands routes through the OneCLI gateway. Credentials are injected at the proxy boundary. + +## Rules + +- Call real API URLs directly with curl, gh, git, npm, etc. Do not set Authorization headers manually. +- If a response includes `connect_url`, show it to the user on its own line and retry after they connect. +- If a response includes `blocked_by_policy`, report the policy and do not circumvent it. +- If a response includes `retry_after_secs`, wait before retrying. +- For tools that require local credential files, create stubs with `"onecli-managed"` at the exact path named in the error, mode 0600, then retry. +- Never ask the user for service API keys or OAuth tokens; direct them to connect services in the OneCLI dashboard. +- Use the `onecli-gateway` and `onecli-providers` skills for detailed behavior and endpoint reference. diff --git a/plugins/cursor/scripts/install-project-hooks.sh b/plugins/cursor/scripts/install-project-hooks.sh new file mode 100755 index 0000000..3c1b614 --- /dev/null +++ b/plugins/cursor/scripts/install-project-hooks.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Fallback: wire OneCLI hooks into the current workspace when plugin hooks don't load. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WS_ROOT="$(git -C "${1:-.}" rev-parse --show-toplevel 2>/dev/null || pwd)" + +mkdir -p "$WS_ROOT/.cursor/hooks" +cat > "$WS_ROOT/.cursor/hooks.json" <- + Design multi-service API integrations through OneCLI Gateway. Use when + building workflows that combine GitHub, Google, AWS, Atlassian, or other + services through the OneCLI proxy. +metadata: + priority: 5 +--- + +# Integration Architect + +Help users design, build, and troubleshoot workflows that combine multiple +external APIs through the OneCLI transparent proxy. + +## Core Principle + +All API requests go through OneCLI via `HTTPS_PROXY`. The gateway injects the +right credentials for each service automatically. + +For supported outbound shell commands, the plugin's `preToolUse` hook will +conservatively auto-source the OneCLI loader. For multi-step shell scripts or +commands the hook skips, activate the loader explicitly: + +```bash +. ~/.onecli/env.sh +``` + +## Workflow Patterns + +### Sequential + +One API's output feeds the next. Example: fetch GitHub issues, then create Jira +tickets. + +```bash +. ~/.onecli/env.sh + +ISSUES=$(curl -s "https://api.github.com/repos/OWNER/REPO/issues?state=open&labels=bug") + +echo "$ISSUES" | jq -c '.[]' | while read -r issue; do + TITLE=$(echo "$issue" | jq -r '.title') + curl -s -X POST "https://api.atlassian.com/ex/jira/CLOUD_ID/rest/api/3/issue" \ + -H "Content-Type: application/json" \ + -d "{\"fields\":{\"summary\":\"$TITLE\",\"project\":{\"key\":\"PROJ\"},\"issuetype\":{\"name\":\"Bug\"}}}" +done +``` + +### Fan-Out + +One trigger calls multiple services. Example: a deployment event notifies Slack, +updates Jira, and logs to Datadog. + +### Aggregation + +Gather data from multiple sources, combine it, then push to one destination. + +## Error Handling + +Handle each service independently: + +- If one service returns `app_not_connected`, show the connect URL and continue + with other services when possible. +- If a gateway policy blocks one request, report it and do not retry that leg. +- Use `set +e` in shell workflows when one failed leg should not stop the full + workflow. + +## Rate Limits + +Each service has upstream limits, and OneCLI may add policy limits. If the +gateway returns `429`, respect `retry_after_secs`. + +## Multiple Connections + +If the user has multiple accounts connected for a service, the gateway returns a +`multiple_connections` error with available connection IDs. Ask which account to +use, then retry with: + +```bash +-H "x-onecli-connection-id: CONNECTION_ID" +``` + +## Recommendations + +1. Get one API call working before chaining services. +2. Verify each service is connected before building the full workflow. +3. Use `jq` for JSON parsing. +4. Prefer direct REST calls over service-specific CLIs that may require local + auth setup. +5. Design write operations to be safely re-run. diff --git a/plugins/cursor/skills/onecli-cleanup/SKILL.md b/plugins/cursor/skills/onecli-cleanup/SKILL.md new file mode 100644 index 0000000..7b66dfe --- /dev/null +++ b/plugins/cursor/skills/onecli-cleanup/SKILL.md @@ -0,0 +1,38 @@ +--- +name: onecli-cleanup +description: >- + Clean up the OneCLI Gateway environment file for Cursor. Use when the user + asks to deactivate OneCLI, uninstall OneCLI, or clean up gateway state + before removing the plugin. +metadata: + priority: 6 +--- + +# OneCLI Cleanup + +Run this only for explicit deactivate or uninstall cleanup. + +Cursor runs `sessionEnd` automatically and removes `~/.onecli/env.sh` when a +composer session ends. Use this skill only when the user wants to clean up +immediately without ending the session, or before uninstalling the plugin. + +## Cleanup + +Resolve the plugin root as the directory two levels above this `SKILL.md`, then +run: + +```bash +node "/hooks/session-end.mjs" +``` + +If the plugin root is not available, use the same cleanup directly: + +```bash +rm -f ~/.onecli/env.sh +``` + +## Verify + +```bash +test ! -e ~/.onecli/env.sh && echo "CLEANED" || echo "STILL_PRESENT" +``` diff --git a/plugins/cursor/skills/onecli-gateway/SKILL.md b/plugins/cursor/skills/onecli-gateway/SKILL.md new file mode 100644 index 0000000..1c29a55 --- /dev/null +++ b/plugins/cursor/skills/onecli-gateway/SKILL.md @@ -0,0 +1,116 @@ +--- +name: onecli-gateway +description: >- + OneCLI Gateway for Cursor: transparent HTTPS proxy instructions for outbound + calls with OneCLI-managed credentials. Use when prompted by the OneCLI + session hook or when the user explicitly asks to use OneCLI Gateway. +metadata: + priority: 8 +--- + +# OneCLI Gateway + +The OneCLI gateway injects stored credentials at the HTTPS proxy boundary. You +do not see or handle credential values directly. + +## Gateway Activation + +The plugin writes `~/.onecli/env.sh` as a non-secret loader. Sourcing it fetches +fresh gateway exports for the current shell command; the file itself does not +store live proxy credentials. + +For supported outbound shell commands, the Cursor `preToolUse` hook will +conservatively auto-source the loader. The `sessionStart` hook also returns +session-scoped `env` exports when an API key is configured. If a command is not +auto-sourced and `HTTPS_PROXY` is not already set to a OneCLI proxy, prefix it +with: + +```bash +. ~/.onecli/env.sh && +``` + +Example: + +```bash +. ~/.onecli/env.sh && curl -s "https://api.github.com/user" +``` + +## Making Requests + +Call the real API URL. The gateway intercepts the request and injects +credentials automatically. + +```bash +. ~/.onecli/env.sh && curl -s "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=5" +. ~/.onecli/env.sh && curl -s "https://api.github.com/user/repos?per_page=10" +. ~/.onecli/env.sh && curl -s "https://api.stripe.com/v1/charges?limit=5" +. ~/.onecli/env.sh && curl -s "https://www.googleapis.com/calendar/v3/calendars/primary/events?maxResults=10" +``` + +Do not set `Authorization` headers manually. + +## Credential Stubs + +Some tools and MCP servers need local credential files before they start. Under +OneCLI, real credentials are injected by the proxy, so local files only need to +satisfy format checks. + +If a tool fails because a credential file is missing: + +1. Do not follow the tool's OAuth or API-key setup flow. +2. Use the exact path named in the error. +3. Create a stub file using `onecli-managed` for secret values. +4. Set file permissions to `0600`. +5. Retry the operation through the gateway. + +OAuth token stub: + +```json +{ + "type": "authorized_user", + "access_token": "onecli-managed", + "refresh_token": "onecli-managed", + "client_id": "onecli-managed", + "client_secret": "onecli-managed", + "token_uri": "https://oauth2.googleapis.com/token", + "expiry": "2099-01-01T00:00:00+00:00" +} +``` + +API key stub: + +```text +onecli-managed +``` + +JSON credential stub: + +```json +{"api_key": "onecli-managed"} +``` + +Do not modify or delete files containing `onecli-managed` values unless the user +explicitly asks; they are placeholders for gateway-managed auth. + +## Error Handling + +If a request returns 401, 403, or a gateway error: + +- If the JSON body contains `connect_url`, show that URL to the user and retry + after they connect. +- If the JSON body contains `claim_url`, show that URL to the user and retry + after they claim the project. +- If the gateway returns `multiple_connections`, ask which account to use, then + retry with `x-onecli-connection-id`. +- If the gateway returns `blocked_by_policy`, show the policy name and reason. + Do not retry or circumvent it. +- If the gateway returns `rate_limited`, wait for `retry_after_secs`. + +## Rules + +- Never say you do not have access before making the HTTP request through the + gateway. +- Never ask the user for service API keys or OAuth tokens. +- Never follow built-in OAuth setup flows while running through the gateway. +- Prefer direct HTTP requests with curl/fetch over service-specific manual auth. +- Respect gateway policy errors. diff --git a/plugins/cursor/skills/onecli-providers/SKILL.md b/plugins/cursor/skills/onecli-providers/SKILL.md new file mode 100644 index 0000000..b7abec2 --- /dev/null +++ b/plugins/cursor/skills/onecli-providers/SKILL.md @@ -0,0 +1,80 @@ +--- +name: onecli-providers +description: >- + Reference of services supported by OneCLI Gateway, including endpoints, + authentication patterns, and connection behavior. Use when planning or making + calls to a specific service through OneCLI. +metadata: + priority: 5 +--- + +# Supported Providers + +## Google Suite + +| Service | Endpoint | Notes | +| --- | --- | --- | +| Gmail | `gmail.googleapis.com` | Also `www.googleapis.com/gmail/*` | +| Google Calendar | `www.googleapis.com/calendar/*` | Primary calendar endpoints | +| Google Drive | `www.googleapis.com/drive/*` | File listing, upload, download | +| Google Docs | `docs.googleapis.com` | Document read/write | +| Google Sheets | `sheets.googleapis.com` | Spreadsheet operations | +| Google Slides | `slides.googleapis.com` | Presentation operations | +| Google Tasks | `tasks.googleapis.com` | Task management | +| Google Forms | `forms.googleapis.com` | Form operations | +| Google Classroom | `classroom.googleapis.com` | Classroom management | +| Google Admin | `admin.googleapis.com` | Admin directory operations | +| Google Analytics | `analyticsdata.googleapis.com` | Analytics data | +| Google Search Console | `searchconsole.googleapis.com` | Search performance | +| Google Meet | `meet.googleapis.com` | Meeting management | +| Google Photos | `photoslibrary.googleapis.com` | Photo library access | +| YouTube | `www.googleapis.com/youtube/*` | YouTube Data API | + +## GitHub + +| Service | Endpoint | Notes | +| --- | --- | --- | +| GitHub API | `api.github.com` | REST API | +| GitHub Git | `github.com` | Git HTTPS operations | +| GitHub Raw | `raw.githubusercontent.com` | Raw file access | + +## Atlassian + +| Service | Endpoint | Notes | +| --- | --- | --- | +| Jira | `api.atlassian.com/ex/jira/*` | Jira Cloud REST API | +| Confluence | `api.atlassian.com/ex/confluence/*` | Confluence Cloud REST API | + +## AWS + +| Service | Endpoint | Notes | +| --- | --- | --- | +| All AWS Services | `*.amazonaws.com`, `*.api.aws` | SigV4 request signing | +| AWS AssumeRole | Same endpoints | STS AssumeRole for temporary credentials | + +Pass AWS region with the `x-onecli-aws-region` header when needed. + +## API Key Services + +| Service | Endpoint | Notes | +| --- | --- | --- | +| Todoist | `api.todoist.com` | OAuth plus API key | +| Resend | `api.resend.com` | API key | +| Cloudflare | `api.cloudflare.com` | API key | +| Notion | `api.notion.com` | OAuth plus API key | + +## Cloud-Only Services + +These require OneCLI Cloud. + +| Service | Endpoint | Notes | +| --- | --- | --- | +| Datadog | Regional endpoints | DD API and application key injection | +| Outlook Mail | `graph.microsoft.com/v1.0/me/messages` | Microsoft Graph | +| Outlook Calendar | `graph.microsoft.com/v1.0/me/calendar` | Microsoft Graph | +| Microsoft Word | `graph.microsoft.com/v1.0/me/drive` | Microsoft Graph through OneDrive or SharePoint | + +## Custom Services + +Users can add custom secrets in the OneCLI dashboard. The gateway injects the +configured headers or query parameters for matching host patterns. diff --git a/plugins/cursor/skills/onecli-setup/SKILL.md b/plugins/cursor/skills/onecli-setup/SKILL.md new file mode 100644 index 0000000..26a4fb1 --- /dev/null +++ b/plugins/cursor/skills/onecli-setup/SKILL.md @@ -0,0 +1,67 @@ +--- +name: onecli-setup +description: >- + Configure the OneCLI Gateway API key for Cursor. Use when the user asks to set + up OneCLI, configure OneCLI, or invokes onecli-setup. +metadata: + priority: 7 +--- + +# OneCLI Setup + +Configure OneCLI once, then start a new Cursor composer session so the +session-start hook can write `~/.onecli/env.sh` and apply gateway exports. + +## Check Existing Configuration + +```bash +test -s ~/.onecli/credentials/api-key && echo "FOUND" || echo "NOT_FOUND" +``` + +If a key exists, verify it: + +```bash +curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $(cat ~/.onecli/credentials/api-key)" \ + https://app.onecli.sh/api/container-config +``` + +If the response is `200`, tell the user OneCLI is already configured. + +## Get an API Key + +If no key exists, tell the user: + +```text +Open https://app.onecli.sh/projects, select or create a project, copy the API +key from the Overview page, and paste it here. +``` + +The key should start with `oc_`. + +## Store and Verify + +After the user provides the key, store it without printing it back: + +```bash +umask 077 +mkdir -p ~/.onecli/credentials +printf '%s' "USER_PROVIDED_KEY" > ~/.onecli/credentials/api-key +printf '%s\n' '{"api-host":"https://app.onecli.sh"}' > ~/.onecli/config.json +chmod 600 ~/.onecli/credentials/api-key ~/.onecli/config.json +curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer USER_PROVIDED_KEY" \ + https://app.onecli.sh/api/container-config +``` + +If the response is `200`, tell the user setup succeeded and they should start a +new Cursor composer session. The session-start hook fetches gateway exports and +applies session env automatically. If they need to refresh in the current +session, resolve the plugin root as the directory two levels above this +`SKILL.md` and run: + +```bash +node "/hooks/session-start.mjs" +``` + +If the response is `401`, ask the user to check the key in the OneCLI dashboard. diff --git a/plugins/cursor/skills/onecli-status/SKILL.md b/plugins/cursor/skills/onecli-status/SKILL.md new file mode 100644 index 0000000..c3d51f7 --- /dev/null +++ b/plugins/cursor/skills/onecli-status/SKILL.md @@ -0,0 +1,87 @@ +--- +name: onecli-status +description: >- + Show OneCLI Gateway status and connected services. Use when the user asks for + OneCLI status, gateway status, connected services, or invokes /onecli-status. +metadata: + priority: 7 +--- + +# OneCLI Status + +Show whether the gateway is configured and which services are connected. + +## Check Local Gateway Environment + +`~/.onecli/env.sh` is a loader, not a static secret file. Sourcing it fetches +fresh gateway exports for the current shell. + +```bash +if [ -r ~/.onecli/env.sh ]; then + . ~/.onecli/env.sh +fi + +if [ -n "$HTTPS_PROXY" ]; then + echo "GATEWAY: active" + echo "HTTPS_PROXY: $HTTPS_PROXY" +else + echo "GATEWAY: not active" +fi +``` + +## Check API Key + +```bash +API_KEY="$( + cat ~/.onecli/credentials/api-key 2>/dev/null || + python3 -c 'import json,os; p=os.path.expanduser("~/.config/onecli-plugin/auth.json"); print(json.load(open(p)).get("apiKey",""))' 2>/dev/null +)" + +if [ -n "$API_KEY" ]; then + curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + https://app.onecli.sh/api/container-config +else + echo "NO_API_KEY" +fi +``` + +`200` means the API key is valid. `401` means it needs to be replaced. + +## List Connected Services + +```bash +API_KEY="$( + cat ~/.onecli/credentials/api-key 2>/dev/null || + python3 -c 'import json,os; p=os.path.expanduser("~/.config/onecli-plugin/auth.json"); print(json.load(open(p)).get("apiKey",""))' 2>/dev/null +)" + +if [ -n "$API_KEY" ]; then + curl -s -H "Authorization: Bearer $API_KEY" https://app.onecli.sh/api/apps | + python3 -c ' +import json, sys +apps = json.load(sys.stdin) +connected = [a for a in apps if a.get("connection", {}).get("status") == "connected"] +available = [a for a in apps if a.get("available") and a.get("connection", {}).get("status") != "connected"] +if connected: + print(f"Connected ({len(connected)}):") + for app in connected: + name = app.get("name", "unknown") + print(f" + {name}") +if available: + print(f"Available ({len(available)}):") + for app in available[:5]: + name = app.get("name", "unknown") + print(f" - {name}") + if len(available) > 5: + print(f" ... and {len(available) - 5} more") +if not connected and not available: + print("No services found.") +' +else + echo "No API key configured. Use the onecli-setup skill first." +fi +``` + +If services are not connected, direct the user to +`https://app.onecli.sh/projects`. diff --git a/scripts/install-cursor-plugin-local.sh b/scripts/install-cursor-plugin-local.sh new file mode 100644 index 0000000..e353dc5 --- /dev/null +++ b/scripts/install-cursor-plugin-local.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PLUGIN_SRC="$ROOT/plugins/cursor" +LOCAL_DIR="${HOME}/.cursor/plugins/local/onecli" + +echo "Building OneCLI plugin hooks..." +(cd "$ROOT" && npm run build) + +echo "Linking local Cursor plugin -> $LOCAL_DIR" +mkdir -p "${HOME}/.cursor/plugins/local" +ln -sfn "$PLUGIN_SRC" "$LOCAL_DIR" + +echo "" +echo "Local plugin installed at: $LOCAL_DIR" +echo "" +echo "Next steps in Cursor:" +echo " 1. Settings -> Features -> enable 'Include third-party Plugins, Skills, and other configs'" +echo " 2. Customize -> Plugins -> + Add -> select this repo's .cursor-plugin/marketplace.json" +echo " (or install the 'onecli' plugin from the onecli-local marketplace)" +echo " 3. Cmd+Shift+P -> Developer: Reload Window" +echo " 4. Start a NEW composer session (sessionStart hook runs once per session)" +echo " 5. Settings -> Hooks: confirm sessionStart / preToolUse / sessionEnd are listed" +echo " 6. Run verification: python3 onecli-plugin/tests/verify_cursor_agent_gateway.py" +echo "" +echo "If plugin hooks do not appear in Settings -> Hooks, project hooks in" +echo " /.cursor/hooks.json" +echo "are wired to the same scripts as a fallback." diff --git a/src/codex/pre-tool-use.mts b/src/codex/pre-tool-use.mts index 2784bd2..4a20ad2 100644 --- a/src/codex/pre-tool-use.mts +++ b/src/codex/pre-tool-use.mts @@ -1,4 +1,6 @@ #!/usr/bin/env node +// Conservative outbound-command rewrite — shared logic with src/cursor/pre-tool-use.mts. +// Rationale, allowlist tradeoffs, and deprecation path: docs/hook-activation.md import { existsSync } from "node:fs"; import { basename } from "node:path"; import { isOnecliProxy, onecliPaths, readHookInput } from "../shared/runtime.mjs"; diff --git a/src/cursor/onecli-cursor-env.mts b/src/cursor/onecli-cursor-env.mts new file mode 100644 index 0000000..0de6a87 --- /dev/null +++ b/src/cursor/onecli-cursor-env.mts @@ -0,0 +1,85 @@ +#!/usr/bin/env node +import { + buildEnvLines, + fetchContainerConfig, + onecliPaths, + parseProxyUrl, + probeProxy, + resolveApiHost, + resolveApiKey, + shellQuote, + writeCABundle, +} from "../shared/runtime.mjs"; + +function fail(message: string): never { + process.stderr.write(`onecli: ${message}\n`); + process.exit(1); +} + +function parseFormat(argv: string[]): string { + const idx = argv.indexOf("--format"); + if (idx === -1) return "sh"; + return argv[idx + 1] ?? "sh"; +} + +async function main(): Promise { + const format = parseFormat(process.argv.slice(2)); + if (format !== "sh") { + fail(`unsupported output format '${format}'.`); + } + + const paths = onecliPaths(); + const sessionId = process.env.ONECLI_CURSOR_SESSION_ID || ""; + + const apiKey = resolveApiKey(paths); + if (!apiKey) { + fail("no API key configured. Use the onecli-setup skill, then retry. This command runs without the gateway."); + } + + const apiHost = resolveApiHost(paths); + const headers: Record = {}; + if (sessionId) headers["X-OneCLI-Cursor-Session-Id"] = sessionId; + + const result = await fetchContainerConfig(apiHost, apiKey, { headers }); + if (!result.ok) { + if (result.reason === "unauthorized") { + fail("API key is invalid or expired. Use the onecli-setup skill to reconfigure."); + } + if (result.reason === "http") { + fail(`gateway config request returned HTTP ${result.status}. Use the onecli-status skill to diagnose.`); + } + fail(`could not reach OneCLI Cloud (${result.message}). This command runs without the gateway.`); + } + + const config = result.config; + const proxyUrl = config.env.HTTPS_PROXY || config.env.https_proxy; + if (proxyUrl) { + const parsed = parseProxyUrl(proxyUrl); + if (parsed) { + const reachable = await probeProxy(parsed.host, parsed.port); + if (!reachable) { + fail( + `gateway proxy unreachable at ${parsed.host}:${parsed.port}. Your API key may target a different environment. This command runs without the gateway.` + ); + } + } + } + + if (config.caCertificate) { + writeCABundle(config.caCertificate, paths); + } + + const lines: string[] = []; + if (sessionId) { + lines.push(`export ONECLI_CURSOR_SESSION_ID=${shellQuote(sessionId)}`); + } + lines.push(...buildEnvLines(config, { caBundlePath: paths.caBundlePath })); + process.stdout.write(lines.join("\n") + "\n"); +} + +main().catch((err) => { + process.stderr.write( + `onecli: env helper error: ${err instanceof Error ? err.message : String(err)}\n` + ); + process.exit(1); +}); diff --git a/src/cursor/pre-tool-use.mts b/src/cursor/pre-tool-use.mts new file mode 100644 index 0000000..3b4651a --- /dev/null +++ b/src/cursor/pre-tool-use.mts @@ -0,0 +1,134 @@ +#!/usr/bin/env node +// Conservative outbound-command rewrite — shared logic with src/codex/pre-tool-use.mts. +// Rationale, allowlist tradeoffs, and deprecation path: docs/hook-activation.md +import { existsSync } from "node:fs"; +import { basename } from "node:path"; +import { isOnecliProxy, onecliPaths, readHookInput } from "../shared/runtime.mjs"; + +const URL_PATTERN = /\b(?:https?:\/\/|ssh:\/\/|git@)/i; +const PACKAGE_NETWORK_COMMANDS = new Set([ + "add", + "audit", + "ci", + "info", + "install", + "outdated", + "publish", + "search", + "update", + "view", +]); +const GIT_NETWORK_COMMANDS = new Set([ + "clone", + "fetch", + "ls-remote", + "pull", + "push", + "remote", + "submodule", +]); +const TERRAFORM_NETWORK_COMMANDS = new Set([ + "apply", + "destroy", + "get", + "import", + "init", + "plan", + "refresh", +]); +const AWS_LOCAL_COMMANDS = new Set(["configure", "help"]); + +function splitCommandPrefix(command: string): { name: string; arg: string } { + const trimmed = command.trimStart(); + const match = trimmed.match(/^([A-Za-z0-9_./-]+)(?:\s+([A-Za-z0-9_./:-]+))?/); + if (!match) return { name: "", arg: "" }; + return { + name: basename(match[1]), + arg: match[2] || "", + }; +} + +function isAlreadyHandled(command: string): boolean { + return ( + command.includes(".onecli/env.sh") || + command.includes("ONECLI_CURSOR_AUTOSOURCED") || + command.includes("HTTPS_PROXY=") || + command.includes("https_proxy=") + ); +} + +function isPackageManager(name: string): boolean { + return ["npm", "pnpm", "yarn", "bun"].includes(name); +} + +function isPythonPackageTool(name: string): boolean { + return ["pip", "pip3"].includes(name); +} + +function shouldRewrite(command: string): boolean { + const { name, arg } = splitCommandPrefix(command); + if (!name) return false; + + if (["curl", "wget", "http", "https"].includes(name)) { + return URL_PATTERN.test(command); + } + + if (name === "gh") { + return true; + } + + if (name === "aws") { + return Boolean(arg) && !arg.startsWith("-") && !AWS_LOCAL_COMMANDS.has(arg); + } + + if (name === "terraform" || name === "tofu") { + return TERRAFORM_NETWORK_COMMANDS.has(arg); + } + + if (name === "git") { + return GIT_NETWORK_COMMANDS.has(arg); + } + + if (isPackageManager(name) || isPythonPackageTool(name)) { + return PACKAGE_NETWORK_COMMANDS.has(arg); + } + + if (["node", "python", "python3", "deno", "bunx", "npx"].includes(name)) { + return URL_PATTERN.test(command); + } + + return false; +} + +function rewrittenCommand(command: string): string { + return `ONECLI_CURSOR_AUTOSOURCED=1; . "$HOME/.onecli/env.sh" && ${command}`; +} + +function writeRewrite(command: string): void { + process.stdout.write( + JSON.stringify({ + permission: "allow", + updated_input: { + command: rewrittenCommand(command), + }, + }) + ); +} + +async function main(): Promise { + const input = await readHookInput(); + const toolInput = input.tool_input as { command?: unknown } | undefined; + const command = toolInput?.command; + if (input.tool_name !== "Shell" || typeof command !== "string") return; + if (isOnecliProxy(process.env.HTTPS_PROXY)) return; + if (!existsSync(onecliPaths().envPath)) return; + if (isAlreadyHandled(command)) return; + if (!shouldRewrite(command)) return; + writeRewrite(command); +} + +main().catch((err) => { + process.stderr.write( + `onecli: pre-tool-use hook error: ${err instanceof Error ? err.message : String(err)}\n` + ); +}); diff --git a/src/cursor/session-end.mts b/src/cursor/session-end.mts new file mode 100644 index 0000000..f050ae4 --- /dev/null +++ b/src/cursor/session-end.mts @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import { unlinkSync } from "node:fs"; +import { onecliPaths } from "../shared/runtime.mjs"; + +function removeEnvFile(): void { + try { + unlinkSync(onecliPaths().envPath); + } catch { + // Missing cleanup target is already clean. + } +} + +removeEnvFile(); +process.stderr.write("onecli: gateway session cleaned up.\n"); diff --git a/src/cursor/session-start.mts b/src/cursor/session-start.mts new file mode 100644 index 0000000..6d8439f --- /dev/null +++ b/src/cursor/session-start.mts @@ -0,0 +1,169 @@ +#!/usr/bin/env node +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + CA_ENV_KEYS, + fetchContainerConfig, + isOnecliProxy, + onecliPaths, + parseProxyUrl, + probeProxy, + readHookInput, + resolveApiHost, + resolveApiKey, + shellQuote, + writeCABundle, + writeFilePrivate, + type ContainerConfig, +} from "../shared/runtime.mjs"; + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_ROOT = process.env.PLUGIN_ROOT || dirname(SCRIPT_DIR); +const HELPER_PATH = join(PLUGIN_ROOT, "bin", "onecli-cursor-env.mjs"); +const NODE_PATH = process.execPath; + +const ACTIVE_CONTEXT = [ + "OneCLI Gateway active. Call external APIs directly (plain curl/gh); requests route through the gateway and credentials are injected automatically. Never add Authorization headers.", + "On errors: connect_url → show it to the user and retry after they connect; blocked_by_policy → report the rule, do not circumvent; rate_limited → wait retry_after_secs. Details: onecli-gateway skill.", +].join(" "); + +const SETUP_CONTEXT = + "OneCLI Gateway: installed but not configured. Tell the user to invoke the onecli-setup skill. Do not attempt external API requests until configured."; + +interface LoaderInputs { + pluginRoot: string; + helperPath: string; + nodePath: string; + sessionId: string; +} + +function buildLoaderContent({ pluginRoot, helperPath, nodePath, sessionId }: LoaderInputs): string { + const sessionLine = sessionId + ? `export ONECLI_CURSOR_SESSION_ID=${shellQuote(sessionId)}` + : "unset ONECLI_CURSOR_SESSION_ID"; + + return [ + "# Generated by the OneCLI Cursor plugin.", + "# This loader contains no gateway credential; sourcing it fetches fresh exports.", + `export ONECLI_CURSOR_PLUGIN_ROOT=${shellQuote(pluginRoot)}`, + sessionLine, + `eval "$(ONECLI_CURSOR_PLUGIN_ROOT="$ONECLI_CURSOR_PLUGIN_ROOT" ONECLI_CURSOR_SESSION_ID="\${ONECLI_CURSOR_SESSION_ID:-}" ${shellQuote(nodePath)} ${shellQuote(helperPath)} --format sh)"`, + "", + ].join("\n"); +} + +function writeLoaderFile(sessionId: string): string { + const paths = onecliPaths(); + const content = buildLoaderContent({ + pluginRoot: PLUGIN_ROOT, + helperPath: HELPER_PATH, + nodePath: NODE_PATH, + sessionId, + }); + writeFilePrivate(paths.envPath, content); + return paths.envPath; +} + +function buildEnvObject(config: ContainerConfig, caBundlePath: string): Record { + const raw = config && typeof config.env === "object" && config.env !== null ? config.env : {}; + const env: Record = {}; + + for (const [key, value] of Object.entries(raw)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (CA_ENV_KEYS.includes(key)) continue; + env[key] = value; + } + + if (config?.caCertificate) { + for (const key of CA_ENV_KEYS) { + env[key] = caBundlePath; + } + } + + env.GIT_TERMINAL_PROMPT = "0"; + env.GIT_HTTP_PROXY_AUTHMETHOD = "basic"; + env.NODE_USE_ENV_PROXY = "1"; + return env; +} + +function emit(output: { env?: Record; additional_context?: string }): void { + if (Object.keys(output).length > 0) { + process.stdout.write(JSON.stringify(output)); + } +} + +async function main(): Promise { + const hookInput = await readHookInput(); + const sessionId = typeof hookInput.session_id === "string" ? hookInput.session_id : ""; + const paths = onecliPaths(); + const envPath = writeLoaderFile(sessionId); + + if (isOnecliProxy(process.env.HTTPS_PROXY)) { + process.stderr.write("onecli: gateway already active.\n"); + emit({ additional_context: ACTIVE_CONTEXT }); + return; + } + + const apiKey = resolveApiKey(paths); + if (!apiKey) { + process.stderr.write("onecli: no API key found.\n"); + emit({ additional_context: SETUP_CONTEXT }); + return; + } + + const apiHost = resolveApiHost(paths); + const headers: Record = {}; + if (sessionId) headers["X-OneCLI-Cursor-Session-Id"] = sessionId; + + const result = await fetchContainerConfig(apiHost, apiKey, { headers }); + if (!result.ok) { + if (result.reason === "unauthorized") { + emit({ + additional_context: + "OneCLI: API key is invalid or expired. Tell the user to run the onecli-setup skill. External API requests will fail this session.", + }); + return; + } + emit({ + additional_context: + "OneCLI gateway config could not be fetched. Use the onecli-status skill to diagnose. The loader file is ready for per-command sourcing.", + }); + process.stderr.write(`onecli: gateway loader written to ${envPath}.\n`); + return; + } + + const config = result.config; + const proxyUrl = config.env.HTTPS_PROXY || config.env.https_proxy; + if (proxyUrl) { + const parsed = parseProxyUrl(proxyUrl); + if (parsed) { + const reachable = await probeProxy(parsed.host, parsed.port); + if (!reachable) { + emit({ + additional_context: + "OneCLI gateway proxy is unreachable. Use the onecli-status skill to diagnose. Per-command sourcing may still work once the proxy is available.", + }); + process.stderr.write(`onecli: gateway loader written to ${envPath}.\n`); + return; + } + } + } + + if (config.caCertificate) { + writeCABundle(config.caCertificate, paths); + } + + const env = buildEnvObject(config, paths.caBundlePath); + if (sessionId) { + env.ONECLI_CURSOR_SESSION_ID = sessionId; + } + + process.stderr.write(`onecli: gateway loader written to ${envPath}.\n`); + emit({ env, additional_context: ACTIVE_CONTEXT }); +} + +main().catch((err) => { + process.stderr.write( + `onecli: plugin error: ${err instanceof Error ? err.message : String(err)}\n` + ); +}); diff --git a/tests/test_cursor_live_e2e.py b/tests/test_cursor_live_e2e.py new file mode 100644 index 0000000..ce7d3f9 --- /dev/null +++ b/tests/test_cursor_live_e2e.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Live E2E verification for the OneCLI Cursor plugin against real OneCLI Cloud.""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +CURSOR = REPO / "plugins" / "cursor" +MARKER = f"cursor-e2e-{int(time.time())}" + + +def run(cmd: list[str], *, input_text: str | None = None, env: dict[str, str] | None = None, cwd: Path | None = None) -> subprocess.CompletedProcess: + result = subprocess.run( + cmd, + input=input_text, + text=True, + capture_output=True, + env=env or os.environ.copy(), + cwd=str(cwd) if cwd else None, + timeout=60, + ) + if result.returncode != 0: + raise RuntimeError( + f"command failed: {cmd}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + return result + + +def api_key() -> str: + path = Path.home() / ".onecli" / "credentials" / "api-key" + if not path.exists(): + raise RuntimeError("missing ~/.onecli/credentials/api-key") + return path.read_text().strip() + + +def probe_activity_endpoints(key: str) -> list[dict]: + import urllib.error + import urllib.request + + candidates = [ + "https://app.onecli.sh/api/github/activity", + "https://app.onecli.sh/api/apps/github/activity", + "https://app.onecli.sh/api/activity", + "https://app.onecli.sh/api/activities/github", + "https://api.onecli.sh/v1/activity", + "https://api.onecli.sh/v1/activities", + ] + found = [] + for url in candidates: + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {key}"}) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + body = resp.read(500).decode("utf-8", errors="replace") + found.append({"url": url, "status": resp.status, "body": body}) + except urllib.error.HTTPError as exc: + found.append({"url": url, "status": exc.code, "body": exc.read(300).decode("utf-8", errors="replace")}) + except Exception as exc: # noqa: BLE001 + found.append({"url": url, "status": "error", "body": str(exc)}) + return found + + +def main() -> int: + node = shutil_which("node") + key = api_key() + + print("== 1) npm test (full workflow suite) ==") + run([sys.executable, "tests/test_workflows.py"], cwd=REPO) + print("PASS: workflow tests") + + print("== 2) sessionStart live hook ==") + hook_input = json.dumps({"hook_event_name": "sessionStart", "session_id": MARKER}) + start = run( + [node, str(CURSOR / "hooks" / "session-start.mjs")], + input_text=hook_input, + cwd=CURSOR, + ) + payload = json.loads(start.stdout) + proxy = payload.get("env", {}).get("HTTPS_PROXY") + if not proxy: + raise RuntimeError("session-start did not return HTTPS_PROXY") + if "gateway.onecli.sh" not in proxy and ":10255" not in proxy: + raise RuntimeError(f"unexpected proxy URL: {proxy}") + if payload.get("additional_context", "").startswith("OneCLI Gateway active") is False: + raise RuntimeError("session-start missing active context") + print(f"PASS: session-start env proxy={proxy[:70]}...") + + print("== 3) preToolUse rewrite live hook ==") + pre = run( + [node, str(CURSOR / "hooks" / "pre-tool-use.mjs")], + input_text=json.dumps( + { + "hook_event_name": "preToolUse", + "tool_name": "Shell", + "tool_input": {"command": "curl -s https://api.github.com/zen"}, + } + ), + cwd=CURSOR, + ) + if pre.stdout.strip(): + rewritten = json.loads(pre.stdout) + command = rewritten["updated_input"]["command"] + if ".onecli/env.sh" not in command: + raise RuntimeError(f"pre-tool-use missing loader prefix: {command}") + print("PASS: pre-tool-use rewrite") + else: + print("PASS: pre-tool-use skipped (gateway already active in hook env)") + + print("== 4) proxied curl through loader ==") + checks = [ + ("https://api.github.com/zen", "zen", {"200"}), + ( + "https://api.github.com/rate_limit", + "rate_limit", + {"200"}, + ), + ] + rate_body = "" + for url, label, ok_codes in checks: + out = Path(f"/tmp/onecli_cursor_e2e_{label}.json") + curl_cmd = ( + f'. "$HOME/.onecli/env.sh" && ' + f'curl -s -o {out} -w "%{{http_code}}" ' + f'-H "X-OneCLI-E2E-Marker: {MARKER}" "{url}"' + ) + curl = run(["/bin/bash", "-lc", curl_cmd], cwd=CURSOR) + code = curl.stdout.strip() + body_preview = out.read_text()[:500] if out.exists() else "" + print(f"{label}_http_code={code}") + print(f"{label}_body={body_preview!r}") + if code not in ok_codes: + raise RuntimeError(f"unexpected {label} curl status: {code}") + if not body_preview.strip(): + raise RuntimeError(f"{label} curl returned empty body") + if label == "rate_limit": + rate_body = out.read_text() + + rate = json.loads(rate_body) + if "resources" not in rate: + raise RuntimeError(f"github /rate_limit unexpected payload: {rate}") + print("PASS: proxied curl completed through gateway with GitHub credentials") + + print("== 5) container-config still valid ==") + cfg = run( + [ + "curl", + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-H", + f"Authorization: Bearer {key}", + "https://app.onecli.sh/api/container-config", + ] + ) + if cfg.stdout.strip() != "200": + raise RuntimeError(f"container-config returned {cfg.stdout}") + print("PASS: API key valid") + + print("== 6) probe dashboard activity endpoints ==") + probes = probe_activity_endpoints(key) + for item in probes: + print(f"probe {item['url']} -> {item['status']}") + + print("== 7) sessionEnd cleanup ==") + run([node, str(CURSOR / "hooks" / "session-end.mjs")], cwd=CURSOR) + if (Path.home() / ".onecli" / "env.sh").exists(): + raise RuntimeError("session-end did not remove env.sh") + print("PASS: session-end cleanup") + + print( + json.dumps( + { + "onecli_cursor_live_e2e": "ok", + "marker": MARKER, + "github_rate_limit_ok": True, + "curl_http_code": "200", + "activity_probe": probes, + "note": "Dashboard Activity tab has no public list API in docs; verify marker in app.onecli.sh Activity UI for provider used.", + }, + indent=2, + ) + ) + return 0 + + +def shutil_which(name: str) -> str: + import shutil + + path = shutil.which(name) + if not path: + raise RuntimeError(f"{name} not found on PATH") + return path + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as exc: # noqa: BLE001 + print(f"LIVE E2E FAILED: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 1adc37d..6c8b4bf 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -21,6 +21,7 @@ REPO = Path(__file__).resolve().parents[1] CLAUDE = REPO / "plugins" / "claude" CODEX = REPO / "plugins" / "codex" +CURSOR = REPO / "plugins" / "cursor" API_KEY = "oc_test_workflows" @@ -59,7 +60,8 @@ def do_GET(self) -> None: { "path": self.path, "authorization": self.headers.get("Authorization"), - "session_id": self.headers.get("X-OneCLI-Codex-Session-Id"), + "session_id": self.headers.get("X-OneCLI-Codex-Session-Id") + or self.headers.get("X-OneCLI-Cursor-Session-Id"), } ) if self.path != "/api/container-config": @@ -156,6 +158,8 @@ def clean_env(home: Path, api_url: str) -> dict[str, str]: "ONECLI_API_KEY", "ONECLI_CODEX_PLUGIN_ROOT", "ONECLI_CODEX_SESSION_ID", + "ONECLI_CURSOR_PLUGIN_ROOT", + "ONECLI_CURSOR_SESSION_ID", "PLUGIN_ROOT", ): env.pop(key, None) @@ -205,15 +209,21 @@ def event_commands(hooks: dict, event: str) -> list[str]: return commands +def cursor_hook_commands(hooks: dict, event: str) -> list[str]: + return [entry["command"] for entry in hooks["hooks"].get(event, [])] + + def assert_manifests_and_hooks() -> str: claude_manifest = load_json(CLAUDE / ".claude-plugin" / "plugin.json") codex_manifest = load_json(CODEX / ".codex-plugin" / "plugin.json") + cursor_manifest = load_json(CURSOR / ".cursor-plugin" / "plugin.json") check(claude_manifest["name"] == "onecli", "claude plugin name drifted") check(codex_manifest["name"] == "onecli", "codex plugin name drifted") + check(cursor_manifest["name"] == "onecli", "cursor plugin name drifted") check( - claude_manifest["version"] == codex_manifest["version"], - "claude and codex plugin versions drifted apart", + claude_manifest["version"] == codex_manifest["version"] == cursor_manifest["version"], + "claude, codex, and cursor plugin versions drifted apart", ) # Root compatibility shim: keeps the repo root installable as the "onecli" @@ -298,6 +308,39 @@ def assert_manifests_and_hooks() -> str: ) check((CODEX / "hooks" / "session-end.mjs").exists(), "codex cleanup script is missing") check((CODEX / "bin" / "onecli-codex-env.mjs").exists(), "codex env helper is missing") + + check(cursor_manifest.get("skills") == "./skills/", "cursor skills path drifted") + check(cursor_manifest.get("rules") == "./rules/", "cursor rules path drifted") + check(cursor_manifest.get("hooks") == "./hooks/hooks.json", "cursor hooks path drifted") + check((CURSOR / ".cursor-plugin" / "marketplace.json").exists(), "cursor marketplace manifest is missing for local + Add install") + check((CURSOR / "rules" / "onecli-gateway.mdc").exists(), "cursor gateway rule is missing") + + cursor_hooks = load_json(CURSOR / "hooks" / "hooks.json") + check(cursor_hooks.get("version") == 1, "cursor hooks.json must use schema version 1") + cursor_events = set(cursor_hooks["hooks"]) + check("sessionStart" in cursor_events, "cursor sessionStart hook is missing") + check("preToolUse" in cursor_events, "cursor preToolUse hook is missing") + check("sessionEnd" in cursor_events, "cursor sessionEnd hook is missing") + check( + cursor_hook_commands(cursor_hooks, "sessionStart") + == ["node ./hooks/session-start.mjs"], + "cursor sessionStart command drifted", + ) + check( + cursor_hook_commands(cursor_hooks, "preToolUse") + == ["node ./hooks/pre-tool-use.mjs"], + "cursor preToolUse command drifted", + ) + check( + cursor_hooks["hooks"]["preToolUse"][0].get("matcher") == "Shell", + "cursor preToolUse must match Shell", + ) + check( + cursor_hook_commands(cursor_hooks, "sessionEnd") + == ["node ./hooks/session-end.mjs"], + "cursor sessionEnd command drifted", + ) + check((CURSOR / "bin" / "onecli-cursor-env.mjs").exists(), "cursor env helper is missing") return command @@ -312,6 +355,14 @@ def assert_skill_inventory() -> None: "onecli-setup": "onecli-setup", "onecli-status": "onecli-status", }, + CURSOR: { + "integration-architect": "integration-architect", + "onecli-cleanup": "onecli-cleanup", + "onecli-gateway": "onecli-gateway", + "onecli-providers": "onecli-providers", + "onecli-setup": "onecli-setup", + "onecli-status": "onecli-status", + }, } for plugin, skills in expected.items(): for directory, name in skills.items(): @@ -363,6 +414,17 @@ def assert_workflow_docs() -> None: ], normalize=True, ) + assert_contains( + "plugins/cursor/skills/onecli-cleanup/SKILL.md", + [ + "explicit deactivate or uninstall cleanup", + "Cursor runs `sessionEnd` automatically", + 'node "/hooks/session-end.mjs"', + "rm -f ~/.onecli/env.sh", + "CLEANED", + ], + normalize=True, + ) assert_contains( "plugins/codex/skills/onecli-gateway/SKILL.md", [ @@ -420,6 +482,10 @@ def assert_node_syntax(node: str) -> None: CODEX / "hooks" / "session-end.mjs", CODEX / "hooks" / "pre-tool-use.mjs", CODEX / "bin" / "onecli-codex-env.mjs", + CURSOR / "hooks" / "session-start.mjs", + CURSOR / "hooks" / "session-end.mjs", + CURSOR / "hooks" / "pre-tool-use.mjs", + CURSOR / "bin" / "onecli-cursor-env.mjs", ] for script in scripts: run_checked([node, "--check", str(script)], env=os.environ.copy(), cwd=REPO) @@ -633,6 +699,108 @@ def assert_pre_tool_use(node: str) -> None: ) +def assert_cursor_loader_file(home: Path) -> None: + env_file = home / ".onecli" / "env.sh" + check(env_file.exists(), "cursor session-start did not write ~/.onecli/env.sh") + mode = stat.S_IMODE(env_file.stat().st_mode) + check(mode == 0o600, f"env.sh mode should be 0600, got {oct(mode)}") + content = env_file.read_text() + check("onecli-cursor-env.mjs" in content, "env.sh must invoke the cursor loader helper") + check("ONECLI_CURSOR_SESSION_ID='sess_cursor'" in content, "env.sh must persist Cursor session id") + check("HTTPS_PROXY" not in content, "env.sh must not contain live proxy exports") + check(API_KEY not in content, "env.sh must not contain the API key") + + +def assert_cursor_session_flow(node: str) -> None: + with tempfile.TemporaryDirectory() as tmp, fake_onecli_services() as services: + home = Path(tmp) / "home" + home.mkdir() + write_api_key(home) + env = clean_env(home, services["api_url"]) + hook_input = json.dumps({"hook_event_name": "sessionStart", "session_id": "sess_cursor"}) + + result = run_checked( + [node, str(CURSOR / "hooks" / "session-start.mjs")], + env=env, + cwd=CURSOR, + input_text=hook_input, + ) + payload = json.loads(result.stdout) + check(payload.get("env", {}).get("HTTPS_PROXY") == services["proxy_url"], "cursor session-start must return session env") + check("OneCLI Gateway active" in payload.get("additional_context", ""), "cursor session-start must return gateway context") + check(services["api"].requests, "cursor session-start must fetch gateway config") + check( + services["api"].requests[-1]["session_id"] == "sess_cursor", + "cursor session-start must forward Cursor session id", + ) + assert_cursor_loader_file(home) + assert_ca_bundle_permissions(home) + + status_check = ( + ". ~/.onecli/env.sh && " + 'test "$ONECLI_CURSOR_SESSION_ID" = sess_cursor && ' + f'test "$HTTPS_PROXY" = {services["proxy_url"]} && ' + 'test -n "$HTTPS_PROXY"' + ) + run_checked(status_check, env=env, cwd=CURSOR, shell=True) + + run_checked([node, str(CURSOR / "hooks" / "session-end.mjs")], env=env, cwd=CURSOR) + check(not (home / ".onecli" / "env.sh").exists(), "cursor session-end did not clean env.sh") + + +def run_cursor_pre_tool(node: str, home: Path, command: str, extra_env: dict[str, str] | None = None) -> str: + env = os.environ.copy() + for key in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"): + env.pop(key, None) + env["HOME"] = str(home) + if extra_env: + env.update(extra_env) + payload = { + "hook_event_name": "preToolUse", + "tool_name": "Shell", + "tool_input": {"command": command}, + } + result = run_checked( + [node, str(CURSOR / "hooks" / "pre-tool-use.mjs")], + env=env, + cwd=CURSOR, + input_text=json.dumps(payload), + ) + return result.stdout + + +def assert_cursor_pre_tool_use(node: str) -> None: + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) + + missing = run_cursor_pre_tool(node, home, "curl -s https://api.github.com/user") + check(missing == "", "cursor preToolUse should skip when env.sh is missing") + + env_file = home / ".onecli" / "env.sh" + env_file.parent.mkdir(parents=True) + env_file.write_text("# loader\n") + + rewritten = run_cursor_pre_tool(node, home, "curl -s https://api.github.com/user") + parsed = json.loads(rewritten) + updated = parsed["updated_input"]["command"] + check(parsed.get("permission") == "allow", "cursor preToolUse must allow rewritten commands") + check( + updated.startswith('ONECLI_CURSOR_AUTOSOURCED=1; . "$HOME/.onecli/env.sh" && curl'), + "cursor curl command was not auto-sourced", + ) + + already = run_cursor_pre_tool( + node, home, '. "$HOME/.onecli/env.sh" && curl -s https://api.github.com/user' + ) + check(already == "", "cursor preToolUse should skip already-sourced commands") + + local = run_cursor_pre_tool(node, home, "git status --short") + check(local == "", "cursor preToolUse should skip local git commands") + + git_network = run_cursor_pre_tool(node, home, "git pull") + check("updated_input" in git_network, "cursor preToolUse should rewrite network git commands") + + def assert_claude_session_flow(node: str) -> None: with tempfile.TemporaryDirectory() as tmp, fake_onecli_services() as services: home = Path(tmp) / "home" @@ -712,6 +880,8 @@ def main() -> int: assert_codex_loader_failure_is_visible(node) assert_codex_plugin_root_hook_command(node, command) assert_pre_tool_use(node) + assert_cursor_session_flow(node) + assert_cursor_pre_tool_use(node) assert_claude_session_flow(node) print( json.dumps( @@ -729,6 +899,8 @@ def main() -> int: "codex_loader_failure_visibility", "codex_plugin_root_hook_command", "codex_pre_tool_use_rewrite", + "cursor_session_flow", + "cursor_pre_tool_use_rewrite", "claude_session_flow_and_quoting", ], }, diff --git a/tests/verify_cursor_agent_gateway.py b/tests/verify_cursor_agent_gateway.py new file mode 100644 index 0000000..4eb8fff --- /dev/null +++ b/tests/verify_cursor_agent_gateway.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Verify Cursor agent shell traffic uses the OneCLI gateway. + +Run this FROM an Agent/subagent Shell tool after the plugin is installed and a +new composer session started. Do not source ~/.onecli/env.sh manually. + +Pass criteria: +- GitHub rate_limit core limit > 1000 (authenticated via gateway) +- Optional: ONECLI activity row appears (manual dashboard check) +""" +from __future__ import annotations + +import json +import subprocess +import sys +import time + + +def main() -> int: + marker = f"cursor-agent-verify-{int(time.time())}" + cmd = ( + f'curl -s -H "X-OneCLI-Agent-Verify: {marker}" ' + "https://api.github.com/rate_limit" + ) + result = subprocess.run( + ["/bin/bash", "-lc", cmd], + text=True, + capture_output=True, + timeout=30, + ) + if result.returncode != 0: + print( + json.dumps( + { + "ok": False, + "reason": "curl_failed", + "stderr": result.stderr, + "stdout": result.stdout, + }, + indent=2, + ) + ) + return 1 + + try: + payload = json.loads(result.stdout) + core_limit = payload["resources"]["core"]["limit"] + except (json.JSONDecodeError, KeyError) as exc: + print( + json.dumps( + { + "ok": False, + "reason": "bad_rate_limit_response", + "error": str(exc), + "stdout": result.stdout[:500], + }, + indent=2, + ) + ) + return 1 + + via_gateway = core_limit > 1000 + print( + json.dumps( + { + "ok": via_gateway, + "marker": marker, + "core_limit": core_limit, + "interpretation": "gateway" if via_gateway else "direct_unauthenticated", + "hint": ( + "Gateway active. Check OneCLI Activity for GET /rate_limit." + if via_gateway + else "Hooks not active. Install plugin, reload, new session, or run install-project-hooks.sh" + ), + }, + indent=2, + ) + ) + return 0 if via_gateway else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tsup.config.ts b/tsup.config.ts index 690427f..83ba75d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -33,4 +33,13 @@ export default defineConfig([ "plugins/codex/hooks" ), bundle(["src/codex/onecli-codex-env.mts"], "plugins/codex/bin"), + bundle( + [ + "src/cursor/session-start.mts", + "src/cursor/pre-tool-use.mts", + "src/cursor/session-end.mts", + ], + "plugins/cursor/hooks" + ), + bundle(["src/cursor/onecli-cursor-env.mts"], "plugins/cursor/bin"), ]);