diff --git a/README.md b/README.md index be1e5ac..05afbc8 100644 --- a/README.md +++ b/README.md @@ -529,14 +529,14 @@ Commit `.agents/skills/one-connect` and the agent links so your team's agents sh ### `one connect` -Create and update a [One Connect](https://www.npmjs.com/package/@withone/connect) app from `one.connect.ts`, typed by `defineConnectApp` from `@withone/connect/config` (0.19.0 or later). +Create and update a [One Connect](https://www.npmjs.com/package/@withone/connect) app from `one.connect.ts`, typed by `defineConnectApp` from `@withone/connect/config` (0.20.0 or later). ```bash one connect types # writes one-connect-env.d.ts: tool slugs and action names autocomplete one connect create # approve the app in the browser; re-run to review and apply changes ``` -`one connect create` opens a setup page in your signed-in browser, where you review the app (or, on a re-run, its new version) and approve it; nothing is created or changed with your API key. A new app's client secret and connect key are made there and shown once, so they never pass through the terminal. Once you approve, the CLI writes `ONE_CLIENT_ID`, `ONE_PERMISSION_SET` and `ONE_REDIRECT_URI` to `.env.local` (or `.env`). Each tool in `ask` takes `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions find "" --no-knowledge`. Options: `--config `, `--env-file `, `--no-open`. Install `@withone/connect` (0.19.0 or later) in the project first. A `.ts` config needs Node 22.18 or later; on older Node use `one.connect.mjs`. +`one connect create` opens a setup page in your signed-in browser, where you review the app (or, on a re-run, its new version) and approve it; nothing is created or changed with your API key. A new app's client secret and connect key are made there and shown once, so they never pass through the terminal. Once you approve, the CLI writes `ONE_CLIENT_ID`, `ONE_REDIRECT_URI` and, for a list of tools, `ONE_PERMISSION_SET` to `.env.local` (or `.env`). `ask` is required: either the tools, where each takes `access` (`read-only`, `read-write`, `full-access`, or `custom` with `actions`, named as `one connect types` wrote them or by id from `one actions find "" --no-knowledge`), or `"everything"`, where users see every connection they have and can connect any other app. An app switched to `"everything"` loses its ask, and the CLI removes `ONE_PERMISSION_SET` from the env file. Options: `--config `, `--env-file `, `--no-open`. Install `@withone/connect` (0.20.0 or later) in the project first. A `.ts` config needs Node 22.18 or later; on older Node use `one.connect.mjs`. ### `one guide [topic]` diff --git a/package-lock.json b/package-lock.json index 8cdddd5..01b0365 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@withone/cli", - "version": "2.10.0", + "version": "2.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@withone/cli", - "version": "2.10.0", + "version": "2.11.0", "dependencies": { "@clack/prompts": "^0.9.1", "commander": "^13.1.0", diff --git a/package.json b/package.json index fd58ab3..6147e7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@withone/cli", - "version": "2.10.0", + "version": "2.11.0", "description": "CLI for managing One", "type": "module", "files": [ diff --git a/skills/one/SKILL.md b/skills/one/SKILL.md index 08468ef..fab6ca1 100644 --- a/skills/one/SKILL.md +++ b/skills/one/SKILL.md @@ -377,10 +377,10 @@ To create the Connect app itself without the dashboard, install `@withone/connec ```bash one --agent connect types # tool slugs and action names autocomplete in one.connect.ts -one --agent connect create # the human approves it in the browser; then writes ONE_CLIENT_ID, ONE_PERMISSION_SET, ONE_REDIRECT_URI to .env +one --agent connect create # the human approves it in the browser; then writes ONE_CLIENT_ID, ONE_REDIRECT_URI and (for a list of tools) ONE_PERMISSION_SET to .env ``` -It opens a setup page in the human's browser and waits up to 10 minutes, so run it in the background or with a long timeout; the link is printed to stderr at once, so pass it on if no browser opened. The human reviews the app (or, on a re-run, its new version) and approves it with their dashboard session: an API key never creates or changes a Connect app. For a new app the page also makes `ONE_CLIENT_SECRET` and `ONE_CONNECT_KEY` and shows them once; tell the human to copy them into the env file the result names. The CLI never receives either secret. Run `connect create` again after editing the file to update the same app. +It opens a setup page in the human's browser and waits up to 10 minutes, so run it in the background or with a long timeout; the link is printed to stderr at once, so pass it on if no browser opened. The human reviews the app (or, on a re-run, its new version) and approves it with their dashboard session: an API key never creates or changes a Connect app. For a new app the page also makes `ONE_CLIENT_SECRET` and `ONE_CONNECT_KEY` and shows them once; tell the human to copy them into the env file the result names. The CLI never receives either secret. Run `connect create` again after editing the file to update the same app. `ask` is required: the tools the app asks for, or `"everything"` (users see every connection they have and can connect any other app; the CLI then removes `ONE_PERMISSION_SET` from the env file). ## Adding New Connections diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 937403b..3ecf6b0 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -11,6 +11,7 @@ import { ensureWhoAmI, getApiBase, getApiKey } from '../lib/config.js'; import { TYPES_FILE, actionNames, + askedTools, buildHandoff, connectorRule, encodeHandoff, @@ -20,6 +21,7 @@ import { findConfigFile, loadConfigFile, readEnvValue, + removeEnvKeys, renderTypes, resolveActions, upsertEnv, @@ -64,7 +66,7 @@ function message(error: unknown): string { /** The ask as connector rules: slugs to catalog ids, action names to ids. */ async function buildRules(api: OneApi, config: ConnectAppConfig): Promise { - const asked = Object.entries(config.ask ?? {}); + const asked = askedTools(config); if (asked.length === 0) return []; const platforms = await api.listPlatforms(); @@ -166,12 +168,15 @@ export async function connectCreateCommand(options: { config?: string; envFile?: if (outcome.kind === 'failed') output.error(`The setup page reported: ${outcome.reason}`); const created = outcome.outcome === 'created'; + // An app that asks for everything sends no permission set, so a set left + // in the file would keep it asking for the old tools. + const asksForEverything = config.ask === 'everything'; fs.writeFileSync( envFile, - upsertEnv(envText, { + upsertEnv(asksForEverything ? removeEnvKeys(envText, ['ONE_PERMISSION_SET']) : envText, { ONE_CLIENT_ID: outcome.clientId, ONE_REDIRECT_URI: config.redirectUris[0], - ...(outcome.permissionSetId ? { ONE_PERMISSION_SET: outcome.permissionSetId } : {}), + ...(!asksForEverything && outcome.permissionSetId ? { ONE_PERMISSION_SET: outcome.permissionSetId } : {}), ...(created ? { ONE_CLIENT_SECRET: null, ONE_CONNECT_KEY: null } : {}), }) ); @@ -179,7 +184,8 @@ export async function connectCreateCommand(options: { config?: string; envFile?: const result = { outcome: outcome.outcome, clientId: outcome.clientId, - permissionSetId: outcome.permissionSetId, + permissionSetId: asksForEverything ? null : outcome.permissionSetId, + asksForEverything, redirectUris: config.redirectUris, envFile: path.relative(cwd, envFile) || envFile, }; @@ -193,7 +199,8 @@ export async function connectCreateCommand(options: { config?: string; envFile?: ); console.log(); console.log(` ${pc.dim('Client ID:')} ${outcome.clientId}`); - if (outcome.permissionSetId) console.log(` ${pc.dim('Permission set:')} ${outcome.permissionSetId}`); + if (asksForEverything) console.log(` ${pc.dim('Asks for:')} everything the user has connected`); + else if (outcome.permissionSetId) console.log(` ${pc.dim('Permission set:')} ${outcome.permissionSetId}`); console.log(` ${pc.dim('Wrote')} ${result.envFile}`); if (created) { console.log(` ${pc.dim('The two secrets came from the browser page, never through here.')}`); @@ -217,7 +224,7 @@ export async function connectTypesCommand(options: { config?: string }): Promise try { const file = findConfigFile(cwd, options.config); const config = file ? validateConfig(await loadConfigFile(file)) : null; - const tools = Object.keys(config && 'config' in config ? config.config.ask ?? {} : {}); + const tools = config && 'config' in config ? askedTools(config.config).map(([slug]) => slug) : []; const platforms = await api.listPlatforms(); const known = new Set(platforms.map((p) => p.platform)); diff --git a/src/lib/connect-app.test.ts b/src/lib/connect-app.test.ts index b6e7c53..88703fc 100644 --- a/src/lib/connect-app.test.ts +++ b/src/lib/connect-app.test.ts @@ -16,6 +16,7 @@ import { readEnvValue, renderTypes, resolveActions, + removeEnvKeys, upsertEnv, validateConfig, } from './connect-app.js'; @@ -86,6 +87,7 @@ describe('clientFields', () => { redirectUris: ['http://localhost:3000/api/one/callback'], reason: 'Mastra plans your day', branding: { panelColor: '#A02235', buttonColor: '#6A2557' }, + ask: 'everything', }), { name: 'Mastra', @@ -107,6 +109,20 @@ describe('validateConfig', () => { assert.ok('config' in result); }); + it('accepts an app that asks for everything', () => { + const result = validateConfig({ name: 'Agent', redirectUris: ['http://localhost:3000/cb'], ask: 'everything' }); + assert.ok('config' in result); + }); + + it('needs ask: a map of tools or "everything"', () => { + const base = { name: 'Agent', redirectUris: ['http://localhost:3000/cb'] }; + for (const ask of [undefined, 'all', {}, []]) { + const result = validateConfig({ ...base, ask }); + assert.ok('errors' in result, `ask ${JSON.stringify(ask)} should be refused`); + assert.match(result.errors[0], /ask/); + } + }); + it('lists every problem at once', () => { const result = validateConfig({ name: '', @@ -119,6 +135,17 @@ describe('validateConfig', () => { }); }); +describe('removeEnvKeys', () => { + it('drops the key and keeps every other line', () => { + assert.equal( + removeEnvKeys('# app\nONE_CLIENT_ID=a\nONE_PERMISSION_SET=ps-1\nDATABASE_URL=x\n', ['ONE_PERMISSION_SET']), + '# app\nONE_CLIENT_ID=a\nDATABASE_URL=x\n' + ); + assert.equal(removeEnvKeys('ONE_CLIENT_ID=a\nONE_PERMISSION_SET=ps-1', ['ONE_PERMISSION_SET']), 'ONE_CLIENT_ID=a\n'); + assert.equal(removeEnvKeys('ONE_CLIENT_ID=a\n', ['ONE_PERMISSION_SET']), 'ONE_CLIENT_ID=a\n'); + }); +}); + describe('upsertEnv', () => { it('replaces in place, keeps other lines, and appends what is missing', () => { const before = '# app\nDATABASE_URL=postgres://x\nONE_CLIENT_ID=old\n'; @@ -208,18 +235,21 @@ describe('handoff', () => { reason: 'Plans your day', }; - it('carries the app fields and the resolved ask, or null for no ask', () => { + it('carries the app fields and the resolved tools', () => { const rule = connectorRule(7, { access: 'read-only' }); - assert.deepEqual(buildHandoff(config, [rule]), { - v: 1, + assert.deepEqual(buildHandoff({ ...config, ask: { linear: { access: 'read-only' } } }, [rule]), { + v: 2, app: { name: 'Mastra', redirectUris: config.redirectUris, consentReason: 'Plans your day' }, - ask: [rule], + ask: { kind: 'tools', rules: [rule] }, }); - assert.equal(buildHandoff(config, []).ask, null); + }); + + it('says so when the app asks for everything', () => { + assert.deepEqual(buildHandoff({ ...config, ask: 'everything' }, []).ask, { kind: 'everything' }); }); it('encodes as deflated base64url JSON that inflates back', () => { - const handoff = buildHandoff(config, []); + const handoff = buildHandoff({ ...config, ask: 'everything' }, []); const encoded = encodeHandoff(handoff); assert.match(encoded, /^[A-Za-z0-9_-]+$/); const json = zlib.inflateRawSync(Buffer.from(encoded, 'base64url')).toString('utf8'); @@ -228,7 +258,9 @@ describe('handoff', () => { it('roughly halves a long list of action ids', () => { const ids = Array.from({ length: 200 }, (_, i) => `conn_mod_def::GJ7Km${i}Q::C4xVtTCm${i}TjeKrcf-4UV9Iw`); - const handoff = buildHandoff(config, [connectorRule(7, { access: 'custom', actions: ids }, ids)]); + const handoff = buildHandoff({ ...config, ask: { linear: { access: 'custom', actions: ids } } }, [ + connectorRule(7, { access: 'custom', actions: ids }, ids), + ]); const plain = Buffer.from(JSON.stringify(handoff)).toString('base64url').length; assert.ok(encodeHandoff(handoff).length < plain / 2); }); diff --git a/src/lib/connect-app.ts b/src/lib/connect-app.ts index d57f8fe..059f318 100644 --- a/src/lib/connect-app.ts +++ b/src/lib/connect-app.ts @@ -27,7 +27,13 @@ export interface ConnectAppConfig { homepageUrl?: string; reason?: string; branding?: { panelColor: string; buttonColor: string; cobrandEmail?: boolean }; - ask?: Record; + /** The tools the app asks for, or everything its users have. */ + ask: Record | 'everything'; +} + +/** The tools a config names; none when it asks for everything. */ +export function askedTools(config: Pick): Array<[string, ToolAsk]> { + return config.ask === 'everything' ? [] : Object.entries(config.ask ?? {}); } /** The dashboard's mapping from a level to the methods a rule admits @@ -112,7 +118,18 @@ export function validateConfig(value: unknown): { config: ConnectAppConfig } | { if (!HEX_COLOR.test(String(v.branding[key] ?? ''))) errors.push(`\`branding.${key}\` must be a #RRGGBB colour.`); } } - for (const [tool, ask] of Object.entries(v.ask ?? {})) { + const asked = v.ask as unknown; + if (asked === undefined) { + errors.push('`ask` is required: name the tools the app asks for, or write `ask: "everything"`.'); + } else if (typeof asked === 'string') { + if (asked !== 'everything') errors.push(`\`ask\` is "${asked}"; it must be a map of tools or "everything".`); + } else if (!asked || typeof asked !== 'object' || Array.isArray(asked)) { + errors.push('`ask` must be a map of tools or "everything".'); + } else if (Object.keys(asked).length === 0) { + errors.push('`ask` names no tools. Name at least one, or write `ask: "everything"`.'); + } + const tools = asked && typeof asked === 'object' && !Array.isArray(asked) ? Object.entries(asked) : []; + for (const [tool, ask] of tools) { const access = (ask as { access?: unknown })?.access; if (access === 'custom') { const actions = (ask as { actions?: unknown }).actions; @@ -205,17 +222,27 @@ export function clientFields(config: ConnectAppConfig): Record * What `one connect create` hands the setup page: the app's fields and its * ask, already resolved to catalog ids. The page shows it for review and * writes it with the person's dashboard session, so an API key never - * creates or changes a Connect app. `ask` is null when the config asks for - * nothing, which leaves an existing ask as it is. + * creates or changes a Connect app. + * + * Version 2 says what the app asks for either way: the tools as rules, or + * everything (no ask of its own, so users see every connection they have). + * Version 1 sent `ask: null` for a config without tools, which the page + * read as "leave the ask alone"; it still accepts that from older CLIs. */ +export type HandoffAsk = { kind: 'everything' } | { kind: 'tools'; rules: ConnectorRule[] }; + export interface ConnectHandoff { - v: 1; + v: 2; app: Record; - ask: ConnectorRule[] | null; + ask: HandoffAsk; } export function buildHandoff(config: ConnectAppConfig, rules: ConnectorRule[]): ConnectHandoff { - return { v: 1, app: clientFields(config), ask: rules.length > 0 ? rules : null }; + return { + v: 2, + app: clientFields(config), + ask: config.ask === 'everything' ? { kind: 'everything' } : { kind: 'tools', rules }, + }; } /** @@ -268,6 +295,11 @@ export function upsertEnv(text: string, values: Record): return `${next}${separator}${appended.join('\n')}\n`; } +/** Drops each key's line, keeping every other line and comment. */ +export function removeEnvKeys(text: string, keys: string[]): string { + return keys.reduce((next, key) => next.replace(new RegExp(`^${key}=.*(?:\r?\n|$)`, 'm'), ''), text); +} + /** The declaration file `one connect types` writes. */ export function renderTypes(tools: string[], actionsByTool: Record): string { const literal = (value: string) => JSON.stringify(value); diff --git a/src/lib/guide-content.ts b/src/lib/guide-content.ts index 3e30c83..e746116 100644 --- a/src/lib/guide-content.ts +++ b/src/lib/guide-content.ts @@ -114,7 +114,7 @@ one --agent relay deliveries --endpoint-id # Check delivery - Use \`actions find\` (and \`actions load\` for more) to learn both the incoming payload shape AND the destination API shape before building templates ### One Connect apps — created from \`one.connect.ts\` -\`one connect create\` reads \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\` (install \`@withone/connect\` 0.19.0 or later in the project first), and opens a setup page where the human reviews the app (or, once .env holds its \`ONE_CLIENT_ID\`, its new version) and approves it with their dashboard session; an API key never creates or changes a Connect app. A new app's client secret and connect key are made and shown there once; the CLI never receives them. It waits up to 10 minutes, then writes \`ONE_CLIENT_ID\`, \`ONE_PERMISSION_SET\` and \`ONE_REDIRECT_URI\` to the env file. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. Each tool's \`access\` is \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`). +\`one connect create\` reads \`one.connect.ts\`, typed by \`defineConnectApp\` from \`@withone/connect/config\` (install \`@withone/connect\` 0.20.0 or later in the project first), and opens a setup page where the human reviews the app (or, once .env holds its \`ONE_CLIENT_ID\`, its new version) and approves it with their dashboard session; an API key never creates or changes a Connect app. A new app's client secret and connect key are made and shown there once; the CLI never receives them. It waits up to 10 minutes, then writes \`ONE_CLIENT_ID\`, \`ONE_REDIRECT_URI\` and, for a list of tools, \`ONE_PERMISSION_SET\` to the env file. \`one connect types\` writes \`one-connect-env.d.ts\` so tool slugs and action names autocomplete. \`ask\` is required: the tools, each with \`access\` \`read-only\`, \`read-write\`, \`full-access\`, or \`custom\` with \`actions\` (names, or ids from \`actions find\`), or \`"everything"\`, which asks for every connection the user has and removes \`ONE_PERMISSION_SET\` from the env file. ### 4. Memory + Sync — Unified store with hybrid FTS + semantic search One ships a local memory store (a real Postgres process bootstrapped on demand via the bundled \`embedded-postgres\` plugin, with a \`postgres\` plugin for remote/self-hosted) that backs both user-authored notes (\`one mem add\`) and synced platform data (\`one sync run\`). Auto-initializes on first use — no separate install step. Run \`one guide memory\` and \`one guide sync\` for the full references.