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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,17 @@ What `one skills add connect` does:

Commit `.agents/skills/one-connect` and the agent links so your team's agents share the skill. Agent mode (`--agent`) returns JSON for every subcommand.

### `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).

```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 guide [topic]`

Get the full CLI usage guide, designed for AI agents that only have the binary (no MCP, no IDE skills).
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.9.2",
"version": "2.10.0",
"description": "CLI for managing One",
"type": "module",
"files": [
Expand Down
9 changes: 9 additions & 0 deletions skills/one/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,15 @@ one --agent skills add connect # from the @withone/connect version the app

It lands in `.agents/skills/one-connect` (and `.claude/skills/one-connect`). The result's `next` array says what to do; the skill covers the button, the two routes, key mode vs token mode, and calling One with the grant. Never ask the user to paste the client secret or connect key into chat.

To create the Connect app itself without the dashboard, install `@withone/connect` in the project (0.19.0 or later; the file imports it from there, not from the CLI), write `one.connect.ts` with `defineConnectApp` from `@withone/connect/config` (name, `redirectUris`, `ask` per tool with `access`: `read-only`, `read-write`, `full-access`, or `custom` with `actions`), then:

```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
```

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.

## Adding New Connections

If the user needs a platform that isn't connected yet, tell them to run:
Expand Down
29 changes: 29 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
relayEventTypesCommand,
relayPlatformsCommand,
} from './commands/relay.js';
import { connectCreateCommand, connectTypesCommand } from './commands/connect.js';

import { registerSyncCommands } from './lib/memory/sync/index.js';
import { registerMemoryCommands } from './commands/mem.js';
Expand Down Expand Up @@ -95,6 +96,10 @@ program
one skills update [connect] Re-copy from the installed @withone/connect after an upgrade
one skills remove connect Remove it

Connect apps (one.connect.ts, typed by @withone/connect/config):
one connect create Approve the app (or its new version) in the browser, then write .env
one connect types Write one-connect-env.d.ts so tool slugs and action names autocomplete

Guide:
one guide [topic] Full CLI guide (topics: overview, actions, workflows, memory, sync, all)

Expand Down Expand Up @@ -658,6 +663,30 @@ flow
await flowScaffoldCommand(template);
});

// ── Connect App Commands ──

const connectApp = program
.command('connect')
.description('Create and update a One Connect app from one.connect.ts');

connectApp
.command('create')
.description('Open the setup page to approve the app (or, once .env holds its ONE_CLIENT_ID, its new version); a new app gets its two secrets there. Then write .env')
.option('--config <file>', 'Config file (default: one.connect.ts, .mts, .mjs or .js in this directory)')
.option('--env-file <file>', 'Env file to write (default: .env.local if it exists, else .env)')
.option('--no-open', 'Print the setup page link instead of opening it')
.action(async (options: { config?: string; envFile?: string; open?: boolean }) => {
await connectCreateCommand(options);
});

connectApp
.command('types')
.description('Write one-connect-env.d.ts: every tool slug, and action names for the tools the config asks for')
.option('--config <file>', 'Config file (default: one.connect.ts, .mts, .mjs or .js in this directory)')
.action(async (options: { config?: string }) => {
await connectTypesCommand(options);
});

// ── Relay Commands ──

const relay = program
Expand Down
242 changes: 242 additions & 0 deletions src/commands/connect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import open from 'open';
import pc from 'picocolors';
import { OneApi } from '../lib/api.js';
import { getConnectDoneUrl, getConnectSetupUrl } from '../lib/browser.js';
import { startConnectCallbackServer } from '../lib/connect-callback.js';
import { ensureWhoAmI, getApiBase, getApiKey } from '../lib/config.js';
import {
TYPES_FILE,
actionNames,
buildHandoff,
connectorRule,
encodeHandoff,
MAX_HANDOFF_LENGTH,
MAX_WINDOWS_OPEN_LENGTH,
defaultEnvFile,
findConfigFile,
loadConfigFile,
readEnvValue,
renderTypes,
resolveActions,
upsertEnv,
validateConfig,
type ConnectAppConfig,
type ConnectorRule,
} from '../lib/connect-app.js';
import * as output from '../lib/output.js';

/** As long as the person may need to read and approve the page. */
const SETUP_TIMEOUT_MS = 10 * 60 * 1000;

function requireApi(): OneApi {
const apiKey = getApiKey();
if (!apiKey) output.error('Not configured. Run `one init` first.');
return new OneApi(apiKey, getApiBase());
}

async function readConfig(cwd: string, explicit?: string): Promise<{ file: string; config: ConnectAppConfig }> {
const file = findConfigFile(cwd, explicit);
if (!file) {
output.error(
explicit
? `No config at ${explicit}.`
: 'No one.connect.ts here. Create one with defineConnectApp from @withone/connect/config.'
);
}
let loaded: unknown;
try {
loaded = await loadConfigFile(file);
} catch (error) {
output.error(`Couldn't load ${path.basename(file)}: ${error instanceof Error ? error.message : String(error)}`);
}
const checked = validateConfig(loaded);
if ('errors' in checked) output.error(`${path.basename(file)} has problems:\n ${checked.errors.join('\n ')}`);
return { file, config: checked.config };
}

function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

/** 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 ?? {});
if (asked.length === 0) return [];

const platforms = await api.listPlatforms();
const idBySlug = new Map(platforms.map((p) => [p.platform, p.id]));
const unknownTools = asked.map(([slug]) => slug).filter((slug) => !idBySlug.has(slug));
if (unknownTools.length > 0) {
output.error(`Not in One's catalog: ${unknownTools.join(', ')}. Run \`one platforms\` for the slugs.`);
}

const rules: ConnectorRule[] = [];
for (const [slug, ask] of asked) {
const definitionId = idBySlug.get(slug)!;
if (ask.access !== 'custom') {
rules.push(connectorRule(definitionId, ask));
continue;
}
const actions = await api.listAllAvailableActions(slug);
const resolved = resolveActions(ask.actions, actionNames(actions), new Set(actions.map((a) => a.systemId)));
if ('unknown' in resolved) {
output.error(
`${slug} has no action named ${resolved.unknown.join(', ')}. ` +
`Run \`one connect types\` for the names, or \`one actions find ${slug} "<what it does>" --no-knowledge\` for an id.`
);
}
rules.push(connectorRule(definitionId, ask, resolved.ids));
}
return rules;
}

export async function connectCreateCommand(options: { config?: string; envFile?: string; open?: boolean }): Promise<void> {
const cwd = process.cwd();
const api = requireApi();
const { file, config } = await readConfig(cwd, options.config);
const envFile = options.envFile ? path.resolve(cwd, options.envFile) : defaultEnvFile(cwd);
const envText = fs.existsSync(envFile) ? fs.readFileSync(envFile, 'utf8') : '';
const existingId = readEnvValue(envText, 'ONE_CLIENT_ID') || undefined;

const spinner = output.createSpinner();
spinner.start(`Reading ${path.basename(file)}...`);

let server: http.Server | undefined;
let timer: NodeJS.Timeout | undefined;
try {
const rules = await buildRules(api, config);
const whoami = await ensureWhoAmI(api);
const scope = {
orgId: whoami?.organization?.id,
projectId: whoami?.project?.id,
envFile: path.basename(envFile),
};

const handoff = encodeHandoff(buildHandoff(config, rules));
if (handoff.length > MAX_HANDOFF_LENGTH) {
output.error(
`${path.basename(file)} is too big to hand to the browser (${Math.ceil(handoff.length / 1024)} KB compressed, ` +
`limit ${Math.floor(MAX_HANDOFF_LENGTH / 1024)} KB). Give tools with long custom lists an access level instead.`
);
}

const state = crypto.randomBytes(16).toString('hex');
const started = await startConnectCallbackServer(state, (done) =>
getConnectDoneUrl(done.clientId, done.outcome, scope)
);
server = started.server;
const url = getConnectSetupUrl({
...scope,
clientId: existingId,
permissionSetId: readEnvValue(envText, 'ONE_PERMISSION_SET') || undefined,
port: started.port,
state,
handoff,
});

// Windows opens links through PowerShell, which can't take a very long
// one; the person opens the printed link instead.
const tooLongToOpen = process.platform === 'win32' && url.length > MAX_WINDOWS_OPEN_LENGTH;
const opening = options.open !== false && !tooLongToOpen;
if (opening) await open(url).catch(() => undefined);
const verb = existingId ? 'Approve the update' : 'Create the app';
if (output.isAgentMode()) {
// The JSON result comes only once the person approves; the link goes
// out now so an agent can hand it over if no browser opened.
process.stderr.write(`${verb} in the browser: ${url}\n`);
} else {
spinner.stop(opening ? `${verb} in your browser` : `${verb}: open this link in your browser`);
console.log(` ${pc.underline(url)}`);
console.log();
spinner.start('Waiting for you to approve it...');
}

const outcome = await Promise.race([
started.result,
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), SETUP_TIMEOUT_MS);
}),
]);
if (!outcome) output.error('Timed out waiting for the browser. Run `one connect create` again.');
if (outcome.kind === 'cancelled') output.error('Cancelled in the browser. Nothing was changed.');
if (outcome.kind === 'failed') output.error(`The setup page reported: ${outcome.reason}`);

const created = outcome.outcome === 'created';
fs.writeFileSync(
envFile,
upsertEnv(envText, {
ONE_CLIENT_ID: outcome.clientId,
ONE_REDIRECT_URI: config.redirectUris[0],
...(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,
redirectUris: config.redirectUris,
envFile: path.relative(cwd, envFile) || envFile,
};
if (output.isAgentMode()) {
output.json(result);
return;
}

spinner.stop(
created ? `Created ${config.name}` : outcome.outcome === 'updated' ? `Updated ${config.name}` : `${config.name} is up to date`
);
console.log();
console.log(` ${pc.dim('Client ID:')} ${outcome.clientId}`);
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.')}`);
}
console.log();
} catch (error) {
spinner.stop('Failed');
output.error(`Error: ${message(error)}`);
} finally {
if (timer) clearTimeout(timer);
server?.close();
}
}

export async function connectTypesCommand(options: { config?: string }): Promise<void> {
const cwd = process.cwd();
const api = requireApi();
const spinner = output.createSpinner();
spinner.start("Reading One's catalog...");

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 platforms = await api.listPlatforms();
const known = new Set(platforms.map((p) => p.platform));
const actionsByTool: Record<string, string[]> = {};
for (const tool of tools.filter((t) => known.has(t))) {
actionsByTool[tool] = [...actionNames(await api.listAllAvailableActions(tool)).keys()];
}

const target = path.join(file ? path.dirname(file) : cwd, TYPES_FILE);
fs.writeFileSync(target, renderTypes([...known], actionsByTool));

if (output.isAgentMode()) {
output.json({ file: target, tools: known.size, actions: actionsByTool });
return;
}
spinner.stop(`Wrote ${path.relative(cwd, target) || target}`);
console.log(` ${pc.dim(`${known.size} tools; action names for ${Object.keys(actionsByTool).join(', ') || 'none yet (add tools to ask, then run it again)'}`)}`);
} catch (error) {
spinner.stop('Failed');
output.error(`Error: ${message(error)}`);
}
}
17 changes: 17 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,23 @@ export class OneApi {
}
}

/** Every action on a platform, page by page, following the server's `pages`. */
async listAllAvailableActions(platform: string): Promise<AvailableAction[]> {
const all: AvailableAction[] = [];
let page = 1;
let totalPages = 1;
do {
const response = await this.requestFull<{ rows?: AvailableAction[]; pages?: number }>({
path: `/available-actions/${encodeURIComponent(platform)}`,
queryParams: { page: String(page), limit: '100' },
});
all.push(...(response.rows || []));
totalPages = response.pages || 1;
page++;
} while (page <= totalPages);
return all;
}

// Webhook Relay methods

async createRelayEndpoint(body: {
Expand Down
Loading
Loading