@1agh/maude 0.31.0 → 0.32.1

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.
@@ -8000,6 +8000,70 @@
8000
8000
  margin-top: 2px;
8001
8001
  }
8002
8002
 
8003
+ .ob-readiness {
8004
+ max-width: 560px;
8005
+ margin: var(--space-3) 0 0;
8006
+ border: 1px solid var(--border-subtle);
8007
+ border-radius: var(--radius-sm);
8008
+ background: var(--bg-1);
8009
+ text-align: left;
8010
+ }
8011
+
8012
+ .ob-readiness-sum {
8013
+ align-items: center;
8014
+ gap: var(--space-2);
8015
+ padding: var(--space-3);
8016
+ cursor: pointer;
8017
+ font-size: var(--type-sm);
8018
+ color: var(--fg-1);
8019
+ list-style: none;
8020
+ display: flex;
8021
+ }
8022
+
8023
+ .ob-readiness-sum::-webkit-details-marker {
8024
+ display: none;
8025
+ }
8026
+
8027
+ .ob-readiness-dot {
8028
+ border-radius: 50%;
8029
+ flex: none;
8030
+ width: 8px;
8031
+ height: 8px;
8032
+ }
8033
+
8034
+ .ob-readiness-dot--ok {
8035
+ background: var(--status-success);
8036
+ }
8037
+
8038
+ .ob-readiness-dot--warn {
8039
+ background: var(--status-warning, var(--status-info));
8040
+ }
8041
+
8042
+ .ob-readiness-lede {
8043
+ color: var(--fg-0);
8044
+ font-weight: 600;
8045
+ }
8046
+
8047
+ .ob-readiness-hint {
8048
+ color: var(--fg-3);
8049
+ font-size: var(--type-xs);
8050
+ margin-left: auto;
8051
+ }
8052
+
8053
+ .ob-readiness-body {
8054
+ padding: 0 var(--space-3) var(--space-3);
8055
+ gap: var(--space-3);
8056
+ flex-direction: column;
8057
+ display: flex;
8058
+ }
8059
+
8060
+ .ob-readiness-note {
8061
+ color: var(--fg-2);
8062
+ font-size: var(--type-xs);
8063
+ line-height: var(--lh-md);
8064
+ margin: 0;
8065
+ }
8066
+
8003
8067
  .ob-create {
8004
8068
  align-items: center;
8005
8069
  gap: var(--space-4);
@@ -12029,6 +12093,128 @@
12029
12093
  animation: none;
12030
12094
  }
12031
12095
  }
12096
+
12097
+ .rdy {
12098
+ gap: var(--space-3);
12099
+ flex-direction: column;
12100
+ display: flex;
12101
+ }
12102
+
12103
+ .rdy-list {
12104
+ gap: var(--space-2);
12105
+ flex-direction: column;
12106
+ margin: 0;
12107
+ padding: 0;
12108
+ list-style: none;
12109
+ display: flex;
12110
+ }
12111
+
12112
+ .rdy-row {
12113
+ gap: var(--space-2);
12114
+ padding: var(--space-2) var(--space-3);
12115
+ border: 1px solid var(--border-subtle);
12116
+ border-radius: var(--radius-sm);
12117
+ background: var(--bg-2);
12118
+ text-align: left;
12119
+ align-items: flex-start;
12120
+ display: flex;
12121
+ }
12122
+
12123
+ .rdy-ic {
12124
+ flex-shrink: 0;
12125
+ margin-top: 1px;
12126
+ }
12127
+
12128
+ .rdy-row--present .rdy-ic {
12129
+ color: var(--status-success);
12130
+ }
12131
+
12132
+ .rdy-row--missing .rdy-ic {
12133
+ color: var(--status-error);
12134
+ }
12135
+
12136
+ .rdy-row--unknown .rdy-ic {
12137
+ color: var(--status-info);
12138
+ }
12139
+
12140
+ .rdy-row--opt.rdy-row--missing .rdy-ic {
12141
+ color: var(--fg-3);
12142
+ }
12143
+
12144
+ .rdy-tx {
12145
+ flex-direction: column;
12146
+ gap: 2px;
12147
+ min-width: 0;
12148
+ display: flex;
12149
+ }
12150
+
12151
+ .rdy-label {
12152
+ font-size: var(--type-sm);
12153
+ color: var(--fg-0);
12154
+ font-weight: 600;
12155
+ }
12156
+
12157
+ .rdy-detail {
12158
+ font-size: var(--type-xs);
12159
+ color: var(--fg-2);
12160
+ line-height: var(--lh-xs);
12161
+ }
12162
+
12163
+ .rdy-fix {
12164
+ margin-top: var(--space-2);
12165
+ gap: var(--space-2);
12166
+ font-size: var(--type-xs);
12167
+ color: var(--fg-1);
12168
+ line-height: var(--lh-xs);
12169
+ align-items: flex-start;
12170
+ display: flex;
12171
+ }
12172
+
12173
+ .rdy-fix-tx {
12174
+ min-width: 0;
12175
+ }
12176
+
12177
+ .rdy code {
12178
+ font-family: var(--font-mono);
12179
+ font-size: var(--type-xs);
12180
+ color: var(--fg-0);
12181
+ background: var(--bg-3);
12182
+ border-radius: var(--radius-xs);
12183
+ overflow-wrap: anywhere;
12184
+ padding: 1px 5px;
12185
+ }
12186
+
12187
+ .rdy-copy {
12188
+ cursor: pointer;
12189
+ font-size: var(--type-xs);
12190
+ color: var(--fg-2);
12191
+ border: 1px solid var(--border-subtle);
12192
+ border-radius: var(--radius-xs);
12193
+ background: none;
12194
+ flex-shrink: 0;
12195
+ padding: 1px 6px;
12196
+ }
12197
+
12198
+ .rdy-copy:hover {
12199
+ color: var(--fg-0);
12200
+ background: var(--bg-3);
12201
+ }
12202
+
12203
+ .rdy-foot {
12204
+ justify-content: flex-end;
12205
+ display: flex;
12206
+ }
12207
+
12208
+ .rdy-modal {
12209
+ max-width: 520px;
12210
+ }
12211
+
12212
+ .rdy-modal-note {
12213
+ margin: 0 0 var(--space-3);
12214
+ color: var(--fg-2);
12215
+ font-size: var(--type-sm);
12216
+ line-height: var(--lh-md);
12217
+ }
12032
12218
  }
12033
12219
 
12034
12220
  @layer utilities;
@@ -24,6 +24,7 @@ import { createGitHubEndpoints } from './github/endpoints.ts';
24
24
  import type { Inspect } from './inspect.ts';
25
25
  import { canvasSlug, writeLocator } from './locator.ts';
26
26
  import { DEV_SERVER_ROOT } from './paths.ts';
27
+ import { probeReadiness } from './readiness.ts';
27
28
  import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
28
29
  import { linkHub } from './sync/hub-link.ts';
29
30
  import { loadWhatsNew } from './whats-new.ts';
@@ -570,6 +571,20 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
570
571
  '/_api/acp/status': () =>
571
572
  Response.json(probeAcpAvailability(), { headers: { 'Cache-Control': 'no-store' } }),
572
573
 
574
+ // DDR-128 — first-open AI-editing readiness. Read-only probe of the AI-editing
575
+ // dependency chain (claude CLI · maude CLI · maude marketplace + plugins in the
576
+ // paired Claude Code · optional agent-browser) with per-item remediation; it
577
+ // installs/mutates nothing. MAIN-ORIGIN ONLY — absent from CANVAS_SAFE_API +
578
+ // startCanvasServer routes, so the untrusted canvas iframe is 403'd. The probe
579
+ // can shell out (login-shell fallback), so a cross-origin Origin-reject also
580
+ // fronts it — a drive-by page can't fire the loopback endpoint to spawn-storm
581
+ // the dev-server (DDR-128 hardening). The onboarding readiness card + the
582
+ // ChatPanel not-connected explainer + the Help-menu modal read this.
583
+ '/_api/preflight': async (req: Request) => {
584
+ if (!sameOriginWrite(req)) return new Response('cross-origin rejected', { status: 403 });
585
+ return Response.json(await probeReadiness(), { headers: { 'Cache-Control': 'no-store' } });
586
+ },
587
+
573
588
  // Phase 31 (DDR-123) — `/design:chat` focus hook. `maude design chat-open`
574
589
  // POSTs here; we emit a bus event the shell turns into "open the Assistant
575
590
  // panel" (app.jsx, native-only). MAIN-ORIGIN ONLY (off the canvas allowlist).
@@ -0,0 +1,238 @@
1
+ // First-open AI-editing readiness probe (DDR-128). Backs `GET /_api/preflight`.
2
+ //
3
+ // Read-only: it reports which pieces of the AI-editing chain are present — the
4
+ // `claude` CLI, the `maude` CLI, the maude marketplace + plugins registered in the
5
+ // paired Claude Code, and the optional `agent-browser` — with per-item remediation.
6
+ // It NEVER installs, links, or mutates anything (DDR-128 detect-and-guide posture).
7
+ //
8
+ // PATH accuracy: in the packaged `.app` the sidecar's PATH is corrected at the Rust
9
+ // boundary (apps/desktop/.../sidecar.rs, DDR-128), so `Bun.which` is accurate here;
10
+ // `resolveOnPath` keeps a login-shell fallback as defense-in-depth for an unusual
11
+ // shell config the Rust resolution missed. Under `maude design serve` the terminal
12
+ // PATH is already correct.
13
+
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ import { resolveAdapterEntry, resolveClaudePath } from './acp/probe.ts';
19
+
20
+ export type ReadinessStatus = 'present' | 'missing' | 'unknown';
21
+
22
+ export interface ReadinessItem {
23
+ id: 'claude' | 'maude' | 'plugins' | 'agent-browser' | 'adapter';
24
+ /** Short human label for the row. */
25
+ label: string;
26
+ /** Required items gate `ready`; optional ones never block it. */
27
+ required: boolean;
28
+ status: ReadinessStatus;
29
+ /** One-line current-state description. */
30
+ detail: string;
31
+ /** Copy-paste-able fix, present only when the item is not satisfied. */
32
+ remediation?: string;
33
+ }
34
+
35
+ export interface ReadinessReport {
36
+ /** True when every REQUIRED item is `present`. */
37
+ ready: boolean;
38
+ items: ReadinessItem[];
39
+ }
40
+
41
+ /**
42
+ * Resolve a binary on PATH. `Bun.which` first (accurate once the sidecar PATH is
43
+ * Rust-corrected, and always correct under a terminal launch); a login-shell
44
+ * fallback recovers a binary the app env can't see when the Rust resolution missed.
45
+ * Unix-only fallback (Windows GUI apps inherit the user PATH). Returns an absolute
46
+ * path or null. `bin` is always a hardcoded literal — never user input.
47
+ *
48
+ * Async + `Bun.spawn` (not `spawnSync`): the fallback shells out for up to 5 s, so a
49
+ * synchronous spawn would block the single-threaded dev-server event loop for the
50
+ * whole probe — a real freeze on a fresh machine where the binary is missing
51
+ * (DDR-128 hardening, ethical-hacker F1). Awaiting yields the loop; the 5 s kill
52
+ * bounds a misconfigured rc.
53
+ */
54
+ export async function resolveOnPath(bin: string): Promise<string | null> {
55
+ const direct = Bun.which(bin);
56
+ if (direct) return direct;
57
+ if (process.platform === 'win32') return null;
58
+ try {
59
+ const shell = process.env.SHELL || '/bin/sh';
60
+ const proc = Bun.spawn([shell, '-ilc', `command -v ${bin} 2>/dev/null`], {
61
+ stdin: 'ignore',
62
+ stdout: 'pipe',
63
+ stderr: 'ignore',
64
+ });
65
+ const killer = setTimeout(() => {
66
+ try {
67
+ proc.kill(9);
68
+ } catch {
69
+ /* already gone */
70
+ }
71
+ }, 5000);
72
+ let out: string;
73
+ try {
74
+ out = await new Response(proc.stdout).text();
75
+ } finally {
76
+ clearTimeout(killer);
77
+ }
78
+ // Instant-prompt frameworks (powerlevel10k) can print to stdout on interactive
79
+ // start, so take the last line that is an absolute path to a real file.
80
+ const hit = out
81
+ .split('\n')
82
+ .map((l) => l.trim())
83
+ .reverse()
84
+ .find((l) => l.startsWith('/') && existsSync(l));
85
+ return hit ?? null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /** Claude Code's config dir — relocatable via `CLAUDE_CONFIG_DIR` (its own contract). */
92
+ function claudeConfigDir(): string {
93
+ return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
94
+ }
95
+
96
+ function readJson<T = unknown>(path: string): T | null {
97
+ try {
98
+ return JSON.parse(readFileSync(path, 'utf8')) as T;
99
+ } catch {
100
+ return null;
101
+ }
102
+ }
103
+
104
+ interface PluginScan {
105
+ /** 'unknown' when the registry couldn't be read (Claude Code's internal contract). */
106
+ status: 'present' | 'unknown';
107
+ marketplace: boolean;
108
+ design: boolean;
109
+ flow: boolean;
110
+ }
111
+
112
+ /**
113
+ * Read-only scan of Claude Code's plugin registry for the maude marketplace
114
+ * (`repo: 1aGh/maude`) and the `design@maude` / `flow@maude` plugins. `readFileSync`
115
+ * follows symlinks (a dev's `~/.claude` is symlinked into Dotfiles). Never writes,
116
+ * never throws — an unrecognized layout yields `status: 'unknown'`.
117
+ */
118
+ function scanPlugins(): PluginScan {
119
+ const dir = claudeConfigDir();
120
+ const markets = readJson<Record<string, { source?: { repo?: string } }>>(
121
+ join(dir, 'plugins', 'known_marketplaces.json')
122
+ );
123
+ const installed = readJson<{ plugins?: Record<string, unknown> }>(
124
+ join(dir, 'plugins', 'installed_plugins.json')
125
+ );
126
+ if (!markets && !installed) {
127
+ return { status: 'unknown', marketplace: false, design: false, flow: false };
128
+ }
129
+ const marketplace =
130
+ !!markets &&
131
+ Object.values(markets).some(
132
+ (m) => String(m?.source?.repo ?? '').toLowerCase() === '1agh/maude'
133
+ );
134
+ const plugins = installed?.plugins ?? {};
135
+ const has = (key: string): boolean => {
136
+ const v = plugins[key];
137
+ return Array.isArray(v) ? v.length > 0 : !!v;
138
+ };
139
+ return { status: 'present', marketplace, design: has('design@maude'), flow: has('flow@maude') };
140
+ }
141
+
142
+ /**
143
+ * Side-effect-free readiness report. The three binary probes run concurrently so a
144
+ * fresh machine (where the login-shell fallback fires) resolves in ~one shell round
145
+ * trip, not three sequential ones. The backing route gates cross-origin callers
146
+ * (DDR-128 hardening) so this can't be turned into a spawn-storm from a drive-by page.
147
+ */
148
+ export async function probeReadiness(): Promise<ReadinessReport> {
149
+ const [claude, maude, agentBrowser] = await Promise.all([
150
+ (async () => resolveClaudePath() ?? (await resolveOnPath('claude')))(),
151
+ resolveOnPath('maude'),
152
+ resolveOnPath('agent-browser'),
153
+ ]);
154
+
155
+ const items: ReadinessItem[] = [];
156
+
157
+ items.push({
158
+ id: 'claude',
159
+ label: 'Claude Code (the `claude` CLI)',
160
+ required: true,
161
+ status: claude ? 'present' : 'missing',
162
+ detail: claude
163
+ ? 'Installed — AI editing drives it on your own Pro/Max subscription.'
164
+ : 'Not found on PATH.',
165
+ remediation: claude
166
+ ? undefined
167
+ : 'Install Claude Code, then run `claude` and `/login` once. AI editing runs on your own Pro/Max subscription — never API billing.',
168
+ });
169
+
170
+ items.push({
171
+ id: 'maude',
172
+ label: 'maude CLI',
173
+ required: true,
174
+ status: maude ? 'present' : 'missing',
175
+ detail: maude ? 'On PATH — `/design:edit` can reach its helpers.' : 'Not found on PATH.',
176
+ remediation: maude
177
+ ? undefined
178
+ : 'Install it: `npm i -g @1agh/maude`. `/design:edit` shells out to `maude design …`, so it must be on PATH.',
179
+ });
180
+
181
+ const scan = scanPlugins();
182
+ const pluginsPresent = scan.design && scan.flow;
183
+ const pluginStatus: ReadinessStatus =
184
+ scan.status === 'unknown' ? 'unknown' : pluginsPresent ? 'present' : 'missing';
185
+ const missing = [!scan.design && 'design@maude', !scan.flow && 'flow@maude']
186
+ .filter(Boolean)
187
+ .join(' + ');
188
+ items.push({
189
+ id: 'plugins',
190
+ label: 'Maude plugins in Claude Code',
191
+ required: true,
192
+ status: pluginStatus,
193
+ detail:
194
+ scan.status === 'unknown'
195
+ ? "Couldn't read Claude Code's plugin registry — check it manually."
196
+ : pluginsPresent
197
+ ? 'design@maude + flow@maude are installed.'
198
+ : `Missing: ${missing}${scan.marketplace ? '' : ' (and the maude marketplace)'}.`,
199
+ remediation:
200
+ pluginStatus === 'present'
201
+ ? undefined
202
+ : 'In Claude Code: `/plugin marketplace add 1aGh/maude`, then `/plugin install design@maude` and `/plugin install flow@maude`.',
203
+ });
204
+
205
+ items.push({
206
+ id: 'agent-browser',
207
+ label: 'agent-browser (optional)',
208
+ required: false,
209
+ status: agentBrowser ? 'present' : 'missing',
210
+ detail: agentBrowser
211
+ ? 'Installed — screenshot evidence during edits.'
212
+ : 'Optional — richer screenshot evidence during edits.',
213
+ remediation: agentBrowser
214
+ ? undefined
215
+ : 'Optional. Install `agent-browser` for screenshot evidence during `/design:edit`.',
216
+ });
217
+
218
+ // The chat bridge itself: the adapter must be resolvable on disk. This is what
219
+ // actually gates the chat panel (probeAcpAvailability → resolveAdapterEntry),
220
+ // distinct from the user-environment checks above. Shown so a build that ships
221
+ // without the staged adapter closure surfaces here instead of leaving every
222
+ // other row green while chat reports "bridge is not installed" (the v0.31–0.32
223
+ // desktop bug; see apps/desktop/scripts/stage-resources.mjs).
224
+ const adapter = resolveAdapterEntry();
225
+ items.push({
226
+ id: 'adapter',
227
+ label: 'Claude agent bridge',
228
+ required: true,
229
+ status: adapter ? 'present' : 'missing',
230
+ detail: adapter ? 'Bundled — the chat panel can spawn it.' : 'Not bundled in this build.',
231
+ remediation: adapter
232
+ ? undefined
233
+ : 'The Claude agent bridge is missing from this build — reinstall or update Maude. If you built it yourself, ensure the desktop staging step bundled `@agentclientprotocol/claude-agent-acp`.',
234
+ });
235
+
236
+ const ready = items.filter((i) => i.required).every((i) => i.status === 'present');
237
+ return { ready, items };
238
+ }
@@ -41,6 +41,10 @@ describe('AcpBridge — round-trip + subscription guardrail', () => {
41
41
  const streamed = JSON.stringify(updates);
42
42
  expect(streamed).toContain('apiKey=<unset>');
43
43
  expect(streamed).not.toContain('sk-must-be-scrubbed');
44
+ // DDR-123 guardrail #2 — the bridge pins the adapter to the user's own
45
+ // `claude` (here MAUDE_CLAUDE_BIN = this bun) via CLAUDE_CODE_EXECUTABLE, so
46
+ // it never falls back to the unshipped ~210 MB native binary.
47
+ expect(streamed).toContain(`claudeExe=${process.execPath}`);
44
48
  } finally {
45
49
  await bridge.stop();
46
50
  }
@@ -61,6 +61,23 @@ describe('ACP bridge origin gate', () => {
61
61
  expect((await focusPost.json()).ok).toBe(true);
62
62
  expect(await status(main, '/_api/acp/focus')).toBe(405); // GET not allowed
63
63
  expect(await status(canvas, '/_api/acp/focus')).toBe(403); // off the canvas origin
64
+
65
+ // (5) DDR-128 — /_api/preflight readiness probe: main-origin 200 with the
66
+ // {ready, items[]} shape; 403 on the untrusted canvas origin (dual-allowlist).
67
+ const pre = await fetch(`${main}/_api/preflight`, { signal: AbortSignal.timeout(2000) });
68
+ expect(pre.status).toBe(200);
69
+ const preJson = await pre.json();
70
+ expect(typeof preJson.ready).toBe('boolean');
71
+ expect(Array.isArray(preJson.items)).toBe(true);
72
+ expect(await status(canvas, '/_api/preflight')).toBe(403);
73
+ // A cross-origin drive-by GET is Origin-rejected before the probe can shell
74
+ // out — DDR-128 DoS hardening (ethical-hacker F1). Same-origin/loopback (no
75
+ // Origin header, asserted above) stays 200.
76
+ const crossOrigin = await fetch(`${main}/_api/preflight`, {
77
+ headers: { Origin: 'https://evil.example' },
78
+ signal: AbortSignal.timeout(2000),
79
+ });
80
+ expect(crossOrigin.status).toBe(403);
64
81
  } finally {
65
82
  await killProc(proc);
66
83
  }
@@ -36,7 +36,8 @@ acp
36
36
  text:
37
37
  `apiKey=${process.env.ANTHROPIC_API_KEY ?? '<unset>'} ` +
38
38
  `model=${process.env.ANTHROPIC_MODEL ?? '<unset>'} ` +
39
- `thinking=${process.env.MAX_THINKING_TOKENS ?? '<unset>'}`,
39
+ `thinking=${process.env.MAX_THINKING_TOKENS ?? '<unset>'} ` +
40
+ `claudeExe=${process.env.CLAUDE_CODE_EXECUTABLE ?? '<unset>'}`,
40
41
  },
41
42
  },
42
43
  });
@@ -0,0 +1,154 @@
1
+ // Readiness probe (DDR-128) — plugin-registry scan + PATH resolution. The
2
+ // login-shell fallback's full correctness is proven on the packaged `.app`
3
+ // (native-verification ceiling); here we cover the registry-scan branches, the
4
+ // no-throw contracts, and the report shape. The probe is async (the fallback shells
5
+ // out off the event loop — DDR-128 hardening), so everything awaits.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import { probeReadiness, resolveOnPath } from '../readiness.ts';
13
+
14
+ const MARKET_OK = {
15
+ maude: { source: { source: 'github', repo: '1aGh/maude' }, installLocation: '/x' },
16
+ };
17
+ const INSTALLED_BOTH = {
18
+ version: 2,
19
+ plugins: {
20
+ 'design@maude': [{ scope: 'user', version: '0.29.0' }],
21
+ 'flow@maude': [{ scope: 'user', version: '0.29.0' }],
22
+ },
23
+ };
24
+ const INSTALLED_DESIGN_ONLY = { version: 2, plugins: { 'design@maude': [{ scope: 'user' }] } };
25
+
26
+ function fixtureClaudeDir(opts: { markets?: unknown; installed?: unknown }): string {
27
+ const dir = mkdtempSync(join(tmpdir(), 'maude-readiness-'));
28
+ const pluginsDir = join(dir, 'plugins');
29
+ mkdirSync(pluginsDir, { recursive: true });
30
+ if (opts.markets !== undefined)
31
+ writeFileSync(join(pluginsDir, 'known_marketplaces.json'), JSON.stringify(opts.markets));
32
+ if (opts.installed !== undefined)
33
+ writeFileSync(join(pluginsDir, 'installed_plugins.json'), JSON.stringify(opts.installed));
34
+ return dir;
35
+ }
36
+
37
+ async function withClaudeDir<T>(dir: string, fn: () => Promise<T> | T): Promise<T> {
38
+ const saved = process.env.CLAUDE_CONFIG_DIR;
39
+ process.env.CLAUDE_CONFIG_DIR = dir;
40
+ try {
41
+ return await fn();
42
+ } finally {
43
+ if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR;
44
+ else process.env.CLAUDE_CONFIG_DIR = saved;
45
+ }
46
+ }
47
+
48
+ const pluginsItem = async (dir: string) =>
49
+ (await withClaudeDir(dir, () => probeReadiness())).items.find((i) => i.id === 'plugins')!;
50
+
51
+ describe('readiness — plugin registry scan', () => {
52
+ test('both plugins + marketplace present → plugins item is present, no remediation', async () => {
53
+ const item = await pluginsItem(
54
+ fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_BOTH })
55
+ );
56
+ expect(item.status).toBe('present');
57
+ expect(item.remediation).toBeUndefined();
58
+ });
59
+
60
+ test('only design@maude installed → missing, names the absent flow@maude + offers the fix', async () => {
61
+ const item = await pluginsItem(
62
+ fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_DESIGN_ONLY })
63
+ );
64
+ expect(item.status).toBe('missing');
65
+ expect(item.detail).toContain('flow@maude');
66
+ expect(item.remediation).toContain('/plugin install');
67
+ });
68
+
69
+ test('registry absent → unknown (Claude Code internal contract; never throws)', async () => {
70
+ const item = await pluginsItem(fixtureClaudeDir({}));
71
+ expect(item.status).toBe('unknown');
72
+ });
73
+
74
+ test('plugin presence is the gate — a foreign-repo marketplace does not change a both-installed verdict', async () => {
75
+ const item = await pluginsItem(
76
+ fixtureClaudeDir({
77
+ markets: { other: { source: { repo: 'someone/else' } } },
78
+ installed: INSTALLED_BOTH,
79
+ })
80
+ );
81
+ expect(item.status).toBe('present');
82
+ });
83
+ });
84
+
85
+ describe('readiness — resolveOnPath', () => {
86
+ test('finds a binary that is on PATH', async () => {
87
+ const hit = await resolveOnPath('sh');
88
+ expect(hit).not.toBeNull();
89
+ expect(existsSync(hit!)).toBe(true);
90
+ });
91
+
92
+ test('returns null for a bogus binary without throwing', async () => {
93
+ expect(await resolveOnPath('maude-definitely-not-real-xyz')).toBeNull();
94
+ });
95
+
96
+ test('login-shell fallback recovers a binary hidden from the app PATH', async () => {
97
+ const saved = process.env.PATH;
98
+ try {
99
+ // Simulate the truncated/empty GUI env so Bun.which misses and the fallback fires.
100
+ process.env.PATH = '';
101
+ const hit = await resolveOnPath('sh');
102
+ // When a login shell is usable it must recover an absolute, real path. In a
103
+ // sandbox with no usable login shell the fallback returns null — we don't fail
104
+ // on that, but a non-null result must be a real absolute path.
105
+ if (hit !== null) {
106
+ expect(hit.startsWith('/')).toBe(true);
107
+ expect(existsSync(hit)).toBe(true);
108
+ }
109
+ } finally {
110
+ if (saved === undefined) delete process.env.PATH;
111
+ else process.env.PATH = saved;
112
+ }
113
+ });
114
+ });
115
+
116
+ describe('readiness — report shape', () => {
117
+ test('always returns the five items with stable ids and a boolean ready', async () => {
118
+ const report = await probeReadiness();
119
+ expect(report.items.map((i) => i.id)).toEqual([
120
+ 'claude',
121
+ 'maude',
122
+ 'plugins',
123
+ 'agent-browser',
124
+ 'adapter',
125
+ ]);
126
+ expect(typeof report.ready).toBe('boolean');
127
+ });
128
+
129
+ test('agent-browser is optional and never gates ready', async () => {
130
+ const ab = (await probeReadiness()).items.find((i) => i.id === 'agent-browser')!;
131
+ expect(ab.required).toBe(false);
132
+ });
133
+
134
+ test('adapter row tracks the real chat gate (resolveAdapterEntry) and is required', async () => {
135
+ const saved = process.env.MAUDE_ACP_ADAPTER_ENTRY;
136
+ try {
137
+ // Point the override at a real file → the bridge is "bundled".
138
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = import.meta.path;
139
+ const present = (await probeReadiness()).items.find((i) => i.id === 'adapter')!;
140
+ expect(present.required).toBe(true);
141
+ expect(present.status).toBe('present');
142
+
143
+ // Point it at a missing path → the row goes missing with remediation, the
144
+ // honest signal the v0.31–0.32 builds lacked (all other rows green, chat dead).
145
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = join(tmpdir(), 'no-such-acp-adapter-xyz');
146
+ const missing = (await probeReadiness()).items.find((i) => i.id === 'adapter')!;
147
+ expect(missing.status).toBe('missing');
148
+ expect(missing.remediation).toBeDefined();
149
+ } finally {
150
+ if (saved === undefined) delete process.env.MAUDE_ACP_ADAPTER_ENTRY;
151
+ else process.env.MAUDE_ACP_ADAPTER_ENTRY = saved;
152
+ }
153
+ });
154
+ });
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "desktop-ai-chat-fix",
6
+ "version": "0.32.1",
7
+ "date": "2026-06-24",
8
+ "kind": "fix",
9
+ "title": "AI chat now opens in the desktop app",
10
+ "summary": "If the Assistant panel said AI editing wasn't ready even when Claude Code and the plugins were all installed, that's fixed. The desktop app now includes everything the chat needs and runs on your own installed Claude — open the Assistant (the ✦ in the menubar, or ⌘⇧A) and it just works.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "ai-editing-readiness-check",
15
+ "version": "0.32.0",
16
+ "date": "2026-06-24",
17
+ "kind": "feature",
18
+ "title": "See what AI editing needs",
19
+ "summary": "The welcome screen and the Assistant panel now show whether AI editing is ready — and if Claude Code, the maude CLI, or the Maude plugins are missing, they hand you the exact step to fix it. Check it any time under Help → Check AI editing readiness.",
20
+ "surface": "design-ui"
21
+ },
4
22
  {
5
23
  "id": "native-desktop-app",
6
24
  "version": "0.31.0",