@kosuke-ai/cli 1.0.1-next.5 → 1.0.1-next.6

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.
Files changed (82) hide show
  1. package/README.md +104 -0
  2. package/dist/commands/mcp.js +85 -0
  3. package/dist/generated/tools/deploy/cancel.js +20 -0
  4. package/dist/generated/tools/deploy/messages.js +20 -0
  5. package/dist/generated/tools/deploy/run.js +52 -0
  6. package/dist/generated/tools/index.js +138 -0
  7. package/dist/generated/tools/preview/get.js +24 -0
  8. package/dist/generated/tools/preview/health.js +24 -0
  9. package/dist/generated/tools/preview/reset.js +23 -0
  10. package/dist/generated/tools/preview/share-create.js +24 -0
  11. package/dist/generated/tools/preview/share-delete.js +24 -0
  12. package/dist/generated/tools/preview/share-get.js +24 -0
  13. package/dist/generated/tools/preview/shared-artifact.js +25 -0
  14. package/dist/generated/tools/preview/shared.js +20 -0
  15. package/dist/generated/tools/projects/create.js +26 -0
  16. package/dist/generated/tools/projects/delete.js +20 -0
  17. package/dist/generated/tools/projects/env-vars-add.js +50 -0
  18. package/dist/generated/tools/projects/env-vars-remove.js +31 -0
  19. package/dist/generated/tools/projects/get.js +20 -0
  20. package/dist/generated/tools/projects/list.js +12 -0
  21. package/dist/generated/tools/projects/services-add.js +111 -0
  22. package/dist/generated/tools/projects/services-list.js +20 -0
  23. package/dist/generated/tools/projects/services-remove.js +25 -0
  24. package/dist/generated/tools/projects/services-update.js +104 -0
  25. package/dist/generated/tools/projects/settings-get.js +20 -0
  26. package/dist/generated/tools/projects/settings-import.js +208 -0
  27. package/dist/generated/tools/projects/settings-update.js +72 -0
  28. package/dist/generated/tools/projects/skills.js +32 -0
  29. package/dist/generated/tools/projects/update.js +24 -0
  30. package/dist/generated/tools/sessions/activate.js +25 -0
  31. package/dist/generated/tools/sessions/artifacts-get.js +30 -0
  32. package/dist/generated/tools/sessions/artifacts-list.js +25 -0
  33. package/dist/generated/tools/sessions/checklist-generate.js +25 -0
  34. package/dist/generated/tools/sessions/checklist-get.js +25 -0
  35. package/dist/generated/tools/sessions/comments-add.js +53 -0
  36. package/dist/generated/tools/sessions/comments-delete.js +30 -0
  37. package/dist/generated/tools/sessions/comments-list.js +25 -0
  38. package/dist/generated/tools/sessions/comments-update.js +62 -0
  39. package/dist/generated/tools/sessions/create.js +69 -0
  40. package/dist/generated/tools/sessions/delete.js +25 -0
  41. package/dist/generated/tools/sessions/diff-files.js +25 -0
  42. package/dist/generated/tools/sessions/diff-patch.js +64 -0
  43. package/dist/generated/tools/sessions/edit-prompt.js +69 -0
  44. package/dist/generated/tools/sessions/files-get.js +30 -0
  45. package/dist/generated/tools/sessions/files-list.js +25 -0
  46. package/dist/generated/tools/sessions/files-write.js +37 -0
  47. package/dist/generated/tools/sessions/get.js +25 -0
  48. package/dist/generated/tools/sessions/git-commit-message.js +25 -0
  49. package/dist/generated/tools/sessions/git-commit.js +29 -0
  50. package/dist/generated/tools/sessions/git-push.js +25 -0
  51. package/dist/generated/tools/sessions/git-state.js +25 -0
  52. package/dist/generated/tools/sessions/jobs-cancel.js +30 -0
  53. package/dist/generated/tools/sessions/jobs-get.js +30 -0
  54. package/dist/generated/tools/sessions/list.js +56 -0
  55. package/dist/generated/tools/sessions/messages.js +25 -0
  56. package/dist/generated/tools/sessions/search.js +17 -0
  57. package/dist/generated/tools/sessions/send.js +137 -0
  58. package/dist/generated/tools/sessions/update.js +63 -0
  59. package/dist/generated/tools/workspaces/access-status.js +28 -0
  60. package/dist/generated/tools/workspaces/ai-provider-get.js +20 -0
  61. package/dist/generated/tools/workspaces/ai-provider-remove.js +39 -0
  62. package/dist/generated/tools/workspaces/ai-provider-set.js +106 -0
  63. package/dist/generated/tools/workspaces/credits.js +20 -0
  64. package/dist/generated/tools/workspaces/get.js +20 -0
  65. package/dist/generated/tools/workspaces/getting-started-get.js +20 -0
  66. package/dist/generated/tools/workspaces/getting-started-update.js +27 -0
  67. package/dist/generated/tools/workspaces/list.js +12 -0
  68. package/dist/generated/tools/workspaces/members.js +24 -0
  69. package/dist/generated/tools/workspaces/sandbox-limit.js +20 -0
  70. package/dist/generated/tools/workspaces/stop-sandboxes.js +31 -0
  71. package/dist/generated/tools/workspaces/usage.js +28 -0
  72. package/dist/program.js +4 -2
  73. package/dist/runtime/mcp-attachments.js +110 -0
  74. package/dist/runtime/mcp-composite-support.js +49 -0
  75. package/dist/runtime/mcp-composite.js +309 -0
  76. package/dist/runtime/mcp-guard.js +74 -0
  77. package/dist/runtime/mcp-invoke.js +123 -0
  78. package/dist/runtime/mcp-open-pr.js +83 -0
  79. package/dist/runtime/mcp-redact.js +31 -0
  80. package/dist/runtime/mcp-server.js +219 -0
  81. package/dist/runtime/mcp-tool.js +6 -0
  82. package/package.json +2 -1
@@ -0,0 +1,309 @@
1
+ import { UsageError } from './invoke.js';
2
+ import { COMPOSITE_TOOLSET, data, done, failed, messageText, pick, tail, } from './mcp-composite-support.js';
3
+ import { openPrCommand, resolveProvider } from './mcp-open-pr.js';
4
+ /**
5
+ * The hand-written tools: `ship`, `watch` and `submit`, the three arcs where a
6
+ * flat mirror of the API makes the model plumb ids by hand. Each one calls
7
+ * generated tools by name and nothing else, so it can reach no route the
8
+ * document does not describe, and it is the only part of the tool list that
9
+ * can drift from the spec.
10
+ */
11
+ /** A chat job the platform will not move on its own. */
12
+ const CHAT_SETTLED = new Set(['completed', 'failed', 'cancelled', 'awaiting_response']);
13
+ /** Likewise for a deploy; `idle` is a project that has never deployed. */
14
+ const DEPLOY_SETTLED = new Set(['deployed', 'failed', 'cancelled', 'awaiting_user', 'idle']);
15
+ /**
16
+ * Under the 60 s an MCP client waits for a call by default: a longer default
17
+ * would time out on the client side while the server kept polling, and the
18
+ * documented `done: false` would never be seen.
19
+ */
20
+ const WATCH_DEFAULT_SECONDS = 50;
21
+ const WATCH_MAX_SECONDS = 1800;
22
+ const POLL_DEFAULT_SECONDS = 5;
23
+ const id = { type: 'string' };
24
+ const ship = {
25
+ name: 'ship',
26
+ toolset: COMPOSITE_TOOLSET,
27
+ description: 'Start work: create a chat session from a prompt and start its first turn, or start an ' +
28
+ 'existing backlog session. Returns the session and the job to pass to `watch`.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ projectId: { ...id, description: 'The project.' },
33
+ content: { type: 'string', description: 'The prompt. Required unless sessionId is given.' },
34
+ sessionId: {
35
+ ...id,
36
+ description: 'An existing backlog session to start instead of creating one.',
37
+ },
38
+ title: {
39
+ type: 'string',
40
+ description: 'Session title; derived from the prompt when omitted.',
41
+ },
42
+ model: { type: 'string', description: 'Model id such as anthropic:sonnet__medium.' },
43
+ priority: { type: 'string', enum: ['urgent', 'high', 'medium', 'low', 'none'] },
44
+ startBranch: {
45
+ type: 'string',
46
+ description: 'Branch to fork from; the project default otherwise.',
47
+ },
48
+ attachments: {
49
+ type: 'array',
50
+ maxItems: 10,
51
+ description: 'Files to attach to the prompt, as paths on this machine.',
52
+ items: {
53
+ type: 'object',
54
+ properties: { path: { type: 'string' } },
55
+ required: ['path'],
56
+ additionalProperties: false,
57
+ },
58
+ },
59
+ },
60
+ required: ['projectId'],
61
+ additionalProperties: false,
62
+ },
63
+ readOnly: false,
64
+ async run(args, context) {
65
+ const { projectId, sessionId, content } = args;
66
+ const warnings = [];
67
+ let result;
68
+ if (typeof sessionId === 'string' && sessionId) {
69
+ result = await context.call('sessions_activate', { projectId, sessionId });
70
+ if (!result.ok)
71
+ return failed(result, 'sessions_activate');
72
+ warnings.push(...result.warnings);
73
+ }
74
+ else {
75
+ if (typeof content !== 'string' || !content.trim()) {
76
+ throw new UsageError('content is required unless sessionId names a backlog session.');
77
+ }
78
+ result = await context.call('sessions_create', pick(args, [
79
+ 'projectId',
80
+ 'content',
81
+ 'title',
82
+ 'model',
83
+ 'priority',
84
+ 'startBranch',
85
+ 'attachments',
86
+ ]));
87
+ if (!result.ok)
88
+ return failed(result, 'sessions_create');
89
+ warnings.push(...result.warnings);
90
+ // Created with a prompt, the session is active and its first job is queued
91
+ // already; if the platform left it for us to start, start it.
92
+ if (!data(result).chatJobId) {
93
+ const session = data(result).session;
94
+ result = await context.call('sessions_activate', { projectId, sessionId: session?.id });
95
+ if (!result.ok)
96
+ return failed(result, 'sessions_activate');
97
+ warnings.push(...result.warnings);
98
+ }
99
+ }
100
+ const payload = data(result);
101
+ const session = (payload.session ?? {});
102
+ return done(result, {
103
+ sessionId: session.id,
104
+ branchName: session.branchName,
105
+ status: session.status,
106
+ chatJobId: payload.chatJobId,
107
+ jobNumber: payload.jobNumber,
108
+ watch: { projectId, sessionId: session.id, jobId: payload.chatJobId },
109
+ }, warnings);
110
+ },
111
+ };
112
+ const watch = {
113
+ name: 'watch',
114
+ toolset: COMPOSITE_TOOLSET,
115
+ description: 'Follow a chat job, or the deploy, until it settles, reporting progress on the way. ' +
116
+ 'Returns the final status and the last assistant message. Stops after timeoutSeconds ' +
117
+ '(default under the usual 60 s client limit; a longer wait needs a client that resets ' +
118
+ 'its timeout on progress) ' +
119
+ 'with done=false; call again to keep waiting.',
120
+ inputSchema: {
121
+ type: 'object',
122
+ properties: {
123
+ projectId: { ...id, description: 'The project.' },
124
+ sessionId: { ...id, description: 'The chat session; required unless deploy is true.' },
125
+ jobId: { ...id, description: 'The chat job; the latest one of the session when omitted.' },
126
+ deploy: { type: 'boolean', description: 'Watch the project deploy instead of a chat job.' },
127
+ timeoutSeconds: {
128
+ type: 'integer',
129
+ minimum: 5,
130
+ maximum: WATCH_MAX_SECONDS,
131
+ description: `How long to wait before returning done=false. Default ${WATCH_DEFAULT_SECONDS}.`,
132
+ },
133
+ pollSeconds: {
134
+ type: 'integer',
135
+ minimum: 1,
136
+ maximum: 60,
137
+ description: 'Seconds between polls.',
138
+ },
139
+ },
140
+ required: ['projectId'],
141
+ additionalProperties: false,
142
+ },
143
+ readOnly: true,
144
+ async run(args, context) {
145
+ const projectId = args.projectId;
146
+ const timeout = Number(args.timeoutSeconds ?? WATCH_DEFAULT_SECONDS) * 1000;
147
+ const interval = Number(args.pollSeconds ?? POLL_DEFAULT_SECONDS) * 1000;
148
+ const started = context.now();
149
+ const elapsed = () => Math.round((context.now() - started) / 1000);
150
+ const remaining = () => Math.max(0, timeout - (context.now() - started));
151
+ const aborted = () => context.signal?.aborted === true;
152
+ const timedOut = () => remaining() === 0 || aborted();
153
+ // Never past the deadline, and nothing more once the caller has gone.
154
+ // After the wait the deadline is tested again, so a budget shorter than
155
+ // the poll interval ends with the last observation, not one more request.
156
+ const pause = async () => {
157
+ if (aborted())
158
+ return;
159
+ await context.sleep(Math.min(interval, remaining()));
160
+ };
161
+ if (args.deploy === true) {
162
+ for (;;) {
163
+ const current = await context.call('deploy_messages', { projectId });
164
+ if (!current.ok)
165
+ return failed(current, 'deploy_messages');
166
+ const payload = data(current);
167
+ const status = String(payload.status);
168
+ const summary = {
169
+ kind: 'deploy',
170
+ status,
171
+ deployJobId: payload.deployJobId,
172
+ error: payload.error,
173
+ lastMessages: tail(payload.messages),
174
+ };
175
+ if (DEPLOY_SETTLED.has(status))
176
+ return done(current, { done: true, ...summary });
177
+ if (timedOut())
178
+ return done(current, { done: false, ...summary });
179
+ await context.progress(`deploy ${status}`, elapsed(), timeout / 1000);
180
+ await pause();
181
+ if (timedOut())
182
+ return done(current, { done: false, ...summary });
183
+ }
184
+ }
185
+ const sessionId = args.sessionId;
186
+ if (typeof sessionId !== 'string' || !sessionId) {
187
+ throw new UsageError('sessionId is required unless deploy is true.');
188
+ }
189
+ let jobId = args.jobId;
190
+ if (!jobId) {
191
+ const session = await context.call('sessions_get', { projectId, sessionId });
192
+ if (!session.ok)
193
+ return failed(session, 'sessions_get');
194
+ const latest = data(session).latestChatJob;
195
+ if (!latest?.id)
196
+ throw new UsageError('This session has no chat job to watch.');
197
+ jobId = String(latest.id);
198
+ }
199
+ for (;;) {
200
+ const current = await context.call('sessions_jobs_get', { projectId, sessionId, jobId });
201
+ if (!current.ok)
202
+ return failed(current, 'sessions_jobs_get');
203
+ const job = (data(current).job ?? {});
204
+ const status = String(job.status);
205
+ if (CHAT_SETTLED.has(status)) {
206
+ const [messages, session] = await Promise.all([
207
+ context.call('sessions_messages', { projectId, sessionId }),
208
+ context.call('sessions_get', { projectId, sessionId }),
209
+ ]);
210
+ const list = messages.ok ? data(messages).messages : undefined;
211
+ const assistant = [...(list ?? [])].reverse().find((m) => m.role === 'assistant');
212
+ const info = session.ok ? data(session) : {};
213
+ const row = (info.session ?? {});
214
+ return done(current, {
215
+ done: true,
216
+ kind: 'chat',
217
+ job,
218
+ session: {
219
+ status: row.status,
220
+ branchName: row.branchName,
221
+ pullRequestNumber: row.pullRequestNumber,
222
+ prUrl: info.prUrl,
223
+ },
224
+ lastAssistantMessage: assistant ? messageText(assistant) : null,
225
+ });
226
+ }
227
+ if (timedOut())
228
+ return done(current, { done: false, kind: 'chat', job });
229
+ await context.progress(`chat job ${status}`, elapsed(), timeout / 1000);
230
+ await pause();
231
+ if (timedOut())
232
+ return done(current, { done: false, kind: 'chat', job });
233
+ }
234
+ },
235
+ };
236
+ const submit = {
237
+ name: 'submit',
238
+ toolset: COMPOSITE_TOOLSET,
239
+ description: 'Ask the session to open its pull request, or bring the open one up to date, by sending ' +
240
+ 'the open-pr skill. Returns the job to pass to `watch`; the PR link is on the session ' +
241
+ 'once the job settles.',
242
+ inputSchema: {
243
+ type: 'object',
244
+ properties: {
245
+ projectId: { ...id, description: 'The project.' },
246
+ sessionId: { ...id, description: 'The chat session whose branch becomes the pull request.' },
247
+ draft: { type: 'boolean', description: 'Open as a draft. Ignored once a PR exists.' },
248
+ reviewers: {
249
+ type: 'array',
250
+ items: { type: 'string' },
251
+ description: 'Reviewers to request, matched loosely against GitHub logins.',
252
+ },
253
+ evidence: {
254
+ type: 'array',
255
+ items: { type: 'string' },
256
+ description: 'Links to add under an evidence section.',
257
+ },
258
+ notes: { type: 'string', description: 'Freeform notes for the pull request body.' },
259
+ },
260
+ required: ['projectId', 'sessionId'],
261
+ additionalProperties: false,
262
+ },
263
+ readOnly: false,
264
+ async run(args, context) {
265
+ const { projectId, sessionId } = args;
266
+ const got = await context.call('sessions_get', { projectId, sessionId });
267
+ if (!got.ok)
268
+ return failed(got, 'sessions_get');
269
+ const session = (data(got).session ?? {});
270
+ if (session.status === 'backlog') {
271
+ throw new UsageError('This session is in the backlog; start it with `ship` first.');
272
+ }
273
+ // Codex reads `$open-pr`, Claude `/open-pr`, so the provider decides whether
274
+ // the message is a skill or prose, and a guess is a silent failure.
275
+ const resolved = await resolveProvider(session, projectId, context);
276
+ if ('failed' in resolved)
277
+ return resolved.failed;
278
+ const { provider, model } = resolved;
279
+ const skills = await context.call('projects_skills', { projectId, provider, sessionId });
280
+ if (!skills.ok)
281
+ return failed(skills, 'projects_skills');
282
+ const list = (data(skills).skills ?? []);
283
+ if (!list.some((skill) => skill.factoryId === 'open-pr' || skill.slug === 'open-pr')) {
284
+ throw new UsageError('The open-pr skill is not available to this session; enable it in Workspace settings.');
285
+ }
286
+ const content = openPrCommand(provider === 'openai' ? '$' : '/', args);
287
+ // When the session had no model, the one the provider was read from is
288
+ // sent along, so the turn runs on the provider the trigger was written for.
289
+ const sent = await context.call('sessions_send', {
290
+ projectId,
291
+ sessionId,
292
+ content,
293
+ ...(model ? { model } : {}),
294
+ });
295
+ if (!sent.ok)
296
+ return failed(sent, 'sessions_send');
297
+ const payload = data(sent);
298
+ return done(sent, {
299
+ sent: content,
300
+ delivery: payload.delivery,
301
+ chatJobId: payload.chatJobId,
302
+ steerId: payload.steerId,
303
+ pullRequestNumber: session.pullRequestNumber ?? null,
304
+ prUrl: data(got).prUrl ?? null,
305
+ watch: { projectId, sessionId, jobId: payload.chatJobId },
306
+ }, [...got.warnings, ...skills.warnings, ...sent.warnings]);
307
+ },
308
+ };
309
+ export const compositeTools = [ship, watch, submit];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * What a tool call may not carry. Tool arguments end up in the agent's
3
+ * transcript, so a credential given as one is a credential written down.
4
+ *
5
+ * Two levels, on purpose. A field whose meaning is "this is a secret" is
6
+ * refused outright: a provider key on `workspaces ai-provider set`, or an
7
+ * environment variable marked `isSecret`. Free text is only scanned, and
8
+ * what it looks like is reported back as a warning: prose quotes credential
9
+ * shapes all the time (this feature's own ticket contains `kosuke_...`), and
10
+ * a regex must not veto a legitimate message.
11
+ */
12
+ export class RefusedArgument extends Error {
13
+ }
14
+ /** Body keys that carry a provider key on `workspaces ai-provider set`. */
15
+ const PROVIDER_KEY_FIELDS = new Set(['anthropicApiKey', 'bedrockApiKey', 'openaiApiKey']);
16
+ const SETTINGS_HINT = 'Tool arguments are visible in the agent history, so set it in Settings on the platform ' +
17
+ 'or with the CLI instead.';
18
+ export function refuseSecrets(args) {
19
+ walk(args, '', (value, path) => {
20
+ if (!isRecord(value))
21
+ return;
22
+ for (const field of Object.keys(value)) {
23
+ if (PROVIDER_KEY_FIELDS.has(field) && value[field] !== undefined && value[field] !== null) {
24
+ throw new RefusedArgument(`${join(path, field)} carries a provider key, which this tool refuses. ${SETTINGS_HINT}`);
25
+ }
26
+ }
27
+ if (value.isSecret === true && 'value' in value) {
28
+ throw new RefusedArgument(`${join(path, 'value')} is marked isSecret, which this tool refuses. ${SETTINGS_HINT}`);
29
+ }
30
+ });
31
+ }
32
+ /** Loose by design: a false positive costs a warning, a miss costs nothing more than today. */
33
+ const CREDENTIAL_SHAPES = [
34
+ { label: 'a Kosuke auth token', pattern: /\bkosuke_[A-Za-z0-9_-]{4,}/ },
35
+ { label: 'an Anthropic API key', pattern: /\bsk-ant-[A-Za-z0-9_-]{8,}/ },
36
+ { label: 'an OpenAI API key', pattern: /\bsk-(?!ant-)[A-Za-z0-9_-]{16,}/ },
37
+ { label: 'an AWS access key id', pattern: /\bAKIA[0-9A-Z]{16}\b/ },
38
+ {
39
+ label: 'a GitHub token',
40
+ pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,})/,
41
+ },
42
+ { label: 'a private key block', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
43
+ ];
44
+ /** One line per argument that looks like it quotes a credential; empty when none does. */
45
+ export function credentialWarnings(args) {
46
+ const warnings = [];
47
+ walk(args, '', (value, path) => {
48
+ if (typeof value !== 'string')
49
+ return;
50
+ for (const shape of CREDENTIAL_SHAPES) {
51
+ if (shape.pattern.test(value)) {
52
+ warnings.push(`${path || 'an argument'} looks like it contains ${shape.label}. It was sent as given; ` +
53
+ `if it is a real credential, it is now in the agent history and should be revoked.`);
54
+ }
55
+ }
56
+ });
57
+ return warnings;
58
+ }
59
+ function walk(value, path, visit) {
60
+ visit(value, path);
61
+ if (Array.isArray(value)) {
62
+ value.forEach((item, index) => walk(item, `${path}[${index}]`, visit));
63
+ }
64
+ else if (isRecord(value)) {
65
+ for (const [key, item] of Object.entries(value))
66
+ walk(item, join(path, key), visit);
67
+ }
68
+ }
69
+ function join(path, key) {
70
+ return path ? `${path}.${key}` : key;
71
+ }
72
+ function isRecord(value) {
73
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
74
+ }
@@ -0,0 +1,123 @@
1
+ import { buildRequest, execute, UsageError } from './invoke.js';
2
+ import { confineAttachment, stageAttachments } from './mcp-attachments.js';
3
+ import { credentialWarnings, refuseSecrets, RefusedArgument } from './mcp-guard.js';
4
+ import { redact } from './mcp-redact.js';
5
+ import { ATTACHMENTS_ARGUMENT, BODY_ARGUMENT } from './mcp-tool.js';
6
+ /** A tool name the server does not expose; distinct from bad arguments, since the cure differs. */
7
+ export class UnknownTool extends Error {
8
+ constructor(name) {
9
+ super(`Unknown tool "${name}". List the tools to see what this server exposes.`);
10
+ }
11
+ }
12
+ function inputFor(operation, args, policy) {
13
+ const names = new Set([...operation.query, ...(operation.body?.fields ?? []), ...(operation.multipart ?? [])].map((field) => field.name));
14
+ const fields = {};
15
+ for (const name of names)
16
+ if (args[name] !== undefined)
17
+ fields[name] = args[name];
18
+ const wholeBody = operation.body?.fields === null && args[BODY_ARGUMENT] !== undefined
19
+ ? args[BODY_ARGUMENT]
20
+ : undefined;
21
+ // Each attachment is an object so a second shape (inline bytes, for a server
22
+ // that does not share the caller's disk) can sit beside `path` later.
23
+ // Each path is confined before anything reads it.
24
+ const attachments = Array.isArray(args[ATTACHMENTS_ARGUMENT])
25
+ ? args[ATTACHMENTS_ARGUMENT].map((item) => confineAttachment(item.path, policy))
26
+ : [];
27
+ // A missing id would become an empty path segment and a 404 that blames the
28
+ // wrong thing; a composite tool handing one on is the likely source.
29
+ const pathParams = {};
30
+ for (const name of operation.pathParams) {
31
+ const value = args[name];
32
+ if (typeof value !== 'string' || !value.trim()) {
33
+ throw new UsageError(`${name} is required and cannot be empty.`);
34
+ }
35
+ pathParams[name] = value;
36
+ }
37
+ return {
38
+ pathParams,
39
+ fields,
40
+ ...(wholeBody !== undefined ? { wholeBody } : {}),
41
+ attachments,
42
+ };
43
+ }
44
+ /** Throws RefusedArgument or UsageError before any request leaves. */
45
+ export async function invokeTool(tool, args, context) {
46
+ refuseSecrets(args);
47
+ const warnings = credentialWarnings(args);
48
+ const input = inputFor(tool.operation, args, context.attachments);
49
+ // The shared request builder reads attachments by path; it gets private
50
+ // copies made from handles checked here, never the caller's paths again.
51
+ const staged = stageAttachments(input.attachments);
52
+ try {
53
+ let request;
54
+ try {
55
+ request = buildRequest(tool.operation, { ...input, attachments: staged.paths });
56
+ }
57
+ catch (error) {
58
+ if (error instanceof UsageError)
59
+ throw new UsageError(asToolUsage(tool, error.message));
60
+ throw error;
61
+ }
62
+ const outcome = await execute(context.clientFor(tool.operation.anonymous), request);
63
+ return { ok: outcome.ok, status: outcome.status, body: redact(outcome.body), warnings };
64
+ }
65
+ finally {
66
+ staged.cleanup();
67
+ }
68
+ }
69
+ /**
70
+ * The request builder speaks in flags, because the commands are its first
71
+ * caller. A model told to pass `--content` would try exactly that and be
72
+ * refused by the schema, so its messages are re-said in argument names.
73
+ */
74
+ function asToolUsage(tool, message) {
75
+ const { operation } = tool;
76
+ let text = message
77
+ .replace(`kosuke ${operation.command.join(' ')}`, tool.name)
78
+ .replace('pass it whole with --body, --body-file or on stdin', 'pass it whole as body')
79
+ .replace('pass --body, or --body-file with a path or - for stdin', 'pass body')
80
+ .replaceAll('--body', BODY_ARGUMENT);
81
+ const fields = [
82
+ ...operation.query,
83
+ ...(operation.body?.fields ?? []),
84
+ ...(operation.multipart ?? []),
85
+ ];
86
+ for (const field of fields)
87
+ text = text.replaceAll(`--${field.flag}`, field.name);
88
+ return text;
89
+ }
90
+ /**
91
+ * A success is the platform's own envelope, `{ data, meta? }`, with the
92
+ * warnings beside it when there are any; a failure is the error envelope
93
+ * plus the status, flagged so the model reads it as one.
94
+ */
95
+ export function toResult(invocation) {
96
+ const { ok, status, body, warnings } = invocation;
97
+ const extra = warnings.length ? { warnings } : {};
98
+ if (ok) {
99
+ const payload = body === undefined
100
+ ? { status, ...extra }
101
+ : typeof body === 'object' && body !== null && !Array.isArray(body)
102
+ ? { ...body, ...extra }
103
+ : { data: body, ...extra };
104
+ return { content: [text(payload)] };
105
+ }
106
+ const error = typeof body === 'object' && body !== null
107
+ ? body
108
+ : { error: body ?? `HTTP ${status}` };
109
+ return { content: [text({ status, ...error, ...extra })], isError: true };
110
+ }
111
+ export function errorResult(error) {
112
+ const message = error instanceof RefusedArgument
113
+ ? `Refused: ${error.message}`
114
+ : error instanceof UsageError
115
+ ? `Invalid arguments: ${error.message}`
116
+ : error instanceof Error
117
+ ? error.message
118
+ : String(error);
119
+ return { content: [{ type: 'text', text: message }], isError: true };
120
+ }
121
+ function text(value) {
122
+ return { type: 'text', text: JSON.stringify(value, null, 2) };
123
+ }
@@ -0,0 +1,83 @@
1
+ import { UsageError } from './invoke.js';
2
+ import { data, failed } from './mcp-composite-support.js';
3
+ /** `anthropic:sonnet__medium` names its provider; anything else does not. */
4
+ function providerOf(model) {
5
+ if (typeof model !== 'string')
6
+ return null;
7
+ const prefix = model.split(':')[0];
8
+ return prefix === 'anthropic' || prefix === 'openai' ? prefix : null;
9
+ }
10
+ /**
11
+ * The provider a session's next turn runs on. The session's own model says so
12
+ * once it has one. Before its first message, or when its stored id is no
13
+ * longer recognised, the platform picks the model the way `access-status`
14
+ * reports it for the caller (scope `member`): their saved preference, then
15
+ * their Subscription's provider, then the workspace's credentials. That is
16
+ * the same resolution the send route runs, so its `defaultModel` is the model
17
+ * the turn would get, and it is pinned on the send so it stays that way.
18
+ * `ai-provider` is not consulted: its `defaultModel` is always null there.
19
+ * No model at all is a refusal: a wrong trigger sends the skill as chat text
20
+ * and reports success.
21
+ */
22
+ export async function resolveProvider(session, projectId, context) {
23
+ const own = providerOf(session.model);
24
+ if (own)
25
+ return { provider: own, model: null };
26
+ const project = await context.call('projects_get', { projectId });
27
+ if (!project.ok)
28
+ return { failed: failed(project, 'projects_get') };
29
+ const workspaceId = data(project).orgId;
30
+ if (typeof workspaceId !== 'string' || !workspaceId) {
31
+ throw new UsageError('The project names no workspace, so the provider cannot be resolved.');
32
+ }
33
+ const access = await context.call('workspaces_access_status', { workspaceId, scope: 'member' });
34
+ if (!access.ok)
35
+ return { failed: failed(access, 'workspaces_access_status') };
36
+ const status = data(access);
37
+ const defaultModel = status.defaultModel;
38
+ const provider = providerOf(defaultModel);
39
+ if (provider)
40
+ return { provider, model: defaultModel };
41
+ const why = session.modelUnavailable === true
42
+ ? 'its model is no longer available'
43
+ : 'it has not run a turn yet';
44
+ const reason = typeof status.reason === 'string' && status.reason ? ` ${status.reason}` : '';
45
+ throw new UsageError(`This session has no model (${why}) and no provider is usable by you in this workspace, ` +
46
+ `so the provider cannot be told.${reason} Send one message with sessions_send and a ` +
47
+ `model, or connect a provider, then submit again.`);
48
+ }
49
+ /** The skill's own flags; a note that carries one would be read as one. */
50
+ const OPEN_PR_FLAGS = /^(-d|--draft|-r|--reviewers|-e|--evidence)$/;
51
+ export function openPrCommand(trigger, args) {
52
+ const parts = [`${trigger}open-pr`];
53
+ if (args.draft === true)
54
+ parts.push('-d');
55
+ const reviewers = names(args.reviewers, 'reviewers');
56
+ if (reviewers.length)
57
+ parts.push('-r', reviewers.join(','));
58
+ const evidence = names(args.evidence, 'evidence');
59
+ if (evidence.length)
60
+ parts.push('-e', evidence.join(','));
61
+ const notes = typeof args.notes === 'string' ? args.notes.trim() : '';
62
+ if (notes) {
63
+ if (notes.split(/\s+/).some((token) => OPEN_PR_FLAGS.test(token))) {
64
+ throw new UsageError('notes must not carry the skill flags (-d, -r, -e); use draft, reviewers and evidence.');
65
+ }
66
+ parts.push(notes);
67
+ }
68
+ return parts.join(' ');
69
+ }
70
+ /** One name per entry, trimmed; a comma or a space inside one would split it on the way in. */
71
+ function names(value, what) {
72
+ if (!Array.isArray(value))
73
+ return [];
74
+ return value
75
+ .map((item) => String(item).trim())
76
+ .filter(Boolean)
77
+ .map((item) => {
78
+ if (/[,\s]/.test(item)) {
79
+ throw new UsageError(`${what} entries are single names or links; "${item}" is not.`);
80
+ }
81
+ return item;
82
+ });
83
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * What leaves a tool result. The platform returns a project's secret
3
+ * environment values in plaintext (`isSecret` is a UI hint there) and the
4
+ * project's Sentry DSN on every project row; an MCP result is pasted into a
5
+ * transcript, so both are replaced before anything is returned. Applied to
6
+ * every result, composite or generated, since responses are not modelled in
7
+ * the document and a shape can appear on more than one operation.
8
+ */
9
+ export const REDACTED = '[redacted]';
10
+ /** `https://<public key>@o123.ingest.sentry.io/456`: the key part is the credential. */
11
+ const SENTRY_DSN = /^https?:\/\/[0-9a-f]{16,}@[^/\s]+\/\d+$/i;
12
+ export function redact(value) {
13
+ if (Array.isArray(value))
14
+ return value.map(redact);
15
+ if (typeof value === 'string')
16
+ return SENTRY_DSN.test(value) ? REDACTED : value;
17
+ if (typeof value !== 'object' || value === null)
18
+ return value;
19
+ const record = value;
20
+ const out = {};
21
+ for (const [key, item] of Object.entries(record)) {
22
+ if (key === 'sentryDsn' && typeof item === 'string')
23
+ out[key] = REDACTED;
24
+ else if (key === 'value' && record.isSecret === true && typeof item === 'string') {
25
+ out[key] = REDACTED;
26
+ }
27
+ else
28
+ out[key] = redact(item);
29
+ }
30
+ return out;
31
+ }