code-workspace-zhuiyi 1.0.0-beta.3 → 1.0.0-beta.4

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/README.md CHANGED
@@ -36,6 +36,8 @@ codew init .code-workspace \
36
36
  --yes
37
37
  ```
38
38
 
39
+ Use `--dev false` for an extension-container Workspace; this skips Workspace dependency installation, disables the system development extension, and omits the project onboarding prompt. Run `codew init .code-workspace --dev true --yes` to enable development mode again. The default for a new Workspace is `true`, and an existing Workspace keeps its saved value when the option is omitted.
40
+
39
41
  Use `--tools claude`, `--tools codex`, or `--tools none` to override the default tool selection. Monitoring is not enabled implicitly; install the `monitor` extension with `--extensions monitor` or through the interactive extension picker.
40
42
 
41
43
  Initialization writes only Workspace-owned state and integrations:
package/README.zh-CN.md CHANGED
@@ -35,6 +35,8 @@ codew init . \
35
35
  --yes
36
36
  ```
37
37
 
38
+ 如果这个 Workspace 主要作为扩展容器使用,可以传 `--dev false`;该模式会跳过 Workspace 依赖安装、停用系统开发扩展并省略项目引导提示。需要恢复开发模式时执行 `codew init . --dev true --yes`。新 Workspace 默认值为 `true`;已有 Workspace 省略该参数时沿用已保存的值。
39
+
38
40
  可用 `--tools claude`、`--tools codex` 或 `--tools none` 覆盖默认工具选择。监控不会隐式启用;请通过 `--extensions monitor` 或交互式扩展选择安装 `monitor` 扩展。
39
41
 
40
42
  初始化只写入 Workspace 自有状态和集成:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
- "releaseVersion": "1.0.0-beta.3",
3
+ "releaseVersion": "1.0.0-beta.4",
4
4
  "requirements": {
5
5
  "node": ">=20.19.0"
6
6
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-workspace-zhuiyi",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.4",
4
4
  "description": "Local multi-project registry and safety layer for AI coding workspaces",
5
5
  "author": "icebearx-ai",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@ const {
25
25
  const { resolveWorkspaceTools } = require("../../core/tools");
26
26
  const { createInteractiveUi, formatExtensionChoice } = require("../../init/ui");
27
27
  const { createRegistryExtensionPicker } = require("../../init/registry-picker");
28
+ const { formatExtensionChanges } = require("../../init/extension-summary");
28
29
  const { confirm } = require("../confirmation");
29
30
  const { selectionResult, success } = require("../result");
30
31
 
@@ -43,18 +44,11 @@ function formatUninstallPlan(plan) {
43
44
  }
44
45
 
45
46
  function formatInstallPlan(plans, options = {}) {
46
- const actionFor = options.actionFor || (() => "install");
47
- const title = options.title || `Install ${plans.length} extension${plans.length === 1 ? "" : "s"}:`;
48
- return [
49
- title,
50
- ...plans.flatMap((plan) => [
51
- ` ${actionFor(plan) === "update" ? "Update" : "Install"} ${plan.id}@${plan.version} [${plan.source || "local"}] [Extension Spec ${plan.extensionSpecVersion}]`,
52
- ` PACKAGE ${plan.packageSha256}`,
53
- ...(plan.capabilities.networkHosts || []).map((host) => ` NETWORK https://${host}`),
54
- ...plan.artifacts.map((artifact) => ` WRITE ${artifact.target} (${artifact.kind})`),
55
- ...(plan.hooks || []).map((hook) => ` HOOK ${hook.id} (${hook.event}${hook.tools?.length ? ` · ${hook.tools.join(",")}` : ""}) -> ${hook.command}`),
56
- ]),
57
- ].join("\n");
47
+ return formatExtensionChanges(plans.map((plan) => ({ ...plan, source: plan.source || "local" })), [], {
48
+ title: options.title,
49
+ actionFor: options.actionFor,
50
+ action: options.action,
51
+ });
58
52
  }
59
53
 
60
54
  function formatRegistrySearchText(result) {
@@ -136,18 +130,35 @@ function lifecycleResultEntry(entry, action) {
136
130
  function lifecycleResultText(results, action) {
137
131
  const actionFor = typeof action === "function" ? action : () => action;
138
132
  const lines = results.map((entry) => entry.status === "installed"
139
- ? `${actionFor(entry) === "upgrade" || actionFor(entry) === "update" ? "Updated" : "Installed"} ${entry.id}@${entry.version}.`
140
- : entry.status === "skipped"
141
- ? `Skipped ${entry.id}@${entry.version}: already current.`
142
- : `Failed ${entry.id}${entry.version ? `@${entry.version}` : ""}: ${entry.message}`);
133
+ ? `${actionFor(entry) === "upgrade" || actionFor(entry) === "update" ? "Updated" : "Installed"} ${entry.name || entry.id}@${entry.version}.`
134
+ : entry.status === "uninstalled"
135
+ ? `Uninstalled ${entry.name || entry.id}${entry.version ? `@${entry.version}` : ""}.`
136
+ : entry.status === "skipped"
137
+ ? `Skipped ${entry.name || entry.id}${entry.version ? `@${entry.version}` : ""}: already current.`
138
+ : `Failed ${entry.name || entry.id}${entry.version ? `@${entry.version}` : ""}: ${entry.message}`);
143
139
  const installed = results.filter((entry) => entry.status === "installed").length;
140
+ const uninstalled = results.filter((entry) => entry.status === "uninstalled").length;
144
141
  const skipped = results.filter((entry) => entry.status === "skipped").length;
145
142
  const failed = results.filter((entry) => entry.status === "failed").length;
146
143
  const hasUpdate = results.some((entry) => actionFor(entry) === "upgrade" || actionFor(entry) === "update");
147
- lines.push(`Extensions: ${installed} ${hasUpdate ? "applied" : "installed"}, ${skipped} skipped, ${failed} failed.`);
144
+ lines.push(`Extensions: ${installed} ${hasUpdate ? "applied" : "installed"}, ${uninstalled} uninstalled, ${skipped} skipped, ${failed} failed.`);
148
145
  return lines.join("\n");
149
146
  }
150
147
 
148
+ function uninstallResultEntry(entry) {
149
+ return {
150
+ name: entry.id,
151
+ ok: entry.status !== "failed",
152
+ action: entry.status === "skipped" ? "skip" : "uninstall",
153
+ status: entry.status,
154
+ version: entry.version || null,
155
+ ...(entry.reason ? { reason: entry.reason } : {}),
156
+ ...(entry.removed ? { removed: entry.removed } : {}),
157
+ ...(entry.code ? { code: entry.code } : {}),
158
+ ...(entry.message ? { message: entry.message } : {}),
159
+ };
160
+ }
161
+
151
162
  function validateInstallOptions(invocation) {
152
163
  if (invocation.options.version) {
153
164
  if (invocation.args.length !== 1) {
@@ -331,27 +342,54 @@ async function executeExtensionSearch(invocation) {
331
342
  }, "No extensions selected. No changes were made.");
332
343
  }
333
344
  const requested = normalizeExtensionNames(selected.map((entry) => entry.id));
334
- const actionById = new Map(selected.map((entry) => [entry.id, entry.action === "update" ? "update" : "install"]));
345
+ const actionById = new Map(selected.map((entry) => [entry.id, entry.action || "install"]));
346
+ const installSelections = selected.filter((entry) => entry.action !== "uninstall" && entry.action !== "keep");
347
+ const uninstallSelections = selected.filter((entry) => entry.action === "uninstall");
348
+ const planningState = structuredClone(stateInspection.state);
349
+ for (const entry of uninstallSelections) delete planningState.extensions[entry.id];
335
350
  const tools = resolveWorkspaceTools({ state: loadState(invocation.root), manifestTools: loadInitManifest().tools }).tools;
336
- const preparation = await prepareRegistryExtensionPlans({
337
- ...dependencies,
338
- requested,
339
- tools,
340
- state: stateInspection.state,
341
- stateError: stateInspection.error,
342
- extensionsRoot: dependencies.extensionsRoot,
343
- extensionStoreRoot: dependencies.extensionStoreRoot || defaultExtensionStoreRoot(),
344
- provider: dependencies.nexusProvider,
345
- });
346
- if (preparation.plans.length > 0) {
347
- const planText = formatInstallPlan(preparation.plans, {
348
- title: `Apply ${preparation.plans.length} extension${preparation.plans.length === 1 ? "" : "s"}:`,
351
+ const preparation = installSelections.length > 0
352
+ ? await prepareRegistryExtensionPlans({
353
+ ...dependencies,
354
+ requested: installSelections.map((entry) => entry.id),
355
+ tools,
356
+ state: planningState,
357
+ stateError: stateInspection.error,
358
+ extensionsRoot: dependencies.extensionsRoot,
359
+ extensionStoreRoot: dependencies.extensionStoreRoot || defaultExtensionStoreRoot(),
360
+ provider: dependencies.nexusProvider,
361
+ })
362
+ : { plans: [], failures: [], diagnostics: [] };
363
+ preparation.plans = preparation.plans.map((plan) => ({ ...plan, action: actionById.get(plan.id) || "install" }));
364
+ const uninstallPlans = [];
365
+ const uninstallPreparationFailures = [];
366
+ for (const entry of uninstallSelections) {
367
+ try {
368
+ uninstallPlans.push(planExtensionUninstall(invocation.root, entry.id));
369
+ } catch (error) {
370
+ uninstallPreparationFailures.push({ id: entry.id, version: null, status: "failed", code: error.code || "EXTENSION_UNINSTALL_FAILED", message: error.message, statePersisted: false, phase: "prepare" });
371
+ }
372
+ }
373
+ const effectiveUninstallPlans = uninstallPlans.filter((plan) => plan.action !== "skip");
374
+ if (preparation.plans.length > 0 || effectiveUninstallPlans.length > 0) {
375
+ const planText = formatExtensionChanges(preparation.plans, effectiveUninstallPlans, {
349
376
  actionFor: (plan) => actionById.get(plan.id) || "install",
350
377
  });
351
378
  if (!(await (dependencies.confirm || confirm)(`${planText}\nContinue?`, invocation.options))) {
352
379
  throw new WorkspaceError("CLI_CANCELLED", "Extension search selection cancelled.");
353
380
  }
354
381
  }
382
+ const uninstallResults = [];
383
+ for (const plan of effectiveUninstallPlans) {
384
+ try {
385
+ uninstallResults.push({ ...applyExtensionUninstall(plan, {
386
+ ...(dependencies || {}),
387
+ extensionStoreRoot: dependencies.extensionStoreRoot || defaultExtensionStoreRoot(),
388
+ }), action: "uninstall" });
389
+ } catch (error) {
390
+ uninstallResults.push({ id: plan.id, version: plan.version, status: "failed", action: "uninstall", code: error.code || "EXTENSION_UNINSTALL_FAILED", message: error.message });
391
+ }
392
+ }
355
393
  const batch = runExtensionBatch(invocation.root, preparation.plans, (extension) => ({
356
394
  schemaVersion: 1,
357
395
  extensionSpecVersion: extension.extensionSpecVersion,
@@ -359,8 +397,8 @@ async function executeExtensionSearch(invocation) {
359
397
  workspace: { name: workspace.name, uuid: workspace.uuid, language: workspace.language },
360
398
  tools,
361
399
  }), {
362
- requested,
363
- preFailures: preparation.failures,
400
+ requested: installSelections.map((entry) => entry.id),
401
+ preFailures: [...preparation.failures, ...uninstallPreparationFailures],
364
402
  useExtensionStore: true,
365
403
  extensionStoreRoot: dependencies.extensionStoreRoot || defaultExtensionStoreRoot(),
366
404
  });
@@ -381,12 +419,32 @@ async function executeExtensionSearch(invocation) {
381
419
  extension: entry.id,
382
420
  version: entry.version,
383
421
  }))),
422
+ ...uninstallResults.filter((entry) => entry.status === "failed").map((entry) => ({
423
+ code: entry.code || "EXTENSION_UNINSTALL_FAILED",
424
+ severity: "error",
425
+ message: entry.message || `Extension ${entry.id} failed to uninstall.`,
426
+ extension: entry.id,
427
+ version: entry.version,
428
+ })),
384
429
  ];
385
- const results = decorateBatchResults(batch, preparation.plans, invocation.options);
430
+ const installedResults = decorateBatchResults(batch, preparation.plans, invocation.options);
431
+ const resultById = new Map([
432
+ ...installedResults.map((entry) => [entry.id, entry]),
433
+ ...uninstallResults.map((entry) => [entry.id, entry]),
434
+ ...uninstallPreparationFailures.map((entry) => [entry.id, entry]),
435
+ ]);
436
+ const rawResults = selected.map((selection) => {
437
+ if (selection.action === "keep") {
438
+ return { id: selection.id, version: stateInspection.state.extensions[selection.id]?.installed?.version || null, status: "skipped", reason: "already-installed", action: "skip" };
439
+ }
440
+ const entry = resultById.get(selection.id);
441
+ return entry || { id: selection.id, status: "failed", message: "Extension operation did not run." };
442
+ });
443
+ const results = rawResults.map((entry) => lifecycleResultEntry(entry, entry.action || actionById.get(entry.id) || "install"));
386
444
  ui.close?.("Extension selection applied.");
387
- return selectionResult("extension.search", requested, results.map((entry) => lifecycleResultEntry(entry, actionById.get(entry.id) || "install")), {
445
+ return selectionResult("extension.search", requested, results, {
388
446
  diagnostics,
389
- text: lifecycleResultText(results, (entry) => actionById.get(entry.id) || "install"),
447
+ text: lifecycleResultText(results, (entry) => entry.action || actionById.get(entry.id) || "install"),
390
448
  });
391
449
  }
392
450
 
@@ -1,13 +1,15 @@
1
1
  const path = require("node:path");
2
2
 
3
- const { loadState } = require("../../core/config");
3
+ const { loadState, resolveWorkspaceDev } = require("../../core/config");
4
4
  const { WorkspaceError } = require("../../core/errors");
5
5
  const { acquireInitLock } = require("../../core/init-lock");
6
6
  const {
7
+ applyExtensionUninstall,
7
8
  discoverSystemExtensions,
8
9
  emptyExtensionState,
9
10
  inspectExtensionState,
10
11
  parseExtensionSelection,
12
+ planExtensionUninstall,
11
13
  prepareExtensionPlans,
12
14
  runExtensionBatch,
13
15
  } = require("../../core/extensions");
@@ -19,6 +21,7 @@ const { initializeWorkspace } = require("../../core/initializer");
19
21
  const { resolveWorkspaceTools } = require("../../core/tools");
20
22
  const { collectInitPlan } = require("../../init/wizard");
21
23
  const { createRegistryExtensionPicker, mapPickerPage } = require("../../init/registry-picker");
24
+ const { formatExtensionChanges } = require("../../init/extension-summary");
22
25
  const { success } = require("../result");
23
26
 
24
27
  function stateWithPlans(state, plans) {
@@ -54,6 +57,17 @@ function migrationData(plan) {
54
57
  };
55
58
  }
56
59
 
60
+ function parseDevOption(value) {
61
+ if (value === undefined) return undefined;
62
+ if (value === "true") return true;
63
+ if (value === "false") return false;
64
+ throw new WorkspaceError("CLI_INVALID_OPTION_VALUE", "--dev requires true or false", {
65
+ option: "dev",
66
+ value,
67
+ allowed: ["true", "false"],
68
+ });
69
+ }
70
+
57
71
  async function executeInit(invocation) {
58
72
  const root = path.resolve(invocation.args[0] || ".");
59
73
  const releaseInitLock = await acquireInitLock(root);
@@ -66,6 +80,8 @@ async function executeInit(invocation) {
66
80
 
67
81
  async function executeInitUnlocked(invocation, root) {
68
82
  const { options } = invocation;
83
+ const requestedDev = parseDevOption(options.dev);
84
+ const dev = resolveWorkspaceDev(root, requestedDev);
69
85
  const dependencies = invocation.dependencies && Object.prototype.hasOwnProperty.call(invocation.dependencies, "defaultRegistryUrl")
70
86
  ? invocation.dependencies
71
87
  : { ...(invocation.dependencies || {}), defaultRegistryUrl: DEFAULT_NEXUS_REGISTRY };
@@ -97,7 +113,13 @@ async function executeInitUnlocked(invocation, root) {
97
113
  const extensionStateInspection = inspectExtensions ? inspectExtensionState(root) : { state: emptyExtensionState(), error: null };
98
114
  const extensionState = extensionStateInspection.state;
99
115
  const systemCatalogResult = inspectExtensions ? discoverSystemExtensions({ tolerant: true }) : { catalog: [], invalid: [] };
100
- const systemRequestedExtensions = systemCatalogResult.catalog.filter((entry) => entry.latestSupported).map((entry) => entry.id);
116
+ const systemExtensionIds = systemCatalogResult.catalog.filter((entry) => entry.latestSupported).map((entry) => entry.id);
117
+ const systemRequestedExtensions = dev ? systemExtensionIds : [];
118
+ const systemRemovalPlans = dev || extensionStateInspection.error
119
+ ? []
120
+ : Object.entries(extensionState.extensions)
121
+ .filter(([id, entry]) => entry.installed?.system === true || systemExtensionIds.includes(id))
122
+ .map(([id]) => planExtensionUninstall(root, id, { systemManaged: true }));
101
123
  let systemPreparation = { plans: [], failures: [], diagnostics: [] };
102
124
  let planningState = extensionState;
103
125
  const prepareSystemForTools = (tools) => {
@@ -112,17 +134,27 @@ async function executeInitUnlocked(invocation, root) {
112
134
  if (!interactive) prepareSystemForTools(resolvedTools.tools);
113
135
  let interactiveOrdinaryPreparation = { plans: [], failures: [], diagnostics: [] };
114
136
  const prepareOrdinaryExtensions = async (selected, selectedTools) => {
137
+ const installSelections = selected.filter((entry) => entry.action !== "uninstall" && entry.action !== "keep");
138
+ const uninstallSelections = selected.filter((entry) => entry.action === "uninstall");
139
+ const uninstallPlans = uninstallSelections.map((entry) => planExtensionUninstall(root, entry.id));
140
+ const installState = structuredClone(planningState);
141
+ for (const entry of uninstallSelections) delete installState.extensions[entry.id];
115
142
  interactiveOrdinaryPreparation = await prepareRegistryExtensionPlans({
116
143
  ...dependencies,
117
- requested: selected.map((entry) => entry.id),
144
+ requested: installSelections.map((entry) => entry.id),
118
145
  tools: selectedTools || resolvedTools.tools,
119
- state: planningState,
146
+ state: installState,
120
147
  stateError: extensionStateInspection.error,
121
148
  extensionsRoot: dependencies.extensionsRoot,
122
149
  extensionStoreRoot: options.extensionStoreRoot || dependencies.extensionStoreRoot || defaultExtensionStoreRoot(),
123
150
  provider: dependencies.nexusProvider,
124
151
  });
125
- return interactiveOrdinaryPreparation.plans;
152
+ interactiveOrdinaryPreparation.plans = interactiveOrdinaryPreparation.plans.map((plan) => ({
153
+ ...plan,
154
+ action: selected.find((entry) => entry.id === plan.id)?.action || "install",
155
+ }));
156
+ interactiveOrdinaryPreparation.uninstallPlans = uninstallPlans;
157
+ return interactiveOrdinaryPreparation;
126
158
  };
127
159
  let plan = null;
128
160
  if (interactive) {
@@ -138,7 +170,8 @@ async function executeInitUnlocked(invocation, root) {
138
170
  initialExtensions: explicitExtensions === null ? undefined : explicitExtensions,
139
171
  prepareSystemExtensions: prepareSystemForTools,
140
172
  prepareExtensions: prepareOrdinaryExtensions,
141
- extensionPicker: createRegistryExtensionPicker({ dependencies, state: extensionState, systemIds: new Set(systemRequestedExtensions) }),
173
+ extensionPicker: createRegistryExtensionPicker({ dependencies, state: extensionState, systemIds: new Set(systemExtensionIds) }),
174
+ formatExtensionChanges,
142
175
  });
143
176
  } catch (error) {
144
177
  if (error.code === "INIT_CANCELLED") {
@@ -151,7 +184,12 @@ async function executeInitUnlocked(invocation, root) {
151
184
  const ordinaryRequestedExtensions = explicitExtensions !== null
152
185
  ? explicitExtensions
153
186
  : plan ? plan.extensions.filter((entry) => entry.system !== true).map((entry) => entry.id) : [];
154
- const requestedExtensions = [...new Set([...ordinaryRequestedExtensions, ...systemRequestedExtensions])];
187
+ const ordinaryRemovalPlans = plan?.extensionRemovals || [];
188
+ const requestedExtensions = [...new Set([
189
+ ...ordinaryRequestedExtensions,
190
+ ...ordinaryRemovalPlans.map((entry) => typeof entry === "string" ? entry : entry.id),
191
+ ...systemRequestedExtensions,
192
+ ])];
155
193
  const extensionPreparation = { plans: [], failures: [], diagnostics: [] };
156
194
  if (interactive) {
157
195
  extensionPreparation.plans = [...systemPreparation.plans, ...interactiveOrdinaryPreparation.plans];
@@ -182,6 +220,7 @@ async function executeInitUnlocked(invocation, root) {
182
220
  const result = await initializeWorkspace(root, {
183
221
  run,
184
222
  tools,
223
+ dev,
185
224
  force: options.force === true,
186
225
  yes: plan ? true : options.yes === true,
187
226
  workspaceName: plan?.workspace.name || options["workspace-name"],
@@ -191,7 +230,46 @@ async function executeInitUnlocked(invocation, root) {
191
230
  initPlan: plan,
192
231
  onStage: null,
193
232
  });
194
- const extensionResult = runExtensionBatch(root, extensionPlans, (extension) => ({
233
+ const systemRemovalResults = [];
234
+ const systemRemovalDiagnostics = [];
235
+ for (const removalPlan of systemRemovalPlans) {
236
+ try {
237
+ systemRemovalResults.push(applyExtensionUninstall(removalPlan, {
238
+ systemManaged: true,
239
+ extensionStoreRoot: options.extensionStoreRoot || dependencies.extensionStoreRoot || defaultExtensionStoreRoot(),
240
+ }));
241
+ } catch (error) {
242
+ systemRemovalResults.push({
243
+ id: removalPlan.id,
244
+ version: removalPlan.version,
245
+ status: "failed",
246
+ code: error.code || "SYSTEM_EXTENSION_DISABLE_FAILED",
247
+ message: error.message,
248
+ });
249
+ systemRemovalDiagnostics.push({
250
+ code: "SYSTEM_EXTENSION_DISABLE_FAILED",
251
+ severity: "warning",
252
+ message: `System extension ${removalPlan.id} could not be disabled: ${error.message}`,
253
+ extension: removalPlan.id,
254
+ causeCode: error.code || null,
255
+ });
256
+ }
257
+ }
258
+ const ordinaryRemovalResults = [];
259
+ const ordinaryRemovalDiagnostics = [];
260
+ for (const removalPlan of ordinaryRemovalPlans) {
261
+ const planToApply = typeof removalPlan === "string" ? planExtensionUninstall(root, removalPlan) : removalPlan;
262
+ try {
263
+ const removal = applyExtensionUninstall(planToApply, {
264
+ extensionStoreRoot: options.extensionStoreRoot || dependencies.extensionStoreRoot || defaultExtensionStoreRoot(),
265
+ });
266
+ ordinaryRemovalResults.push({ ...removal, action: removal.status === "skipped" ? "skip" : "uninstall" });
267
+ } catch (error) {
268
+ ordinaryRemovalResults.push({ id: planToApply.id, version: planToApply.version || null, status: "failed", action: "uninstall", code: error.code || "EXTENSION_UNINSTALL_FAILED", message: error.message });
269
+ ordinaryRemovalDiagnostics.push({ code: error.code || "EXTENSION_UNINSTALL_FAILED", severity: "warning", message: `Extension ${planToApply.id} failed to uninstall: ${error.message}`, extension: planToApply.id });
270
+ }
271
+ }
272
+ const extensionBatch = runExtensionBatch(root, extensionPlans, (extension) => ({
195
273
  schemaVersion: 1,
196
274
  extensionSpecVersion: extension.extensionSpecVersion,
197
275
  extension: { id: extension.id, version: extension.version },
@@ -202,12 +280,31 @@ async function executeInitUnlocked(invocation, root) {
202
280
  },
203
281
  tools,
204
282
  }), {
205
- requested: requestedExtensions,
283
+ requested: [...new Set([...ordinaryRequestedExtensions, ...systemRequestedExtensions])],
206
284
  preFailures: extensionPreparation.failures,
207
285
  useExtensionStore: true,
208
286
  extensionStoreRoot: options.extensionStoreRoot || defaultExtensionStoreRoot(),
209
287
  });
288
+ const extensionResultEntries = [...ordinaryRemovalResults, ...extensionBatch.results];
289
+ const extensionResult = {
290
+ requested: requestedExtensions,
291
+ results: extensionResultEntries,
292
+ summary: {
293
+ installed: extensionResultEntries.filter((entry) => entry.status === "installed").length,
294
+ uninstalled: extensionResultEntries.filter((entry) => entry.status === "uninstalled").length,
295
+ skipped: extensionResultEntries.filter((entry) => entry.status === "skipped").length,
296
+ failed: extensionResultEntries.filter((entry) => entry.status === "failed").length,
297
+ },
298
+ };
210
299
  const extensionDiagnostics = [
300
+ ...(dev === false && extensionStateInspection.error ? [{
301
+ code: "SYSTEM_EXTENSION_DISABLE_SKIPPED",
302
+ severity: "warning",
303
+ message: `System extension cleanup was skipped because extension state could not be read: ${extensionStateInspection.error.message}`,
304
+ causeCode: extensionStateInspection.error.code || null,
305
+ }] : []),
306
+ ...systemRemovalDiagnostics,
307
+ ...ordinaryRemovalDiagnostics,
211
308
  ...extensionPreparation.diagnostics,
212
309
  ...extensionResult.results
213
310
  .filter((entry) => entry.status === "failed")
@@ -240,6 +337,7 @@ async function executeInitUnlocked(invocation, root) {
240
337
  managedFiles: result.managedFiles,
241
338
  localConfig: result.localConfig,
242
339
  workspace: result.workspaceConfig.workspace,
340
+ dev: result.workspaceConfig.workspace.dev,
243
341
  language: result.language,
244
342
  tools: toolSelection,
245
343
  migration: migrationData(result.migration),
@@ -247,6 +345,10 @@ async function executeInitUnlocked(invocation, root) {
247
345
  verification: result.verification,
248
346
  plan: result.initPlan,
249
347
  stages: result.stages.map((stage) => stage.name),
348
+ systemExtensions: {
349
+ enabled: dev,
350
+ results: systemRemovalResults,
351
+ },
250
352
  extensions: extensionResult,
251
353
  };
252
354
  const lines = [
@@ -254,9 +356,9 @@ async function executeInitUnlocked(invocation, root) {
254
356
  `Workspace: ${result.workspaceConfig.workspace.name} (${result.workspaceConfig.workspace.uuid})`,
255
357
  `Language: ${result.language}`,
256
358
  `Tools: ${tools.length ? tools.join(", ") : "none"} (${toolSelection.source})`,
257
- `Extensions: ${extensionResult.summary.installed} installed, ${extensionResult.summary.skipped} skipped, ${extensionResult.summary.failed} failed`,
359
+ `Extensions: ${extensionResult.summary.installed} installed, ${extensionResult.summary.uninstalled || 0} uninstalled, ${extensionResult.summary.skipped} skipped, ${extensionResult.summary.failed} failed`,
258
360
  ];
259
- if (result.localConfig.action === "write" || result.permissions.action === "skip") {
361
+ if (result.workspaceConfig.workspace.dev !== false && (result.localConfig.action === "write" || result.permissions.action === "skip")) {
260
362
  lines.push(tools.length > 0
261
363
  ? "Add local projects with the `codew-add-projects` skill."
262
364
  : "Add local projects with `code-workspace project inspect`, then register a complete project record.");
@@ -264,4 +366,4 @@ async function executeInitUnlocked(invocation, root) {
264
366
  return success("init", data, lines.join("\n"), extensionDiagnostics);
265
367
  }
266
368
 
267
- module.exports = { createRegistryExtensionPicker, executeInit, mapPickerPage, migrationData, stateWithPlans };
369
+ module.exports = { createRegistryExtensionPicker, executeInit, mapPickerPage, migrationData, parseDevOption, stateWithPlans };
@@ -41,7 +41,7 @@ const COMMANDS = [
41
41
  { path: ["config", "set"], args: [{ name: "key", required: true }, { name: "value", required: true }], workspace: "none", config: [], interaction: "never", effects: "planned-write", options: {} },
42
42
  { path: ["config", "delete"], args: [{ name: "key", required: true }], workspace: "none", config: [], interaction: "never", effects: "planned-write", options: {} },
43
43
  { path: ["init"], args: [{ name: "path", required: false }], workspace: "target", config: [], interaction: "optional", effects: "planned-write", options: {
44
- tools: { type: "string" }, extensions: { type: "string" }, "workspace-name": { type: "string" }, language: { type: "string" },
44
+ tools: { type: "string" }, extensions: { type: "string" }, "workspace-name": { type: "string" }, language: { type: "string" }, dev: { type: "string" },
45
45
  yes: { type: "boolean" }, force: { type: "boolean" },
46
46
  } },
47
47
  { path: ["update"], args: [], workspace: "required", config: [], interaction: "optional", effects: "planned-write", options: {
@@ -39,6 +39,22 @@ function statePath(root) {
39
39
  return path.join(root, LOCAL_DIRECTORY, STATE_FILE);
40
40
  }
41
41
 
42
+ function resolveWorkspaceDev(root, explicit) {
43
+ if (explicit !== undefined) return explicit;
44
+ if (!fs.existsSync(configPath(root))) return true;
45
+ const document = readConfigDocument(root);
46
+ const dev = document.value.workspace?.dev;
47
+ if (dev === undefined) return true;
48
+ if (typeof dev !== "boolean") {
49
+ throw new WorkspaceError("WORKSPACE_DEV_INVALID", "workspace.dev must be a boolean", {
50
+ actual: dev,
51
+ supported: [true, false],
52
+ file: document.file,
53
+ });
54
+ }
55
+ return dev;
56
+ }
57
+
42
58
  function findWorkspaceRoot(start = process.cwd()) {
43
59
  let current = path.resolve(start);
44
60
  while (true) {
@@ -91,7 +107,18 @@ function normalizeWorkspaceLanguage(value, options = {}) {
91
107
  function normalizeWorkspace(value, options = {}) {
92
108
  const identity = normalizeWorkspaceIdentity(value);
93
109
  if (!identity) return null;
94
- return { ...identity, language: normalizeWorkspaceLanguage(value.language, options) };
110
+ const dev = value.dev;
111
+ if (dev !== undefined && typeof dev !== "boolean") {
112
+ throw new WorkspaceError("WORKSPACE_DEV_INVALID", "workspace.dev must be a boolean", {
113
+ actual: dev,
114
+ supported: [true, false],
115
+ });
116
+ }
117
+ return {
118
+ ...identity,
119
+ language: normalizeWorkspaceLanguage(value.language, options),
120
+ ...(dev === undefined ? {} : { dev }),
121
+ };
95
122
  }
96
123
 
97
124
  function normalizeProjects(value) {
@@ -508,6 +535,7 @@ module.exports = {
508
535
  renderProjectConfig,
509
536
  resolveProjectConfigPath,
510
537
  resolveProjectConfigReference,
538
+ resolveWorkspaceDev,
511
539
  requireWorkspaceRoot,
512
540
  saveConfig,
513
541
  saveProjectConfig,
@@ -1453,14 +1453,15 @@ function runExtensionBatch(root, plans, context, options = {}) {
1453
1453
  };
1454
1454
  }
1455
1455
 
1456
- function planExtensionUninstall(root, id) {
1456
+ function planExtensionUninstall(root, id, options = {}) {
1457
1457
  const extensionId = validateExtensionName(id);
1458
- if (isSystemExtensionId(extensionId)) {
1458
+ const systemManaged = options.systemManaged === true;
1459
+ if (!systemManaged && isSystemExtensionId(extensionId)) {
1459
1460
  throw extensionError("EXTENSION_SYSTEM_MANAGED", `System extension cannot be manually uninstalled: ${extensionId}`, { extension: extensionId });
1460
1461
  }
1461
1462
  const state = loadExtensionState(root);
1462
1463
  const installed = state.extensions[extensionId]?.installed || null;
1463
- if (installed?.system === true) {
1464
+ if (!systemManaged && installed?.system === true) {
1464
1465
  throw extensionError("EXTENSION_SYSTEM_MANAGED", `System extension cannot be manually uninstalled: ${extensionId}`, { extension: extensionId });
1465
1466
  }
1466
1467
  if (!installed) return Object.freeze({ root: path.resolve(root), id: extensionId, action: "skip", reason: "not-installed", targets: [] });
@@ -1473,6 +1474,7 @@ function planExtensionUninstall(root, id) {
1473
1474
  return Object.freeze({
1474
1475
  root: path.resolve(root),
1475
1476
  id: extensionId,
1477
+ system: installed.system === true,
1476
1478
  action: "remove",
1477
1479
  version: installed.version,
1478
1480
  packageSha256: installed.packageSha256 || null,
@@ -1485,7 +1487,7 @@ function planExtensionUninstall(root, id) {
1485
1487
  }
1486
1488
 
1487
1489
  function applyExtensionUninstall(plan, options = {}) {
1488
- if (plan.system === true || isSystemExtensionId(plan.id)) {
1490
+ if (options.systemManaged !== true && (plan.system === true || isSystemExtensionId(plan.id))) {
1489
1491
  throw extensionError("EXTENSION_SYSTEM_MANAGED", `System extension cannot be manually uninstalled: ${plan.id}`, { extension: plan.id });
1490
1492
  }
1491
1493
  if (plan.action === "skip") return { id: plan.id, status: "skipped", reason: plan.reason, removed: [] };
@@ -50,11 +50,16 @@ async function collectWorkspaceSetup(root, options = {}) {
50
50
  const file = configPath(root);
51
51
  if (fs.existsSync(file)) {
52
52
  const current = loadConfig(root, { defaultLanguage: language });
53
+ const workspace = {
54
+ ...current.workspace,
55
+ dev: options.dev ?? current.workspace.dev ?? true,
56
+ };
53
57
  return normalizeConfig({
54
58
  ...current,
55
- workspace: current.workspace || {
59
+ workspace: current.workspace ? workspace : {
56
60
  name: options.workspaceName || DEFAULT_WORKSPACE_NAME,
57
61
  uuid: randomUUID(),
62
+ dev: options.dev ?? true,
58
63
  },
59
64
  });
60
65
  }
@@ -71,7 +76,7 @@ async function collectWorkspaceSetup(root, options = {}) {
71
76
  }
72
77
  return normalizeConfig({
73
78
  schemaVersion: 2,
74
- workspace: { name: name || DEFAULT_WORKSPACE_NAME, uuid: options.workspaceUuid || randomUUID(), language },
79
+ workspace: { name: name || DEFAULT_WORKSPACE_NAME, uuid: options.workspaceUuid || randomUUID(), language, dev: options.dev ?? true },
75
80
  projects: [],
76
81
  });
77
82
  }
@@ -106,7 +111,9 @@ async function initializeWorkspaceStages(rootInput, options = {}) {
106
111
  }
107
112
 
108
113
  const dependencies = await stage("Install workspace dependencies", () =>
109
- installWorkspaceDependencies(root, { run })
114
+ workspaceConfig.workspace.dev === false
115
+ ? { action: "skip", reason: "development mode disabled" }
116
+ : installWorkspaceDependencies(root, { run })
110
117
  );
111
118
  if (dependencies.action === "install") {
112
119
  options.transaction?.recordExternalEffect({
@@ -50,8 +50,13 @@ function mergePageSelections(selected, page, values) {
50
50
  }
51
51
  }
52
52
 
53
- function selectedResult(selected) {
54
- return [...selected.entries()].map(([id, action]) => ({ id, action }));
53
+ function selectedResult(selected, installedIds = []) {
54
+ const result = [...selected.entries()].map(([id, action]) => ({ id, action }));
55
+ const selectedIds = new Set(selected.keys());
56
+ for (const id of installedIds) {
57
+ if (!selectedIds.has(id)) result.push({ id, action: "uninstall" });
58
+ }
59
+ return result;
55
60
  }
56
61
 
57
62
  function pageOptions(page) {
@@ -140,7 +145,9 @@ async function runClackExtensionPicker(options = {}) {
140
145
  }
141
146
 
142
147
  const selected = new Map();
143
- for (const id of options.selectedIds || []) selected.set(String(id), "install");
148
+ const selectionActions = options.selectionActions || {};
149
+ for (const id of options.selectedIds || []) selected.set(String(id), selectionActions[id] || "install");
150
+ const installedIds = [...new Set((options.installedIds || []).map(String))];
144
151
  let query = String(options.query || "");
145
152
  let session = null;
146
153
  let page = null;
@@ -203,7 +210,7 @@ async function runClackExtensionPicker(options = {}) {
203
210
  }
204
211
  if (action === "done") {
205
212
  closeSession();
206
- return { status: "submitted", selections: selectedResult(selected) };
213
+ return { status: "submitted", selections: selectedResult(selected, installedIds) };
207
214
  }
208
215
  if (action === "search") {
209
216
  promptForQuery = true;
@@ -255,7 +262,7 @@ async function runClackExtensionPicker(options = {}) {
255
262
  closeSession();
256
263
  return {
257
264
  status: "submitted",
258
- selections: selectedResult(selected),
265
+ selections: selectedResult(selected, installedIds),
259
266
  };
260
267
  }
261
268
  if (action === "search") {
@@ -284,7 +291,7 @@ async function runClackExtensionPicker(options = {}) {
284
291
  closeSession();
285
292
  return {
286
293
  status: "submitted",
287
- selections: selectedResult(selected),
294
+ selections: selectedResult(selected, installedIds),
288
295
  };
289
296
  }
290
297
  if (retryAction === "retry") {
@@ -23,8 +23,8 @@ function normalizePickerItem(value) {
23
23
  const status = PICKER_STATUSES.includes(item.status)
24
24
  ? item.status
25
25
  : item.available === false ? "unavailable" : "not-installed";
26
- const disabled = item.disabled === true || status === "installed-current" || status === "unavailable";
27
- const action = disabled ? "none" : status === "installed-outdated" ? "update" : "install";
26
+ const disabled = item.disabled === true || status === "unavailable" || (status === "installed-current" && item.allowUninstall !== true);
27
+ const action = disabled ? "none" : item.action || (status === "installed-outdated" ? "update" : status === "installed-current" ? "keep" : "install");
28
28
  return Object.freeze({
29
29
  ...item,
30
30
  id,
@@ -69,6 +69,7 @@ function createPickerState(options = {}) {
69
69
  error: null,
70
70
  selectedIds: Object.freeze([...(options.selectedIds || [])]),
71
71
  selectionActions: Object.freeze({ ...(options.selectionActions || {}) }),
72
+ installedIds: Object.freeze([...(options.installedIds || [])]),
72
73
  cancelled: false,
73
74
  submitted: false,
74
75
  statusMessage: "",
@@ -80,7 +81,12 @@ function withState(state, changes) {
80
81
  }
81
82
 
82
83
  function selectedResult(state) {
83
- return state.selectedIds.map((id) => ({ id, action: state.selectionActions[id] || "install" }));
84
+ const selected = new Set(state.selectedIds);
85
+ const result = state.selectedIds.map((id) => ({ id, action: state.selectionActions[id] || "install" }));
86
+ for (const id of state.installedIds || []) {
87
+ if (!selected.has(id)) result.push({ id, action: "uninstall" });
88
+ }
89
+ return result;
84
90
  }
85
91
 
86
92
  function currentItem(state) {
@@ -284,7 +290,7 @@ async function runExtensionPicker(options = {}) {
284
290
  renderedLines = lines.length + 1;
285
291
  });
286
292
  const events = options.events || keypressEvents(options.input || process.stdin);
287
- let state = createPickerState({ query: options.query || "", selectedIds: options.selectedIds, selectionActions: options.selectionActions });
293
+ let state = createPickerState({ query: options.query || "", selectedIds: options.selectedIds, selectionActions: options.selectionActions, installedIds: options.installedIds });
288
294
  let session = await resolvePageProvider(pageProvider, state.query);
289
295
 
290
296
  async function load(method, event = "load-start") {
@@ -0,0 +1,51 @@
1
+ const { LOCAL_DIRECTORY } = require("../core/config");
2
+ const { EXTENSION_STATE_FILE } = require("../core/extensions");
3
+
4
+ function pluralizeExtension(count) {
5
+ return `extension${count === 1 ? "" : "s"}`;
6
+ }
7
+
8
+ function installAction(plan, actionFor) {
9
+ const action = actionFor(plan);
10
+ return action === "update" ? "Update" : "Install";
11
+ }
12
+
13
+ function formatExtensionChanges(installPlans = [], uninstallPlans = [], options = {}) {
14
+ const plans = Array.from(installPlans || []);
15
+ const removals = Array.from(uninstallPlans || []);
16
+ const total = plans.length + removals.filter((plan) => plan?.action !== "skip").length;
17
+ if (total === 0) return "No extension changes.";
18
+
19
+ const hasInstall = plans.length > 0;
20
+ const hasUninstall = removals.some((plan) => plan?.action !== "skip");
21
+ const title = options.title || (hasInstall && hasUninstall
22
+ ? `Apply ${total} extension changes:`
23
+ : hasUninstall
24
+ ? `Uninstall ${total} ${pluralizeExtension(total)}:`
25
+ : `${options.action === "update" ? "Update" : "Install"} ${total} ${pluralizeExtension(total)}:`);
26
+ const actionFor = options.actionFor || ((plan) => plan.action || options.action || "install");
27
+ const lines = [title];
28
+
29
+ for (const plan of plans) {
30
+ const details = [plan.source, plan.extensionSpecVersion ? `Extension Spec ${plan.extensionSpecVersion}` : null].filter(Boolean);
31
+ lines.push(` ${installAction(plan, actionFor)} ${plan.id}@${plan.version}${details.length ? ` [${details.join("] [")}]` : ""}`);
32
+ lines.push(` PACKAGE ${plan.packageSha256}`);
33
+ for (const host of plan.capabilities?.networkHosts || []) lines.push(` NETWORK https://${host}`);
34
+ for (const artifact of plan.artifacts || []) lines.push(` WRITE ${artifact.target} (${artifact.kind})`);
35
+ for (const hook of plan.hooks || []) {
36
+ lines.push(` HOOK ${hook.id} (${hook.event}${hook.tools?.length ? ` · ${hook.tools.join(",")}` : ""}) -> ${hook.command}`);
37
+ }
38
+ }
39
+
40
+ for (const plan of removals) {
41
+ if (!plan || plan.action === "skip") continue;
42
+ const id = typeof plan === "string" ? plan : plan.id;
43
+ const version = typeof plan === "string" ? null : plan.version;
44
+ lines.push(` Uninstall ${id}${version ? `@${version}` : ""}`);
45
+ for (const target of plan.targets || []) lines.push(` REMOVE ${target}`);
46
+ lines.push(` REMOVE ${LOCAL_DIRECTORY}/${EXTENSION_STATE_FILE} entry`);
47
+ }
48
+ return lines.join("\n");
49
+ }
50
+
51
+ module.exports = { formatExtensionChanges };
package/src/init/plan.js CHANGED
@@ -1,10 +1,11 @@
1
- function createInitPlan({ root, workspace, tools, language, extensions = [] }) {
1
+ function createInitPlan({ root, workspace, tools, language, extensions = [], extensionRemovals = [] }) {
2
2
  return {
3
3
  root,
4
4
  workspace,
5
5
  tools: tools.slice(),
6
6
  language,
7
7
  extensions: extensions.slice(),
8
+ extensionRemovals: extensionRemovals.slice(),
8
9
  };
9
10
  }
10
11
 
@@ -23,7 +23,9 @@ function mapPickerPage(page, state = emptyExtensionState(), systemIds = new Set(
23
23
  version: candidate?.version || null,
24
24
  packageSha256: candidate?.packageSha256 || null,
25
25
  status,
26
- disabled: status === "installed-current" || status === "unavailable",
26
+ disabled: status === "unavailable",
27
+ allowUninstall: Boolean(installed),
28
+ action: status === "installed-outdated" ? "update" : status === "installed-current" ? "keep" : "install",
27
29
  };
28
30
  });
29
31
  return { ...page, items };
@@ -31,9 +33,15 @@ function mapPickerPage(page, state = emptyExtensionState(), systemIds = new Set(
31
33
 
32
34
  function createRegistryExtensionPicker({ dependencies = {}, state = emptyExtensionState(), systemIds = new Set() }) {
33
35
  const createSession = dependencies.createRegistryExtensionBrowseSession || createRegistryExtensionBrowseSession;
36
+ const installedIds = Object.entries(state.extensions || {})
37
+ .filter(([id, entry]) => entry?.installed && entry.installed.system !== true && !systemIds.has(id))
38
+ .map(([id]) => id);
39
+ const installedSelectionActions = Object.fromEntries(installedIds.map((id) => [id, "keep"]));
34
40
  return async ({ ui, query = "", selectedIds = [] }) => ui.extensionPicker({
35
41
  query,
36
- selectedIds,
42
+ selectedIds: selectedIds.length > 0 ? selectedIds : installedIds,
43
+ selectionActions: installedSelectionActions,
44
+ installedIds,
37
45
  pageProvider: async (pageQuery) => {
38
46
  const session = await createSession({
39
47
  ...dependencies,
@@ -17,15 +17,25 @@ function normalizePickerSelections(value) {
17
17
  }).filter((entry) => entry.id);
18
18
  }
19
19
 
20
+ function normalizePreparedExtensions(value, selected = []) {
21
+ const prepared = Array.isArray(value) ? { plans: value } : (value || {});
22
+ return {
23
+ plans: Array.from(prepared.plans || []),
24
+ uninstallPlans: Array.isArray(prepared.uninstallPlans)
25
+ ? Array.from(prepared.uninstallPlans)
26
+ : selected.filter((entry) => entry.action === "uninstall").map((entry) => entry.id),
27
+ };
28
+ }
29
+
20
30
  async function collectExtensionPlans(options, ui, tools) {
21
31
  if (options.extensions !== undefined) {
22
32
  const selected = normalizePickerSelections(options.extensions);
23
- if (typeof options.prepareExtensions === "function") return options.prepareExtensions(selected, tools);
33
+ if (typeof options.prepareExtensions === "function") return normalizePreparedExtensions(await options.prepareExtensions(selected, tools), selected);
24
34
  const catalog = options.extensionCatalog || [];
25
- return resolveExtensionPlans(catalog, selected.map((entry) => entry.id), {
35
+ return { plans: resolveExtensionPlans(catalog, selected.filter((entry) => entry.action !== "uninstall" && entry.action !== "keep").map((entry) => entry.id), {
26
36
  tools,
27
37
  state: options.extensionState,
28
- });
38
+ }), uninstallPlans: [] };
29
39
  }
30
40
 
31
41
  if (typeof options.extensionPicker === "function") {
@@ -35,21 +45,21 @@ async function collectExtensionPlans(options, ui, tools) {
35
45
  selectedIds: options.initialExtensions || [],
36
46
  query: options.extensionQuery || "",
37
47
  }));
38
- if (typeof options.prepareExtensions === "function") return options.prepareExtensions(selected, tools);
39
- return selected;
48
+ if (typeof options.prepareExtensions === "function") return normalizePreparedExtensions(await options.prepareExtensions(selected, tools), selected);
49
+ return { plans: selected.filter((entry) => entry.action !== "uninstall" && entry.action !== "keep"), uninstallPlans: selected.filter((entry) => entry.action === "uninstall").map((entry) => entry.id) };
40
50
  } catch (error) {
41
51
  ui.note("Extensions unavailable", [
42
52
  error.message || "The Nexus extension Registry could not be reached.",
43
53
  "Ordinary extensions will be skipped; core Workspace initialization can continue.",
44
54
  ]);
45
- return [];
55
+ return { plans: [], uninstallPlans: [] };
46
56
  }
47
57
  }
48
58
 
49
59
  // Kept for direct callers that inject a catalog (production init no longer does).
50
60
  const extensionCatalog = options.extensionCatalog || [];
51
61
  const supportedExtensions = extensionCatalog.filter((entry) => entry.latestSupported);
52
- if (supportedExtensions.length === 0) return [];
62
+ if (supportedExtensions.length === 0) return { plans: [], uninstallPlans: [] };
53
63
  const extensionNames = await ui.multiselect(
54
64
  "Extensions (experimental, select any)",
55
65
  supportedExtensions.map((entry) => ({
@@ -58,10 +68,10 @@ async function collectExtensionPlans(options, ui, tools) {
58
68
  })),
59
69
  options.initialExtensions !== undefined ? options.initialExtensions : []
60
70
  );
61
- return resolveExtensionPlans(extensionCatalog, extensionNames, {
71
+ return { plans: resolveExtensionPlans(extensionCatalog, extensionNames, {
62
72
  tools,
63
73
  state: options.extensionState,
64
- });
74
+ }), uninstallPlans: [] };
65
75
  }
66
76
 
67
77
  async function collectInitPlan(root, manifest, options = {}) {
@@ -101,18 +111,31 @@ async function collectInitPlan(root, manifest, options = {}) {
101
111
  const systemExtensions = typeof options.prepareSystemExtensions === "function"
102
112
  ? await options.prepareSystemExtensions(tools)
103
113
  : (options.systemExtensions || []);
114
+ const ordinary = await collectExtensionPlans(options, ui, tools);
104
115
  const extensions = [
105
116
  ...systemExtensions,
106
- ...(await collectExtensionPlans(options, ui, tools)),
117
+ ...ordinary.plans,
107
118
  ];
108
119
  const workspace = existing?.workspace || { name, uuid: randomUUID() };
109
- const plan = createInitPlan({ root, workspace, tools, language, extensions });
120
+ const extensionRemovals = ordinary.uninstallPlans || [];
121
+ const plan = createInitPlan({ root, workspace, tools, language, extensions, extensionRemovals });
122
+ const extensionChanges = [
123
+ ...extensions.map((entry) => `${entry.action === "update" ? "Update" : "Install"} ${entry.id}@${entry.version}`),
124
+ ...extensionRemovals.map((entry) => typeof entry === "string" ? `Uninstall ${entry}` : `Uninstall ${entry.id}@${entry.version}`),
125
+ ];
110
126
  ui.note("Ready to initialize", [
111
127
  `Workspace ${workspace.name}`,
112
128
  `Language ${language}`,
113
129
  `Tools ${tools.length ? tools.join(", ") : "none"}`,
114
130
  `Extensions ${extensions.length ? extensions.map((entry) => `${entry.id}@${entry.version} [Spec ${entry.extensionSpecVersion}] (${entry.manifestSha256})`).join(", ") : "none"}`,
131
+ `Changes ${extensionChanges.length ? extensionChanges.join(", ") : "none"}`,
115
132
  ]);
133
+ if (extensionChanges.length) {
134
+ const formatted = typeof options.formatExtensionChanges === "function"
135
+ ? options.formatExtensionChanges(extensions, extensionRemovals)
136
+ : extensionChanges.join("\n");
137
+ ui.note("Extension changes", formatted.split("\n"));
138
+ }
116
139
  if (existing?.projects?.length) {
117
140
  const permissionPlan = planPermissionChanges({
118
141
  root,