@assistkick/create 1.18.0 → 1.21.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 +1 -1
- package/templates/assistkick-product-system/packages/backend/src/routes/mcp_config.ts +89 -0
- package/templates/assistkick-product-system/packages/backend/src/server.ts +8 -1
- package/templates/assistkick-product-system/packages/backend/src/services/chat_cli_bridge.ts +7 -0
- package/templates/assistkick-product-system/packages/backend/src/services/chat_ws_handler.ts +26 -1
- package/templates/assistkick-product-system/packages/backend/src/services/mcp_config_service.ts +134 -0
- package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +34 -0
- package/templates/assistkick-product-system/packages/frontend/src/components/ChatView.tsx +14 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/McpConfigModal.tsx +208 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/0001_superb_roxanne_simpson.sql +8 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0001_snapshot.json +1019 -23
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +7 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +10 -0
- package/templates/assistkick-product-system/packages/shared/lib/app_use_flow.ts +484 -0
- package/templates/assistkick-product-system/packages/shared/lib/openapi.ts +146 -0
- package/templates/assistkick-product-system/packages/shared/package.json +2 -1
- package/templates/assistkick-product-system/packages/shared/tools/agent_builder.ts +341 -0
- package/templates/assistkick-product-system/packages/shared/tools/app_use_record.ts +268 -0
- package/templates/assistkick-product-system/packages/shared/tools/app_use_run.ts +348 -0
- package/templates/assistkick-product-system/packages/shared/tools/app_use_validate.ts +67 -0
- package/templates/assistkick-product-system/packages/shared/tools/openapi_describe.ts +59 -0
- package/templates/assistkick-product-system/packages/shared/tools/openapi_list.ts +69 -0
- package/templates/assistkick-product-system/packages/shared/tools/openapi_schema.ts +67 -0
- package/templates/assistkick-product-system/packages/shared/tools/workflow_builder.ts +754 -0
- package/templates/skills/assistkick-agent-builder/SKILL.md +168 -0
- package/templates/skills/assistkick-app-use/SKILL.md +296 -0
- package/templates/skills/assistkick-app-use/references/agent-browser.md +1156 -0
- package/templates/skills/assistkick-openapi-explorer/SKILL.md +78 -0
- package/templates/skills/assistkick-openapi-explorer/cache/.gitignore +2 -0
- package/templates/skills/assistkick-workflow-builder/SKILL.md +234 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* agent_builder — Create and edit AI agents stored in the database.
|
|
5
|
+
*
|
|
6
|
+
* Actions:
|
|
7
|
+
* list — List all agents
|
|
8
|
+
* get <id> — Get an agent with full details
|
|
9
|
+
* create — Create a new agent
|
|
10
|
+
* update <id> — Update agent fields
|
|
11
|
+
* delete <id> — Delete an agent
|
|
12
|
+
* reset <id> — Reset a default agent's grounding to original
|
|
13
|
+
* list-skills — List available skills from disk
|
|
14
|
+
* list-models — List supported Claude models
|
|
15
|
+
* list-placeholders — Show available grounding template placeholders
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { program } from 'commander';
|
|
19
|
+
import chalk from 'chalk';
|
|
20
|
+
import { eq, isNull } from 'drizzle-orm';
|
|
21
|
+
import { agents, workflows } from '../db/schema.js';
|
|
22
|
+
import { getDb } from '../lib/db.js';
|
|
23
|
+
import { randomUUID } from 'node:crypto';
|
|
24
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
25
|
+
import { join, dirname } from 'node:path';
|
|
26
|
+
import { existsSync } from 'node:fs';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const SUPPORTED_MODELS = [
|
|
32
|
+
{ id: 'claude-opus-4-6', label: 'Claude Opus 4.6', description: 'Most capable, best for complex reasoning' },
|
|
33
|
+
{ id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', description: 'Balanced performance and speed' },
|
|
34
|
+
{ id: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5', description: 'Fastest, best for simple tasks' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const GROUNDING_PLACEHOLDERS = [
|
|
38
|
+
{ placeholder: '{{featureId}}', description: 'The feature ID being worked on', context: 'All stages' },
|
|
39
|
+
{ placeholder: '{{projectId}}', description: 'Current project ID', context: 'All stages' },
|
|
40
|
+
{ placeholder: '{{mainToolsDir}}', description: 'Absolute path to tools directory', context: 'All stages' },
|
|
41
|
+
{ placeholder: '{{mainSkillDir}}', description: 'Absolute path to shared data directory', context: 'All stages' },
|
|
42
|
+
{ placeholder: '{{pidFlag}}', description: 'The --project-id flag string', context: 'All stages' },
|
|
43
|
+
{ placeholder: '{{cycle}}', description: 'Current development cycle number', context: 'Developer' },
|
|
44
|
+
{ placeholder: '{{previousReviewNotes}}', description: 'Review feedback from prior cycle', context: 'Developer' },
|
|
45
|
+
{ placeholder: '{{debuggerFindings}}', description: 'Root cause analysis from debugger', context: 'Developer' },
|
|
46
|
+
{ placeholder: '{{unaddressedNotes}}', description: 'QA rejection notes to investigate', context: 'Debugger' },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// ── Skills discovery ──────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
let SKILLS_DIR = join(__dirname, '..', '..', '..', '..', '.claude', 'skills');
|
|
53
|
+
|
|
54
|
+
// Walk up to find the project root with .claude/skills
|
|
55
|
+
function findSkillsDir(): string {
|
|
56
|
+
let dir = join(__dirname, '..');
|
|
57
|
+
while (dir !== dirname(dir)) {
|
|
58
|
+
if (existsSync(join(dir, 'pnpm-workspace.yaml'))) {
|
|
59
|
+
const candidate = join(dir, '..', '.claude', 'skills');
|
|
60
|
+
if (existsSync(candidate)) return candidate;
|
|
61
|
+
}
|
|
62
|
+
dir = dirname(dir);
|
|
63
|
+
}
|
|
64
|
+
return SKILLS_DIR;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function listSkillsFromDisk(): Promise<Array<{ id: string; name: string; preview: string }>> {
|
|
68
|
+
const skillsDir = findSkillsDir();
|
|
69
|
+
if (!existsSync(skillsDir)) return [];
|
|
70
|
+
|
|
71
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
72
|
+
const skills: Array<{ id: string; name: string; preview: string }> = [];
|
|
73
|
+
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (!entry.isDirectory()) continue;
|
|
76
|
+
const skillMdPath = join(skillsDir, entry.name, 'SKILL.md');
|
|
77
|
+
let name = entry.name;
|
|
78
|
+
let preview = '';
|
|
79
|
+
|
|
80
|
+
if (existsSync(skillMdPath)) {
|
|
81
|
+
try {
|
|
82
|
+
const content = await readFile(skillMdPath, 'utf-8');
|
|
83
|
+
const lines = content.split('\n');
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
if (line.trim().startsWith('# ')) {
|
|
86
|
+
name = line.trim().slice(2).trim();
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const headingEnd = content.indexOf('\n', content.indexOf('# '));
|
|
91
|
+
const body = content.slice(headingEnd >= 0 ? headingEnd + 1 : 0).trim();
|
|
92
|
+
preview = body.length > 200 ? body.slice(0, 200) + '…' : body;
|
|
93
|
+
} catch {
|
|
94
|
+
// Use folder name as fallback
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
skills.push({ id: entry.name, name, preview });
|
|
98
|
+
}
|
|
99
|
+
return skills;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function formatSkillsList(skillsJson: string): string {
|
|
105
|
+
try {
|
|
106
|
+
const arr = JSON.parse(skillsJson);
|
|
107
|
+
return Array.isArray(arr) && arr.length > 0 ? arr.join(', ') : '(none)';
|
|
108
|
+
} catch {
|
|
109
|
+
return skillsJson || '(none)';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function printAgent(agent: any) {
|
|
114
|
+
const scope = agent.projectId ? chalk.gray(` (project: ${agent.projectId})`) : chalk.gray(' (global)');
|
|
115
|
+
const def = agent.isDefault ? chalk.yellow(' [DEFAULT]') : '';
|
|
116
|
+
|
|
117
|
+
console.log(chalk.cyan.bold(`\nAgent: ${agent.name}${def}${scope}\n`));
|
|
118
|
+
console.log(` ID: ${agent.id}`);
|
|
119
|
+
console.log(` Model: ${chalk.bold(agent.model)}`);
|
|
120
|
+
console.log(` Skills: ${formatSkillsList(agent.skills)}`);
|
|
121
|
+
console.log(` Has default grounding: ${agent.defaultGrounding ? 'yes' : 'no'}`);
|
|
122
|
+
console.log(` Created: ${agent.createdAt}`);
|
|
123
|
+
console.log(` Updated: ${agent.updatedAt}`);
|
|
124
|
+
console.log();
|
|
125
|
+
console.log(chalk.cyan(' Grounding:'));
|
|
126
|
+
const groundingLines = agent.grounding.split('\n');
|
|
127
|
+
for (const line of groundingLines) {
|
|
128
|
+
console.log(` ${line}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
program
|
|
135
|
+
.argument('<action>', 'Action to perform')
|
|
136
|
+
.argument('[target]', 'Agent ID')
|
|
137
|
+
.option('--project-id <id>', 'Project ID (scope filter)')
|
|
138
|
+
.option('--scope <scope>', 'Scope filter: global or project', 'global')
|
|
139
|
+
.option('--name <name>', 'Agent name')
|
|
140
|
+
.option('--model <model>', 'Claude model ID')
|
|
141
|
+
.option('--skills <json>', 'Skills as JSON array (e.g. \'["assistkick-developer"]\')')
|
|
142
|
+
.option('--grounding <text>', 'Agent grounding/system prompt')
|
|
143
|
+
.option('--grounding-file <path>', 'Read grounding from a file instead of --grounding')
|
|
144
|
+
.parse();
|
|
145
|
+
|
|
146
|
+
const [action, target] = program.args;
|
|
147
|
+
const opts = program.opts();
|
|
148
|
+
|
|
149
|
+
(async () => {
|
|
150
|
+
const db = getDb();
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
switch (action) {
|
|
154
|
+
|
|
155
|
+
// ── List agents ─────────────────────────────────────────────────────────
|
|
156
|
+
case 'list': {
|
|
157
|
+
let rows;
|
|
158
|
+
if (opts.scope === 'project' && opts.projectId) {
|
|
159
|
+
rows = await db.select().from(agents).where(eq(agents.projectId, opts.projectId));
|
|
160
|
+
} else if (opts.projectId) {
|
|
161
|
+
// Show both global and project-scoped
|
|
162
|
+
const allRows = await db.select().from(agents);
|
|
163
|
+
rows = allRows.filter((r: any) => r.projectId === null || r.projectId === opts.projectId);
|
|
164
|
+
} else {
|
|
165
|
+
rows = await db.select().from(agents).where(isNull(agents.projectId));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(chalk.cyan.bold('Agents:\n'));
|
|
169
|
+
for (const row of rows) {
|
|
170
|
+
const def = row.isDefault ? chalk.yellow(' [DEFAULT]') : '';
|
|
171
|
+
const scope = row.projectId ? chalk.gray(` (project: ${row.projectId})`) : chalk.gray(' (global)');
|
|
172
|
+
console.log(` ${chalk.bold(row.name)}${def}${scope}`);
|
|
173
|
+
console.log(` ID: ${row.id} Model: ${row.model} Skills: ${formatSkillsList(row.skills)}`);
|
|
174
|
+
}
|
|
175
|
+
console.log('\n' + JSON.stringify({
|
|
176
|
+
agents: rows.map((r: any) => ({
|
|
177
|
+
id: r.id, name: r.name, model: r.model, skills: r.skills,
|
|
178
|
+
projectId: r.projectId, isDefault: r.isDefault,
|
|
179
|
+
})),
|
|
180
|
+
}));
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Get agent ───────────────────────────────────────────────────────────
|
|
185
|
+
case 'get': {
|
|
186
|
+
if (!target) throw new Error('Usage: agent_builder get <agent-id>');
|
|
187
|
+
const [row] = await db.select().from(agents).where(eq(agents.id, target));
|
|
188
|
+
if (!row) throw new Error(`Agent not found: ${target}`);
|
|
189
|
+
|
|
190
|
+
printAgent(row);
|
|
191
|
+
console.log('\n' + JSON.stringify({ agent: row }));
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── Create agent ────────────────────────────────────────────────────────
|
|
196
|
+
case 'create': {
|
|
197
|
+
if (!opts.name) throw new Error('--name is required');
|
|
198
|
+
|
|
199
|
+
let grounding = opts.grounding || '';
|
|
200
|
+
if (opts.groundingFile) {
|
|
201
|
+
if (!existsSync(opts.groundingFile)) throw new Error(`Grounding file not found: ${opts.groundingFile}`);
|
|
202
|
+
grounding = await readFile(opts.groundingFile, 'utf-8');
|
|
203
|
+
}
|
|
204
|
+
if (!grounding) throw new Error('--grounding or --grounding-file is required');
|
|
205
|
+
|
|
206
|
+
const now = new Date().toISOString();
|
|
207
|
+
const id = randomUUID();
|
|
208
|
+
|
|
209
|
+
const record = {
|
|
210
|
+
id,
|
|
211
|
+
name: opts.name.trim(),
|
|
212
|
+
model: opts.model || 'claude-opus-4-6',
|
|
213
|
+
skills: opts.skills || '[]',
|
|
214
|
+
grounding,
|
|
215
|
+
defaultGrounding: null,
|
|
216
|
+
projectId: opts.projectId || null,
|
|
217
|
+
isDefault: 0,
|
|
218
|
+
createdAt: now,
|
|
219
|
+
updatedAt: now,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
await db.insert(agents).values(record);
|
|
223
|
+
console.log(chalk.green(`Created agent: ${opts.name} (${id})`));
|
|
224
|
+
console.log(JSON.stringify({ id, name: opts.name, model: record.model }));
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Update agent ────────────────────────────────────────────────────────
|
|
229
|
+
case 'update': {
|
|
230
|
+
if (!target) throw new Error('Usage: agent_builder update <agent-id> [options]');
|
|
231
|
+
const [existing] = await db.select().from(agents).where(eq(agents.id, target));
|
|
232
|
+
if (!existing) throw new Error(`Agent not found: ${target}`);
|
|
233
|
+
|
|
234
|
+
const now = new Date().toISOString();
|
|
235
|
+
const updates: Record<string, any> = { updatedAt: now };
|
|
236
|
+
|
|
237
|
+
if (opts.name) updates.name = opts.name.trim();
|
|
238
|
+
if (opts.model) updates.model = opts.model;
|
|
239
|
+
if (opts.skills) updates.skills = opts.skills;
|
|
240
|
+
|
|
241
|
+
if (opts.groundingFile) {
|
|
242
|
+
if (!existsSync(opts.groundingFile)) throw new Error(`Grounding file not found: ${opts.groundingFile}`);
|
|
243
|
+
updates.grounding = await readFile(opts.groundingFile, 'utf-8');
|
|
244
|
+
} else if (opts.grounding) {
|
|
245
|
+
updates.grounding = opts.grounding;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await db.update(agents).set(updates).where(eq(agents.id, target));
|
|
249
|
+
console.log(chalk.green(`Updated agent: ${existing.name} (${target})`));
|
|
250
|
+
console.log(JSON.stringify({ id: target, ...updates }));
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Delete agent ────────────────────────────────────────────────────────
|
|
255
|
+
case 'delete': {
|
|
256
|
+
if (!target) throw new Error('Usage: agent_builder delete <agent-id>');
|
|
257
|
+
const [existing] = await db.select().from(agents).where(eq(agents.id, target));
|
|
258
|
+
if (!existing) throw new Error(`Agent not found: ${target}`);
|
|
259
|
+
|
|
260
|
+
if (existing.isDefault) throw new Error('Cannot delete a default agent');
|
|
261
|
+
|
|
262
|
+
// Check if agent is referenced in any workflow
|
|
263
|
+
const allWorkflows = await db.select().from(workflows);
|
|
264
|
+
const referencing = allWorkflows.find((w: any) => w.graphData.includes(target));
|
|
265
|
+
if (referencing) {
|
|
266
|
+
throw new Error(`Cannot delete agent — referenced in workflow "${referencing.name}" (${referencing.id})`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
await db.delete(agents).where(eq(agents.id, target));
|
|
270
|
+
console.log(chalk.green(`Deleted agent: ${existing.name} (${target})`));
|
|
271
|
+
console.log(JSON.stringify({ deleted: target }));
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Reset default agent ─────────────────────────────────────────────────
|
|
276
|
+
case 'reset': {
|
|
277
|
+
if (!target) throw new Error('Usage: agent_builder reset <agent-id>');
|
|
278
|
+
const [existing] = await db.select().from(agents).where(eq(agents.id, target));
|
|
279
|
+
if (!existing) throw new Error(`Agent not found: ${target}`);
|
|
280
|
+
if (!existing.isDefault) throw new Error('Only default agents can be reset');
|
|
281
|
+
if (!existing.defaultGrounding) throw new Error('No default grounding available for this agent');
|
|
282
|
+
|
|
283
|
+
const now = new Date().toISOString();
|
|
284
|
+
await db.update(agents)
|
|
285
|
+
.set({ grounding: existing.defaultGrounding, updatedAt: now })
|
|
286
|
+
.where(eq(agents.id, target));
|
|
287
|
+
|
|
288
|
+
console.log(chalk.green(`Reset agent grounding to default: ${existing.name} (${target})`));
|
|
289
|
+
console.log(JSON.stringify({ id: target, reset: true }));
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── List skills ─────────────────────────────────────────────────────────
|
|
294
|
+
case 'list-skills': {
|
|
295
|
+
const skills = await listSkillsFromDisk();
|
|
296
|
+
|
|
297
|
+
console.log(chalk.cyan.bold('Available Skills:\n'));
|
|
298
|
+
for (const skill of skills) {
|
|
299
|
+
console.log(` ${chalk.bold(skill.id)}`);
|
|
300
|
+
if (skill.name !== skill.id) console.log(` Name: ${skill.name}`);
|
|
301
|
+
if (skill.preview) {
|
|
302
|
+
const short = skill.preview.split('\n')[0].slice(0, 100);
|
|
303
|
+
console.log(` ${chalk.gray(short)}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
console.log('\n' + JSON.stringify({ skills }));
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── List models ─────────────────────────────────────────────────────────
|
|
311
|
+
case 'list-models': {
|
|
312
|
+
console.log(chalk.cyan.bold('Supported Models:\n'));
|
|
313
|
+
for (const m of SUPPORTED_MODELS) {
|
|
314
|
+
console.log(` ${chalk.bold(m.id)}`);
|
|
315
|
+
console.log(` ${m.label} — ${m.description}`);
|
|
316
|
+
}
|
|
317
|
+
console.log('\n' + JSON.stringify({ models: SUPPORTED_MODELS }));
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── List placeholders ───────────────────────────────────────────────────
|
|
322
|
+
case 'list-placeholders': {
|
|
323
|
+
console.log(chalk.cyan.bold('Grounding Template Placeholders:\n'));
|
|
324
|
+
console.log(' Use these in agent grounding text. They are resolved at execution time.\n');
|
|
325
|
+
for (const p of GROUNDING_PLACEHOLDERS) {
|
|
326
|
+
console.log(` ${chalk.bold(p.placeholder)}`);
|
|
327
|
+
console.log(` ${p.description}`);
|
|
328
|
+
console.log(` Context: ${chalk.gray(p.context)}`);
|
|
329
|
+
}
|
|
330
|
+
console.log('\n' + JSON.stringify({ placeholders: GROUNDING_PLACEHOLDERS }));
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
default:
|
|
335
|
+
throw new Error(`Unknown action "${action}". Valid actions: list, get, create, update, delete, reset, list-skills, list-models, list-placeholders`);
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
})();
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* app_use_record — Record flow sessions by accumulating steps into a YAML flow file.
|
|
5
|
+
*
|
|
6
|
+
* The agent calls `start` to begin a session, `add-step` / `add-assert` to log actions,
|
|
7
|
+
* and `stop` to write the final YAML. Steps and comments are accumulated in a temp file.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* pnpm tsx packages/shared/tools/app_use_record.ts start --output flows/login.yaml --url http://localhost:5173 [--name "Login Flow"]
|
|
11
|
+
* pnpm tsx packages/shared/tools/app_use_record.ts add-step --type click --selector "@e1" [--value "..."] [--label "Click submit"]
|
|
12
|
+
* pnpm tsx packages/shared/tools/app_use_record.ts add-assert --type assertVisible --value "Welcome" [--label "Check welcome msg"]
|
|
13
|
+
* pnpm tsx packages/shared/tools/app_use_record.ts add-comment "Snapshot taken - found 5 interactive elements"
|
|
14
|
+
* pnpm tsx packages/shared/tools/app_use_record.ts status
|
|
15
|
+
* pnpm tsx packages/shared/tools/app_use_record.ts stop
|
|
16
|
+
* pnpm tsx packages/shared/tools/app_use_record.ts discard
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { program } from 'commander';
|
|
20
|
+
import chalk from 'chalk';
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import {
|
|
25
|
+
type FlowConfig, type FlowStep, type FlowFile,
|
|
26
|
+
writeFlowFile, serializeFlowYaml,
|
|
27
|
+
} from '../lib/app_use_flow.js';
|
|
28
|
+
|
|
29
|
+
// ── Session state persistence ─────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const SESSION_FILE = join(tmpdir(), 'app-use-recording-session.json');
|
|
32
|
+
|
|
33
|
+
interface RecordingSession {
|
|
34
|
+
outputPath: string;
|
|
35
|
+
config: FlowConfig;
|
|
36
|
+
steps: FlowStep[];
|
|
37
|
+
comments: string[];
|
|
38
|
+
startedAt: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadSession(): RecordingSession | null {
|
|
42
|
+
if (!existsSync(SESSION_FILE)) return null;
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function saveSession(session: RecordingSession): void {
|
|
51
|
+
writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), 'utf-8');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function clearSession(): void {
|
|
55
|
+
if (existsSync(SESSION_FILE)) unlinkSync(SESSION_FILE);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requireSession(): RecordingSession {
|
|
59
|
+
const session = loadSession();
|
|
60
|
+
if (!session) {
|
|
61
|
+
throw new Error('No active recording session. Start one with: app_use_record start --output <path> --url <url>');
|
|
62
|
+
}
|
|
63
|
+
return session;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── CLI ───────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
program
|
|
69
|
+
.argument('<action>', 'Action: start, add-step, add-assert, add-comment, status, stop, discard')
|
|
70
|
+
.argument('[text]', 'Comment text (for add-comment)')
|
|
71
|
+
.option('--output <path>', 'Output YAML file path (for start)')
|
|
72
|
+
.option('--url <url>', 'App URL (for start, sets appId)')
|
|
73
|
+
.option('--name <name>', 'Flow name (for start)')
|
|
74
|
+
.option('--platform <platform>', 'Platform: web, ios, android (for start)', 'web')
|
|
75
|
+
.option('--auto-session <bool>', 'Auto-manage browser session', 'true')
|
|
76
|
+
.option('--type <type>', 'Step type (for add-step / add-assert)')
|
|
77
|
+
.option('--selector <sel>', 'Element selector')
|
|
78
|
+
.option('--value <val>', 'Step value')
|
|
79
|
+
.option('--params <json>', 'Additional params as JSON')
|
|
80
|
+
.option('--label <label>', 'Human-readable label for the step')
|
|
81
|
+
.option('--optional', 'Mark step as optional (failure won\'t abort)')
|
|
82
|
+
.parse();
|
|
83
|
+
|
|
84
|
+
const [action, text] = program.args;
|
|
85
|
+
const opts = program.opts();
|
|
86
|
+
|
|
87
|
+
(async () => {
|
|
88
|
+
try {
|
|
89
|
+
switch (action) {
|
|
90
|
+
|
|
91
|
+
// ── Start recording ───────────────────────────────────────────────────
|
|
92
|
+
case 'start': {
|
|
93
|
+
if (!opts.output) throw new Error('--output is required');
|
|
94
|
+
|
|
95
|
+
const existing = loadSession();
|
|
96
|
+
if (existing) {
|
|
97
|
+
throw new Error(`Recording already in progress (output: ${existing.outputPath}). Use "stop" or "discard" first.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const config: FlowConfig = {
|
|
101
|
+
platform: opts.platform || 'web',
|
|
102
|
+
autoSession: opts.autoSession !== 'false',
|
|
103
|
+
};
|
|
104
|
+
if (opts.url) config.appId = opts.url;
|
|
105
|
+
if (opts.name) config.name = opts.name;
|
|
106
|
+
|
|
107
|
+
const session: RecordingSession = {
|
|
108
|
+
outputPath: opts.output,
|
|
109
|
+
config,
|
|
110
|
+
steps: [],
|
|
111
|
+
comments: [],
|
|
112
|
+
startedAt: new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
saveSession(session);
|
|
116
|
+
console.log(chalk.green(`Recording started`));
|
|
117
|
+
console.log(` Output: ${opts.output}`);
|
|
118
|
+
console.log(` Platform: ${config.platform}`);
|
|
119
|
+
if (opts.url) console.log(` URL: ${opts.url}`);
|
|
120
|
+
console.log(JSON.stringify({ status: 'recording', output: opts.output }));
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Add a step ────────────────────────────────────────────────────────
|
|
125
|
+
case 'add-step': {
|
|
126
|
+
if (!opts.type) throw new Error('--type is required for add-step');
|
|
127
|
+
const session = requireSession();
|
|
128
|
+
|
|
129
|
+
const step: FlowStep = { type: opts.type };
|
|
130
|
+
if (opts.selector) step.selector = opts.selector;
|
|
131
|
+
if (opts.value) step.value = opts.value;
|
|
132
|
+
if (opts.label) step.label = opts.label;
|
|
133
|
+
if (opts.optional) step.optional = true;
|
|
134
|
+
if (opts.params) step.params = JSON.parse(opts.params);
|
|
135
|
+
|
|
136
|
+
session.steps.push(step);
|
|
137
|
+
saveSession(session);
|
|
138
|
+
|
|
139
|
+
const idx = session.steps.length;
|
|
140
|
+
console.log(chalk.green(`Step ${idx} added: ${step.type}${step.selector ? ' ' + step.selector : ''}${step.value ? ' ' + step.value : ''}`));
|
|
141
|
+
console.log(JSON.stringify({ stepIndex: idx, step }));
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Add an assertion ──────────────────────────────────────────────────
|
|
146
|
+
case 'add-assert': {
|
|
147
|
+
if (!opts.type) throw new Error('--type is required for add-assert');
|
|
148
|
+
const session = requireSession();
|
|
149
|
+
|
|
150
|
+
const step: FlowStep = { type: opts.type };
|
|
151
|
+
if (opts.selector) step.selector = opts.selector;
|
|
152
|
+
if (opts.value) step.value = opts.value;
|
|
153
|
+
if (opts.label) step.label = opts.label;
|
|
154
|
+
if (opts.optional) step.optional = true;
|
|
155
|
+
if (opts.params) step.params = JSON.parse(opts.params);
|
|
156
|
+
|
|
157
|
+
session.steps.push(step);
|
|
158
|
+
saveSession(session);
|
|
159
|
+
|
|
160
|
+
const idx = session.steps.length;
|
|
161
|
+
console.log(chalk.green(`Assertion ${idx} added: ${step.type} "${step.value || ''}"`));
|
|
162
|
+
console.log(JSON.stringify({ stepIndex: idx, step }));
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Add a comment ─────────────────────────────────────────────────────
|
|
167
|
+
case 'add-comment': {
|
|
168
|
+
if (!text) throw new Error('Comment text is required');
|
|
169
|
+
const session = requireSession();
|
|
170
|
+
|
|
171
|
+
// Comments are associated with the step count at the time of recording
|
|
172
|
+
const comment = `[after step ${session.steps.length}] ${text}`;
|
|
173
|
+
session.comments.push(comment);
|
|
174
|
+
saveSession(session);
|
|
175
|
+
|
|
176
|
+
console.log(chalk.gray(`Comment added: ${text}`));
|
|
177
|
+
console.log(JSON.stringify({ comment: text, afterStep: session.steps.length }));
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Show status ───────────────────────────────────────────────────────
|
|
182
|
+
case 'status': {
|
|
183
|
+
const session = loadSession();
|
|
184
|
+
if (!session) {
|
|
185
|
+
console.log(chalk.yellow('No active recording session'));
|
|
186
|
+
console.log(JSON.stringify({ status: 'idle' }));
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.log(chalk.cyan.bold('Recording Session'));
|
|
191
|
+
console.log(` Output: ${session.outputPath}`);
|
|
192
|
+
console.log(` Platform: ${session.config.platform}`);
|
|
193
|
+
console.log(` Steps: ${session.steps.length}`);
|
|
194
|
+
console.log(` Comments: ${session.comments.length}`);
|
|
195
|
+
console.log(` Started: ${session.startedAt}`);
|
|
196
|
+
console.log();
|
|
197
|
+
|
|
198
|
+
if (session.steps.length > 0) {
|
|
199
|
+
console.log(chalk.cyan(' Steps:'));
|
|
200
|
+
for (let i = 0; i < session.steps.length; i++) {
|
|
201
|
+
const s = session.steps[i];
|
|
202
|
+
const label = s.label || `${s.type}${s.selector ? ' ' + s.selector : ''}${s.value ? ' ' + s.value : ''}`;
|
|
203
|
+
console.log(` ${i + 1}. ${label}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
console.log('\n' + JSON.stringify({
|
|
208
|
+
status: 'recording', output: session.outputPath,
|
|
209
|
+
steps: session.steps.length, comments: session.comments.length,
|
|
210
|
+
}));
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Stop and save ─────────────────────────────────────────────────────
|
|
215
|
+
case 'stop': {
|
|
216
|
+
const session = requireSession();
|
|
217
|
+
|
|
218
|
+
const flow: FlowFile = {
|
|
219
|
+
config: session.config,
|
|
220
|
+
steps: session.steps,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Ensure output directory exists
|
|
224
|
+
const dir = dirname(session.outputPath);
|
|
225
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
226
|
+
|
|
227
|
+
writeFlowFile(session.outputPath, flow, session.comments);
|
|
228
|
+
clearSession();
|
|
229
|
+
|
|
230
|
+
console.log(chalk.green(`Flow saved: ${session.outputPath}`));
|
|
231
|
+
console.log(` Steps: ${session.steps.length}`);
|
|
232
|
+
console.log(` Comments: ${session.comments.length}`);
|
|
233
|
+
|
|
234
|
+
// Preview the file
|
|
235
|
+
console.log(chalk.gray('\n--- Preview ---'));
|
|
236
|
+
console.log(chalk.gray(serializeFlowYaml(flow, session.comments)));
|
|
237
|
+
console.log(chalk.gray('--- End preview ---'));
|
|
238
|
+
|
|
239
|
+
console.log('\n' + JSON.stringify({
|
|
240
|
+
status: 'saved', output: session.outputPath,
|
|
241
|
+
steps: session.steps.length,
|
|
242
|
+
}));
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Discard ───────────────────────────────────────────────────────────
|
|
247
|
+
case 'discard': {
|
|
248
|
+
const session = loadSession();
|
|
249
|
+
if (!session) {
|
|
250
|
+
console.log(chalk.yellow('No active recording session to discard'));
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const stepCount = session.steps.length;
|
|
255
|
+
clearSession();
|
|
256
|
+
console.log(chalk.yellow(`Discarded recording (${stepCount} steps)`));
|
|
257
|
+
console.log(JSON.stringify({ status: 'discarded', stepsLost: stepCount }));
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
default:
|
|
262
|
+
throw new Error(`Unknown action "${action}". Valid: start, add-step, add-assert, add-comment, status, stop, discard`);
|
|
263
|
+
}
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.error(chalk.red(`Error: ${(err as Error).message}`));
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
})();
|