@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.
- package/apps/studio/acp/bridge.ts +18 -1
- package/apps/studio/client/app.jsx +7 -0
- package/apps/studio/client/panels/ChatPanel.jsx +46 -20
- package/apps/studio/client/panels/OnboardingWizard.jsx +35 -0
- package/apps/studio/client/panels/ReadinessList.jsx +189 -0
- package/apps/studio/client/styles/3-shell-maude.css +12 -0
- package/apps/studio/client/styles/6-acp-chat.css +50 -0
- package/apps/studio/dist/client.bundle.js +2838 -2521
- package/apps/studio/dist/styles.css +186 -0
- package/apps/studio/http.ts +15 -0
- package/apps/studio/readiness.ts +238 -0
- package/apps/studio/test/acp-bridge.test.ts +4 -0
- package/apps/studio/test/acp-origin-gate.test.ts +17 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +2 -1
- package/apps/studio/test/readiness.test.ts +154 -0
- package/apps/studio/whats-new.json +18 -0
- package/package.json +8 -8
|
@@ -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;
|
package/apps/studio/http.ts
CHANGED
|
@@ -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",
|