SLOPSHOPPER

al-status

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

newguardstatustimer
v0.1.0no licenseupdated 2026-10-15NathapolDev/al-status-line
A shopper browsing a rack in a slop shop
README

al-status-line

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
  • Environment: first "request": "launch" config in .vscode/launch.json
  • Version: version in app.json
  • Compile: ✓ 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)
  • Last publish: a successful al_publish, or .vscode/rad.json emptied by a VS Code publish

Install

At 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.

Source 2 files
hooks/register.ts 78 lines
1import 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}
78
hooks/parse.ts 65 lines
1// 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