Status line for AL projects: publish environment, app version, last compile errors

AL Status for Claude Code mods
A Claude Code status line for Business Central AL projects:
☁️ sandbox · v1.0.0.0 · ✓ · Last publish ⬆ 15/10 17:21
"request": "launch" config in .vscode/launch.jsonversion in app.json✓ or ✗ n errors from the last al_compile / al_build / alc run, a newer .app built in VS Code, or live VS Code diagnostics (when mcp__ide__getDiagnostics is already allowed)al_publish, or .vscode/rad.json emptied by a VS Code publishAt the prompt of a terminal session:
/plugin install al-status --marketplace NathapolDev/al-status-line
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
From a shell:
claude plugin install al-status --marketplace NathapolDev/al-status-line
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with NathapolDev/al-status-line, then Add plugin.
hooks/register.ts 78 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { countErrors, isRadEmpty, parseJsonc, publishEnv, publishSucceeded, statusText } from './parse'
4
5const COMPILE_TOOLS = new Set(['mcp__al__al_compile', 'mcp__al__al_build'])
6const SHELL_COMPILE = /\balc(\.exe)?\b|compile\.ps1/i
7const IDE_DIAGNOSTICS = 'mcp__ide__getDiagnostics'
8
9async function setCompile($: EngineInterface, root: string, errors: number, at: number) {
10 await $.store.set(`errors:${root}`, errors)
11 await $.store.set(`compiledAt:${root}`, at)
12}
13
14// Picks up builds/publishes done in VS Code from the files the AL extension writes.
15async function syncFromVsCode($: EngineInterface, root: string) {
16 // A new .app = a successful build or publish (VS Code or al_build).
17 const apps = (await $.fs.list(root)).filter(f => f.kind === 'file' && f.name.endsWith('.app'))
18 const newestApp = Math.max(0, ...apps.map(f => f.mtimeMs))
19 const compiledAt = Number((await $.store.get(`compiledAt:${root}`)) ?? 0)
20 if (newestApp > compiledAt) await setCompile($, root, 0, newestApp)
21
22 // ponytail: an emptied rad.json = publish; a build with no changes since the last publish also counts
23 const rad = `${root}/.vscode/rad.json`
24 if (await $.fs.exists(rad)) {
25 const { mtimeMs } = await $.fs.stat(rad)
26 const publishedAt = Number((await $.store.get(`published:${root}`)) ?? 0)
27 if (mtimeMs > publishedAt && isRadEmpty(await $.fs.read(rad))) await $.store.set(`published:${root}`, mtimeMs)
28 }
29
30 // Live errors from the VS Code AL language server, only when the IDE tool is already allowed (no prompts).
31 const verdict = await $.tool.check({ tool: IDE_DIAGNOSTICS, input: {} }).catch(() => undefined)
32 if (verdict?.decision === 'allow') {
33 const found = await $.tool.call({ tool: IDE_DIAGNOSTICS }).catch(() => undefined)
34 if (found && found.deny === undefined && !found.isError) await setCompile($, root, countErrors(found.result), await $.clock.now())
35 }
36}
37
38async function refresh($: EngineInterface, root: string) {
39 if (!(await $.fs.exists(`${root}/app.json`))) return $.ui.status(undefined)
40 await syncFromVsCode($, root).catch(() => undefined)
41 const version = await $.fs.read(`${root}/app.json`).then(t => parseJsonc(t).version, () => undefined)
42 const env = await $.fs.read(`${root}/.vscode/launch.json`).then(t => publishEnv(parseJsonc(t)), () => undefined)
43 const errors = (await $.store.get(`errors:${root}`)) as number | undefined
44 const publishedAt = (await $.store.get(`published:${root}`)) as number | undefined
45 $.ui.status(statusText(env, version, errors, publishedAt))
46}
47
48export const register: Register = on => {
49 on('session.start', async ($, e, next) => {
50 const root = await $.session.root()
51 await refresh($, root)
52 // ponytail: 15s poll catches VS Code builds/publishes and app.json/launch.json edits; no file-watch event needed
53 $.clock.every(15000, () => void refresh($, root))
54 return next(e)
55 })
56
57 on('tool.call', async ($, e, next) => {
58 const ran = await next(e)
59 if (e.tool === IDE_DIAGNOSTICS || ran.deny !== undefined) return ran
60 const command = (e as { command?: unknown }).command
61 const isCompile = COMPILE_TOOLS.has(e.tool)
62 || ((e.tool === 'Bash' || e.tool === 'PowerShell') && typeof command === 'string' && SHELL_COMPILE.test(command))
63 if (isCompile) {
64 const root = await $.session.root()
65 const errors = countErrors(ran.result)
66 // a failed compile with no parsable diagnostics still must not show ✓
67 await setCompile($, root, ran.isError && errors === 0 ? 1 : errors, await $.clock.now())
68 await refresh($, root)
69 }
70 if (e.tool === 'mcp__al__al_publish' && !ran.isError && publishSucceeded(ran.result)) {
71 const root = await $.session.root()
72 await $.store.set(`published:${root}`, await $.clock.now())
73 await refresh($, root)
74 }
75 return ran
76 }).catch(($, e, next) => next(e)) // a status failure must never block the tool
77}
78hooks/parse.ts 65 lines1// launch.json is JSONC: drop comments (outside strings) and trailing commas.
2export function parseJsonc(text: string): any {
3 let out = ''
4 text = text.replace(/^/, '')
5 for (let i = 0; i < text.length; i++) {
6 const c = text[i]
7 if (c === '"') {
8 const start = i
9 for (i++; i < text.length && text[i] !== '"'; i++) if (text[i] === '\\') i++
10 out += text.slice(start, i + 1)
11 } else if (c === '/' && text[i + 1] === '/') {
12 while (i < text.length && text[i] !== '\n') i++
13 out += '\n'
14 } else if (c === '/' && text[i + 1] === '*') {
15 i = text.indexOf('*/', i + 2)
16 if (i < 0) break
17 i++
18 } else out += c
19 }
20 return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'))
21}
22
23// Publish target = first "launch" config (snapshotInitialize/attach can't publish).
24export function publishEnv(launch: any): string | undefined {
25 const cfg = (launch?.configurations ?? []).find((c: any) => c?.request === 'launch')
26 return cfg ? cfg.environmentName ?? cfg.serverInstance ?? cfg.name : undefined
27}
28
29// Errors in a compile result: MCP diagnostics ("Severity":"Error", possibly JSON-in-text) or alc lines ("error AL0118").
30export function countErrors(result: unknown): number {
31 const s = typeof result === 'string' ? result : JSON.stringify(result ?? '')
32 const mcp = s.match(/\\*"severity\\*"\s*:\s*\\*"error/gi)?.length ?? 0
33 if (mcp > 0) return mcp
34 return new Set(s.match(/[^\s"\\]*: error AL\d{4}[^\n"\\]*/gi) ?? []).size
35}
36
37// MCP publish reports failure in its payload, not always as a tool error.
38export function publishSucceeded(result: unknown): boolean {
39 const s = typeof result === 'string' ? result : JSON.stringify(result ?? '')
40 return !/\\*"succe(ss|eded)\\*"\s*:\s*false/i.test(s)
41}
42
43// The AL extension empties .vscode/rad.json after a successful publish (RAD docs).
44export function isRadEmpty(text: string): boolean {
45 try {
46 const rad = parseJsonc(text)
47 return ['Added', 'Modified', 'Removed'].every(k => Array.isArray(rad[k]) && rad[k].length === 0)
48 } catch {
49 return false
50 }
51}
52
53const pad = (n: number) => String(n).padStart(2, '0')
54
55export function formatTime(ms: number): string {
56 const d = new Date(ms)
57 return `${pad(d.getDate())}/${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`
58}
59
60export function statusText(env: string | undefined, version: string | undefined, errors: number | undefined, publishedAt?: number): string {
61 const build = errors === undefined ? '? not compiled' : errors === 0 ? '✓' : `✗ ${errors} error${errors > 1 ? 's' : ''}`
62 const published = publishedAt === undefined ? 'never' : formatTime(publishedAt)
63 return `☁️ ${env ?? '-'} · v${version ?? '-'} · ${build} · Last publish ⬆ ${published}`
64}
65