multi-agents-cli 1.1.78 → 1.1.80

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/init.js CHANGED
@@ -29,7 +29,7 @@ const {
29
29
  CLIENT_FRAMEWORKS,
30
30
  BACKEND_FRAMEWORKS,
31
31
  FRAMEWORK_VERSION_FALLBACK,
32
- fetchLatestVersions,
32
+ fetchLatestVersions, prefetchVersions,
33
33
  STATE_OPTIONS,
34
34
  UI_OPTIONS,
35
35
  STYLING_OPTIONS,
@@ -38,6 +38,8 @@ const {
38
38
  ORM_OPTIONS,
39
39
  AUTH_OPTIONS,
40
40
  IDE_CANDIDATES,
41
+ BLOCK_CONFIG,
42
+ INTENT_MAP,
41
43
  } = require('./lib/data-config');
42
44
 
43
45
  const {
@@ -373,6 +375,44 @@ const main = async () => {
373
375
  steps.push(0, 'Project name', projectName);
374
376
  separator();
375
377
 
378
+ // ── Intent selection ────────────────────────────────────────────────────────
379
+ rl.pause();
380
+ const intentChoices = Object.entries(INTENT_MAP).map(([k, v]) => ({ label: v.label, value: k }));
381
+ const intentIdx = await arrowSelect('What would you like to build?', intentChoices);
382
+ const intentKey = intentChoices[intentIdx].value;
383
+ const intent = INTENT_MAP[intentKey];
384
+
385
+ // Secondary prompt for fullstack order
386
+ let blockOrder = intent.blocks;
387
+ if (intentKey === 'fullstack') {
388
+ const orderIdx = await arrowSelect('Full-stack order?', [
389
+ { label: 'Client first, then backend' },
390
+ { label: 'Backend first, then client' },
391
+ ]);
392
+ blockOrder = orderIdx === 0 ? ['client', 'backend'] : ['backend', 'client'];
393
+ }
394
+
395
+ // Activate blocks and resolve absolute step indices
396
+ let absoluteStep = 0;
397
+ const blockEntries = [];
398
+ for (const blockKey of blockOrder) {
399
+ const block = BLOCK_CONFIG[blockKey];
400
+ block.isActive = true;
401
+ let isFirstSeg = true;
402
+ for (const [segKey, seg] of Object.entries(block)) {
403
+ if (segKey === 'isActive' || typeof seg !== 'object' || !('step' in seg)) continue;
404
+ seg.absoluteStep = absoluteStep;
405
+ if (isFirstSeg) { blockEntries.push(absoluteStep); isFirstSeg = false; }
406
+ absoluteStep++;
407
+ }
408
+ }
409
+ steps.blockEntries = blockEntries;
410
+
411
+ // Prefetch versions for active blocks in background
412
+ if (BLOCK_CONFIG.client.isActive) prefetchVersions(CLIENT_FRAMEWORKS);
413
+
414
+ separator();
415
+
376
416
  // Run all question steps with back-nav support
377
417
  const answers = await runQuestions(stepDefs, steps);
378
418
  if (answers === RESTART) {
@@ -389,7 +429,7 @@ const main = async () => {
389
429
  const clientUi = answers.clientUi || null;
390
430
  const clientStyle = answers.clientStyle || null;
391
431
  const useIntegratedBackend = answers.useIntegratedBackend || false;
392
- const backendFwObj = answers.backendFwObj || null;
432
+ const backendFwObj = answers.backendFw || null;
393
433
  const backendFw = backendFwObj ? fwValue(backendFwObj) : null;
394
434
  const backendLang = backendFwObj ? backendFwObj.language : null;
395
435
  const backendOrm = answers.backendOrm || null;
@@ -59,8 +59,8 @@ const FRAMEWORK_REGISTRY = {
59
59
  'Fastify': { registry: 'npm', package: 'fastify' },
60
60
  'FastAPI': { registry: 'pypi', package: 'fastapi' },
61
61
  'Django': { registry: 'pypi', package: 'django' },
62
- 'Laravel': { registry: 'npm', package: null },
63
- 'Rails': { registry: 'npm', package: null },
62
+ 'Laravel': { registry: 'packagist', package: 'laravel/framework' },
63
+ 'Rails': { registry: 'rubygems', package: 'rails' },
64
64
  };
65
65
 
66
66
  const FRAMEWORK_VERSION_FALLBACK = {
@@ -79,21 +79,35 @@ const FRAMEWORK_VERSION_FALLBACK = {
79
79
  'Rails': ['7.2', '7.1', '7.0'],
80
80
  };
81
81
 
82
+ const _versionCache = {};
83
+
84
+ const getVersionCache = (frameworkValue) => _versionCache[frameworkValue] || null;
85
+
86
+ const prefetchVersions = (frameworks) => {
87
+ frameworks.forEach(fw => {
88
+ if (_versionCache[fw.value]) return;
89
+ fetchLatestVersions(fw.value).catch(() => {});
90
+ });
91
+ };
92
+
82
93
  const fetchLatestVersions = async (frameworkValue) => {
94
+ if (_versionCache[frameworkValue]) return _versionCache[frameworkValue];
83
95
  const entry = FRAMEWORK_REGISTRY[frameworkValue];
84
96
  if (!entry || !entry.package) return null;
85
97
 
86
98
  try {
87
99
  const https = require('https');
88
- const fetch = (url) => new Promise((resolve, reject) => {
89
- const req = https.get(url, { timeout: 3000 }, (res) => {
90
- let data = '';
91
- res.on('data', chunk => data += chunk);
92
- res.on('end', () => resolve(data));
93
- });
94
- req.on('error', reject);
95
- req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
96
- });
100
+ const fetch = (url) => Promise.race([
101
+ new Promise((resolve, reject) => {
102
+ const req = https.get(url, (res) => {
103
+ let data = '';
104
+ res.on('data', chunk => data += chunk);
105
+ res.on('end', () => resolve(data));
106
+ });
107
+ req.on('error', reject);
108
+ }),
109
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2500)),
110
+ ]);
97
111
 
98
112
  if (entry.registry === 'npm') {
99
113
  const raw = await fetch(`https://registry.npmjs.org/${entry.package}`);
@@ -104,7 +118,9 @@ const fetchLatestVersions = async (frameworkValue) => {
104
118
  .filter((v, i, arr) => arr.indexOf(v) === i)
105
119
  .sort((a, b) => b - a)
106
120
  .slice(0, 3);
107
- return versions.length ? versions.map(String) : null;
121
+ const result = versions.length ? versions.map(String) : null;
122
+ if (result) _versionCache[frameworkValue] = result;
123
+ return result;
108
124
  }
109
125
 
110
126
  if (entry.registry === 'pypi') {
@@ -120,8 +136,37 @@ const fetchLatestVersions = async (frameworkValue) => {
120
136
  .map(v => v.split('.').slice(0, 2).join('.'))
121
137
  .filter((v, i, arr) => arr.indexOf(v) === i)
122
138
  .slice(0, 3);
123
- return versions.length ? versions : null;
139
+ const result = versions.length ? versions : null;
140
+ if (result) _versionCache[frameworkValue] = result;
141
+ return result;
142
+ }
143
+ if (entry.registry === 'packagist') {
144
+ const raw = await fetch(`https://packagist.org/packages/${entry.package}.json`);
145
+ const json = JSON.parse(raw);
146
+ const versions = Object.keys(json.package?.versions || {})
147
+ .filter(v => /^v?\d+\.\d+\.\d+$/.test(v) && !v.includes('-'))
148
+ .map(v => parseInt(v.replace(/^v/, '').split('.')[0]))
149
+ .filter((v, i, arr) => arr.indexOf(v) === i)
150
+ .sort((a, b) => b - a)
151
+ .slice(0, 3);
152
+ const result = versions.length ? versions.map(String) : null;
153
+ if (result) _versionCache[frameworkValue] = result;
154
+ return result;
155
+ }
156
+
157
+ if (entry.registry === 'rubygems') {
158
+ const raw = await fetch(`https://rubygems.org/api/v1/versions/${entry.package}.json`);
159
+ const json = JSON.parse(raw);
160
+ const versions = json
161
+ .map(v => parseInt(v.number.split('.')[0]))
162
+ .filter((v, i, arr) => arr.indexOf(v) === i)
163
+ .sort((a, b) => b - a)
164
+ .slice(0, 3);
165
+ const result = versions.length ? versions.map(String) : null;
166
+ if (result) _versionCache[frameworkValue] = result;
167
+ return result;
124
168
  }
169
+
125
170
  } catch {
126
171
  return null;
127
172
  }
@@ -258,14 +303,45 @@ const BLOCKS = {
258
303
  SHARED: 'shared',
259
304
  };
260
305
 
306
+ // ── Block config ─────────────────────────────────────────────────────────────
307
+
308
+ const BLOCK_CONFIG = {
309
+ client: {
310
+ isActive: true,
311
+ framework: { data: CLIENT_FRAMEWORKS, required: true, key: 'clientFw', step: 0 },
312
+ stateManagement: { data: STATE_OPTIONS, required: false, key: 'clientState', step: 1 },
313
+ uiLibrary: { data: UI_OPTIONS, required: false, key: 'clientUi', step: 2 },
314
+ styling: { data: STYLING_OPTIONS, required: false, key: 'clientStyle', step: 3 },
315
+ },
316
+ backend: {
317
+ isActive: false,
318
+ framework: { data: BACKEND_FRAMEWORKS, required: false, key: 'backendFw', step: 0 },
319
+ database: { data: DB_OPTIONS, required: false, key: 'backendDb', step: 1 },
320
+ orm: { data: ORM_OPTIONS, required: false, key: 'backendOrm', step: 2 },
321
+ auth: { data: AUTH_OPTIONS, required: false, key: 'backendAuth', step: 3 },
322
+ },
323
+ };
324
+
325
+ // ── Intent map ────────────────────────────────────────────────────────────────
326
+
327
+ const INTENT_MAP = {
328
+ fullstack: { label: 'Full-stack', blocks: ['client', 'backend'], order: null },
329
+ frontend: { label: 'Frontend only', blocks: ['client'], order: null },
330
+ backend: { label: 'Backend only', blocks: ['backend'], order: null },
331
+ };
332
+
261
333
  module.exports = {
262
334
  BLOCKS,
335
+ BLOCK_CONFIG,
336
+ INTENT_MAP,
263
337
  FRAMEWORK_CONVENTIONS,
264
338
  CLIENT_FRAMEWORKS,
265
339
  BACKEND_FRAMEWORKS,
266
340
  FRAMEWORK_REGISTRY,
267
341
  FRAMEWORK_VERSION_FALLBACK,
268
342
  fetchLatestVersions,
343
+ getVersionCache,
344
+ prefetchVersions,
269
345
  STATE_OPTIONS,
270
346
  UI_OPTIONS,
271
347
  STYLING_OPTIONS,
@@ -2,12 +2,12 @@
2
2
 
3
3
  // ── Imports ───────────────────────────────────────────────────────────────────
4
4
 
5
- const { dim, bold, blue, green, cyan, separator, arrowSelect, arrowConfirm, selectRequired, selectOptional, stepHeader } = require('./ui');
5
+ const { dim, bold, blue, green, cyan, separator, arrowSelect, arrowConfirm, selectRequired, selectOptional, stepHeader, createSpinner } = require('./ui');
6
6
  const { BLOCKS } = require('./data-config');
7
7
  const { BACK, RESTART } = require('./steps');
8
8
  const {
9
9
  CLIENT_FRAMEWORKS, BACKEND_FRAMEWORKS, FRAMEWORK_VERSION_FALLBACK,
10
- fetchLatestVersions, STATE_OPTIONS, UI_OPTIONS, STYLING_OPTIONS,
10
+ fetchLatestVersions, getVersionCache, prefetchVersions, STATE_OPTIONS, UI_OPTIONS, STYLING_OPTIONS,
11
11
  DB_OPTIONS, ORM_OPTIONS_BY_DB, ORM_OPTIONS, AUTH_OPTIONS,
12
12
  } = require('./data-config');
13
13
  const { buildIDEOptions, verifyIDE, detectTerminal } = require('./detect');
@@ -36,10 +36,16 @@ const buildStepDefs = (IDE_CANDIDATES) => [
36
36
  run: async (machine, answers) => {
37
37
  stepHeader(2, 12);
38
38
  const fw = answers.clientFw;
39
- const versions = await fetchLatestVersions(fwValue(fw)) || FRAMEWORK_VERSION_FALLBACK[fwValue(fw)] || [];
39
+ const cached = getVersionCache(fwValue(fw));
40
+ let versions;
41
+ if (cached) {
42
+ versions = cached;
43
+ } else {
44
+ const spinner = createSpinner('Fetching latest versions...');
45
+ versions = await fetchLatestVersions(fwValue(fw)) || FRAMEWORK_VERSION_FALLBACK[fwValue(fw)] || [];
46
+ spinner.stop();
47
+ }
40
48
  if (!versions.length) return null; // skip silently
41
-
42
- console.log(dim(' Fetching latest versions...'));
43
49
  const versionChoices = versions.map((v, i) => ({
44
50
  label: i === 0 ? `v${v} ${dim('(latest)')}` : `v${v}`,
45
51
  value: v,
@@ -105,25 +111,16 @@ const buildStepDefs = (IDE_CANDIDATES) => [
105
111
 
106
112
  // ── Step 6: Backend framework ────────────────────────────────────────────────
107
113
  {
108
- name: 'backendFwObj',
114
+ name: 'backendFw',
109
115
  run: async (machine, answers) => {
110
- stepHeader(7, 12);
111
116
  if (answers.useIntegratedBackend) return null; // skip
117
+ stepHeader(7, 12);
112
118
 
113
119
  separator();
114
120
  console.log(`\n${bold(blue('Backend configuration'))}`);
115
121
  console.log(dim(' You can skip the backend framework and decide later.\n'));
116
122
 
117
- const choices = [
118
- ...BACKEND_FRAMEWORKS.map(f => ({ label: f.label || f.value })),
119
- { label: dim('Skip (decide later)') },
120
- ];
121
- const idx = await arrowSelect('Backend framework:', choices);
122
-
123
- // Back nav
124
- if (idx >= BACKEND_FRAMEWORKS.length + 1) return BACK;
125
-
126
- return idx === BACKEND_FRAMEWORKS.length ? null : BACKEND_FRAMEWORKS[idx];
123
+ return await selectOptional('Backend framework:', BACKEND_FRAMEWORKS, machine, 6, answers, BLOCKS.BACKEND);
127
124
  },
128
125
  },
129
126
 
@@ -131,11 +128,19 @@ const buildStepDefs = (IDE_CANDIDATES) => [
131
128
  {
132
129
  name: 'backendFwVersion',
133
130
  run: async (machine, answers) => {
134
- stepHeader(8, 12);
135
- const fwObj = answers.backendFwObj;
131
+ const fwObj = answers.backendFw;
136
132
  if (!fwObj) return null; // skip — no backend selected
133
+ stepHeader(8, 12);
137
134
 
138
- const versions = await fetchLatestVersions(fwValue(fwObj)) || FRAMEWORK_VERSION_FALLBACK[fwValue(fwObj)] || [];
135
+ const cached = getVersionCache(fwValue(fwObj));
136
+ let versions;
137
+ if (cached) {
138
+ versions = cached;
139
+ } else {
140
+ const spinner = createSpinner('Fetching latest versions...');
141
+ versions = await fetchLatestVersions(fwValue(fwObj)) || FRAMEWORK_VERSION_FALLBACK[fwValue(fwObj)] || [];
142
+ spinner.stop();
143
+ }
139
144
  if (!versions.length) return null;
140
145
 
141
146
  const vChoices = versions.map((v, i) => ({
@@ -158,8 +163,8 @@ const buildStepDefs = (IDE_CANDIDATES) => [
158
163
  {
159
164
  name: 'backendDb',
160
165
  run: async (machine, answers) => {
166
+ if (!answers.backendFw) return null;
161
167
  stepHeader(9, 12);
162
- if (!answers.backendFwObj) return null;
163
168
  return await selectOptional('Database type:', DB_OPTIONS, machine, 8, answers, BLOCKS.BACKEND);
164
169
  },
165
170
  },
@@ -168,11 +173,11 @@ const buildStepDefs = (IDE_CANDIDATES) => [
168
173
  {
169
174
  name: 'backendOrm',
170
175
  run: async (machine, answers) => {
176
+ const { backendFw, backendDb } = answers;
177
+ if (!backendFw || !backendDb) return null;
171
178
  stepHeader(10, 12);
172
- const { backendFwObj, backendDb } = answers;
173
- if (!backendFwObj || !backendDb) return null;
174
179
  const byDb = ORM_OPTIONS_BY_DB[backendDb] || [];
175
- const byFw = ORM_OPTIONS[fwValue(backendFwObj)] || [];
180
+ const byFw = ORM_OPTIONS[fwValue(backendFw)] || [];
176
181
  const ormChoices = byDb.length && byFw.length ? byDb.filter(o => byFw.includes(o)) : byDb.length ? byDb : byFw;
177
182
  return await selectOptional('ORM / query layer:', ormChoices, machine, 9, answers, BLOCKS.BACKEND);
178
183
  },
@@ -182,10 +187,10 @@ const buildStepDefs = (IDE_CANDIDATES) => [
182
187
  {
183
188
  name: 'backendAuth',
184
189
  run: async (machine, answers) => {
190
+ const { backendFw } = answers;
191
+ if (!backendFw) return null;
185
192
  stepHeader(11, 12);
186
- const { backendFwObj } = answers;
187
- if (!backendFwObj) return null;
188
- return await selectOptional('Auth strategy:', AUTH_OPTIONS[fwValue(backendFwObj)] || [], machine, 10, answers, BLOCKS.BACKEND);
193
+ return await selectOptional('Auth strategy:', AUTH_OPTIONS[fwValue(backendFw)] || [], machine, 10, answers, BLOCKS.BACKEND);
189
194
  },
190
195
  },
191
196
 
package/lib/steps.js CHANGED
@@ -10,10 +10,12 @@ const SHOW_LIST = Symbol('SHOW_LIST');
10
10
  // ── Step machine ──────────────────────────────────────────────────────────────
11
11
 
12
12
  class StepMachine {
13
- constructor() {
13
+ constructor(blockEntries = []) {
14
14
  this.history = []; // [{ stepIndex, stepName, answer }]
15
15
  this.inBackNav = false;
16
16
  this.resumeIndex = null; // step index where back-nav started
17
+ this.blockEntries = blockEntries; // absolute step indices that are block entry points
18
+ this.snapshot = null; // history snapshot taken at back-nav start
17
19
  }
18
20
 
19
21
  // Push a completed step answer onto the history stack
@@ -63,11 +65,30 @@ class StepMachine {
63
65
  return opts;
64
66
  }
65
67
 
68
+ // Take a snapshot of current history + answers (for ignore-changes restore)
69
+ snapshotHistory() {
70
+ this.snapshot = this.history.map(h => ({ ...h }));
71
+ }
72
+
73
+ // Restore history from snapshot
74
+ restoreSnapshot() {
75
+ if (this.snapshot) {
76
+ this.history = this.snapshot.map(h => ({ ...h }));
77
+ this.snapshot = null;
78
+ }
79
+ }
80
+
81
+ // Check if a step index is a block entry point
82
+ isBlockEntry(stepIndex) {
83
+ return this.blockEntries.includes(stepIndex);
84
+ }
85
+
66
86
  // Enter back-nav mode — record where the user first pressed Back
67
87
  enterBackNav(currentStepIndex) {
68
88
  if (!this.inBackNav) {
69
89
  this.inBackNav = true;
70
90
  this.resumeIndex = currentStepIndex;
91
+ this.snapshotHistory();
71
92
  }
72
93
  }
73
94
 
@@ -82,6 +103,7 @@ class StepMachine {
82
103
  this.history = [];
83
104
  this.inBackNav = false;
84
105
  this.resumeIndex = null;
106
+ this.snapshot = null;
85
107
  }
86
108
  }
87
109
 
@@ -106,9 +128,34 @@ const runQuestions = async (stepDefs, machine) => {
106
128
  if (result === BACK) {
107
129
  if (i === 0) return RESTART; // nowhere to go back from step 0
108
130
  machine.enterBackNav(i); // record where back-nav started
131
+ i--;
132
+ // Skip back over auto-skipped steps (not in history), stop at block entry
133
+ while (i > 0 && !machine.history.find(h => h.stepIndex === i) && !machine.isBlockEntry(i)) i--;
134
+ // Pop the step we landed on
109
135
  const popped = machine.pop();
110
136
  if (popped) delete answers[popped.stepName];
111
- i--;
137
+ // Block boundary checkpoint — fires when back-nav reaches a block entry point
138
+ if (machine.isBlockEntry(i)) {
139
+ const { arrowSelect } = require('./ui');
140
+ const boundaryChoice = await arrowSelect('You\'ve reached the start of this block.', [
141
+ { label: '→ Continue from here (re-answer this block)' },
142
+ { label: '→ Ignore changes (restore to where you started)' },
143
+ { label: '← Restart configuration' },
144
+ ]);
145
+ if (boundaryChoice === 1) {
146
+ const resumeTarget = machine.resumeIndex;
147
+ machine.restoreSnapshot();
148
+ Object.keys(answers).forEach(k => delete answers[k]);
149
+ machine.history.forEach(h => { answers[h.stepName] = h.answer; });
150
+ machine.exitBackNav();
151
+ const entryStep = machine.blockEntries.find(e => e <= i) ?? 0;
152
+ i = resumeTarget != null ? resumeTarget : entryStep + 1;
153
+
154
+ } else if (boundaryChoice === 2) {
155
+ return RESTART;
156
+ }
157
+ // 'continue' — just proceed from current block entry step
158
+ }
112
159
  continue;
113
160
  }
114
161
 
package/lib/ui.js CHANGED
@@ -38,6 +38,22 @@ const rl = readline.createInterface({
38
38
  const ask = (question) =>
39
39
  new Promise((resolve) => rl.question(question, (a) => resolve(a.trim())));
40
40
 
41
+ const createSpinner = (label) => {
42
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
43
+ let i = 0;
44
+ process.stdout.write(dim(' ' + label + ' '));
45
+ const timer = setInterval(() => {
46
+ process.stdout.write(String.fromCharCode(13) + dim(' ' + label + ' ' + frames[i % frames.length]));
47
+ i++;
48
+ }, 80);
49
+ return {
50
+ stop: () => {
51
+ clearInterval(timer);
52
+ process.stdout.write(String.fromCharCode(13) + dim(' ' + label + ' done') + String.fromCharCode(10));
53
+ },
54
+ };
55
+ };
56
+
41
57
  const arrowSelect = async (message, choices, showBack = false, backLabel = '← Restart configuration') => {
42
58
  const allChoices = showBack
43
59
  ? [...choices, { label: dim(backLabel) }]
@@ -241,7 +257,7 @@ module.exports = {
241
257
  stepHeader,
242
258
  c, bold, green, yellow, dim, cyan, blue, red,
243
259
  rl, ask,
244
- arrowSelect, arrowConfirm,
260
+ arrowSelect, arrowConfirm, createSpinner,
245
261
  selectRequired, selectOptional,
246
262
  separator, showList, summaryLine, renderTrajectoryLines,
247
263
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "multi-agents-cli",
3
- "version": "1.1.78",
3
+ "version": "1.1.80",
4
4
  "description": "Multi-agent workflow orchestration for Claude Code — isolated git worktrees, structured state tracking, autonomous task chaining",
5
5
  "keywords": [
6
6
  "claude-code",