From 02fa4bf4e52254fa6ede850797fa2fa8f96a6322 Mon Sep 17 00:00:00 2001 From: chrisjleal <184772742+chrisjleal@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:03:04 -0500 Subject: [PATCH 1/4] import creates the agent in one step A signed-in `proofable import` now previews the source, creates the agent with proofable_agent_create using the import's full record (instructions travel in the request body), defaults permissions to 30 days unless --expires-at is passed, and mounts it for the detected host unless --no-mount. It prints the agent, what it may do, the expiry and its page. A terminal that is not signed in is sent to the browser import. When the agent must finish in the browser and the link cannot carry the instructions, the CLI says so. `owner/name` reads a GitHub repository and --dry-run stops before anything is created. --- CHANGELOG.md | 8 + README.md | 3 + cli/proofable.mjs | 238 +++++++++++++++++++++++++--- test/cli.test.js | 200 ++++++++++++++++++++++- test/fixtures/import-fetch-mock.mjs | 33 ++++ 5 files changed, 460 insertions(+), 22 deletions(-) create mode 100644 test/fixtures/import-fetch-mock.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index ca297b2..fa5fac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ Product release notes: [docs.proofable.me/changelog](https://docs.proofable.me/c ## [Unreleased] +### 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`. Permissions expire in 30 days unless you pass `--expires-at `; `--no-mount` skips the mount. It prints the agent's name, what it may do, when that expires, and its agent page. `owner/name` reads a GitHub repository, and `--dry-run` shows what would happen without creating anything. + +### Fixed + +- **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 diff --git a/README.md b/README.md index d7c67dd..703b016 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,13 @@ Interactive clients add `https://mcp.proofable.me/mcp`, click **Connect**, and s ```bash npx -y @proofable/sdk setup npx -y @proofable/sdk setup --access-key $PROOFABLE_ACCESS_KEY +npx -y @proofable/sdk import npx -y @proofable/sdk mount --apply npx -y @proofable/sdk doctor --live ``` +`import` brings an agent you already run: a GitHub repository, a folder, an MCP server URL, or an A2A agent card. One command creates it on your profile and mounts it into the current project. Its permissions expire in 30 days; change that with `--expires-at `, or skip the mount with `--no-mount`. A terminal that is not signed in gets a browser link instead; sign it in with `npx -y @proofable/sdk auth --oauth`. + `--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). ## Core methods diff --git a/cli/proofable.mjs b/cli/proofable.mjs index 702147a..73a4daa 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: 30 days from now)', + ' --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,101 @@ async function postAgentImport(pathname, body, accessKey, signal) { return json; } +// An imported agent's permissions end after 30 days unless --expires-at says otherwise, +// the same default as the agent composer on proofable.me. +const IMPORT_DEFAULT_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; +const IMPORT_BROWSER_URL = `${PROOFABLE_APP_URL}/profile/agents/new?import=1`; + +/** --expires-at as Unix ms; 0 means the permissions do not expire. */ +function resolveImportExpiresAt(raw, now = Date.now()) { + const value = String(raw || '').trim(); + if (!value) return now + IMPORT_DEFAULT_EXPIRY_MS; + if (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 +2126,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 with permissions that expire: ${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 { diff --git a/test/cli.test.js b/test/cli.test.js index 4d5d7b1..3a3bb97 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,203 @@ 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 DAY_MS = 24 * 60 * 60 * 1000; + 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 a 30-day expiry, then mounts it', async () => { + const expiresAt = Date.now() + 30 * DAY_MS; + const context = await makeImportContext({ replies: importReplies({ bundle: mountedBundle(expiresAt) }) }); + const before = Date.now(); + const { stderr } = await runImportCli(['agent-src'], context); + const after = Date.now(); + + 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).toBeGreaterThanOrEqual(before + 30 * DAY_MS); + expect(sentExpiresAt).toBeLessThanOrEqual(after + 30 * DAY_MS); + + 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: ${new Date(expiresAt).toISOString().slice(0, 10)}`); + 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 context = await makeImportContext({ replies: importReplies({ bundle: mountedBundle(0) }) }); + const { stdout } = await runImportCli(['agent-src', '--expires-at', 'never', '--no-mount', '--json'], context); + + const create = (await readFetchLog(context)).find(call => call.key === 'mcp:proofable_agent_create'); + expect(create.body.params.arguments.expiresAt).toBe(0); + const payload = JSON.parse(stdout); + expect(payload.created).toBe(true); + 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', '--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); + }); +}); 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' } }); +}; From 1ce6e1cbeb32a7175cbd6876c197fbbe3308e4a0 Mon Sep 17 00:00:00 2001 From: chrisjleal <184772742+chrisjleal@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:12:06 -0500 Subject: [PATCH 2/4] Imported agents never expire by default; README starts with the agent path `proofable import` now creates agents whose permissions do not expire. `--expires-at ` sets an end when you want one, and the help text, tests and changelog say so. The README opens with the fastest way to a working agent: connect and sign in, import a repository or folder, then use it. It includes one prompt a coding agent can paste to set itself up. App checks follow. --- CHANGELOG.md | 2 +- README.md | 55 ++++++++++++++++++++++++++++++++++++++++------- cli/proofable.mjs | 17 +++++++-------- test/cli.test.js | 24 ++++++++++----------- 4 files changed, 67 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa5fac5..c72c433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Product release notes: [docs.proofable.me/changelog](https://docs.proofable.me/c ### 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`. Permissions expire in 30 days unless you pass `--expires-at `; `--no-mount` skips the mount. It prints the agent's name, what it may do, when that expires, and its agent page. `owner/name` reads a GitHub repository, and `--dry-run` shows what would happen without creating anything. +- **`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 diff --git a/README.md b/README.md index 703b016..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,16 +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 +npx -y @proofable/sdk import --dry-run npx -y @proofable/sdk mount --apply npx -y @proofable/sdk doctor --live ``` -`import` brings an agent you already run: a GitHub repository, a folder, an MCP server URL, or an A2A agent card. One command creates it on your profile and mounts it into the current project. Its permissions expire in 30 days; change that with `--expires-at `, or skip the mount with `--no-mount`. A terminal that is not signed in gets a browser link instead; sign it in with `npx -y @proofable/sdk auth --oauth`. +`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. -`--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). +`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 73a4daa..b1226d5 100644 --- a/cli/proofable.mjs +++ b/cli/proofable.mjs @@ -1132,7 +1132,7 @@ 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: 30 days from now)', + ' --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' @@ -1951,7 +1951,7 @@ function collectImportFiles(dir, into, root = dir) { } const IMPORT_USAGE = - 'Usage: proofable import [--apply ] [--expires-at ] [--no-mount] [--dry-run] [--json]'; + 'Usage: proofable import [--apply ] [--expires-at ] [--no-mount] [--dry-run] [--json]'; function readImportSource(target) { const value = String(target || '').trim(); @@ -2020,16 +2020,15 @@ async function postAgentImport(pathname, body, accessKey, signal) { return json; } -// An imported agent's permissions end after 30 days unless --expires-at says otherwise, -// the same default as the agent composer on proofable.me. -const IMPORT_DEFAULT_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; const IMPORT_BROWSER_URL = `${PROOFABLE_APP_URL}/profile/agents/new?import=1`; -/** --expires-at as Unix ms; 0 means the permissions do not expire. */ +/** + * --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) return now + IMPORT_DEFAULT_EXPIRY_MS; - if (value === '0' || value.toLowerCase() === 'never') return 0; + 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.'); @@ -2146,7 +2145,7 @@ async function runImport(options) { emitCliBanner(options); writeCliLine(paint('import', 'green')); logStep('ok', 'agent', agentLabel); - writeGuidanceLine(`Would create it with permissions that expire: ${formatImportExpiry(expiresAt)}`); + writeGuidanceLine(`Would create it. Expires: ${formatImportExpiry(expiresAt)}`); if (mountTarget) { writeGuidanceLine(applyHost ? `Would mount it for ${applyHost}` : `Would write ${MOUNT_MANIFEST_RELATIVE}`); } diff --git a/test/cli.test.js b/test/cli.test.js index 3a3bb97..0499c64 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -776,7 +776,6 @@ describe('proofable CLI', () => { }); describe('proofable import', () => { - const DAY_MS = 24 * 60 * 60 * 1000; 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; @@ -883,12 +882,9 @@ describe('proofable import', () => { return raw.split('\n').filter(Boolean).map(line => JSON.parse(line)); } - it('creates the agent with the full import record and a 30-day expiry, then mounts it', async () => { - const expiresAt = Date.now() + 30 * DAY_MS; - const context = await makeImportContext({ replies: importReplies({ bundle: mountedBundle(expiresAt) }) }); - const before = Date.now(); + 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 after = Date.now(); const calls = await readFetchLog(context); const create = calls.find(call => call.key === 'mcp:proofable_agent_create'); @@ -897,26 +893,27 @@ describe('proofable import', () => { const { expiresAt: sentExpiresAt, ...sentArgs } = create.body.params.arguments; expect(sentArgs).toEqual(importCommit().mcpCreateArgs); expect(sentArgs.instructions).toHaveLength(LONG_INSTRUCTIONS.length); - expect(sentExpiresAt).toBeGreaterThanOrEqual(before + 30 * DAY_MS); - expect(sentExpiresAt).toBeLessThanOrEqual(after + 30 * DAY_MS); + 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: ${new Date(expiresAt).toISOString().slice(0, 10)}`); + 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 context = await makeImportContext({ replies: importReplies({ bundle: mountedBundle(0) }) }); - const { stdout } = await runImportCli(['agent-src', '--expires-at', 'never', '--no-mount', '--json'], context); + 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(0); + 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(); }); @@ -959,7 +956,7 @@ describe('proofable import', () => { it('previews without creating anything on --dry-run', async () => { const context = await makeImportContext(); - const { stdout } = await runImportCli(['agent-src', '--dry-run', '--json'], context); + 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']); @@ -967,5 +964,6 @@ describe('proofable import', () => { expect(payload.dryRun).toBe(true); expect(payload.created).toBe(false); expect(payload.createArgs.instructions).toBe(LONG_INSTRUCTIONS); + expect(payload.createArgs.expiresAt).toBe(0); }); }); From 9487292546f894e09e21df7360e69e559f2c365d Mon Sep 17 00:00:00 2001 From: chrisjleal <184772742+chrisjleal@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:34:04 -0500 Subject: [PATCH 3/4] auth --oauth signs in the CLI and leaves editors on their own sign-in Writing the short-lived token into Cursor, VS Code or Claude replaced the sign-in setup gave them and broke when it expired. Naming an editor with --client still writes it there. --- CHANGELOG.md | 1 + cli/proofable.mjs | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c72c433..f57e3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Product release notes: [docs.proofable.me/changelog](https://docs.proofable.me/c ### 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 diff --git a/cli/proofable.mjs b/cli/proofable.mjs index b1226d5..f807361 100644 --- a/cli/proofable.mjs +++ b/cli/proofable.mjs @@ -2398,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(); From 79bf7469c4269d9d0c4f684173071d8c7f3596bb Mon Sep 17 00:00:00 2001 From: chrisjleal <184772742+chrisjleal@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:19:33 -0500 Subject: [PATCH 4/4] Release 0.1.10 `proofable import` creates the agent in one step and mounts it: identity plus delegation, default permissions, and no expiry unless `--expires-at` sets one. Imported agents never expire by default. `proofable auth --oauth` signs in the CLI without writing its short-lived token into editor configs (Cursor, VS Code, Claude) unless an editor is named with `--client`. --- CHANGELOG.md | 5 ++++- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f57e3ca..0d9af85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ 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. @@ -183,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/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"