@1agh/maude 0.31.0 → 0.32.0

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,220 @@
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 { 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';
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
+ const ready = items.filter((i) => i.required).every((i) => i.status === 'present');
219
+ return { ready, items };
220
+ }
@@ -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
  }
@@ -0,0 +1,127 @@
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 four items with stable ids and a boolean ready', async () => {
118
+ const report = await probeReadiness();
119
+ expect(report.items.map((i) => i.id)).toEqual(['claude', 'maude', 'plugins', 'agent-browser']);
120
+ expect(typeof report.ready).toBe('boolean');
121
+ });
122
+
123
+ test('agent-browser is optional and never gates ready', async () => {
124
+ const ab = (await probeReadiness()).items.find((i) => i.id === 'agent-browser')!;
125
+ expect(ab.required).toBe(false);
126
+ });
127
+ });
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "ai-editing-readiness-check",
6
+ "version": "0.32.0",
7
+ "date": "2026-06-24",
8
+ "kind": "feature",
9
+ "title": "See what AI editing needs",
10
+ "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.",
11
+ "surface": "design-ui"
12
+ },
4
13
  {
5
14
  "id": "native-desktop-app",
6
15
  "version": "0.31.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -43,13 +43,13 @@
43
43
  "prepublishOnly": "bash scripts/check-version-parity.sh && bash apps/studio/bin/check-runtime-bundles.sh"
44
44
  },
45
45
  "optionalDependencies": {
46
- "@1agh/maude-darwin-arm64": "0.31.0",
47
- "@1agh/maude-darwin-x64": "0.31.0",
48
- "@1agh/maude-linux-arm64": "0.31.0",
49
- "@1agh/maude-linux-arm64-musl": "0.31.0",
50
- "@1agh/maude-linux-x64": "0.31.0",
51
- "@1agh/maude-linux-x64-musl": "0.31.0",
52
- "@1agh/maude-win32-x64": "0.31.0"
46
+ "@1agh/maude-darwin-arm64": "0.32.0",
47
+ "@1agh/maude-darwin-x64": "0.32.0",
48
+ "@1agh/maude-linux-arm64": "0.32.0",
49
+ "@1agh/maude-linux-arm64-musl": "0.32.0",
50
+ "@1agh/maude-linux-x64": "0.32.0",
51
+ "@1agh/maude-linux-x64-musl": "0.32.0",
52
+ "@1agh/maude-win32-x64": "0.32.0"
53
53
  },
54
54
  "files": [
55
55
  "cli",