agentic-workflow-manager 6.4.1 → 6.4.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.
@@ -52,13 +52,30 @@ function runAddBundleCore(options, prefs, bundles, deps = {}) {
52
52
  console.error(picocolors_1.default.red('No project root found (need a .git/, package.json, or .awm/profile.json here). Run inside a project, or pass --global.'));
53
53
  return { code: 1, selectedAgents };
54
54
  }
55
+ // `--method` used to be accepted here and silently discarded — this branch
56
+ // hardcoded 'symlink' regardless of what was passed. Since D-001 made bundle
57
+ // names the ONLY thing `add [name]` resolves against, this is the sole live
58
+ // path for any real invocation; the interactive/`--all` path below still has
59
+ // its own (correct) method resolution, but a valid bundle name never reaches
60
+ // it. Found running the issue #55 Windows playbook's WIN-02: `--method copy`
61
+ // on native Windows still produced a Junction, because 'symlink' was the
62
+ // only value that ever actually reached the installer. The no-flag default
63
+ // stays 'symlink' (unchanged) — only the explicit-override case was broken.
64
+ let methodVal = 'symlink';
65
+ if (options.method) {
66
+ if (options.method !== 'symlink' && options.method !== 'copy') {
67
+ console.error(picocolors_1.default.red(`Invalid method "${options.method}". Use: symlink or copy.`));
68
+ return { code: 1, selectedAgents };
69
+ }
70
+ methodVal = options.method;
71
+ }
55
72
  let result;
56
73
  try {
57
74
  result = d.addBundle({
58
75
  bundleName: matchedBundle.name,
59
76
  bundles,
60
77
  agents: selectedAgents,
61
- method: 'symlink',
78
+ method: methodVal,
62
79
  projectRoot: projectRoot ?? cwd,
63
80
  scopeOverride,
64
81
  });
@@ -128,8 +128,17 @@ async function runInit(opts = {}) {
128
128
  // Fires at most once per `awm init` run (native Windows only) — this is
129
129
  // the single emission point; `renderReport`/`renderInitOutcome` below do
130
130
  // NOT also embed it (that used to triple-fire the same text: once here,
131
- // once in the "Initial state" render, once in "Final state").
132
- (0, paths_1.noteWindowsCaveat)((m) => console.log(picocolors_1.default.dim(`ℹ ${m}`)));
131
+ // once in the "Initial state" render, once in "Final state"). In `--json`
132
+ // mode it goes to stderr instead of stdout: stdout must stay pure JSON for
133
+ // `awm init --yes --json > init.json` (documented in core-acceptance.md
134
+ // CORE-03) — a stray banner ahead of the `{` broke that contract on every
135
+ // native-Windows `--json` run.
136
+ if (opts.json) {
137
+ (0, paths_1.noteWindowsCaveat)((m) => process.stderr.write(picocolors_1.default.dim(`ℹ ${m}`) + '\n'));
138
+ }
139
+ else {
140
+ (0, paths_1.noteWindowsCaveat)((m) => console.log(picocolors_1.default.dim(`ℹ ${m}`)));
141
+ }
133
142
  const cwd = opts.cwd ?? process.cwd();
134
143
  const agent = opts.agent === undefined ? 'claude-code' : (0, providers_1.requireAgentTarget)(opts.agent);
135
144
  // R2: gate BEFORE anything is read or written — an unsupported provider
@@ -4,6 +4,7 @@ exports.assertProviderSupported = assertProviderSupported;
4
4
  const child_process_1 = require("child_process");
5
5
  const providers_1 = require("../providers");
6
6
  const versioning_1 = require("./versioning");
7
+ const paths_1 = require("./paths");
7
8
  function assertProviderSupported(agent, exec = child_process_1.execFileSync) {
8
9
  const provider = (0, providers_1.providerFor)(agent);
9
10
  if (!provider.versionCommand || !provider.minimumVersion) {
@@ -15,17 +16,40 @@ function assertProviderSupported(agent, exec = child_process_1.execFileSync) {
15
16
  encoding: 'utf8',
16
17
  stdio: ['ignore', 'pipe', 'pipe'],
17
18
  timeout: 5000,
19
+ // Windows can't CreateProcess a `.cmd` shim directly (npm installs
20
+ // `codex` as `codex.cmd`, not `codex.exe`) — execFileSync needs a
21
+ // shell to resolve and run it, or it throws ENOENT even though
22
+ // typing `codex --version` in the same shell works fine. Safe here
23
+ // (unlike sensors.json's `cmd`, core/paths.ts's resolveOnPath):
24
+ // `provider.versionCommand.command`/`args` are hardcoded first-party
25
+ // config (providers/index.ts), never attacker-controlled input.
26
+ // Found running the issue #55 Windows playbook: `awm init -a codex`
27
+ // reported "Codex is not installed" on a machine where it plainly
28
+ // was — `codex --version` worked fine typed directly.
29
+ shell: (0, paths_1.isWindowsNative)(),
18
30
  }).toString();
19
31
  }
20
32
  catch (error) {
21
- const code = error.code;
33
+ const err = error;
22
34
  // `provider.label` y `versionCommand`, no "Codex" literal. Esta funcion es
23
35
  // generica sobre AgentTarget desde siempre, pero cada mensaje y el patron de
24
36
  // parseo nombraban al unico provider que hoy declara `versionCommand` — el
25
37
  // segundo en declararlo habria reportado "Codex no esta instalado" al no
26
38
  // encontrar SU binario, y habria fallado a parsear una salida perfectamente
27
39
  // valida contra el formato de otro programa.
28
- if (code === 'ENOENT') {
40
+ //
41
+ // Two distinct "not found" shapes to catch now that Windows goes through
42
+ // a shell (see `shell: isWindowsNative()` above): without a shell, a
43
+ // missing binary is a spawn-level ENOENT; through cmd.exe, the shell
44
+ // itself spawns fine and the missing command instead surfaces as a
45
+ // non-zero exit with "'codex' is not recognized..." on stderr — no
46
+ // ENOENT anywhere. Missing this second shape was a real regression:
47
+ // windows-latest CI (no codex installed at all) started reporting
48
+ // "version probe failed" instead of "not installed", because the exit
49
+ // code alone doesn't say WHY the shell failed.
50
+ const shellCommandNotFound = (0, paths_1.isWindowsNative)()
51
+ && /is not recognized as an internal or external command/i.test(String(err.stderr ?? ''));
52
+ if (err.code === 'ENOENT' || shellCommandNotFound) {
29
53
  throw new Error(`${provider.label} is not installed or not available on PATH ` +
30
54
  `(tried \`${provider.versionCommand.command}\`). Install it, then re-run.`);
31
55
  }
package/dist/src/index.js CHANGED
@@ -122,7 +122,7 @@ program.command('add [name]')
122
122
  if (name) {
123
123
  const allBundles = (0, bundles_1.discoverAllBundles)();
124
124
  const prefs = (0, config_1.getPreferences)();
125
- const outcome = (0, add_1.runAddBundleCore)({ name, agent: options.agent, scope: options.scope }, prefs, allBundles);
125
+ const outcome = (0, add_1.runAddBundleCore)({ name, agent: options.agent, scope: options.scope, method: options.method }, prefs, allBundles);
126
126
  if (outcome.code !== 0)
127
127
  process.exit(outcome.code);
128
128
  (0, prompts_1.outro)('Done.');
@@ -534,12 +534,17 @@ program.command('remove [name]')
534
534
  targetAgents = agentChoice;
535
535
  }
536
536
  let scopeVal;
537
- if (options.scope) {
538
- if (options.scope !== 'local' && options.scope !== 'global') {
539
- console.error(picocolors_1.default.red(`Invalid scope "${options.scope}". Use: local or global.`));
537
+ if (options.scope || options.yes) {
538
+ // Same reasoning as the --agent default just above: `--yes` means ZERO
539
+ // prompts. Without the `options.yes` half of this condition, `awm remove
540
+ // <bundle> --yes` still opened the scope picker and hung any
541
+ // non-interactive caller — the flag promised no-interactive and wasn't.
542
+ const resolved = (0, config_1.resolveScopeOption)(options.scope, prefs.defaultScope);
543
+ if (!resolved.ok) {
544
+ console.error(picocolors_1.default.red(resolved.error));
540
545
  process.exit(1);
541
546
  }
542
- scopeVal = options.scope;
547
+ scopeVal = resolved.scope;
543
548
  }
544
549
  else {
545
550
  const scopeChoice = await (0, prompts_1.select)({
@@ -9,6 +9,7 @@ exports.getPreferences = getPreferences;
9
9
  exports.loadPreferences = loadPreferences;
10
10
  exports.savePreferences = savePreferences;
11
11
  exports.enableAgent = enableAgent;
12
+ exports.resolveScopeOption = resolveScopeOption;
12
13
  // src/utils/config.ts
13
14
  const fs_1 = __importDefault(require("fs"));
14
15
  const path_1 = __importDefault(require("path"));
@@ -158,3 +159,19 @@ function enableAgent(prefs, agent) {
158
159
  ? prefs
159
160
  : { ...prefs, enabledAgents: [...prefs.enabledAgents, agent] };
160
161
  }
162
+ /**
163
+ * Resolve `--scope` the same way across every non-interactive command path: an
164
+ * explicit value wins (validated), otherwise fall back to the caller's default
165
+ * instead of prompting. Pulled out of `remove`'s Commander closure (D-006) after
166
+ * `--yes` was found to still open the scope picker with no `--scope` given —
167
+ * `--yes` means zero prompts, and this is the one call site that had drifted
168
+ * from that rule (the sibling --agent default sits right next to it in index.ts).
169
+ */
170
+ function resolveScopeOption(explicit, fallback) {
171
+ if (explicit === undefined)
172
+ return { ok: true, scope: fallback };
173
+ if (explicit !== 'local' && explicit !== 'global') {
174
+ return { ok: false, error: `Invalid scope "${explicit}". Use: local or global.` };
175
+ }
176
+ return { ok: true, scope: explicit };
177
+ }
@@ -94,3 +94,45 @@ it('awm add demo materializes a real Copilot .instructions.md file end-to-end',
94
94
  fs_1.default.rmSync(content, { recursive: true, force: true });
95
95
  fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
96
96
  });
97
+ // Regression for WIN-02 (issue #55 Windows playbook): `--method copy` was
98
+ // silently discarded by this exact function — `runAddBundleCore` hardcoded
99
+ // 'symlink' regardless of what the CLI parsed, so a real Windows machine got
100
+ // a Junction back no matter what `--method` asked for.
101
+ describe('runAddBundleCore --method', () => {
102
+ const claudeCodePrefs = {
103
+ defaultAgent: 'claude-code', enabledAgents: ['claude-code'], installMethod: 'symlink', defaultScope: 'local',
104
+ };
105
+ it('honours an explicit --method copy: a real directory, not a symlink', () => {
106
+ const content = makeContentFixture();
107
+ const projectRoot = makeProjectRoot();
108
+ const bundles = (0, bundles_1.discoverBundles)(content);
109
+ const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'claude-code', method: 'copy', cwd: projectRoot }, claudeCodePrefs, bundles);
110
+ expect(outcome.code).toBe(0);
111
+ const skillPath = path_1.default.join(projectRoot, '.claude/skills/demo-skill');
112
+ expect(fs_1.default.lstatSync(skillPath).isSymbolicLink()).toBe(false);
113
+ expect(fs_1.default.readFileSync(path_1.default.join(skillPath, 'SKILL.md'), 'utf8')).toContain('Follow the demo skill body.');
114
+ fs_1.default.rmSync(content, { recursive: true, force: true });
115
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
116
+ });
117
+ it('still defaults to symlink when --method is omitted (unchanged behavior)', () => {
118
+ const content = makeContentFixture();
119
+ const projectRoot = makeProjectRoot();
120
+ const bundles = (0, bundles_1.discoverBundles)(content);
121
+ const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'claude-code', cwd: projectRoot }, claudeCodePrefs, bundles);
122
+ expect(outcome.code).toBe(0);
123
+ const skillPath = path_1.default.join(projectRoot, '.claude/skills/demo-skill');
124
+ expect(fs_1.default.lstatSync(skillPath).isSymbolicLink()).toBe(true);
125
+ fs_1.default.rmSync(content, { recursive: true, force: true });
126
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
127
+ });
128
+ it('rejects an invalid --method value without touching the filesystem', () => {
129
+ const content = makeContentFixture();
130
+ const projectRoot = makeProjectRoot();
131
+ const bundles = (0, bundles_1.discoverBundles)(content);
132
+ const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'claude-code', method: 'bogus', cwd: projectRoot }, claudeCodePrefs, bundles);
133
+ expect(outcome.code).toBe(1);
134
+ expect(fs_1.default.existsSync(path_1.default.join(projectRoot, '.claude/skills/demo-skill'))).toBe(false);
135
+ fs_1.default.rmSync(content, { recursive: true, force: true });
136
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
137
+ });
138
+ });
@@ -108,6 +108,29 @@ describe('runInit', () => {
108
108
  const caveatCalls = logSpy.mock.calls.filter((c) => /awm watch/i.test(String(c[0])));
109
109
  expect(caveatCalls).toHaveLength(0);
110
110
  });
111
+ // Regression: `awm init --yes --json > init.json` on native Windows used to
112
+ // write the caveat to stdout via console.log AHEAD of the JSON payload,
113
+ // so the documented core-acceptance.md CORE-03 flow produced a file that
114
+ // wasn't valid JSON (banner text, then `{...}`). The caveat must still
115
+ // reach the operator — it now goes to stderr instead of vanishing.
116
+ it('does not pollute --json stdout with the caveat on native Windows', async () => {
117
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
118
+ const errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
119
+ const { runInit } = require('../../src/commands/init');
120
+ await runInit({
121
+ cwd: tmpHome,
122
+ yes: true,
123
+ json: true,
124
+ actions: { syncCache: async () => { }, installHook: () => ({ status: 'installed' }) },
125
+ });
126
+ const written = writeSpy.mock.calls.map((c) => c[0]).join('');
127
+ expect(() => JSON.parse(written)).not.toThrow();
128
+ const caveatOnStdout = logSpy.mock.calls.filter((c) => /awm watch/i.test(String(c[0])));
129
+ expect(caveatOnStdout).toHaveLength(0);
130
+ const caveatOnStderr = errSpy.mock.calls.filter((c) => /awm watch/i.test(String(c[0])));
131
+ expect(caveatOnStderr).toHaveLength(1);
132
+ errSpy.mockRestore();
133
+ });
111
134
  });
112
135
  it('returns exit 0 on a bare HOME and never prompts with --yes (cache stubbed)', async () => {
113
136
  const { runInit } = require('../../src/commands/init');
@@ -10,6 +10,47 @@ describe('assertProviderSupported', () => {
10
10
  });
11
11
  expect(exec).toHaveBeenCalledWith('codex', ['--version'], expect.any(Object));
12
12
  });
13
+ // Regression: npm installs `codex` as `codex.cmd` on Windows, and
14
+ // execFileSync can't CreateProcess a `.cmd` shim without a shell — it threw
15
+ // ENOENT here even on a machine where `codex --version` worked fine typed
16
+ // directly. `provider.versionCommand` is hardcoded first-party config
17
+ // (providers/index.ts), never attacker-controlled, so `shell: true` here
18
+ // carries none of the injection risk core/paths.ts's resolveOnPath was
19
+ // built to avoid for sensors.json's user/registry-supplied `cmd`.
20
+ describe('on native Windows', () => {
21
+ const realPlatform = process.platform;
22
+ afterEach(() => {
23
+ Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true });
24
+ });
25
+ it('runs the version probe through a shell so the .cmd shim resolves', () => {
26
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
27
+ const exec = jest.fn(() => Buffer.from('codex-cli 0.145.0\n'));
28
+ (0, provider_version_1.assertProviderSupported)('codex', exec);
29
+ expect(exec).toHaveBeenCalledWith('codex', ['--version'], expect.objectContaining({ shell: true }));
30
+ });
31
+ it('does not use a shell on non-Windows platforms', () => {
32
+ Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
33
+ const exec = jest.fn(() => Buffer.from('codex-cli 0.145.0\n'));
34
+ (0, provider_version_1.assertProviderSupported)('codex', exec);
35
+ expect(exec).toHaveBeenCalledWith('codex', ['--version'], expect.objectContaining({ shell: false }));
36
+ });
37
+ // Regression from the fix above: shipping shell:true changed how a
38
+ // GENUINELY missing binary fails. Without a shell it's a spawn-level
39
+ // ENOENT; through cmd.exe the shell itself starts fine and the missing
40
+ // command surfaces as a non-zero exit with this exact stderr text — no
41
+ // ENOENT anywhere. windows-latest CI (no codex installed) caught this:
42
+ // it started reporting "version probe failed" instead of "not
43
+ // installed" the first time shell:true shipped without this branch.
44
+ it('still reports "not installed" when the shell itself says the command is unknown', () => {
45
+ Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
46
+ const shellNotFound = Object.assign(new Error('Command failed: codex --version'), {
47
+ status: 1,
48
+ stderr: Buffer.from("'codex' is not recognized as an internal or external command,\r\noperable program or batch file.\r\n"),
49
+ });
50
+ expect(() => (0, provider_version_1.assertProviderSupported)('codex', () => { throw shellNotFound; }))
51
+ .toThrow('Codex is not installed or not available on PATH');
52
+ });
53
+ });
13
54
  it.each(['0.145.1', '0.146.0', '1.0.0'])('accepts stable Codex version %s above the minimum', (version) => {
14
55
  expect((0, provider_version_1.assertProviderSupported)('codex', () => Buffer.from(`codex-cli ${version}\n`))).toEqual({ provider: 'codex', version });
15
56
  });
@@ -6,6 +6,27 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const fs_1 = __importDefault(require("fs"));
7
7
  const os_1 = __importDefault(require("os"));
8
8
  const path_1 = __importDefault(require("path"));
9
+ const config_1 = require("../../src/utils/config");
10
+ // Regression for the `awm remove <bundle> --yes` gap found running the issue #55
11
+ // Windows playbook (CORE-17): `--yes` skipped the agent prompt but not the scope
12
+ // one, so a supposedly non-interactive removal still hung waiting on a picker.
13
+ // D-006's own stated rule is "`--yes` implica cero prompts" — this closes the one
14
+ // call site that had drifted from it.
15
+ describe('resolveScopeOption', () => {
16
+ it('falls back to the default when nothing explicit was passed (the --yes path)', () => {
17
+ expect((0, config_1.resolveScopeOption)(undefined, 'local')).toEqual({ ok: true, scope: 'local' });
18
+ expect((0, config_1.resolveScopeOption)(undefined, 'global')).toEqual({ ok: true, scope: 'global' });
19
+ });
20
+ it('an explicit valid value wins over the default', () => {
21
+ expect((0, config_1.resolveScopeOption)('global', 'local')).toEqual({ ok: true, scope: 'global' });
22
+ });
23
+ it('rejects a value that is neither local nor global', () => {
24
+ expect((0, config_1.resolveScopeOption)('bogus', 'local')).toEqual({
25
+ ok: false,
26
+ error: 'Invalid scope "bogus". Use: local or global.',
27
+ });
28
+ });
29
+ });
9
30
  describe('Preferences Manager', () => {
10
31
  let tmpHome;
11
32
  let tmpWork;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "6.4.1",
3
+ "version": "6.4.2",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"