@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
|
@@ -247,6 +247,16 @@ export const workflowToolCalls = sqliteTable('workflow_tool_calls', {
|
|
|
247
247
|
createdAt: text('created_at').notNull(),
|
|
248
248
|
});
|
|
249
249
|
|
|
250
|
+
// --- user_mcp_configs table ---
|
|
251
|
+
export const userMcpConfigs = sqliteTable('user_mcp_configs', {
|
|
252
|
+
id: text('id').primaryKey(),
|
|
253
|
+
userId: text('user_id').notNull(),
|
|
254
|
+
configType: text('config_type').notNull(), // 'localhost' or 'remote'
|
|
255
|
+
mcpServersJson: text('mcp_servers_json').notNull().default('{}'),
|
|
256
|
+
createdAt: text('created_at').notNull(),
|
|
257
|
+
updatedAt: text('updated_at').notNull(),
|
|
258
|
+
});
|
|
259
|
+
|
|
250
260
|
// --- chat_permission_rules table ---
|
|
251
261
|
export const chatPermissionRules = sqliteTable('chat_permission_rules', {
|
|
252
262
|
id: text('id').primaryKey(),
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* app_use_flow — Shared types and utilities for the app-use flow YAML format.
|
|
3
|
+
*
|
|
4
|
+
* A flow file is a YAML document with an optional header (config) separated
|
|
5
|
+
* from steps by `---`. Steps are a list of actions that map to agent-browser
|
|
6
|
+
* commands (for web) or platform-specific commands (for iOS/Android).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { dirname } from 'node:path';
|
|
12
|
+
|
|
13
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export type Platform = 'web' | 'ios' | 'android';
|
|
16
|
+
|
|
17
|
+
export interface FlowConfig {
|
|
18
|
+
/** App URL (web) or bundle ID (mobile) */
|
|
19
|
+
appId?: string;
|
|
20
|
+
/** Target platform */
|
|
21
|
+
platform?: Platform;
|
|
22
|
+
/** Display name for the flow */
|
|
23
|
+
name?: string;
|
|
24
|
+
/** Environment variables for interpolation */
|
|
25
|
+
env?: Record<string, string>;
|
|
26
|
+
/** Whether to manage agent-browser session automatically */
|
|
27
|
+
autoSession?: boolean;
|
|
28
|
+
/** Tags for filtering */
|
|
29
|
+
tags?: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type StepType =
|
|
33
|
+
| 'open' | 'click' | 'dblclick' | 'fill' | 'type' | 'press'
|
|
34
|
+
| 'hover' | 'select' | 'check' | 'uncheck'
|
|
35
|
+
| 'scroll' | 'scrollIntoView'
|
|
36
|
+
| 'wait' | 'snapshot' | 'screenshot'
|
|
37
|
+
| 'assertVisible' | 'assertNotVisible' | 'assertUrl' | 'assertText' | 'assertWithAI'
|
|
38
|
+
| 'back' | 'forward' | 'reload'
|
|
39
|
+
| 'eval' | 'setViewport' | 'setDevice'
|
|
40
|
+
| 'upload' | 'drag'
|
|
41
|
+
| 'tab' | 'frame'
|
|
42
|
+
| 'sessionStart' | 'sessionEnd';
|
|
43
|
+
|
|
44
|
+
export interface FlowStep {
|
|
45
|
+
/** Action type */
|
|
46
|
+
type: StepType;
|
|
47
|
+
/** Main argument (shorthand for the primary parameter) */
|
|
48
|
+
value?: string;
|
|
49
|
+
/** Element selector (ref @e1, CSS #id, text=..., etc.) */
|
|
50
|
+
selector?: string;
|
|
51
|
+
/** Additional parameters (type-specific) */
|
|
52
|
+
params?: Record<string, unknown>;
|
|
53
|
+
/** Human-readable label for this step */
|
|
54
|
+
label?: string;
|
|
55
|
+
/** If true, step failure doesn't abort the flow */
|
|
56
|
+
optional?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface FlowFile {
|
|
60
|
+
config: FlowConfig;
|
|
61
|
+
steps: FlowStep[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface StepResult {
|
|
65
|
+
step: FlowStep;
|
|
66
|
+
index: number;
|
|
67
|
+
status: 'passed' | 'failed' | 'skipped';
|
|
68
|
+
durationMs: number;
|
|
69
|
+
output?: string;
|
|
70
|
+
error?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface FlowResult {
|
|
74
|
+
flowName: string;
|
|
75
|
+
platform: Platform;
|
|
76
|
+
status: 'passed' | 'failed';
|
|
77
|
+
totalSteps: number;
|
|
78
|
+
passed: number;
|
|
79
|
+
failed: number;
|
|
80
|
+
skipped: number;
|
|
81
|
+
durationMs: number;
|
|
82
|
+
steps: StepResult[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Parsing ───────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse a raw YAML step into a normalized FlowStep.
|
|
89
|
+
* Supports both shorthand (`- click: "@e1"`) and full object form.
|
|
90
|
+
*/
|
|
91
|
+
function parseStep(raw: unknown): FlowStep | null {
|
|
92
|
+
if (typeof raw === 'string') {
|
|
93
|
+
// Simple command with no args: `- back`, `- reload`
|
|
94
|
+
return { type: raw as StepType };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (typeof raw !== 'object' || raw === null) return null;
|
|
98
|
+
const obj = raw as Record<string, unknown>;
|
|
99
|
+
|
|
100
|
+
// Find the action key (first key that is a valid step type)
|
|
101
|
+
const keys = Object.keys(obj);
|
|
102
|
+
const typeKey = keys[0];
|
|
103
|
+
if (!typeKey) return null;
|
|
104
|
+
|
|
105
|
+
const step: FlowStep = { type: typeKey as StepType };
|
|
106
|
+
|
|
107
|
+
const val = obj[typeKey];
|
|
108
|
+
|
|
109
|
+
if (typeof val === 'string' || typeof val === 'number') {
|
|
110
|
+
// Shorthand: `- click: "@e1"` or `- wait: 2000`
|
|
111
|
+
step.value = String(val);
|
|
112
|
+
} else if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
|
|
113
|
+
// Full object form
|
|
114
|
+
const params = val as Record<string, unknown>;
|
|
115
|
+
if (params.selector !== undefined) step.selector = String(params.selector);
|
|
116
|
+
if (params.value !== undefined) step.value = String(params.value);
|
|
117
|
+
if (params.label !== undefined) step.label = String(params.label);
|
|
118
|
+
if (params.optional !== undefined) step.optional = Boolean(params.optional);
|
|
119
|
+
|
|
120
|
+
// Remaining keys go into params
|
|
121
|
+
const rest: Record<string, unknown> = {};
|
|
122
|
+
for (const [k, v] of Object.entries(params)) {
|
|
123
|
+
if (!['selector', 'value', 'label', 'optional'].includes(k)) {
|
|
124
|
+
rest[k] = v;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (Object.keys(rest).length > 0) step.params = rest;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Also pick up top-level label/optional
|
|
131
|
+
if (obj.label !== undefined) step.label = String(obj.label);
|
|
132
|
+
if (obj.optional !== undefined) step.optional = Boolean(obj.optional);
|
|
133
|
+
|
|
134
|
+
return step;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Parse a flow YAML string into a FlowFile.
|
|
139
|
+
* Handles the `---` separator between config header and steps.
|
|
140
|
+
*/
|
|
141
|
+
export function parseFlowYaml(content: string): FlowFile {
|
|
142
|
+
// Split on document separator
|
|
143
|
+
const parts = content.split(/^---\s*$/m);
|
|
144
|
+
|
|
145
|
+
let config: FlowConfig = {};
|
|
146
|
+
let stepsRaw: unknown[] = [];
|
|
147
|
+
|
|
148
|
+
if (parts.length >= 2) {
|
|
149
|
+
// Header + steps
|
|
150
|
+
const headerPart = parts[0].trim();
|
|
151
|
+
const stepsPart = parts.slice(1).join('---').trim();
|
|
152
|
+
|
|
153
|
+
if (headerPart) {
|
|
154
|
+
config = (parseYaml(headerPart) as FlowConfig) || {};
|
|
155
|
+
}
|
|
156
|
+
if (stepsPart) {
|
|
157
|
+
stepsRaw = (parseYaml(stepsPart) as unknown[]) || [];
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
// No separator — treat as steps only, or check if it's an array
|
|
161
|
+
const parsed = parseYaml(content);
|
|
162
|
+
if (Array.isArray(parsed)) {
|
|
163
|
+
stepsRaw = parsed;
|
|
164
|
+
} else if (typeof parsed === 'object' && parsed !== null) {
|
|
165
|
+
// Could be a config-only document
|
|
166
|
+
config = parsed as FlowConfig;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const steps = (stepsRaw || [])
|
|
171
|
+
.map(parseStep)
|
|
172
|
+
.filter((s): s is FlowStep => s !== null);
|
|
173
|
+
|
|
174
|
+
// Defaults
|
|
175
|
+
if (!config.platform) config.platform = 'web';
|
|
176
|
+
if (config.autoSession === undefined) config.autoSession = true;
|
|
177
|
+
|
|
178
|
+
return { config, steps };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Read and parse a flow YAML file.
|
|
183
|
+
*/
|
|
184
|
+
export function readFlowFile(path: string): FlowFile {
|
|
185
|
+
if (!existsSync(path)) {
|
|
186
|
+
throw new Error(`Flow file not found: ${path}`);
|
|
187
|
+
}
|
|
188
|
+
const content = readFileSync(path, 'utf-8');
|
|
189
|
+
return parseFlowYaml(content);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Serialization ─────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Serialize a FlowStep back to a YAML-friendly plain object.
|
|
196
|
+
*/
|
|
197
|
+
function stepToYamlObj(step: FlowStep): Record<string, unknown> {
|
|
198
|
+
const hasExtra = step.selector || step.params || step.label || step.optional;
|
|
199
|
+
|
|
200
|
+
if (!hasExtra && step.value) {
|
|
201
|
+
// Shorthand form
|
|
202
|
+
return { [step.type]: step.value };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!hasExtra && !step.value) {
|
|
206
|
+
// Bare command
|
|
207
|
+
return { [step.type]: null };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Full object form
|
|
211
|
+
const inner: Record<string, unknown> = {};
|
|
212
|
+
if (step.selector) inner.selector = step.selector;
|
|
213
|
+
if (step.value) inner.value = step.value;
|
|
214
|
+
if (step.label) inner.label = step.label;
|
|
215
|
+
if (step.optional) inner.optional = step.optional;
|
|
216
|
+
if (step.params) Object.assign(inner, step.params);
|
|
217
|
+
|
|
218
|
+
return { [step.type]: inner };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Serialize a FlowFile to YAML string.
|
|
223
|
+
*/
|
|
224
|
+
export function serializeFlowYaml(flow: FlowFile, comments?: string[]): string {
|
|
225
|
+
const parts: string[] = [];
|
|
226
|
+
|
|
227
|
+
// Config header
|
|
228
|
+
const configCopy = { ...flow.config };
|
|
229
|
+
if (configCopy.autoSession === true) delete configCopy.autoSession; // default
|
|
230
|
+
const hasConfig = Object.keys(configCopy).length > 0;
|
|
231
|
+
|
|
232
|
+
if (hasConfig) {
|
|
233
|
+
parts.push(stringifyYaml(configCopy).trim());
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
parts.push('---');
|
|
237
|
+
|
|
238
|
+
// Comments
|
|
239
|
+
if (comments && comments.length > 0) {
|
|
240
|
+
for (const c of comments) {
|
|
241
|
+
parts.push(`# ${c}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Steps
|
|
246
|
+
const stepsObj = flow.steps.map(stepToYamlObj);
|
|
247
|
+
parts.push(stringifyYaml(stepsObj).trim());
|
|
248
|
+
|
|
249
|
+
return parts.join('\n') + '\n';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Write a flow file to disk.
|
|
254
|
+
*/
|
|
255
|
+
export function writeFlowFile(path: string, flow: FlowFile, comments?: string[]): void {
|
|
256
|
+
const dir = dirname(path);
|
|
257
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
258
|
+
writeFileSync(path, serializeFlowYaml(flow, comments), 'utf-8');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── Interpolation ─────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Interpolate `${VAR}` references in a string using the env map.
|
|
265
|
+
*/
|
|
266
|
+
export function interpolate(text: string, env: Record<string, string>): string {
|
|
267
|
+
return text.replace(/\$\{(\w+)\}/g, (_, key) => env[key] ?? `\${${key}}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Step → agent-browser command mapping ──────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Translate a FlowStep to an agent-browser CLI command (for web platform).
|
|
274
|
+
* Returns the command as a string array (args to pass to agent-browser).
|
|
275
|
+
*/
|
|
276
|
+
export function stepToAgentBrowserArgs(step: FlowStep, env: Record<string, string> = {}): string[] {
|
|
277
|
+
const val = step.value ? interpolate(step.value, env) : '';
|
|
278
|
+
const sel = step.selector ? interpolate(step.selector, env) : '';
|
|
279
|
+
|
|
280
|
+
switch (step.type) {
|
|
281
|
+
case 'open':
|
|
282
|
+
return ['open', val];
|
|
283
|
+
|
|
284
|
+
case 'click':
|
|
285
|
+
return ['click', sel || val];
|
|
286
|
+
|
|
287
|
+
case 'dblclick':
|
|
288
|
+
return ['dblclick', sel || val];
|
|
289
|
+
|
|
290
|
+
case 'fill':
|
|
291
|
+
return ['fill', sel, val];
|
|
292
|
+
|
|
293
|
+
case 'type':
|
|
294
|
+
return ['type', sel, val];
|
|
295
|
+
|
|
296
|
+
case 'press':
|
|
297
|
+
return ['press', val];
|
|
298
|
+
|
|
299
|
+
case 'hover':
|
|
300
|
+
return ['hover', sel || val];
|
|
301
|
+
|
|
302
|
+
case 'select':
|
|
303
|
+
return ['select', sel, val];
|
|
304
|
+
|
|
305
|
+
case 'check':
|
|
306
|
+
return ['check', sel || val];
|
|
307
|
+
|
|
308
|
+
case 'uncheck':
|
|
309
|
+
return ['uncheck', sel || val];
|
|
310
|
+
|
|
311
|
+
case 'scroll': {
|
|
312
|
+
const dir = val || 'down';
|
|
313
|
+
const args = ['scroll', dir];
|
|
314
|
+
if (step.params?.pixels) args.push(String(step.params.pixels));
|
|
315
|
+
if (step.params?.selector) args.push('--selector', String(step.params.selector));
|
|
316
|
+
return args;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
case 'scrollIntoView':
|
|
320
|
+
return ['scrollintoview', sel || val];
|
|
321
|
+
|
|
322
|
+
case 'wait': {
|
|
323
|
+
if (step.params?.text) return ['wait', '--text', String(step.params.text)];
|
|
324
|
+
if (step.params?.url) return ['wait', '--url', String(step.params.url)];
|
|
325
|
+
if (step.params?.load) return ['wait', '--load', String(step.params.load)];
|
|
326
|
+
if (step.params?.fn) return ['wait', '--fn', String(step.params.fn)];
|
|
327
|
+
// Numeric wait (ms) or selector wait
|
|
328
|
+
if (val && /^\d+$/.test(val)) return ['wait', val];
|
|
329
|
+
if (val) return ['wait', val];
|
|
330
|
+
return ['wait', '1000'];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
case 'snapshot': {
|
|
334
|
+
const args = ['snapshot'];
|
|
335
|
+
if (step.params?.interactive !== false) args.push('-i');
|
|
336
|
+
if (step.params?.compact) args.push('-c');
|
|
337
|
+
if (step.params?.depth) args.push('-d', String(step.params.depth));
|
|
338
|
+
if (step.params?.selector) args.push('-s', String(step.params.selector));
|
|
339
|
+
return args;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
case 'screenshot': {
|
|
343
|
+
const args = ['screenshot'];
|
|
344
|
+
if (val) args.push(val);
|
|
345
|
+
if (step.params?.full) args.push('--full');
|
|
346
|
+
if (step.params?.annotate) args.push('--annotate');
|
|
347
|
+
return args;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
case 'assertVisible':
|
|
351
|
+
// Implemented via snapshot + check (handled at runner level)
|
|
352
|
+
return ['snapshot', '-i', '--json'];
|
|
353
|
+
|
|
354
|
+
case 'assertNotVisible':
|
|
355
|
+
return ['snapshot', '-i', '--json'];
|
|
356
|
+
|
|
357
|
+
case 'assertUrl':
|
|
358
|
+
return ['get', 'url'];
|
|
359
|
+
|
|
360
|
+
case 'assertText':
|
|
361
|
+
return ['get', 'text', sel];
|
|
362
|
+
|
|
363
|
+
case 'assertWithAI':
|
|
364
|
+
// Screenshot for AI analysis (handled at runner level)
|
|
365
|
+
return ['screenshot'];
|
|
366
|
+
|
|
367
|
+
case 'back':
|
|
368
|
+
return ['back'];
|
|
369
|
+
|
|
370
|
+
case 'forward':
|
|
371
|
+
return ['forward'];
|
|
372
|
+
|
|
373
|
+
case 'reload':
|
|
374
|
+
return ['reload'];
|
|
375
|
+
|
|
376
|
+
case 'eval':
|
|
377
|
+
return ['eval', val];
|
|
378
|
+
|
|
379
|
+
case 'setViewport': {
|
|
380
|
+
const w = step.params?.width || 1280;
|
|
381
|
+
const h = step.params?.height || 720;
|
|
382
|
+
return ['set', 'viewport', String(w), String(h)];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
case 'setDevice':
|
|
386
|
+
return ['set', 'device', val];
|
|
387
|
+
|
|
388
|
+
case 'upload':
|
|
389
|
+
return ['upload', sel, val];
|
|
390
|
+
|
|
391
|
+
case 'drag': {
|
|
392
|
+
const target = step.params?.target ? String(step.params.target) : '';
|
|
393
|
+
return ['drag', sel || val, target];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
case 'tab': {
|
|
397
|
+
if (step.params?.action === 'new') return ['tab', 'new', val].filter(Boolean);
|
|
398
|
+
if (step.params?.action === 'close') return ['tab', 'close', val].filter(Boolean);
|
|
399
|
+
return ['tab', val].filter(Boolean);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
case 'frame':
|
|
403
|
+
return val === 'main' ? ['frame', 'main'] : ['frame', sel || val];
|
|
404
|
+
|
|
405
|
+
case 'sessionStart':
|
|
406
|
+
return ['open', val || 'about:blank'];
|
|
407
|
+
|
|
408
|
+
case 'sessionEnd':
|
|
409
|
+
return ['close'];
|
|
410
|
+
|
|
411
|
+
default:
|
|
412
|
+
throw new Error(`Unknown step type: ${step.type}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── Validation ────────────────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
const VALID_STEP_TYPES: Set<string> = new Set([
|
|
419
|
+
'open', 'click', 'dblclick', 'fill', 'type', 'press',
|
|
420
|
+
'hover', 'select', 'check', 'uncheck',
|
|
421
|
+
'scroll', 'scrollIntoView',
|
|
422
|
+
'wait', 'snapshot', 'screenshot',
|
|
423
|
+
'assertVisible', 'assertNotVisible', 'assertUrl', 'assertText', 'assertWithAI',
|
|
424
|
+
'back', 'forward', 'reload',
|
|
425
|
+
'eval', 'setViewport', 'setDevice',
|
|
426
|
+
'upload', 'drag',
|
|
427
|
+
'tab', 'frame',
|
|
428
|
+
'sessionStart', 'sessionEnd',
|
|
429
|
+
]);
|
|
430
|
+
|
|
431
|
+
const REQUIRES_SELECTOR: Set<string> = new Set([
|
|
432
|
+
'fill', 'type', 'assertText',
|
|
433
|
+
]);
|
|
434
|
+
|
|
435
|
+
const REQUIRES_VALUE: Set<string> = new Set([
|
|
436
|
+
'open', 'press', 'eval',
|
|
437
|
+
]);
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Validate a parsed flow and return a list of issues.
|
|
441
|
+
*/
|
|
442
|
+
export function validateFlow(flow: FlowFile): string[] {
|
|
443
|
+
const issues: string[] = [];
|
|
444
|
+
|
|
445
|
+
if (flow.config.platform && !['web', 'ios', 'android'].includes(flow.config.platform)) {
|
|
446
|
+
issues.push(`Invalid platform: "${flow.config.platform}". Must be web, ios, or android`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (flow.steps.length === 0) {
|
|
450
|
+
issues.push('Flow has no steps');
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
for (let i = 0; i < flow.steps.length; i++) {
|
|
454
|
+
const step = flow.steps[i];
|
|
455
|
+
const prefix = `Step ${i + 1}`;
|
|
456
|
+
|
|
457
|
+
if (!VALID_STEP_TYPES.has(step.type)) {
|
|
458
|
+
issues.push(`${prefix}: unknown type "${step.type}"`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (REQUIRES_VALUE.has(step.type) && !step.value) {
|
|
463
|
+
issues.push(`${prefix} (${step.type}): missing required value`);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (REQUIRES_SELECTOR.has(step.type) && !step.selector) {
|
|
467
|
+
issues.push(`${prefix} (${step.type}): missing required selector`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// fill and type need both selector and value
|
|
471
|
+
if ((step.type === 'fill' || step.type === 'type') && !step.value) {
|
|
472
|
+
issues.push(`${prefix} (${step.type}): missing required value`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// click/hover need either selector or value
|
|
476
|
+
if (['click', 'dblclick', 'hover', 'check', 'uncheck', 'scrollIntoView'].includes(step.type)) {
|
|
477
|
+
if (!step.selector && !step.value) {
|
|
478
|
+
issues.push(`${prefix} (${step.type}): missing selector or value`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return issues;
|
|
484
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helper to load an OpenAPI spec from a local path or URL.
|
|
3
|
+
*
|
|
4
|
+
* If the source is a URL, the spec is downloaded into a local cache
|
|
5
|
+
* directory and reused for the rest of the current day.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const CACHE_DIR = join(__dirname, '..', '..', '..', '..', '.claude', 'skills', 'assistkick-openapi-explorer', 'cache');
|
|
14
|
+
|
|
15
|
+
function isUrl(source: string): boolean {
|
|
16
|
+
return source.startsWith('http://') || source.startsWith('https://');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function urlToCachePath(url: string): string {
|
|
20
|
+
const parsed = new URL(url);
|
|
21
|
+
const safeName = (parsed.host + parsed.pathname).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
22
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
23
|
+
return join(CACHE_DIR, `${safeName}_${today}.json`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function download(url: string, dest: string): Promise<void> {
|
|
27
|
+
const res = await fetch(url, {
|
|
28
|
+
headers: { 'User-Agent': 'openapi-explorer/1.0' },
|
|
29
|
+
signal: AbortSignal.timeout(30_000),
|
|
30
|
+
});
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
33
|
+
}
|
|
34
|
+
const body = await res.text();
|
|
35
|
+
const dir = dirname(dest);
|
|
36
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
37
|
+
writeFileSync(dest, body, 'utf-8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Load an OpenAPI spec from a local file path or URL.
|
|
42
|
+
* URLs are cached locally per day — a fresh download happens only once per day.
|
|
43
|
+
*/
|
|
44
|
+
export async function loadSpec(source: string): Promise<Record<string, any>> {
|
|
45
|
+
let path: string;
|
|
46
|
+
|
|
47
|
+
if (isUrl(source)) {
|
|
48
|
+
const cached = urlToCachePath(source);
|
|
49
|
+
if (!existsSync(cached)) {
|
|
50
|
+
await download(source, cached);
|
|
51
|
+
}
|
|
52
|
+
path = cached;
|
|
53
|
+
} else {
|
|
54
|
+
path = source;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!existsSync(path)) {
|
|
58
|
+
throw new Error(`Spec file not found: ${path}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const raw = readFileSync(path, 'utf-8');
|
|
62
|
+
return JSON.parse(raw);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve a JSON Pointer $ref (e.g. "#/components/schemas/User") to its value in the spec.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveRef(spec: Record<string, any>, ref: string): any {
|
|
69
|
+
const parts = ref.replace(/^#\//, '').split('/');
|
|
70
|
+
let obj: any = spec;
|
|
71
|
+
for (const part of parts) {
|
|
72
|
+
obj = obj[part];
|
|
73
|
+
if (obj === undefined) {
|
|
74
|
+
throw new Error(`Cannot resolve $ref: ${ref}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return obj;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Recursively inline $ref references up to a depth limit.
|
|
82
|
+
* Adds a __schema__ key to indicate which schema each inlined object represents.
|
|
83
|
+
*/
|
|
84
|
+
export function inlineRefs(spec: Record<string, any>, obj: any, depth = 0): any {
|
|
85
|
+
if (depth > 5) return obj;
|
|
86
|
+
|
|
87
|
+
if (Array.isArray(obj)) {
|
|
88
|
+
return obj.map(item => inlineRefs(spec, item, depth + 1));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (obj !== null && typeof obj === 'object') {
|
|
92
|
+
if ('$ref' in obj) {
|
|
93
|
+
const refName = (obj['$ref'] as string).split('/').pop()!;
|
|
94
|
+
const resolved = resolveRef(spec, obj['$ref']);
|
|
95
|
+
return { __schema__: refName, ...inlineRefs(spec, resolved, depth + 1) };
|
|
96
|
+
}
|
|
97
|
+
const result: Record<string, any> = {};
|
|
98
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
99
|
+
result[k] = inlineRefs(spec, v, depth + 1);
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return obj;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Recursively collect all $ref strings from an object.
|
|
109
|
+
*/
|
|
110
|
+
export function collectRefs(obj: any, refs: Set<string> = new Set()): Set<string> {
|
|
111
|
+
if (Array.isArray(obj)) {
|
|
112
|
+
for (const item of obj) collectRefs(item, refs);
|
|
113
|
+
} else if (obj !== null && typeof obj === 'object') {
|
|
114
|
+
if ('$ref' in obj) refs.add(obj['$ref'] as string);
|
|
115
|
+
for (const v of Object.values(obj)) collectRefs(v, refs);
|
|
116
|
+
}
|
|
117
|
+
return refs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a named schema and all its nested $ref dependencies.
|
|
122
|
+
* Returns a map of schema name → schema definition.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveSchemaTree(
|
|
125
|
+
spec: Record<string, any>,
|
|
126
|
+
schemaName: string,
|
|
127
|
+
visited: Record<string, any> = {},
|
|
128
|
+
): Record<string, any> {
|
|
129
|
+
if (schemaName in visited) return visited;
|
|
130
|
+
|
|
131
|
+
const schemas = spec?.components?.schemas ?? {};
|
|
132
|
+
if (!(schemaName in schemas)) return visited;
|
|
133
|
+
|
|
134
|
+
const schema = schemas[schemaName];
|
|
135
|
+
visited[schemaName] = schema;
|
|
136
|
+
|
|
137
|
+
const refs = collectRefs(schema);
|
|
138
|
+
for (const ref of refs) {
|
|
139
|
+
const childName = ref.split('/').pop()!;
|
|
140
|
+
if (!(childName in visited)) {
|
|
141
|
+
resolveSchemaTree(spec, childName, visited);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return visited;
|
|
146
|
+
}
|