Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <platform> "<what it does>" --no-knowledge`. Options: `--config <file>`, `--env-file <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 <platform> "<what it does>" --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 <file>`, `--env-file <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]`

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@withone/cli",
"version": "2.10.0",
"version": "2.11.0",
"description": "CLI for managing One",
"type": "module",
"files": [
Expand Down
4 changes: 2 additions & 2 deletions skills/one/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 13 additions & 6 deletions src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ensureWhoAmI, getApiBase, getApiKey } from '../lib/config.js';
import {
TYPES_FILE,
actionNames,
askedTools,
buildHandoff,
connectorRule,
encodeHandoff,
Expand All @@ -20,6 +21,7 @@ import {
findConfigFile,
loadConfigFile,
readEnvValue,
removeEnvKeys,
renderTypes,
resolveActions,
upsertEnv,
Expand Down Expand Up @@ -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<ConnectorRule[]> {
const asked = Object.entries(config.ask ?? {});
const asked = askedTools(config);
if (asked.length === 0) return [];

const platforms = await api.listPlatforms();
Expand Down Expand Up @@ -166,20 +168,24 @@ 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 } : {}),
})
);

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,
};
Expand All @@ -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.')}`);
Expand All @@ -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));
Expand Down
46 changes: 39 additions & 7 deletions src/lib/connect-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
readEnvValue,
renderTypes,
resolveActions,
removeEnvKeys,
upsertEnv,
validateConfig,
} from './connect-app.js';
Expand Down Expand Up @@ -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',
Expand All @@ -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: '',
Expand All @@ -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';
Expand Down Expand Up @@ -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');
Expand All @@ -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);
});
Expand Down
46 changes: 39 additions & 7 deletions src/lib/connect-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ export interface ConnectAppConfig {
homepageUrl?: string;
reason?: string;
branding?: { panelColor: string; buttonColor: string; cobrandEmail?: boolean };
ask?: Record<string, ToolAsk>;
/** The tools the app asks for, or everything its users have. */
ask: Record<string, ToolAsk> | 'everything';
}

/** The tools a config names; none when it asks for everything. */
export function askedTools(config: Pick<ConnectAppConfig, 'ask'>): Array<[string, ToolAsk]> {
return config.ask === 'everything' ? [] : Object.entries(config.ask ?? {});
}

/** The dashboard's mapping from a level to the methods a rule admits
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -205,17 +222,27 @@ export function clientFields(config: ConnectAppConfig): Record<string, unknown>
* 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<string, unknown>;
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 },
};
}

/**
Expand Down Expand Up @@ -268,6 +295,11 @@ export function upsertEnv(text: string, values: Record<string, string | null>):
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, string[]>): string {
const literal = (value: string) => JSON.stringify(value);
Expand Down
2 changes: 1 addition & 1 deletion src/lib/guide-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ one --agent relay deliveries --endpoint-id <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.
Expand Down
Loading