@kosuke-ai/cli 6.8.0 → 6.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -130,8 +130,37 @@ rest leaves room for, so fewer when you belong to many workspaces, and marks
130
130
  seen only the events it prints. The rest stay New for
131
131
  `kosuke activity list --unseen`. `--workspace <id>` reads that one workspace
132
132
  alone, in both the sessions and the events, and the commands it names for the
133
- rest carry the same flag. `--peek` prints the same events and marks none seen,
134
- for an agent that reads the digest before every answer.
133
+ rest carry the same flag; with it, or inside an agent's turn, the digest also
134
+ lists the connected integrations, one line each. `--peek` prints the same
135
+ events and marks none seen, for an agent that reads the digest before every
136
+ answer.
137
+
138
+ ## Integrations
139
+
140
+ `kosuke integrations` lists, describes and runs the tools of the integrations
141
+ connected to a workspace, and to you.
142
+
143
+ ```sh
144
+ kosuke integrations list --workspace <id> # a block per toolkit
145
+ kosuke integrations list --workspace <id> --toolkit gmail # that toolkit's tools
146
+ kosuke integrations describe <tool> --workspace <id> # one tool's input schema
147
+ kosuke integrations call <tool> --workspace <id> --account notion-personal --args '{"query":"roadmap"}'
148
+ ```
149
+
150
+ `list` with no filter prints each toolkit's connections (alias, scope, label,
151
+ account, short id) and how many read-only and write tools it has. `--toolkit` and
152
+ `--search` list the matching tools, and a list that is cut short says "N of M"
153
+ and how to narrow it. `--account` takes a connection's alias such as
154
+ `notion-personal`, its short id or `scope:label`; case, curly quotes and extra
155
+ spaces do not matter, and it is needed when several connections can run a tool.
156
+ `call` prints the tool's text on stdout and the connection it ran on on stderr,
157
+ and exits 1 when the tool reports an error.
158
+
159
+ With an API key, `--workspace` is required (you must belong to it), and `call`
160
+ runs only your own personal connections. Inside an agent's turn the credential
161
+ decides the workspace and the flag is optional. A Connector Token is refused.
162
+ `kosuke integrations tools` and `execute` print the raw JSON of the same two
163
+ routes. `kosuke agent summary` lists the same connections, one line each.
135
164
 
136
165
  ## Generated from the API
137
166
 
@@ -1,8 +1,10 @@
1
+ import { readOverview } from '../integrations/api.js';
1
2
  import { createApiClient } from '../runtime/client.js';
3
+ import { isTurnToken } from '../runtime/credentials.js';
2
4
  import { HttpError, NEEDS_YOU_FILTER, paginationTotal } from './http.js';
3
5
  /**
4
- * `kosuke agent summary`: what needs the member, which providers are connected
5
- * and what is New, in a few dozen lines an agent reads at the start of a
6
+ * `kosuke agent summary`: what needs the member, which providers and integrations
7
+ * are connected and what is New, in a few dozen lines an agent reads at the start of a
6
8
  * conversation. Kept to about 1,500 tokens: each list is capped and names the
7
9
  * command that reads the rest, and it claims only as many events as the other
8
10
  * lines leave room for.
@@ -13,7 +15,14 @@ const SUMMARY_TIMEOUT_MS = 8000;
13
15
  * must cost the digest its numbers, never the digest.
14
16
  */
15
17
  const PROVIDERS_TIMEOUT_MS = 4000;
18
+ /**
19
+ * Shorter still than the providers': connections are one cheap read, and a slow
20
+ * platform must cost the digest this section, never the digest.
21
+ */
22
+ const INTEGRATIONS_TIMEOUT_MS = 3000;
16
23
  const NEEDS_YOU_SHOWN = 5;
24
+ /** Connections listed before "and N more"; a member's own seven fit. */
25
+ const INTEGRATIONS_SHOWN = 8;
17
26
  /** However much room is left: past this, the digest stops being a glance. */
18
27
  const EVENTS_SHOWN = 15;
19
28
  /**
@@ -222,6 +231,55 @@ async function fetchProviders(client, workspace, signal) {
222
231
  return null;
223
232
  }
224
233
  }
234
+ /**
235
+ * The Integrations section's lines, one per connection: toolkit, alias, scope, label and the
236
+ * account it reaches. The platform decides which are the caller's (a member's personal ones come
237
+ * only where the credential may use them), so this lists what it was sent, each once. Null when
238
+ * there is no workspace to ask about; a refusal or a failed read still says so, because an
239
+ * absent section would read as "nothing is connected".
240
+ */
241
+ async function fetchIntegrations(client, workspace, apiKey, signal) {
242
+ // A member's key names a workspace; a turn's token already is one.
243
+ if (!workspace && !isTurnToken(apiKey))
244
+ return null;
245
+ try {
246
+ const result = await readOverview(client, workspace ? { workspace } : {}, signal);
247
+ if (!result.ok) {
248
+ return [
249
+ result.status === 403
250
+ ? '- Not available to this credential.'
251
+ : '- Could not be read. `kosuke integrations list` retries.',
252
+ ];
253
+ }
254
+ const seen = new Set();
255
+ const lines = [];
256
+ for (const toolkit of result.overview.toolkits) {
257
+ for (const connection of toolkit.connections) {
258
+ if (seen.has(connection.id))
259
+ continue;
260
+ seen.add(connection.id);
261
+ lines.push(integrationLine(toolkit.toolkit, connection));
262
+ }
263
+ }
264
+ if (lines.length === 0)
265
+ return ['- None connected.'];
266
+ const total = Math.max(result.overview.connectionTotal, lines.length);
267
+ const shown = lines.slice(0, INTEGRATIONS_SHOWN);
268
+ if (total > shown.length) {
269
+ const scope = workspace ? ` --workspace ${workspace}` : '';
270
+ shown.push(`(and ${total - shown.length} more: kosuke integrations list${scope})`);
271
+ }
272
+ return shown;
273
+ }
274
+ catch {
275
+ return null;
276
+ }
277
+ }
278
+ function integrationLine(toolkit, connection) {
279
+ const label = clip(connection.label, NAME_MAX);
280
+ const identity = clip(connection.accountIdentity ?? 'account unknown', NAME_MAX);
281
+ return `- ${clip(toolkit, NAME_MAX)}: ${connection.alias}, ${connection.scope}, "${label}", ${identity}`;
282
+ }
225
283
  /** Never clipped: a workspace missing here would read as one the digest did not cover. */
226
284
  function coverageLine(sessions) {
227
285
  const covered = sessions?.meta?.workspaces;
@@ -251,6 +309,9 @@ export function planSummary(options) {
251
309
  head.push('');
252
310
  if (options.providers?.length)
253
311
  head.push('Providers:', ...options.providers, '');
312
+ if (options.integrations?.length) {
313
+ head.push('Integrations (`kosuke integrations list` shows their tools):', ...options.integrations, '');
314
+ }
254
315
  const coverage = coverageLine(sessions);
255
316
  const room = BUDGET_CHARS - head.join('\n').length - coverage.length - ACTIVITY_FRAME_CHARS - scope.length;
256
317
  const lineMax = eventLineMax(apiUrl, needing);
@@ -294,7 +355,7 @@ export function renderSummary(plan, activity) {
294
355
  }
295
356
  /**
296
357
  * The whole digest, or one line saying why not, so the agent reading it
297
- * knows to query the CLI itself. Needs You and the providers are read first:
358
+ * knows to query the CLI itself. Needs You, the providers and the integrations are read first:
298
359
  * their lines decide how many events fit, and the Activity read marks events
299
360
  * seen, so a digest that failed after it would lose them.
300
361
  */
@@ -309,17 +370,19 @@ export async function fetchSummary(options) {
309
370
  const signal = AbortSignal.timeout(options.timeoutMs ?? SUMMARY_TIMEOUT_MS);
310
371
  try {
311
372
  const workspace = options.workspace ? { workspace: options.workspace } : {};
312
- const [needsYou, providers] = await Promise.all([
373
+ const [needsYou, providers, integrations] = await Promise.all([
313
374
  client.GET('/api/chat-sessions', {
314
375
  params: { query: { ...NEEDS_YOU_QUERY, ...workspace } },
315
376
  signal,
316
377
  }),
317
378
  fetchProviders(client, options.workspace, AbortSignal.any([signal, AbortSignal.timeout(PROVIDERS_TIMEOUT_MS)])),
379
+ fetchIntegrations(client, options.workspace, options.apiKey, AbortSignal.any([signal, AbortSignal.timeout(INTEGRATIONS_TIMEOUT_MS)])),
318
380
  ]);
319
381
  const sessions = body(needsYou);
320
382
  const plan = planSummary({
321
383
  sessions,
322
384
  providers,
385
+ integrations,
323
386
  apiUrl: options.apiUrl,
324
387
  now: (options.now ?? Date.now)(),
325
388
  ...workspace,
@@ -0,0 +1,303 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { InvalidArgumentError } from 'commander';
3
+ import { groupFor } from '../runtime/attach.js';
4
+ import { isTurnToken } from '../runtime/credentials.js';
5
+ import { connectionOf, failureOf, isRow, oneLine, readOverview, rowsOf, TOOLS_PATH, transportFailure, } from '../integrations/api.js';
6
+ import { choiceLines, renderOverview } from '../integrations/render.js';
7
+ /**
8
+ * `kosuke integrations list|describe|call`: the connected tools, listed and run
9
+ * from a laptop or an agent's shell. Hand-written because the output is text
10
+ * and the exit code says whether the tool worked, which the generated
11
+ * `integrations tools` and `integrations execute` (raw JSON passthroughs, kept
12
+ * beside these) cannot do.
13
+ *
14
+ * Tool descriptions, schemas and results come from third-party services: they
15
+ * are printed, never acted on.
16
+ */
17
+ const EXECUTE_PATH = '/api/agent-integrations/execute';
18
+ const READ_TIMEOUT_MS = 30_000;
19
+ /** A guarded call is up to three sequential vendor requests of 20 s each. */
20
+ const CALL_TIMEOUT_MS = 70_000;
21
+ const EXIT_FAILED = 1;
22
+ const EXIT_USAGE = 2;
23
+ const TOOLKIT_PATTERN = /^[a-z0-9_]{1,64}$/;
24
+ const MAX_SEARCH_CHARS = 100;
25
+ const MAX_ACCOUNT_CHARS = 100;
26
+ /** Wrong flags or arguments, found before any request. */
27
+ class UsageError extends Error {
28
+ }
29
+ async function readAllStdin() {
30
+ const chunks = [];
31
+ for await (const chunk of process.stdin)
32
+ chunks.push(Buffer.from(chunk));
33
+ return Buffer.concat(chunks).toString('utf8');
34
+ }
35
+ function toolkitSlug(raw) {
36
+ if (!TOOLKIT_PATTERN.test(raw)) {
37
+ throw new InvalidArgumentError('Expected a toolkit slug such as gmail, as `list` prints it.');
38
+ }
39
+ return raw;
40
+ }
41
+ function searchText(raw) {
42
+ if (raw.trim() === '' || raw.length > MAX_SEARCH_CHARS) {
43
+ throw new InvalidArgumentError(`Expected 1 to ${MAX_SEARCH_CHARS} characters.`);
44
+ }
45
+ return raw;
46
+ }
47
+ function accountText(raw) {
48
+ // A flag swallowed as the value (`--account --args`) shows as a leading dash.
49
+ if (raw.trim() === '' || raw.startsWith('-') || raw.length > MAX_ACCOUNT_CHARS) {
50
+ throw new InvalidArgumentError(`Expected an alias, a connection id or scope:label, up to ${MAX_ACCOUNT_CHARS} characters.`);
51
+ }
52
+ return raw;
53
+ }
54
+ function limitNumber(raw) {
55
+ if (!/^\d+$/.test(raw) || Number(raw) < 1 || Number(raw) > 100) {
56
+ throw new InvalidArgumentError('Expected a whole number from 1 to 100.');
57
+ }
58
+ return Number(raw);
59
+ }
60
+ const WORKSPACE_HELP = 'The workspace to use. Required with an API key, which is refused when you are not a member of it. ' +
61
+ 'Inside an agent turn the credential decides, and this is optional.';
62
+ /** A turn's token decides its workspace; a member's API key has to name one. */
63
+ function workspaceFor(runtime, given) {
64
+ if (given)
65
+ return given;
66
+ if (isTurnToken(runtime.credentials().apiKey))
67
+ return undefined;
68
+ throw new UsageError('--workspace <id> is required with an API key. `kosuke workspaces list` shows the ids.');
69
+ }
70
+ /** The 409 that asks for `--account`: its choices, one per line. */
71
+ function accountChoiceText(error, tool, given) {
72
+ if (!isRow(error))
73
+ return null;
74
+ const choices = rowsOf(isRow(error.details) ? error.details.choices : []).flatMap((row) => connectionOf(row) ?? []);
75
+ if (choices.length === 0)
76
+ return null;
77
+ return choicesSentence(error.code === 'INTEGRATION_INVALID_ACCOUNT', tool, given, choices);
78
+ }
79
+ function choicesSentence(invalid, tool, given, choices) {
80
+ const head = invalid
81
+ ? `No single account matches --account "${oneLine(given ?? '', 60)}". The accounts are:`
82
+ : `More than one account can run ${tool}. Pass --account with one of:`;
83
+ return `${head}\n${choiceLines(choices)}`;
84
+ }
85
+ /**
86
+ * The executor answers a tool failure as text. Its own refusals are
87
+ * `{"error": code, "message": sentence}`, shown as the sentence; anything else
88
+ * (a vendor's words included) is printed as received.
89
+ */
90
+ function failureText(text, choices, tool, given) {
91
+ let body;
92
+ try {
93
+ body = JSON.parse(text);
94
+ }
95
+ catch {
96
+ return text;
97
+ }
98
+ if (!isRow(body) || typeof body.message !== 'string' || typeof body.error !== 'string') {
99
+ return text;
100
+ }
101
+ if (body.error === 'account_required' || body.error === 'invalid_account') {
102
+ return choices.length > 0
103
+ ? choicesSentence(body.error === 'invalid_account', tool, given, choices)
104
+ : body.message.replace(/\bPass account\b/, 'Pass --account');
105
+ }
106
+ return body.message;
107
+ }
108
+ export function attachIntegrationsCommands(root, runtime, deps = {}) {
109
+ const { stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), exit = (code) => {
110
+ process.exitCode = code;
111
+ }, readStdin = readAllStdin, readFile: readText = (path) => readFile(path, 'utf8'), readTimeoutMs = READ_TIMEOUT_MS, callTimeoutMs = CALL_TIMEOUT_MS, } = deps;
112
+ const fail = (failure, code = EXIT_FAILED) => {
113
+ stderr(`${typeof failure === 'string' ? failure : failure.message}\n`);
114
+ exit(code);
115
+ };
116
+ /** Runs one command; a usage error is reported and exits 2. */
117
+ const guarded = (run) => async (...args) => {
118
+ try {
119
+ await run(...args);
120
+ }
121
+ catch (error) {
122
+ if (!(error instanceof UsageError))
123
+ throw error;
124
+ fail(error.message, EXIT_USAGE);
125
+ }
126
+ };
127
+ const group = groupFor(root, ['integrations']);
128
+ group
129
+ .command('list')
130
+ .description('List the connected tools. With no filter, one block per toolkit: its connections (alias, ' +
131
+ 'scope, label, account, id) and how many read-only and write tools it has. --toolkit and ' +
132
+ '--search list the matching tools. A cut list says "N of M" and how to narrow it.')
133
+ .option('--toolkit <slug>', 'Only this toolkit, such as gmail.', toolkitSlug)
134
+ .option('--search <text>', 'Only tools whose name or description mention the text.', searchText)
135
+ .option('--account <account>', 'Only what one connection offers: an alias, id or scope:label.', accountText)
136
+ .option('--limit <n>', 'How many tools to list with --toolkit or --search. Default 40, at most 100.', limitNumber)
137
+ .option('--workspace <id>', WORKSPACE_HELP)
138
+ .option('--json', "Print the platform's answer as JSON.")
139
+ .addHelpText('after', '\nExit codes:\n 0 ok\n 1 the platform refused or could not answer\n 2 --workspace missing with an API key')
140
+ .action(guarded(async (flags) => {
141
+ const client = runtime.client();
142
+ const workspace = workspaceFor(runtime, flags.workspace);
143
+ const result = await readOverview(client, {
144
+ ...(workspace ? { workspace } : {}),
145
+ ...(flags.toolkit ? { toolkit: flags.toolkit } : {}),
146
+ ...(flags.search ? { search: flags.search } : {}),
147
+ ...(flags.account ? { account: flags.account } : {}),
148
+ ...(flags.limit ? { limit: flags.limit } : {}),
149
+ }, AbortSignal.timeout(readTimeoutMs));
150
+ if (!result.ok)
151
+ return fail(result);
152
+ if (flags.json) {
153
+ stdout(`${JSON.stringify(result.raw, null, 2)}\n`);
154
+ // stdout stays parseable, so the cut is told beside it.
155
+ if (result.overview.note)
156
+ stderr(`${result.overview.note}\n`);
157
+ return;
158
+ }
159
+ stdout(renderOverview(result.overview, Boolean(flags.toolkit || flags.search)));
160
+ }));
161
+ group
162
+ .command('describe')
163
+ .description("Print one tool's input schema and annotations as JSON.")
164
+ .argument('<name>', 'The tool, as `list` prints it.')
165
+ .option('--account <account>', 'The connection whose schema you want: an alias, id or scope:label. Needed when several can run the tool.', accountText)
166
+ .option('--workspace <id>', WORKSPACE_HELP)
167
+ .addHelpText('after', '\nExit codes:\n 0 ok\n 1 no such tool, a connection has to be named, or the platform refused\n 2 --workspace missing with an API key')
168
+ .action(guarded(async (name, flags) => {
169
+ const client = runtime.client();
170
+ const workspace = workspaceFor(runtime, flags.workspace);
171
+ try {
172
+ const { data, error, response } = await client.GET(TOOLS_PATH, {
173
+ params: {
174
+ query: {
175
+ name,
176
+ ...(flags.account ? { account: flags.account } : {}),
177
+ ...(workspace ? { workspace } : {}),
178
+ },
179
+ },
180
+ signal: AbortSignal.timeout(readTimeoutMs),
181
+ });
182
+ if (response.status === 404) {
183
+ return fail(`No connected tool is named ${oneLine(name, 80)}${flags.account ? ' for that account' : ''}. ` +
184
+ 'Run `kosuke integrations list` to see them.');
185
+ }
186
+ if (response.status === 409) {
187
+ const choices = accountChoiceText(error, oneLine(name, 80), flags.account);
188
+ if (choices)
189
+ return fail(choices);
190
+ }
191
+ if (!response.ok)
192
+ return fail(failureOf(response.status, error, 'GET'));
193
+ const tools = rowsOf(isRow(data) && isRow(data.data) ? data.data.tools : []);
194
+ // Matched here too: a platform that ignores `name` answers with every tool.
195
+ const tool = tools.find((entry) => typeof entry.name === 'string' && entry.name.toLowerCase() === name.toLowerCase());
196
+ if (!tool) {
197
+ return fail(`No connected tool is named ${oneLine(name, 80)}. Run \`kosuke integrations list\` to see them.`);
198
+ }
199
+ const { description, inputSchema, annotations } = tool;
200
+ stdout(`${JSON.stringify({ name: tool.name, description, inputSchema, annotations }, null, 2)}\n`);
201
+ }
202
+ catch (error) {
203
+ fail(transportFailure(error, 'GET'));
204
+ }
205
+ }));
206
+ group
207
+ .command('call')
208
+ .description('Run a tool with a JSON object of arguments and print its text result. The connection it ' +
209
+ 'ran on is named on stderr. A tool that reports an error exits 1.')
210
+ .argument('<name>', 'The tool, as `list` prints it.')
211
+ .argument('[stdin]', 'A lone - reads the JSON arguments from stdin.')
212
+ .option('--account <account>', 'The connection to run on: an alias, id or scope:label. Needed when several can run the tool.', accountText)
213
+ .option('--args <json>', 'The tool arguments, a JSON object. Default {}.')
214
+ .option('--args-file <path>', 'Read the arguments from a file, or - for stdin.')
215
+ .option('--workspace <id>', WORKSPACE_HELP)
216
+ .addHelpText('after', '\nWith an API key, a call runs only on your own personal connections; one on a workspace\n' +
217
+ "connection is refused (an agent's turn in the workspace can run it).\n" +
218
+ '\nExit codes:\n 0 the tool ran\n 1 the tool or the platform reported an error, or the outcome of a write is unknown\n 2 usage error: --workspace missing, or arguments that are not a JSON object')
219
+ .action(guarded(async (name, dash, flags) => {
220
+ const toolArguments = await readArguments(flags, dash);
221
+ const client = runtime.client();
222
+ const workspace = workspaceFor(runtime, flags.workspace);
223
+ let data;
224
+ try {
225
+ const result = await client.POST(EXECUTE_PATH, {
226
+ params: { query: workspace ? { workspace } : {} },
227
+ body: {
228
+ name,
229
+ arguments: toolArguments,
230
+ ...(flags.account ? { account: flags.account } : {}),
231
+ },
232
+ signal: AbortSignal.timeout(callTimeoutMs),
233
+ });
234
+ if (!result.response.ok) {
235
+ return fail(failureOf(result.response.status, result.error, 'POST'));
236
+ }
237
+ data = isRow(result.data) ? result.data.data : undefined;
238
+ }
239
+ catch (error) {
240
+ return fail(transportFailure(error, 'POST'));
241
+ }
242
+ report(name, flags.account, data);
243
+ }));
244
+ /** Reads `--args`, `--args-file` or a lone `-`: one source at most, and a JSON object. */
245
+ async function readArguments(flags, dash) {
246
+ if (dash !== undefined && dash !== '-')
247
+ throw new UsageError(`Unexpected argument: ${dash}`);
248
+ const fromStdin = dash === '-' || flags.argsFile === '-';
249
+ // Each flag and the positional `-` is its own source, even when two of them name stdin.
250
+ const sources = [flags.args !== undefined, flags.argsFile !== undefined, dash === '-'];
251
+ if (sources.filter(Boolean).length > 1)
252
+ throw new UsageError('Give the arguments one way only.');
253
+ if (!sources.includes(true))
254
+ return {};
255
+ let text;
256
+ if (flags.args !== undefined)
257
+ text = flags.args;
258
+ else if (fromStdin)
259
+ text = await readStdin();
260
+ else {
261
+ try {
262
+ text = await readText(flags.argsFile);
263
+ }
264
+ catch {
265
+ throw new UsageError(`Could not read the arguments file ${flags.argsFile}.`);
266
+ }
267
+ }
268
+ let parsed;
269
+ try {
270
+ parsed = JSON.parse(text);
271
+ }
272
+ catch {
273
+ throw new UsageError('The arguments are not valid JSON.');
274
+ }
275
+ if (!isRow(parsed))
276
+ throw new UsageError('The arguments must be a JSON object.');
277
+ return parsed;
278
+ }
279
+ /** The tool's text on stdout; which connection ran it, and any failure, on stderr. */
280
+ function report(name, account, data) {
281
+ const answer = isRow(data) ? data : {};
282
+ const content = rowsOf(answer.content);
283
+ if (content.length === 0 && !Array.isArray(answer.content)) {
284
+ return fail('Integration tools returned an unexpected response.');
285
+ }
286
+ const text = content
287
+ .map((block) => (typeof block.text === 'string' ? block.text : ''))
288
+ .join('\n');
289
+ const connection = isRow(answer.connection) ? connectionOf(answer.connection) : null;
290
+ // The alias may name a different connection tomorrow, so every call says which one it ran on.
291
+ if (connection) {
292
+ const identity = connection.accountIdentity ?? 'account unknown';
293
+ stderr(`Ran on ${connection.alias} (${connection.scope}, "${connection.label}", ${identity}).\n`);
294
+ }
295
+ if (answer.isError === true) {
296
+ const choices = rowsOf(answer.choices).flatMap((row) => connectionOf(row) ?? []);
297
+ const message = failureText(text, choices, oneLine(name, 80), account);
298
+ fail(message.endsWith('\n') ? message.slice(0, -1) : message);
299
+ return;
300
+ }
301
+ stdout(text.endsWith('\n') || text === '' ? text : `${text}\n`);
302
+ }
303
+ }