singleton-pipeline 0.4.0-beta.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/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "singleton-pipeline",
3
+ "version": "0.4.0-beta.0",
4
+ "description": "Visual pipeline builder for multi-agent AI workflows. Orchestrates Claude Code, Codex, Copilot, and OpenCode under a unified security policy.",
5
+ "keywords": [
6
+ "ai",
7
+ "agents",
8
+ "claude",
9
+ "claude-code",
10
+ "codex",
11
+ "copilot",
12
+ "opencode",
13
+ "pipeline",
14
+ "orchestrator",
15
+ "cli"
16
+ ],
17
+ "homepage": "https://github.com/RomainLENTZ/singleton_pipeline",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/RomainLENTZ/singleton_pipeline.git"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/RomainLENTZ/singleton_pipeline/issues"
24
+ },
25
+ "license": "MIT",
26
+ "author": "Romain Lentz",
27
+ "type": "module",
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "bin": {
32
+ "singleton": "./packages/cli/src/index.js"
33
+ },
34
+ "files": [
35
+ "packages/cli/src/**/*.js",
36
+ "!packages/cli/src/**/*.test.js",
37
+ "!packages/cli/src/__fixtures__",
38
+ "packages/cli/package.json",
39
+ "packages/server/src/**/*.js",
40
+ "packages/server/package.json",
41
+ "packages/web/dist/**",
42
+ "packages/web/package.json",
43
+ "docs/reference.md",
44
+ "README.md",
45
+ "LICENSE"
46
+ ],
47
+ "workspaces": [
48
+ "packages/*"
49
+ ],
50
+ "dependencies": {
51
+ "@inquirer/prompts": "^7.2.1",
52
+ "blessed": "^0.1.81",
53
+ "chalk": "^5.6.2",
54
+ "commander": "^12.1.0",
55
+ "cors": "^2.8.5",
56
+ "express": "^4.21.2",
57
+ "fast-glob": "^3.3.2",
58
+ "figlet": "^1.11.0",
59
+ "kleur": "^4.1.5"
60
+ },
61
+ "devDependencies": {
62
+ "vitest": "^3.2.4"
63
+ },
64
+ "scripts": {
65
+ "singleton": "node packages/cli/src/index.js",
66
+ "scan": "node packages/cli/src/index.js scan",
67
+ "serve": "node packages/cli/src/index.js serve",
68
+ "dev:server": "node packages/server/src/index.js",
69
+ "test": "vitest run",
70
+ "test:watch": "vitest",
71
+ "build:web": "npm run build -w @singleton/web",
72
+ "prepublishOnly": "npm run build:web && npm test",
73
+ "pack:smoke": "npm pack && tar -tzf singleton-pipeline-*.tgz | head -60"
74
+ }
75
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@singleton/cli",
3
+ "version": "0.4.0-beta.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "singleton": "./src/index.js"
7
+ },
8
+ "main": "./src/index.js",
9
+ "dependencies": {
10
+ "@inquirer/prompts": "^7.2.1",
11
+ "blessed": "^0.1.81",
12
+ "chalk": "^5.6.2",
13
+ "commander": "^12.1.0",
14
+ "fast-glob": "^3.3.2",
15
+ "figlet": "^1.11.0",
16
+ "kleur": "^4.1.5"
17
+ }
18
+ }
@@ -0,0 +1,440 @@
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
+
7
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
8
+ const CHOICE_NONE = { name: '(none)', value: '' };
9
+ const CLAUDE_MODELS = [
10
+ { name: 'claude-opus-4-7', value: 'claude-opus-4-7' },
11
+ { name: 'claude-sonnet-4-6', value: 'claude-sonnet-4-6' },
12
+ { name: 'claude-haiku-4-5', value: 'claude-haiku-4-5' },
13
+ CHOICE_NONE,
14
+ ];
15
+ const CODEX_MODELS = [
16
+ { name: 'gpt-5.4', value: 'gpt-5.4' },
17
+ { name: 'gpt-5-codex', value: 'gpt-5-codex' },
18
+ { name: 'gpt-5.2-codex', value: 'gpt-5.2-codex' },
19
+ { name: 'gpt-5.1-codex', value: 'gpt-5.1-codex' },
20
+ CHOICE_NONE,
21
+ ];
22
+ const COPILOT_MODELS = [
23
+ { name: 'gpt-5.4-mini', value: 'gpt-5.4-mini' },
24
+ { name: 'gpt-5.4', value: 'gpt-5.4' },
25
+ { name: 'gpt-4.1', value: 'gpt-4.1' },
26
+ CHOICE_NONE,
27
+ ];
28
+ const OPENCODE_MODELS = [
29
+ { name: 'ollama/qwen2.5-coder:14b', value: 'ollama/qwen2.5-coder:14b' },
30
+ { name: 'ollama/qwen2.5-coder:7b', value: 'ollama/qwen2.5-coder:7b' },
31
+ { name: 'anthropic/claude-sonnet-4-6', value: 'anthropic/claude-sonnet-4-6' },
32
+ CHOICE_NONE,
33
+ ];
34
+ const PROVIDERS = [
35
+ { name: 'claude', value: 'claude' },
36
+ { name: 'codex', value: 'codex' },
37
+ { name: 'copilot', value: 'copilot' },
38
+ { name: 'opencode', value: 'opencode' },
39
+ ];
40
+ const CLAUDE_PERMISSION_MODES = [
41
+ { name: '(safe default)', value: '' },
42
+ { name: 'bypassPermissions', value: 'bypassPermissions' },
43
+ ];
44
+ const DEFAULT_AGENTS_DIR = '.singleton/agents';
45
+ const CLAUDE_DEFAULT_MODEL = 'claude-sonnet-4-6';
46
+ const CODEX_DEFAULT_MODEL = 'gpt-5.4';
47
+ const COPILOT_DEFAULT_MODEL = 'gpt-5.4-mini';
48
+ const OPENCODE_DEFAULT_MODEL = 'ollama/qwen2.5-coder:14b';
49
+
50
+ function uniqueSorted(values) {
51
+ return [...new Set(values.filter(Boolean))].sort();
52
+ }
53
+
54
+ function defaultTitleFromId(id) {
55
+ return id
56
+ .split('-')
57
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
58
+ .join(' ');
59
+ }
60
+
61
+ function parseCsvList(value) {
62
+ return uniqueSorted(
63
+ String(value || '')
64
+ .split(',')
65
+ .map((s) => s.trim())
66
+ .filter(Boolean)
67
+ );
68
+ }
69
+
70
+ async function loadAgentCreationContext(root) {
71
+ const existing = await scanAgents(root);
72
+
73
+ return {
74
+ root,
75
+ existing,
76
+ existingIds: new Set(existing.map((a) => a.id)),
77
+ existingOutputs: uniqueSorted(existing.flatMap((a) => a.outputs)),
78
+ existingInputs: uniqueSorted(existing.flatMap((a) => a.inputs)),
79
+ existingTags: uniqueSorted(existing.flatMap((a) => a.tags)),
80
+ };
81
+ }
82
+
83
+ function inputSuggestionsFromContext(context) {
84
+ return uniqueSorted([...context.existingOutputs, ...context.existingInputs]);
85
+ }
86
+
87
+ function validateAgentId(existingIds, value) {
88
+ if (!SLUG_RE.test(value)) return 'invalid slug (a-z, 0-9, hyphens)';
89
+ if (existingIds.has(value)) return `id "${value}" is already used`;
90
+ return true;
91
+ }
92
+
93
+ function defaultModelForProvider(provider) {
94
+ if (provider === 'codex') return CODEX_DEFAULT_MODEL;
95
+ if (provider === 'copilot') return COPILOT_DEFAULT_MODEL;
96
+ if (provider === 'opencode') return OPENCODE_DEFAULT_MODEL;
97
+ return CLAUDE_DEFAULT_MODEL;
98
+ }
99
+
100
+ function permissionChoicesForProvider(provider) {
101
+ return provider === 'claude' ? CLAUDE_PERMISSION_MODES : [CHOICE_NONE];
102
+ }
103
+
104
+ function normalizeAgentDraft(draft) {
105
+ return {
106
+ ...draft,
107
+ inputs: uniqueSorted(draft.inputs || []),
108
+ outputs: uniqueSorted(draft.outputs || []),
109
+ tags: uniqueSorted(draft.tags || []),
110
+ permissionMode: draft.provider === 'claude' ? (draft.permissionMode || '') : '',
111
+ runnerAgent: ['copilot', 'opencode'].includes(draft.provider) ? (draft.runnerAgent || '') : '',
112
+ model: draft.model || '',
113
+ estimatedTokens: draft.estimatedTokens || '',
114
+ };
115
+ }
116
+
117
+ async function writeAgentDraft({ root, draft, askOverwrite }) {
118
+ const filename = draft.filename.endsWith('.md') ? draft.filename : `${draft.filename}.md`;
119
+ const targetDir = path.resolve(root, DEFAULT_AGENTS_DIR);
120
+ const targetFile = path.join(targetDir, filename);
121
+
122
+ try {
123
+ await fs.access(targetFile);
124
+ const overwrite = await askOverwrite(path.relative(root, targetFile));
125
+ if (!overwrite) return null;
126
+ } catch {
127
+ // file doesn't exist
128
+ }
129
+
130
+ const content = renderAgentFile(normalizeAgentDraft(draft));
131
+ await fs.mkdir(targetDir, { recursive: true });
132
+ await fs.writeFile(targetFile, content);
133
+ return targetFile;
134
+ }
135
+
136
+ async function askShellValue(shell, message, { defaultValue = '', validate = null, normalize = (v) => v } = {}) {
137
+ while (true) {
138
+ const answer = await shell.prompt(defaultValue ? `${message} (default: ${defaultValue})` : message);
139
+ const value = answer.trim() || defaultValue;
140
+ const verdict = validate ? validate(value) : true;
141
+ if (verdict === true) return normalize(value);
142
+ shell.log(`{red-fg}✕{/} ${verdict}`);
143
+ }
144
+ }
145
+
146
+ function modelChoicesForProvider(provider) {
147
+ if (provider === 'codex') return CODEX_MODELS;
148
+ if (provider === 'copilot') return COPILOT_MODELS;
149
+ if (provider === 'opencode') return OPENCODE_MODELS;
150
+ return CLAUDE_MODELS;
151
+ }
152
+
153
+ async function askShellChoice(shell, message, choices, defaultValue) {
154
+ shell.log('');
155
+ shell.log(`{bold}${message}{/}`);
156
+ choices.forEach((choice, index) => {
157
+ const marker = choice.value === defaultValue ? 'default' : 'option';
158
+ shell.log(` {#797C81-fg}${index + 1}.{/} ${choice.name}${choice.value === defaultValue ? ` {#797C81-fg}(${marker}){/}` : ''}`);
159
+ });
160
+
161
+ while (true) {
162
+ const answer = (await shell.prompt(`${message} (number or exact value)`)).trim();
163
+ const raw = answer || defaultValue;
164
+ const byIndex = /^\d+$/.test(raw) ? choices[Number(raw) - 1] : null;
165
+ const byValue = choices.find((choice) => choice.value === raw);
166
+ const selected = byIndex || byValue;
167
+ if (selected) return selected.value;
168
+ shell.log(`{red-fg}✕{/} Choose a value from the list.`);
169
+ }
170
+ }
171
+
172
+ const DONE = '__DONE__';
173
+ const UNDO = '__UNDO__';
174
+ const SLUG_ITEM_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
175
+
176
+ async function collectList({ message, existing }) {
177
+ const picked = [];
178
+
179
+ while (true) {
180
+ const summary = picked.length ? style.muted(`[${picked.join(', ')}]`) : '';
181
+
182
+ const answer = await search({
183
+ message: summary ? `${message} ${summary}` : message,
184
+ source: async (term) => {
185
+ const t = (term || '').trim();
186
+ const choices = [];
187
+
188
+ if (picked.length > 0) {
189
+ choices.push({
190
+ name: `← remove "${picked[picked.length - 1]}"`,
191
+ value: UNDO
192
+ });
193
+ }
194
+
195
+ const avail = existing.filter((v) => !picked.includes(v));
196
+ const matching = t
197
+ ? avail.filter((v) => v.toLowerCase().includes(t.toLowerCase()))
198
+ : avail;
199
+ for (const v of matching) choices.push({ name: v, value: v });
200
+
201
+ if (t && SLUG_ITEM_RE.test(t) && !existing.includes(t) && !picked.includes(t)) {
202
+ choices.push({ name: `+ create "${t}"`, value: t });
203
+ }
204
+
205
+ choices.push({ name: '✓ finish', value: DONE });
206
+ return choices;
207
+ }
208
+ });
209
+
210
+ if (answer === DONE) break;
211
+ if (answer === UNDO) {
212
+ picked.pop();
213
+ continue;
214
+ }
215
+ if (!picked.includes(answer)) picked.push(answer);
216
+ }
217
+
218
+ return picked;
219
+ }
220
+
221
+ export async function newAgentCommand(opts) {
222
+ const root = path.resolve(opts.root || process.cwd());
223
+ const context = await loadAgentCreationContext(root);
224
+ const inputSuggestions = inputSuggestionsFromContext(context);
225
+
226
+ const id = await input({
227
+ message: 'id',
228
+ validate: (v) => validateAgentId(context.existingIds, v),
229
+ });
230
+
231
+ const title = await input({
232
+ message: 'title',
233
+ default: id
234
+ .split('-')
235
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
236
+ .join(' ')
237
+ });
238
+
239
+ const description = await input({
240
+ message: 'description',
241
+ validate: (v) => (v.trim() ? true : 'required')
242
+ });
243
+
244
+ const inputs = await collectList({
245
+ message: 'inputs',
246
+ existing: inputSuggestions,
247
+ });
248
+
249
+ const outputs = await collectList({
250
+ message: 'outputs',
251
+ existing: context.existingOutputs,
252
+ });
253
+
254
+ const tags = await collectList({
255
+ message: 'tags',
256
+ existing: context.existingTags,
257
+ });
258
+
259
+ const provider = await select({
260
+ message: 'provider',
261
+ choices: PROVIDERS,
262
+ default: 'claude'
263
+ });
264
+
265
+ const model = await select({
266
+ message: 'model',
267
+ choices: modelChoicesForProvider(provider),
268
+ default: defaultModelForProvider(provider),
269
+ });
270
+
271
+ const permissionMode = provider === 'claude'
272
+ ? await select({
273
+ message: 'permission_mode',
274
+ choices: permissionChoicesForProvider(provider),
275
+ default: '',
276
+ })
277
+ : '';
278
+ const runnerAgent = ['copilot', 'opencode'].includes(provider)
279
+ ? await input({
280
+ message: 'runner_agent',
281
+ default: id,
282
+ })
283
+ : '';
284
+
285
+ const estimatedRaw = await input({
286
+ message: 'estimated_tokens',
287
+ default: '',
288
+ validate: (v) => (v === '' || /^\d+$/.test(v) ? true : 'expected an integer')
289
+ });
290
+
291
+ const filename = await input({
292
+ message: 'file',
293
+ default: `${id}.md`,
294
+ validate: (v) => (v.endsWith('.md') ? true : 'must end with .md')
295
+ });
296
+
297
+ const targetFile = await writeAgentDraft({
298
+ root,
299
+ draft: {
300
+ title,
301
+ id,
302
+ description,
303
+ inputs,
304
+ outputs,
305
+ tags,
306
+ provider,
307
+ model,
308
+ runnerAgent,
309
+ permissionMode,
310
+ estimatedTokens: estimatedRaw,
311
+ filename,
312
+ },
313
+ askOverwrite: (relativeFile) => confirm({
314
+ message: `${relativeFile} already exists. Overwrite?`,
315
+ default: false,
316
+ }),
317
+ });
318
+ if (!targetFile) return;
319
+
320
+ console.log(style.muted(`Canonical directory: ${DEFAULT_AGENTS_DIR}`));
321
+ console.log(line.success(path.relative(root, targetFile)));
322
+ }
323
+
324
+ export async function newAgentShellCommand({ root, shell }) {
325
+ const absRoot = path.resolve(root || process.cwd());
326
+ const context = await loadAgentCreationContext(absRoot);
327
+ const inputSuggestions = inputSuggestionsFromContext(context);
328
+
329
+ shell.log('{bold}New agent{/}');
330
+ shell.log(`{#797C81-fg}Canonical dir: ${DEFAULT_AGENTS_DIR}{/}`);
331
+ shell.log('');
332
+
333
+ const id = await askShellValue(shell, 'id', {
334
+ validate: (v) => validateAgentId(context.existingIds, v),
335
+ });
336
+
337
+ const title = await askShellValue(shell, 'title', {
338
+ defaultValue: defaultTitleFromId(id),
339
+ });
340
+
341
+ const description = await askShellValue(shell, 'description', {
342
+ validate: (v) => (v.trim() ? true : 'required'),
343
+ });
344
+
345
+ if (inputSuggestions.length) {
346
+ shell.log(`{#797C81-fg}Input suggestions: ${inputSuggestions.join(', ')}{/}`);
347
+ }
348
+ const inputs = parseCsvList(await askShellValue(shell, 'inputs (comma separated)'));
349
+
350
+ if (context.existingOutputs.length) {
351
+ shell.log(`{#797C81-fg}Output suggestions: ${context.existingOutputs.join(', ')}{/}`);
352
+ }
353
+ const outputs = parseCsvList(await askShellValue(shell, 'outputs (comma separated)', {
354
+ validate: (v) => (parseCsvList(v).length > 0 ? true : 'at least one output is required'),
355
+ }));
356
+
357
+ if (context.existingTags.length) {
358
+ shell.log(`{#797C81-fg}Tag suggestions: ${context.existingTags.join(', ')}{/}`);
359
+ }
360
+ const tags = parseCsvList(await askShellValue(shell, 'tags (comma separated, optional)'));
361
+
362
+ const provider = await askShellChoice(shell, 'provider', PROVIDERS, 'claude');
363
+ const model = await askShellChoice(
364
+ shell,
365
+ 'model',
366
+ modelChoicesForProvider(provider),
367
+ defaultModelForProvider(provider)
368
+ );
369
+ const permissionMode = provider === 'claude'
370
+ ? await askShellChoice(shell, 'permission_mode', permissionChoicesForProvider(provider), '')
371
+ : '';
372
+ const runnerAgent = ['copilot', 'opencode'].includes(provider)
373
+ ? await askShellValue(shell, 'runner_agent', { defaultValue: id })
374
+ : '';
375
+
376
+ const estimatedTokens = await askShellValue(shell, 'estimated_tokens (optional)', {
377
+ validate: (v) => (v === '' || /^\d+$/.test(v) ? true : 'expected an integer'),
378
+ });
379
+
380
+ const filename = await askShellValue(shell, 'file', {
381
+ defaultValue: `${id}.md`,
382
+ validate: (v) => (v.endsWith('.md') ? true : 'must end with .md'),
383
+ });
384
+
385
+ const targetFile = await writeAgentDraft({
386
+ root: absRoot,
387
+ draft: {
388
+ title,
389
+ id,
390
+ description,
391
+ inputs,
392
+ outputs,
393
+ tags,
394
+ provider,
395
+ model,
396
+ runnerAgent,
397
+ permissionMode,
398
+ estimatedTokens,
399
+ filename,
400
+ },
401
+ askOverwrite: async (relativeFile) => {
402
+ const overwrite = await askShellValue(shell, `${relativeFile} already exists. Overwrite? [y/N]`, {
403
+ defaultValue: 'n',
404
+ normalize: (v) => v.toLowerCase(),
405
+ validate: (v) => (['y', 'yes', 'n', 'no'].includes(v.toLowerCase()) ? true : 'answer y or n'),
406
+ });
407
+ if (!['y', 'yes'].includes(overwrite)) {
408
+ shell.log(`{#797C81-fg}Creation cancelled.{/}`);
409
+ return false;
410
+ }
411
+ return true;
412
+ },
413
+ });
414
+ if (!targetFile) return null;
415
+
416
+ shell.log('');
417
+ shell.log(`{green-fg}✓{/} ${path.relative(absRoot, targetFile)}`);
418
+ return targetFile;
419
+ }
420
+
421
+ function renderAgentFile({ title, id, description, inputs, outputs, tags, provider, model, runnerAgent, permissionMode, estimatedTokens }) {
422
+ const lines = [
423
+ `# ${title}`,
424
+ '',
425
+ '## Config',
426
+ '',
427
+ `- **id**: ${id}`,
428
+ `- **description**: ${description}`,
429
+ `- **inputs**: ${inputs.join(', ')}`,
430
+ `- **outputs**: ${outputs.join(', ')}`
431
+ ];
432
+ if (tags.length) lines.push(`- **tags**: ${tags.join(', ')}`);
433
+ if (provider) lines.push(`- **provider**: ${provider}`);
434
+ if (model) lines.push(`- **model**: ${model}`);
435
+ if (runnerAgent) lines.push(`- **runner_agent**: ${runnerAgent}`);
436
+ if (permissionMode) lines.push(`- **permission_mode**: ${permissionMode}`);
437
+ if (estimatedTokens) lines.push(`- **estimated_tokens**: ${estimatedTokens}`);
438
+ lines.push('', '---', '', '## Prompt', '', '<!-- Your prompt here -->', '');
439
+ return lines.join('\n');
440
+ }