@soleri/cli 1.7.0 → 1.8.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.
@@ -1,31 +1,73 @@
1
1
  /**
2
2
  * Interactive create wizard using @clack/prompts.
3
+ *
4
+ * Guided flow with archetypes, multiselects, and playbook-assisted
5
+ * custom fields. Happy path: 1 typed field (name), everything else
6
+ * is Enter / arrow keys / space bar.
3
7
  */
4
8
  import * as p from '@clack/prompts';
5
9
  import type { AgentConfig } from '@soleri/forge/lib';
10
+ import { ARCHETYPES, type Archetype } from './archetypes.js';
11
+ import {
12
+ DOMAIN_OPTIONS,
13
+ CUSTOM_DOMAIN_GUIDANCE,
14
+ PRINCIPLE_CATEGORIES,
15
+ CUSTOM_PRINCIPLE_GUIDANCE,
16
+ SKILL_CATEGORIES,
17
+ CORE_SKILLS,
18
+ ALL_OPTIONAL_SKILLS,
19
+ TONE_OPTIONS,
20
+ CUSTOM_ROLE_GUIDANCE,
21
+ CUSTOM_DESCRIPTION_GUIDANCE,
22
+ CUSTOM_GREETING_GUIDANCE,
23
+ } from './playbook.js';
24
+
25
+ /** Slugify a display name into a kebab-case ID. */
26
+ function slugify(name: string): string {
27
+ return name
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, '-')
30
+ .replace(/^-|-$/g, '');
31
+ }
6
32
 
7
33
  /**
8
34
  * Run the interactive create wizard and return an AgentConfig.
9
- * Returns null if the user cancels.
35
+ * Returns null if the user cancels at any point.
10
36
  */
11
37
  export async function runCreateWizard(initialName?: string): Promise<AgentConfig | null> {
12
38
  p.intro('Create a new Soleri agent');
13
39
 
14
- const id =
15
- initialName ??
16
- ((await p.text({
17
- message: 'Agent ID (kebab-case)',
18
- placeholder: 'my-agent',
19
- validate: (v = '') => {
20
- if (!/^[a-z][a-z0-9-]*$/.test(v)) return 'Must be kebab-case (e.g., "my-agent")';
21
- },
22
- })) as string);
40
+ // ─── Step 1: Archetype ────────────────────────────────────
41
+ const archetypeChoices = [
42
+ ...ARCHETYPES.map((a) => ({
43
+ value: a.value,
44
+ label: a.label,
45
+ hint: a.hint,
46
+ })),
47
+ {
48
+ value: '_custom',
49
+ label: '\u2726 Create Custom',
50
+ hint: "I'll guide you through defining your own agent type",
51
+ },
52
+ ];
23
53
 
24
- if (p.isCancel(id)) return null;
54
+ const archetypeValue = await p.select({
55
+ message: 'What kind of agent are you building?',
56
+ options: archetypeChoices,
57
+ });
58
+
59
+ if (p.isCancel(archetypeValue)) return null;
60
+
61
+ const archetype: Archetype | undefined = ARCHETYPES.find((a) => a.value === archetypeValue);
62
+ const isCustom = archetypeValue === '_custom';
63
+
64
+ // ─── Step 2: Display name ─────────────────────────────────
65
+ const nameDefault = archetype ? archetype.label : undefined;
25
66
 
26
67
  const name = (await p.text({
27
68
  message: 'Display name',
28
- placeholder: 'My Agent',
69
+ placeholder: nameDefault ?? 'My Agent',
70
+ initialValue: initialName ?? nameDefault,
29
71
  validate: (v) => {
30
72
  if (!v || v.length > 50) return 'Required (max 50 chars)';
31
73
  },
@@ -33,81 +75,313 @@ export async function runCreateWizard(initialName?: string): Promise<AgentConfig
33
75
 
34
76
  if (p.isCancel(name)) return null;
35
77
 
36
- const role = (await p.text({
37
- message: 'Role (one line)',
38
- placeholder: 'A helpful AI assistant for...',
39
- validate: (v) => {
40
- if (!v || v.length > 100) return 'Required (max 100 chars)';
78
+ // ─── Step 3: Agent ID (auto-derived, confirm or edit) ─────
79
+ const autoId = slugify(name);
80
+
81
+ const id = (await p.text({
82
+ message: 'Agent ID (auto-generated, press Enter to accept)',
83
+ placeholder: autoId,
84
+ initialValue: autoId,
85
+ validate: (v = '') => {
86
+ if (!/^[a-z][a-z0-9-]*$/.test(v)) return 'Must be kebab-case (e.g., "my-agent")';
41
87
  },
42
88
  })) as string;
43
89
 
44
- if (p.isCancel(role)) return null;
90
+ if (p.isCancel(id)) return null;
45
91
 
46
- const description = (await p.text({
47
- message: 'Description',
48
- placeholder: 'This agent helps developers with...',
49
- validate: (v) => {
50
- if (!v || v.length < 10 || v.length > 500) return 'Required (10-500 chars)';
51
- },
52
- })) as string;
92
+ // ─── Step 4: Role ─────────────────────────────────────────
93
+ let role: string;
53
94
 
54
- if (p.isCancel(description)) return null;
95
+ if (isCustom) {
96
+ p.note(
97
+ [
98
+ CUSTOM_ROLE_GUIDANCE.instruction,
99
+ '',
100
+ 'Examples:',
101
+ ...CUSTOM_ROLE_GUIDANCE.examples.map((e) => ` "${e}"`),
102
+ ].join('\n'),
103
+ '\u2726 Custom Agent Playbook',
104
+ );
55
105
 
56
- const domainsRaw = (await p.text({
57
- message: 'Domains (comma-separated, kebab-case)',
58
- placeholder: 'api-design, security, testing',
59
- validate: (v = '') => {
60
- const parts = v
61
- .split(',')
62
- .map((s) => s.trim())
63
- .filter(Boolean);
64
- if (parts.length === 0) return 'At least one domain required';
65
- for (const d of parts) {
66
- if (!/^[a-z][a-z0-9-]*$/.test(d)) return `Invalid domain "${d}" — must be kebab-case`;
67
- }
68
- },
69
- })) as string;
106
+ const customRole = (await p.text({
107
+ message: 'What does your agent do? (one sentence)',
108
+ placeholder: 'Validates GraphQL schemas against federation rules',
109
+ validate: (v) => {
110
+ if (!v || v.length > 100) return 'Required (max 100 chars)';
111
+ },
112
+ })) as string;
70
113
 
71
- if (p.isCancel(domainsRaw)) return null;
114
+ if (p.isCancel(customRole)) return null;
115
+ role = customRole;
116
+ } else {
117
+ const prefilledRole = archetype!.defaults.role;
118
+ const editedRole = (await p.text({
119
+ message: 'Role (pre-filled, press Enter to accept)',
120
+ initialValue: prefilledRole,
121
+ validate: (v) => {
122
+ if (!v || v.length > 100) return 'Required (max 100 chars)';
123
+ },
124
+ })) as string;
72
125
 
73
- const domains = domainsRaw
74
- .split(',')
75
- .map((s) => s.trim())
76
- .filter(Boolean);
126
+ if (p.isCancel(editedRole)) return null;
127
+ role = editedRole;
128
+ }
77
129
 
78
- const principlesRaw = (await p.text({
79
- message: 'Principles (one per line)',
80
- placeholder: 'Security first\nSimplicity over cleverness\nTest everything',
81
- validate: (v = '') => {
82
- const lines = v
83
- .split('\n')
84
- .map((s) => s.trim())
85
- .filter(Boolean);
86
- if (lines.length === 0) return 'At least one principle required';
87
- if (lines.length > 10) return 'Max 10 principles';
130
+ // ─── Step 5: Description ──────────────────────────────────
131
+ let description: string;
132
+
133
+ if (isCustom) {
134
+ p.note(
135
+ [
136
+ CUSTOM_DESCRIPTION_GUIDANCE.instruction,
137
+ '',
138
+ 'Example:',
139
+ ...CUSTOM_DESCRIPTION_GUIDANCE.examples.map((e) => ` "${e}"`),
140
+ ].join('\n'),
141
+ '\u2726 Description',
142
+ );
143
+
144
+ const customDesc = (await p.text({
145
+ message: 'Describe your agent in detail',
146
+ placeholder: 'This agent helps developers with...',
147
+ validate: (v) => {
148
+ if (!v || v.length < 10 || v.length > 500) return 'Required (10-500 chars)';
149
+ },
150
+ })) as string;
151
+
152
+ if (p.isCancel(customDesc)) return null;
153
+ description = customDesc;
154
+ } else {
155
+ const prefilledDesc = archetype!.defaults.description;
156
+ const editedDesc = (await p.text({
157
+ message: 'Description (pre-filled, press Enter to accept)',
158
+ initialValue: prefilledDesc,
159
+ validate: (v) => {
160
+ if (!v || v.length < 10 || v.length > 500) return 'Required (10-500 chars)';
161
+ },
162
+ })) as string;
163
+
164
+ if (p.isCancel(editedDesc)) return null;
165
+ description = editedDesc;
166
+ }
167
+
168
+ // ─── Step 6: Domains (multiselect) ────────────────────────
169
+ const preselectedDomains = new Set(archetype?.defaults.domains ?? []);
170
+
171
+ const domainChoices = [
172
+ ...DOMAIN_OPTIONS.map((d) => ({
173
+ value: d.value,
174
+ label: d.label,
175
+ hint: d.hint,
176
+ })),
177
+ {
178
+ value: '_custom',
179
+ label: '\u2726 Add custom domain...',
180
+ hint: 'Define your own domain with playbook guidance',
88
181
  },
89
- })) as string;
182
+ ];
90
183
 
91
- if (p.isCancel(principlesRaw)) return null;
184
+ // Pre-select archetype domains via initialValues
185
+ const domainSelection = await p.multiselect({
186
+ message: 'Select domains (areas of expertise)',
187
+ options: domainChoices,
188
+ initialValues: [...preselectedDomains],
189
+ required: true,
190
+ });
92
191
 
93
- const principles = principlesRaw
94
- .split('\n')
95
- .map((s) => s.trim())
96
- .filter(Boolean);
192
+ if (p.isCancel(domainSelection)) return null;
97
193
 
98
- const greeting = (await p.text({
194
+ const domains = (domainSelection as string[]).filter((d) => d !== '_custom');
195
+ const wantsCustomDomain = (domainSelection as string[]).includes('_custom');
196
+
197
+ if (wantsCustomDomain) {
198
+ p.note(
199
+ [
200
+ CUSTOM_DOMAIN_GUIDANCE.instruction,
201
+ '',
202
+ 'Examples:',
203
+ ...CUSTOM_DOMAIN_GUIDANCE.examples.map((e) => ` ${e}`),
204
+ '',
205
+ 'Avoid:',
206
+ ...CUSTOM_DOMAIN_GUIDANCE.antiExamples.map((e) => ` \u2717 ${e}`),
207
+ ].join('\n'),
208
+ '\u2726 Custom Domain',
209
+ );
210
+
211
+ const customDomain = (await p.text({
212
+ message: 'Custom domain name (kebab-case)',
213
+ placeholder: 'graphql-federation',
214
+ validate: (v = '') => {
215
+ if (!/^[a-z][a-z0-9-]*$/.test(v)) return 'Must be kebab-case';
216
+ if (domains.includes(v)) return 'Already selected';
217
+ },
218
+ })) as string;
219
+
220
+ if (!p.isCancel(customDomain)) {
221
+ domains.push(customDomain);
222
+ }
223
+ }
224
+
225
+ if (domains.length === 0) {
226
+ p.log.error('At least one domain is required');
227
+ return null;
228
+ }
229
+
230
+ // ─── Step 7: Principles (multiselect) ─────────────────────
231
+ const preselectedPrinciples = new Set(archetype?.defaults.principles ?? []);
232
+
233
+ // Flatten categories into a single options list with group labels
234
+ const principleChoices = PRINCIPLE_CATEGORIES.flatMap((cat) => cat.options.map((o) => ({
235
+ value: o.value,
236
+ label: o.label,
237
+ hint: cat.label,
238
+ })));
239
+
240
+ principleChoices.push({
241
+ value: '_custom',
242
+ label: '\u2726 Add custom principle...',
243
+ hint: 'Write your own guiding principle',
244
+ });
245
+
246
+ const principleSelection = await p.multiselect({
247
+ message: 'Select guiding principles',
248
+ options: principleChoices,
249
+ initialValues: [...preselectedPrinciples],
250
+ required: true,
251
+ });
252
+
253
+ if (p.isCancel(principleSelection)) return null;
254
+
255
+ const principles = (principleSelection as string[]).filter((p) => p !== '_custom');
256
+ const wantsCustomPrinciple = (principleSelection as string[]).includes('_custom');
257
+
258
+ if (wantsCustomPrinciple) {
259
+ p.note(
260
+ [
261
+ CUSTOM_PRINCIPLE_GUIDANCE.instruction,
262
+ '',
263
+ 'Good principles are specific and actionable:',
264
+ ...CUSTOM_PRINCIPLE_GUIDANCE.examples.map((e) => ` \u2713 "${e}"`),
265
+ '',
266
+ 'Avoid vague principles:',
267
+ ...CUSTOM_PRINCIPLE_GUIDANCE.antiExamples.map((e) => ` \u2717 ${e}`),
268
+ ].join('\n'),
269
+ '\u2726 Custom Principle',
270
+ );
271
+
272
+ const customPrinciple = (await p.text({
273
+ message: 'Your custom principle',
274
+ placeholder: 'Every public API must have a deprecation path',
275
+ validate: (v) => {
276
+ if (!v) return 'Required';
277
+ if (v.length > 100) return 'Max 100 chars';
278
+ },
279
+ })) as string;
280
+
281
+ if (!p.isCancel(customPrinciple)) {
282
+ principles.push(customPrinciple);
283
+ }
284
+ }
285
+
286
+ if (principles.length === 0) {
287
+ p.log.error('At least one principle is required');
288
+ return null;
289
+ }
290
+
291
+ // ─── Step 8: Communication tone ───────────────────────────
292
+ const defaultTone = archetype?.defaults.tone ?? 'pragmatic';
293
+
294
+ const tone = await p.select({
295
+ message: 'Communication tone',
296
+ options: TONE_OPTIONS.map((t) => ({
297
+ value: t.value,
298
+ label: t.label,
299
+ hint: t.hint,
300
+ })),
301
+ initialValue: defaultTone,
302
+ });
303
+
304
+ if (p.isCancel(tone)) return null;
305
+
306
+ // ─── Step 9: Skills (multiselect) ─────────────────────────
307
+ const preselectedSkills = new Set(archetype?.defaults.skills ?? []);
308
+
309
+ p.note(`Always included: ${CORE_SKILLS.join(', ')}`, 'Core Skills');
310
+
311
+ const skillChoices = SKILL_CATEGORIES.flatMap((cat) =>
312
+ cat.options.map((o) => ({
313
+ value: o.value,
314
+ label: o.label,
315
+ hint: `${o.hint} (${cat.label})`,
316
+ })),
317
+ );
318
+
319
+ const skillSelection = await p.multiselect({
320
+ message: 'Select additional skills',
321
+ options: skillChoices,
322
+ initialValues: [...preselectedSkills].filter((s) => ALL_OPTIONAL_SKILLS.includes(s)),
323
+ required: false,
324
+ });
325
+
326
+ if (p.isCancel(skillSelection)) return null;
327
+
328
+ const selectedSkills = [...CORE_SKILLS, ...(skillSelection as string[])];
329
+
330
+ // ─── Step 10: Greeting (auto or custom) ───────────────────
331
+ const autoGreeting = archetype
332
+ ? archetype.defaults.greetingTemplate(name)
333
+ : `Hello! I'm ${name}. I ${role[0].toLowerCase()}${role.slice(1)}.`;
334
+
335
+ const greetingChoice = await p.select({
99
336
  message: 'Greeting message',
100
- placeholder: `Hello! I'm ${name}, your AI assistant for...`,
101
- validate: (v) => {
102
- if (!v || v.length < 10 || v.length > 300) return 'Required (10-300 chars)';
103
- },
104
- })) as string;
337
+ options: [
338
+ {
339
+ value: 'auto',
340
+ label: `Auto \u2014 "${autoGreeting.length > 70 ? autoGreeting.slice(0, 67) + '...' : autoGreeting}"`,
341
+ hint: 'Generated from name + role',
342
+ },
343
+ {
344
+ value: 'custom',
345
+ label: '\u2726 Custom \u2014 Write your own greeting',
346
+ hint: 'Opens playbook-guided text field',
347
+ },
348
+ ],
349
+ initialValue: 'auto',
350
+ });
351
+
352
+ if (p.isCancel(greetingChoice)) return null;
353
+
354
+ let greeting: string;
355
+
356
+ if (greetingChoice === 'custom') {
357
+ p.note(
358
+ [
359
+ CUSTOM_GREETING_GUIDANCE.instruction,
360
+ '',
361
+ 'Examples:',
362
+ ...CUSTOM_GREETING_GUIDANCE.examples.map((e) => ` "${e}"`),
363
+ ].join('\n'),
364
+ '\u2726 Custom Greeting',
365
+ );
366
+
367
+ const customGreeting = (await p.text({
368
+ message: 'Your greeting',
369
+ placeholder: `Hello! I'm ${name}...`,
370
+ validate: (v) => {
371
+ if (!v || v.length < 10 || v.length > 300) return 'Required (10-300 chars)';
372
+ },
373
+ })) as string;
105
374
 
106
- if (p.isCancel(greeting)) return null;
375
+ if (p.isCancel(customGreeting)) return null;
376
+ greeting = customGreeting;
377
+ } else {
378
+ greeting = autoGreeting;
379
+ }
107
380
 
381
+ // ─── Step 11: Output directory ────────────────────────────
108
382
  const outputDir = (await p.text({
109
383
  message: 'Output directory',
110
- defaultValue: process.cwd(),
384
+ initialValue: process.cwd(),
111
385
  placeholder: process.cwd(),
112
386
  validate: (v) => {
113
387
  if (!v) return 'Required';
@@ -123,7 +397,9 @@ export async function runCreateWizard(initialName?: string): Promise<AgentConfig
123
397
  description,
124
398
  domains,
125
399
  principles,
400
+ tone: tone as 'precise' | 'mentor' | 'pragmatic',
126
401
  greeting,
127
402
  outputDir,
403
+ skills: selectedSkills,
128
404
  };
129
405
  }