haltija 1.1.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/LICENSE +190 -0
- package/README.md +220 -0
- package/bin/build-bookmarklet.ts +107 -0
- package/bin/cli-subcommand.mjs +537 -0
- package/bin/format-events.mjs +125 -0
- package/bin/format-test.mjs +183 -0
- package/bin/format-tree.mjs +165 -0
- package/bin/hj.mjs +59 -0
- package/bin/mcp-setup.mjs +288 -0
- package/bin/server.ts +9 -0
- package/bin/tosijs-dev.mjs +591 -0
- package/bin/tosijs-dev.ts +74 -0
- package/dist/client.js +387 -0
- package/dist/component.js +6685 -0
- package/dist/index.js +10201 -0
- package/dist/server.js +9847 -0
- package/docs/CI-INTEGRATION.md +230 -0
- package/docs/EXECUTIVE-SUMMARY.md +213 -0
- package/docs/README.md +67 -0
- package/docs/REST-API.md +123 -0
- package/docs/ROADMAP.md +591 -0
- package/docs/UX-CRIMES.md +599 -0
- package/docs/agent-prompt.md +139 -0
- package/docs/getting-started/app.md +96 -0
- package/docs/getting-started/playground.md +75 -0
- package/docs/getting-started/service.md +96 -0
- package/docs/recipes.md +245 -0
- package/haltija-icon.svg +79 -0
- package/package.json +68 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Haltija CLI subcommand handler
|
|
4
|
+
*
|
|
5
|
+
* Translates CLI subcommands to REST API calls:
|
|
6
|
+
* haltija tree → GET /tree
|
|
7
|
+
* haltija click 42 → POST /click {"ref":"42"}
|
|
8
|
+
* haltija click "#btn" → POST /click {"selector":"#btn"}
|
|
9
|
+
* haltija type 10 "hello" → POST /type {"ref":"10","text":"hello"}
|
|
10
|
+
* haltija eval "1+1" → POST /eval {"code":"1+1"}
|
|
11
|
+
* haltija navigate "url" → POST /navigate {"url":"..."}
|
|
12
|
+
* haltija key Enter → POST /key {"key":"Enter"}
|
|
13
|
+
* haltija status → GET /status
|
|
14
|
+
* haltija docs → GET /docs
|
|
15
|
+
*
|
|
16
|
+
* Also available as: hj tree, hj click 42, etc.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawn } from 'child_process'
|
|
20
|
+
import { existsSync } from 'fs'
|
|
21
|
+
import { dirname, join } from 'path'
|
|
22
|
+
import { fileURLToPath } from 'url'
|
|
23
|
+
import { formatTree } from './format-tree.mjs'
|
|
24
|
+
import { formatEvents } from './format-events.mjs'
|
|
25
|
+
import { formatTestResult, formatSuiteResult } from './format-test.mjs'
|
|
26
|
+
|
|
27
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
28
|
+
|
|
29
|
+
// Endpoints that use GET (everything else is POST)
|
|
30
|
+
export const GET_ENDPOINTS = new Set([
|
|
31
|
+
'location', 'events', 'console', 'windows', 'recordings',
|
|
32
|
+
'status', 'version', 'docs', 'api', 'stats'
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
// Compound paths (subcommand contains slash)
|
|
36
|
+
export const COMPOUND_PATHS = {
|
|
37
|
+
'mutations-watch': '/mutations/watch',
|
|
38
|
+
'mutations-unwatch': '/mutations/unwatch',
|
|
39
|
+
'mutations-status': '/mutations/status',
|
|
40
|
+
'events-watch': '/events/watch',
|
|
41
|
+
'events-unwatch': '/events/unwatch',
|
|
42
|
+
'events-stats': '/events/stats',
|
|
43
|
+
'select-start': '/select/start',
|
|
44
|
+
'select-cancel': '/select/cancel',
|
|
45
|
+
'select-status': '/select/status',
|
|
46
|
+
'select-result': '/select/result',
|
|
47
|
+
'select-clear': '/select/clear',
|
|
48
|
+
'tabs-open': '/tabs/open',
|
|
49
|
+
'tabs-close': '/tabs/close',
|
|
50
|
+
'tabs-focus': '/tabs/focus',
|
|
51
|
+
'recording-start': '/recording/start',
|
|
52
|
+
'recording-stop': '/recording/stop',
|
|
53
|
+
'recording-generate': '/recording/generate',
|
|
54
|
+
'test-run': '/test/run',
|
|
55
|
+
'test-suite': '/test/suite',
|
|
56
|
+
'test-validate': '/test/validate',
|
|
57
|
+
'send-message': '/send/message',
|
|
58
|
+
'send-selection': '/send/selection',
|
|
59
|
+
'send-recording': '/send/recording',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// GET compound endpoints
|
|
63
|
+
export const GET_COMPOUND = new Set([
|
|
64
|
+
'mutations-status', 'events-stats', 'select-status', 'select-result'
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
// How to map positional args to body fields for each endpoint
|
|
68
|
+
export const ARG_MAPS = {
|
|
69
|
+
click: (args) => parseTargetArgs(args),
|
|
70
|
+
type: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), text: args.slice(1).join(' ') }),
|
|
71
|
+
key: (args) => ({ key: args[0], ...parseModifiers(args.slice(1)) }),
|
|
72
|
+
drag: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), deltaX: num(args[1]), deltaY: num(args[2]) }),
|
|
73
|
+
scroll: (args) => parseScrollArgs(args),
|
|
74
|
+
navigate: (args) => ({ url: args[0] }),
|
|
75
|
+
eval: (args) => ({ code: args.join(' ') }),
|
|
76
|
+
query: (args) => ({ selector: args[0] }),
|
|
77
|
+
inspect: (args) => parseTargetArgs(args),
|
|
78
|
+
'inspectAll': (args) => ({ selector: args[0] }),
|
|
79
|
+
tree: (args) => parseTreeArgs(args),
|
|
80
|
+
highlight: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), label: args[1] }),
|
|
81
|
+
unhighlight: () => ({}),
|
|
82
|
+
find: (args) => ({ text: args.join(' ') }),
|
|
83
|
+
wait: (args) => parseWaitArgs(args),
|
|
84
|
+
call: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), method: args[1], args: args.slice(2).map(tryParseJSON) }),
|
|
85
|
+
fetch: (args) => ({ url: args[0], prompt: args.slice(1).join(' ') || undefined }),
|
|
86
|
+
screenshot: (args) => parseTargetArgs(args),
|
|
87
|
+
snapshot: (args) => ({ context: args.join(' ') || undefined }),
|
|
88
|
+
select: (args) => ({ action: args[0] }),
|
|
89
|
+
'select-start': () => ({}),
|
|
90
|
+
'select-cancel': () => ({}),
|
|
91
|
+
'select-clear': () => ({}),
|
|
92
|
+
refresh: (args) => (args.includes('--hard') ? { hard: true } : {}),
|
|
93
|
+
'tabs-open': (args) => ({ url: args[0] }),
|
|
94
|
+
'tabs-close': (args) => ({ window: args[0] }),
|
|
95
|
+
'tabs-focus': (args) => ({ window: args[0] }),
|
|
96
|
+
'events-watch': (args) => ({ preset: args[0] || 'interactive' }),
|
|
97
|
+
'mutations-watch': (args) => ({ preset: args[0] || 'smart' }),
|
|
98
|
+
form: (args) => parseTargetArgs(args),
|
|
99
|
+
// send <agent> <message> or send selection/recording
|
|
100
|
+
// --no-submit flag prevents auto-submit (paste only)
|
|
101
|
+
'send-message': (args) => {
|
|
102
|
+
const noSubmit = args.includes('--no-submit')
|
|
103
|
+
const filtered = args.filter(a => a !== '--no-submit')
|
|
104
|
+
return { agent: filtered[0], message: filtered.slice(1).join(' '), submit: !noSubmit }
|
|
105
|
+
},
|
|
106
|
+
'send-selection': (args) => {
|
|
107
|
+
const noSubmit = args.includes('--no-submit')
|
|
108
|
+
const filtered = args.filter(a => a !== '--no-submit')
|
|
109
|
+
return { agent: filtered[0], submit: !noSubmit }
|
|
110
|
+
},
|
|
111
|
+
'send-recording': (args) => {
|
|
112
|
+
const noSubmit = args.includes('--no-submit')
|
|
113
|
+
const filtered = args.filter(a => a !== '--no-submit')
|
|
114
|
+
return { agent: filtered[0], description: filtered.slice(1).join(' ') || undefined, submit: !noSubmit }
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Parse a target argument — @ref number or selector */
|
|
119
|
+
export function parseTargetArgs(args) {
|
|
120
|
+
if (!args.length || !args[0]) return {}
|
|
121
|
+
const target = args[0]
|
|
122
|
+
// @42 or plain 42 → ref
|
|
123
|
+
if (/^@?\d+$/.test(target)) return { ref: target.replace('@', '') }
|
|
124
|
+
// Everything else is a selector
|
|
125
|
+
return { selector: target }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Parse tree-specific args */
|
|
129
|
+
export function parseTreeArgs(args) {
|
|
130
|
+
const body = {}
|
|
131
|
+
for (let i = 0; i < args.length; i++) {
|
|
132
|
+
const a = args[i]
|
|
133
|
+
if (a === '--depth' || a === '-d') { body.depth = num(args[++i]); continue }
|
|
134
|
+
if (a === '--selector' || a === '-s') { body.selector = args[++i]; continue }
|
|
135
|
+
if (a === '--compact' || a === '-c') { body.compact = true; continue }
|
|
136
|
+
if (a === '--visible') { body.visibleOnly = true; continue }
|
|
137
|
+
if (a === '--text') { body.includeText = true; continue }
|
|
138
|
+
if (a === '--no-text') { body.includeText = false; continue }
|
|
139
|
+
if (a === '--shadow') { body.pierceShadow = true; continue }
|
|
140
|
+
// First positional arg is selector if present
|
|
141
|
+
if (!a.startsWith('-')) { body.selector = a; continue }
|
|
142
|
+
}
|
|
143
|
+
return Object.keys(body).length ? body : undefined
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Parse scroll args */
|
|
147
|
+
export function parseScrollArgs(args) {
|
|
148
|
+
if (!args.length) return {}
|
|
149
|
+
const first = args[0]
|
|
150
|
+
if (first.startsWith('.') || first.startsWith('#') || first.startsWith('[')) {
|
|
151
|
+
return { selector: first }
|
|
152
|
+
}
|
|
153
|
+
// deltaX deltaY
|
|
154
|
+
if (args.length >= 2 && !isNaN(args[0]) && !isNaN(args[1])) {
|
|
155
|
+
return { deltaX: num(args[0]), deltaY: num(args[1]) }
|
|
156
|
+
}
|
|
157
|
+
// Just deltaY
|
|
158
|
+
if (!isNaN(first)) return { deltaY: num(first) }
|
|
159
|
+
return parseTargetArgs(args)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Parse wait args: selector or ms */
|
|
163
|
+
export function parseWaitArgs(args) {
|
|
164
|
+
if (!args.length) return { ms: 1000 }
|
|
165
|
+
const first = args[0]
|
|
166
|
+
if (!isNaN(first)) return { ms: num(first) }
|
|
167
|
+
return { ...parseTargetArgs([first]), timeout: args[1] ? num(args[1]) : undefined }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Parse key modifiers */
|
|
171
|
+
export function parseModifiers(args) {
|
|
172
|
+
const mods = {}
|
|
173
|
+
for (const a of args) {
|
|
174
|
+
if (a === '--ctrl' || a === '-c') mods.ctrl = true
|
|
175
|
+
if (a === '--shift' || a === '-s') mods.shift = true
|
|
176
|
+
if (a === '--alt' || a === '-a') mods.alt = true
|
|
177
|
+
if (a === '--meta' || a === '-m') mods.meta = true
|
|
178
|
+
}
|
|
179
|
+
return Object.keys(mods).length ? mods : {}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function num(s) { return s != null ? Number(s) : undefined }
|
|
183
|
+
|
|
184
|
+
function tryParseJSON(s) {
|
|
185
|
+
try { return JSON.parse(s) } catch { return s }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Remove undefined values from an object */
|
|
189
|
+
export function clean(obj) {
|
|
190
|
+
if (!obj) return undefined
|
|
191
|
+
const result = {}
|
|
192
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
193
|
+
if (v !== undefined) result[k] = v
|
|
194
|
+
}
|
|
195
|
+
return Object.keys(result).length ? result : undefined
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ============================================
|
|
199
|
+
// Server auto-start
|
|
200
|
+
// ============================================
|
|
201
|
+
|
|
202
|
+
async function isServerRunning(port) {
|
|
203
|
+
try {
|
|
204
|
+
const resp = await fetch(`http://localhost:${port}/status`, {
|
|
205
|
+
signal: AbortSignal.timeout(1000)
|
|
206
|
+
})
|
|
207
|
+
return resp.ok
|
|
208
|
+
} catch {
|
|
209
|
+
return false
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function startServerInBackground(port) {
|
|
214
|
+
const serverPath = join(__dirname, '../dist/server.js')
|
|
215
|
+
if (!existsSync(serverPath)) {
|
|
216
|
+
console.error('Error: Server not built. Run `bun run build` first.')
|
|
217
|
+
process.exit(1)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Try bun first, fall back to node
|
|
221
|
+
let command = 'bun'
|
|
222
|
+
let cmdArgs = ['run', serverPath]
|
|
223
|
+
try {
|
|
224
|
+
const { execSync } = await import('child_process')
|
|
225
|
+
execSync('bun --version', { stdio: 'ignore' })
|
|
226
|
+
} catch {
|
|
227
|
+
command = 'node'
|
|
228
|
+
cmdArgs = [serverPath]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const child = spawn(command, cmdArgs, {
|
|
232
|
+
env: { ...process.env, DEV_CHANNEL_PORT: String(port) },
|
|
233
|
+
stdio: 'ignore',
|
|
234
|
+
detached: true
|
|
235
|
+
})
|
|
236
|
+
child.unref()
|
|
237
|
+
|
|
238
|
+
// Wait for server to be ready
|
|
239
|
+
const maxWait = 5000
|
|
240
|
+
const start = Date.now()
|
|
241
|
+
while (Date.now() - start < maxWait) {
|
|
242
|
+
if (await isServerRunning(port)) return true
|
|
243
|
+
await new Promise(r => setTimeout(r, 200))
|
|
244
|
+
}
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ============================================
|
|
249
|
+
// Main subcommand execution
|
|
250
|
+
// ============================================
|
|
251
|
+
|
|
252
|
+
export async function runSubcommand(subcommand, subArgs, port = '8700') {
|
|
253
|
+
const baseUrl = `http://localhost:${port}`
|
|
254
|
+
const jsonOutput = subArgs.includes('--json')
|
|
255
|
+
// Remove --json from subArgs before processing
|
|
256
|
+
const filteredArgs = subArgs.filter(a => a !== '--json')
|
|
257
|
+
|
|
258
|
+
// Check if server is running, auto-start if not
|
|
259
|
+
if (!(await isServerRunning(port))) {
|
|
260
|
+
process.stderr.write('\x1b[2mStarting Haltija server...\x1b[0m')
|
|
261
|
+
const started = await startServerInBackground(port)
|
|
262
|
+
if (started) {
|
|
263
|
+
process.stderr.write('\x1b[2m done\x1b[0m\n')
|
|
264
|
+
} else {
|
|
265
|
+
process.stderr.write('\n')
|
|
266
|
+
console.error('Error: Could not start server. Run `haltija --server` in another terminal.')
|
|
267
|
+
process.exit(1)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Special handling for 'send' command - route to appropriate endpoint
|
|
272
|
+
// hj send selection [agent] → /send/selection
|
|
273
|
+
// hj send recording [agent] → /send/recording
|
|
274
|
+
// hj send <agent> <message...> → /send/message
|
|
275
|
+
if (subcommand === 'send') {
|
|
276
|
+
const firstArg = filteredArgs[0]?.toLocaleLowerCase()
|
|
277
|
+
if (firstArg === 'selection') {
|
|
278
|
+
subcommand = 'send-selection'
|
|
279
|
+
filteredArgs.shift() // Remove 'selection'
|
|
280
|
+
} else if (firstArg === 'recording') {
|
|
281
|
+
subcommand = 'send-recording'
|
|
282
|
+
filteredArgs.shift() // Remove 'recording'
|
|
283
|
+
} else {
|
|
284
|
+
subcommand = 'send-message'
|
|
285
|
+
// Args stay as: <agent> <message...>
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Resolve compound path
|
|
290
|
+
const path = COMPOUND_PATHS[subcommand] || `/${subcommand}`
|
|
291
|
+
const isGet = GET_ENDPOINTS.has(subcommand) || GET_COMPOUND.has(subcommand)
|
|
292
|
+
|
|
293
|
+
// Build request body for POST
|
|
294
|
+
let body = undefined
|
|
295
|
+
if (!isGet) {
|
|
296
|
+
const mapper = ARG_MAPS[subcommand]
|
|
297
|
+
if (mapper) {
|
|
298
|
+
body = clean(mapper(filteredArgs))
|
|
299
|
+
} else if (filteredArgs.length) {
|
|
300
|
+
// Generic: try to parse as JSON or pass as first positional
|
|
301
|
+
const joined = filteredArgs.join(' ')
|
|
302
|
+
try {
|
|
303
|
+
body = JSON.parse(joined)
|
|
304
|
+
} catch {
|
|
305
|
+
body = parseTargetArgs(filteredArgs)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Handle window targeting via --window flag
|
|
311
|
+
const windowIdx = filteredArgs.indexOf('--window')
|
|
312
|
+
if (windowIdx !== -1 && filteredArgs[windowIdx + 1]) {
|
|
313
|
+
const windowId = filteredArgs[windowIdx + 1]
|
|
314
|
+
if (isGet) {
|
|
315
|
+
// Append as query param for GET
|
|
316
|
+
const url = new URL(path, baseUrl)
|
|
317
|
+
url.searchParams.set('window', windowId)
|
|
318
|
+
return doRequest(url.toString(), 'GET', undefined, { subcommand, jsonOutput })
|
|
319
|
+
} else {
|
|
320
|
+
if (!body) body = {}
|
|
321
|
+
body.window = windowId
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const url = `${baseUrl}${path}`
|
|
326
|
+
return doRequest(url, isGet ? 'GET' : 'POST', body, { subcommand, jsonOutput })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function doRequest(url, method, body, context = {}) {
|
|
330
|
+
const { subcommand, jsonOutput } = context
|
|
331
|
+
try {
|
|
332
|
+
const opts = { method }
|
|
333
|
+
if (body) {
|
|
334
|
+
opts.headers = { 'Content-Type': 'application/json' }
|
|
335
|
+
opts.body = JSON.stringify(body)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const resp = await fetch(url, opts)
|
|
339
|
+
const contentType = resp.headers.get('content-type') || ''
|
|
340
|
+
|
|
341
|
+
if (contentType.includes('application/json')) {
|
|
342
|
+
const json = await resp.json()
|
|
343
|
+
|
|
344
|
+
// Text format for supported subcommands (unless --json)
|
|
345
|
+
if (!jsonOutput && subcommand === 'tree' && json.success && json.data) {
|
|
346
|
+
console.log(formatTree(json.data))
|
|
347
|
+
} else if (!jsonOutput && subcommand === 'events' && (json.events || Array.isArray(json))) {
|
|
348
|
+
console.log(formatEvents(json))
|
|
349
|
+
} else if (!jsonOutput && subcommand === 'test-run' && json.test) {
|
|
350
|
+
console.log(formatTestResult(json))
|
|
351
|
+
} else if (!jsonOutput && subcommand === 'test-suite' && json.results) {
|
|
352
|
+
console.log(formatSuiteResult(json))
|
|
353
|
+
} else {
|
|
354
|
+
console.log(JSON.stringify(json, null, 2))
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
const text = await resp.text()
|
|
358
|
+
console.log(text)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!resp.ok) {
|
|
362
|
+
process.exit(1)
|
|
363
|
+
}
|
|
364
|
+
} catch (err) {
|
|
365
|
+
if (err.cause?.code === 'ECONNREFUSED') {
|
|
366
|
+
console.error('Error: Cannot connect to Haltija server.')
|
|
367
|
+
console.error('Start the server with: haltija --server')
|
|
368
|
+
} else {
|
|
369
|
+
console.error(`Error: ${err.message}`)
|
|
370
|
+
}
|
|
371
|
+
process.exit(1)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Known valid subcommands */
|
|
376
|
+
export const KNOWN_COMMANDS = new Set([
|
|
377
|
+
'tree', 'query', 'inspect', 'inspectAll', 'find',
|
|
378
|
+
'click', 'type', 'key', 'drag', 'scroll', 'call',
|
|
379
|
+
'navigate', 'refresh', 'location',
|
|
380
|
+
'events', 'events-watch', 'events-unwatch', 'console',
|
|
381
|
+
'mutations-watch', 'mutations-unwatch', 'mutations-status',
|
|
382
|
+
'eval', 'fetch',
|
|
383
|
+
'screenshot', 'snapshot', 'highlight', 'unhighlight',
|
|
384
|
+
'select-start', 'select-result', 'select-cancel', 'select-clear',
|
|
385
|
+
'windows', 'tabs-open', 'tabs-close', 'tabs-focus',
|
|
386
|
+
'recording-start', 'recording-stop', 'recording-generate', 'recordings',
|
|
387
|
+
'test-run', 'test-validate',
|
|
388
|
+
'send', 'send-message', 'send-selection', 'send-recording',
|
|
389
|
+
'status', 'version', 'docs', 'api', 'stats'
|
|
390
|
+
])
|
|
391
|
+
|
|
392
|
+
/** Common typos/aliases mapped to correct commands */
|
|
393
|
+
const COMMAND_ALIASES = {
|
|
394
|
+
'open': 'navigate',
|
|
395
|
+
'goto': 'navigate',
|
|
396
|
+
'go': 'navigate',
|
|
397
|
+
'url': 'navigate',
|
|
398
|
+
'load': 'navigate',
|
|
399
|
+
'get': 'tree',
|
|
400
|
+
'dom': 'tree',
|
|
401
|
+
'page': 'tree',
|
|
402
|
+
'input': 'type',
|
|
403
|
+
'write': 'type',
|
|
404
|
+
'enter': 'key',
|
|
405
|
+
'press': 'key',
|
|
406
|
+
'run': 'eval',
|
|
407
|
+
'js': 'eval',
|
|
408
|
+
'exec': 'eval',
|
|
409
|
+
'shot': 'screenshot',
|
|
410
|
+
'capture': 'screenshot',
|
|
411
|
+
'ls': 'tree',
|
|
412
|
+
'list': 'tree',
|
|
413
|
+
'show': 'tree',
|
|
414
|
+
'help': '--help',
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Check if a string is a valid subcommand */
|
|
418
|
+
export function isSubcommand(arg) {
|
|
419
|
+
if (!arg || arg.startsWith('-')) return false
|
|
420
|
+
if (/^\d+$/.test(arg)) return false // Legacy port number
|
|
421
|
+
return KNOWN_COMMANDS.has(arg)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Get suggestion for unknown command */
|
|
425
|
+
export function getSuggestion(cmd) {
|
|
426
|
+
// Check aliases first
|
|
427
|
+
if (COMMAND_ALIASES[cmd]) {
|
|
428
|
+
return COMMAND_ALIASES[cmd]
|
|
429
|
+
}
|
|
430
|
+
// Simple prefix match
|
|
431
|
+
for (const known of KNOWN_COMMANDS) {
|
|
432
|
+
if (known.startsWith(cmd) || cmd.startsWith(known.slice(0, 3))) {
|
|
433
|
+
return known
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return null
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** List available subcommands for --help */
|
|
440
|
+
export function listSubcommands() {
|
|
441
|
+
return `
|
|
442
|
+
Subcommands (replace curl with simple commands):
|
|
443
|
+
${bold('Inspect')}
|
|
444
|
+
tree [selector] [-d depth] DOM tree with ref IDs
|
|
445
|
+
query <selector> Find elements matching selector
|
|
446
|
+
inspect <@ref|selector> Detailed element info
|
|
447
|
+
inspectAll <selector> Deep inspect all matches
|
|
448
|
+
find <text> Find elements by text content
|
|
449
|
+
|
|
450
|
+
${bold('Interact')}
|
|
451
|
+
click <@ref|selector|"text"> Click an element
|
|
452
|
+
type <@ref|selector> <text> Type text into element
|
|
453
|
+
key <key> [--ctrl --shift] Press a key
|
|
454
|
+
drag <@ref|selector> <dx> <dy> Drag element
|
|
455
|
+
scroll [selector|dy] Scroll page or element
|
|
456
|
+
call <@ref|selector> <method> Call element method/get property
|
|
457
|
+
|
|
458
|
+
${bold('Navigate')}
|
|
459
|
+
navigate <url> Go to URL
|
|
460
|
+
refresh [--hard] Reload page
|
|
461
|
+
location Current URL and title
|
|
462
|
+
|
|
463
|
+
${bold('Observe')}
|
|
464
|
+
events Get semantic events
|
|
465
|
+
events-watch [preset] Start watching events
|
|
466
|
+
events-unwatch Stop watching events
|
|
467
|
+
console Get console output
|
|
468
|
+
mutations-watch [preset] Start watching DOM changes
|
|
469
|
+
mutations-unwatch Stop watching
|
|
470
|
+
mutations-status Check mutation watcher
|
|
471
|
+
|
|
472
|
+
${bold('Evaluate')}
|
|
473
|
+
eval <code> Run JavaScript in browser
|
|
474
|
+
fetch <url> [prompt] Fetch and process URL
|
|
475
|
+
|
|
476
|
+
${bold('Capture')}
|
|
477
|
+
screenshot [@ref|selector] Take screenshot
|
|
478
|
+
snapshot [context] Full page state capture
|
|
479
|
+
highlight <@ref|selector> Highlight element
|
|
480
|
+
unhighlight Remove highlights
|
|
481
|
+
|
|
482
|
+
${bold('Selection')}
|
|
483
|
+
select-start Begin region selection
|
|
484
|
+
select-result Get selection result
|
|
485
|
+
select-cancel Cancel selection
|
|
486
|
+
select-clear Clear selection
|
|
487
|
+
|
|
488
|
+
${bold('Windows')}
|
|
489
|
+
windows List browser windows
|
|
490
|
+
tabs-open <url> Open new tab
|
|
491
|
+
tabs-close <windowId> Close tab
|
|
492
|
+
tabs-focus <windowId> Focus tab
|
|
493
|
+
|
|
494
|
+
${bold('Recording')}
|
|
495
|
+
recording-start Start recording
|
|
496
|
+
recording-stop Stop recording
|
|
497
|
+
recording-generate Generate test from recording
|
|
498
|
+
recordings List recordings
|
|
499
|
+
|
|
500
|
+
${bold('Send to Agent')}
|
|
501
|
+
send <agent> <message> Send message to agent (auto-submits)
|
|
502
|
+
send selection [agent] Send browser selection to agent
|
|
503
|
+
send recording [agent] Send last recording to agent
|
|
504
|
+
--no-submit Paste only, don't auto-submit
|
|
505
|
+
|
|
506
|
+
${bold('Testing')}
|
|
507
|
+
test-run <json> Run a test
|
|
508
|
+
test-validate <json> Validate test format
|
|
509
|
+
|
|
510
|
+
${bold('Info')}
|
|
511
|
+
status Server status
|
|
512
|
+
version Server version
|
|
513
|
+
docs API documentation
|
|
514
|
+
api Full API reference
|
|
515
|
+
stats Usage statistics
|
|
516
|
+
|
|
517
|
+
${bold('Options')}
|
|
518
|
+
--window <id> Target specific window
|
|
519
|
+
--port <n> Server port (default: 8700)
|
|
520
|
+
|
|
521
|
+
${bold('Examples')}
|
|
522
|
+
hj tree # See the page
|
|
523
|
+
hj tree -d 5 # Deeper tree
|
|
524
|
+
hj click 42 # Click by ref
|
|
525
|
+
hj click "#submit" # Click by selector
|
|
526
|
+
hj type 10 Hello world # Type text
|
|
527
|
+
hj key Enter # Press Enter
|
|
528
|
+
hj key a --ctrl # Ctrl+A
|
|
529
|
+
hj eval document.title # Get page title
|
|
530
|
+
hj navigate https://example.com
|
|
531
|
+
hj events # See what happened
|
|
532
|
+
hj send claude "check this" # Message an agent
|
|
533
|
+
hj send selection # Send selection to agent
|
|
534
|
+
`
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function bold(s) { return `\x1b[1m${s}\x1b[0m` }
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text formatter for Haltija semantic events
|
|
3
|
+
*
|
|
4
|
+
* One line per event, chronological. Agent-scannable, human-readable.
|
|
5
|
+
*
|
|
6
|
+
* 1706000000000 input:typed input#email "user@example.com"
|
|
7
|
+
* 1706000001200 interaction:click button#submit "Sign In"
|
|
8
|
+
* 1706000002500 navigation:load /dashboard
|
|
9
|
+
* 1706000003100 console:error "TypeError: Cannot read property 'map'"
|
|
10
|
+
* ---
|
|
11
|
+
* hj events --json --since=1706000000000
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Format an events response as one-liner text
|
|
16
|
+
* @param {object} response - Response from GET /events (has .events array)
|
|
17
|
+
* @returns {string} formatted text output with footer
|
|
18
|
+
*/
|
|
19
|
+
export function formatEvents(response) {
|
|
20
|
+
const events = response?.events || response
|
|
21
|
+
if (!events || !Array.isArray(events) || events.length === 0) {
|
|
22
|
+
return '(no events)\n---\nhj events --json'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const lines = events.map(ev => {
|
|
26
|
+
const parts = []
|
|
27
|
+
parts.push(String(ev.timestamp))
|
|
28
|
+
parts.push(ev.type)
|
|
29
|
+
const target = formatTarget(ev.target)
|
|
30
|
+
if (target) parts.push(target)
|
|
31
|
+
const summary = extractPayloadSummary(ev)
|
|
32
|
+
if (summary) parts.push(summary)
|
|
33
|
+
return parts.join(' ')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Footer with JSON escape hatch using first event's timestamp
|
|
37
|
+
const sinceTs = events[0].timestamp
|
|
38
|
+
lines.push('---')
|
|
39
|
+
lines.push(`hj events --json --since=${sinceTs}`)
|
|
40
|
+
|
|
41
|
+
return lines.join('\n')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compact target: tag#id or tag.class or just tag
|
|
46
|
+
*/
|
|
47
|
+
function formatTarget(target) {
|
|
48
|
+
if (!target) return ''
|
|
49
|
+
let result = target.tag || ''
|
|
50
|
+
if (target.id) {
|
|
51
|
+
result += `#${target.id}`
|
|
52
|
+
} else if (target.selector) {
|
|
53
|
+
// Use selector if no simpler representation
|
|
54
|
+
return target.selector
|
|
55
|
+
}
|
|
56
|
+
return result || ''
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract the most meaningful value from event payload
|
|
61
|
+
*/
|
|
62
|
+
function extractPayloadSummary(ev) {
|
|
63
|
+
const { type, payload, target } = ev
|
|
64
|
+
if (!payload && !target) return ''
|
|
65
|
+
|
|
66
|
+
// Type-specific extraction
|
|
67
|
+
if (type === 'input:typed') {
|
|
68
|
+
return quote(payload?.text || payload?.finalValue || '')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (type === 'interaction:click') {
|
|
72
|
+
return quote(payload?.text || target?.text || '')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (type === 'interaction:submit') {
|
|
76
|
+
return payload?.formAction || payload?.formId || ''
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (type?.startsWith('navigation:')) {
|
|
80
|
+
return payload?.to || payload?.url || ''
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (type?.startsWith('console:')) {
|
|
84
|
+
return quote(truncate(payload?.message || '', 120))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (type === 'scroll:stop') {
|
|
88
|
+
return `${payload?.direction || ''} ${payload?.distance || 0}px`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (type === 'hover:dwell') {
|
|
92
|
+
return `${payload?.duration || 0}ms`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (type === 'mutation:change') {
|
|
96
|
+
const what = payload?.changeType || ''
|
|
97
|
+
const el = payload?.element || ''
|
|
98
|
+
return `${what} ${el}`.trim()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (type === 'focus:focus' || type === 'focus:blur') {
|
|
102
|
+
return target?.text || target?.selector || ''
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Default: first short string value in payload
|
|
106
|
+
if (payload) {
|
|
107
|
+
for (const val of Object.values(payload)) {
|
|
108
|
+
if (typeof val === 'string' && val.length > 0 && val.length < 200) {
|
|
109
|
+
return quote(truncate(val, 80))
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return ''
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function quote(s) {
|
|
118
|
+
if (!s) return ''
|
|
119
|
+
return `"${s}"`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function truncate(str, max) {
|
|
123
|
+
if (!str || str.length <= max) return str
|
|
124
|
+
return str.slice(0, max - 1) + '…'
|
|
125
|
+
}
|