foundry-design 0.2.0-beta.2 → 0.2.0-beta.21

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.
Files changed (48) hide show
  1. package/README.md +164 -17
  2. package/dist/companion.d.ts +24 -0
  3. package/dist/companion.d.ts.map +1 -0
  4. package/dist/companion.js +89 -0
  5. package/dist/companion.js.map +1 -0
  6. package/dist/delivery-export.d.ts +27 -0
  7. package/dist/delivery-export.d.ts.map +1 -0
  8. package/dist/delivery-export.js +200 -0
  9. package/dist/delivery-export.js.map +1 -0
  10. package/dist/doctor.d.ts +24 -0
  11. package/dist/doctor.d.ts.map +1 -0
  12. package/dist/doctor.js +206 -0
  13. package/dist/doctor.js.map +1 -0
  14. package/dist/index.js +583 -124
  15. package/dist/index.js.map +1 -1
  16. package/dist/indexer.d.ts.map +1 -1
  17. package/dist/indexer.js +690 -30
  18. package/dist/indexer.js.map +1 -1
  19. package/dist/installer.d.ts +46 -0
  20. package/dist/installer.d.ts.map +1 -1
  21. package/dist/installer.js +551 -100
  22. package/dist/installer.js.map +1 -1
  23. package/dist/project.d.ts +9 -1
  24. package/dist/project.d.ts.map +1 -1
  25. package/dist/project.js +50 -2
  26. package/dist/project.js.map +1 -1
  27. package/dist/proxy.d.ts +7 -0
  28. package/dist/proxy.d.ts.map +1 -0
  29. package/dist/proxy.js +106 -0
  30. package/dist/proxy.js.map +1 -0
  31. package/dist/release.d.ts +5 -0
  32. package/dist/release.d.ts.map +1 -0
  33. package/dist/release.js +12 -0
  34. package/dist/release.js.map +1 -0
  35. package/dist/skill/foundry-design-control/SKILL.md +41 -15
  36. package/dist/skill/foundry-design-control/references/apply-run-contract.md +13 -7
  37. package/dist/skill/foundry-design-control/references/change-contract.md +9 -6
  38. package/dist/skill/foundry-design-control/references/component-workshop.md +46 -0
  39. package/dist/skill/foundry-design-control/references/design-branches.md +47 -0
  40. package/dist/skill/foundry-design-control/references/design-system-intelligence.md +40 -0
  41. package/dist/skill/foundry-design-control/references/motion-studio.md +101 -0
  42. package/dist/skill/foundry-design-control/references/portable-branch-records.md +38 -0
  43. package/dist/skill/foundry-design-control/references/responsive-design-lab.md +34 -0
  44. package/dist/skill/foundry-design-control/references/typography-studio.md +14 -0
  45. package/dist/skill/foundry-design-control/references/visual-agent-conversation.md +14 -0
  46. package/dist/skill/foundry-design-control/references/visual-workbench.md +27 -4
  47. package/dist/skill/foundry-design-control/scripts/foundry.sh +1 -5
  48. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,17 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
- import { createHash } from 'node:crypto';
4
- import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
5
- import { basename, dirname, join, resolve } from 'node:path';
3
+ import { createHash, randomBytes } from 'node:crypto';
4
+ import { access, appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
6
6
  import { createInterface } from 'node:readline/promises';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { renderChangePrompt } from 'foundry-design-protocol';
9
- import { FoundryRuntime, SessionStore } from 'foundry-design-runtime';
10
- import { createSetupPlan, setupProject, uninstallProject, } from './installer.js';
11
- import { addSessionParams, detectPlatform } from './project.js';
8
+ import { homedir } from 'node:os';
9
+ import { renderChangePrompt, } from 'foundry-design-protocol';
10
+ import { FoundryRuntime, SessionStore, renderDeliveryMarkdown, } from 'foundry-design-runtime';
11
+ import { createSetupPlan, createUpdatePlan, installHostAgentIntegration, installAgentIntegration, setupProject, updateProject, uninstallProject, uninstallHostAgentIntegration, } from './installer.js';
12
+ import { addSessionParams, detectPlatform, normalizeTargetUrl, resolveProjectRoot, } from './project.js';
12
13
  import { indexProjectDesign } from './indexer.js';
14
+ import { startBasicPreviewProxy } from './proxy.js';
15
+ import { FOUNDRY_VERSION, releasePreflight } from './release.js';
16
+ import { collectDoctorReport } from './doctor.js';
17
+ import { CompanionStore } from './companion.js';
18
+ import { DeliveryExportConflictError, renderPortableDeliveryJson, writeRepositoryDeliveryExport, } from './delivery-export.js';
13
19
  const args = process.argv.slice(2);
14
- const command = args[0] ?? 'help';
20
+ const command = args[0]?.startsWith('-') ? 'launch' : (args[0] ?? 'launch');
15
21
  const runtimeRepository = resolve(fileURLToPath(new URL('../../../', import.meta.url)));
16
22
  function flag(name) {
17
23
  const index = args.indexOf(name);
@@ -20,24 +26,43 @@ function flag(name) {
20
26
  function has(name) {
21
27
  return args.includes(name);
22
28
  }
23
- function projectRoot() {
24
- return resolve(process.env.INIT_CWD ?? process.cwd(), flag('--project') ?? '.');
29
+ async function projectRoot(options = {}) {
30
+ const specified = flag('--project');
31
+ const start = resolve(process.env.INIT_CWD ?? process.cwd(), specified ?? '.');
32
+ return resolveProjectRoot(start, {
33
+ explicit: Boolean(specified),
34
+ allowUninitialized: options.allowUninitialized,
35
+ });
25
36
  }
26
37
  function printHelp() {
27
38
  console.log(`Foundry Design Control
28
39
 
29
40
  Usage:
30
- foundry-design setup [--project PATH] [--agent codex,cursor,claude] [--url URL] [--yes]
41
+ foundry-design [--project PATH] [--url URL] [--yes] [--no-start]
42
+ foundry-design setup [--project PATH] [--agent codex,cursor,claude] [--project-agent] [--url URL] [--yes]
43
+ foundry-design update [--project PATH] [--agent codex,cursor,claude] [--yes]
44
+ foundry-design install [--agent codex,cursor,claude] [--yes]
45
+ foundry-design connect [--project PATH] [--url URL] [--yes]
46
+ foundry-design disconnect [--project PATH] [--yes]
47
+ foundry-design reset [--project PATH] [--agent codex,cursor,claude] [--url URL] [--yes]
48
+ foundry-design companion [--json]
31
49
  foundry-design init <web|swiftui|react-native> [--project PATH]
32
- foundry-design start [--project PATH] [--url URL] [--platform PLATFORM] [--no-open] [--no-dev]
33
- foundry-design doctor [--project PATH]
50
+ foundry-design start [--project PATH] [--url URL] [--platform PLATFORM] [--new] [--no-open] [--no-dev]
51
+ foundry-design doctor [--project PATH] [--repair] [--json]
52
+ foundry-design status [--project PATH] [--json]
34
53
  foundry-design index [--project PATH] [--output FILE]
35
- foundry-design uninstall [--project PATH] [--yes]
54
+ foundry-design uninstall [--project PATH] [--global] [--agent codex,cursor,claude] [--yes]
36
55
  foundry-design export <SESSION_ID> [--format json|prompt|full] [--output FILE]
37
- foundry-design install-agent <cursor|claude|codex> [--project PATH]
56
+ foundry-design delivery export <DELIVERY_ID> --format repo|markdown|json --output PATH [--yes]
57
+ foundry-design install-agent <cursor|claude|codex> [--project-agent --project PATH]
38
58
 
39
59
  Foundry is local-only and never edits product source from inspector controls.`);
40
60
  }
61
+ async function pathExists(path) {
62
+ return access(path)
63
+ .then(() => true)
64
+ .catch(() => false);
65
+ }
41
66
  async function gitOutput(root, gitArgs) {
42
67
  return new Promise((resolveRevision) => {
43
68
  const child = spawn('git', gitArgs, {
@@ -70,6 +95,126 @@ async function revision(root) {
70
95
  }
71
96
  return `${head}-dirty-${hash.digest('hex').slice(0, 12)}`;
72
97
  }
98
+ function safeSourcePath(root, input) {
99
+ const candidate = isAbsolute(input) ? relative(root, input) : input;
100
+ const normalized = candidate.replaceAll('\\', '/').replace(/^\.\//, '');
101
+ const absolute = resolve(root, normalized);
102
+ const fromRoot = relative(root, absolute).replaceAll('\\', '/');
103
+ if (!normalized || fromRoot.startsWith('../') || isAbsolute(fromRoot)) {
104
+ throw new Error(`Source path is outside the project root: ${input}`);
105
+ }
106
+ return fromRoot;
107
+ }
108
+ async function sourceFileSnapshot(root, path, locations = []) {
109
+ const safePath = safeSourcePath(root, path);
110
+ const content = await readFile(resolve(root, safePath)).catch((error) => {
111
+ if (error.code === 'ENOENT')
112
+ return null;
113
+ throw error;
114
+ });
115
+ const sourceLines = content?.toString('utf8').split(/\r?\n/) ?? [];
116
+ return {
117
+ path: safePath,
118
+ exists: content !== null,
119
+ sha256: content === null ? null : createHash('sha256').update(content).digest('hex'),
120
+ lineAnchors: content === null
121
+ ? []
122
+ : [
123
+ ...new Map(locations
124
+ .filter((location) => location.line &&
125
+ Number.isInteger(location.line) &&
126
+ location.line > 0 &&
127
+ location.line <= sourceLines.length &&
128
+ (!location.symbol || sourceLines[location.line - 1].includes(location.symbol)))
129
+ .map((location) => [
130
+ JSON.stringify([location.line, location.symbol]),
131
+ {
132
+ line: location.line,
133
+ endLine: location.line,
134
+ symbol: location.symbol,
135
+ sha256: createHash('sha256')
136
+ .update(sourceLines[location.line - 1])
137
+ .digest('hex'),
138
+ },
139
+ ])).values(),
140
+ ].sort((left, right) => left.line - right.line),
141
+ };
142
+ }
143
+ function parseChangedRanges(path, diff) {
144
+ const ranges = [];
145
+ for (const line of diff.split('\n')) {
146
+ const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
147
+ if (!match)
148
+ continue;
149
+ ranges.push({
150
+ path,
151
+ oldStart: Number(match[1]),
152
+ oldLines: match[2] === undefined ? 1 : Number(match[2]),
153
+ newStart: Number(match[3]),
154
+ newLines: match[4] === undefined ? 1 : Number(match[4]),
155
+ });
156
+ }
157
+ return ranges;
158
+ }
159
+ async function gitChangedRanges(root, tracked, untracked) {
160
+ const ranges = [];
161
+ for (const path of tracked) {
162
+ const diff = (await gitOutput(root, ['diff', '--no-ext-diff', '--unified=0', 'HEAD', '--', path])) ?? '';
163
+ ranges.push(...parseChangedRanges(path, diff));
164
+ }
165
+ for (const path of untracked) {
166
+ const content = await readFile(resolve(root, path), 'utf8').catch(() => '');
167
+ ranges.push({
168
+ path,
169
+ oldStart: 0,
170
+ oldLines: 0,
171
+ newStart: 1,
172
+ newLines: content.split(/\r?\n/).length,
173
+ });
174
+ }
175
+ return ranges;
176
+ }
177
+ async function projectSourceState(root, sourcePaths, sourceLocations = []) {
178
+ const headRevision = await gitOutput(root, ['rev-parse', 'HEAD']);
179
+ if (!headRevision) {
180
+ const mappedPaths = [...new Set(sourcePaths.map((path) => safeSourcePath(root, path)))].sort();
181
+ if (!mappedPaths.length) {
182
+ return {
183
+ supported: false,
184
+ revision: null,
185
+ reason: 'No source-backed paths are available for filesystem verification.',
186
+ };
187
+ }
188
+ const files = await Promise.all(mappedPaths.map((path) => sourceFileSnapshot(root, path, sourceLocations.filter((location) => safeSourcePath(root, location.path) === path))));
189
+ const fallbackRevision = createHash('sha256').update(JSON.stringify(files)).digest('hex');
190
+ return {
191
+ supported: true,
192
+ scope: 'mapped-files',
193
+ revision: `mapped-${fallbackRevision}`,
194
+ files,
195
+ };
196
+ }
197
+ const currentRevision = (await revision(root)) ?? headRevision;
198
+ const tracked = (await gitOutput(root, ['diff', '--name-only', 'HEAD']))?.split('\n').filter(Boolean) ?? [];
199
+ const untracked = (await gitOutput(root, ['ls-files', '--others', '--exclude-standard']))
200
+ ?.split('\n')
201
+ .filter(Boolean) ?? [];
202
+ const paths = [
203
+ ...new Set([...tracked, ...untracked, ...sourcePaths]
204
+ .map((file) => safeSourcePath(root, file))
205
+ .filter(Boolean)),
206
+ ].sort();
207
+ const files = await Promise.all(paths.map((path) => sourceFileSnapshot(root, path, sourceLocations.filter((location) => safeSourcePath(root, location.path) === path))));
208
+ const changedRanges = await gitChangedRanges(root, tracked.map((path) => safeSourcePath(root, path)), untracked.map((path) => safeSourcePath(root, path)));
209
+ return {
210
+ supported: true,
211
+ scope: 'git',
212
+ revision: currentRevision,
213
+ headRevision,
214
+ files,
215
+ changedRanges,
216
+ };
217
+ }
73
218
  async function openUrl(url) {
74
219
  const executable = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
75
220
  const childArgs = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
@@ -105,18 +250,29 @@ function requestedAgents() {
105
250
  throw new Error(`Unsupported agent: ${invalid.join(', ')}`);
106
251
  return [...new Set(agents)];
107
252
  }
253
+ async function sharedAgents(root, additional = []) {
254
+ const installed = await new CompanionStore(homedir()).read();
255
+ const detected = (await createSetupPlan(root)).agents;
256
+ return [...new Set([...installed.agents, ...additional, ...detected])];
257
+ }
108
258
  async function setup() {
109
- const root = projectRoot();
259
+ console.log(`${releasePreflight('set up this project')}\n`);
260
+ const root = await projectRoot();
261
+ const requested = requestedAgents();
262
+ const projectAgentSetup = has('--project-agent');
263
+ const hostAgents = requested ?? (await sharedAgents(root));
110
264
  const options = {
111
- agents: requestedAgents(),
112
- targetUrl: flag('--url'),
265
+ agents: projectAgentSetup ? hostAgents : [],
266
+ targetUrl: normalizeTargetUrl(flag('--url')),
113
267
  packageRoot: has('--local-mcp') ? runtimeRepository : undefined,
114
268
  };
115
269
  const plan = await createSetupPlan(root, options);
116
270
  console.log(`Foundry setup\n\nProject: ${root}\nPlatform: ${plan.platform}`);
117
271
  if (plan.framework)
118
272
  console.log(`Framework: ${plan.framework}`);
119
- console.log(`Agent integration: ${plan.agents.length ? plan.agents.join(', ') : 'plugin-provided'}`);
273
+ console.log(`Agent integration: ${projectAgentSetup
274
+ ? `${plan.agents.join(', ')} (project-scoped compatibility mode)`
275
+ : `${hostAgents.join(', ')} (shared across projects)`}`);
120
276
  console.log('\nFiles Foundry will manage:');
121
277
  for (const path of plan.files)
122
278
  console.log(` ${path}`);
@@ -128,22 +284,90 @@ async function setup() {
128
284
  return;
129
285
  }
130
286
  const result = await setupProject(root, options);
287
+ if (!projectAgentSetup) {
288
+ for (const agent of hostAgents) {
289
+ await installHostAgentIntegration(homedir(), agent, {
290
+ packageRoot: options.packageRoot,
291
+ });
292
+ }
293
+ await new CompanionStore(homedir()).recordInstallation(hostAgents);
294
+ await new CompanionStore(homedir()).registerProject(root, result.targetUrl);
295
+ }
131
296
  console.log(`\n✓ Foundry configured ${result.changed.length} files.`);
297
+ for (const check of result.validation) {
298
+ const marker = check.status === 'passed' ? '✓' : check.status === 'skipped' ? '–' : '!';
299
+ console.log(`${marker} ${check.name}: ${check.status.replaceAll('-', ' ')}`);
300
+ }
132
301
  if (result.skillDirectories.length) {
133
302
  console.log(`✓ Installed the Foundry skill for ${result.agents.join(', ')}.`);
134
303
  }
304
+ if (!projectAgentSetup && hostAgents.length) {
305
+ console.log(`✓ Installed the shared Foundry connection for ${hostAgents.join(', ')}.`);
306
+ }
135
307
  if (result.targetUrl)
136
308
  console.log(`✓ Preview URL: ${result.targetUrl}`);
137
309
  if (result.devCommand)
138
310
  console.log(`✓ Development command: ${result.devCommand.command} ${result.devCommand.args.join(' ')}`);
139
- console.log('\nRestart your coding agent, then ask: "Start Foundry for this project."');
140
- console.log('You can also run: foundry-design start');
311
+ console.log('\nSetup complete. One coding-agent restart is required so it can load Foundry.');
312
+ console.log(`1. Restart ${!projectAgentSetup && hostAgents.length
313
+ ? hostAgents.join(', ')
314
+ : result.agents.length
315
+ ? result.agents.join(', ')
316
+ : 'your coding agent'}.`);
317
+ console.log('2. Reopen this exact project folder.');
318
+ console.log('3. Ask: "Start Foundry for this project and keep listening for Apply with agent requests."');
319
+ console.log('\nAfter that, the browser will confirm when the agent is ready.');
320
+ }
321
+ async function update() {
322
+ console.log(`${releasePreflight('update this project')}\n`);
323
+ const root = await projectRoot();
324
+ const requested = requestedAgents();
325
+ const projectAgentSetup = has('--project-agent');
326
+ const hostAgents = requested ?? (await sharedAgents(root));
327
+ const options = {
328
+ agents: projectAgentSetup ? hostAgents : [],
329
+ packageRoot: has('--local-mcp') ? runtimeRepository : undefined,
330
+ };
331
+ const plan = await createUpdatePlan(root, options);
332
+ console.log(`Foundry update\n\nProject: ${root}`);
333
+ console.log(`Agent integration: ${projectAgentSetup
334
+ ? `${plan.agents.join(', ')} (project-scoped compatibility mode)`
335
+ : `${hostAgents.join(', ')} (shared across projects)`}`);
336
+ console.log('\nFoundry will refresh these managed paths:');
337
+ for (const path of plan.files)
338
+ console.log(` ${path}`);
339
+ console.log('\nFiles changed since Foundry installed them will be preserved.');
340
+ if (!(await confirm('\nContinue with this update?'))) {
341
+ console.log('Update cancelled.');
342
+ return;
343
+ }
344
+ const result = await updateProject(root, options);
345
+ if (!projectAgentSetup) {
346
+ for (const agent of hostAgents)
347
+ await installHostAgentIntegration(homedir(), agent, {
348
+ packageRoot: options.packageRoot,
349
+ });
350
+ await new CompanionStore(homedir()).recordInstallation(hostAgents);
351
+ await new CompanionStore(homedir()).registerProject(root, result.targetUrl);
352
+ }
353
+ console.log(`\n✓ Foundry refreshed ${result.changed.length} managed paths.`);
354
+ if (result.preserved.length) {
355
+ console.log('Preserved files containing user changes:');
356
+ for (const path of result.preserved)
357
+ console.log(` ${path}`);
358
+ }
359
+ for (const check of result.validation) {
360
+ const marker = check.status === 'passed' ? '✓' : check.status === 'skipped' ? '–' : '!';
361
+ console.log(`${marker} ${check.name}: ${check.status.replaceAll('-', ' ')}`);
362
+ }
363
+ console.log('\nThe project now uses the shared Foundry agent connection.');
141
364
  }
142
365
  async function initProject() {
366
+ console.log(`${releasePreflight('initialize this project')}\n`);
143
367
  const platform = (args[1] ?? 'web');
144
368
  if (!['web', 'swiftui', 'react-native'].includes(platform))
145
369
  throw new Error(`Unsupported platform: ${platform}`);
146
- const root = projectRoot();
370
+ const root = await projectRoot({ allowUninitialized: true });
147
371
  const configRoot = join(root, '.foundry');
148
372
  await mkdir(configRoot, { recursive: true });
149
373
  await writeFile(join(configRoot, 'foundry.config.json'), `${JSON.stringify({
@@ -184,7 +408,7 @@ async function initProject() {
184
408
  console.log('Import and call .foundry/web-adapter.ts once from your development-only client entry.');
185
409
  }
186
410
  async function start() {
187
- const root = projectRoot();
411
+ const root = await projectRoot();
188
412
  let config;
189
413
  try {
190
414
  config = JSON.parse(await readFile(join(root, '.foundry', 'foundry.config.json'), 'utf8'));
@@ -195,10 +419,8 @@ async function start() {
195
419
  const platform = flag('--platform') ??
196
420
  config?.platform ??
197
421
  (await detectPlatform(root));
198
- const targetUrl = flag('--url') ?? config?.targetUrl;
199
- if (platform === 'web' && config && !config.instrumented) {
200
- throw new Error('Foundry setup is incomplete. Connect .foundry/web-adapter.ts to a development-only client entry, then set instrumented to true.');
201
- }
422
+ const targetUrl = normalizeTargetUrl(flag('--url') ?? config?.targetUrl);
423
+ let basicPreview;
202
424
  let developmentServer;
203
425
  if (platform === 'web' &&
204
426
  targetUrl &&
@@ -224,54 +446,105 @@ async function start() {
224
446
  }
225
447
  }
226
448
  const projectRevision = await revision(root);
449
+ const configuredViewports = config?.design?.viewports ?? [];
450
+ const initialViewport = configuredViewports.find((viewport) => viewport.id.toLowerCase() === 'desktop' || viewport.label.toLowerCase() === 'desktop') ??
451
+ [...configuredViewports].sort((left, right) => right.width - left.width)[0] ?? {
452
+ width: 1440,
453
+ height: 900,
454
+ };
227
455
  const context = {
228
456
  projectRoot: root,
229
457
  revision: projectRevision,
230
458
  platform,
231
459
  targetUrl,
460
+ previewOrigin: targetUrl ? new URL(targetUrl).origin : undefined,
232
461
  targetName: basename(root),
462
+ viewport: { width: initialViewport.width, height: initialViewport.height ?? 900 },
233
463
  theme: 'system',
234
464
  breakpoint: 'current',
235
465
  state: 'current',
236
466
  };
237
467
  const store = new SessionStore();
238
- const session = await store.create(context);
468
+ const resumable = has('--new')
469
+ ? undefined
470
+ : (await store.list()).find((candidate) => candidate.changeSet.context.projectRoot === root &&
471
+ candidate.changeSet.context.revision === projectRevision &&
472
+ candidate.changeSet.context.targetUrl === targetUrl);
473
+ const session = resumable
474
+ ? await store.read(resumable.changeSet.sessionId)
475
+ : await store.create(context);
239
476
  if (platform === 'web') {
240
477
  const graph = await indexProjectDesign(root, config, projectRevision);
241
478
  await store.setDesignGraph(session.changeSet.sessionId, graph);
242
479
  console.log(`Indexed ${graph.tokens.length} tokens, ${graph.components.length} components, and ${graph.breakpoints.length} viewports.`);
243
480
  }
244
- const runtime = new FoundryRuntime({ store });
481
+ const runtime = new FoundryRuntime({
482
+ store,
483
+ resolveProjectRevision: async ({ projectRoot: revisionRoot, sourcePaths, sourceLocations }) => projectSourceState(revisionRoot, sourcePaths, sourceLocations),
484
+ reindexProjectDesign: async ({ projectRoot: graphRoot }) => {
485
+ const currentConfig = await readFile(join(graphRoot, '.foundry', 'foundry.config.json'), 'utf8')
486
+ .then((content) => JSON.parse(content))
487
+ .catch(() => undefined);
488
+ return indexProjectDesign(graphRoot, currentConfig, await revision(graphRoot));
489
+ },
490
+ });
491
+ let ownsRuntime = false;
245
492
  try {
246
493
  await runtime.start();
494
+ ownsRuntime = true;
247
495
  }
248
496
  catch (error) {
249
497
  if (error.code !== 'EADDRINUSE')
250
498
  throw error;
251
- developmentServer?.kill('SIGTERM');
252
- console.error('Port 4387 is already in use. Stop the existing Foundry runtime before starting another session.');
253
- process.exitCode = 1;
254
- return;
499
+ const healthy = await fetch('http://127.0.0.1:4387/v1/health')
500
+ .then((response) => response.ok)
501
+ .catch(() => false);
502
+ if (!healthy)
503
+ throw new Error('Port 4387 is in use by another process. Stop it and run Foundry again.');
504
+ console.log('Using the existing local Foundry runtime.');
255
505
  }
256
506
  const reviewUrl = `http://127.0.0.1:4387/?session=${encodeURIComponent(session.changeSet.sessionId)}&token=${encodeURIComponent(session.token)}`;
507
+ if (platform === 'web' && targetUrl && config && !config.instrumented) {
508
+ basicPreview = await startBasicPreviewProxy(targetUrl, config.runtimeUrl);
509
+ console.log('Basic mode: Foundry is attached without changing the project entry point.');
510
+ console.log('Run setup again after adding a supported client entry to enable exact source mapping.');
511
+ }
512
+ if (platform === 'web' && targetUrl) {
513
+ await store.setPreviewOrigin(session.changeSet.sessionId, new URL(basicPreview?.url ?? targetUrl).origin);
514
+ }
515
+ const previewCapability = platform === 'web' && targetUrl ? randomBytes(32).toString('base64url') : undefined;
516
+ if (previewCapability) {
517
+ await store.setPreviewCapability(session.changeSet.sessionId, previewCapability);
518
+ }
257
519
  const productUrl = targetUrl && platform === 'web'
258
- ? addSessionParams(targetUrl, session.changeSet.sessionId, session.token)
520
+ ? addSessionParams(basicPreview?.url ?? targetUrl, session.changeSet.sessionId, session.token, previewCapability)
521
+ : reviewUrl;
522
+ const workspaceUrl = platform === 'web' && targetUrl
523
+ ? `${reviewUrl}&preview=${encodeURIComponent(productUrl)}`
259
524
  : reviewUrl;
260
525
  console.log(`Foundry session ${session.changeSet.sessionId}`);
526
+ if (resumable)
527
+ console.log('Resumed the most recent session for this project revision.');
261
528
  console.log(`Platform: ${platform}`);
262
- console.log(`Inspector: ${reviewUrl}`);
529
+ console.log(`Workspace: ${workspaceUrl}`);
263
530
  if (targetUrl && platform === 'web')
264
- console.log(`Instrumented preview: ${productUrl}`);
531
+ console.log(`${basicPreview ? 'Basic' : 'Precision'} preview: ${productUrl}`);
265
532
  if (platform === 'web') {
266
533
  console.log('\nFirst edit:');
267
534
  console.log(' 1. Option-click an element. Shift-click to add more.');
268
535
  console.log(' 2. Drag a blue handle or use the inspector for exact values.');
269
536
  console.log(' 3. Review the semantic mapping, then Apply with agent.');
537
+ console.log('\nAgent handoff:');
538
+ console.log(' Keep this process running while you design.');
539
+ console.log(' Foundry will show “Agent is ready” only while a coding agent is listening.');
540
+ console.log(' If it is not ready, ask the agent: “Keep listening for Apply with agent requests.”');
270
541
  }
271
542
  if (!has('--no-open'))
272
- await openUrl(productUrl);
543
+ await openUrl(workspaceUrl);
273
544
  const shutdown = async () => {
274
- await runtime.stop();
545
+ if (ownsRuntime)
546
+ await runtime.stop();
547
+ await basicPreview?.stop();
275
548
  developmentServer?.kill('SIGTERM');
276
549
  process.exit(0);
277
550
  };
@@ -280,7 +553,7 @@ async function start() {
280
553
  await new Promise(() => { });
281
554
  }
282
555
  async function indexDesign() {
283
- const root = projectRoot();
556
+ const root = await projectRoot();
284
557
  const config = await readFile(join(root, '.foundry', 'foundry.config.json'), 'utf8')
285
558
  .then((content) => JSON.parse(content))
286
559
  .catch(() => undefined);
@@ -295,7 +568,33 @@ async function indexDesign() {
295
568
  console.log(content);
296
569
  }
297
570
  async function uninstall() {
298
- const root = projectRoot();
571
+ if (has('--global')) {
572
+ const installed = await new CompanionStore(homedir()).read();
573
+ const agents = requestedAgents() ?? installed.agents;
574
+ if (!agents.length) {
575
+ console.log('No shared Foundry agent connection is registered.');
576
+ return;
577
+ }
578
+ if (!(await confirm(`Remove the shared Foundry connection for ${agents.join(', ')}?`))) {
579
+ console.log('Uninstall cancelled.');
580
+ return;
581
+ }
582
+ const preserved = [];
583
+ for (const agent of agents) {
584
+ const result = await uninstallHostAgentIntegration(homedir(), agent);
585
+ preserved.push(...result.preserved);
586
+ }
587
+ await new CompanionStore(homedir()).removeInstallation(agents);
588
+ console.log(`Removed the shared Foundry connection for ${agents.join(', ')}.`);
589
+ if (preserved.length) {
590
+ console.log('Preserved customized skill files:');
591
+ for (const path of preserved)
592
+ console.log(` ${path}`);
593
+ }
594
+ return;
595
+ }
596
+ console.log(`${releasePreflight('remove managed project integration')}\n`);
597
+ const root = await projectRoot();
299
598
  if (!(await confirm(`Remove Foundry-managed project integration from ${root}?`))) {
300
599
  console.log('Uninstall cancelled.');
301
600
  return;
@@ -308,68 +607,108 @@ async function uninstall() {
308
607
  console.log(` ${path}`);
309
608
  }
310
609
  }
610
+ async function disconnect() {
611
+ const root = await projectRoot();
612
+ if (!(await confirm(`Disconnect ${root} from Foundry's recent projects?`))) {
613
+ console.log('Disconnect cancelled.');
614
+ return;
615
+ }
616
+ await new CompanionStore(homedir()).unregisterProject(root);
617
+ console.log(`Disconnected ${root}. The project adapter remains installed and can reconnect later.`);
618
+ }
311
619
  async function doctor() {
312
- const root = projectRoot();
313
- const platform = await detectPlatform(root);
314
- const configPath = join(root, '.foundry', 'foundry.config.json');
315
- let config;
316
- try {
317
- config = JSON.parse(await readFile(configPath, 'utf8'));
620
+ const root = await projectRoot();
621
+ const report = await collectDoctorReport(root);
622
+ if (has('--json'))
623
+ console.log(JSON.stringify(report, null, 2));
624
+ else {
625
+ for (const check of report.checks) {
626
+ const marker = check.status === 'passed' ? '✓' : check.status === 'warning' ? '△' : '○';
627
+ console.log(`${marker} ${check.label}: ${check.detail}`);
628
+ }
629
+ console.log(report.ready
630
+ ? '\nFoundry is ready and an agent is actively listening.'
631
+ : '\nFoundry is not yet ready for Apply with agent. Configuration and a live listener are separate checks.');
632
+ }
633
+ if (has('--repair')) {
634
+ console.log(`\n${releasePreflight('repair this project and agent connection')}\n`);
635
+ const installed = report.checks.find((check) => check.id === 'integration')?.status !== 'failed';
636
+ const configuredAgents = installed ? (await createUpdatePlan(root)).agents : [];
637
+ const hostAgents = await sharedAgents(root, configuredAgents);
638
+ const result = installed
639
+ ? await updateProject(root, {
640
+ agents: [],
641
+ targetUrl: normalizeTargetUrl(flag('--url')),
642
+ })
643
+ : await setupProject(root, {
644
+ agents: [],
645
+ targetUrl: normalizeTargetUrl(flag('--url')),
646
+ });
647
+ for (const agent of hostAgents)
648
+ await installHostAgentIntegration(homedir(), agent, {
649
+ packageRoot: has('--local-mcp') ? runtimeRepository : undefined,
650
+ });
651
+ await new CompanionStore(homedir()).recordInstallation(hostAgents);
652
+ await new CompanionStore(homedir()).registerProject(root, result.targetUrl);
653
+ console.log(`✓ Repaired ${result.changed.length} managed paths and the shared ${hostAgents.join(', ')} connection.`);
654
+ const repaired = await collectDoctorReport(root);
655
+ const requiredAfterRepair = new Set([
656
+ 'config',
657
+ 'project-connection',
658
+ 'integration',
659
+ 'agent-configured',
660
+ 'agent-config-valid',
661
+ 'agent-version',
662
+ ]);
663
+ const remaining = repaired.checks.filter((check) => requiredAfterRepair.has(check.id) && check.status === 'failed');
664
+ if (remaining.length) {
665
+ throw new Error(`Repair is incomplete: ${remaining.map((check) => check.label).join(', ')}`);
666
+ }
667
+ console.log('✓ Project and agent configuration now use the same Foundry release.');
668
+ if (repaired.checks.find((check) => check.id === 'agent-listening')?.status !== 'passed') {
669
+ console.log('△ Restart the active coding agent once to activate its listener.');
670
+ }
671
+ }
672
+ else if (report.checks.some((check) => check.status === 'failed')) {
673
+ console.log('Run: foundry-design doctor --repair');
318
674
  }
319
- catch {
320
- /* Report setup as missing below. */
321
- }
322
- const agentFiles = [
323
- join(root, '.codex', 'config.toml'),
324
- join(root, '.cursor', 'mcp.json'),
325
- join(root, '.mcp.json'),
326
- ];
327
- const configuredAgents = [];
328
- for (const path of agentFiles) {
329
- if ((await readFile(path, 'utf8').catch(() => '')).includes('foundry-design-control'))
330
- configuredAgents.push(path);
331
- }
332
- const pluginProvided = await readFile(join(root, '.foundry', 'install-manifest.json'), 'utf8')
333
- .then((content) => {
334
- const manifest = JSON.parse(content);
335
- return Array.isArray(manifest.agents) && manifest.agents.length === 0;
336
- })
337
- .catch(() => false);
338
- const checks = [
339
- ['Project', root, true],
340
- ['Detected platform', platform, true],
341
- ['Foundry config', configPath, Boolean(config)],
342
- [
343
- 'Instrumentation',
344
- config?.instrumented ? `${config.framework ?? config.platform}` : 'integration pending',
345
- Boolean(config?.instrumented),
346
- ],
347
- [
348
- 'Agent connection',
349
- configuredAgents.length
350
- ? configuredAgents.join(', ')
351
- : pluginProvided
352
- ? 'plugin-provided'
353
- : 'not configured',
354
- configuredAgents.length > 0 || pluginProvided,
355
- ],
356
- [
357
- 'Project preview',
358
- config?.targetUrl ?? 'not configured',
359
- config?.targetUrl ? await urlAvailable(config.targetUrl) : platform !== 'web',
360
- ],
361
- [
362
- 'Runtime health',
363
- 'http://127.0.0.1:4387/v1/health',
364
- await fetch('http://127.0.0.1:4387/v1/health')
365
- .then((response) => response.ok)
366
- .catch(() => false),
367
- ],
368
- ];
369
- for (const [label, value, passed] of checks)
370
- console.log(`${passed ? '✓' : '○'} ${label}: ${value}`);
371
- if (!config)
372
- console.log('Run: foundry-design setup');
675
+ }
676
+ async function launch() {
677
+ console.log(`${releasePreflight('install, update, and start Foundry')}\n`);
678
+ const root = await projectRoot();
679
+ const manifest = join(root, '.foundry', 'install-manifest.json');
680
+ const installed = await pathExists(manifest);
681
+ const configuredAgents = installed ? (await createUpdatePlan(root)).agents : [];
682
+ const hostAgents = await sharedAgents(root, configuredAgents);
683
+ console.log(`Foundry\n\nProject: ${root}`);
684
+ console.log(`${installed ? 'Refreshing' : 'Preparing'} the project and shared ${hostAgents.join(', ')} connection.`);
685
+ if (!(await confirm('Continue?'))) {
686
+ console.log('Foundry cancelled.');
687
+ return;
688
+ }
689
+ const result = installed
690
+ ? await updateProject(root, {
691
+ agents: [],
692
+ targetUrl: normalizeTargetUrl(flag('--url')),
693
+ })
694
+ : await setupProject(root, {
695
+ agents: [],
696
+ targetUrl: normalizeTargetUrl(flag('--url')),
697
+ });
698
+ for (const agent of hostAgents)
699
+ await installHostAgentIntegration(homedir(), agent, {
700
+ packageRoot: has('--local-mcp') ? runtimeRepository : undefined,
701
+ });
702
+ await new CompanionStore(homedir()).recordInstallation(hostAgents);
703
+ await new CompanionStore(homedir()).registerProject(root, result.targetUrl);
704
+ console.log(`✓ ${installed ? 'Updated' : 'Installed'} and validated Foundry.`);
705
+ for (const check of result.validation)
706
+ console.log(`${check.status === 'passed' ? '✓' : check.status === 'skipped' ? '–' : '!'} ${check.name}: ${check.status.replaceAll('-', ' ')}`);
707
+ if (!installed)
708
+ console.log(`\nRestart ${hostAgents.join(', ')} once to load the shared connection. The visual session will open now and queued batches will wait safely until it reconnects.`);
709
+ if (has('--no-start'))
710
+ return;
711
+ await start();
373
712
  }
374
713
  async function exportSession() {
375
714
  const id = args[1];
@@ -395,45 +734,163 @@ async function exportSession() {
395
734
  else
396
735
  console.log(content);
397
736
  }
737
+ async function exportDelivery() {
738
+ if (args[1] !== 'export')
739
+ throw new Error('delivery requires the export subcommand');
740
+ const recordId = args[2];
741
+ if (!recordId)
742
+ throw new Error('delivery export requires a delivery record id');
743
+ const output = flag('--output');
744
+ if (!output)
745
+ throw new Error('delivery export requires --output PATH');
746
+ const format = flag('--format') ?? 'repo';
747
+ if (!['repo', 'markdown', 'json'].includes(format)) {
748
+ throw new Error('delivery export format must be repo, markdown, or json');
749
+ }
750
+ const store = new SessionStore();
751
+ const summary = (await store.list()).find((session) => session.deliveryRecords.some((record) => record.id === recordId));
752
+ if (!summary)
753
+ throw new Error(`Unknown delivery record: ${recordId}`);
754
+ const session = await store.read(summary.changeSet.sessionId);
755
+ const record = session.deliveryRecords.find((candidate) => candidate.id === recordId);
756
+ if (format === 'repo') {
757
+ if (!(await confirm(`Export ${record.title} into ${resolve(output)}?`))) {
758
+ console.log('Delivery export cancelled.');
759
+ return;
760
+ }
761
+ let manifest;
762
+ try {
763
+ manifest = await writeRepositoryDeliveryExport(output, session, record);
764
+ }
765
+ catch (error) {
766
+ if (error instanceof DeliveryExportConflictError && error.documentationPageIds.length) {
767
+ await store.markDocumentationConflicted(session.changeSet.sessionId, error.documentationPageIds);
768
+ }
769
+ throw error;
770
+ }
771
+ await store.recordDocumentationExports(session.changeSet.sessionId, manifest.documentationPages);
772
+ console.log(`Exported ${manifest.files.length} managed delivery files to ${resolve(output)}.`);
773
+ console.log('Foundry preserved the project root changelog and will refuse to overwrite edited generated docs.');
774
+ return;
775
+ }
776
+ const content = format === 'markdown'
777
+ ? renderDeliveryMarkdown(record, session.changeSet.context.projectRoot)
778
+ : renderPortableDeliveryJson(record, session.changeSet.context.projectRoot);
779
+ await mkdir(dirname(resolve(output)), { recursive: true });
780
+ await writeFile(resolve(output), `${content}\n`);
781
+ console.log(`Exported ${format} delivery record to ${resolve(output)}.`);
782
+ }
398
783
  async function installAgent() {
784
+ console.log(`${releasePreflight('install the coding-agent connection')}\n`);
399
785
  const agent = args[1];
400
786
  if (!agent || !['cursor', 'claude', 'codex'].includes(agent))
401
787
  throw new Error('install-agent requires cursor, claude, or codex');
402
- const root = projectRoot();
403
- const server = {
404
- command: 'npx',
405
- args: ['-y', 'foundry-design-mcp-server@beta'],
406
- env: { FOUNDRY_DESIGN_RUNTIME_URL: 'http://127.0.0.1:4387' },
407
- };
408
- if (agent === 'codex') {
409
- const directory = join(root, '.codex');
410
- await mkdir(directory, { recursive: true });
411
- const file = join(directory, 'foundry-mcp.toml');
412
- await writeFile(file, `# Merge this project-scoped server into your Codex MCP configuration.\n[mcp_servers.foundry-design-control]\ncommand = "npx"\nargs = ["-y", "foundry-design-mcp-server@beta"]\n[mcp_servers.foundry-design-control.env]\nFOUNDRY_DESIGN_RUNTIME_URL = "http://127.0.0.1:4387"\n`);
413
- console.log(`Wrote Codex MCP snippet: ${file}`);
788
+ if (!has('--project-agent')) {
789
+ const result = await installHostAgentIntegration(homedir(), agent, {
790
+ packageRoot: has('--local-mcp') ? runtimeRepository : undefined,
791
+ });
792
+ console.log(`Installed the shared Foundry connection for ${agent}.`);
793
+ console.log(`MCP configuration: ${result.configFile}`);
794
+ console.log(`Skill: ${result.skillDirectory}`);
795
+ if (result.preserved.length) {
796
+ console.log('Preserved customized skill files:');
797
+ for (const path of result.preserved)
798
+ console.log(` ${path}`);
799
+ }
800
+ await new CompanionStore(homedir()).recordInstallation([agent]);
801
+ console.log(`Restart ${agent === 'codex' ? 'Codex' : agent === 'cursor' ? 'Cursor' : 'Claude Code'} once. Future projects connect with "npx foundry-design" without another MCP install.`);
414
802
  return;
415
803
  }
416
- const file = agent === 'cursor' ? join(root, '.cursor', 'mcp.json') : join(root, '.mcp.json');
417
- await mkdir(dirname(file), { recursive: true });
418
- let current = {};
419
- try {
420
- current = JSON.parse(await readFile(file, 'utf8'));
804
+ const root = await projectRoot();
805
+ const file = await installAgentIntegration(root, agent, has('--local-mcp') ? runtimeRepository : undefined);
806
+ console.log(`Installed Foundry MCP configuration for ${agent}: ${file}`);
807
+ console.log(`Restart ${agent === 'codex' ? 'Codex' : agent === 'cursor' ? 'Cursor' : 'Claude Code'} before starting a Foundry apply session.`);
808
+ }
809
+ async function installHost() {
810
+ console.log(`${releasePreflight('install Foundry for your coding agents')}\n`);
811
+ const agents = requestedAgents() ?? ['codex', 'cursor', 'claude'];
812
+ if (!(await confirm(`Install the shared Foundry connection for ${agents.join(', ')}?`))) {
813
+ console.log('Installation cancelled.');
814
+ return;
421
815
  }
422
- catch {
423
- /* Create a new project config. */
816
+ for (const agent of agents) {
817
+ const result = await installHostAgentIntegration(homedir(), agent, {
818
+ packageRoot: has('--local-mcp') ? runtimeRepository : undefined,
819
+ });
820
+ console.log(`✓ ${agent}: ${result.configFile}`);
821
+ }
822
+ await new CompanionStore(homedir()).recordInstallation(agents);
823
+ console.log('\nFoundry is installed once for these agents. Restart each selected agent once.');
824
+ console.log('Then open any project and run: npx foundry-design');
825
+ }
826
+ async function reset() {
827
+ console.log(`${releasePreflight('reset managed project and agent integration')}\n`);
828
+ const root = await projectRoot();
829
+ const installed = await pathExists(join(root, '.foundry', 'install-manifest.json'));
830
+ const configuredAgents = installed ? (await createUpdatePlan(root)).agents : [];
831
+ const requested = requestedAgents();
832
+ const hostAgents = requested ?? (await sharedAgents(root, configuredAgents));
833
+ if (!(await confirm(`Reset Foundry-managed integration for ${root}?`))) {
834
+ console.log('Reset cancelled.');
835
+ return;
836
+ }
837
+ const options = {
838
+ agents: [],
839
+ targetUrl: normalizeTargetUrl(flag('--url')),
840
+ packageRoot: has('--local-mcp') ? runtimeRepository : undefined,
841
+ };
842
+ const result = installed ? await updateProject(root, options) : await setupProject(root, options);
843
+ for (const agent of hostAgents)
844
+ await installHostAgentIntegration(homedir(), agent, options);
845
+ await new CompanionStore(homedir()).recordInstallation(hostAgents);
846
+ await new CompanionStore(homedir()).registerProject(root, result.targetUrl);
847
+ console.log(`✓ Reset ${result.changed.length} managed paths and ${hostAgents.join(', ')} shared connections.`);
848
+ if (result.preserved.length) {
849
+ console.log('Preserved customized files:');
850
+ for (const path of result.preserved)
851
+ console.log(` ${path}`);
852
+ }
853
+ console.log('Restart the active coding agent once, reopen this project, then run npx foundry-design.');
854
+ }
855
+ async function companion() {
856
+ const state = await new CompanionStore(homedir()).read();
857
+ if (has('--json')) {
858
+ console.log(JSON.stringify(state, null, 2));
859
+ return;
860
+ }
861
+ console.log(`Foundry ${state.foundryVersion}`);
862
+ console.log(`Installed agents: ${state.agents.join(', ') || 'none'}`);
863
+ console.log(`Recent projects: ${state.projects.length}`);
864
+ for (const project of state.projects.slice(0, 5)) {
865
+ console.log(` ${project.root}${project.targetUrl ? ` · ${project.targetUrl}` : ''}`);
424
866
  }
425
- current.mcpServers = { ...current.mcpServers, 'foundry-design-control': server };
426
- await writeFile(file, `${JSON.stringify(current, null, 2)}\n`);
427
- console.log(`Installed Foundry MCP configuration for ${agent}: ${file}`);
428
867
  }
429
868
  try {
430
- if (command === 'setup')
869
+ if (has('--version') || command === 'version')
870
+ console.log(FOUNDRY_VERSION);
871
+ else if (has('--help') || command === 'help')
872
+ printHelp();
873
+ else if (command === 'launch')
874
+ await launch();
875
+ else if (command === 'setup')
431
876
  await setup();
877
+ else if (command === 'update')
878
+ await update();
879
+ else if (command === 'install')
880
+ await installHost();
881
+ else if (command === 'connect')
882
+ await launch();
883
+ else if (command === 'disconnect')
884
+ await disconnect();
885
+ else if (command === 'reset')
886
+ await reset();
887
+ else if (command === 'companion')
888
+ await companion();
432
889
  else if (command === 'init')
433
890
  await initProject();
434
891
  else if (command === 'start')
435
892
  await start();
436
- else if (command === 'doctor')
893
+ else if (command === 'doctor' || command === 'status')
437
894
  await doctor();
438
895
  else if (command === 'index')
439
896
  await indexDesign();
@@ -441,6 +898,8 @@ try {
441
898
  await uninstall();
442
899
  else if (command === 'export')
443
900
  await exportSession();
901
+ else if (command === 'delivery')
902
+ await exportDelivery();
444
903
  else if (command === 'install-agent')
445
904
  await installAgent();
446
905
  else