agentic-workflow-manager 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/src/commands/add.js +84 -0
  2. package/dist/src/commands/agent.js +95 -0
  3. package/dist/src/commands/backup.js +46 -0
  4. package/dist/src/commands/doctor.js +80 -4
  5. package/dist/src/commands/export.js +48 -0
  6. package/dist/src/commands/hooks/claude.js +192 -0
  7. package/dist/src/commands/hooks/codex.js +191 -0
  8. package/dist/src/commands/hooks/index.js +23 -8
  9. package/dist/src/commands/hooks/install.js +11 -107
  10. package/dist/src/commands/hooks/resync.js +28 -18
  11. package/dist/src/commands/hooks/shared.js +94 -0
  12. package/dist/src/commands/hooks/status.js +13 -64
  13. package/dist/src/commands/hooks/uninstall.js +11 -47
  14. package/dist/src/commands/init.js +90 -28
  15. package/dist/src/commands/registry/index.js +1 -1
  16. package/dist/src/commands/sync.js +113 -0
  17. package/dist/src/commands/update.js +107 -0
  18. package/dist/src/core/agent-targets.js +50 -0
  19. package/dist/src/core/artifact-state.js +65 -0
  20. package/dist/src/core/atomic-file.js +72 -0
  21. package/dist/src/core/bundle-install.js +60 -38
  22. package/dist/src/core/bundles.js +21 -0
  23. package/dist/src/core/context/managed-block.js +190 -0
  24. package/dist/src/core/context/orchestrator.js +3 -1
  25. package/dist/src/core/context/project-constitution-inject.js +1 -1
  26. package/dist/src/core/context/regenerate.js +3 -3
  27. package/dist/src/core/context/strategies/codex-agents.js +100 -0
  28. package/dist/src/core/diagnostics/checks.js +16 -0
  29. package/dist/src/core/diagnostics/context.js +37 -8
  30. package/dist/src/core/diagnostics/provider-checks.js +219 -0
  31. package/dist/src/core/discovery.js +9 -3
  32. package/dist/src/core/executor.js +30 -9
  33. package/dist/src/core/export/index.js +51 -0
  34. package/dist/src/core/export/pack.js +58 -0
  35. package/dist/src/core/export/resolve.js +77 -0
  36. package/dist/src/core/export/transform.js +57 -0
  37. package/dist/src/core/export/types.js +2 -0
  38. package/dist/src/core/init/mutation-targets.js +139 -0
  39. package/dist/src/core/init/provider-facts.js +164 -0
  40. package/dist/src/core/init/steps.js +65 -9
  41. package/dist/src/core/install-planner.js +206 -0
  42. package/dist/src/core/install-transaction.js +405 -0
  43. package/dist/src/core/profile.js +1 -1
  44. package/dist/src/core/provider-artifacts.js +54 -0
  45. package/dist/src/core/provider-version.js +36 -0
  46. package/dist/src/core/reconciliation.js +61 -0
  47. package/dist/src/core/registries.js +10 -0
  48. package/dist/src/core/renderers/canonical-agent.js +27 -0
  49. package/dist/src/core/renderers/codex-agent.js +60 -0
  50. package/dist/src/core/skill-integrity.js +4 -3
  51. package/dist/src/index.js +99 -289
  52. package/dist/src/providers/index.js +143 -39
  53. package/dist/src/ui/provider-preflight.js +26 -0
  54. package/dist/src/utils/config.js +101 -9
  55. package/dist/tests/commands/agent.test.js +103 -0
  56. package/dist/tests/commands/backup.test.js +109 -0
  57. package/dist/tests/commands/doctor.test.js +65 -4
  58. package/dist/tests/commands/export.test.js +64 -0
  59. package/dist/tests/commands/hooks/codex.test.js +230 -0
  60. package/dist/tests/commands/hooks/install.test.js +20 -1
  61. package/dist/tests/commands/hooks/resync.test.js +53 -6
  62. package/dist/tests/commands/hooks/status.test.js +10 -0
  63. package/dist/tests/commands/hooks/uninstall.test.js +12 -0
  64. package/dist/tests/commands/init.test.js +195 -8
  65. package/dist/tests/commands/multi-agent-targeting.test.js +231 -0
  66. package/dist/tests/core/agent-targets.test.js +45 -0
  67. package/dist/tests/core/artifact-state.test.js +144 -0
  68. package/dist/tests/core/atomic-file.test.js +96 -0
  69. package/dist/tests/core/bundle-install.test.js +208 -17
  70. package/dist/tests/core/bundles.test.js +12 -0
  71. package/dist/tests/core/context/managed-block.test.js +90 -0
  72. package/dist/tests/core/context/orchestrator.test.js +48 -3
  73. package/dist/tests/core/context/regenerate.test.js +1 -1
  74. package/dist/tests/core/context/strategies/codex-agents.test.js +185 -0
  75. package/dist/tests/core/context/strategies/config-instructions.test.js +1 -1
  76. package/dist/tests/core/diagnostics/checks.test.js +19 -0
  77. package/dist/tests/core/diagnostics/context.test.js +74 -0
  78. package/dist/tests/core/diagnostics/provider-checks.test.js +252 -0
  79. package/dist/tests/core/executor.test.js +20 -0
  80. package/dist/tests/core/export/engine.test.js +104 -0
  81. package/dist/tests/core/export/pack.test.js +122 -0
  82. package/dist/tests/core/export/resolve.test.js +78 -0
  83. package/dist/tests/core/export/transform.test.js +74 -0
  84. package/dist/tests/core/init/mutation-targets.test.js +235 -0
  85. package/dist/tests/core/init/orchestrator.test.js +1 -1
  86. package/dist/tests/core/init/provider-facts.test.js +129 -0
  87. package/dist/tests/core/init/steps.test.js +106 -2
  88. package/dist/tests/core/install-planner.test.js +224 -0
  89. package/dist/tests/core/install-transaction.test.js +257 -0
  90. package/dist/tests/core/provider-artifacts.test.js +64 -0
  91. package/dist/tests/core/provider-version.test.js +53 -0
  92. package/dist/tests/core/reconciliation.test.js +165 -0
  93. package/dist/tests/core/registries-sync.test.js +4 -4
  94. package/dist/tests/core/renderers/canonical-agent.test.js +58 -0
  95. package/dist/tests/core/renderers/codex-agent.test.js +128 -0
  96. package/dist/tests/core/versioning.test.js +1 -1
  97. package/dist/tests/integration/codex-provider-isolated.test.js +206 -0
  98. package/dist/tests/providers/hooks-config.test.js +43 -25
  99. package/dist/tests/providers/index.test.js +144 -39
  100. package/dist/tests/structural/codex-agent-escaping-completeness.test.js +77 -0
  101. package/dist/tests/ui/provider-preflight.test.js +25 -0
  102. package/dist/tests/utils/config.test.js +178 -18
  103. package/package.json +1 -1
@@ -72,12 +72,21 @@ describe('runInit', () => {
72
72
  });
73
73
  it('returns exit 1 on a bare HOME and never prompts with --yes (cache stubbed)', async () => {
74
74
  const { runInit } = require('../../src/commands/init');
75
- const code = await runInit({ cwd: tmpHome, yes: true, actions: { syncCache: async () => { } } });
75
+ const code = await runInit({
76
+ cwd: tmpHome,
77
+ yes: true,
78
+ actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) },
79
+ });
76
80
  expect(code).toBe(1); // cache/hook/devCore siguen ausentes (syncCache no-op) → degradado
77
81
  });
78
82
  it('--json emits a parseable InitOutcome', async () => {
79
83
  const { runInit } = require('../../src/commands/init');
80
- const code = await runInit({ cwd: tmpHome, yes: true, json: true, actions: { syncCache: async () => { } } });
84
+ const code = await runInit({
85
+ cwd: tmpHome,
86
+ yes: true,
87
+ json: true,
88
+ actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) },
89
+ });
81
90
  const written = writeSpy.mock.calls.map((c) => c[0]).join('');
82
91
  const parsed = JSON.parse(written);
83
92
  expect(Array.isArray(parsed.steps)).toBe(true);
@@ -85,23 +94,201 @@ describe('runInit', () => {
85
94
  expect(code).toBe(1);
86
95
  });
87
96
  const prefsFile = () => path_1.default.join(process.env.AWM_HOME, 'preferences.json');
88
- const readAgent = () => JSON.parse(fs_1.default.readFileSync(prefsFile(), 'utf-8')).defaultAgent;
97
+ const readPreferences = () => JSON.parse(fs_1.default.readFileSync(prefsFile(), 'utf-8'));
98
+ const readAgent = () => readPreferences().defaultAgent;
99
+ const readPrefs = () => readPreferences();
100
+ function writePrefs(prefs) {
101
+ const { savePreferences } = require('../../src/utils/config');
102
+ savePreferences(prefs);
103
+ }
104
+ /**
105
+ * Full no-op InitActions stub set — every member records its own token
106
+ * onto `calls` (in invocation order) and returns a benign, type-shaped
107
+ * value so a whole `runInitSteps` pass can complete without touching real
108
+ * registry content. Used both to keep the "does the pipeline actually
109
+ * succeed" tests independent of real registry content, and to observe
110
+ * call order (install-hook / install-bundle) relative to the gate/backup/
111
+ * save-preferences sequence.
112
+ */
113
+ function fakeActions(calls) {
114
+ return {
115
+ syncCache: async () => { calls.push('syncCache'); },
116
+ installHook: () => { calls.push('install-hook'); return { status: 'installed' }; },
117
+ installBundle: () => { calls.push('install-bundle'); return { installed: [], skipped: [] }; },
118
+ syncProfile: () => { calls.push('syncProfile'); return { installed: [], skipped: [], extensions: [] }; },
119
+ initSensors: () => { calls.push('initSensors'); return { detection: { pack: 'generic' } }; },
120
+ addExtension: () => { calls.push('addExtension'); },
121
+ ensureProfile: () => { calls.push('ensureProfile'); },
122
+ gatherProject: () => { calls.push('gatherProject'); return null; },
123
+ contextStatus: () => { calls.push('contextStatus'); return 'absent'; },
124
+ installContext: () => { calls.push('installContext'); },
125
+ repairGlobalSkills: () => { calls.push('repairGlobalSkills'); return { relinked: [], pruned: [], failed: [] }; },
126
+ injectProjectConstitution: () => { calls.push('injectProjectConstitution'); return 'injected'; },
127
+ };
128
+ }
129
+ function codexInitOptions(calls = []) {
130
+ return {
131
+ cwd: tmpHome,
132
+ yes: true,
133
+ agent: 'codex',
134
+ assertProviderSupported: () => ({ provider: 'codex', version: '0.150.0' }),
135
+ actions: fakeActions(calls),
136
+ };
137
+ }
138
+ /** Cheap recursive content snapshot for "did this subtree change at all" assertions. */
139
+ function snapshotTree(dir) {
140
+ if (!fs_1.default.existsSync(dir))
141
+ return null;
142
+ const walk = (p) => {
143
+ const st = fs_1.default.lstatSync(p);
144
+ if (st.isSymbolicLink())
145
+ return { type: 'symlink', target: fs_1.default.readlinkSync(p) };
146
+ if (st.isDirectory()) {
147
+ const entries = fs_1.default.readdirSync(p).sort();
148
+ return { type: 'dir', entries: Object.fromEntries(entries.map((e) => [e, walk(path_1.default.join(p, e))])) };
149
+ }
150
+ return { type: 'file', content: fs_1.default.readFileSync(p, 'utf8') };
151
+ };
152
+ return walk(dir);
153
+ }
89
154
  it('#7: first init (no -a) persists claude-code as the default agent', async () => {
90
155
  const { runInit } = require('../../src/commands/init');
91
156
  expect(fs_1.default.existsSync(prefsFile())).toBe(false);
92
- await runInit({ cwd: tmpHome, yes: true, actions: { syncCache: async () => { } } });
157
+ await runInit({ cwd: tmpHome, yes: true, actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) } });
93
158
  expect(readAgent()).toBe('claude-code');
94
159
  });
95
160
  it('#7: init -a opencode persists the explicit agent', async () => {
96
161
  const { runInit } = require('../../src/commands/init');
97
- await runInit({ cwd: tmpHome, yes: true, agent: 'opencode', actions: { syncCache: async () => { } } });
162
+ const code = await runInit({
163
+ cwd: tmpHome,
164
+ yes: true,
165
+ agent: 'opencode',
166
+ actions: fakeActions([]),
167
+ });
168
+ expect(code).toBeLessThanOrEqual(1);
98
169
  expect(readAgent()).toBe('opencode');
170
+ expect(readPreferences().enabledAgents).toContain('opencode');
99
171
  });
100
172
  it('#7: re-init without -a does NOT clobber an existing explicit preference', async () => {
101
- const { savePreferences } = require('../../src/utils/config');
102
- savePreferences({ defaultAgent: 'opencode', installMethod: 'symlink', defaultScope: 'local' });
173
+ writePrefs({
174
+ defaultAgent: 'opencode',
175
+ enabledAgents: ['opencode'],
176
+ installMethod: 'symlink',
177
+ defaultScope: 'local',
178
+ });
103
179
  const { runInit } = require('../../src/commands/init');
104
- await runInit({ cwd: tmpHome, yes: true, actions: { syncCache: async () => { } } });
180
+ await runInit({ cwd: tmpHome, yes: true, actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) } });
105
181
  expect(readAgent()).toBe('opencode');
106
182
  });
183
+ it('enabling a new default preserves every previously enabled agent', async () => {
184
+ writePrefs({
185
+ defaultAgent: 'claude-code',
186
+ enabledAgents: ['claude-code', 'codex'],
187
+ installMethod: 'symlink',
188
+ defaultScope: 'local',
189
+ });
190
+ const { runInit } = require('../../src/commands/init');
191
+ const code = await runInit({
192
+ cwd: tmpHome,
193
+ yes: true,
194
+ agent: 'opencode',
195
+ actions: fakeActions([]),
196
+ });
197
+ expect(code).toBeLessThanOrEqual(1);
198
+ expect(readPreferences().enabledAgents)
199
+ .toEqual(['claude-code', 'codex', 'opencode']);
200
+ });
201
+ // -----------------------------------------------------------------------
202
+ // Step 1 — gate ordering, backup/rollback atomicity, Codex/Claude coexistence
203
+ // -----------------------------------------------------------------------
204
+ it('gates Codex before preferences or provider writes', async () => {
205
+ const calls = [];
206
+ const { runInit } = require('../../src/commands/init');
207
+ const code = await runInit({
208
+ cwd: tmpHome,
209
+ agent: 'codex',
210
+ yes: true,
211
+ actions: fakeActions(calls),
212
+ assertProviderSupported: () => {
213
+ calls.push('version-gate');
214
+ throw new Error('requires Codex >= 0.145.0');
215
+ },
216
+ });
217
+ expect(code).toBe(2);
218
+ expect(calls).toEqual(['version-gate']); // verifies R2
219
+ expect(fs_1.default.existsSync(prefsFile())).toBe(false);
220
+ });
221
+ it('enables Codex without changing the existing default or Claude files', async () => {
222
+ writePrefs({
223
+ defaultAgent: 'claude-code',
224
+ enabledAgents: ['claude-code', 'opencode'],
225
+ installMethod: 'symlink',
226
+ defaultScope: 'local',
227
+ });
228
+ const claudeBefore = snapshotTree(path_1.default.join(tmpHome, '.claude'));
229
+ const { runInit } = require('../../src/commands/init');
230
+ const code = await runInit(codexInitOptions());
231
+ expect(code).toBeLessThanOrEqual(1);
232
+ expect(readPrefs().defaultAgent).toBe('claude-code');
233
+ expect(readPrefs().enabledAgents).toEqual(['claude-code', 'opencode', 'codex']); // verifies R11
234
+ expect(snapshotTree(path_1.default.join(tmpHome, '.claude'))).toEqual(claudeBefore); // verifies R19
235
+ });
236
+ it('runs one session in order: version-gate < begin-backup < save-preferences < install-hook < install-bundle', async () => {
237
+ const calls = [];
238
+ const installTransaction = require('../../src/core/install-transaction');
239
+ const realBeginBackupSession = installTransaction.beginBackupSession;
240
+ jest.spyOn(installTransaction, 'beginBackupSession').mockImplementation((...args) => {
241
+ calls.push('begin-backup');
242
+ return realBeginBackupSession(...args);
243
+ });
244
+ const config = require('../../src/utils/config');
245
+ const realSavePreferences = config.savePreferences;
246
+ jest.spyOn(config, 'savePreferences').mockImplementation((...args) => {
247
+ calls.push('save-preferences');
248
+ return realSavePreferences(...args);
249
+ });
250
+ const { runInit } = require('../../src/commands/init');
251
+ const code = await runInit({
252
+ cwd: tmpHome,
253
+ yes: true,
254
+ actions: fakeActions(calls),
255
+ assertProviderSupported: () => {
256
+ calls.push('version-gate');
257
+ return { provider: 'claude-code', version: null };
258
+ },
259
+ });
260
+ expect(code).toBeLessThanOrEqual(1);
261
+ const relevant = calls.filter((c) => ['version-gate', 'begin-backup', 'save-preferences', 'install-hook', 'install-bundle'].includes(c));
262
+ expect(relevant).toEqual(['version-gate', 'begin-backup', 'save-preferences', 'install-hook', 'install-bundle']);
263
+ });
264
+ it('rolls back a genuinely-modified target (real hook install) when a later step fails', async () => {
265
+ // Seed just enough real registry content for the REAL installHook to
266
+ // succeed and actually merge an AWM entry into settings.json.
267
+ const contentRoot = path_1.default.join(process.env.AWM_HOME, 'registries', 'baseline');
268
+ fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'hooks'), { recursive: true });
269
+ fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'hooks', 'session-start'), '#!/bin/sh\n', { mode: 0o755 });
270
+ fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'hooks', 'run-hook.cmd'), '#!/bin/sh\n', { mode: 0o755 });
271
+ fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'skills', 'using-awm'), { recursive: true });
272
+ fs_1.default.writeFileSync(path_1.default.join(contentRoot, 'skills', 'using-awm', 'SKILL.md'), '# using-awm');
273
+ fs_1.default.writeFileSync(path_1.default.join(process.env.AWM_HOME, 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.com/baseline.git' }], null, 2));
274
+ const settingsPath = path_1.default.join(tmpHome, '.claude', 'settings.json');
275
+ fs_1.default.mkdirSync(path_1.default.dirname(settingsPath), { recursive: true });
276
+ fs_1.default.writeFileSync(settingsPath, JSON.stringify({ pristine: true, unrelated: 'keep' }, null, 2));
277
+ const calls = [];
278
+ const actions = fakeActions(calls);
279
+ delete actions.installHook; // let the REAL installHook run and genuinely modify settings.json
280
+ actions.installBundle = () => { throw new Error('boom'); }; // devCore step fails AFTER hook succeeded
281
+ const { runInit } = require('../../src/commands/init');
282
+ const code = await runInit({
283
+ cwd: tmpHome,
284
+ yes: true,
285
+ actions,
286
+ });
287
+ expect(code).toBe(2);
288
+ // preferences.json didn't exist before this run — rollback removes it again
289
+ expect(fs_1.default.existsSync(prefsFile())).toBe(false);
290
+ // settings.json WAS genuinely modified mid-run (real installHook ran) —
291
+ // rollback must restore its exact pre-run content, not just leave it be.
292
+ expect(JSON.parse(fs_1.default.readFileSync(settingsPath, 'utf8'))).toEqual({ pristine: true, unrelated: 'keep' });
293
+ });
107
294
  });
@@ -0,0 +1,231 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ // tests/commands/multi-agent-targeting.test.ts
7
+ //
8
+ // R12/R13/R14: `add`, `remove`, `sync`, `update` and `doctor` must all resolve
9
+ // agent targets the same way — every enabled agent when `--agent` is absent,
10
+ // an explicit comma-separated subset when present — and `add` must refuse a
11
+ // selection that would strand a shared artifact target (R14).
12
+ //
13
+ // Each command exposes (or is wired to) a UI-free core that this file drives
14
+ // directly, injecting spies where the command has a real side effect
15
+ // (installBundle / syncProfile / planReconciliation) so the test observes
16
+ // exactly which agent targets that side effect received, without touching
17
+ // real registry content or the network.
18
+ const fs_1 = __importDefault(require("fs"));
19
+ const os_1 = __importDefault(require("os"));
20
+ const path_1 = __importDefault(require("path"));
21
+ describe('multi-agent targeting (add/remove/sync/update/doctor)', () => {
22
+ let tmpHome;
23
+ let originalHome;
24
+ let originalAwmHome;
25
+ beforeEach(() => {
26
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-targeting-'));
27
+ originalHome = process.env.HOME;
28
+ originalAwmHome = process.env.AWM_HOME;
29
+ process.env.HOME = tmpHome;
30
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
31
+ jest.resetModules();
32
+ });
33
+ afterEach(() => {
34
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
35
+ if (originalHome === undefined)
36
+ delete process.env.HOME;
37
+ else
38
+ process.env.HOME = originalHome;
39
+ if (originalAwmHome === undefined)
40
+ delete process.env.AWM_HOME;
41
+ else
42
+ process.env.AWM_HOME = originalAwmHome;
43
+ });
44
+ function writePrefs(enabledAgents, defaultAgent = enabledAgents[0]) {
45
+ const { savePreferences } = require('../../src/utils/config');
46
+ const prefs = {
47
+ defaultAgent,
48
+ enabledAgents,
49
+ installMethod: 'symlink',
50
+ defaultScope: 'local',
51
+ };
52
+ savePreferences(prefs);
53
+ return prefs;
54
+ }
55
+ function bundle(over = {}) {
56
+ return {
57
+ name: 'demo', description: '', version: '1.0.0', scope: 'baseline', visibility: 'public',
58
+ dependsOn: [], skills: [{ name: 'demo-skill', onSignal: false }], workflows: [], agents: [],
59
+ ...over,
60
+ };
61
+ }
62
+ async function invokeCommandWithPlannerSpy(command, explicit) {
63
+ const prefs = writePrefs(['claude-code', 'opencode', 'codex'], 'claude-code');
64
+ switch (command) {
65
+ case 'add': {
66
+ const { runAddBundleCore } = require('../../src/commands/add');
67
+ let captured = [];
68
+ const outcome = runAddBundleCore({ name: 'demo', agent: explicit, cwd: tmpHome }, prefs, [bundle()], { addBundle: (o) => { captured = o.agents; return { installed: [], skipped: [], recordedExtension: null, transactionId: 'tx', modifiedFiles: [] }; } });
69
+ return { selectedAgents: captured, code: outcome.code };
70
+ }
71
+ case 'remove': {
72
+ // remove has no bundle-name concept — it resolves targets the same
73
+ // way as the other 4 commands (index.ts calls resolveAgentTargets
74
+ // directly), so this exercises that exact function.
75
+ const { resolveAgentTargets } = require('../../src/core/agent-targets');
76
+ const selectedAgents = resolveAgentTargets({ prefs, explicit });
77
+ return { selectedAgents };
78
+ }
79
+ case 'sync': {
80
+ const projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-sync-project-'));
81
+ fs_1.default.mkdirSync(path_1.default.join(projectRoot, '.awm'), { recursive: true });
82
+ fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'profile.json'), JSON.stringify({ extensions: ['demo'] }));
83
+ const { runSyncCore } = require('../../src/commands/sync');
84
+ let captured = [];
85
+ const outcome = await runSyncCore({ cwd: projectRoot, agent: explicit }, {
86
+ syncRegistries: async () => [],
87
+ verifyMinCliVersions: () => [],
88
+ verifyProjectPins: async () => [],
89
+ syncProfile: (o) => { captured = o.agents; return { installed: [], skipped: [], extensions: ['demo'], transactionIds: [], modifiedFiles: [] }; },
90
+ });
91
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
92
+ return { selectedAgents: captured, code: outcome.code };
93
+ }
94
+ case 'update': {
95
+ const { runUpdateCore } = require('../../src/commands/update');
96
+ let captured = [];
97
+ const outcome = await runUpdateCore({ agent: explicit }, {
98
+ syncRegistries: async () => [],
99
+ verifyMinCliVersions: () => [],
100
+ regenerateGlobalContext: () => [],
101
+ planReconciliation: (o) => { captured = o.targets; return { operations: [], records: [], reports: [] }; },
102
+ applyInstallPlan: () => ({ installed: [], skipped: [], transactionId: 'tx', modifiedFiles: [] }),
103
+ resyncInstalledHooks: () => [],
104
+ offerSelfUpdate: async () => { },
105
+ });
106
+ return { selectedAgents: captured, code: outcome.code };
107
+ }
108
+ case 'doctor': {
109
+ const { runDoctor } = require('../../src/commands/doctor');
110
+ let captured = [];
111
+ const { resolveAgentTargets } = require('../../src/core/agent-targets');
112
+ const code = runDoctor({
113
+ cwd: tmpHome,
114
+ agent: explicit,
115
+ resolveTargets: (input) => {
116
+ const resolved = resolveAgentTargets(input);
117
+ captured = resolved;
118
+ return resolved;
119
+ },
120
+ });
121
+ return { selectedAgents: captured, code };
122
+ }
123
+ }
124
+ }
125
+ it.each(['add', 'remove', 'sync', 'update', 'doctor'])('%s targets all enabled providers when --agent is absent', async (command) => {
126
+ const observed = await invokeCommandWithPlannerSpy(command, undefined);
127
+ expect(observed.selectedAgents).toEqual(['claude-code', 'opencode', 'codex']); // verifies R12
128
+ });
129
+ it.each(['add', 'remove', 'sync', 'update', 'doctor'])('%s honors an independently addressable explicit subset', async (command) => {
130
+ const observed = await invokeCommandWithPlannerSpy(command, 'codex,claude-code');
131
+ expect(observed.selectedAgents).toEqual(['codex', 'claude-code']); // verifies R13
132
+ });
133
+ // -----------------------------------------------------------------------
134
+ // R14 — shared-target completeness
135
+ // -----------------------------------------------------------------------
136
+ it('add refuses a codex-only skill install when OpenCode (sharing the same skill dir) is also enabled', () => {
137
+ writePrefs(['opencode', 'codex'], 'opencode');
138
+ const { runAddBundleCore } = require('../../src/commands/add');
139
+ const { getPreferences } = require('../../src/utils/config');
140
+ const outcome = runAddBundleCore({ name: 'demo', agent: 'codex', cwd: tmpHome }, getPreferences(), [bundle()]);
141
+ expect(outcome.code).toBe(1); // verifies R14 — planInstall's assertCompleteSharedGroup rejects the partial selection
142
+ });
143
+ // remove does NOT reproduce add's shared-target refusal by design: R16
144
+ // says a shared target is *retained* (not deleted, not errored) as long
145
+ // as another enabled owner remains. Selecting only 'codex' to remove a
146
+ // skill OpenCode still owns must succeed and leave the shared symlink in
147
+ // place — documented here since the plan text's "add/remove both fail"
148
+ // phrasing doesn't hold for remove once R16 is taken into account (see
149
+ // install-planner.ts's planRemoval, which has no assertCompleteSharedGroup
150
+ // equivalent, unlike planInstall).
151
+ it('remove retains (does not error on) a shared skill target still owned by another enabled agent', () => {
152
+ const { planRemoval } = require('../../src/core/install-planner');
153
+ const shared = {
154
+ name: 'demo-skill', type: 'skill', scope: 'global',
155
+ targetPath: '/home/x/.agents/skills/demo-skill', sourcePath: '/registry/skills/demo-skill',
156
+ renderer: 'link', owners: ['opencode', 'codex'],
157
+ };
158
+ const plan = planRemoval({
159
+ records: [shared],
160
+ selectedAgents: ['codex'],
161
+ enabledAgents: ['opencode', 'codex'],
162
+ artifactNames: ['demo-skill'],
163
+ });
164
+ expect(plan.operations).toEqual([]); // no unlink — still owned by opencode
165
+ expect(plan.records).toEqual([{ ...shared, owners: ['opencode'] }]);
166
+ });
167
+ // -----------------------------------------------------------------------
168
+ // update: syncs each registry once, then reconciles the full target set once
169
+ // -----------------------------------------------------------------------
170
+ it('update syncs registries once and reconciles the resolved targets in a single plan/apply pass', async () => {
171
+ writePrefs(['claude-code', 'opencode', 'codex'], 'claude-code');
172
+ // Seed a hooks/ dir so capabilityRoot('hooks') resolves and the hook-resync stage actually runs.
173
+ const contentRoot = path_1.default.join(process.env.AWM_HOME, 'registries', 'baseline');
174
+ fs_1.default.mkdirSync(path_1.default.join(contentRoot, 'hooks'), { recursive: true });
175
+ fs_1.default.writeFileSync(path_1.default.join(process.env.AWM_HOME, 'registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.com/baseline.git' }], null, 2));
176
+ const { runUpdateCore } = require('../../src/commands/update');
177
+ const syncRegistries = jest.fn(async () => []);
178
+ const planReconciliation = jest.fn((o) => ({ operations: [], records: [], reports: [] }));
179
+ const applyInstallPlan = jest.fn(() => ({ installed: [], skipped: [], transactionId: 'tx', modifiedFiles: [] }));
180
+ const resyncInstalledHooks = jest.fn(() => []);
181
+ const outcome = await runUpdateCore({}, {
182
+ syncRegistries,
183
+ verifyMinCliVersions: () => [],
184
+ regenerateGlobalContext: () => [],
185
+ planReconciliation,
186
+ applyInstallPlan,
187
+ resyncInstalledHooks,
188
+ offerSelfUpdate: async () => { },
189
+ });
190
+ expect(outcome.code).toBe(0);
191
+ expect(syncRegistries).toHaveBeenCalledTimes(1);
192
+ expect(planReconciliation).toHaveBeenCalledTimes(1);
193
+ expect(applyInstallPlan).toHaveBeenCalledTimes(1);
194
+ expect(planReconciliation.mock.calls[0][0].targets).toEqual(['claude-code', 'opencode', 'codex']);
195
+ expect(resyncInstalledHooks).toHaveBeenCalledTimes(1); // once per update run, not once per provider
196
+ });
197
+ it('update reports the failing stage and returns a non-zero exit code instead of swallowing the error', async () => {
198
+ writePrefs(['claude-code'], 'claude-code');
199
+ const { runUpdateCore } = require('../../src/commands/update');
200
+ const outcome = await runUpdateCore({}, {
201
+ syncRegistries: async () => [],
202
+ verifyMinCliVersions: () => [],
203
+ regenerateGlobalContext: () => [],
204
+ planReconciliation: () => { throw new Error('reconciliation exploded'); },
205
+ applyInstallPlan: () => ({ installed: [], skipped: [], transactionId: 'tx', modifiedFiles: [] }),
206
+ resyncInstalledHooks: () => [],
207
+ offerSelfUpdate: async () => { },
208
+ });
209
+ expect(outcome.code).toBe(1); // no silent catch{} — verifies Step 6's "no aborta" removal
210
+ });
211
+ it('sync reports a syncProfile failure and returns a non-zero exit code instead of crashing uncaught', async () => {
212
+ writePrefs(['claude-code'], 'claude-code');
213
+ const projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-sync-project-'));
214
+ fs_1.default.mkdirSync(path_1.default.join(projectRoot, '.awm'), { recursive: true });
215
+ fs_1.default.writeFileSync(path_1.default.join(projectRoot, '.awm', 'profile.json'), JSON.stringify({ extensions: ['demo'] }));
216
+ const { runSyncCore } = require('../../src/commands/sync');
217
+ let outcome;
218
+ try {
219
+ outcome = await runSyncCore({ cwd: projectRoot }, {
220
+ syncRegistries: async () => [],
221
+ verifyMinCliVersions: () => [],
222
+ verifyProjectPins: async () => [],
223
+ syncProfile: () => { throw new Error('physical target already claimed by a different source'); },
224
+ });
225
+ }
226
+ finally {
227
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
228
+ }
229
+ expect(outcome.code).toBe(1); // no silent catch{} — syncProfile's throw must not propagate uncaught
230
+ });
231
+ });
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const agent_targets_1 = require("../../src/core/agent-targets");
4
+ const prefs = {
5
+ defaultAgent: 'claude-code',
6
+ enabledAgents: ['claude-code', 'opencode', 'codex'],
7
+ installMethod: 'symlink',
8
+ defaultScope: 'local',
9
+ };
10
+ describe('resolveAgentTargets', () => {
11
+ it('targets every enabled agent when --agent is absent', () => {
12
+ expect((0, agent_targets_1.resolveAgentTargets)({ prefs, explicit: undefined }))
13
+ .toEqual(['claude-code', 'opencode', 'codex']);
14
+ });
15
+ it('returns a copy without mutating the enabled list', () => {
16
+ const enabledBefore = [...prefs.enabledAgents];
17
+ const resolved = (0, agent_targets_1.resolveAgentTargets)({ prefs });
18
+ expect(resolved).not.toBe(prefs.enabledAgents);
19
+ expect(prefs.enabledAgents).toEqual(enabledBefore);
20
+ });
21
+ it('preserves an exact explicit enabled subset', () => {
22
+ expect((0, agent_targets_1.resolveAgentTargets)({ prefs, explicit: 'codex,claude-code' }))
23
+ .toEqual(['codex', 'claude-code']);
24
+ });
25
+ it('trims and deduplicates explicit agents in first-seen order', () => {
26
+ expect((0, agent_targets_1.resolveAgentTargets)({
27
+ prefs,
28
+ explicit: ' codex, claude-code,codex,opencode ',
29
+ })).toEqual(['codex', 'claude-code', 'opencode']);
30
+ });
31
+ it('rejects an explicit disabled provider', () => {
32
+ expect(() => (0, agent_targets_1.resolveAgentTargets)({
33
+ prefs: { ...prefs, enabledAgents: ['claude-code'] },
34
+ explicit: 'codex',
35
+ })).toThrow('codex is not enabled; run awm init --agent codex');
36
+ });
37
+ it.each(['', ' ', ' , , '])('rejects empty explicit input %j', (explicit) => {
38
+ expect(() => (0, agent_targets_1.resolveAgentTargets)({ prefs, explicit }))
39
+ .toThrow('--agent requires at least one provider');
40
+ });
41
+ it('rejects an invalid provider', () => {
42
+ expect(() => (0, agent_targets_1.resolveAgentTargets)({ prefs, explicit: 'unknown' }))
43
+ .toThrow('Invalid agent "unknown".');
44
+ });
45
+ });
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const os_1 = __importDefault(require("os"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const artifact_state_1 = require("../../src/core/artifact-state");
10
+ describe('artifact-state', () => {
11
+ let tmpHome;
12
+ let stateFile;
13
+ let originalHome;
14
+ let originalAwmHome;
15
+ beforeEach(() => {
16
+ // Per CLAUDE.md: no test may touch the real ~/.awm — isolate HOME/AWM_HOME.
17
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-artifact-state-'));
18
+ originalHome = process.env.HOME;
19
+ originalAwmHome = process.env.AWM_HOME;
20
+ process.env.HOME = tmpHome;
21
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
22
+ stateFile = path_1.default.join(tmpHome, '.awm', 'state', 'artifacts.json');
23
+ });
24
+ afterEach(() => {
25
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
26
+ if (originalHome === undefined)
27
+ delete process.env.HOME;
28
+ else
29
+ process.env.HOME = originalHome;
30
+ if (originalAwmHome === undefined)
31
+ delete process.env.AWM_HOME;
32
+ else
33
+ process.env.AWM_HOME = originalAwmHome;
34
+ });
35
+ function record(name, targetPath, owners) {
36
+ return {
37
+ name,
38
+ type: 'skill',
39
+ scope: 'global',
40
+ targetPath,
41
+ sourcePath: path_1.default.join(tmpHome, 'registry', 'skills', name),
42
+ renderer: 'link',
43
+ owners,
44
+ };
45
+ }
46
+ describe('artifactStateFile', () => {
47
+ it('resolves under AWM_HOME/state/artifacts.json', () => {
48
+ expect((0, artifact_state_1.artifactStateFile)()).toBe(stateFile);
49
+ });
50
+ });
51
+ describe('readArtifactState', () => {
52
+ it('returns [] when the file does not exist', () => {
53
+ expect(fs_1.default.existsSync(stateFile)).toBe(false);
54
+ expect((0, artifact_state_1.readArtifactState)(stateFile)).toEqual([]);
55
+ });
56
+ it('throws a clear error on invalid JSON', () => {
57
+ fs_1.default.mkdirSync(path_1.default.dirname(stateFile), { recursive: true });
58
+ fs_1.default.writeFileSync(stateFile, '{ not json');
59
+ expect(() => (0, artifact_state_1.readArtifactState)(stateFile)).toThrow(/not valid JSON/);
60
+ });
61
+ it('throws when the parsed content is not an array', () => {
62
+ fs_1.default.mkdirSync(path_1.default.dirname(stateFile), { recursive: true });
63
+ fs_1.default.writeFileSync(stateFile, JSON.stringify({ foo: 'bar' }));
64
+ expect(() => (0, artifact_state_1.readArtifactState)(stateFile)).toThrow(/must contain an array/);
65
+ });
66
+ it('defaults to artifactStateFile() when no file is given', () => {
67
+ expect((0, artifact_state_1.readArtifactState)()).toEqual([]);
68
+ });
69
+ });
70
+ describe('writeArtifactState / round-trip', () => {
71
+ it('round-trips records written then read back', () => {
72
+ const records = [
73
+ record('development-process', path_1.default.join(tmpHome, '.agents/skills/development-process'), ['opencode', 'codex']),
74
+ ];
75
+ (0, artifact_state_1.writeArtifactState)(records, stateFile);
76
+ expect((0, artifact_state_1.readArtifactState)(stateFile)).toEqual(records);
77
+ });
78
+ it('sorts output by targetPath deterministically regardless of input order', () => {
79
+ const b = record('b-skill', path_1.default.join(tmpHome, '.agents/skills/b-skill'), ['codex']);
80
+ const a = record('a-skill', path_1.default.join(tmpHome, '.agents/skills/a-skill'), ['codex']);
81
+ const c = record('c-skill', path_1.default.join(tmpHome, '.agents/skills/c-skill'), ['codex']);
82
+ (0, artifact_state_1.writeArtifactState)([b, c, a], stateFile);
83
+ const persisted = JSON.parse(fs_1.default.readFileSync(stateFile, 'utf8'));
84
+ expect(persisted.map((r) => r.targetPath)).toEqual([
85
+ a.targetPath,
86
+ b.targetPath,
87
+ c.targetPath,
88
+ ]);
89
+ expect((0, artifact_state_1.readArtifactState)(stateFile)).toEqual([a, b, c]);
90
+ });
91
+ it('creates the state directory if missing and writes with restrictive mode', () => {
92
+ expect(fs_1.default.existsSync(path_1.default.dirname(stateFile))).toBe(false);
93
+ (0, artifact_state_1.writeArtifactState)([record('s', path_1.default.join(tmpHome, '.agents/skills/s'), ['codex'])], stateFile);
94
+ expect(fs_1.default.existsSync(stateFile)).toBe(true);
95
+ expect(fs_1.default.statSync(stateFile).mode & 0o777).toBe(0o600);
96
+ });
97
+ });
98
+ describe('mergeArtifactRecords', () => {
99
+ it('preserves existing records whose targetPath is not touched by incoming', () => {
100
+ const a = record('a-skill', path_1.default.join(tmpHome, '.agents/skills/a-skill'), ['codex']);
101
+ const b = record('b-skill', path_1.default.join(tmpHome, '.agents/skills/b-skill'), ['opencode']);
102
+ const merged = (0, artifact_state_1.mergeArtifactRecords)([a], [b]);
103
+ expect(merged).toEqual(expect.arrayContaining([a, b]));
104
+ expect(merged).toHaveLength(2);
105
+ });
106
+ it('replaces the existing record for a targetPath shared with an incoming record, without duplicating it', () => {
107
+ const targetPath = path_1.default.join(tmpHome, '.agents/skills/shared');
108
+ const before = record('shared', targetPath, ['codex']);
109
+ const after = record('shared', targetPath, ['codex', 'opencode']);
110
+ const merged = (0, artifact_state_1.mergeArtifactRecords)([before], [after]);
111
+ expect(merged).toHaveLength(1);
112
+ expect(merged[0]).toEqual(after);
113
+ });
114
+ it('simulates a multi-bundle awm init: sequential applyInstallPlan-style merges accumulate, not overwrite', () => {
115
+ // Mirrors the real bug: several separate applyInstallPlan calls,
116
+ // each only knowing about the artifacts IT touched, must not
117
+ // discard each other's records when persisted in sequence.
118
+ const bundle1 = record('dev-core', path_1.default.join(tmpHome, '.agents/skills/dev-core'), ['claude-code']);
119
+ const bundle2 = record('ambient', path_1.default.join(tmpHome, '.agents/skills/ambient'), ['codex']);
120
+ let state = (0, artifact_state_1.mergeArtifactRecords)((0, artifact_state_1.readArtifactState)(stateFile), [bundle1]);
121
+ (0, artifact_state_1.writeArtifactState)(state, stateFile);
122
+ state = (0, artifact_state_1.mergeArtifactRecords)((0, artifact_state_1.readArtifactState)(stateFile), [bundle2]);
123
+ (0, artifact_state_1.writeArtifactState)(state, stateFile);
124
+ expect((0, artifact_state_1.readArtifactState)(stateFile)).toEqual(expect.arrayContaining([bundle1, bundle2]));
125
+ expect((0, artifact_state_1.readArtifactState)(stateFile)).toHaveLength(2);
126
+ });
127
+ it('updating the same targetPath twice (e.g. re-running init) replaces without duplicating or losing unrelated records', () => {
128
+ const unrelated = record('unrelated', path_1.default.join(tmpHome, '.agents/skills/unrelated'), ['codex']);
129
+ const targetPath = path_1.default.join(tmpHome, '.agents/skills/dev-core');
130
+ const v1 = record('dev-core', targetPath, ['claude-code']);
131
+ const v2 = record('dev-core', targetPath, ['claude-code', 'opencode']);
132
+ let state = (0, artifact_state_1.mergeArtifactRecords)((0, artifact_state_1.readArtifactState)(stateFile), [unrelated]);
133
+ (0, artifact_state_1.writeArtifactState)(state, stateFile);
134
+ state = (0, artifact_state_1.mergeArtifactRecords)((0, artifact_state_1.readArtifactState)(stateFile), [v1]);
135
+ (0, artifact_state_1.writeArtifactState)(state, stateFile);
136
+ state = (0, artifact_state_1.mergeArtifactRecords)((0, artifact_state_1.readArtifactState)(stateFile), [v2]);
137
+ (0, artifact_state_1.writeArtifactState)(state, stateFile);
138
+ const final = (0, artifact_state_1.readArtifactState)(stateFile);
139
+ expect(final).toHaveLength(2);
140
+ expect(final).toEqual(expect.arrayContaining([unrelated, v2]));
141
+ expect(final).not.toEqual(expect.arrayContaining([v1]));
142
+ });
143
+ });
144
+ });