singleton-pipeline 0.4.0-beta.13 → 0.4.0-beta.14
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/dist/packages/cli/src/assets/singleton-logo.txt +10 -0
- package/dist/packages/cli/src/commands/new.js +763 -0
- package/dist/packages/cli/src/commands/repl.js +557 -0
- package/dist/packages/cli/src/commands/usage.js +49 -0
- package/dist/packages/cli/src/executor/debug-loop.js +525 -0
- package/dist/packages/cli/src/executor/inputs.js +226 -0
- package/dist/packages/cli/src/executor/outputs.js +134 -0
- package/dist/packages/cli/src/executor/preflight.js +605 -0
- package/dist/packages/cli/src/executor/replay-loop.js +120 -0
- package/dist/packages/cli/src/executor/run-report.js +209 -0
- package/dist/packages/cli/src/executor/run-setup.js +114 -0
- package/dist/packages/cli/src/executor/security-review.js +97 -0
- package/dist/packages/cli/src/executor/snapshot-manager.js +349 -0
- package/dist/packages/cli/src/executor/step-runner.js +241 -0
- package/dist/packages/cli/src/executor.js +584 -0
- package/dist/packages/cli/src/index.js +107 -0
- package/dist/packages/cli/src/parser.js +89 -0
- package/dist/packages/cli/src/runners/_shared.js +96 -0
- package/dist/packages/cli/src/runners/claude.js +103 -0
- package/dist/packages/cli/src/runners/codex-instructions.js +69 -0
- package/dist/packages/cli/src/runners/codex.js +141 -0
- package/dist/packages/cli/src/runners/copilot.js +209 -0
- package/dist/packages/cli/src/runners/index.js +18 -0
- package/dist/packages/cli/src/runners/opencode.js +240 -0
- package/dist/packages/cli/src/scanner.js +43 -0
- package/dist/packages/cli/src/security/policy.js +115 -0
- package/dist/packages/cli/src/sentinels.js +1 -0
- package/dist/packages/cli/src/shell.js +753 -0
- package/dist/packages/cli/src/theme.js +39 -0
- package/dist/packages/cli/src/timeline.js +238 -0
- package/dist/packages/cli/src/types.js +1 -0
- package/dist/packages/cli/src/usage/aggregator.js +44 -0
- package/dist/packages/cli/src/usage/reader.js +30 -0
- package/dist/packages/cli/src/usage/types.js +1 -0
- package/dist/packages/server/src/index.js +36 -0
- package/dist/packages/server/src/routes/agents.js +31 -0
- package/dist/packages/server/src/routes/files.js +45 -0
- package/dist/packages/server/src/routes/pipelines.js +74 -0
- package/docs/reference.md +28 -0
- package/package.json +15 -14
- package/packages/web/dist/assets/{index-CnKytBly.js → index-9S0goZlQ.js} +1 -1
- package/packages/web/dist/assets/{index-CCFWfCA2.css → index-iV4UtXoN.css} +1 -1
- package/packages/web/dist/assets/logo-COSyZmgk.png +0 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/cli/package.json +0 -18
- package/packages/cli/src/commands/new.js +0 -786
- package/packages/cli/src/commands/repl.js +0 -548
- package/packages/cli/src/executor/debug-loop.js +0 -587
- package/packages/cli/src/executor/inputs.js +0 -202
- package/packages/cli/src/executor/outputs.js +0 -140
- package/packages/cli/src/executor/preflight.js +0 -459
- package/packages/cli/src/executor/replay-loop.js +0 -172
- package/packages/cli/src/executor/run-report.js +0 -189
- package/packages/cli/src/executor/run-setup.js +0 -93
- package/packages/cli/src/executor/security-review.js +0 -108
- package/packages/cli/src/executor/snapshot-manager.js +0 -335
- package/packages/cli/src/executor/step-runner.js +0 -266
- package/packages/cli/src/executor.js +0 -652
- package/packages/cli/src/index.js +0 -107
- package/packages/cli/src/parser.js +0 -78
- package/packages/cli/src/runners/_shared.js +0 -83
- package/packages/cli/src/runners/claude.js +0 -122
- package/packages/cli/src/runners/codex-instructions.js +0 -75
- package/packages/cli/src/runners/codex.js +0 -165
- package/packages/cli/src/runners/copilot.js +0 -224
- package/packages/cli/src/runners/index.js +0 -20
- package/packages/cli/src/runners/opencode.js +0 -265
- package/packages/cli/src/scanner.js +0 -47
- package/packages/cli/src/security/policy.js +0 -126
- package/packages/cli/src/shell.js +0 -732
- package/packages/cli/src/theme.js +0 -46
- package/packages/cli/src/timeline.js +0 -180
- package/packages/server/package.json +0 -11
- package/packages/server/src/index.js +0 -43
- package/packages/server/src/routes/agents.js +0 -32
- package/packages/server/src/routes/files.js +0 -42
- package/packages/server/src/routes/pipelines.js +0 -74
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { input, search, select, confirm } from '@inquirer/prompts';
|
|
4
|
+
import { style, line } from '../theme.js';
|
|
5
|
+
import { scanAgents } from '../scanner.js';
|
|
6
|
+
import { S } from '../shell.js';
|
|
7
|
+
import { ESC_SENTINEL as ESC } from '../sentinels.js';
|
|
8
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
9
|
+
const CHOICE_NONE = { name: '(none)', value: '' };
|
|
10
|
+
const CLAUDE_MODELS = [
|
|
11
|
+
{ name: 'claude-opus-4-7', value: 'claude-opus-4-7' },
|
|
12
|
+
{ name: 'claude-sonnet-4-6', value: 'claude-sonnet-4-6' },
|
|
13
|
+
{ name: 'claude-haiku-4-5', value: 'claude-haiku-4-5' },
|
|
14
|
+
CHOICE_NONE,
|
|
15
|
+
];
|
|
16
|
+
const CODEX_MODELS = [
|
|
17
|
+
{ name: 'gpt-5.4', value: 'gpt-5.4' },
|
|
18
|
+
{ name: 'gpt-5-codex', value: 'gpt-5-codex' },
|
|
19
|
+
{ name: 'gpt-5.2-codex', value: 'gpt-5.2-codex' },
|
|
20
|
+
{ name: 'gpt-5.1-codex', value: 'gpt-5.1-codex' },
|
|
21
|
+
CHOICE_NONE,
|
|
22
|
+
];
|
|
23
|
+
const COPILOT_MODELS = [
|
|
24
|
+
{ name: 'gpt-5.4-mini', value: 'gpt-5.4-mini' },
|
|
25
|
+
{ name: 'gpt-5.4', value: 'gpt-5.4' },
|
|
26
|
+
{ name: 'gpt-4.1', value: 'gpt-4.1' },
|
|
27
|
+
CHOICE_NONE,
|
|
28
|
+
];
|
|
29
|
+
const OPENCODE_MODELS = [
|
|
30
|
+
{ name: 'ollama/qwen2.5-coder:14b', value: 'ollama/qwen2.5-coder:14b' },
|
|
31
|
+
{ name: 'ollama/qwen2.5-coder:7b', value: 'ollama/qwen2.5-coder:7b' },
|
|
32
|
+
{ name: 'anthropic/claude-sonnet-4-6', value: 'anthropic/claude-sonnet-4-6' },
|
|
33
|
+
CHOICE_NONE,
|
|
34
|
+
];
|
|
35
|
+
const PROVIDERS = [
|
|
36
|
+
{ name: 'claude', value: 'claude' },
|
|
37
|
+
{ name: 'codex', value: 'codex' },
|
|
38
|
+
{ name: 'copilot', value: 'copilot' },
|
|
39
|
+
{ name: 'opencode', value: 'opencode' },
|
|
40
|
+
];
|
|
41
|
+
const CLAUDE_PERMISSION_MODES = [
|
|
42
|
+
{ name: '(safe default)', value: '' },
|
|
43
|
+
{ name: 'bypassPermissions', value: 'bypassPermissions' },
|
|
44
|
+
];
|
|
45
|
+
const DEFAULT_AGENTS_DIR = '.singleton/agents';
|
|
46
|
+
const CLAUDE_DEFAULT_MODEL = 'claude-sonnet-4-6';
|
|
47
|
+
const CODEX_DEFAULT_MODEL = 'gpt-5.4';
|
|
48
|
+
const COPILOT_DEFAULT_MODEL = 'gpt-5.4-mini';
|
|
49
|
+
const OPENCODE_DEFAULT_MODEL = 'ollama/qwen2.5-coder:14b';
|
|
50
|
+
function uniqueSorted(values) {
|
|
51
|
+
return [...new Set(values.filter(Boolean).map(String))].sort();
|
|
52
|
+
}
|
|
53
|
+
function defaultTitleFromId(id) {
|
|
54
|
+
return id
|
|
55
|
+
.split('-')
|
|
56
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
57
|
+
.join(' ');
|
|
58
|
+
}
|
|
59
|
+
function parseCsvList(value) {
|
|
60
|
+
return uniqueSorted(String(value || '')
|
|
61
|
+
.split(',')
|
|
62
|
+
.map((s) => s.trim())
|
|
63
|
+
.filter(Boolean));
|
|
64
|
+
}
|
|
65
|
+
async function loadAgentCreationContext(root) {
|
|
66
|
+
const existing = await scanAgents(root);
|
|
67
|
+
return {
|
|
68
|
+
root,
|
|
69
|
+
existing,
|
|
70
|
+
existingIds: new Set(existing.map((a) => a.id)),
|
|
71
|
+
existingOutputs: uniqueSorted(existing.flatMap((a) => a.outputs)),
|
|
72
|
+
existingInputs: uniqueSorted(existing.flatMap((a) => a.inputs)),
|
|
73
|
+
existingTags: uniqueSorted(existing.flatMap((a) => a.tags)),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function inputSuggestionsFromContext(context) {
|
|
77
|
+
return uniqueSorted([...context.existingOutputs, ...context.existingInputs]);
|
|
78
|
+
}
|
|
79
|
+
function validateAgentId(existingIds, value) {
|
|
80
|
+
if (!SLUG_RE.test(value))
|
|
81
|
+
return 'invalid slug (a-z, 0-9, hyphens)';
|
|
82
|
+
if (existingIds.has(value))
|
|
83
|
+
return `id "${value}" is already used`;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
function defaultModelForProvider(provider) {
|
|
87
|
+
if (provider === 'codex')
|
|
88
|
+
return CODEX_DEFAULT_MODEL;
|
|
89
|
+
if (provider === 'copilot')
|
|
90
|
+
return COPILOT_DEFAULT_MODEL;
|
|
91
|
+
if (provider === 'opencode')
|
|
92
|
+
return OPENCODE_DEFAULT_MODEL;
|
|
93
|
+
return CLAUDE_DEFAULT_MODEL;
|
|
94
|
+
}
|
|
95
|
+
function permissionChoicesForProvider(provider) {
|
|
96
|
+
return provider === 'claude' ? CLAUDE_PERMISSION_MODES : [CHOICE_NONE];
|
|
97
|
+
}
|
|
98
|
+
function normalizeAgentDraft(draft) {
|
|
99
|
+
return {
|
|
100
|
+
...draft,
|
|
101
|
+
id: draft.id || '',
|
|
102
|
+
title: draft.title || '',
|
|
103
|
+
description: draft.description || '',
|
|
104
|
+
provider: draft.provider || '',
|
|
105
|
+
inputs: uniqueSorted(draft.inputs || []),
|
|
106
|
+
outputs: uniqueSorted(draft.outputs || []),
|
|
107
|
+
tags: uniqueSorted(draft.tags || []),
|
|
108
|
+
permissionMode: draft.provider === 'claude' ? (draft.permissionMode || '') : '',
|
|
109
|
+
runnerAgent: ['copilot', 'opencode'].includes(draft.provider || '') ? (draft.runnerAgent || '') : '',
|
|
110
|
+
model: draft.model || '',
|
|
111
|
+
estimatedTokens: draft.estimatedTokens || '',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
async function writeAgentDraft({ root, draft, askOverwrite }) {
|
|
115
|
+
const filename = draft.filename.endsWith('.md') ? draft.filename : `${draft.filename}.md`;
|
|
116
|
+
const targetDir = path.resolve(root, DEFAULT_AGENTS_DIR);
|
|
117
|
+
const targetFile = path.join(targetDir, filename);
|
|
118
|
+
try {
|
|
119
|
+
await fs.access(targetFile);
|
|
120
|
+
const overwrite = await askOverwrite(path.relative(root, targetFile));
|
|
121
|
+
if (!overwrite)
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// file doesn't exist
|
|
126
|
+
}
|
|
127
|
+
const content = renderAgentFile(normalizeAgentDraft(draft));
|
|
128
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
129
|
+
await fs.writeFile(targetFile, content);
|
|
130
|
+
return targetFile;
|
|
131
|
+
}
|
|
132
|
+
function modelChoicesForProvider(provider) {
|
|
133
|
+
if (provider === 'codex')
|
|
134
|
+
return CODEX_MODELS;
|
|
135
|
+
if (provider === 'copilot')
|
|
136
|
+
return COPILOT_MODELS;
|
|
137
|
+
if (provider === 'opencode')
|
|
138
|
+
return OPENCODE_MODELS;
|
|
139
|
+
return CLAUDE_MODELS;
|
|
140
|
+
}
|
|
141
|
+
const DONE = '__DONE__';
|
|
142
|
+
const UNDO = '__UNDO__';
|
|
143
|
+
const SLUG_ITEM_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
144
|
+
async function collectList({ message, existing }) {
|
|
145
|
+
const picked = [];
|
|
146
|
+
while (true) {
|
|
147
|
+
const summary = picked.length ? style.muted(`[${picked.join(', ')}]`) : '';
|
|
148
|
+
const answer = await search({
|
|
149
|
+
message: summary ? `${message} ${summary}` : message,
|
|
150
|
+
source: async (term) => {
|
|
151
|
+
const t = (term || '').trim();
|
|
152
|
+
const choices = [];
|
|
153
|
+
if (picked.length > 0) {
|
|
154
|
+
choices.push({
|
|
155
|
+
name: `← remove "${picked[picked.length - 1]}"`,
|
|
156
|
+
value: UNDO
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const avail = existing.filter((v) => !picked.includes(v));
|
|
160
|
+
const matching = t
|
|
161
|
+
? avail.filter((v) => v.toLowerCase().includes(t.toLowerCase()))
|
|
162
|
+
: avail;
|
|
163
|
+
for (const v of matching)
|
|
164
|
+
choices.push({ name: v, value: v });
|
|
165
|
+
if (t && SLUG_ITEM_RE.test(t) && !existing.includes(t) && !picked.includes(t)) {
|
|
166
|
+
choices.push({ name: `+ create "${t}"`, value: t });
|
|
167
|
+
}
|
|
168
|
+
choices.push({ name: '✓ finish', value: DONE });
|
|
169
|
+
return choices;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
if (answer === DONE)
|
|
173
|
+
break;
|
|
174
|
+
if (answer === UNDO) {
|
|
175
|
+
picked.pop();
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!picked.includes(String(answer)))
|
|
179
|
+
picked.push(String(answer));
|
|
180
|
+
}
|
|
181
|
+
return picked;
|
|
182
|
+
}
|
|
183
|
+
export async function newAgentCommand(opts = {}) {
|
|
184
|
+
const root = path.resolve(opts.root || process.cwd());
|
|
185
|
+
const context = await loadAgentCreationContext(root);
|
|
186
|
+
const inputSuggestions = inputSuggestionsFromContext(context);
|
|
187
|
+
const id = await input({
|
|
188
|
+
message: 'id',
|
|
189
|
+
validate: (v) => validateAgentId(context.existingIds, v),
|
|
190
|
+
});
|
|
191
|
+
const title = await input({
|
|
192
|
+
message: 'title',
|
|
193
|
+
default: id
|
|
194
|
+
.split('-')
|
|
195
|
+
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
|
|
196
|
+
.join(' ')
|
|
197
|
+
});
|
|
198
|
+
const description = await input({
|
|
199
|
+
message: 'description',
|
|
200
|
+
validate: (v) => (v.trim() ? true : 'required')
|
|
201
|
+
});
|
|
202
|
+
const inputs = await collectList({
|
|
203
|
+
message: 'inputs',
|
|
204
|
+
existing: inputSuggestions,
|
|
205
|
+
});
|
|
206
|
+
const outputs = await collectList({
|
|
207
|
+
message: 'outputs',
|
|
208
|
+
existing: context.existingOutputs,
|
|
209
|
+
});
|
|
210
|
+
const tags = await collectList({
|
|
211
|
+
message: 'tags',
|
|
212
|
+
existing: context.existingTags,
|
|
213
|
+
});
|
|
214
|
+
const provider = await select({
|
|
215
|
+
message: 'provider',
|
|
216
|
+
choices: PROVIDERS,
|
|
217
|
+
default: 'claude'
|
|
218
|
+
});
|
|
219
|
+
const model = await select({
|
|
220
|
+
message: 'model',
|
|
221
|
+
choices: modelChoicesForProvider(provider),
|
|
222
|
+
default: defaultModelForProvider(provider),
|
|
223
|
+
});
|
|
224
|
+
const permissionMode = provider === 'claude'
|
|
225
|
+
? await select({
|
|
226
|
+
message: 'permission_mode',
|
|
227
|
+
choices: permissionChoicesForProvider(provider),
|
|
228
|
+
default: '',
|
|
229
|
+
})
|
|
230
|
+
: '';
|
|
231
|
+
const runnerAgent = ['copilot', 'opencode'].includes(provider)
|
|
232
|
+
? await input({
|
|
233
|
+
message: 'runner_agent',
|
|
234
|
+
default: id,
|
|
235
|
+
})
|
|
236
|
+
: '';
|
|
237
|
+
const estimatedRaw = await input({
|
|
238
|
+
message: 'estimated_tokens',
|
|
239
|
+
default: '',
|
|
240
|
+
validate: (v) => (v === '' || /^\d+$/.test(v) ? true : 'expected an integer')
|
|
241
|
+
});
|
|
242
|
+
const filename = await input({
|
|
243
|
+
message: 'file',
|
|
244
|
+
default: `${id}.md`,
|
|
245
|
+
validate: (v) => (v.endsWith('.md') ? true : 'must end with .md')
|
|
246
|
+
});
|
|
247
|
+
const targetFile = await writeAgentDraft({
|
|
248
|
+
root,
|
|
249
|
+
draft: {
|
|
250
|
+
title,
|
|
251
|
+
id,
|
|
252
|
+
description,
|
|
253
|
+
inputs,
|
|
254
|
+
outputs,
|
|
255
|
+
tags,
|
|
256
|
+
provider,
|
|
257
|
+
model,
|
|
258
|
+
runnerAgent,
|
|
259
|
+
permissionMode,
|
|
260
|
+
estimatedTokens: estimatedRaw,
|
|
261
|
+
filename,
|
|
262
|
+
},
|
|
263
|
+
askOverwrite: (relativeFile) => confirm({
|
|
264
|
+
message: `${relativeFile} already exists. Overwrite?`,
|
|
265
|
+
default: false,
|
|
266
|
+
}),
|
|
267
|
+
});
|
|
268
|
+
if (!targetFile)
|
|
269
|
+
return;
|
|
270
|
+
console.log(style.muted(`Canonical directory: ${DEFAULT_AGENTS_DIR}`));
|
|
271
|
+
console.log(line.success(path.relative(root, targetFile)));
|
|
272
|
+
}
|
|
273
|
+
const SECTIONS = [
|
|
274
|
+
{
|
|
275
|
+
title: 'identity',
|
|
276
|
+
hint: 'Who this agent is and what it does.',
|
|
277
|
+
fields: ['id', 'title', 'description'],
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
title: 'schema',
|
|
281
|
+
hint: 'What it consumes and produces.',
|
|
282
|
+
fields: ['inputs', 'outputs', 'tags'],
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
title: 'runtime',
|
|
286
|
+
hint: 'How it executes and which model it uses.',
|
|
287
|
+
fields: ['provider', 'model', 'permissionMode', 'runnerAgent'],
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
title: 'meta',
|
|
291
|
+
hint: 'File location and token budget.',
|
|
292
|
+
fields: ['estimatedTokens', 'file'],
|
|
293
|
+
},
|
|
294
|
+
];
|
|
295
|
+
const FIELD_LABELS = {
|
|
296
|
+
id: 'id',
|
|
297
|
+
title: 'title',
|
|
298
|
+
description: 'description',
|
|
299
|
+
inputs: 'inputs',
|
|
300
|
+
outputs: 'outputs',
|
|
301
|
+
tags: 'tags',
|
|
302
|
+
provider: 'provider',
|
|
303
|
+
model: 'model',
|
|
304
|
+
permissionMode: 'permission mode',
|
|
305
|
+
runnerAgent: 'runner agent',
|
|
306
|
+
estimatedTokens: 'estimated tokens',
|
|
307
|
+
file: 'file',
|
|
308
|
+
};
|
|
309
|
+
function isFieldVisible(name, draft) {
|
|
310
|
+
if (name === 'permissionMode')
|
|
311
|
+
return draft.provider === 'claude';
|
|
312
|
+
if (name === 'runnerAgent')
|
|
313
|
+
return ['copilot', 'opencode'].includes(draft.provider || '');
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
function buildOrder(draft) {
|
|
317
|
+
const order = [];
|
|
318
|
+
for (const section of SECTIONS) {
|
|
319
|
+
for (const name of section.fields) {
|
|
320
|
+
if (!isFieldVisible(name, draft))
|
|
321
|
+
continue;
|
|
322
|
+
order.push({ section: section.title, name });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return order;
|
|
326
|
+
}
|
|
327
|
+
function formatValue(name, value) {
|
|
328
|
+
if (value === null || value === undefined)
|
|
329
|
+
return null;
|
|
330
|
+
if (Array.isArray(value))
|
|
331
|
+
return value.length ? value.join(', ') : null;
|
|
332
|
+
if (value === '')
|
|
333
|
+
return null;
|
|
334
|
+
return String(value);
|
|
335
|
+
}
|
|
336
|
+
function defaultFor(name, draft) {
|
|
337
|
+
if (name === 'title')
|
|
338
|
+
return draft.id ? defaultTitleFromId(draft.id) : '';
|
|
339
|
+
if (name === 'provider')
|
|
340
|
+
return 'claude';
|
|
341
|
+
if (name === 'model')
|
|
342
|
+
return defaultModelForProvider(draft.provider);
|
|
343
|
+
if (name === 'runnerAgent')
|
|
344
|
+
return draft.id || '';
|
|
345
|
+
if (name === 'file')
|
|
346
|
+
return draft.id ? `${draft.id}.md` : '';
|
|
347
|
+
if (name === 'permissionMode')
|
|
348
|
+
return '';
|
|
349
|
+
return '';
|
|
350
|
+
}
|
|
351
|
+
function suggestionsFor(name, draft, context) {
|
|
352
|
+
if (name === 'inputs')
|
|
353
|
+
return uniqueSorted([...context.existingOutputs, ...context.existingInputs]);
|
|
354
|
+
if (name === 'outputs')
|
|
355
|
+
return context.existingOutputs;
|
|
356
|
+
if (name === 'tags')
|
|
357
|
+
return context.existingTags;
|
|
358
|
+
if (name === 'provider')
|
|
359
|
+
return PROVIDERS.map((p) => p.value);
|
|
360
|
+
if (name === 'model')
|
|
361
|
+
return modelChoicesForProvider(draft.provider).map((m) => m.value).filter(Boolean);
|
|
362
|
+
if (name === 'permissionMode')
|
|
363
|
+
return ['(default)', 'bypassPermissions'];
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
// Builds a `shell.prompt({ completer })` callback for a scalar field. Items match
|
|
367
|
+
// the buffer substring, case-insensitively. Returns null when no autocomplete applies.
|
|
368
|
+
function buildScalarCompleter(name, draft, context) {
|
|
369
|
+
if (!['provider', 'model', 'permissionMode'].includes(name))
|
|
370
|
+
return null;
|
|
371
|
+
const items = suggestionsFor(name, draft, context);
|
|
372
|
+
if (!items.length)
|
|
373
|
+
return null;
|
|
374
|
+
return ({ buffer }) => {
|
|
375
|
+
const t = String(buffer || '').toLowerCase();
|
|
376
|
+
return items
|
|
377
|
+
.filter((v) => v.toLowerCase().includes(t))
|
|
378
|
+
.map((v) => ({ label: v, value: v }));
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
// Completer for list fields: filters existing repo values, excludes already-picked
|
|
382
|
+
// items, and offers a `+ create "<term>"` row when the user types a fresh slug.
|
|
383
|
+
function buildListCompleter(name, draft, context, picked) {
|
|
384
|
+
const all = suggestionsFor(name, draft, context);
|
|
385
|
+
return ({ buffer }) => {
|
|
386
|
+
const t = String(buffer || '').trim();
|
|
387
|
+
const tl = t.toLowerCase();
|
|
388
|
+
const avail = all.filter((v) => !picked.includes(v));
|
|
389
|
+
const matching = tl
|
|
390
|
+
? avail.filter((v) => v.toLowerCase().includes(tl))
|
|
391
|
+
: avail;
|
|
392
|
+
const items = matching.map((v) => ({ label: v, value: v }));
|
|
393
|
+
if (t && SLUG_ITEM_RE.test(t) && !all.includes(t) && !picked.includes(t)) {
|
|
394
|
+
items.push({ label: `+ create "${t}"`, value: t });
|
|
395
|
+
}
|
|
396
|
+
return items;
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function validateFieldRaw(name, value, draft, context) {
|
|
400
|
+
if (name === 'id') {
|
|
401
|
+
const verdict = validateAgentId(context.existingIds, value);
|
|
402
|
+
return verdict === true ? null : verdict;
|
|
403
|
+
}
|
|
404
|
+
if (name === 'description' && !value.trim())
|
|
405
|
+
return 'required';
|
|
406
|
+
if (name === 'outputs' && parseCsvList(value).length === 0)
|
|
407
|
+
return 'at least one output is required';
|
|
408
|
+
if (name === 'provider' && !PROVIDERS.some((p) => p.value === value)) {
|
|
409
|
+
return `choose one of: ${PROVIDERS.map((p) => p.value).join(', ')}`;
|
|
410
|
+
}
|
|
411
|
+
if (name === 'model') {
|
|
412
|
+
const allowed = modelChoicesForProvider(draft.provider).map((m) => m.value);
|
|
413
|
+
if (value && !allowed.includes(value))
|
|
414
|
+
return `unknown model for ${draft.provider}`;
|
|
415
|
+
}
|
|
416
|
+
if (name === 'permissionMode') {
|
|
417
|
+
if (value && value !== '(default)' && value !== 'bypassPermissions') {
|
|
418
|
+
return 'choose: (default) or bypassPermissions';
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (name === 'estimatedTokens' && value !== '' && !/^\d+$/.test(value))
|
|
422
|
+
return 'expected an integer';
|
|
423
|
+
if (name === 'file' && !value.endsWith('.md'))
|
|
424
|
+
return 'must end with .md';
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
function parseFieldValue(name, value) {
|
|
428
|
+
if (['inputs', 'outputs', 'tags'].includes(name))
|
|
429
|
+
return parseCsvList(value);
|
|
430
|
+
if (name === 'permissionMode' && (value === '' || value === '(default)'))
|
|
431
|
+
return '';
|
|
432
|
+
return value;
|
|
433
|
+
}
|
|
434
|
+
function renderForm(shell, { draft, activeField, currentStep, totalSteps, context, error = null }) {
|
|
435
|
+
const lines = [];
|
|
436
|
+
lines.push('');
|
|
437
|
+
lines.push(`{${S.text}-fg}{bold}New agent{/}` +
|
|
438
|
+
` {${S.subtle}-fg}·{/} {${S.muted}-fg}step ${currentStep}/${totalSteps}{/}` +
|
|
439
|
+
` {${S.subtle}-fg}·{/} {${S.keyword}-fg}${FIELD_LABELS[activeField]}{/}`);
|
|
440
|
+
lines.push(` {${S.subtle}-fg}:back to revisit · :cancel to abort{/}`);
|
|
441
|
+
lines.push('');
|
|
442
|
+
for (const section of SECTIONS) {
|
|
443
|
+
const visible = section.fields.filter((name) => isFieldVisible(name, draft));
|
|
444
|
+
if (!visible.length)
|
|
445
|
+
continue;
|
|
446
|
+
lines.push(` {${S.muted}-fg}{bold}${section.title}{/} {${S.subtle}-fg}${section.hint}{/}`);
|
|
447
|
+
for (const name of visible) {
|
|
448
|
+
const label = FIELD_LABELS[name].padEnd(16);
|
|
449
|
+
const value = formatValue(name, draft[name]);
|
|
450
|
+
const isActive = name === activeField;
|
|
451
|
+
if (isActive) {
|
|
452
|
+
lines.push(` {${S.accent}-fg}▸{/} {${S.text}-fg}{bold}${label}{/} {${S.muted}-fg}…{/}`);
|
|
453
|
+
}
|
|
454
|
+
else if (value !== null) {
|
|
455
|
+
lines.push(` {${S.muted}-fg}${label}{/} {${S.text}-fg}${value}{/}`);
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
lines.push(` {${S.muted}-fg}${label}{/} {${S.subtle}-fg}—{/}`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
lines.push('');
|
|
462
|
+
}
|
|
463
|
+
const suggestions = suggestionsFor(activeField, draft, context);
|
|
464
|
+
if (suggestions.length) {
|
|
465
|
+
const label = ['inputs', 'outputs', 'tags'].includes(activeField) ? 'existing' : 'options';
|
|
466
|
+
lines.push(` {${S.muted}-fg}${label}:{/} {${S.subtle}-fg}${suggestions.join(', ')}{/}`);
|
|
467
|
+
}
|
|
468
|
+
// The default is rendered as ghost text inside the prompt bar itself
|
|
469
|
+
// (shell.prompt({ default })), so we don't duplicate it inline here.
|
|
470
|
+
if (error) {
|
|
471
|
+
lines.push('');
|
|
472
|
+
lines.push(` {${S.error}-fg}✕ ${error}{/}`);
|
|
473
|
+
}
|
|
474
|
+
shell.setContent(lines.join('\n'));
|
|
475
|
+
}
|
|
476
|
+
// Review render — same shape as renderForm but no active field marker, and a
|
|
477
|
+
// concise header tailored to the confirmation step. The full target path is
|
|
478
|
+
// shown in the prompt bar message, not duplicated here.
|
|
479
|
+
/**
|
|
480
|
+
* @param {any} shell
|
|
481
|
+
* @param {{ draft: any, error?: string | null }} options
|
|
482
|
+
*/
|
|
483
|
+
function renderReview(shell, { draft, error = null }) {
|
|
484
|
+
const lines = [];
|
|
485
|
+
lines.push('');
|
|
486
|
+
lines.push(`{${S.text}-fg}{bold}New agent{/} {${S.subtle}-fg}·{/} {${S.muted}-fg}review{/}`);
|
|
487
|
+
lines.push('');
|
|
488
|
+
for (const section of SECTIONS) {
|
|
489
|
+
const visible = section.fields.filter((name) => isFieldVisible(name, draft));
|
|
490
|
+
if (!visible.length)
|
|
491
|
+
continue;
|
|
492
|
+
lines.push(` {${S.muted}-fg}{bold}${section.title}{/} {${S.subtle}-fg}${section.hint}{/}`);
|
|
493
|
+
for (const name of visible) {
|
|
494
|
+
const label = FIELD_LABELS[name].padEnd(16);
|
|
495
|
+
const value = formatValue(name, draft[name]);
|
|
496
|
+
if (value !== null) {
|
|
497
|
+
lines.push(` {${S.muted}-fg}${label}{/} {${S.text}-fg}${value}{/}`);
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
lines.push(` {${S.muted}-fg}${label}{/} {${S.subtle}-fg}—{/}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
lines.push('');
|
|
504
|
+
}
|
|
505
|
+
if (error) {
|
|
506
|
+
lines.push(` {${S.error}-fg}✕ ${error}{/}`);
|
|
507
|
+
}
|
|
508
|
+
shell.setContent(lines.join('\n'));
|
|
509
|
+
}
|
|
510
|
+
function cancelled(shell) {
|
|
511
|
+
shell.setMode(null);
|
|
512
|
+
shell.clear();
|
|
513
|
+
shell.log(`{${S.muted}-fg}/new cancelled{/}`);
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
// One-token-at-a-time collection sub-loop for list fields. Renders the form
|
|
517
|
+
// after each pick so the user sees the growing list. Returns 'done' / 'back' /
|
|
518
|
+
// 'cancelled'.
|
|
519
|
+
async function collectListInShell({ shell, draft, name, context, renderForField, }) {
|
|
520
|
+
const picked = Array.isArray(draft[name]) ? [...draft[name]] : [];
|
|
521
|
+
const isRequired = name === 'outputs';
|
|
522
|
+
let error = null;
|
|
523
|
+
while (true) {
|
|
524
|
+
draft[name] = picked.length ? picked : null;
|
|
525
|
+
renderForField(error);
|
|
526
|
+
error = null;
|
|
527
|
+
const message = picked.length
|
|
528
|
+
? `${FIELD_LABELS[name]} + ${picked.length} picked · empty Enter to finish · :pop to undo last`
|
|
529
|
+
: `${FIELD_LABELS[name]} ${isRequired ? 'pick at least one' : 'empty Enter to skip'}`;
|
|
530
|
+
const completer = buildListCompleter(name, draft, context, picked);
|
|
531
|
+
const answer = await shell.prompt(message, { silent: true, completer });
|
|
532
|
+
if (answer === ESC)
|
|
533
|
+
return 'cancelled';
|
|
534
|
+
const t = String(answer || '').trim();
|
|
535
|
+
if (t === ':cancel' || t === ':q')
|
|
536
|
+
return 'cancelled';
|
|
537
|
+
if (t === ':back') {
|
|
538
|
+
draft[name] = null;
|
|
539
|
+
return 'back';
|
|
540
|
+
}
|
|
541
|
+
if (t === ':pop') {
|
|
542
|
+
if (picked.length > 0)
|
|
543
|
+
picked.pop();
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (t === '') {
|
|
547
|
+
if (isRequired && picked.length === 0) {
|
|
548
|
+
error = 'at least one output is required';
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
draft[name] = picked;
|
|
552
|
+
return 'done';
|
|
553
|
+
}
|
|
554
|
+
if (!SLUG_ITEM_RE.test(t)) {
|
|
555
|
+
error = `invalid slug "${t}" (a-z, 0-9, hyphens, underscores)`;
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (picked.includes(t)) {
|
|
559
|
+
error = `"${t}" already picked`;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
picked.push(t);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// Run the form loop starting at `startIndex`, mutating `draft` in place.
|
|
566
|
+
// Returns 'done' when all fields are filled, or 'cancelled' if the user aborted.
|
|
567
|
+
async function runForm({ shell, draft, context, startIndex = 0, }) {
|
|
568
|
+
let order = buildOrder(draft);
|
|
569
|
+
let i = Math.min(startIndex, order.length - 1);
|
|
570
|
+
let lastError = null;
|
|
571
|
+
while (i < order.length) {
|
|
572
|
+
const name = order[i].name;
|
|
573
|
+
const isList = ['inputs', 'outputs', 'tags'].includes(name);
|
|
574
|
+
const renderForField = (errOverride = null) => renderForm(shell, {
|
|
575
|
+
draft,
|
|
576
|
+
activeField: name,
|
|
577
|
+
currentStep: i + 1,
|
|
578
|
+
totalSteps: order.length,
|
|
579
|
+
context,
|
|
580
|
+
error: errOverride ?? lastError,
|
|
581
|
+
});
|
|
582
|
+
if (isList) {
|
|
583
|
+
lastError = null;
|
|
584
|
+
const result = await collectListInShell({ shell, draft, name, context, renderForField });
|
|
585
|
+
if (result === 'cancelled')
|
|
586
|
+
return 'cancelled';
|
|
587
|
+
if (result === 'back') {
|
|
588
|
+
if (i > 0) {
|
|
589
|
+
i -= 1;
|
|
590
|
+
draft[order[i].name] = null;
|
|
591
|
+
order = buildOrder(draft);
|
|
592
|
+
}
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
i += 1;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
renderForField();
|
|
599
|
+
lastError = null;
|
|
600
|
+
const def = defaultFor(name, draft);
|
|
601
|
+
const completer = buildScalarCompleter(name, draft, context);
|
|
602
|
+
const answer = await shell.prompt(FIELD_LABELS[name], {
|
|
603
|
+
silent: true,
|
|
604
|
+
completer,
|
|
605
|
+
default: def,
|
|
606
|
+
});
|
|
607
|
+
if (answer === ESC)
|
|
608
|
+
return 'cancelled';
|
|
609
|
+
const trimmed = String(answer || '').trim();
|
|
610
|
+
if (trimmed === ':cancel' || trimmed === ':q')
|
|
611
|
+
return 'cancelled';
|
|
612
|
+
if (trimmed === ':back') {
|
|
613
|
+
if (i > 0) {
|
|
614
|
+
i -= 1;
|
|
615
|
+
draft[order[i].name] = null;
|
|
616
|
+
order = buildOrder(draft);
|
|
617
|
+
}
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
// Shell already substituted the default when buffer was empty; treat the answer as-is.
|
|
621
|
+
const raw = trimmed;
|
|
622
|
+
const err = validateFieldRaw(name, raw, draft, context);
|
|
623
|
+
if (err) {
|
|
624
|
+
lastError = err;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
draft[name] = parseFieldValue(name, raw);
|
|
628
|
+
if (name === 'provider') {
|
|
629
|
+
// Provider change adds/removes conditional fields; reset model if it's
|
|
630
|
+
// no longer valid for the new provider.
|
|
631
|
+
const allowedModels = modelChoicesForProvider(draft.provider).map((m) => m.value);
|
|
632
|
+
if (draft.model && !allowedModels.includes(draft.model))
|
|
633
|
+
draft.model = null;
|
|
634
|
+
order = buildOrder(draft);
|
|
635
|
+
}
|
|
636
|
+
i += 1;
|
|
637
|
+
}
|
|
638
|
+
return 'done';
|
|
639
|
+
}
|
|
640
|
+
export async function newAgentShellCommand({ root, shell }) {
|
|
641
|
+
const absRoot = path.resolve(root || process.cwd());
|
|
642
|
+
const context = await loadAgentCreationContext(absRoot);
|
|
643
|
+
const draft = {
|
|
644
|
+
id: null, title: null, description: null,
|
|
645
|
+
inputs: null, outputs: null, tags: null,
|
|
646
|
+
provider: null, model: null,
|
|
647
|
+
permissionMode: null, runnerAgent: null,
|
|
648
|
+
estimatedTokens: null, file: null,
|
|
649
|
+
};
|
|
650
|
+
shell.setMode('running');
|
|
651
|
+
const formResult = await runForm({ shell, draft, context });
|
|
652
|
+
if (formResult === 'cancelled')
|
|
653
|
+
return cancelled(shell);
|
|
654
|
+
// ── Confirm + write ────────────────────────────────────────────────────
|
|
655
|
+
let targetDir = DEFAULT_AGENTS_DIR;
|
|
656
|
+
let confirmError = null;
|
|
657
|
+
while (true) {
|
|
658
|
+
renderReview(shell, { draft, error: confirmError });
|
|
659
|
+
confirmError = null;
|
|
660
|
+
const fullPath = path.join(targetDir, String(draft.file));
|
|
661
|
+
// The prompt message itself carries the target path and the inline action
|
|
662
|
+
// hints, so the form panel above stays clean and the user reads everything
|
|
663
|
+
// in one place. Tagged content is rendered verbatim by updatePrompt.
|
|
664
|
+
const promptMessage = `{${S.warning}-fg}{bold}write to{/} {${S.keyword}-fg}${fullPath}{/}` +
|
|
665
|
+
` {${S.subtle}-fg}· :dir · :back · :cancel{/}`;
|
|
666
|
+
const answer = await shell.prompt(promptMessage, { silent: true });
|
|
667
|
+
if (answer === ESC)
|
|
668
|
+
return cancelled(shell);
|
|
669
|
+
const trimmed = String(answer || '').trim();
|
|
670
|
+
if (trimmed === ':cancel' || trimmed === ':q')
|
|
671
|
+
return cancelled(shell);
|
|
672
|
+
if (trimmed === ':back') {
|
|
673
|
+
// Re-edit the last field — clear it and run the form from that index.
|
|
674
|
+
const order = buildOrder(draft);
|
|
675
|
+
const lastIdx = order.length - 1;
|
|
676
|
+
draft[order[lastIdx].name] = null;
|
|
677
|
+
const r = await runForm({ shell, draft, context, startIndex: lastIdx });
|
|
678
|
+
if (r === 'cancelled')
|
|
679
|
+
return cancelled(shell);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (trimmed === ':dir') {
|
|
683
|
+
const next = await shell.prompt('directory', { silent: true, default: targetDir });
|
|
684
|
+
if (next === ESC)
|
|
685
|
+
continue;
|
|
686
|
+
const cleaned = String(next || '').trim();
|
|
687
|
+
// Reserved control tokens inside the dir sub-prompt: `:back` cancels just
|
|
688
|
+
// the directory change, `:cancel`/`:q` aborts the whole `/new`. Without
|
|
689
|
+
// this guard the user would end up creating a literal `:back/` folder.
|
|
690
|
+
if (cleaned === ':back')
|
|
691
|
+
continue;
|
|
692
|
+
if (cleaned === ':cancel' || cleaned === ':q')
|
|
693
|
+
return cancelled(shell);
|
|
694
|
+
if (cleaned)
|
|
695
|
+
targetDir = cleaned;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (trimmed === '' || ['y', 'yes'].includes(trimmed.toLowerCase())) {
|
|
699
|
+
const targetAbsDir = path.resolve(absRoot, targetDir);
|
|
700
|
+
const targetFile = path.join(targetAbsDir, String(draft.file));
|
|
701
|
+
let exists = false;
|
|
702
|
+
try {
|
|
703
|
+
await fs.access(targetFile);
|
|
704
|
+
exists = true;
|
|
705
|
+
}
|
|
706
|
+
catch { }
|
|
707
|
+
if (exists) {
|
|
708
|
+
const overwrite = await shell.prompt(`overwrite ${path.relative(absRoot, targetFile)}? [y/N]`, { silent: true });
|
|
709
|
+
if (overwrite === ESC) {
|
|
710
|
+
confirmError = 'overwrite cancelled';
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
const ow = String(overwrite || '').trim().toLowerCase();
|
|
714
|
+
if (!['y', 'yes'].includes(ow)) {
|
|
715
|
+
confirmError = 'overwrite cancelled';
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
await fs.mkdir(targetAbsDir, { recursive: true });
|
|
720
|
+
await fs.writeFile(targetFile, renderAgentFile(normalizeAgentDraft({
|
|
721
|
+
...draft,
|
|
722
|
+
filename: draft.file,
|
|
723
|
+
inputs: draft.inputs || [],
|
|
724
|
+
outputs: draft.outputs || [],
|
|
725
|
+
tags: draft.tags || [],
|
|
726
|
+
permissionMode: draft.permissionMode || '',
|
|
727
|
+
runnerAgent: draft.runnerAgent || '',
|
|
728
|
+
estimatedTokens: draft.estimatedTokens || '',
|
|
729
|
+
})));
|
|
730
|
+
shell.setMode(null);
|
|
731
|
+
shell.clear();
|
|
732
|
+
shell.log(`{${S.success}-fg}✓{/} {${S.text}-fg}${path.relative(absRoot, targetFile)}{/}`);
|
|
733
|
+
return targetFile;
|
|
734
|
+
}
|
|
735
|
+
confirmError = 'unknown command (Enter, :dir, :back, :cancel)';
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
function renderAgentFile({ title, id, description, inputs, outputs, tags, provider, model, runnerAgent, permissionMode, estimatedTokens }) {
|
|
739
|
+
const lines = [
|
|
740
|
+
`# ${title}`,
|
|
741
|
+
'',
|
|
742
|
+
'## Config',
|
|
743
|
+
'',
|
|
744
|
+
`- **id**: ${id}`,
|
|
745
|
+
`- **description**: ${description}`,
|
|
746
|
+
`- **inputs**: ${inputs.join(', ')}`,
|
|
747
|
+
`- **outputs**: ${outputs.join(', ')}`
|
|
748
|
+
];
|
|
749
|
+
if (tags.length)
|
|
750
|
+
lines.push(`- **tags**: ${tags.join(', ')}`);
|
|
751
|
+
if (provider)
|
|
752
|
+
lines.push(`- **provider**: ${provider}`);
|
|
753
|
+
if (model)
|
|
754
|
+
lines.push(`- **model**: ${model}`);
|
|
755
|
+
if (runnerAgent)
|
|
756
|
+
lines.push(`- **runner_agent**: ${runnerAgent}`);
|
|
757
|
+
if (permissionMode)
|
|
758
|
+
lines.push(`- **permission_mode**: ${permissionMode}`);
|
|
759
|
+
if (estimatedTokens)
|
|
760
|
+
lines.push(`- **estimated_tokens**: ${estimatedTokens}`);
|
|
761
|
+
lines.push('', '---', '', '## Prompt', '', '<!-- Your prompt here -->', '');
|
|
762
|
+
return lines.join('\n');
|
|
763
|
+
}
|