@rse/ase 2.0.4 → 2.0.6

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.
Files changed (48) hide show
  1. package/dst/ase-config-schema.js +1 -1
  2. package/dst/ase-hook.js +12 -3
  3. package/dst/ase-setup-settings.js +111 -1
  4. package/dst/ase-setup.js +19 -7
  5. package/dst/ase-spec.js +184 -49
  6. package/dst/ase-task-board-tui-model.js +8 -1
  7. package/dst/ase-task-board-tui.js +26 -2
  8. package/dst/ase-task-board-web-client.js +10 -10
  9. package/dst/man/ase-artifact.md +1 -1
  10. package/dst/man/ase-config.md +5 -3
  11. package/dst/man/ase-mcp.md +1 -1
  12. package/dst/man/ase-setup-install.md +10 -1
  13. package/dst/man/ase-setup-status.md +6 -0
  14. package/dst/man/ase-setup-uninstall.md +3 -1
  15. package/dst/man/ase-setup-update.md +3 -0
  16. package/dst/man/ase-setup.md +4 -4
  17. package/dst/man/ase-spec-export.md +14 -3
  18. package/dst/man/ase-spec-init.md +10 -1
  19. package/dst/man/ase-spec-lint.md +2 -1
  20. package/dst/man/ase-spec-preview.md +10 -1
  21. package/dst/man/ase-spec-query.md +96 -0
  22. package/dst/man/ase-spec.md +32 -11
  23. package/dst/man/ase.md +3 -3
  24. package/package.json +21 -18
  25. package/plugin/.claude-plugin/icon.png +0 -0
  26. package/plugin/.claude-plugin/plugin.json +1 -1
  27. package/plugin/.codex-plugin/plugin.json +1 -1
  28. package/plugin/.github/plugin/plugin.json +1 -1
  29. package/plugin/meta/ase-format-spec.md +29 -5
  30. package/plugin/meta/ase-format-specbook.md +427 -47
  31. package/plugin/meta/ase-format-specbook.yaml +180 -78
  32. package/plugin/mods/ase-mods-common.ts +474 -0
  33. package/plugin/mods/ase-mods-evaluate.ts +227 -0
  34. package/plugin/mods/ase-mods-grill.ts +283 -0
  35. package/plugin/mods/ase-mods-help-browse.ts +410 -0
  36. package/plugin/mods/ase-mods-help-compose.ts +561 -0
  37. package/plugin/mods/ase-mods-help-find.ts +434 -0
  38. package/plugin/mods/ase-mods-help-hint.ts +134 -0
  39. package/plugin/mods/ase-mods-help-show.ts +142 -0
  40. package/plugin/mods/ase-mods.ts +220 -381
  41. package/plugin/skills/ase-code-lint/SKILL.md +15 -15
  42. package/plugin/skills/ase-code-lint/help.md +21 -8
  43. package/plugin/skills/ase-help-intent/SKILL.md +2 -1
  44. package/plugin/skills/ase-help-intent/data.md +42 -16
  45. package/plugin/skills/ase-spec-activate/SKILL.md +4 -1
  46. package/plugin/skills/ase-spec-lint/SKILL.md +15 -15
  47. package/plugin/skills/ase-spec-lint/help.md +21 -8
  48. package/plugin/mods/claude-code.d.ts +0 -13440
@@ -0,0 +1,434 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+
7
+ /* Claude Code function hooks module ("mod", EARLY ACCESS):
8
+ Intent help, matching the still unsubmitted prompt, once suffixed with
9
+ a blank and "!!", as an intent against the help of all ASE skills (like
10
+ the skill "ase-help-intent", but over the session context), and showing
11
+ the proposed skills in a master/detail view in the band above the
12
+ prompt, where LEFT/RIGHT (or the wheel over the skills) selects a skill,
13
+ UP/DOWN and "b"/"f" (or the wheel over the details) scroll its details,
14
+ RETURN picks its command into the prompt, SPACE picks it into the synopsis
15
+ form, and "!!", BACKSPACE, or any other key restores the prompt as typed
16
+ without the trigger. */
17
+
18
+ import type { Elements, Frozen, PromptBox, PromptEditInput, RenderElement } from "claude-code"
19
+ import { type Engine, type Char, type BandRender, type BandScroll, type View, PICKED, CODE,
20
+ held, decode, section, wrap, uniform, flow, frame, layout, wheel, panel } from "./ase-mods-common.ts"
21
+
22
+ /* the trigger of an intent inside the prompt (suffixed with a blank and "!!",
23
+ so the doubled exclamation mark of a sentence is no trigger), the skill of
24
+ a proposed command (with or without its "ase:" plugin prefix), the maximum
25
+ number of command proposals, the help corpus of all skills (relative to
26
+ the plugin directory), and the fallback model without any session to fork */
27
+ const INTENT = /\s+!!$/
28
+ const SKILL = /^\/?(?:ase:)?(ase-[a-z0-9]+-[a-z0-9]+)(?![\w-])/
29
+ const PROPOSALS = 8
30
+ const CORPUS = "skills/ase-help-intent/data.md"
31
+ const FALLBACK = "sonnet"
32
+
33
+ /* the color of the box around (and of the rationale inside) the command and
34
+ rationale of a proposal, the title placed into the top edge of the frame,
35
+ and the share of the band columns taken by the master view */
36
+ const BOXED = "blue"
37
+ const TITLE = "[ ⧉ ASE: Find Skill Command ]"
38
+ const MASTER = 0.3
39
+
40
+ /* the frames of the spinner animating the status message while matching,
41
+ and the milliseconds between two frames */
42
+ const SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
43
+ const SPIN = 100
44
+
45
+ /* the help of a single skill: its catalog entry and its entire manual page */
46
+ type Help = { catalog: string, manual: string }
47
+
48
+ /* a command proposal for an intent */
49
+ type Proposal = { skill: string, command: string, rationale: string }
50
+
51
+ /* an intent under matching: the scroll state of its band, the original prompt
52
+ (without the trigger and its blank, resp. without the trigger only), the
53
+ matching state, the status message,
54
+ the spinner frame, the command proposals, the selected proposal, and
55
+ the last key, if a "!" or "/" */
56
+ type Intent = View & {
57
+ draft: string,
58
+ typed: string,
59
+ state: "busy" | "done" | "fail",
60
+ message: string,
61
+ spin: number,
62
+ proposals: Proposal[],
63
+ select: number,
64
+ pending: string
65
+ }
66
+
67
+ /* parse the help corpus into the help of each skill, where
68
+ the catalog entry is the NAME, SYNOPSIS, DESCRIPTION, and SCENARIOS only */
69
+ const helps = (text: string): Map<string, Help> => {
70
+ const result = new Map<string, Help>()
71
+ for (const m of text.matchAll(/<purpose name="([^"]+)">\n([\s\S]*?)<\/purpose>/g)) {
72
+ const manual = m[2]!.trim()
73
+ const catalog = manual.split(/^(?=## {2})/m)
74
+ .filter((part) => /^## {2}(?:NAME|SYNOPSIS|DESCRIPTION|SCENARIOS)\b/.test(part))
75
+ .join("").trim()
76
+ result.set(m[1]!, { catalog, manual })
77
+ }
78
+ return result
79
+ }
80
+
81
+ /* extract the text out of a model reply (as the runtime result of a fork
82
+ or completion may deviate from its declared type, like a string, a result
83
+ object, an unanswered reply, or a refusal with a reason instead of null) */
84
+ const reply = (result: unknown): string | undefined => {
85
+ if (typeof result === "string")
86
+ return result
87
+ if (typeof result === "object" && result !== null) {
88
+ const r = result as { isAnswered?: boolean, text?: unknown }
89
+ if (r.isAnswered !== false && typeof r.text === "string")
90
+ return r.text
91
+ }
92
+ return undefined
93
+ }
94
+
95
+ /* extract the JSON array out of a model reply (trying each opening bracket,
96
+ as any prose before the array may contain brackets, too) */
97
+ const array = (text: string | undefined): unknown[] | undefined => {
98
+ if (text === undefined)
99
+ return undefined
100
+ const j = text.lastIndexOf("]")
101
+ for (let i = text.indexOf("["); i !== -1 && i < j; i = text.indexOf("[", i + 1)) {
102
+ try {
103
+ const value: unknown = JSON.parse(text.slice(i, j + 1))
104
+ if (Array.isArray(value))
105
+ return value
106
+ }
107
+ catch {
108
+ /* continue with the next opening bracket */
109
+ }
110
+ }
111
+ return undefined
112
+ }
113
+
114
+ /* the prompt for selecting the fitting skills out of the catalog */
115
+ const selection = (draft: string, catalog: string) => `
116
+ Do *not* call any tools and do *not* perform the following intent!
117
+ Instead, only *match* the following free-text intent, interpreted in the
118
+ context of this session, against the following catalog of ASE skills:
119
+
120
+ <intent>
121
+ ${draft}
122
+ </intent>
123
+
124
+ <catalog>
125
+ ${catalog}
126
+ </catalog>
127
+
128
+ Select *every* skill which adequately fits the intent -- judging the
129
+ fit primarily by its SCENARIOS and DESCRIPTION, and secondarily by its
130
+ NAME and SYNOPSIS -- ordered from best-fitting to worst-fitting, and
131
+ keep at most the ${PROPOSALS} best ones. Reply with
132
+ a JSON array of the skill names only (like \`[ "ase-code-lint" ]\`),
133
+ resp. with an empty JSON array if no skill fits, and nothing else.
134
+ `
135
+
136
+ /* the prompt for generating the commands out of the manual pages */
137
+ const generation = (draft: string, manuals: string) => `
138
+ Do *not* call any tools and do *not* perform the following intent!
139
+ Instead, only *generate* commands which realize the following free-text
140
+ intent, interpreted in the context of this session, out of the
141
+ following manual pages of ASE skills:
142
+
143
+ <intent>
144
+ ${draft}
145
+ </intent>
146
+
147
+ <manuals>
148
+ ${manuals}
149
+ </manuals>
150
+
151
+ For each skill, in the given order, generate a concrete command which
152
+ realizes the intent, with the option flags and positional arguments
153
+ derived from its SYNOPSIS, OPTIONS, and ARGUMENTS, and their concrete
154
+ values taken from the intent and the context of this session. Reply with
155
+ a JSON array of objects \`{ "command": "/ase:<skill> <arguments>",
156
+ "rationale": "<justification>" }\`, where the justification is a *very
157
+ brief*, single-sentence explanation of why the skill and its arguments
158
+ match the intent, and nothing else.
159
+ `
160
+
161
+ /* determine the lines of the detail view of a proposal out of the manual page
162
+ of its skill: the command and rationale of the proposal (inside a box), and
163
+ the name, title, and description of its skill */
164
+ const details = (proposal: Proposal, manual: string, columns: number): Char[][] => {
165
+ const title = section(manual, "NAME").trim().replace(/^`[^`]*`\s+-\s+/, "")
166
+ const boxed = wrap(uniform(`❯ ${proposal.command}`, true, CODE), columns - 4, 2)
167
+ if (proposal.rationale !== "")
168
+ boxed.push(...wrap(uniform(`▷ Rationale: ${proposal.rationale}`, false, BOXED), columns - 4, 2))
169
+ const lines = frame(boxed, columns, BOXED)
170
+ lines.push([], ...wrap(uniform(title !== "" ? `${proposal.skill}: ${title}` : proposal.skill,
171
+ true, undefined), columns, 0))
172
+ const description = section(manual, "DESCRIPTION")
173
+ if (description.trim() !== "")
174
+ lines.push([], ...flow(description, columns))
175
+ return lines
176
+ }
177
+
178
+ /* the intent help: its state and its operations */
179
+ export const modHelpFind = () => {
180
+ /* the help of all skills (loaded on first use) and the currently shown intent */
181
+ let corpus: Map<string, Help> | undefined
182
+ let intent: Intent | undefined
183
+
184
+ /* determine whether the intent band is shown */
185
+ const active = () => intent !== undefined
186
+
187
+ /* close the intent band */
188
+ const reset = () => {
189
+ intent = undefined
190
+ }
191
+
192
+ /* close the intent band (as on a failure) and determine the prompt to
193
+ restore (the prompt as typed), if the band was shown */
194
+ const cancel = (): string | undefined => {
195
+ const typed = intent?.typed
196
+ reset()
197
+ return typed
198
+ }
199
+
200
+ /* while the intent band is shown, every edit is a key of the band (with the
201
+ prompt held): LEFT/RIGHT select a proposal, UP/DOWN scroll its details by
202
+ a line and "b"/"f" by a page (as the engine consumes PAGEUP/PAGEDOWN),
203
+ RETURN (arriving as a typed newline in the held prompt) operates the band
204
+ as at the prompt, SPACE closes the band with the command of the selected
205
+ proposal suffixed with the trigger of the synopsis form, "//" (two "/"
206
+ in a row) closes the band with the prompt as typed suffixed with the
207
+ trigger of the manpage browser (both to switch over to the other band),
208
+ and "!!" (two "!" in a row), a deletion (like BACKSPACE), or any
209
+ other key closes the band and restores the prompt as typed without the
210
+ trigger (onto whose end the edit of a typed text is still to be applied) */
211
+ const edit = (e: Frozen<PromptEditInput>): PromptBox | string => {
212
+ const current = intent!
213
+ const { draft, typed, state, proposals } = current
214
+ const key = decode(e)
215
+ const pending = current.pending
216
+ current.pending = /^[!/]$/.test(key.text) && key.text !== pending ? key.text : ""
217
+ if (current.pending !== "")
218
+ return held(draft)
219
+ if (key.text === "//" || (key.text === "/" && pending === "/")) {
220
+ reset()
221
+ const text = `${typed}//`
222
+ return { text, cursor: text.length }
223
+ }
224
+ if (key.name === "return") {
225
+ const text = submit()
226
+ return intent !== undefined ? held(draft) : { text, cursor: text.length }
227
+ }
228
+ if (state === "done" && key.text === " ") {
229
+ reset()
230
+ const text = `${proposals[current.select]!.command} !!`
231
+ return { text, cursor: text.length }
232
+ }
233
+ const roll = key.name === "up" ? -1 : key.name === "down" ? 1 :
234
+ key.text === "b" ? -current.page : key.text === "f" ? current.page : 0
235
+ const step = roll !== 0 ? 0 : key.step
236
+ if (state === "done" && (roll !== 0 || step !== 0)) {
237
+ if (step !== 0) {
238
+ current.select = Math.max(0, Math.min(proposals.length - 1, current.select + step))
239
+ current.scroll = 0
240
+ }
241
+ else
242
+ current.scroll = Math.max(0, current.scroll + roll)
243
+ return held(draft)
244
+ }
245
+ reset()
246
+ if (key.text === "" || key.text === "!!" || (key.text === "!" && pending === "!"))
247
+ return { text: typed, cursor: typed.length }
248
+ return typed
249
+ }
250
+
251
+ /* stash the prompt as the intent, hold the prompt (so the keys of the
252
+ band arrive as edits), and match the intent in the background */
253
+ const open = (box: PromptBox, engine: Pick<Engine, "invalidate" | "read" | "fork" | "complete" | "sleep">): PromptBox | undefined => {
254
+ const draft = box.text.replace(INTENT, "")
255
+ if (!INTENT.test(box.text) || draft.trim() === "")
256
+ return undefined
257
+ const current: Intent = { draft, typed: box.text.slice(0, -2), state: "busy",
258
+ message: "matching intent...", spin: 0, proposals: [],
259
+ select: 0, top: 0, scroll: 0, page: 1, split: 0, pending: "" }
260
+ intent = current
261
+
262
+ /* update the intent, unless it was closed in the meantime */
263
+ const settle = (state: Intent["state"], message: string, proposals: Proposal[] = []) => {
264
+ if (intent !== current)
265
+ return
266
+ Object.assign(current, { state, message, proposals })
267
+ engine.invalidate()
268
+ }
269
+
270
+ /* ask the model for a stage over the transcript of the session (sharing its
271
+ prompt cache), or, as the fork yields no text on a cold snapshot or an API
272
+ error, without any session context (telling so in the status message) */
273
+ const ask = async (prompt: string, stage: string): Promise<string | undefined> => {
274
+ settle("busy", `${stage}...`)
275
+ const forked = reply(await engine.fork(prompt))
276
+ if (forked !== undefined)
277
+ return forked
278
+ if (intent !== current)
279
+ return undefined
280
+ settle("busy", `${stage} (without session context)...`)
281
+ return reply(await engine.complete({ model: FALLBACK, prompt, maxTokens: 4096 }))
282
+ }
283
+
284
+ /* match the intent in two stages: select the fitting skills out of
285
+ the catalog, then generate the commands out of their manual pages */
286
+ const match = async () => {
287
+ const skills = corpus ??= helps(await engine.read(CORPUS))
288
+ const catalog = [ ...skills ].map(([ name, help ]) =>
289
+ `<skill name="${name}">\n${help.catalog}\n</skill>`).join("\n")
290
+ const names = (array(await ask(selection(draft, catalog), "matching intent")) ?? [])
291
+ .filter((name, i, all): name is string =>
292
+ typeof name === "string" && skills.has(name) && all.indexOf(name) === i)
293
+ .slice(0, PROPOSALS)
294
+ if (intent !== current)
295
+ return
296
+ if (names.length === 0) {
297
+ settle("fail", "no confident match for the intent -- please refine or clarify it")
298
+ return
299
+ }
300
+ const manuals = names.map((name) =>
301
+ `<skill name="${name}">\n${skills.get(name)!.manual}\n</skill>`).join("\n")
302
+ const proposals = (array(await ask(generation(draft, manuals), `generating commands for ${names.join(", ")}`)) ?? [])
303
+ .flatMap((item): Proposal[] => {
304
+ const { command, rationale } = (item ?? {}) as { command?: unknown, rationale?: unknown }
305
+ const text = typeof command === "string" ? command.trim() : ""
306
+ const skill = SKILL.exec(text)?.[1]
307
+ if (skill === undefined || !skills.has(skill))
308
+ return []
309
+ return [ {
310
+ skill,
311
+ command: text.replace(SKILL, "/ase:$1").replace(/\s+/g, " "),
312
+ rationale: typeof rationale === "string" ? rationale.trim().replace(/\s+/g, " ") : ""
313
+ } ]
314
+ })
315
+ .slice(0, PROPOSALS)
316
+
317
+ /* without any generated command, still propose the bare commands of the selected skills */
318
+ if (proposals.length === 0)
319
+ settle("done", "pick a command (without generated arguments)",
320
+ names.map((skill) => ({ skill, command: `/ase:${skill}`, rationale: "" })))
321
+ else
322
+ settle("done", "pick a command", proposals)
323
+ }
324
+ match().catch((err: unknown) => {
325
+ settle("fail", `matching failed: ${err instanceof Error ? err.message : String(err)}`)
326
+ })
327
+
328
+ /* animate the spinner while matching, unless the intent was closed in the meantime */
329
+ const animate = async () => {
330
+ while (intent === current && current.state === "busy") {
331
+ await engine.sleep(SPIN)
332
+ current.spin = (current.spin + 1) % SPINNER.length
333
+ engine.invalidate()
334
+ }
335
+ }
336
+ animate().catch(() => {
337
+ /* ignore a stopped animation, as the status message stays readable */
338
+ })
339
+ return held(draft)
340
+ }
341
+
342
+ /* operate the intent band by the wheel: over the master view step the
343
+ selection, over the detail view scroll it, resp. scroll it by a page
344
+ with the scroll keys (PAGEUP/PAGEDOWN) while the band is focused
345
+ (and tell whether the band took the scrolling) */
346
+ const scroll = (e: BandScroll): boolean => {
347
+ if (intent === undefined || intent.state !== "done")
348
+ return false
349
+ const current = intent
350
+ wheel(e, current, (by) => {
351
+ const select = Math.max(0, Math.min(current.proposals.length - 1, current.select + by))
352
+ if (select !== current.select) {
353
+ current.select = select
354
+ current.scroll = 0
355
+ }
356
+ })
357
+ return true
358
+ }
359
+
360
+ /* operate the intent band by RETURN at the prompt: pick the selected
361
+ proposal (closing the band), if any, resp. close a failed band, and
362
+ determine the prompt to refill (the held prompt while the band stays
363
+ shown, but without its space after the cursor, as the refill places
364
+ the cursor at its end) */
365
+ const submit = (): string => {
366
+ const current = intent!
367
+ const proposal = current.state === "done" ? current.proposals[current.select] : undefined
368
+ if (proposal !== undefined || current.state === "fail")
369
+ reset()
370
+ return proposal !== undefined ? `${proposal.command} ` :
371
+ intent !== undefined ? held(current.draft).text.trimEnd() : `${current.draft} `
372
+ }
373
+
374
+ /* draw the intent into the band above the prompt (terminal only): a master view
375
+ of the proposed skills and a detail view of the selected one side by side */
376
+ const render = (e: BandRender, ui: Elements["terminal"]): RenderElement | undefined => {
377
+ /* yield to a survey and draw nothing without an intent */
378
+ if (intent === undefined || e.props.hasSurvey)
379
+ return undefined
380
+ const current = intent
381
+ const { proposals } = current
382
+
383
+ /* determine the geometry of the band (with the master view in a fixed ratio)
384
+ and the lines of the detail view: the command and rationale of the
385
+ selected proposal (inside a box), and the name, title, and description
386
+ of its skill */
387
+ const { Text } = ui
388
+ const geometry = layout(e, MASTER)
389
+ const { span, body, master, detail } = geometry
390
+ const clip = (text: string, n: number) =>
391
+ text.length > n ? `${text.slice(0, n - 1)}…` : text
392
+ const proposal = current.state === "done" ? proposals[current.select] : undefined
393
+ const lines = proposal !== undefined ?
394
+ details(proposal, corpus?.get(proposal.skill)?.manual ?? "", detail) : undefined
395
+
396
+ /* keep the selected proposal in the master view */
397
+ current.top = Math.max(0, current.select - body + 1, Math.min(current.top, current.select))
398
+
399
+ /* determine the rows of the status message (as long as no proposals exist):
400
+ a box of half the columns, centered vertically and horizontally */
401
+ const hint: string[] = []
402
+ if (proposal === undefined) {
403
+ const size = Math.max(12, Math.floor(span / 2))
404
+ const text = current.state === "busy" ? `${SPINNER[current.spin]} ${current.message}` : current.message
405
+ const boxed = frame(wrap(uniform(text, false, undefined), size - 4, 0)
406
+ .slice(0, Math.max(1, body - 2)), size, undefined)
407
+ const pad = " ".repeat(Math.max(0, Math.floor((span - size) / 2)))
408
+ hint.push(...Array.from({ length: Math.max(0, Math.floor((body - boxed.length) / 2)) }, () => " "),
409
+ ...boxed.map((line) => pad + line.map((char) => char.ch).join("")))
410
+ }
411
+
412
+ /* draw the status message resp. both views (the master view with the
413
+ selected proposal in red, and the detail view), and the key hints
414
+ (the intent itself stays visible in the prompt) */
415
+ return panel(ui, geometry, current, {
416
+ title: TITLE,
417
+ hints: current.state === "done" ? "⌫/!!: cancel+close · ⏎: apply+close · SPACE: compose · ←/→: select · b/f: scroll" +
418
+ (geometry.overflow ? " · wheel: select/scroll" : "") + " · //: Browse Skill Manpages" :
419
+ "⌫/!!/any key: cancel+close · //: Browse Skill Manpages",
420
+ rows: (i) => {
421
+ if (proposal === undefined)
422
+ return Text({ dimColor: true, wrap: "truncate-end", children: hint[i] ?? " " })
423
+ const k = current.top + i
424
+ const p = proposals[k]
425
+ return p !== undefined ? Text({ color: k === current.select ? PICKED : undefined,
426
+ children: clip(`${k === current.select ? "▶" : " "} ${p.skill}`, master) }) : undefined
427
+ },
428
+ lines
429
+ })
430
+ }
431
+
432
+ return { active, reset, cancel, edit, open, scroll, submit, render }
433
+ }
434
+
@@ -0,0 +1,134 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+
7
+ /* Claude Code function hooks module ("mod", EARLY ACCESS):
8
+ Hint band, showing for a skill command (bare or with its options and
9
+ arguments already typed) the entire argument hint of the skill, wrapped
10
+ over multiple lines, in the band above the prompt (instead of the one-line
11
+ and hence usually truncated argument hint of the engine), together with
12
+ the hint on the manual band and the synopsis form, while the prompt starts
13
+ with the skill command followed by a blank (so the typeahead of the engine
14
+ is closed), until the prompt is submitted. */
15
+
16
+ import type { Elements, RenderElement } from "claude-code"
17
+ import { type Engine, type Char, type BandRender, type View, COMMAND,
18
+ wrap, uniform, layout, panel } from "./ase-mods-common.ts"
19
+
20
+ /* the skill command (followed by a blank) at the start of the prompt, the
21
+ argument hint in the frontmatter of a skill, its groups (the bracketed
22
+ resp. parenthesized options and arguments, with one level of nesting, and
23
+ the other words), the non-breaking space keeping a group on a single line,
24
+ the colors of its syntax characters and of its references, the indentation
25
+ of the continuation lines, and the title placed into the top edge of the band */
26
+ const SKILL = /^\/(?:ase:)?(ase-[a-z0-9]+-[a-z0-9]+)(?=\s)/
27
+ const HINT = /^argument-hint:\s*"(.*)"\s*$/m
28
+ const GROUP = /\[(?:[^[\]]|\[[^\]]*\])*\]|\((?:[^()]|\([^)]*\))*\)|\S+/g
29
+ const NBSP = String.fromCodePoint(0xA0)
30
+ const SYNTAX = "gray"
31
+ const REFER = "red"
32
+ const INDENT = 4
33
+ const TITLE = "[ ⧉ ASE: Show Skill Synopsis ]"
34
+
35
+ /* a hint band: the scroll state of its frame, the skill command as typed,
36
+ the argument hint of its skill, and the lines with the columns they were
37
+ wrapped to */
38
+ type Hint = View & {
39
+ command: string,
40
+ hint: string,
41
+ lines: Char[][],
42
+ columns: number
43
+ }
44
+
45
+ /* convert an argument hint into styled characters: the syntax characters
46
+ of its optional groups, plain groups, and alternatives in grey and its "<...>" references in red */
47
+ const tint = (text: string): Char[] => {
48
+ const chars: Char[] = []
49
+ let reference = false
50
+ for (const ch of text) {
51
+ if (ch === "<")
52
+ reference = true
53
+ chars.push({ ch, bold: false, color: reference ? REFER : "[()|]".includes(ch) ? SYNTAX : undefined })
54
+ if (ch === ">")
55
+ reference = false
56
+ }
57
+ return chars
58
+ }
59
+
60
+ /* the hint band: its state and its operations */
61
+ export const modHelpHint = () => {
62
+ /* the argument hints of the skills (loaded on first use, undefined
63
+ for an unknown skill), the currently shown band, and the generation
64
+ of the latest tracking resp. closing (discarding any older tracking) */
65
+ const hints = new Map<string, string | undefined>()
66
+ let shown: Hint | undefined
67
+ let generation = 0
68
+
69
+ /* close the hint band */
70
+ const reset = () => {
71
+ generation++
72
+ shown = undefined
73
+ }
74
+
75
+ /* determine the argument hint of a skill (loaded on first use) */
76
+ const synopsis = async (skill: string, engine: Pick<Engine, "read">): Promise<string | undefined> => {
77
+ if (!hints.has(skill))
78
+ hints.set(skill, await engine.read(`skills/${skill}/SKILL.md`)
79
+ .then((text) => HINT.exec(text)?.[1]).catch(() => undefined))
80
+ return hints.get(skill)
81
+ }
82
+
83
+ /* determine the lines of the hint band, wrapped to the given columns: the
84
+ skill command after the prompt marker and in the prompt color, followed by the tinted groups of the
85
+ argument hint, each kept on a single line (if it fits) */
86
+ const wrapped = (current: Hint, columns: number): Char[][] => {
87
+ if (current.columns !== columns) {
88
+ const groups = (current.hint.match(GROUP) ?? []).map((group) =>
89
+ group.length <= columns - INDENT ? group.replaceAll(" ", NBSP) : group)
90
+ current.lines = wrap([ ...uniform("❯ ", false, undefined), ...uniform(current.command, false, COMMAND),
91
+ ...tint(` ${groups.join(" ")}`) ], columns, INDENT)
92
+ current.columns = columns
93
+ }
94
+ return current.lines
95
+ }
96
+
97
+ /* track the prompt and show the hint band while the prompt starts with the
98
+ command of a skill with an argument hint and a blank, else close it
99
+ (and tell whether the band changed) */
100
+ const track = async (text: string, engine: Pick<Engine, "read">): Promise<boolean> => {
101
+ const prior = shown
102
+ const current = ++generation
103
+ const command = SKILL.exec(text)
104
+ if (command === null)
105
+ shown = undefined
106
+ else if (shown?.command !== command[0]) {
107
+ const hint = await synopsis(command[1]!, engine)
108
+ if (current !== generation)
109
+ return false
110
+ shown = hint !== undefined ? { command: command[0], hint, lines: [], columns: 0,
111
+ top: 0, scroll: 0, page: 1, split: 0 } : undefined
112
+ }
113
+ return shown !== prior
114
+ }
115
+
116
+ /* draw the argument hint into the band above the prompt (terminal only),
117
+ with the band exactly as tall as the hint (as it always fits, without
118
+ a scroll position), yielding to a survey */
119
+ const render = (e: BandRender, ui: Elements["terminal"]): RenderElement | undefined => {
120
+ if (shown === undefined || e.props.hasSurvey)
121
+ return undefined
122
+ const geometry = layout(e, 0)
123
+ const lines = wrapped(shown, geometry.span - 3)
124
+ return panel(ui, { ...geometry, body: lines.length, overflow: false }, shown, {
125
+ title: TITLE,
126
+ hints: "//: Show Skill Manpage · !!: Compose Skill Command · (keep typing the command)",
127
+ lines,
128
+ fit: true
129
+ })
130
+ }
131
+
132
+ return { reset, synopsis, track, render }
133
+ }
134
+