@bpmnkit/cli 0.0.13 → 0.0.15

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.
@@ -0,0 +1,413 @@
1
+ import { spawn } from "node:child_process";
2
+ // ─── Spinner ──────────────────────────────────────────────────────────────────
3
+ class Spinner {
4
+ frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5
+ i = 0;
6
+ timer = null;
7
+ msg = "";
8
+ isTTY = process.stderr.isTTY === true;
9
+ start(msg) {
10
+ this.msg = msg;
11
+ if (!this.isTTY) {
12
+ process.stderr.write(`${msg}\n`);
13
+ return;
14
+ }
15
+ process.stderr.write(`${this.frames[0]} ${msg}`);
16
+ this.timer = setInterval(() => {
17
+ this.i++;
18
+ process.stderr.write(`\r${this.frames[this.i % this.frames.length]} ${this.msg}`);
19
+ }, 80);
20
+ }
21
+ update(msg) {
22
+ this.msg = msg;
23
+ if (!this.isTTY) {
24
+ process.stderr.write(`${msg}\n`);
25
+ return;
26
+ }
27
+ process.stderr.write(`\r${this.frames[this.i % this.frames.length]} ${msg}`);
28
+ }
29
+ stop() {
30
+ if (this.timer) {
31
+ clearInterval(this.timer);
32
+ this.timer = null;
33
+ }
34
+ if (this.isTTY) {
35
+ process.stderr.write("\r\x1b[K"); // CR + erase to end of line
36
+ }
37
+ }
38
+ }
39
+ // ─── Compact AI adapter ───────────────────────────────────────────────────────
40
+ /** Try claude → copilot → gemini. Return the first available binary name. */
41
+ async function detectAi() {
42
+ for (const bin of ["claude", "copilot", "gemini"]) {
43
+ const ok = await Promise.race([
44
+ new Promise((res) => {
45
+ const p = spawn(bin, ["--version"], { stdio: "ignore" });
46
+ p.on("error", () => res(false));
47
+ p.on("close", (code) => res(code === 0));
48
+ }),
49
+ new Promise((res) => setTimeout(() => res(false), 3000)),
50
+ ]);
51
+ if (ok)
52
+ return bin;
53
+ }
54
+ return null;
55
+ }
56
+ /** Run an AI binary with the given prompt and collect the full text output. */
57
+ async function runAi(bin, prompt) {
58
+ return new Promise((resolve, reject) => {
59
+ let args;
60
+ if (bin === "claude") {
61
+ args = [
62
+ "-p",
63
+ prompt,
64
+ "--output-format",
65
+ "stream-json",
66
+ "--verbose",
67
+ "--dangerously-skip-permissions",
68
+ "--permission-mode",
69
+ "bypassPermissions",
70
+ ];
71
+ }
72
+ else if (bin === "copilot") {
73
+ args = ["-p", prompt, "--yolo"];
74
+ }
75
+ else {
76
+ args = ["--prompt", prompt, "--yolo"];
77
+ }
78
+ const env = { ...process.env, CLAUDECODE: undefined };
79
+ const proc = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], env });
80
+ // Drain stderr to prevent the pipe buffer from filling and stalling the child.
81
+ proc.stderr?.resume();
82
+ let out = "";
83
+ let buf = "";
84
+ if (bin === "claude") {
85
+ proc.stdout?.on("data", (chunk) => {
86
+ buf += chunk.toString();
87
+ const lines = buf.split("\n");
88
+ buf = lines.pop() ?? "";
89
+ for (const line of lines) {
90
+ if (!line.trim())
91
+ continue;
92
+ try {
93
+ const ev = JSON.parse(line);
94
+ if (ev.type === "assistant" && ev.message?.content) {
95
+ for (const block of ev.message.content) {
96
+ if (block.type === "text" && block.text)
97
+ out += block.text;
98
+ }
99
+ }
100
+ }
101
+ catch {
102
+ /* skip non-JSON */
103
+ }
104
+ }
105
+ });
106
+ }
107
+ else {
108
+ proc.stdout?.on("data", (chunk) => {
109
+ out += chunk.toString();
110
+ });
111
+ }
112
+ proc.on("error", reject);
113
+ proc.on("close", (code) => {
114
+ if (code === 0)
115
+ resolve(out);
116
+ else
117
+ reject(new Error(`${bin} exited with code ${code}`));
118
+ });
119
+ });
120
+ }
121
+ // ─── System prompt ────────────────────────────────────────────────────────────
122
+ const SYSTEM_PROMPT = [
123
+ "Convert the user query into a Camunda 8 search spec. Output ONLY a valid JSON object — no prose, no markdown.",
124
+ "",
125
+ 'Schema: { "resource": <resource>, "filter": { ... } }',
126
+ "",
127
+ "Resources and filter fields:",
128
+ ' "processInstances": state("ACTIVE"|"COMPLETED"|"TERMINATED"), hasIncident(bool), processDefinitionId(str), processInstanceKey(str)',
129
+ ' "incidents": state("ACTIVE"|"RESOLVED"), type(str), processInstanceKey(str), processDefinitionKey(str)',
130
+ ' "userTasks": state("CREATED"|"COMPLETED"|"CANCELED"), processInstanceKey(str), assignee(str)',
131
+ ' "variables": name(str), value(str — JSON-serialized: 3355→"3355", true→"true", hello→"\\"hello\\""), processInstanceKey(str)',
132
+ ' "jobs": state("ACTIVATABLE"|"ACTIVE"|"FAILED"|"COMPLETED"|"CANCELED"|"ERROR_THROWN"), type(str), processInstanceKey(str)',
133
+ ' "decisionInstances": decisionDefinitionId(str), state("EVALUATED"|"FAILED"), processInstanceKey(str)',
134
+ "",
135
+ 'Use "variables" whenever a variable name or value is mentioned.',
136
+ "Omit filter fields that are not relevant. Output ONLY the JSON object.",
137
+ ].join("\n");
138
+ async function fetchSpec(spec, getClient) {
139
+ const client = await getClient();
140
+ const filter = spec.filter;
141
+ const body = { filter };
142
+ switch (spec.resource) {
143
+ case "processInstances": {
144
+ const result = await client.processInstance.searchProcessInstances(body);
145
+ const items = result.items ?? [];
146
+ const total = result.page?.totalItems ?? items.length;
147
+ return {
148
+ resource: spec.resource,
149
+ filter,
150
+ items,
151
+ total,
152
+ columns: [
153
+ { key: "processInstanceKey", header: "PROCESS INSTANCE KEY", maxWidth: 22 },
154
+ { key: "processDefinitionId", header: "PROCESS", maxWidth: 30 },
155
+ { key: "state", header: "STATE", maxWidth: 12 },
156
+ { key: "startDate", header: "STARTED", maxWidth: 20, transform: relTime },
157
+ { key: "hasIncident", header: "INCIDENT", maxWidth: 8 },
158
+ ],
159
+ relations: [
160
+ {
161
+ groupName: "process-instance",
162
+ commandName: "get",
163
+ description: "View process instance",
164
+ params: [{ field: "processInstanceKey", param: "processInstanceKey" }],
165
+ },
166
+ ],
167
+ };
168
+ }
169
+ case "incidents": {
170
+ const result = await client.incident.searchIncidents(body);
171
+ const items = result.items ?? [];
172
+ const total = result.page?.totalItems ?? items.length;
173
+ return {
174
+ resource: spec.resource,
175
+ filter,
176
+ items,
177
+ total,
178
+ columns: [
179
+ { key: "incidentKey", header: "INCIDENT KEY", maxWidth: 22 },
180
+ { key: "processInstanceKey", header: "PROCESS INSTANCE KEY", maxWidth: 22 },
181
+ { key: "type", header: "TYPE", maxWidth: 28 },
182
+ { key: "state", header: "STATE", maxWidth: 10 },
183
+ { key: "message", header: "MESSAGE", maxWidth: 50 },
184
+ ],
185
+ relations: [
186
+ {
187
+ groupName: "process-instance",
188
+ commandName: "get",
189
+ description: "View process instance",
190
+ params: [{ field: "processInstanceKey", param: "processInstanceKey" }],
191
+ },
192
+ ],
193
+ };
194
+ }
195
+ case "userTasks": {
196
+ const result = await client.userTask.searchUserTasks(body);
197
+ const items = result.items ?? [];
198
+ const total = result.page?.totalItems ?? items.length;
199
+ return {
200
+ resource: spec.resource,
201
+ filter,
202
+ items,
203
+ total,
204
+ columns: [
205
+ { key: "userTaskKey", header: "USER TASK KEY", maxWidth: 22 },
206
+ { key: "processInstanceKey", header: "PROCESS INSTANCE KEY", maxWidth: 22 },
207
+ { key: "elementId", header: "ELEMENT", maxWidth: 24 },
208
+ { key: "assignee", header: "ASSIGNEE", maxWidth: 20 },
209
+ { key: "state", header: "STATE", maxWidth: 12 },
210
+ ],
211
+ relations: [
212
+ {
213
+ groupName: "user-task",
214
+ commandName: "get",
215
+ description: "View user task",
216
+ params: [{ field: "userTaskKey", param: "userTaskKey" }],
217
+ },
218
+ {
219
+ groupName: "process-instance",
220
+ commandName: "get",
221
+ description: "View process instance",
222
+ params: [{ field: "processInstanceKey", param: "processInstanceKey" }],
223
+ },
224
+ ],
225
+ };
226
+ }
227
+ case "variables": {
228
+ const result = await client.variable.searchVariables(body);
229
+ const items = result.items ?? [];
230
+ const total = result.page?.totalItems ?? items.length;
231
+ return {
232
+ resource: spec.resource,
233
+ filter,
234
+ items,
235
+ total,
236
+ columns: [
237
+ { key: "variableKey", header: "VARIABLE KEY", maxWidth: 22 },
238
+ { key: "processInstanceKey", header: "PROCESS INSTANCE KEY", maxWidth: 22 },
239
+ { key: "name", header: "NAME", maxWidth: 24 },
240
+ { key: "value", header: "VALUE", maxWidth: 50 },
241
+ ],
242
+ relations: [
243
+ {
244
+ groupName: "variable",
245
+ commandName: "get",
246
+ description: "View variable",
247
+ params: [{ field: "variableKey", param: "variableKey" }],
248
+ },
249
+ {
250
+ groupName: "process-instance",
251
+ commandName: "get",
252
+ description: "View process instance",
253
+ params: [{ field: "processInstanceKey", param: "processInstanceKey" }],
254
+ },
255
+ ],
256
+ };
257
+ }
258
+ case "jobs": {
259
+ const result = await client.job.searchJobs(body);
260
+ const items = result.items ?? [];
261
+ const total = result.page?.totalItems ?? items.length;
262
+ return {
263
+ resource: spec.resource,
264
+ filter,
265
+ items,
266
+ total,
267
+ columns: [
268
+ { key: "jobKey", header: "JOB KEY", maxWidth: 22 },
269
+ { key: "processInstanceKey", header: "PROCESS INSTANCE KEY", maxWidth: 22 },
270
+ { key: "type", header: "TYPE", maxWidth: 28 },
271
+ { key: "state", header: "STATE", maxWidth: 12 },
272
+ { key: "retries", header: "RETRIES", maxWidth: 8 },
273
+ ],
274
+ relations: [
275
+ {
276
+ groupName: "process-instance",
277
+ commandName: "get",
278
+ description: "View process instance",
279
+ params: [{ field: "processInstanceKey", param: "processInstanceKey" }],
280
+ },
281
+ ],
282
+ };
283
+ }
284
+ case "decisionInstances": {
285
+ const result = await client.decisionInstance.searchDecisionInstances(body);
286
+ const items = result.items ?? [];
287
+ const total = result.page?.totalItems ?? items.length;
288
+ return {
289
+ resource: spec.resource,
290
+ filter,
291
+ items,
292
+ total,
293
+ columns: [
294
+ { key: "decisionInstanceKey", header: "DECISION INSTANCE KEY", maxWidth: 22 },
295
+ { key: "processInstanceKey", header: "PROCESS INSTANCE KEY", maxWidth: 22 },
296
+ { key: "decisionDefinitionId", header: "DECISION", maxWidth: 30 },
297
+ { key: "state", header: "STATE", maxWidth: 12 },
298
+ ],
299
+ relations: [
300
+ {
301
+ groupName: "process-instance",
302
+ commandName: "get",
303
+ description: "View process instance",
304
+ params: [{ field: "processInstanceKey", param: "processInstanceKey" }],
305
+ },
306
+ ],
307
+ };
308
+ }
309
+ default:
310
+ throw new Error(`Unknown resource: "${spec.resource}"`);
311
+ }
312
+ }
313
+ function relTime(value) {
314
+ if (typeof value !== "string" || !value)
315
+ return "—";
316
+ const d = new Date(value);
317
+ const diff = Date.now() - d.getTime();
318
+ const m = Math.floor(diff / 60_000);
319
+ if (m < 1)
320
+ return "just now";
321
+ if (m < 60)
322
+ return `${m}m ago`;
323
+ const h = Math.floor(m / 60);
324
+ if (h < 24)
325
+ return `${h}h ${m % 60}m ago`;
326
+ return d.toLocaleString(undefined, {
327
+ month: "short",
328
+ day: "numeric",
329
+ hour: "2-digit",
330
+ minute: "2-digit",
331
+ });
332
+ }
333
+ // ─── Quick-parse bypass (no AI needed) ───────────────────────────────────────
334
+ function quickParse(query) {
335
+ const q = query.trim().toLowerCase();
336
+ // Pure numeric → process instance key lookup
337
+ if (/^\d+$/.test(query.trim())) {
338
+ return { resource: "processInstances", filter: { processInstanceKey: query.trim() } };
339
+ }
340
+ // Single state keyword
341
+ const stateMap = {
342
+ active: { resource: "processInstances", filter: { state: "ACTIVE" } },
343
+ completed: { resource: "processInstances", filter: { state: "COMPLETED" } },
344
+ terminated: { resource: "processInstances", filter: { state: "TERMINATED" } },
345
+ incidents: { resource: "incidents", filter: {} },
346
+ };
347
+ if (q in stateMap)
348
+ return stateMap[q] ?? null;
349
+ return null;
350
+ }
351
+ // ─── Exported query runner ────────────────────────────────────────────────────
352
+ export async function runAskQuery(query, getClient, onStatus) {
353
+ // Try quick-parse first (no AI token cost)
354
+ const quick = quickParse(query);
355
+ if (quick) {
356
+ return fetchSpec(quick, getClient);
357
+ }
358
+ // Detect AI binary
359
+ onStatus("Detecting AI…");
360
+ const bin = await detectAi();
361
+ if (!bin) {
362
+ throw new Error("No AI CLI found. Install claude, copilot, or gemini and ensure it is in PATH.");
363
+ }
364
+ // Ask AI to translate query → search spec
365
+ onStatus(`Asking ${bin}…`);
366
+ const fullPrompt = `${SYSTEM_PROMPT}\n\nQuery: ${query}\n\nJSON:`;
367
+ const raw = await runAi(bin, fullPrompt);
368
+ // Extract JSON from the response (strip any prose/markdown fences)
369
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
370
+ if (!jsonMatch) {
371
+ throw new Error(`AI returned no JSON.\n\nRaw output:\n${raw}`);
372
+ }
373
+ let spec;
374
+ try {
375
+ spec = JSON.parse(jsonMatch[0]);
376
+ }
377
+ catch {
378
+ throw new Error(`AI returned invalid JSON.\n\nRaw output:\n${raw}`);
379
+ }
380
+ // Execute the spec against the API
381
+ onStatus(`Searching ${spec.resource}…`);
382
+ return fetchSpec(spec, getClient);
383
+ }
384
+ // ─── Command group ────────────────────────────────────────────────────────────
385
+ export const askGroup = {
386
+ name: "ask",
387
+ description: "Natural language search using a local AI (claude/copilot/gemini)",
388
+ commands: [
389
+ {
390
+ name: "query",
391
+ description: "Ask in plain language; the AI translates it to a Camunda API search",
392
+ args: [{ name: "query", description: "Natural language query", required: true }],
393
+ async run(ctx) {
394
+ const query = ctx.positional.join(" ").trim();
395
+ if (!query)
396
+ throw new Error("Provide a query, e.g.: casen ask active incidents");
397
+ const spinner = new Spinner();
398
+ try {
399
+ spinner.start("Detecting AI…");
400
+ const result = await runAskQuery(query, ctx.getClient, (msg) => spinner.update(msg));
401
+ spinner.stop();
402
+ ctx.output.info(`${result.resource} ${JSON.stringify(result.filter)}`);
403
+ ctx.output.printList({ items: result.items, page: { totalItems: result.total } }, result.columns);
404
+ }
405
+ catch (err) {
406
+ spinner.stop();
407
+ throw err;
408
+ }
409
+ },
410
+ },
411
+ ],
412
+ };
413
+ //# sourceMappingURL=ask.js.map
@@ -1,5 +1,6 @@
1
1
  import { adminCommandGroups } from "../generated/admin-commands.js";
2
2
  import { decisionDefinitionGroup, decisionRequirementsGroup, generatedCommandGroups, jobGroup, processDefinitionGroup, userTaskGroup, } from "../generated/commands.js";
3
+ import { askGroup } from "./ask.js";
3
4
  import { getDmnReqsXmlCmd, getDmnXmlCmd, getStartFormCmd, getUserTaskFormCmd, getXmlCmd, renderBpmnCmd, } from "./bpmn.js";
4
5
  import { completionGroup } from "./completion.js";
5
6
  import { connectorGroup } from "./connector.js";
@@ -32,16 +33,50 @@ const customisedGroups = generatedCommandGroups.map((g) => {
32
33
  }
33
34
  return g;
34
35
  });
35
- const allGroups = [
36
- profileGroup,
37
- settingsGroup,
36
+ const sortedOtherGroups = [
38
37
  connectorGroup,
39
38
  ...customisedGroups,
40
39
  ...adminCommandGroups,
41
40
  completionGroup,
41
+ ].sort((a, b) => a.name.localeCompare(b.name));
42
+ export const commandGroups = [
43
+ askGroup,
44
+ settingsGroup,
45
+ profileGroup,
46
+ ...sortedOtherGroups,
42
47
  ];
43
- // Sort alphabetically by name for the main menu
44
- export const commandGroups = allGroups.sort((a, b) => a.name.localeCompare(b.name));
45
48
  // Compute follow-up relations between commands based on shared field/arg names
46
49
  computeRelations(commandGroups);
50
+ // Manually inject relations on GET commands (they return a single object, not a
51
+ // list, so they have no `columns` and are skipped by computeRelations).
52
+ const piGroup = commandGroups.find((g) => g.name === "process-instance");
53
+ const pdGroup = commandGroups.find((g) => g.name === "process-definition");
54
+ const piGetCmd = piGroup?.commands.find((c) => c.name === "get");
55
+ const pdGetCmd = pdGroup?.commands.find((c) => c.name === "get");
56
+ const pdRelations = [
57
+ {
58
+ groupName: "process-definition",
59
+ commandName: "get",
60
+ description: "View process definition",
61
+ params: [{ field: "processDefinitionKey", param: "processDefinitionKey" }],
62
+ },
63
+ {
64
+ groupName: "process-definition",
65
+ commandName: "render",
66
+ description: "Render BPMN diagram",
67
+ params: [{ field: "processDefinitionKey", param: "processDefinitionKey" }],
68
+ },
69
+ {
70
+ groupName: "process-definition",
71
+ commandName: "get-xml",
72
+ description: "Get BPMN XML",
73
+ params: [{ field: "processDefinitionKey", param: "processDefinitionKey" }],
74
+ },
75
+ ];
76
+ if (piGetCmd) {
77
+ piGetCmd.relations = pdRelations;
78
+ }
79
+ if (pdGetCmd) {
80
+ pdGetCmd.relations = pdRelations.filter((r) => r.commandName !== "get");
81
+ }
47
82
  //# sourceMappingURL=index.js.map
@@ -406,21 +406,6 @@ export const decisionInstanceGroup = {
406
406
  argName: "decisionEvaluationInstanceKey",
407
407
  get: (client, key) => client.decisionInstance.getDecisionInstance(key),
408
408
  }),
409
- {
410
- name: "delete",
411
- description: "Delete decision instance",
412
- args: [{ name: "decisionInstanceKey", description: "decisionInstanceKey", required: true }],
413
- flags: [DATA_OPT_FLAG],
414
- async run(ctx) {
415
- const decisionInstanceKey = ctx.positional[0];
416
- if (!decisionInstanceKey)
417
- throw new Error("Missing required argument: <decisionInstanceKey>");
418
- const body = parseJson(ctx.flags.data, "data");
419
- const client = await ctx.getClient();
420
- await client.decisionInstance.deleteDecisionInstance(decisionInstanceKey, body);
421
- ctx.output.ok("delete completed.");
422
- },
423
- },
424
409
  makeCreateCmd({
425
410
  name: "delete-batch-operation",
426
411
  description: "Delete decision instances (batch)",
package/dist/run.js CHANGED
@@ -6,7 +6,7 @@ import { printCommandHelp, printGlobalHelp, printGroupHelp, printVersion } from
6
6
  import { createNullWriter, createOutputWriter, printRawResponse } from "./output.js";
7
7
  import { runProfileManager } from "./profile-tui.js";
8
8
  import { runSettingsManager } from "./settings-tui.js";
9
- import { runGroupTui, runMainTui } from "./tui.js";
9
+ import { runAskTui, runGroupTui, runMainTui } from "./tui.js";
10
10
  // ─── Profile info ─────────────────────────────────────────────────────────────
11
11
  function buildProfileInfo(profileName) {
12
12
  const effectiveName = profileName ?? getActiveName() ?? "none";
@@ -91,9 +91,32 @@ export async function run(argv) {
91
91
  process.exitCode = 1;
92
92
  return;
93
93
  }
94
+ // ── ask: join remaining positionals as the query ─────────────────────────
95
+ if (group.name === "ask" && positional.length >= 2 && !wantHelp) {
96
+ const queryWords = positional.slice(1);
97
+ const output = createOutputWriter((flags.output ?? flags.o ?? "table"), flags["no-color"] === true);
98
+ const ctx = {
99
+ positional: queryWords,
100
+ flags,
101
+ output,
102
+ getClient: () => Promise.resolve(createClientFromProfile(profileName)),
103
+ getAdminClient: () => Promise.resolve(createAdminClientFromProfile(profileName)),
104
+ };
105
+ const cmd = group.commands[0];
106
+ if (cmd)
107
+ await cmd.run(ctx);
108
+ return;
109
+ }
94
110
  // ── TUI (no subcommand, no --help) ───────────────────────────────────────
95
111
  if (positional.length === 1 && !wantHelp) {
96
- if (group.name === "profile") {
112
+ if (group.name === "ask") {
113
+ const { name: pName, info: pInfo } = buildProfileInfo(profileName);
114
+ await runAskTui(commandGroups, getClient, getAdminClient, {
115
+ profile: pName,
116
+ profileInfo: pInfo,
117
+ });
118
+ }
119
+ else if (group.name === "profile") {
97
120
  await runProfileManager();
98
121
  }
99
122
  else if (group.name === "settings") {
package/dist/tui.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { renderBpmnAscii } from "@bpmnkit/ascii";
3
3
  import { appendAuditEntry, getAuditLog, getSettings, saveSettings } from "@bpmnkit/profiles";
4
+ import { runAskQuery } from "./commands/ask.js";
4
5
  // ─── ANSI helpers ─────────────────────────────────────────────────────────────
5
6
  const CSI = "\x1b[";
6
7
  const HIDE = `${CSI}?25l`;
@@ -375,6 +376,10 @@ function renderMain(state, screen) {
375
376
  const desc = dim(fit(g.description, cols - nameW - 8));
376
377
  const line = ` ${name} ${desc}`;
377
378
  lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
379
+ // Add separator after the pinned top section (ask, settings, profile)
380
+ if (!searching && g.name === "profile" && i < groups.length - 1) {
381
+ lines.push(` ${dim("─".repeat(cols - 4))}`);
382
+ }
378
383
  }
379
384
  if (groups.length > viewH) {
380
385
  const hi = Math.min(screen.scroll + viewH, groups.length);
@@ -386,6 +391,33 @@ function renderMain(state, screen) {
386
391
  lines.push(`\n${hint}`);
387
392
  return lines;
388
393
  }
394
+ const ASK_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
395
+ let askSpinnerFrame = 0;
396
+ function renderAsk(state, screen) {
397
+ const { cols } = termSize();
398
+ const lines = [
399
+ renderHeader(["casen", "ask"], cols, state.profile),
400
+ `\n ${dim("Natural language search using a local AI (claude/copilot/gemini)")}\n`,
401
+ ];
402
+ const inputLine = ` > ${renderText(screen.query, screen.cursor, cols - 6, true)}`;
403
+ lines.push(inputLine);
404
+ lines.push("");
405
+ if (screen.status === "running" && screen.statusMsg) {
406
+ const frame = ASK_SPINNER_FRAMES[askSpinnerFrame % ASK_SPINNER_FRAMES.length] ?? "⠋";
407
+ lines.push(` ${frame} ${dim(screen.statusMsg)}`);
408
+ }
409
+ else if (screen.error) {
410
+ lines.push(` ${red("error:")} ${screen.error}`);
411
+ }
412
+ lines.push("");
413
+ if (screen.status === "running") {
414
+ lines.push(` ${dim("type query")} ${cyan("enter")} run ${cyan("esc")} back ${cyan("^C")} abort`);
415
+ }
416
+ else {
417
+ lines.push(` ${dim("type query")} ${cyan("enter")} run ${cyan("esc")} back ${cyan("^C")} quit`);
418
+ }
419
+ return lines;
420
+ }
389
421
  function renderCommands(state, screen) {
390
422
  const { cols, rows } = termSize();
391
423
  const searching = screen.search.length > 0;
@@ -688,9 +720,10 @@ function renderResults(state, screen) {
688
720
  lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
689
721
  }
690
722
  const spaceHint = entries.some(([, v]) => v instanceof ArrayValue)
691
- ? ` ${cyan("space")} expand array`
723
+ ? ` ${cyan("space")} expand`
692
724
  : "";
693
- lines.push(`\n${rawToggle}${curlToggle}${spaceHint} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
725
+ const fHint = screen.cmd.relations ? ` ${cyan("f")} follow-up` : "";
726
+ lines.push(`\n ${dim("↑↓")} navigate ${cyan("enter")} navigate field${spaceHint}${fHint}${rawToggle}${curlToggle} ${cyan("m")} main ${cyan("esc")} back ${cyan("q")} quit`);
694
727
  }
695
728
  else {
696
729
  lines.push(renderHeader([screen.group.name, screen.cmd.name], cols, state.profile));
@@ -746,7 +779,8 @@ function renderDetail(state, screen) {
746
779
  const spaceHint = entries.some(([, v]) => v instanceof ArrayValue)
747
780
  ? ` ${cyan("space/→")} expand ${cyan("←")} back`
748
781
  : ` ${cyan("←")} back`;
749
- lines.push(` ${dim("↑↓")} navigate${spaceHint} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
782
+ const fHint = screen.cmd?.relations ? ` ${cyan("f")} follow-up` : "";
783
+ lines.push(` ${dim("↑↓")} navigate ${cyan("enter")} navigate field${spaceHint}${fHint} ${cyan("m")} main ${cyan("esc")} back ${cyan("q")} quit`);
750
784
  return lines;
751
785
  }
752
786
  function renderFollowup(state, screen) {
@@ -1171,6 +1205,160 @@ function launchWorkerView(inputScreen, state) {
1171
1205
  stopWorkerScreen(ws);
1172
1206
  });
1173
1207
  }
1208
+ // ─── Ask screen helpers ───────────────────────────────────────────────────────
1209
+ async function runAskInTui(screen, state) {
1210
+ let frame = 0;
1211
+ screen._timer = setInterval(() => {
1212
+ frame++;
1213
+ askSpinnerFrame = frame;
1214
+ if (!state.quitting && state.stack.includes(screen)) {
1215
+ render(state);
1216
+ }
1217
+ else {
1218
+ if (screen._timer !== null) {
1219
+ clearInterval(screen._timer);
1220
+ screen._timer = null;
1221
+ }
1222
+ }
1223
+ }, 80);
1224
+ let result;
1225
+ try {
1226
+ result = await runAskQuery(screen.query, state.getClient, (msg) => {
1227
+ screen.statusMsg = msg;
1228
+ });
1229
+ }
1230
+ catch (err) {
1231
+ if (screen._timer !== null) {
1232
+ clearInterval(screen._timer);
1233
+ screen._timer = null;
1234
+ }
1235
+ screen.error = err instanceof Error ? err.message : String(err);
1236
+ screen.status = "idle";
1237
+ screen.statusMsg = "";
1238
+ render(state);
1239
+ return;
1240
+ }
1241
+ if (screen._timer !== null) {
1242
+ clearInterval(screen._timer);
1243
+ screen._timer = null;
1244
+ }
1245
+ const askGrp = state.groups.find((g) => g.name === "ask") ?? {
1246
+ name: "ask",
1247
+ description: "",
1248
+ commands: [],
1249
+ };
1250
+ const syntheticCmd = {
1251
+ name: result.resource,
1252
+ description: "AI search results",
1253
+ run: async () => { },
1254
+ relations: result.relations,
1255
+ };
1256
+ state.stack.push({
1257
+ kind: "results",
1258
+ group: askGrp,
1259
+ cmd: syntheticCmd,
1260
+ output: {
1261
+ type: "list",
1262
+ items: result.items,
1263
+ columns: result.columns,
1264
+ total: result.total,
1265
+ },
1266
+ raw: null,
1267
+ rawView: false,
1268
+ curlView: false,
1269
+ altView: false,
1270
+ cursor: 0,
1271
+ scroll: 0,
1272
+ });
1273
+ render(state);
1274
+ }
1275
+ async function handleAskKey(key, screen, state, done) {
1276
+ // Ctrl+C always quits
1277
+ if (key === "\x03") {
1278
+ done();
1279
+ return;
1280
+ }
1281
+ // ESC: if running do nothing; otherwise pop
1282
+ if (key === "\x1b") {
1283
+ if (screen.status === "running") {
1284
+ render(state);
1285
+ return;
1286
+ }
1287
+ if (screen._timer !== null) {
1288
+ clearInterval(screen._timer);
1289
+ screen._timer = null;
1290
+ }
1291
+ state.stack.pop();
1292
+ render(state);
1293
+ return;
1294
+ }
1295
+ // Enter: run if not running and has query
1296
+ if (key === "\r" || key === "\n") {
1297
+ if (screen.status !== "running" && screen.query.trim()) {
1298
+ screen.status = "running";
1299
+ screen.statusMsg = "";
1300
+ screen.error = "";
1301
+ render(state);
1302
+ runAskInTui(screen, state).catch((err) => {
1303
+ if (screen._timer !== null) {
1304
+ clearInterval(screen._timer);
1305
+ screen._timer = null;
1306
+ }
1307
+ screen.error = err instanceof Error ? err.message : String(err);
1308
+ screen.status = "idle";
1309
+ render(state);
1310
+ });
1311
+ }
1312
+ return;
1313
+ }
1314
+ // Text editing (only when idle)
1315
+ if (screen.status === "running") {
1316
+ render(state);
1317
+ return;
1318
+ }
1319
+ switch (key) {
1320
+ case "\x7f": // backspace
1321
+ case "\x08":
1322
+ if (screen.cursor > 0) {
1323
+ screen.query = screen.query.slice(0, screen.cursor - 1) + screen.query.slice(screen.cursor);
1324
+ screen.cursor--;
1325
+ }
1326
+ break;
1327
+ case "\x01": // Ctrl+A
1328
+ case "\x1b[H":
1329
+ screen.cursor = 0;
1330
+ break;
1331
+ case "\x05": // Ctrl+E
1332
+ case "\x1b[F":
1333
+ screen.cursor = screen.query.length;
1334
+ break;
1335
+ case "\x0b": // Ctrl+K — clear to end
1336
+ screen.query = screen.query.slice(0, screen.cursor);
1337
+ break;
1338
+ case "\x15": // Ctrl+U — clear line
1339
+ screen.query = "";
1340
+ screen.cursor = 0;
1341
+ break;
1342
+ case "\x1b[D": // left arrow
1343
+ if (screen.cursor > 0)
1344
+ screen.cursor--;
1345
+ break;
1346
+ case "\x1b[C": // right arrow
1347
+ if (screen.cursor < screen.query.length)
1348
+ screen.cursor++;
1349
+ break;
1350
+ default: {
1351
+ const printable = [...key].filter((ch) => ch >= " ").join("");
1352
+ if (printable) {
1353
+ screen.query =
1354
+ screen.query.slice(0, screen.cursor) + printable + screen.query.slice(screen.cursor);
1355
+ screen.cursor += printable.length;
1356
+ }
1357
+ break;
1358
+ }
1359
+ }
1360
+ render(state);
1361
+ }
1174
1362
  function render(state) {
1175
1363
  if (state.quitting)
1176
1364
  return;
@@ -1182,6 +1370,9 @@ function render(state) {
1182
1370
  case "main":
1183
1371
  lines = renderMain(state, screen);
1184
1372
  break;
1373
+ case "ask":
1374
+ lines = renderAsk(state, screen);
1375
+ break;
1185
1376
  case "commands":
1186
1377
  lines = renderCommands(state, screen);
1187
1378
  break;
@@ -1283,6 +1474,17 @@ function handleMainKey(key, screen, state, done) {
1283
1474
  message: "",
1284
1475
  });
1285
1476
  }
1477
+ else if (group?.name === "ask") {
1478
+ state.stack.push({
1479
+ kind: "ask",
1480
+ query: "",
1481
+ cursor: 0,
1482
+ status: "idle",
1483
+ statusMsg: "",
1484
+ error: "",
1485
+ _timer: null,
1486
+ });
1487
+ }
1286
1488
  else if (group) {
1287
1489
  state.stack.push({ kind: "commands", group, cursor: 0, search: "" });
1288
1490
  }
@@ -1762,6 +1964,41 @@ function handleResultsKey(key, screen, state, done) {
1762
1964
  screen.cursor++;
1763
1965
  else if (key === " " || key === "\x1b[C")
1764
1966
  expandArray();
1967
+ else if (key === "\r" || key === "\n") {
1968
+ const entry = entries[screen.cursor];
1969
+ if (entry) {
1970
+ const [fieldKey, v] = entry;
1971
+ if (v instanceof ArrayValue) {
1972
+ expandArray();
1973
+ }
1974
+ else {
1975
+ const resolved = resolveRelationsForField(fieldKey, screen.cmd.relations, state.groups);
1976
+ if (resolved.length > 0) {
1977
+ state.stack.push({
1978
+ kind: "followup",
1979
+ sourceGroup: screen.group,
1980
+ item: screen.output.data,
1981
+ relations: resolved,
1982
+ cursor: 0,
1983
+ scroll: 0,
1984
+ });
1985
+ }
1986
+ }
1987
+ }
1988
+ }
1989
+ else if (key === "f" || key === "F") {
1990
+ const resolved = resolveAllRelations(screen.cmd.relations, state.groups);
1991
+ if (resolved.length > 0) {
1992
+ state.stack.push({
1993
+ kind: "followup",
1994
+ sourceGroup: screen.group,
1995
+ item: screen.output.data,
1996
+ relations: resolved,
1997
+ cursor: 0,
1998
+ scroll: 0,
1999
+ });
2000
+ }
2001
+ }
1765
2002
  else if (key === "\x1b[D")
1766
2003
  state.stack.pop();
1767
2004
  else if (key === "\x1b")
@@ -1851,6 +2088,7 @@ function handleResultsKey(key, screen, state, done) {
1851
2088
  state.stack.push({
1852
2089
  kind: "detail",
1853
2090
  group: screen.group,
2091
+ cmd: screen.cmd,
1854
2092
  item,
1855
2093
  label: "detail",
1856
2094
  cursor: 0,
@@ -1862,17 +2100,8 @@ function handleResultsKey(key, screen, state, done) {
1862
2100
  case "f":
1863
2101
  case "F": {
1864
2102
  const item = screen.output.items[screen.cursor];
1865
- if (item && screen.cmd.relations) {
1866
- const resolved = [];
1867
- for (const rel of screen.cmd.relations) {
1868
- const tGroup = state.groups.find((g) => g.name === rel.groupName);
1869
- if (!tGroup)
1870
- continue;
1871
- const tCmd = tGroup.commands.find((c) => c.name === rel.commandName);
1872
- if (!tCmd)
1873
- continue;
1874
- resolved.push({ group: tGroup, cmd: tCmd, params: rel.params });
1875
- }
2103
+ if (item) {
2104
+ const resolved = resolveAllRelations(screen.cmd.relations, state.groups);
1876
2105
  if (resolved.length > 0) {
1877
2106
  state.stack.push({
1878
2107
  kind: "followup",
@@ -1900,6 +2129,25 @@ function handleResultsKey(key, screen, state, done) {
1900
2129
  }
1901
2130
  render(state);
1902
2131
  }
2132
+ function resolveAllRelations(relations, groups) {
2133
+ if (!relations)
2134
+ return [];
2135
+ const out = [];
2136
+ for (const rel of relations) {
2137
+ const tGroup = groups.find((g) => g.name === rel.groupName);
2138
+ if (!tGroup)
2139
+ continue;
2140
+ const tCmd = tGroup.commands.find((c) => c.name === rel.commandName);
2141
+ if (!tCmd)
2142
+ continue;
2143
+ out.push({ group: tGroup, cmd: tCmd, params: rel.params });
2144
+ }
2145
+ return out;
2146
+ }
2147
+ function resolveRelationsForField(fieldName, relations, groups) {
2148
+ const matching = (relations ?? []).filter((rel) => rel.params.some((p) => p.field === fieldName));
2149
+ return resolveAllRelations(matching, groups);
2150
+ }
1903
2151
  function handleDetailKey(key, screen, state, done) {
1904
2152
  const { rows } = termSize();
1905
2153
  const viewH = Math.max(3, rows - 7);
@@ -1946,9 +2194,59 @@ function handleDetailKey(key, screen, state, done) {
1946
2194
  }
1947
2195
  break;
1948
2196
  }
2197
+ case "\r":
2198
+ case "\n": {
2199
+ const entry = entries[screen.cursor];
2200
+ if (entry && typeof screen.item === "object" && screen.item !== null) {
2201
+ const [fieldKey, v] = entry;
2202
+ if (v instanceof ArrayValue) {
2203
+ state.stack.push({
2204
+ kind: "detail",
2205
+ group: screen.group,
2206
+ item: v.items,
2207
+ label: `${fieldKey} (${v.items.length})`,
2208
+ cursor: 0,
2209
+ scroll: 0,
2210
+ });
2211
+ }
2212
+ else {
2213
+ const item = screen.item;
2214
+ const resolved = resolveRelationsForField(fieldKey, screen.cmd?.relations, state.groups);
2215
+ if (resolved.length > 0) {
2216
+ state.stack.push({
2217
+ kind: "followup",
2218
+ sourceGroup: screen.group,
2219
+ item,
2220
+ relations: resolved,
2221
+ cursor: 0,
2222
+ scroll: 0,
2223
+ });
2224
+ }
2225
+ }
2226
+ }
2227
+ break;
2228
+ }
1949
2229
  case "\x1b[D":
1950
2230
  state.stack.pop();
1951
2231
  break;
2232
+ case "f":
2233
+ case "F": {
2234
+ if (typeof screen.item === "object" && screen.item !== null) {
2235
+ const item = screen.item;
2236
+ const resolved = resolveAllRelations(screen.cmd?.relations, state.groups);
2237
+ if (resolved.length > 0) {
2238
+ state.stack.push({
2239
+ kind: "followup",
2240
+ sourceGroup: screen.group,
2241
+ item,
2242
+ relations: resolved,
2243
+ cursor: 0,
2244
+ scroll: 0,
2245
+ });
2246
+ }
2247
+ }
2248
+ break;
2249
+ }
1952
2250
  case "m":
1953
2251
  case "M":
1954
2252
  popToMain(state);
@@ -2421,6 +2719,9 @@ async function handleKey(key, state, done) {
2421
2719
  case "main":
2422
2720
  handleMainKey(key, screen, state, done);
2423
2721
  break;
2722
+ case "ask":
2723
+ await handleAskKey(key, screen, state, done);
2724
+ break;
2424
2725
  case "commands":
2425
2726
  handleCommandsKey(key, screen, state, done);
2426
2727
  break;
@@ -2520,6 +2821,23 @@ export async function runMainTui(groups, getClient, getAdminClient, opts) {
2520
2821
  profileInfo: opts?.profileInfo ?? [],
2521
2822
  });
2522
2823
  }
2824
+ /** Open the TUI directly at the ask screen (natural language search). */
2825
+ export async function runAskTui(groups, getClient, getAdminClient, opts) {
2826
+ return startTui({
2827
+ groups,
2828
+ stack: [
2829
+ { kind: "main", cursor: 0, scroll: 0, search: "" },
2830
+ { kind: "ask", query: "", cursor: 0, status: "idle", statusMsg: "", error: "", _timer: null },
2831
+ ],
2832
+ getClient,
2833
+ getAdminClient: getAdminClient ??
2834
+ opts?.getAdminClient ??
2835
+ (() => Promise.reject(new Error("No admin client"))),
2836
+ quitting: false,
2837
+ profile: opts?.profile ?? "",
2838
+ profileInfo: opts?.profileInfo ?? [],
2839
+ });
2840
+ }
2523
2841
  /**
2524
2842
  * Open the TUI directly at a specific group's command list.
2525
2843
  * The main menu is placed at the bottom of the stack so `m` always works.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bpmnkit/cli",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
4
4
  "description": "Command-line interface for Camunda 8 — deploy, manage, and monitor processes from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,9 +15,9 @@
15
15
  "node": ">=20"
16
16
  },
17
17
  "dependencies": {
18
+ "@bpmnkit/connector-gen": "0.0.7",
18
19
  "@bpmnkit/api": "0.0.12",
19
- "@bpmnkit/ascii": "0.0.12",
20
- "@bpmnkit/connector-gen": "0.0.6",
20
+ "@bpmnkit/ascii": "0.0.13",
21
21
  "@bpmnkit/profiles": "0.0.9"
22
22
  },
23
23
  "publishConfig": {