@detergent-software/atk 8.1.2-dev.9 → 8.1.2

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 (131) hide show
  1. package/CHANGELOG.md +335 -0
  2. package/build/commands/audit.d.ts.map +1 -1
  3. package/build/commands/audit.js +14 -9
  4. package/build/commands/audit.js.map +1 -1
  5. package/build/commands/browse.js +21 -29
  6. package/build/commands/browse.js.map +1 -1
  7. package/build/commands/info.js +2 -2
  8. package/build/commands/info.js.map +1 -1
  9. package/build/commands/install.d.ts.map +1 -1
  10. package/build/commands/install.js +35 -75
  11. package/build/commands/install.js.map +1 -1
  12. package/build/commands/list.js +2 -2
  13. package/build/commands/list.js.map +1 -1
  14. package/build/commands/prune.d.ts.map +1 -1
  15. package/build/commands/prune.js +10 -8
  16. package/build/commands/prune.js.map +1 -1
  17. package/build/commands/sync.d.ts.map +1 -1
  18. package/build/commands/sync.js +5 -4
  19. package/build/commands/sync.js.map +1 -1
  20. package/build/commands/uninstall.d.ts +9 -1
  21. package/build/commands/uninstall.d.ts.map +1 -1
  22. package/build/commands/uninstall.js +40 -35
  23. package/build/commands/uninstall.js.map +1 -1
  24. package/build/commands/update.d.ts +9 -1
  25. package/build/commands/update.d.ts.map +1 -1
  26. package/build/commands/update.js +42 -12
  27. package/build/commands/update.js.map +1 -1
  28. package/build/commands/why.d.ts.map +1 -1
  29. package/build/commands/why.js +13 -5
  30. package/build/commands/why.js.map +1 -1
  31. package/build/components/AssetDetail.js +3 -1
  32. package/build/components/AssetDetail.js.map +1 -1
  33. package/build/components/AssetTable.d.ts.map +1 -1
  34. package/build/components/AssetTable.js +7 -3
  35. package/build/components/AssetTable.js.map +1 -1
  36. package/build/components/DependencyTree.d.ts +1 -1
  37. package/build/components/DependencyTree.d.ts.map +1 -1
  38. package/build/components/DependencyTree.js +4 -2
  39. package/build/components/DependencyTree.js.map +1 -1
  40. package/build/components/InstallSummary.d.ts +1 -0
  41. package/build/components/InstallSummary.d.ts.map +1 -1
  42. package/build/components/InstallSummary.js +3 -1
  43. package/build/components/InstallSummary.js.map +1 -1
  44. package/build/components/ListBrowseList.d.ts.map +1 -1
  45. package/build/components/ListBrowseList.js +4 -2
  46. package/build/components/ListBrowseList.js.map +1 -1
  47. package/build/components/OrphanListItem.d.ts +3 -1
  48. package/build/components/OrphanListItem.d.ts.map +1 -1
  49. package/build/components/OrphanListItem.js +3 -2
  50. package/build/components/OrphanListItem.js.map +1 -1
  51. package/build/components/SkippedAssetSection.d.ts +2 -0
  52. package/build/components/SkippedAssetSection.d.ts.map +1 -1
  53. package/build/components/SkippedAssetSection.js +1 -1
  54. package/build/components/SkippedAssetSection.js.map +1 -1
  55. package/build/components/SyncDriftedList.d.ts +3 -2
  56. package/build/components/SyncDriftedList.d.ts.map +1 -1
  57. package/build/components/SyncDriftedList.js +13 -3
  58. package/build/components/SyncDriftedList.js.map +1 -1
  59. package/build/hooks/useListActions.d.ts.map +1 -1
  60. package/build/hooks/useListActions.js +33 -16
  61. package/build/hooks/useListActions.js.map +1 -1
  62. package/build/hooks/usePruneState.d.ts +13 -15
  63. package/build/hooks/usePruneState.d.ts.map +1 -1
  64. package/build/hooks/usePruneState.js +10 -6
  65. package/build/hooks/usePruneState.js.map +1 -1
  66. package/build/hooks/useUninstallState.d.ts +16 -29
  67. package/build/hooks/useUninstallState.d.ts.map +1 -1
  68. package/build/hooks/useUninstallState.js +146 -139
  69. package/build/hooks/useUninstallState.js.map +1 -1
  70. package/build/lib/adapter.js +1 -1
  71. package/build/lib/adapter.js.map +1 -1
  72. package/build/lib/asset-tools.d.ts +5 -0
  73. package/build/lib/asset-tools.d.ts.map +1 -1
  74. package/build/lib/asset-tools.js +7 -0
  75. package/build/lib/asset-tools.js.map +1 -1
  76. package/build/lib/diagnostics.d.ts.map +1 -1
  77. package/build/lib/diagnostics.js +6 -5
  78. package/build/lib/diagnostics.js.map +1 -1
  79. package/build/lib/diff-command.d.ts.map +1 -1
  80. package/build/lib/diff-command.js +10 -7
  81. package/build/lib/diff-command.js.map +1 -1
  82. package/build/lib/enabled-list.d.ts +15 -0
  83. package/build/lib/enabled-list.d.ts.map +1 -0
  84. package/build/lib/enabled-list.js +83 -0
  85. package/build/lib/enabled-list.js.map +1 -0
  86. package/build/lib/file-snapshot.d.ts +17 -0
  87. package/build/lib/file-snapshot.d.ts.map +1 -0
  88. package/build/lib/file-snapshot.js +41 -0
  89. package/build/lib/file-snapshot.js.map +1 -0
  90. package/build/lib/installed-for-tool.d.ts +8 -0
  91. package/build/lib/installed-for-tool.d.ts.map +1 -0
  92. package/build/lib/installed-for-tool.js +18 -0
  93. package/build/lib/installed-for-tool.js.map +1 -0
  94. package/build/lib/installer.d.ts +109 -4
  95. package/build/lib/installer.d.ts.map +1 -1
  96. package/build/lib/installer.js +546 -162
  97. package/build/lib/installer.js.map +1 -1
  98. package/build/lib/lockfile.d.ts +30 -4
  99. package/build/lib/lockfile.d.ts.map +1 -1
  100. package/build/lib/lockfile.js +35 -15
  101. package/build/lib/lockfile.js.map +1 -1
  102. package/build/lib/multi-tool-install.d.ts +23 -0
  103. package/build/lib/multi-tool-install.d.ts.map +1 -0
  104. package/build/lib/multi-tool-install.js +78 -0
  105. package/build/lib/multi-tool-install.js.map +1 -0
  106. package/build/lib/org.d.ts +16 -0
  107. package/build/lib/org.d.ts.map +1 -1
  108. package/build/lib/org.js +18 -0
  109. package/build/lib/org.js.map +1 -1
  110. package/build/lib/owned-paths.d.ts +68 -0
  111. package/build/lib/owned-paths.d.ts.map +1 -0
  112. package/build/lib/owned-paths.js +162 -0
  113. package/build/lib/owned-paths.js.map +1 -0
  114. package/build/lib/registry.js +6 -2
  115. package/build/lib/registry.js.map +1 -1
  116. package/build/lib/resolver.d.ts.map +1 -1
  117. package/build/lib/resolver.js +35 -13
  118. package/build/lib/resolver.js.map +1 -1
  119. package/build/lib/sync.d.ts +8 -1
  120. package/build/lib/sync.d.ts.map +1 -1
  121. package/build/lib/sync.js +81 -28
  122. package/build/lib/sync.js.map +1 -1
  123. package/build/lib/uninstaller.d.ts +28 -4
  124. package/build/lib/uninstaller.d.ts.map +1 -1
  125. package/build/lib/uninstaller.js +95 -97
  126. package/build/lib/uninstaller.js.map +1 -1
  127. package/build/lib/updater.d.ts +59 -3
  128. package/build/lib/updater.d.ts.map +1 -1
  129. package/build/lib/updater.js +353 -80
  130. package/build/lib/updater.js.map +1 -1
  131. package/package.json +1 -1
@@ -2,21 +2,73 @@ import { existsSync } from 'node:fs';
2
2
  import { mkdir, readFile, unlink } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { getPlacement, isEntrypoint, resolveInstalledPaths } from './adapter.js';
5
- import { carryExcludedTools } from './asset-tools.js';
5
+ import { carryExcludedTools, isExcludedForTool } from './asset-tools.js';
6
6
  import { computeConfigEntryChecksum, computeHookGroupChecksum, hashBytes } from './checksum.js';
7
- import { mergeConfigEntriesForTool } from './config-entries.js';
8
- import { detectConfigFormat, normalizeForFormat, readConfigFile, resolveConfigFormat, writeConfigFile, } from './config-file.js';
7
+ import { getConfigEntriesForTool, getOtherToolsConfigEntries, mergeConfigEntriesForTool, normalizeConfigPath, } from './config-entries.js';
8
+ import { normalizeForFormat, readConfigFile, resolveConfigFormat, writeConfigFile, } from './config-file.js';
9
+ import { addToEnabledList, removeFromEnabledList } from './enabled-list.js';
9
10
  import { ApiCodeError, errorGuidance, errorMessage } from './errors.js';
10
11
  import { ensureGitignoreEntries } from './gitignore.js';
11
12
  import { buildHookGroup, deriveHookCommand, hookGroupOwnsCommand, removeHookGroup } from './hook-config.js';
12
13
  import { hookScriptPath, makeExecutable } from './hook-script.js';
13
- import { addAssetToLockfile, readLockfile, removeAssetFromLockfile, writeLockfile } from './lockfile.js';
14
+ import { isInstalledForTool } from './installed-for-tool.js';
15
+ import { addAssetToLockfile, findInstalledAsset, readLockfile, removeAssetFromLockfile, writeLockfile, } from './lockfile.js';
16
+ import { formatOrgName } from './org.js';
17
+ import { configEntriesOwnedByOthers, holdsAnotherOwnersValue, pathsOwnedByOthers } from './owned-paths.js';
14
18
  import { toRelativePosix } from './paths.js';
15
19
  import { fetchAssetFileBytes, fetchAssetManifest, listAssetFiles } from './registry.js';
16
20
  import { writeBinaryFile, writeTextFile } from './write-file.js';
17
21
  export class InstallerError extends ApiCodeError {
18
22
  name = 'InstallerError';
19
23
  }
24
+ /**
25
+ * A file the install would place at `path` exists and is not ATK-managed, or belongs to `owner`,
26
+ * another installed asset; nothing was written there. With `entry`, the conflict is that part of
27
+ * the file (a memory-template's block, a config entry), which `owner` wrote.
28
+ */
29
+ export class FileConflictError extends InstallerError {
30
+ name = 'FileConflictError';
31
+ owner;
32
+ path;
33
+ /** What conflicts, quoted for a message: `"<path>"`, or `the <entry> in "<path>"`. */
34
+ where;
35
+ constructor(path, owner, entry) {
36
+ const where = entry ? `the ${entry} in "${path}"` : `"${path}"`;
37
+ super(owner
38
+ ? `File conflict: ${where} belongs to the installed ${owner.type} "${formatOrgName(owner.org, owner.name)}".`
39
+ : `File conflict: ${where} already exists and was not installed by ATK. Use --force to overwrite.`, owner ? { hint: 'Uninstall it first if you want this one instead.' } : {});
40
+ this.owner = owner;
41
+ this.path = path;
42
+ this.where = where;
43
+ }
44
+ }
45
+ /**
46
+ * An install plan that failed at `asset` with `cause`, after rolling back the
47
+ * `rolledBack` assets it had already installed. Keeps the cause's API code and
48
+ * guidance, so callers that name the asset themselves can quote just the cause.
49
+ */
50
+ export class InstallPlanError extends InstallerError {
51
+ asset;
52
+ cause;
53
+ /** The plan kept what it installed before `asset`, because it moved an installed asset to another version. */
54
+ keptInstall;
55
+ name = 'InstallPlanError';
56
+ constructor(asset, cause, rolledBack, keptInstall = false) {
57
+ const rollback = rolledBack > 0 ? ` and ${rolledBack} previously installed asset(s) were rolled back` : '';
58
+ super(`Install failed at asset "${formatOrgName(asset.org, asset.name)}"${rollback}: ${errorMessage(cause)}`, errorGuidance(cause));
59
+ this.asset = asset;
60
+ this.cause = cause;
61
+ this.keptInstall = keptInstall;
62
+ }
63
+ }
64
+ /** A plan whose files for `asset` could not be downloaded; nothing was written. */
65
+ export class PlanDownloadError extends InstallPlanError {
66
+ name = 'PlanDownloadError';
67
+ constructor(asset, cause) {
68
+ super(asset, cause, 0);
69
+ this.message = `Download failed for asset "${formatOrgName(asset.org, asset.name)}": ${errorMessage(cause)}`;
70
+ }
71
+ }
20
72
  /**
21
73
  * Guard for multi-tool installs run with `skipUnsupported`. Each tool skips the asset
22
74
  * types it cannot place, which is fine as long as some tool placed something. If every
@@ -36,9 +88,13 @@ export function assertInstalledByAnyTool(plan, toolResults) {
36
88
  }
37
89
  /**
38
90
  * Execute a full install plan — install all assets in order.
39
- * On partial failure, rolls back previously installed assets (deletes placed files,
40
- * reverts config entries, removes lockfile entries) so the system is not left in
41
- * an inconsistent state.
91
+ * On partial failure, deletes the files the failing asset wrote where no file was before placing
92
+ * its files failed, and rolls back the assets the plan added before it (deletes placed files,
93
+ * reverts config entries and memory-template blocks, removes lockfile entries) so the system is not
94
+ * left in an inconsistent state. An asset the tool already had in place keeps what the plan placed,
95
+ * since rolling it back would delete its working copy, and gets its lockfile entry back. When the
96
+ * plan moved such an asset to another version, which may need the assets the plan added, nothing is
97
+ * rolled back ({@link InstallPlanError.keptInstall}).
42
98
  */
43
99
  export async function executeInstallPlan(plan, options) {
44
100
  const results = [];
@@ -52,152 +108,339 @@ export async function executeInstallPlan(plan, options) {
52
108
  // Read lockfile once — used for both conflict detection and per-asset install logic
53
109
  let lockfile = await readLockfile(options.projectDir);
54
110
  const initialLockfile = lockfile;
55
- // Build set of ATK-managed file paths from the lockfile for conflict detection
56
- const managedPaths = buildManagedPathsSet(lockfile, options.projectDir, options.adapter);
111
+ // Map each ATK-managed file path to the assets that installed it, for conflict detection
112
+ const managedPaths = buildManagedPaths(options.managedFrom ?? lockfile, options.projectDir, options.adapter);
113
+ // ...and each block or keyed config entry to the assets that wrote it
114
+ const managedEntries = buildManagedEntries(options.managedFrom ?? lockfile, options.projectDir, options.adapter);
115
+ // ...and each keyed config entry of other tools to its owners, who may share it for this tool
116
+ const ownersElsewhere = buildEntryOwnersElsewhere(options.managedFrom ?? lockfile, options.adapter);
57
117
  // In multi-tool projects the same plan runs once per tool. A tool that has no
58
118
  // placement for an asset type (e.g. Codex CLI has no `rule` placement) skips
59
119
  // those entries instead of aborting the whole run for that tool. When the tool
60
120
  // supports nothing in the plan, fail loudly so a single-tool install isn't a
61
121
  // silent no-op.
62
- const supportedEntries = [];
122
+ const supportedEntries = placeableEntries(plan, options.adapter);
63
123
  for (const entry of plan.entries) {
64
- if (getPlacement(options.adapter, entry.asset.type)) {
65
- supportedEntries.push(entry);
66
- }
67
- else {
124
+ if (!getPlacement(options.adapter, entry.asset.type)) {
68
125
  options.onProgress?.(`Skipping ${entry.asset.name}: ${options.adapter.displayName} does not support ${entry.asset.type} assets`);
69
126
  }
70
127
  }
71
128
  if (supportedEntries.length === 0 && plan.entries.length > 0 && !options.skipUnsupported) {
72
129
  throw new InstallerError(`Tool "${options.adapter.tool}" does not support asset type "${plan.entries[0].asset.type}"`);
73
130
  }
131
+ // The assets this tool had in place before the plan, by plan entry, which a failure keeps.
132
+ const installedBefore = options.installedBefore ?? options.managedFrom ?? lockfile;
133
+ const inPlace = new Map();
134
+ for (const entry of supportedEntries) {
135
+ const installed = findInstalledAsset(installedBefore, entry.asset.name, entry.asset.type, entry.asset.org);
136
+ if (installed && isInstalledForTool(installed, options.adapter, options.projectDir))
137
+ inPlace.set(entry, installed);
138
+ }
139
+ // The files the asset being placed wrote where no file was, which a failure removes.
140
+ let created = [];
74
141
  try {
75
142
  for (const entry of supportedEntries) {
76
- const { lockfile: updatedLockfile, result } = await installAsset(entry, options, managedPaths, lockfile);
143
+ created = [];
144
+ const { lockfile: updatedLockfile, result, sharedEntries, } = await installAsset(entry, options, managedPaths, managedEntries, ownersElsewhere, lockfile, created);
77
145
  results.push(result);
146
+ // A later entry of this plan may land on these files or entries, and must see whose they are.
147
+ for (const file of result.filesPlaced)
148
+ addOwner(managedPaths, file.destination, entry.asset);
149
+ for (const shared of sharedEntries)
150
+ addOwner(managedEntries, sharedEntryKey(shared), entry.asset);
78
151
  lockfile = updatedLockfile;
79
152
  }
80
153
  }
81
154
  catch (error) {
82
- // Roll back all successfully installed assets in reverse order
83
- if (results.length > 0) {
84
- options.onProgress?.(`Install failed, rolling back ${results.length} installed asset(s)...`);
85
- for (let i = results.length - 1; i >= 0; i--) {
86
- const result = results[i];
87
- options.onProgress?.(`Rolling back ${result.name}...`);
88
- // Delete placed files (best-effort)
89
- for (const file of result.filesPlaced) {
90
- try {
91
- if (existsSync(file.destination)) {
92
- await unlink(file.destination);
93
- }
94
- }
95
- catch (error) {
96
- options.onProgress?.(`Warning: Failed to delete file during rollback: ${file.destination} — ${errorMessage(error)}`);
97
- }
98
- }
99
- // Revert config file entries (best-effort)
100
- for (const entry of result.configEntriesCreated) {
101
- try {
102
- const configPath = join(options.projectDir, entry.file);
103
- const format = resolveConfigFormat(configPath, getPlacement(options.adapter, result.type));
104
- await revertConfigEntry(configPath, entry.key, entry.command, format, options.onProgress);
105
- }
106
- catch (error) {
107
- options.onProgress?.(`Warning: Failed to revert config entry ${entry.key} during rollback — ${errorMessage(error)}`);
108
- }
109
- }
110
- // Revert the lockfile entry using the in-memory lockfile (best-effort). An asset that
111
- // was already recorded before this plan ran (a sync/update reinstall, or a second
112
- // tool's pass in a multi-tool install) gets its previous entry back, so other tools'
113
- // config entries survive; a newly added asset is removed.
114
- try {
115
- const rolledBack = supportedEntries[i].asset;
116
- const previousEntry = (initialLockfile.assets ?? []).find((a) => a.name === rolledBack.name && a.type === rolledBack.type && a.org === rolledBack.org);
117
- lockfile = previousEntry
118
- ? addAssetToLockfile(lockfile, previousEntry)
119
- : removeAssetFromLockfile(lockfile, rolledBack.name, rolledBack.type, rolledBack.org);
120
- await writeLockfile(options.projectDir, lockfile);
121
- }
122
- catch (error) {
123
- options.onProgress?.(`Warning: Failed to remove lockfile entry during rollback for ${result.name} — ${errorMessage(error)}`);
155
+ // The failing asset isn't in `results`. Nothing else owns a path it created, so all of them go.
156
+ for (const path of created) {
157
+ try {
158
+ if (existsSync(path)) {
159
+ await unlink(path);
124
160
  }
125
161
  }
162
+ catch (error) {
163
+ options.onProgress?.(`Warning: Failed to delete file during rollback: ${path} — ${errorMessage(error)}`);
164
+ }
165
+ }
166
+ // Re-throw the original error with rollback context, keeping any API error code and
167
+ // recovery hint/command (e.g. from an API auth failure).
168
+ const failed = supportedEntries[results.length].asset;
169
+ const placed = results.map((result, i) => ({
170
+ asset: supportedEntries[i].asset,
171
+ entry: supportedEntries[i],
172
+ result,
173
+ }));
174
+ if (placed.some(({ entry }) => inPlace.has(entry) && inPlace.get(entry).version !== entry.version)) {
175
+ options.onProgress?.(`Keeping the install for ${options.adapter.tool}: it moved an installed asset to another version.`);
176
+ throw new InstallPlanError(failed, error, 0, true);
177
+ }
178
+ // Roll back the assets the plan added, in reverse order
179
+ const added = placed.filter(({ entry }) => !inPlace.has(entry));
180
+ if (added.length > 0) {
181
+ options.onProgress?.(`Install failed, rolling back ${added.length} installed asset(s)...`);
182
+ lockfile = await rollBackInstalledAssets(added, initialLockfile, lockfile, options);
126
183
  options.onProgress?.(`Rollback complete.`);
127
184
  }
128
- // Re-throw the original error wrapped in InstallerError with rollback context,
129
- // keeping any API error code and recovery hint/command (e.g. from an API auth failure).
130
- const originalMessage = errorMessage(error);
131
- throw new InstallerError(`Install failed at asset "${supportedEntries[results.length]?.asset.name ?? 'unknown'}" ` +
132
- `and ${results.length} previously installed asset(s) were rolled back: ${originalMessage}`, errorGuidance(error));
185
+ // The assets that were in place keep their files, of the same version, and get their entries back.
186
+ const kept = placed.filter(({ entry }) => inPlace.has(entry));
187
+ if (kept.length > 0) {
188
+ try {
189
+ for (const { asset } of kept) {
190
+ const previous = findInstalledAsset(initialLockfile, asset.name, asset.type, asset.org);
191
+ if (previous)
192
+ lockfile = addAssetToLockfile(lockfile, previous);
193
+ }
194
+ await writeLockfile(options.projectDir, lockfile);
195
+ }
196
+ catch (error) {
197
+ options.onProgress?.(`Warning: Failed to restore lockfile entries during rollback — ${errorMessage(error)}`);
198
+ }
199
+ }
200
+ throw new InstallPlanError(failed, error, added.length);
133
201
  }
134
202
  return results;
135
203
  }
136
204
  /**
137
- * Append entry names to a string-array key in a config file (e.g., `enabledMcpjsonServers`
138
- * in `.claude/settings.local.json`), creating the file/array as needed and de-duplicating.
139
- * Used to auto-approve installed MCP servers so the tool doesn't prompt on open.
205
+ * Download the files of every plan entry this tool can place, without writing anything. A
206
+ * caller that replaces an installed asset downloads first, so a refused download leaves the
207
+ * installed version in place, then passes the result to {@link executeInstallPlan} as `files`.
140
208
  */
141
- async function addToEnabledList(enabledListFilePath, enabledListKey, names, onProgress) {
142
- try {
143
- const format = detectConfigFormat(enabledListFilePath);
144
- let config = {};
145
- if (existsSync(enabledListFilePath)) {
146
- config = await readConfigFile(enabledListFilePath, format);
209
+ export async function fetchPlanFiles(plan, adapter) {
210
+ const files = new Map();
211
+ for (const entry of plan.entries) {
212
+ if (!getPlacement(adapter, entry.asset.type))
213
+ continue;
214
+ try {
215
+ files.set(planEntryKey(entry), await fetchAssetFiles(entry.asset, entry.version, entry.asset.org));
147
216
  }
148
- else {
149
- const dir = dirname(enabledListFilePath);
150
- if (!existsSync(dir)) {
151
- await mkdir(dir, { recursive: true });
217
+ catch (error) {
218
+ throw new PlanDownloadError(entry.asset, error);
219
+ }
220
+ }
221
+ return files;
222
+ }
223
+ /**
224
+ * The message of an install failure for `asset`, for a caller whose own message already names
225
+ * it: a plan that failed at that asset gives just its cause; one that failed at another asset
226
+ * keeps the installer's message, which names that asset.
227
+ */
228
+ export function installFailureMessage(error, asset) {
229
+ const atAsset = error instanceof InstallPlanError &&
230
+ error.asset.name === asset.name &&
231
+ error.asset.type === asset.type &&
232
+ error.asset.org === asset.org;
233
+ return errorMessage(atAsset ? error.cause : error);
234
+ }
235
+ /** The entries of `plan` the adapter's tool has a placement for, in plan order. */
236
+ export function placeableEntries(plan, adapter) {
237
+ return plan.entries.filter((entry) => getPlacement(adapter, entry.asset.type));
238
+ }
239
+ /**
240
+ * Undo the installs of `installed` for the adapter's tool, in reverse order (best-effort): delete
241
+ * their placed files, revert their config entries (deleting a config file they created once it is
242
+ * empty), put back the memory-template block they replaced or take out the one they added (deleting
243
+ * a file they created once it is empty), take the names they added out of an enabled list, and give
244
+ * each asset its lockfile entry from `before`, or remove it. A file or config entry an asset of
245
+ * `before` also owns is kept.
246
+ * Returns `lockfile` with the entries reverted, as written.
247
+ */
248
+ export async function rollBackInstalledAssets(installed, before, lockfile, options) {
249
+ for (const { asset: rolledBack, result } of installed.toReversed()) {
250
+ options.onProgress?.(`Rolling back ${result.name}...`);
251
+ // Delete placed files (best-effort), except one another asset installed before this plan
252
+ // also owns: a global and an org asset of one name and type can share a placement.
253
+ const ownedByOthers = pathsOwnedByOthers(before, rolledBack, options.adapter, options.projectDir);
254
+ for (const file of result.filesPlaced) {
255
+ if (ownedByOthers.has(file.destination))
256
+ continue;
257
+ try {
258
+ if (existsSync(file.destination)) {
259
+ await unlink(file.destination);
260
+ }
261
+ }
262
+ catch (error) {
263
+ options.onProgress?.(`Warning: Failed to delete file during rollback: ${file.destination} — ${errorMessage(error)}`);
152
264
  }
153
265
  }
154
- const existing = Array.isArray(config[enabledListKey]) ? config[enabledListKey] : [];
155
- const list = existing.filter((v) => typeof v === 'string');
156
- let changed = !Array.isArray(config[enabledListKey]);
157
- for (const name of names) {
158
- if (!list.includes(name)) {
159
- list.push(name);
160
- changed = true;
266
+ // Revert config file entries (best-effort), except one holding the value of an asset from
267
+ // before this plan that also owns it
268
+ const placement = getPlacement(options.adapter, result.type);
269
+ const entriesOwnedByOthers = configEntriesOwnedByOthers(before, rolledBack, options.adapter, options.projectDir);
270
+ const kept = new Set();
271
+ for (const entry of result.configEntriesCreated) {
272
+ try {
273
+ if (await holdsAnotherOwnersValue(entry, entriesOwnedByOthers, options.projectDir)) {
274
+ kept.add(entry.key);
275
+ continue;
276
+ }
277
+ const configPath = join(options.projectDir, entry.file);
278
+ const format = resolveConfigFormat(configPath, placement);
279
+ const created = result.configFilesCreated?.includes(entry.file) ?? false;
280
+ await revertConfigEntry(configPath, entry.key, entry.command, format, created, options.onProgress);
281
+ }
282
+ catch (error) {
283
+ options.onProgress?.(`Warning: Failed to revert config entry ${entry.key} during rollback — ${errorMessage(error)}`);
161
284
  }
162
285
  }
163
- if (changed) {
164
- config[enabledListKey] = list;
165
- await writeConfigFile(enabledListFilePath, config, format, onProgress);
286
+ // Give a memory-template's file back the block of that name it had before, or none
287
+ if (result.blockWritten) {
288
+ await revertMemoryBlock(options.projectDir, result.name, result.blockWritten, options.onProgress);
289
+ }
290
+ // Take the names the install added out of the enabled list again, except one whose entry was
291
+ // kept for its other owner (as uninstall does)
292
+ const { configKey, enabledListFile, enabledListKey } = placement ?? {};
293
+ const names = (result.enabledListAdded ?? []).filter((name) => !kept.has(`${configKey}.${name}`));
294
+ if (enabledListFile && enabledListKey && names.length > 0) {
295
+ await removeFromEnabledList(join(options.projectDir, enabledListFile), enabledListKey, names, result.configFilesCreated?.includes(enabledListFile) ?? false, options.onProgress);
296
+ }
297
+ // Revert the lockfile entry using the in-memory lockfile (best-effort). An asset that
298
+ // was already recorded before this plan ran (a sync/update reinstall, or a second
299
+ // tool's pass in a multi-tool install) gets its previous entry back, so other tools'
300
+ // config entries survive; a newly added asset is removed.
301
+ try {
302
+ const previousEntry = (before.assets ?? []).find((a) => a.name === rolledBack.name && a.type === rolledBack.type && a.org === rolledBack.org);
303
+ lockfile = previousEntry
304
+ ? addAssetToLockfile(lockfile, previousEntry)
305
+ : removeAssetFromLockfile(lockfile, rolledBack.name, rolledBack.type, rolledBack.org);
306
+ await writeLockfile(options.projectDir, lockfile);
307
+ }
308
+ catch (error) {
309
+ options.onProgress?.(`Warning: Failed to remove lockfile entry during rollback for ${result.name} — ${errorMessage(error)}`);
166
310
  }
167
311
  }
168
- catch (error) {
169
- // Non-fatal — log but don't fail the install
170
- onProgress?.(`Warning: Failed to update enabled list ${enabledListFilePath} — ${errorMessage(error)}`);
312
+ return lockfile;
313
+ }
314
+ /** Record `owner` as one of the assets that placed the file at `key`, or wrote the shared entry. */
315
+ function addOwner(owned, key, owner) {
316
+ const owners = owned.get(key);
317
+ if (owners) {
318
+ owners.push(owner);
171
319
  }
320
+ else {
321
+ owned.set(key, [owner]);
322
+ }
323
+ }
324
+ /** The regular expression source of the `<!-- ATK: {name} -->` block, delimiters included. */
325
+ function atkBlockPattern(assetName) {
326
+ const escapedName = assetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
327
+ return `<!-- ATK: ${escapedName} -->\\n[\\s\\S]*?<!-- /ATK: ${escapedName} -->`;
328
+ }
329
+ /**
330
+ * Map each name of a keyed config entry other tools have to the owners of each such entry: the
331
+ * assets that recorded it for one other tool, in one file. Assets the adapter's tool can't hold
332
+ * are left out, as in {@link buildManagedEntries}.
333
+ */
334
+ function buildEntryOwnersElsewhere(lockfile, adapter) {
335
+ const byName = new Map();
336
+ for (const asset of lockfile.assets ?? []) {
337
+ if (isExcludedForTool(asset, adapter.tool) || !getPlacement(adapter, asset.type))
338
+ continue;
339
+ for (const entry of getOtherToolsConfigEntries(asset, adapter)) {
340
+ const name = configEntryName(entry.key);
341
+ if (entry.command !== undefined || name === undefined)
342
+ continue;
343
+ const byFile = byName.get(name) ?? new Map();
344
+ byName.set(name, byFile);
345
+ addOwner(byFile, sharedEntryKey({ file: normalizeConfigPath(entry.file), key: entry.key }), asset);
346
+ }
347
+ }
348
+ return new Map([...byName].map(([name, byFile]) => [name, [...byFile.values()]]));
172
349
  }
173
350
  /**
174
- * Build a set of all file paths managed by ATK from the given lockfile.
175
- * Used for conflict detection — files in this set can be safely overwritten.
351
+ * Map each memory-template block and keyed config entry the given lockfile's assets wrote for the
352
+ * adapter's tool to those assets. Used for conflict detection, like {@link buildManagedPaths}.
353
+ */
354
+ function buildManagedEntries(lockfile, projectDir, adapter) {
355
+ const entries = new Map();
356
+ for (const asset of lockfile.assets ?? []) {
357
+ if (isExcludedForTool(asset, adapter.tool))
358
+ continue;
359
+ const placement = getPlacement(adapter, asset.type);
360
+ if (!placement)
361
+ continue;
362
+ if (asset.type === 'memory-template') {
363
+ addOwner(entries, sharedEntryKey(memoryTemplateBlock(placement, asset.name, projectDir)), asset);
364
+ }
365
+ for (const entry of getConfigEntriesForTool(asset, adapter)) {
366
+ // A hook's event entry is an array every hook shares; its own group is found by its command.
367
+ if (entry.command !== undefined)
368
+ continue;
369
+ addOwner(entries, sharedEntryKey({ file: join(projectDir, entry.file), key: entry.key }), asset);
370
+ }
371
+ }
372
+ return entries;
373
+ }
374
+ /**
375
+ * Map every file path managed by ATK in the given lockfile to the assets that installed it.
376
+ * Used for conflict detection — an asset may overwrite only the files it installed itself.
176
377
  * Resolves installed paths dynamically via the adapter rather than reading them from the lockfile.
177
378
  */
178
- function buildManagedPathsSet(lockfile, projectDir, adapter) {
179
- const paths = new Set();
379
+ function buildManagedPaths(lockfile, projectDir, adapter) {
380
+ const paths = new Map();
180
381
  for (const asset of lockfile.assets ?? []) {
181
382
  const resolved = resolveInstalledPaths(asset, adapter, projectDir);
182
383
  for (const file of resolved.files) {
183
- paths.add(join(projectDir, file.installedPath));
384
+ addOwner(paths, join(projectDir, file.installedPath), asset);
184
385
  }
185
386
  }
186
387
  return paths;
187
388
  }
188
389
  /**
189
- * Check if a file at the destination path would conflict with an existing non-ATK file.
190
- * Throws InstallerError if the file exists and is not tracked in the lockfile.
191
- * ATK-managed files (in the lockfile) can be safely overwritten (e.g., reinstall/update).
390
+ * Throw a FileConflictError if another installed asset wrote `entry` and `asset` didn't. An entry
391
+ * no installed asset wrote is left to the merge, as before. An asset that recorded the entry of
392
+ * that name for another tool together with all of its owners here (a forced install, or one from
393
+ * before installs refused a shared entry) shares it here too: otherwise the first of them to sync
394
+ * a newly added tool would lock the others out of it.
395
+ */
396
+ function checkEntryConflict(entry, managedEntries, ownersElsewhere, asset) {
397
+ const owners = managedEntries.get(sharedEntryKey(entry));
398
+ if (!owners || isOwner(owners, asset)) {
399
+ return;
400
+ }
401
+ const sharedElsewhere = (entry.entry ? ownersElsewhere.get(entry.entry.name) : undefined)?.some((coOwners) => isOwner(coOwners, asset) && owners.every((owner) => isOwner(coOwners, owner)));
402
+ if (sharedElsewhere) {
403
+ return;
404
+ }
405
+ throw new FileConflictError(entry.file, owners[0], entry.label);
406
+ }
407
+ /**
408
+ * Check if a file at the destination path would conflict with a file `asset` didn't install.
409
+ * Throws FileConflictError if the file exists and is not tracked in the lockfile, or is tracked
410
+ * only for other assets (a global and an org asset of one name and type share a placement).
411
+ * The asset's own files can be safely overwritten (e.g., reinstall/update).
192
412
  */
193
- async function checkFileConflict(destPath, managedPaths) {
413
+ async function checkFileConflict(destPath, managedPaths, asset) {
194
414
  if (!existsSync(destPath)) {
195
415
  return;
196
416
  }
197
- if (managedPaths.has(destPath)) {
417
+ const owners = managedPaths.get(destPath) ?? [];
418
+ if (isOwner(owners, asset)) {
419
+ return;
420
+ }
421
+ throw new FileConflictError(destPath, owners[0]);
422
+ }
423
+ /**
424
+ * Throw a FileConflictError if the config entry `entry` names already exists without ATK's
425
+ * `_managedBy` marker and no installed asset wrote it: the user wrote it, so only --force replaces
426
+ * it, as with a file. A config file that doesn't parse is left to the merge, which warns.
427
+ */
428
+ async function checkUserEntryConflict(entry, managedEntries, format) {
429
+ if (!entry.entry || managedEntries.has(sharedEntryKey(entry)) || !existsSync(entry.file)) {
430
+ return;
431
+ }
432
+ const config = await readConfigFile(entry.file, format).catch(() => undefined);
433
+ const section = config?.[entry.entry.section];
434
+ const existing = isRecord(section) ? section[entry.entry.name] : undefined;
435
+ if (existing === undefined || (isRecord(existing) && existing['_managedBy'] === 'atk')) {
198
436
  return;
199
437
  }
200
- throw new InstallerError(`File conflict: "${destPath}" already exists and was not installed by ATK. Use --force to overwrite.`);
438
+ throw new FileConflictError(entry.file, undefined, entry.label);
439
+ }
440
+ /** The name a keyed config entry has in its section: `neon` for `mcpServers.neon`. */
441
+ function configEntryName(key) {
442
+ const dotIndex = key.indexOf('.');
443
+ return dotIndex === -1 ? undefined : key.slice(dotIndex + 1);
201
444
  }
202
445
  /**
203
446
  * Fetch all files for an asset version from the ATK API, plus its
@@ -244,11 +487,36 @@ async function fetchManifestEntrypoint(ref, version) {
244
487
  return undefined;
245
488
  }
246
489
  }
490
+ /** The `<!-- ATK: {name} -->` block in `content`, delimiters included, if it has one. */
491
+ function findAtkBlock(content, assetName) {
492
+ return new RegExp(atkBlockPattern(assetName)).exec(content)?.[0];
493
+ }
494
+ /**
495
+ * The named entries of a config fragment: the map at `content[configKey]`, then `content[fragmentKey]`
496
+ * (for tools whose target key differs from the asset content's key, e.g. `mcp_servers` vs
497
+ * `mcpServers`), and finally the whole content. Entries that aren't objects are skipped.
498
+ */
499
+ function fragmentEntries(content, configKey, fragmentKey) {
500
+ const pickMap = (key) => {
501
+ if (!key)
502
+ return undefined;
503
+ const candidate = content[key];
504
+ return candidate && typeof candidate === 'object' && !Array.isArray(candidate)
505
+ ? candidate
506
+ : undefined;
507
+ };
508
+ const inner = pickMap(configKey) ?? pickMap(fragmentKey) ?? content;
509
+ return Object.entries(inner).filter((pair) => !!pair[1] && typeof pair[1] === 'object');
510
+ }
247
511
  /**
248
512
  * Install a single asset from the install plan.
249
- * Accepts the current in-memory lockfile and returns the updated lockfile alongside the result.
513
+ * Accepts the current in-memory lockfile and returns the updated lockfile alongside the result,
514
+ * and the shared entries the asset wrote.
515
+ * While it places the asset's files, `created` holds each path it writes where no file was (added
516
+ * before the write), so a caller can remove them when placing fails partway. It is emptied once
517
+ * every file is placed.
250
518
  */
251
- async function installAsset(entry, options, managedPaths, lockfile) {
519
+ async function installAsset(entry, options, managedPaths, managedEntries, ownersElsewhere, lockfile, created) {
252
520
  const { asset } = entry;
253
521
  const { adapter, force, onProgress, pin, projectDir } = options;
254
522
  onProgress?.(`Installing ${asset.name}...`);
@@ -259,21 +527,56 @@ async function installAsset(entry, options, managedPaths, lockfile) {
259
527
  }
260
528
  // Determine base path
261
529
  const basePath = projectDir;
262
- // Fetch asset files from the ATK API (version-aware, org-aware)
263
- const { entrypoint: declaredEntrypoint, files: assetFiles } = await fetchAssetFiles(asset, entry.version, entry.asset.org);
530
+ // Fetch asset files from the ATK API (version-aware, org-aware), unless the caller already did.
531
+ // A caller that downloaded first may have removed the installed files since, so a gap in its
532
+ // download must not turn into a fresh request that can be refused.
533
+ const prefetched = options.files?.get(planEntryKey(entry));
534
+ if (options.files && !prefetched) {
535
+ throw new InstallerError(`The files of "${asset.name}"@${entry.version} were not downloaded before the install.`);
536
+ }
537
+ const { entrypoint: declaredEntrypoint, files: assetFiles } = prefetched ?? (await fetchAssetFiles(asset, entry.version, entry.asset.org));
264
538
  // Find the entrypoint file
265
539
  const entrypointFile = assetFiles.find((f) => isEntrypoint(f.relativePath, asset.type, declaredEntrypoint));
540
+ // Determine merge content for configMerge mode (a keyed merge; a hook merge doesn't use it)
541
+ let mergeContent;
542
+ if (placement.configMerge && !placement.configHookMerge && entrypointFile) {
543
+ try {
544
+ mergeContent = JSON.parse(entrypointFile.content.toString('utf-8'));
545
+ }
546
+ catch {
547
+ // If content isn't valid JSON, fall back to marker-only behavior
548
+ }
549
+ }
550
+ // The target config file's serialization format (JSON by default; TOML for tools like Codex
551
+ // CLI), resolved once here and threaded into the entry checks and every merge helper.
552
+ const configFormat = placement.configFile
553
+ ? resolveConfigFormat(join(basePath, placement.configFile), placement)
554
+ : 'json';
555
+ // Check the block or config entries the asset writes into shared files before writing anything,
556
+ // so a conflict leaves another asset's entry, or one the user wrote, as it was.
557
+ const sharedEntries = plannedSharedEntries(asset.name, asset.type, placement, basePath, mergeContent);
558
+ if (!force) {
559
+ for (const shared of sharedEntries) {
560
+ checkEntryConflict(shared, managedEntries, ownersElsewhere, asset);
561
+ await checkUserEntryConflict(shared, managedEntries, configFormat);
562
+ }
563
+ }
266
564
  // Branch on placement type
267
565
  const placedFiles = [];
268
566
  const filesPlaced = [];
269
567
  const configEntries = [];
568
+ // The shared entries written so far: the merge helpers warn and write nothing when they fail.
569
+ const written = [];
570
+ // The memory-template block written, which a rollback undoes.
571
+ let blockWritten;
270
572
  if (placement.fileless) {
271
573
  // Fileless placement (e.g., MCP configs) — no files placed on disk
272
574
  // Config merging handled below
273
575
  }
274
576
  else if (asset.type === 'memory-template') {
275
577
  // Memory templates append to a shared file with delimiters
276
- const targetPath = join(basePath, placement.path.replace(/\{name\}/g, asset.name));
578
+ const block = memoryTemplateBlock(placement, asset.name, basePath);
579
+ const targetPath = block.file;
277
580
  const targetDir = dirname(targetPath);
278
581
  if (!existsSync(targetDir)) {
279
582
  await mkdir(targetDir, { recursive: true });
@@ -284,29 +587,36 @@ async function installAsset(entry, options, managedPaths, lockfile) {
284
587
  }
285
588
  const content = (entrypointFile?.content ?? firstFile.content).toString('utf-8');
286
589
  const delimitedContent = `<!-- ATK: ${asset.name} -->\n${content}\n<!-- /ATK: ${asset.name} -->`;
287
- if (existsSync(targetPath)) {
288
- const existing = await readFile(targetPath, 'utf-8');
289
- // Strip any existing ATK block for this asset before appending the new one
290
- const cleaned = stripAtkBlock(existing, asset.name);
291
- await writeTextFile(targetPath, cleaned.trimEnd() + '\n\n' + delimitedContent + '\n');
292
- }
293
- else {
294
- await writeTextFile(targetPath, delimitedContent + '\n');
295
- }
590
+ const existing = existsSync(targetPath) ? await readFile(targetPath, 'utf-8') : undefined;
591
+ // Strip any existing ATK block for this asset before appending the new one
592
+ const rest = existing === undefined ? '' : stripAtkBlock(existing, asset.name).trimEnd();
593
+ await writeTextFile(targetPath, (rest ? `${rest}\n\n` : '') + delimitedContent + '\n');
594
+ written.push(block);
595
+ const replaced = existing === undefined ? undefined : findAtkBlock(existing, asset.name);
596
+ blockWritten = {
597
+ created: existing === undefined,
598
+ file: toRelativePosix(projectDir, targetPath),
599
+ ...(replaced !== undefined ? { replaced } : {}),
600
+ };
296
601
  // Memory-template entries are tracked as placed files, not config entries.
297
602
  // They don't go through updateConfigFile() and have no config entry checksum.
298
603
  }
299
604
  else if (placement.entrypointName) {
300
605
  // Directory-based placement (e.g., skills)
301
- for (const file of assetFiles) {
302
- const destPath = resolveFilePath(placement.path, asset.name, basePath, file.relativePath, asset.type, placement.entrypointName, declaredEntrypoint);
606
+ const destinations = assetFiles.map((file) => resolveFilePath(placement.path, asset.name, basePath, file.relativePath, asset.type, placement.entrypointName, declaredEntrypoint));
607
+ // Check every destination before writing any, so a conflict leaves none of the asset's files behind.
608
+ if (!force) {
609
+ for (const destPath of destinations)
610
+ await checkFileConflict(destPath, managedPaths, asset);
611
+ }
612
+ for (const [index, file] of assetFiles.entries()) {
613
+ const destPath = destinations[index];
303
614
  const destDir = dirname(destPath);
304
- if (!force) {
305
- await checkFileConflict(destPath, managedPaths);
306
- }
307
615
  if (!existsSync(destDir)) {
308
616
  await mkdir(destDir, { recursive: true });
309
617
  }
618
+ if (!existsSync(destPath))
619
+ created.push(destPath);
310
620
  await writeBinaryFile(destPath, file.content);
311
621
  const checksum = hashBytes(file.content);
312
622
  placedFiles.push({
@@ -322,15 +632,19 @@ async function installAsset(entry, options, managedPaths, lockfile) {
322
632
  }
323
633
  else {
324
634
  // Default single-file placement
325
- for (const file of assetFiles) {
326
- const destPath = resolveFilePath(placement.path, asset.name, basePath, file.relativePath, asset.type, undefined, declaredEntrypoint);
635
+ const destinations = assetFiles.map((file) => resolveFilePath(placement.path, asset.name, basePath, file.relativePath, asset.type, undefined, declaredEntrypoint));
636
+ if (!force) {
637
+ for (const destPath of destinations)
638
+ await checkFileConflict(destPath, managedPaths, asset);
639
+ }
640
+ for (const [index, file] of assetFiles.entries()) {
641
+ const destPath = destinations[index];
327
642
  const destDir = dirname(destPath);
328
- if (!force) {
329
- await checkFileConflict(destPath, managedPaths);
330
- }
331
643
  if (!existsSync(destDir)) {
332
644
  await mkdir(destDir, { recursive: true });
333
645
  }
646
+ if (!existsSync(destPath))
647
+ created.push(destPath);
334
648
  await writeBinaryFile(destPath, file.content);
335
649
  const checksum = hashBytes(file.content);
336
650
  placedFiles.push({
@@ -350,12 +664,17 @@ async function installAsset(entry, options, managedPaths, lockfile) {
350
664
  await makeExecutable(scriptDest);
351
665
  }
352
666
  }
353
- // Handle config file updates if needed. The target file's serialization format
354
- // (JSON by default; TOML for tools like Codex CLI) is resolved once here and
355
- // threaded into every merge helper.
356
- const configFormat = placement.configFile
357
- ? resolveConfigFormat(join(basePath, placement.configFile), placement)
358
- : 'json';
667
+ // Every file is placed, and the config entries below can point at them (a hook runs its script),
668
+ // so a failure from here on leaves them in place.
669
+ created.length = 0;
670
+ // The config files the writes below can create, which a rollback then removes once empty.
671
+ const absentConfigFiles = [placement.configFile, placement.enabledListFile]
672
+ .filter((file) => !!file)
673
+ .map((file) => join(basePath, file))
674
+ .filter((path) => !existsSync(path));
675
+ // The names the install adds to the placement's enabled list, which a rollback takes out again.
676
+ let enabledListAdded = [];
677
+ // Handle config file updates if needed.
359
678
  if (placement.configFile && placement.configKey && placement.configHookMerge) {
360
679
  // Hook placement: merge the asset's hookConfig event bindings into the tool
361
680
  // config file as valid event arrays (e.g. settings.json hooks.Stop[]), with the
@@ -365,16 +684,6 @@ async function installAsset(entry, options, managedPaths, lockfile) {
365
684
  configEntries.push(...hookEntries);
366
685
  }
367
686
  else if (placement.configFile && placement.configKey) {
368
- // Determine merge content for configMerge mode
369
- let mergeContent;
370
- if (placement.configMerge && entrypointFile) {
371
- try {
372
- mergeContent = JSON.parse(entrypointFile.content.toString('utf-8'));
373
- }
374
- catch {
375
- // If content isn't valid JSON, fall back to marker-only behavior
376
- }
377
- }
378
687
  if (placement.configFragment) {
379
688
  // Fragment merge (e.g., mcp-config): the asset content is a full config fragment
380
689
  // (e.g. a whole .mcp.json). Merge the entries inside content[configKey] into
@@ -385,7 +694,7 @@ async function installAsset(entry, options, managedPaths, lockfile) {
385
694
  // Auto-register the merged entry names into an enabled-list (e.g. enabledMcpjsonServers
386
695
  // in settings.local.json) so the tool auto-approves them.
387
696
  if (names.length > 0 && placement.enabledListFile && placement.enabledListKey) {
388
- await addToEnabledList(join(basePath, placement.enabledListFile), placement.enabledListKey, names, onProgress);
697
+ enabledListAdded = await addToEnabledList(join(basePath, placement.enabledListFile), placement.enabledListKey, names, onProgress);
389
698
  }
390
699
  }
391
700
  else {
@@ -395,6 +704,10 @@ async function installAsset(entry, options, managedPaths, lockfile) {
395
704
  }
396
705
  }
397
706
  }
707
+ written.push(...sharedEntries.filter((shared) => configEntries.some((c) => !c.command && c.key === shared.key)));
708
+ const configFilesCreated = absentConfigFiles
709
+ .filter((path) => existsSync(path))
710
+ .map((path) => toRelativePosix(projectDir, path));
398
711
  // Config entries are tool-specific but the lockfile is shared across tools: tag this
399
712
  // tool's entries and carry over whatever other tools already registered for this asset,
400
713
  // so a multi-tool install/sync/update never drops another tool's registration record.
@@ -431,14 +744,34 @@ async function installAsset(entry, options, managedPaths, lockfile) {
431
744
  return {
432
745
  lockfile: updatedLockfile,
433
746
  result: {
747
+ ...(blockWritten ? { blockWritten } : {}),
434
748
  ...(options.bundleOrigin ? { bundleOrigin: options.bundleOrigin } : {}),
435
749
  configEntriesCreated: configEntries,
750
+ ...(configFilesCreated.length > 0 ? { configFilesCreated } : {}),
751
+ ...(enabledListAdded.length > 0 ? { enabledListAdded } : {}),
436
752
  filesPlaced,
437
753
  installReason: entry.reason,
438
754
  name: asset.name,
755
+ ...(entry.asset.org ? { org: entry.asset.org } : {}),
439
756
  type: asset.type,
440
757
  version: entry.version,
441
758
  },
759
+ sharedEntries: written,
760
+ };
761
+ }
762
+ /** Whether `asset` is one of `owners`. */
763
+ function isOwner(owners, asset) {
764
+ return owners.some((o) => o.name === asset.name && o.type === asset.type && o.org === asset.org);
765
+ }
766
+ function isRecord(value) {
767
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
768
+ }
769
+ /** The `<!-- ATK: {name} -->` block a memory-template asset writes into its placement's file. */
770
+ function memoryTemplateBlock(placement, name, basePath) {
771
+ return {
772
+ file: join(basePath, placement.path.replace(/\{name\}/g, name)),
773
+ key: `ATK:${name}`,
774
+ label: `"${name}" block`,
442
775
  };
443
776
  }
444
777
  /**
@@ -450,9 +783,7 @@ async function installAsset(entry, options, managedPaths, lockfile) {
450
783
  * with the `_managedBy: 'atk'` marker. Returns one config entry per merged name (for lockfile
451
784
  * tracking / checksum verification) plus the list of names that were merged.
452
785
  *
453
- * The inner map is looked up at `content[configKey]`, then `content[fragmentKey]` (for tools
454
- * whose target key differs from the asset content's key, e.g. `mcp_servers` vs `mcpServers`),
455
- * and finally the whole content is treated as the inner map.
786
+ * The entries are found by {@link fragmentEntries}.
456
787
  */
457
788
  async function mergeFragmentConfig(configFilePath, configKey, format, projectDir, mergeContent, fragmentKey, onProgress) {
458
789
  const empty = { entries: [], names: [] };
@@ -460,17 +791,8 @@ async function mergeFragmentConfig(configFilePath, configKey, format, projectDir
460
791
  return empty;
461
792
  }
462
793
  try {
463
- const pickMap = (key) => {
464
- if (!key)
465
- return undefined;
466
- const candidate = mergeContent[key];
467
- return candidate && typeof candidate === 'object' && !Array.isArray(candidate)
468
- ? candidate
469
- : undefined;
470
- };
471
- const inner = pickMap(configKey) ?? pickMap(fragmentKey) ?? mergeContent;
472
- const innerNames = Object.keys(inner).filter((name) => inner[name] && typeof inner[name] === 'object');
473
- if (innerNames.length === 0) {
794
+ const inner = fragmentEntries(mergeContent, configKey, fragmentKey);
795
+ if (inner.length === 0) {
474
796
  return empty;
475
797
  }
476
798
  let config = {};
@@ -488,8 +810,8 @@ async function mergeFragmentConfig(configFilePath, configKey, format, projectDir
488
810
  }
489
811
  const section = config[configKey];
490
812
  const entries = [];
491
- for (const name of innerNames) {
492
- const value = normalizeForFormat({ ...inner[name], _managedBy: 'atk' }, format);
813
+ for (const [name, fragment] of inner) {
814
+ const value = normalizeForFormat({ ...fragment, _managedBy: 'atk' }, format);
493
815
  section[name] = value;
494
816
  entries.push({
495
817
  checksum: computeConfigEntryChecksum(value),
@@ -498,7 +820,7 @@ async function mergeFragmentConfig(configFilePath, configKey, format, projectDir
498
820
  });
499
821
  }
500
822
  await writeConfigFile(configFilePath, config, format, onProgress);
501
- return { entries, names: innerNames };
823
+ return { entries, names: inner.map(([name]) => name) };
502
824
  }
503
825
  catch (error) {
504
826
  // Non-fatal — log but don't fail the install
@@ -561,6 +883,36 @@ async function mergeHookConfig(configFilePath, configKey, format, hookConfig, co
561
883
  return [];
562
884
  }
563
885
  }
886
+ /** The {@link PlanFiles} key of a plan entry: one asset version. */
887
+ function planEntryKey(entry) {
888
+ return `${entry.asset.type}:${entry.asset.org ?? ''}:${entry.asset.name}@${entry.version}`;
889
+ }
890
+ /**
891
+ * The memory-template block or keyed config entries an asset of `name` and `type` will write: the
892
+ * fragment's own entry names for a fragment merge, the asset name for any other keyed merge. A
893
+ * hook's event entries are shared by every hook, so they aren't among them.
894
+ */
895
+ function plannedSharedEntries(name, type, placement, basePath, mergeContent) {
896
+ // The same routing as installAsset: a file-placing memory-template writes its block, then any
897
+ // keyed (not hook) config merge writes its entries.
898
+ const entries = !placement.fileless && type === 'memory-template' ? [memoryTemplateBlock(placement, name, basePath)] : [];
899
+ const { configFile, configKey } = placement;
900
+ if (!configFile || !configKey || placement.configHookMerge) {
901
+ return entries;
902
+ }
903
+ const names = placement.configFragment
904
+ ? fragmentEntries(mergeContent ?? {}, configKey, placement.configFragmentKey).map(([entryName]) => entryName)
905
+ : [name];
906
+ return [
907
+ ...entries,
908
+ ...names.map((entryName) => ({
909
+ entry: { name: entryName, section: configKey },
910
+ file: join(basePath, configFile),
911
+ key: `${configKey}.${entryName}`,
912
+ label: `"${configKey}.${entryName}" entry`,
913
+ })),
914
+ ];
915
+ }
564
916
  /**
565
917
  * Resolve the destination file path for an asset file.
566
918
  * For directory-based placement (entrypointName set), the path is a directory template.
@@ -587,9 +939,10 @@ function resolveFilePath(pathTemplate, assetName, basePath, relativePath, assetT
587
939
  /**
588
940
  * Revert a config file entry created during installation.
589
941
  * Reads the config file, removes the entry at the given key path (e.g., "mcpServers.my-mcp"),
590
- * and writes it back. If the parent section becomes empty, it is also removed.
942
+ * and writes it back. If the parent section becomes empty, it is also removed. A file the
943
+ * install created (`deleteWhenEmpty`) is deleted instead once nothing is left in it.
591
944
  */
592
- async function revertConfigEntry(configFilePath, keyPath, command, format, onProgress) {
945
+ async function revertConfigEntry(configFilePath, keyPath, command, format, deleteWhenEmpty, onProgress) {
593
946
  if (!existsSync(configFilePath)) {
594
947
  return;
595
948
  }
@@ -619,12 +972,45 @@ async function revertConfigEntry(configFilePath, keyPath, command, format, onPro
619
972
  delete config[sectionKey];
620
973
  }
621
974
  }
975
+ if (deleteWhenEmpty && Object.keys(config).length === 0) {
976
+ await unlink(configFilePath);
977
+ return;
978
+ }
622
979
  await writeConfigFile(configFilePath, config, format, onProgress);
623
980
  }
624
981
  catch (error) {
625
982
  onProgress?.(`Warning: Failed to revert config entry ${keyPath} in ${configFilePath} — ${errorMessage(error)}`);
626
983
  }
627
984
  }
985
+ /**
986
+ * Undo the write of a memory-template block (best-effort): put back the block of that name the
987
+ * install replaced, or take the block out, deleting the file once it is empty if the install
988
+ * created it.
989
+ */
990
+ async function revertMemoryBlock(projectDir, assetName, { created, file, replaced }, onProgress) {
991
+ const path = join(projectDir, file);
992
+ try {
993
+ if (!existsSync(path))
994
+ return;
995
+ const content = await readFile(path, 'utf-8');
996
+ const reverted = (replaced === undefined
997
+ ? stripAtkBlock(content, assetName)
998
+ : content.replace(new RegExp(atkBlockPattern(assetName)), () => replaced)).trimEnd();
999
+ if (created && reverted.trim() === '') {
1000
+ await unlink(path);
1001
+ }
1002
+ else {
1003
+ await writeTextFile(path, `${reverted}\n`);
1004
+ }
1005
+ }
1006
+ catch (error) {
1007
+ onProgress?.(`Warning: Failed to revert the "${assetName}" block during rollback — ${errorMessage(error)}`);
1008
+ }
1009
+ }
1010
+ /** The {@link ManagedEntries} key of a shared entry. */
1011
+ function sharedEntryKey(entry) {
1012
+ return `${entry.file}#${entry.key}`;
1013
+ }
628
1014
  /**
629
1015
  * Remove an existing ATK-delimited block for the given asset name from file content.
630
1016
  * Matches blocks of the form:
@@ -634,9 +1020,7 @@ async function revertConfigEntry(configFilePath, keyPath, command, format, onPro
634
1020
  * along with surrounding blank lines to keep formatting clean.
635
1021
  */
636
1022
  function stripAtkBlock(content, assetName) {
637
- const escapedName = assetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
638
- const pattern = new RegExp(`(\n{0,2})<!-- ATK: ${escapedName} -->\\n[\\s\\S]*?<!-- /ATK: ${escapedName} -->\\n?`, 'g');
639
- return content.replace(pattern, '');
1023
+ return content.replace(new RegExp(`(\n{0,2})${atkBlockPattern(assetName)}\\n?`, 'g'), '');
640
1024
  }
641
1025
  /**
642
1026
  * Update a tool config file (e.g., settings.json) with an entry for the installed asset.