diff --git a/CHANGELOG.md b/CHANGELOG.md index ca297b2..0d9af85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ Product release notes: [docs.proofable.me/changelog](https://docs.proofable.me/c ## [Unreleased] +## [0.1.10] - 2026-10-28 + +### Changed + +- **`proofable import` creates the agent in one step.** A signed-in terminal now creates the agent on your profile with its full instructions and mounts it into the current project, for the host the source names or the one you pass with `--apply`. Its permissions never expire unless you set an end with `--expires-at` (a date or Unix milliseconds); `--no-mount` skips the mount. It prints the agent's name, what it may do, when its permissions end, and its agent page. `owner/name` reads a GitHub repository, and `--dry-run` shows what would happen without creating anything. + +### Fixed + +- **`proofable auth --oauth` signs in the CLI and leaves your editors alone.** It no longer writes its short-lived token into Cursor, VS Code or Claude, which keep the sign-in `proofable setup` gave them, and it works on a machine with none of them installed. Name an editor with `--client` to write the token there too. +- **Long agent instructions are never dropped without a word.** A terminal that is not signed in is sent to the browser import. When a step has to finish in the browser and the link cannot carry the instructions, the CLI says so and tells you to paste them there. + ## [0.1.9] - 2026-10-27 ### Fixed @@ -174,7 +185,8 @@ The `@proofable/sdk` package continues the line published as `@neus/sdk`. The no | [1.1.0](https://www.npmjs.com/package/@neus/sdk/v/1.1.0) | 2026-05-26 | First public SDK release on npm: OAuth-first setup, the CLI, public MCP tools including encrypted Vault secrets, and the Claude Code plugin with the trust workflow skill. | | [1.0.12](https://www.npmjs.com/package/@neus/sdk/v/1.0.12) | 2026-05-26 | First `@neus/sdk` release with the hosted MCP CLI (`neus setup`, `neus auth`, import/export) and the OAuth browser flow. | -[Unreleased]: https://github.com/proofable/sdk/compare/v0.1.9...HEAD +[Unreleased]: https://github.com/proofable/sdk/compare/v0.1.10...HEAD +[0.1.10]: https://github.com/proofable/sdk/compare/v0.1.9...v0.1.10 [0.1.9]: https://github.com/proofable/sdk/compare/v0.1.8...v0.1.9 [0.1.8]: https://github.com/proofable/sdk/compare/v0.1.7...v0.1.8 [0.1.7]: https://github.com/proofable/sdk/compare/v0.1.6...v0.1.7 diff --git a/README.md b/README.md index d7c67dd..4f4dfb7 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,53 @@ Add verification gates, reusable proof, and agent permissions to your app. [![npm](https://img.shields.io/npm/v/%40proofable%2Fsdk?label=%40proofable%2Fsdk&color=98C0EF)](https://www.npmjs.com/package/%40proofable%2Fsdk) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE) -Verify someone once, then reuse that proof on every later check. - ## Start here +Bring the agent you already run. Proofable gives it an ID tied to you and a list of what it may do. + +Requires Node.js 20 or later. + +**1. Connect and sign in** + ```bash -npm install @proofable/sdk +npx -y @proofable/sdk setup +npx -y @proofable/sdk auth --oauth ``` -Requires Node.js 20 or later. +`setup` connects Proofable to the AI tools on this machine. +`auth --oauth` signs this terminal in through your browser. + +**2. Import your agent** + +```bash +npx -y @proofable/sdk import owner/name --apply claude +``` + +Point it at a GitHub repository (`owner/name` or its URL) or a folder. +It creates the agent on your profile and writes it into this project. +Name the tool with `--apply`. It takes `cursor`, `claude`, `codex`, `hermes`, `openclaw` or `opencode`, and VS Code uses `cursor`. + +**3. Use it** + +`import` prints what the agent may do, the files it wrote and the agent's page. +Open this project in that tool. The agent's instructions and permissions are in those files. +Its permissions never expire unless you pass `--expires-at `. + +**Or let your coding agent do it** + +Paste this into Claude Code, Cursor or Codex. + +```text +Set up Proofable for this project. Run `npx -y @proofable/sdk setup`. Then run `npx -y @proofable/sdk auth --oauth` and wait for me to sign in through the browser. Then run `npx -y @proofable/sdk import . --apply `, replacing with claude, cursor or codex to match the tool you run in. Show me what the agent may do and its page link. +``` + +## Add a check to your app + +Verify someone once, then reuse that proof on every later check. + +```bash +npm install @proofable/sdk +``` **Send a person to verify** @@ -76,13 +114,17 @@ import { VerifyGate } from '@proofable/sdk/widgets'; Interactive clients add `https://mcp.proofable.me/mcp`, click **Connect**, and sign in. Servers and CI send a server key from `PROOFABLE_ACCESS_KEY` as a Bearer token. ```bash -npx -y @proofable/sdk setup npx -y @proofable/sdk setup --access-key $PROOFABLE_ACCESS_KEY +npx -y @proofable/sdk import --dry-run npx -y @proofable/sdk mount --apply npx -y @proofable/sdk doctor --live ``` -`--apply` accepts `cursor`, `claude`, `codex`, `hermes`, `openclaw`, or `opencode`. VS Code uses `--apply cursor`. Setup steps: [docs.proofable.me/mcp/setup](https://docs.proofable.me/mcp/setup). +`import` also reads an MCP server URL or an A2A agent card. +`--dry-run` shows what it would create. `--no-mount` creates the agent without writing it into the project. +A terminal that is not signed in gets a browser link instead. + +`mount` writes an agent you already have into this project for another tool. Setup steps: [docs.proofable.me/mcp/setup](https://docs.proofable.me/mcp/setup). ## Core methods diff --git a/cli/proofable.mjs b/cli/proofable.mjs index 702147a..f807361 100644 --- a/cli/proofable.mjs +++ b/cli/proofable.mjs @@ -1000,6 +1000,8 @@ function parseArgs(argv) { apply: '', agentTarget: '', importTarget: '', + noMount: false, + expiresAt: '', gateCommand: '', subject: '', gateTarget: '' @@ -1063,6 +1065,17 @@ function parseArgs(argv) { options.importTarget = token; continue; } + if (token === '--no-mount') { + options.noMount = true; + continue; + } + if (token === '--expires-at') { + const value = argv[index + 1]; + if (!value) throw new Error('--expires-at requires a value (a date, Unix milliseconds, or never)'); + options.expiresAt = value.trim(); + index += 1; + continue; + } if (token === '--subject') { const value = argv[index + 1]; if (!value) throw new Error('--subject requires a value'); @@ -1106,7 +1119,7 @@ function printUsage(exitCode = 0) { ' examples Show assistant prompts to try after install', ' doctor Deep check: config status, profile connection, and live MCP context', ' mount Connect a Trusted Agent to a project or runtime', - ' import Inspect a package, repo, card, or runtime link', + ' import Create an agent from a repo (URL or owner/name), folder, MCP URL, or agent card, then mount it', ' gate check --subject Check a Gate Policy file against a subject', ' gate get Read a published gate snapshot', ' help Show this message', @@ -1119,6 +1132,8 @@ function printUsage(exitCode = 0) { ' --live Run live MCP checks (uses your client credential or --access-key)', ' --agent Agent id for mount (also accepted positionally: `proofable mount `)', ' --apply Write mounted agent rules to the current project', + ' --expires-at When an imported agent\'s permissions end (default: never)', + ' --no-mount Import without writing the agent into the current project', ' --json Print JSON output', ' --dry-run Preview changes without writing files' ]; @@ -1935,16 +1950,23 @@ function collectImportFiles(dir, into, root = dir) { } } +const IMPORT_USAGE = + 'Usage: proofable import [--apply ] [--expires-at ] [--no-mount] [--dry-run] [--json]'; + function readImportSource(target) { const value = String(target || '').trim(); if (!value) { - throw new Error('Usage: proofable import [--json]'); + throw new Error(IMPORT_USAGE); } if (/^https?:\/\//i.test(value)) { return { url: value }; } const resolved = path.resolve(value); if (!fs.existsSync(resolved)) { + // `owner/name` with no matching local folder is a GitHub repository. + if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) { + return { url: `https://github.com/${value}` }; + } throw new Error(`Source not found: ${value}`); } const stat = fs.lstatSync(resolved); @@ -1998,23 +2020,100 @@ async function postAgentImport(pathname, body, accessKey, signal) { return json; } +const IMPORT_BROWSER_URL = `${PROOFABLE_APP_URL}/profile/agents/new?import=1`; + +/** + * --expires-at as Unix ms. An imported agent's permissions never expire unless the + * caller sets a date, so no flag, `never` and `0` all return 0 (no expiry). + */ +function resolveImportExpiresAt(raw, now = Date.now()) { + const value = String(raw || '').trim(); + if (!value || value === '0' || value.toLowerCase() === 'never') return 0; + const ms = /^\d+$/.test(value) ? Number(value) : Date.parse(value); + if (!Number.isFinite(ms) || ms <= now) { + throw new Error('--expires-at must be a future date (for example 2026-12-31), Unix milliseconds, or never.'); + } + return ms; +} + +function formatImportExpiry(expiresAt) { + const ms = Number(expiresAt); + if (!Number.isFinite(ms) || ms <= 0) return 'never'; + return new Date(ms).toISOString().slice(0, 10); +} + +/** True when the commit's browser link does not carry the agent's full instructions. */ +function importLinkOmitsInstructions(commit) { + const instructions = String(commit?.mcpCreateArgs?.instructions || commit?.identityPrefill?.instructions || '').trim(); + if (!instructions || !commit?.hostedVerifyUrl) return false; + if (commit.hostedVerifyUrlOmitsInstructions === true) return true; + try { + const carried = new URL(commit.hostedVerifyUrl).searchParams.get('prefillInstructions') || ''; + return carried.trim() !== instructions; + } catch { + return true; + } +} + +function describeAgentPermissions(bundle, created) { + const actions = bundle?.delegation?.allowedActions; + if (Array.isArray(actions) && actions.length > 0) { + return actions.map(action => String(action).replace(/_/g, ' ')).join(', '); + } + const preset = created?.agent?.preset; + return preset ? `the ${preset} preset` : 'see the agent page'; +} + async function runImport(options) { const cwd = process.cwd(); const scope = resolveScope(options); - const accessKey = await resolveLiveAccessKeyWithRefresh(options, scope, cwd); const target = String(options.importTarget || '').trim(); if (!target) { - throw new Error('Usage: proofable import [--json]'); + throw new Error(IMPORT_USAGE); } - if (!accessKey) { - throw new Error('Credential required. Run `npx -y @proofable/sdk auth --oauth` or pass --access-key.'); + const expiresAt = resolveImportExpiresAt(options.expiresAt); + const requestedApply = String(options.apply || '').trim(); + if (requestedApply && !normalizeApplyHost(requestedApply)) { + throw new Error(`--apply must be ${APPLY_HOSTS.join(', ')}`); } - const source = readImportSource(target); + const accessKey = await resolveLiveAccessKeyWithRefresh(options, scope, cwd); + + // Reading a source needs a signed-in caller, so an unsigned terminal hands off to the + // browser import, which signs in and reads the source itself. + const browserFallback = () => { + const payload = { + command: 'import', + created: false, + signedIn: false, + browserUrl: IMPORT_BROWSER_URL, + signInCommand: 'npx -y @proofable/sdk auth --oauth' + }; + if (options.json) { + printJson(payload); + return payload; + } + emitCliBanner(options); + writeCliLine(paint('import', 'green')); + logStep('warn', 'sign in', 'This terminal is not signed in to Proofable.'); + writeGuidanceLine(`Import ${source.url || target} in the browser: ${IMPORT_BROWSER_URL}`); + writeGuidanceLine(`Or sign in here and run this again: ${payload.signInCommand}`); + writeCliLine(''); + return payload; + }; + if (!accessKey) return browserFallback(); + const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 30000); + const timeout = setTimeout(() => controller.abort(), 60000); try { - const previewJson = await postAgentImport('/api/v1/agents/import/preview', source, accessKey, controller.signal); + let previewJson; + try { + previewJson = await postAgentImport('/api/v1/agents/import/preview', source, accessKey, controller.signal); + } catch (error) { + // A stored sign-in the server no longer accepts gets the same browser hand-off. + if (error?.status === 401) return browserFallback(); + throw error; + } const preview = previewJson.preview; if (!preview) { throw new Error('Inspect did not return a preview.'); @@ -2026,29 +2125,125 @@ async function runImport(options) { controller.signal ); const commit = commitJson.commit; - const payload = { - command: 'import', - preview, - commit, - hostedVerifyUrl: commit?.hostedVerifyUrl || null, - apply: commit?.runtimeHandoff?.apply || null - }; + if (!commit?.mcpCreateArgs?.agentId) { + throw new Error('Import did not return an agent to create.'); + } + const agentLabel = commit.mcpCreateArgs.agentLabel || preview.identity?.label || target; + // The whole record rides in the request body, so long instructions arrive intact. + const createArgs = { ...commit.mcpCreateArgs, expiresAt }; + const applyHost = + normalizeApplyHost(requestedApply) || normalizeApplyHost(commit.runtimeHandoff?.applyHost) || null; + const mountTarget = options.noMount ? null : applyHost || MOUNT_MANIFEST_RELATIVE; + const payload = { command: 'import', preview, commit, createArgs }; + + if (options.dryRun) { + Object.assign(payload, { dryRun: true, created: false, mountTarget }); + if (options.json) { + printJson(payload); + return payload; + } + emitCliBanner(options); + writeCliLine(paint('import', 'green')); + logStep('ok', 'agent', agentLabel); + writeGuidanceLine(`Would create it. Expires: ${formatImportExpiry(expiresAt)}`); + if (mountTarget) { + writeGuidanceLine(applyHost ? `Would mount it for ${applyHost}` : `Would write ${MOUNT_MANIFEST_RELATIVE}`); + } + writeCliLine(''); + return payload; + } + + const { sessionId } = await initializeMcpSession(accessKey, controller.signal); + const callTool = args => callMcpTool({ ...args, accessKey, sessionId, signal: controller.signal }); + const createCall = await callTool({ name: 'proofable_agent_create', args: createArgs }); + const created = createCall.ok ? createCall.payload : null; + + if (!created || created.error || created.linked !== true) { + // This terminal could not finish the agent; the browser link is the way on. + const omitsInstructions = importLinkOmitsInstructions(commit); + Object.assign(payload, { + created: false, + result: created, + hostedVerifyUrl: commit.hostedVerifyUrl || null, + hostedCheckoutUrl: created?.hostedCheckoutUrl || null, + linkOmitsInstructions: omitsInstructions + }); + process.exitCode = 1; + if (options.json) { + printJson(payload); + return payload; + } + emitCliBanner(options); + writeCliLine(paint('import', 'green')); + logStep('warn', 'agent', `${agentLabel} was not created`); + writeGuidanceLine(String(created?.message || createCall.error || 'Proofable could not finish the agent here.')); + if (payload.hostedCheckoutUrl) { + writeGuidanceLine(`Complete billing: ${payload.hostedCheckoutUrl}`); + } + if (payload.hostedVerifyUrl) { + writeGuidanceLine(`Finish in the browser: ${payload.hostedVerifyUrl}`); + } + if (omitsInstructions) { + const count = String(createArgs.instructions || '').trim().length.toLocaleString('en-US'); + writeGuidanceLine( + `That link leaves out the agent's instructions (${count} characters, more than a link can carry). Paste them on that page; run this again with --json to print them.` + ); + } + writeCliLine(''); + return payload; + } + + const agentId = created.agent?.agentId || createArgs.agentId; + let bundle = null; + let mounted = null; + let mountError = ''; + try { + bundle = await resolveRuntimeBundleFromMcp({ callMcpTool: callTool, accessKey, agentId, signal: controller.signal }); + if (mountTarget) { + mounted = applyHost + ? applyRuntimeBundle(applyHost, bundle, cwd) + : { flavor: null, written: [writeMountManifest(bundle, cwd)] }; + } + } catch (error) { + mountError = errorMessage(error); + } + + const grantedExpiresAt = bundle?.delegation ? bundle.delegation.expiresAt : expiresAt; + const agentUrl = `${PROOFABLE_APP_URL}/agent/${encodeURIComponent(agentId)}`; + Object.assign(payload, { + created: true, + result: created, + agentId, + permissions: bundle?.delegation?.allowedActions || null, + expiresAt: grantedExpiresAt ?? null, + agentUrl, + mounted, + ...(mountError ? { mountError } : {}) + }); if (options.json) { printJson(payload); return payload; } emitCliBanner(options); writeCliLine(paint('import', 'green')); - logStep('ok', 'agent', commit?.identityPrefill?.agentLabel || preview.identity?.label || target); + logStep('ok', 'agent', agentLabel); + writeGuidanceLine(`Allowed: ${describeAgentPermissions(bundle, created)}`); + writeGuidanceLine(`Expires: ${formatImportExpiry(grantedExpiresAt)}`); if (preview.excludedSecrets?.length) { logStep('warn', 'secrets', 'Secrets were left out. Reconnect those apps next.'); } - if (commit?.hostedVerifyUrl) { - writeGuidanceLine(`Register identity: ${commit.hostedVerifyUrl}`); + for (const filePath of mounted?.written || []) { + logStep('ok', 'wrote', filePath); } - if (commit?.runtimeHandoff?.apply) { - writeGuidanceLine(`Then connect the runtime: ${commit.runtimeHandoff.apply}`); + const mountCommand = `npx -y @proofable/sdk mount ${agentId} --apply ${applyHost || ''}`; + if (mountError && mountTarget) { + logStep('warn', 'mount', 'Not mounted into this project.'); + writeGuidanceLine(mountError); + writeGuidanceLine(`Mount it later: ${mountCommand}`); + } else if (mounted && !applyHost) { + writeGuidanceLine(`Add it to a host: ${mountCommand}`); } + writeGuidanceLine(`Agent page: ${agentUrl}`); writeCliLine(''); return payload; } finally { @@ -2203,8 +2398,11 @@ async function runAuthBrowser(options) { if (scope !== 'user') { throw new Error('Browser auth only supports user scope. Remove --project flag.'); } - const clients = resolveClients(scope, options.clients); - ensureClientSelection(scope, clients); + // `--oauth` signs in this CLI. Editors keep their own sign-in from `setup`: a short-lived + // token written into their config stops working when it expires. Name a client to opt in. + const clients = Array.isArray(options.clients) && options.clients.length > 0 + ? resolveClients(scope, options.clients) + : []; const browserManagedClients = clients.filter(client => client !== 'codex'); const hostManagedClients = clients.filter(client => client === 'codex'); const cwd = process.cwd(); diff --git a/package-lock.json b/package-lock.json index 68b2148..9e7608c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@proofable/sdk", - "version": "0.1.9", + "version": "0.1.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@proofable/sdk", - "version": "0.1.9", + "version": "0.1.10", "license": "Apache-2.0", "dependencies": { "@noble/ed25519": "^2.3.0", diff --git a/package.json b/package.json index 9e81ee7..b69fcc2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@proofable/sdk", - "version": "0.1.9", + "version": "0.1.10", "description": "SDK and CLI for verification gates, reusable proof, and agent permissions.", "bin": { "proofable": "cli/proofable.mjs" diff --git a/test/cli.test.js b/test/cli.test.js index 4d5d7b1..0499c64 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -4,7 +4,8 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { buildRuntimeBundle } from '../runtime-mount.js'; const execFileAsync = promisify(execFile); const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -768,6 +769,201 @@ describe('proofable CLI', () => { expect(stdout).toContain('import '); expect(stdout).toContain('hermes'); expect(stdout).toContain('openclaw'); + expect(stdout).toContain('--expires-at'); + expect(stdout).toContain('--no-mount'); }); }); + +describe('proofable import', () => { + const WALLET = '0x1111111111111111111111111111111111111111'; + const LONG_INSTRUCTIONS = `Review pull requests and report risks.\n${'Follow the release checklist line by line. '.repeat(200)}`.trim(); + const fetchMockUrl = pathToFileURL(path.join(__dirname, 'fixtures', 'import-fetch-mock.mjs')).href; + + function importCommit() { + const mcpCreateArgs = { + agentId: 'repo-reviewer', + agentLabel: 'Repo Reviewer', + agentType: 'agent', + description: 'Reviews pull requests.', + instructions: LONG_INSTRUCTIONS, + capabilities: ['proofs'] + }; + return { + previewId: 'preview-1', + identityPrefill: { ...mcpCreateArgs }, + // Over the link budget, so the server leaves the instructions out of the link. + hostedVerifyUrl: + 'https://proofable.me/verify?verifiers=agent-identity&agentId=repo-reviewer&agentLabel=Repo+Reviewer&returnTo=%2Fprofile%3Ftab%3Dagents', + hostedVerifyUrlOmitsInstructions: true, + mcpCreateArgs, + runtimeHandoff: { + origin: 'cursor', + applyHost: 'cursor', + apply: 'npx -y @proofable/sdk mount repo-reviewer --apply cursor' + } + }; + } + + function mountedBundle(expiresAt) { + return buildRuntimeBundle({ + identity: { + qHash: `0x${'a'.repeat(64)}`, + agentId: 'repo-reviewer', + agentWallet: WALLET, + agentLabel: 'Repo Reviewer', + instructions: LONG_INSTRUCTIONS + }, + delegation: { + qHash: `0x${'b'.repeat(64)}`, + controllerWallet: WALLET, + agentWallet: WALLET, + agentId: 'repo-reviewer', + allowedActions: ['read_context', 'read_proofs', 'execute_jobs'], + expiresAt + }, + controllerWallet: WALLET + }); + } + + function importReplies({ create, bundle } = {}) { + return { + '/api/v1/agents/import/preview': { + body: { + success: true, + preview: { + schema: 'proofable.agent-import.preview.v1', + previewId: 'preview-1', + identity: { label: 'Repo Reviewer', agentId: 'repo-reviewer' }, + excludedSecrets: [] + } + } + }, + '/api/v1/agents/import/commit': { body: { success: true, commit: importCommit() } }, + 'mcp:initialize': { protocolVersion: '2025-11-25', capabilities: {} }, + 'mcp:proofable_agent_create': create ?? { + status: 'ok', + linked: true, + path: 'session_auto_complete', + agent: { agentId: 'repo-reviewer', agentWallet: WALLET, controllerWallet: WALLET, preset: 'assistant' }, + proofs: { identity: { qHash: `0x${'a'.repeat(64)}` }, delegation: { qHash: `0x${'b'.repeat(64)}` } } + }, + ...(bundle ? { 'mcp:proofable_agent_mount': bundle } : {}) + }; + } + + async function makeImportContext({ accessKey = 'npk_test_import', replies = importReplies() } = {}) { + const context = await makeCliContext(); + const repliesPath = path.join(context.workspaceDir, '..', 'fetch-replies.json'); + const logPath = path.join(context.workspaceDir, '..', 'fetch-log.jsonl'); + await writeJson(repliesPath, replies); + context.env = { + ...context.env, + PROOFABLE_ACCESS_KEY: accessKey, + PROOFABLE_TEST_FETCH_REPLIES: repliesPath, + PROOFABLE_TEST_FETCH_LOG: logPath + }; + const agentDir = path.join(context.workspaceDir, 'agent-src'); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile(path.join(agentDir, 'AGENTS.md'), LONG_INSTRUCTIONS, 'utf8'); + return { ...context, logPath }; + } + + async function runImportCli(args, context) { + return execFileAsync(process.execPath, ['--import', fetchMockUrl, cliPath, 'import', ...args], { + encoding: 'utf8', + cwd: context.workspaceDir, + env: context.env + }); + } + + async function readFetchLog(context) { + const raw = await fs.readFile(context.logPath, 'utf8').catch(() => ''); + return raw.split('\n').filter(Boolean).map(line => JSON.parse(line)); + } + + it('creates the agent with the full import record and no expiry by default, then mounts it', async () => { + const context = await makeImportContext({ replies: importReplies({ bundle: mountedBundle(0) }) }); + const { stderr } = await runImportCli(['agent-src'], context); + + const calls = await readFetchLog(context); + const create = calls.find(call => call.key === 'mcp:proofable_agent_create'); + expect(create).toBeTruthy(); + expect(create.headers.authorization).toBe('Bearer npk_test_import'); + const { expiresAt: sentExpiresAt, ...sentArgs } = create.body.params.arguments; + expect(sentArgs).toEqual(importCommit().mcpCreateArgs); + expect(sentArgs.instructions).toHaveLength(LONG_INSTRUCTIONS.length); + expect(sentExpiresAt).toBe(0); + + const rulesPath = path.join(context.workspaceDir, '.cursor', 'rules', 'proofable-agent-repo-reviewer.mdc'); + await expect(fs.stat(rulesPath)).resolves.toBeTruthy(); + expect(stderr).toContain('Repo Reviewer'); + expect(stderr).toContain('Allowed: read context, read proofs, execute jobs'); + expect(stderr).toContain('Expires: never'); + expect(stderr).toContain('Agent page: https://proofable.me/agent/repo-reviewer'); + expect(stderr).not.toContain('/verify?'); + }); + + it('passes a chosen expiry through and skips the mount with --no-mount', async () => { + const expiresAt = Date.parse('2099-12-31'); + const context = await makeImportContext({ replies: importReplies({ bundle: mountedBundle(expiresAt) }) }); + const { stdout } = await runImportCli(['agent-src', '--expires-at', '2099-12-31', '--no-mount', '--json'], context); + + const create = (await readFetchLog(context)).find(call => call.key === 'mcp:proofable_agent_create'); + expect(create.body.params.arguments.expiresAt).toBe(expiresAt); + const payload = JSON.parse(stdout); + expect(payload.created).toBe(true); + expect(payload.expiresAt).toBe(expiresAt); + expect(payload.mounted).toBeNull(); + await expect(fs.stat(path.join(context.workspaceDir, '.proofable'))).rejects.toThrow(); + }); + + it('falls back to the browser import when the terminal is not signed in', async () => { + const context = await makeImportContext({ accessKey: '' }); + const { stdout } = await runImportCli(['agent-src', '--json'], context); + + expect(await readFetchLog(context)).toEqual([]); + const payload = JSON.parse(stdout); + expect(payload.created).toBe(false); + expect(payload.signedIn).toBe(false); + expect(payload.browserUrl).toBe('https://proofable.me/profile/agents/new?import=1'); + expect(payload.signInCommand).toBe('npx -y @proofable/sdk auth --oauth'); + }); + + it('says when the browser link leaves out long instructions instead of dropping them silently', async () => { + const context = await makeImportContext({ + replies: importReplies({ + create: { + status: 'signatures_required', + path: 'signatures_required', + message: 'Agent "repo-reviewer" setup is in progress.' + } + }) + }); + const error = await runImportCli(['agent-src'], context).then( + () => null, + failure => failure + ); + + expect(error?.code).toBe(1); + const create = (await readFetchLog(context)).find(call => call.key === 'mcp:proofable_agent_create'); + expect(create.body.params.arguments.instructions).toBe(LONG_INSTRUCTIONS); + expect(error.stderr).toContain('Finish in the browser: https://proofable.me/verify?verifiers=agent-identity'); + expect(error.stderr).toContain( + `leaves out the agent's instructions (${LONG_INSTRUCTIONS.length.toLocaleString('en-US')} characters` + ); + }); + + it('previews without creating anything on --dry-run', async () => { + const context = await makeImportContext(); + const { stdout } = await runImportCli(['agent-src', '--expires-at', 'never', '--dry-run', '--json'], context); + + const keys = (await readFetchLog(context)).map(call => call.key); + expect(keys).toEqual(['/api/v1/agents/import/preview', '/api/v1/agents/import/commit']); + const payload = JSON.parse(stdout); + expect(payload.dryRun).toBe(true); + expect(payload.created).toBe(false); + expect(payload.createArgs.instructions).toBe(LONG_INSTRUCTIONS); + expect(payload.createArgs.expiresAt).toBe(0); + }); +}); diff --git a/test/fixtures/import-fetch-mock.mjs b/test/fixtures/import-fetch-mock.mjs new file mode 100644 index 0000000..dbcaf0a --- /dev/null +++ b/test/fixtures/import-fetch-mock.mjs @@ -0,0 +1,33 @@ +// Preloaded with `node --import` by the CLI import tests. Replaces fetch with canned +// API and MCP replies read from PROOFABLE_TEST_FETCH_REPLIES and records every request +// to PROOFABLE_TEST_FETCH_LOG as one JSON line. +import fs from 'node:fs'; + +const replies = JSON.parse(fs.readFileSync(process.env.PROOFABLE_TEST_FETCH_REPLIES, 'utf8')); +const logPath = process.env.PROOFABLE_TEST_FETCH_LOG; + +function json(value, init = {}) { + return new Response(JSON.stringify(value), { + status: init.status || 200, + headers: { 'content-type': 'application/json', ...(init.headers || {}) } + }); +} + +globalThis.fetch = async (input, init = {}) => { + const url = String(input); + const body = typeof init.body === 'string' ? JSON.parse(init.body) : null; + const key = body?.jsonrpc + ? body.method === 'tools/call' + ? `mcp:${body.params.name}` + : `mcp:${body.method}` + : new URL(url).pathname; + fs.appendFileSync(logPath, `${JSON.stringify({ url, key, headers: init.headers || {}, body })}\n`); + + if (!(key in replies)) return json({ error: { message: `No test reply for ${key}` } }, { status: 500 }); + const reply = replies[key]; + if (!body?.jsonrpc) return json(reply.body, { status: reply.status || 200 }); + const result = key === 'mcp:initialize' + ? reply + : { content: [{ type: 'text', text: JSON.stringify(reply) }] }; + return json({ jsonrpc: '2.0', id: body.id, result }, { headers: { 'mcp-session-id': 'test-session' } }); +};