agentic-workflow-manager 3.11.0 → 3.12.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 (39) hide show
  1. package/dist/src/commands/doctor.js +1 -1
  2. package/dist/src/core/context/materializer.js +7 -0
  3. package/dist/src/core/context/orchestrator.js +26 -6
  4. package/dist/src/core/context/strategies/codex-agents.js +69 -15
  5. package/dist/src/core/diagnostics/context.js +11 -6
  6. package/dist/src/core/diagnostics/provider-checks.js +92 -11
  7. package/dist/src/core/init/mutation-targets.js +18 -2
  8. package/dist/src/core/init/provider-facts.js +5 -4
  9. package/dist/src/core/init/steps.js +16 -2
  10. package/dist/src/core/install-planner.js +56 -6
  11. package/dist/src/core/install-transaction.js +55 -6
  12. package/dist/src/core/provider-artifacts.js +1 -1
  13. package/dist/src/core/renderers/copilot-instructions.js +28 -0
  14. package/dist/src/core/renderers/cursor-mdc.js +49 -0
  15. package/dist/src/core/renderers/skill-source.js +50 -0
  16. package/dist/src/core/skill-integrity.js +1 -1
  17. package/dist/src/index.js +8 -0
  18. package/dist/src/providers/index.js +69 -2
  19. package/dist/tests/commands/add.test.js +96 -0
  20. package/dist/tests/commands/doctor.test.js +25 -0
  21. package/dist/tests/commands/init.test.js +56 -0
  22. package/dist/tests/core/bundle-install.test.js +63 -0
  23. package/dist/tests/core/context/materializer.test.js +8 -0
  24. package/dist/tests/core/context/orchestrator.test.js +51 -0
  25. package/dist/tests/core/context/strategies/codex-agents.test.js +157 -20
  26. package/dist/tests/core/diagnostics/checks.test.js +1 -0
  27. package/dist/tests/core/diagnostics/provider-tier.test.js +292 -0
  28. package/dist/tests/core/init/mutation-targets.test.js +63 -0
  29. package/dist/tests/core/init/provider-facts.test.js +16 -0
  30. package/dist/tests/core/init/steps.test.js +37 -0
  31. package/dist/tests/core/install-planner.test.js +118 -0
  32. package/dist/tests/core/install-transaction.test.js +109 -0
  33. package/dist/tests/core/provider-artifacts.test.js +11 -0
  34. package/dist/tests/core/renderers/copilot-instructions.test.js +47 -0
  35. package/dist/tests/core/renderers/cursor-mdc.test.js +137 -0
  36. package/dist/tests/core/skill-integrity.test.js +18 -0
  37. package/dist/tests/providers/index.test.js +45 -1
  38. package/dist/tests/providers/injection-config.test.js +16 -0
  39. package/package.json +1 -1
@@ -26,7 +26,8 @@ const providers_1 = require("../providers");
26
26
  /** Resolves the single physical filesystem location an intent renders to for one agent. */
27
27
  /**
28
28
  * Resolves the physical target path + renderer for one artifact intent on one
29
- * agent (dir + filename, applying the `.toml` rename for `codex-agent-toml`).
29
+ * agent (dir + filename, applying the renderer-specific extension rename for
30
+ * `codex-agent-toml`/`cursor-mdc`/`copilot-instructions`).
30
31
  * Shared with `core/init/mutation-targets.ts`, which needs the exact same
31
32
  * dir/filename computation to enumerate paths before a real `awm init` run —
32
33
  * duplicating this logic there would let the two silently diverge.
@@ -36,9 +37,27 @@ function physicalTarget(intent, agent, scope, projectRoot) {
36
37
  if (!config)
37
38
  throw new Error(`${intent.type}s are not supported by ${(0, providers_1.providerFor)(agent).label}`);
38
39
  const dir = scope === 'local' ? path_1.default.join(projectRoot, config.local) : config.global;
39
- const filename = config.renderer === 'codex-agent-toml'
40
- ? `${path_1.default.parse(intent.installName).name}.toml`
40
+ if (dir === null) {
41
+ throw (0, providers_1.unsupportedScopeError)(intent.type, scope, (0, providers_1.providerFor)(agent).label, config.globalUnsupportedReason);
42
+ }
43
+ // Rendered targets get a provider-specific extension in place of a
44
+ // trailing `.md` (if any) on intent.installName, so e.g. `using-awm`
45
+ // (skills carry no extension) or `using-awm.md` both become
46
+ // `using-awm.instructions.md`, never `using-awm.md.instructions.md`.
47
+ // Deliberately NOT path.parse(...).name: it strips everything after the
48
+ // LAST dot, not just a real trailing extension — a skill literally named
49
+ // `v1.2-migration` would silently truncate to `v1.mdc`, dropping
50
+ // `2-migration` and risking a collision with any other skill named `v1`.
51
+ // `.md` is the only extension a skill's installName is ever expected to
52
+ // carry (skills are markdown files), so stripping that literal suffix is
53
+ // both sufficient and precise.
54
+ const baseName = intent.installName.endsWith('.md')
55
+ ? intent.installName.slice(0, -'.md'.length)
41
56
  : intent.installName;
57
+ const filename = config.renderer === 'codex-agent-toml' ? `${baseName}.toml`
58
+ : config.renderer === 'cursor-mdc' ? `${baseName}.mdc`
59
+ : config.renderer === 'copilot-instructions' ? `${baseName}.instructions.md`
60
+ : intent.installName;
42
61
  return { targetPath: path_1.default.join(dir, filename), renderer: config.renderer };
43
62
  }
44
63
  /**
@@ -53,7 +72,12 @@ function physicalTarget(intent, agent, scope, projectRoot) {
53
72
  */
54
73
  function skillTargetDir(agent, scope, projectRoot) {
55
74
  const config = (0, providers_1.providerFor)(agent).skill;
56
- return scope === 'local' ? path_1.default.join(projectRoot, config.local) : config.global;
75
+ if (scope === 'local')
76
+ return path_1.default.join(projectRoot, config.local);
77
+ if (config.global === null) {
78
+ throw (0, providers_1.unsupportedScopeError)('skill', scope, (0, providers_1.providerFor)(agent).label, config.globalUnsupportedReason);
79
+ }
80
+ return config.global;
57
81
  }
58
82
  /**
59
83
  * Of `candidates`, the ones that share `agent`'s skill physical target at
@@ -69,7 +93,20 @@ function skillTargetDir(agent, scope, projectRoot) {
69
93
  */
70
94
  function agentsSharingSkillTarget(agent, candidates, scope, projectRoot) {
71
95
  const target = skillTargetDir(agent, scope, projectRoot);
72
- return candidates.filter((candidate) => skillTargetDir(candidate, scope, projectRoot) === target);
96
+ return candidates.filter((candidate) => {
97
+ // A candidate that doesn't support this scope at all (e.g. Copilot at
98
+ // `global` — skillTargetDir throws) trivially can't share `agent`'s
99
+ // target; it just isn't part of the group. Without this guard, a
100
+ // Copilot in `candidates` (enabled for some OTHER, valid install) would
101
+ // crash this whole computation for every unrelated agent, since the
102
+ // exception surfaces from inside `.filter()`'s callback uncaught.
103
+ try {
104
+ return skillTargetDir(candidate, scope, projectRoot) === target;
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ });
73
110
  }
74
111
  /**
75
112
  * Skills are, today, the only artifact type where two agents' provider
@@ -85,7 +122,20 @@ function assertCompleteSharedGroup(intent, selected, enabled, scope, projectRoot
85
122
  return;
86
123
  for (const agent of selected) {
87
124
  const target = physicalTarget(intent, agent, scope, projectRoot).targetPath;
88
- const group = enabled.filter((candidate) => physicalTarget(intent, candidate, scope, projectRoot).targetPath === target);
125
+ // Same reasoning as agentsSharingSkillTarget above: a candidate in
126
+ // `enabled` that doesn't support this scope (e.g. Copilot at `global`)
127
+ // can't be part of the shared-target group — it just isn't a
128
+ // candidate, not a hard failure of this assertion. Without this
129
+ // guard, having Copilot enabled at all would crash every OTHER
130
+ // agent's shared-group check.
131
+ const group = enabled.filter((candidate) => {
132
+ try {
133
+ return physicalTarget(intent, candidate, scope, projectRoot).targetPath === target;
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ });
89
139
  if (group.some((candidate) => !selected.includes(candidate))) {
90
140
  throw new Error(`Shared skill target cannot diverge; select the complete shared target group: ${group.join(',')}`);
91
141
  }
@@ -31,6 +31,8 @@ const artifact_state_1 = require("./artifact-state");
31
31
  const paths_1 = require("./paths");
32
32
  const atomic_file_1 = require("./atomic-file");
33
33
  const codex_agent_1 = require("./renderers/codex-agent");
34
+ const cursor_mdc_1 = require("./renderers/cursor-mdc");
35
+ const copilot_instructions_1 = require("./renderers/copilot-instructions");
34
36
  const executor_1 = require("./executor");
35
37
  /**
36
38
  * The single timestamp-sanitization rule for transaction IDs, shared by
@@ -225,12 +227,25 @@ function stageRenderedFile(content, targetPath) {
225
227
  fs_1.default.writeFileSync(staged, content, 'utf8');
226
228
  return staged;
227
229
  }
230
+ /**
231
+ * `cursor-mdc`/`copilot-instructions` targets are always 'skill'-type
232
+ * operations (providers/index.ts only assigns these renderers to a
233
+ * provider's `skill` ArtifactConfig), whose `sourcePath` is the skill's
234
+ * DIRECTORY (discovery.ts's `discoverSkills`/bundle-install.ts's
235
+ * `expandBundleArtifacts` both set it that way — the whole directory is what
236
+ * a `link` renderer symlinks), not the SKILL.md file itself. Both renderers
237
+ * are sourced from that directory's SKILL.md, so every call site needs this
238
+ * same one-line join instead of reading `op.sourcePath` directly.
239
+ */
240
+ function readSkillMdSource(op) {
241
+ return fs_1.default.readFileSync(path_1.default.join(op.sourcePath, 'SKILL.md'), 'utf8');
242
+ }
228
243
  /**
229
244
  * The real, filesystem-touching TransactionDeps used by applyInstallPlan by
230
- * default. Renders `codex-agent-toml` targets from the canonical agent
231
- * Markdown source at stage time; every other renderer uses the plain
232
- * symlink/copy staging from executor.ts. Never logs target contents or
233
- * environment variables.
245
+ * default. Renders `codex-agent-toml`/`cursor-mdc`/`copilot-instructions`
246
+ * targets from their respective canonical sources at stage time; every other
247
+ * renderer ('link') uses the plain symlink/copy staging from executor.ts.
248
+ * Never logs target contents or environment variables.
234
249
  */
235
250
  function defaultTransactionDeps() {
236
251
  let index = 0;
@@ -241,11 +256,17 @@ function defaultTransactionDeps() {
241
256
  if (!fs_1.default.existsSync(op.sourcePath)) {
242
257
  throw new Error(`Source path does not exist: ${op.sourcePath}`);
243
258
  }
259
+ // Renders without writing anything, purely to surface parse errors
260
+ // before any backup/replace happens.
244
261
  if (op.renderer === 'codex-agent-toml') {
245
- // Renders without writing anything, purely to surface parse errors
246
- // before any backup/replace happens.
247
262
  (0, codex_agent_1.renderCodexAgent)(fs_1.default.readFileSync(op.sourcePath, 'utf8'));
248
263
  }
264
+ else if (op.renderer === 'cursor-mdc') {
265
+ (0, cursor_mdc_1.renderCursorMdc)(readSkillMdSource(op));
266
+ }
267
+ else if (op.renderer === 'copilot-instructions') {
268
+ (0, copilot_instructions_1.renderCopilotInstructions)(readSkillMdSource(op));
269
+ }
249
270
  },
250
271
  backup(op, backupDir) {
251
272
  if (createdAt === null)
@@ -266,6 +287,14 @@ function defaultTransactionDeps() {
266
287
  const rendered = (0, codex_agent_1.renderCodexAgent)(fs_1.default.readFileSync(op.sourcePath, 'utf8'));
267
288
  return stageRenderedFile(rendered, op.targetPath);
268
289
  }
290
+ if (op.renderer === 'cursor-mdc') {
291
+ const rendered = (0, cursor_mdc_1.renderCursorMdc)(readSkillMdSource(op));
292
+ return stageRenderedFile(rendered, op.targetPath);
293
+ }
294
+ if (op.renderer === 'copilot-instructions') {
295
+ const rendered = (0, copilot_instructions_1.renderCopilotInstructions)(readSkillMdSource(op));
296
+ return stageRenderedFile(rendered, op.targetPath);
297
+ }
269
298
  return (0, executor_1.stageArtifact)(op.sourcePath, op.targetPath, op.method);
270
299
  },
271
300
  replace(op, staged) {
@@ -289,6 +318,26 @@ function defaultTransactionDeps() {
289
318
  }
290
319
  return;
291
320
  }
321
+ if (op.renderer === 'cursor-mdc') {
322
+ if (!stat.isFile()) {
323
+ throw new Error(`verification failed: ${op.targetPath} is not a regular file`);
324
+ }
325
+ const content = fs_1.default.readFileSync(op.targetPath, 'utf8');
326
+ if (!content.startsWith('---\n') || !content.includes('alwaysApply:')) {
327
+ throw new Error(`verification failed: ${op.targetPath} does not look like rendered Cursor .mdc`);
328
+ }
329
+ return;
330
+ }
331
+ if (op.renderer === 'copilot-instructions') {
332
+ if (!stat.isFile()) {
333
+ throw new Error(`verification failed: ${op.targetPath} is not a regular file`);
334
+ }
335
+ const content = fs_1.default.readFileSync(op.targetPath, 'utf8');
336
+ if (!content.startsWith('---\n') || !content.includes('applyTo:')) {
337
+ throw new Error(`verification failed: ${op.targetPath} does not look like rendered Copilot instructions`);
338
+ }
339
+ return;
340
+ }
292
341
  if (op.method === 'symlink' && !stat.isSymbolicLink()) {
293
342
  throw new Error(`verification failed: ${op.targetPath} is not a symlink`);
294
343
  }
@@ -30,7 +30,7 @@ function scanLegacyArtifacts(agents, scope) {
30
30
  if (!config)
31
31
  continue;
32
32
  const dir = config[scope];
33
- if (!fs_1.default.existsSync(dir))
33
+ if (dir === null || !fs_1.default.existsSync(dir))
34
34
  continue;
35
35
  for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
36
36
  const fullPath = path_1.default.join(dir, entry.name);
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderCopilotInstructions = renderCopilotInstructions;
4
+ // src/core/renderers/copilot-instructions.ts
5
+ //
6
+ // Renders a SKILL.md source into GitHub Copilot's `.instructions.md` format.
7
+ // Copilot's instructions format is fundamentally file-glob-triggered
8
+ // (`applyTo` matches file paths against the current edit), which doesn't map
9
+ // cleanly onto AWM's trigger-phrase-based skill activation — a real format
10
+ // mismatch this task cannot fully resolve (D4: this whole tier is "context
11
+ // read, not enforced", not runtime-gated the way Claude Code's own skill
12
+ // invocation is). `applyTo: "**"` (match every file) is the practical
13
+ // default: it keeps the skill's guidance always present in Copilot's context
14
+ // rather than guessing a file-type restriction that doesn't correspond to
15
+ // anything in the skill's actual metadata. A future task revisiting this
16
+ // tier should start from this note, not rediscover the mismatch.
17
+ const skill_source_1 = require("./skill-source");
18
+ function renderCopilotInstructions(source) {
19
+ const { body } = (0, skill_source_1.parseSkillSource)(source);
20
+ return [
21
+ '---',
22
+ 'applyTo: "**"',
23
+ '---',
24
+ '',
25
+ body,
26
+ '',
27
+ ].join('\n');
28
+ }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderCursorMdc = renderCursorMdc;
4
+ // src/core/renderers/cursor-mdc.ts
5
+ //
6
+ // Renders a SKILL.md source into Cursor's `.mdc` rule format. Per this
7
+ // session's D4 correction note (docs/plans/2026-08-07-team-rollout-hardening-design.md),
8
+ // Cursor's current rule frontmatter has three keys: `description`, `globs`,
9
+ // `alwaysApply`. For an individual skill rule (not the Task 4.2 always-on
10
+ // `awm.mdc` context carrier), the correct activation mode is "Agent
11
+ // Requested": `description` set to the skill's own description (so Cursor
12
+ // can decide relevance), `globs` left blank, `alwaysApply: false` — letting
13
+ // Cursor pull the skill in contextually instead of force-loading every
14
+ // installed skill's full content into every request.
15
+ const skill_source_1 = require("./skill-source");
16
+ // YAML plain scalars break on a bare colon-followed-by-space (parsed as a
17
+ // mapping), a `#` preceded by whitespace ANYWHERE in the string — not just
18
+ // at the start — (starts a comment, silently truncating everything after
19
+ // it), a leading YAML-special indicator character, an embedded double
20
+ // quote, or an embedded control/null/DEL byte (invalid in a YAML plain
21
+ // scalar regardless of position, and would otherwise be emitted unquoted
22
+ // straight into the frontmatter) — the same class of problem tomlString/
23
+ // escapeControlChars (codex-agent.ts) guard against for TOML, adapted to
24
+ // YAML's own rules. JSON.stringify produces a YAML-1.1/1.2-compatible
25
+ // double-quoted scalar (YAML's double-quoted flow scalar is a superset of
26
+ // JSON string syntax), so it doubles as the escaping/quoting mechanism once
27
+ // quoting is needed — it \u-escapes \x00-\x1F, but NOT \x7F (DEL is not in
28
+ // JSON's own list of characters requiring escape), so yamlString below
29
+ // escapes that one byte itself after JSON.stringify runs.
30
+ const YAML_UNSAFE = /:(\s|$)|(?:^|\s)#|^[\s\-?:,[\]{}#&*!|>'"%@`]|"|[\x00-\x1f\x7f]/;
31
+ function yamlString(value) {
32
+ if (value !== value.trim() || value === '' || YAML_UNSAFE.test(value)) {
33
+ return JSON.stringify(value).replace(/\x7f/g, '\\u007f');
34
+ }
35
+ return value;
36
+ }
37
+ function renderCursorMdc(source) {
38
+ const { description, body } = (0, skill_source_1.parseSkillSource)(source);
39
+ return [
40
+ '---',
41
+ `description: ${yamlString(description)}`,
42
+ 'globs:',
43
+ 'alwaysApply: false',
44
+ '---',
45
+ '',
46
+ body,
47
+ '',
48
+ ].join('\n');
49
+ }
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseSkillSource = parseSkillSource;
4
+ // src/core/renderers/skill-source.ts
5
+ //
6
+ // Shared parsing for the two provider-specific skill renderers
7
+ // (cursor-mdc.ts, copilot-instructions.ts): both are sourced from a
8
+ // SKILL.md's frontmatter + body, the same relationship renderCodexAgent
9
+ // (codex-agent.ts) has with its canonical agent source — transform, not
10
+ // link. Reuses discovery.ts's `matchFrontmatterBlock` (already the single
11
+ // source of truth for locating the frontmatter block elsewhere in this
12
+ // codebase) rather than writing a second frontmatter parser.
13
+ const discovery_1 = require("../discovery");
14
+ /**
15
+ * Parses a raw SKILL.md source into its `description` frontmatter field and
16
+ * body content. Mirrors discovery.ts's `readArtifactDescription` for the
17
+ * quote-stripping/block-scalar handling of the `description` line, and
18
+ * canonical-agent.ts's `parseCanonicalAgent` for the "throw on missing
19
+ * required piece" discipline — a renderer should never silently embed an
20
+ * empty description or body.
21
+ */
22
+ function parseSkillSource(source) {
23
+ const frontmatter = (0, discovery_1.matchFrontmatterBlock)(source);
24
+ if (frontmatter === null)
25
+ throw new Error('skill source requires YAML frontmatter');
26
+ const line = frontmatter.split(/\r?\n/).find((l) => /^description\s*:/.test(l));
27
+ if (!line)
28
+ throw new Error('skill source requires a non-empty description');
29
+ let description = line.replace(/^description\s*:/, '').trim();
30
+ if ((description.startsWith('"') && description.endsWith('"')) ||
31
+ (description.startsWith("'") && description.endsWith("'"))) {
32
+ description = description.slice(1, -1);
33
+ }
34
+ // A YAML block scalar indicator (`>-`, `|-`, `>`, `|`, `>+`, `|+`) means the
35
+ // real description text lives on the FOLLOWING indented lines, not on this
36
+ // line at all — treating the bare indicator as the description would embed
37
+ // literal "|-" into every rendered skill. Mirrors discovery.ts's
38
+ // readArtifactDescription, which detects the same shape and treats it as
39
+ // absent rather than mis-parsing it.
40
+ const BLOCK_INDICATORS = new Set(['>-', '>', '|-', '|', '>+', '|+']);
41
+ if (BLOCK_INDICATORS.has(description))
42
+ description = '';
43
+ if (!description)
44
+ throw new Error('skill source requires a non-empty description');
45
+ const bodyMatch = source.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n([\s\S]*)$/);
46
+ const body = bodyMatch ? bodyMatch[1].trim() : '';
47
+ if (!body)
48
+ throw new Error('skill source requires a non-empty body');
49
+ return { description, body };
50
+ }
@@ -90,7 +90,7 @@ function reconcileAllSkillLinks(registryContentDirs) {
90
90
  const out = [];
91
91
  for (const agent of providers_1.AGENT_TARGETS) {
92
92
  const skillsDir = (0, providers_1.providerFor)(agent).skill.global;
93
- if (!fs_1.default.existsSync(skillsDir))
93
+ if (skillsDir === null || !fs_1.default.existsSync(skillsDir))
94
94
  continue;
95
95
  out.push({ agent, result: repairGlobalSkills(skillsDir, registryContentDirs) });
96
96
  }
package/dist/src/index.js CHANGED
@@ -183,6 +183,10 @@ program.command('add [name]')
183
183
  continue;
184
184
  }
185
185
  const targetDir = config[scopeVal];
186
+ if (targetDir === null) {
187
+ skipped.push(`${artifact.name} (${currentAgent})`);
188
+ continue;
189
+ }
186
190
  const finalDest = path_1.default.join(targetDir, artifact.name);
187
191
  (0, executor_1.installArtifact)(artifact.sourcePath, finalDest, methodVal);
188
192
  installed.push(`${artifact.name} → ${currentAgent} (${scopeVal})`);
@@ -350,6 +354,10 @@ program.command('add [name]')
350
354
  continue;
351
355
  }
352
356
  const targetDir = config[scopeVal];
357
+ if (targetDir === null) {
358
+ skipped.push(`${artifact.name} (${currentAgent})`);
359
+ continue;
360
+ }
353
361
  const finalDest = path_1.default.join(targetDir, artifact.name);
354
362
  (0, executor_1.installArtifact)(artifact.sourcePath, finalDest, methodVal);
355
363
  installed.push(`${artifact.name} → ${currentAgent} (${scopeVal})`);
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.UnsupportedRendererError = exports.AGENT_TARGETS = void 0;
7
7
  exports.isAgentTarget = isAgentTarget;
8
8
  exports.requireAgentTarget = requireAgentTarget;
9
+ exports.unsupportedScopeError = unsupportedScopeError;
9
10
  exports.providers = providers;
10
11
  exports.providerFor = providerFor;
11
12
  exports.getTargetPath = getTargetPath;
@@ -16,7 +17,7 @@ exports.getInjection = getInjection;
16
17
  // src/providers/index.ts
17
18
  const path_1 = __importDefault(require("path"));
18
19
  const paths_1 = require("../core/paths");
19
- exports.AGENT_TARGETS = ['antigravity', 'opencode', 'claude-code', 'codex'];
20
+ exports.AGENT_TARGETS = ['antigravity', 'opencode', 'claude-code', 'codex', 'cursor', 'copilot'];
20
21
  function isAgentTarget(value) {
21
22
  return typeof value === 'string' &&
22
23
  exports.AGENT_TARGETS.includes(value);
@@ -31,6 +32,14 @@ function requireAgentTarget(value) {
31
32
  class UnsupportedRendererError extends Error {
32
33
  }
33
34
  exports.UnsupportedRendererError = UnsupportedRendererError;
35
+ /** Shared message shape for "this scope isn't supported by this provider" —
36
+ * used everywhere a `null` `ArtifactConfig.global` is resolved (this file's
37
+ * `getTargetPath`, and `install-planner.ts`'s `physicalTarget`/`skillTargetDir`,
38
+ * which duplicate the resolution logic for their own return-shape needs). */
39
+ function unsupportedScopeError(artifactType, scope, providerLabel, reason) {
40
+ return new Error(`${artifactType} ${scope} scope is not supported by ${providerLabel}` +
41
+ (reason ? `: ${reason}` : '.'));
42
+ }
34
43
  function providers() {
35
44
  const home = (0, paths_1.homeDir)();
36
45
  const awm = (0, paths_1.awmHome)();
@@ -118,6 +127,44 @@ function providers() {
118
127
  localFile: 'AGENTS.md',
119
128
  },
120
129
  },
130
+ cursor: {
131
+ label: 'Cursor',
132
+ skill: {
133
+ global: path_1.default.join(home, '.cursor/rules'),
134
+ local: '.cursor/rules',
135
+ renderer: 'cursor-mdc',
136
+ },
137
+ workflow: null,
138
+ agent: null,
139
+ injection: {
140
+ type: 'managed-agents-md',
141
+ // Cursor has no confirmed user-level/global AGENTS.md-equivalent file — its
142
+ // "User Rules" live inside Cursor's own app settings, not a plain file on disk
143
+ // (per docs research done for this task, R4 Task 4.1). Until a primary source
144
+ // confirms a real global path, `null` here is the honest answer, not a guess.
145
+ globalPath: null,
146
+ localFile: 'AGENTS.md',
147
+ },
148
+ },
149
+ copilot: {
150
+ label: 'Copilot',
151
+ skill: {
152
+ global: null,
153
+ globalUnsupportedReason: 'GitHub Copilot has no user-level skill discovery mechanism — skills must be installed per-project.',
154
+ local: '.github/instructions',
155
+ renderer: 'copilot-instructions',
156
+ },
157
+ workflow: null,
158
+ agent: null,
159
+ injection: {
160
+ type: 'managed-agents-md',
161
+ // Copilot is inherently repository-scoped — confirmed no ~/.copilot or
162
+ // equivalent user-level AGENTS.md file exists. Task 4.2 owns the actual
163
+ // runtime handling of a null globalPath (project-only injection).
164
+ globalPath: null,
165
+ localFile: 'AGENTS.md',
166
+ },
167
+ },
121
168
  };
122
169
  }
123
170
  function providerFor(agent) {
@@ -137,7 +184,11 @@ function getTargetPath(type, agent, scope) {
137
184
  const config = provider[type];
138
185
  if (!config)
139
186
  throw new Error(`${type}s are not supported by ${provider.label}.`);
140
- return config[scope];
187
+ const targetPath = scope === 'global' ? config.global : config.local;
188
+ if (targetPath === null) {
189
+ throw unsupportedScopeError(type, scope, provider.label, config.globalUnsupportedReason);
190
+ }
191
+ return targetPath;
141
192
  }
142
193
  function getHookConfig(agent) {
143
194
  return providerFor(agent).hooks;
@@ -152,6 +203,22 @@ function getSettingsMergeHookConfig(agent) {
152
203
  }
153
204
  return config;
154
205
  }
206
+ /**
207
+ * Guards callers that only know how to stage a target via a plain
208
+ * symlink/copy (`core/executor.ts`'s stageArtifact) — today, that means
209
+ * `core/provider-artifacts.ts`'s legacy single-artifact scan/preflight and
210
+ * `src/index.ts`'s legacy interactive `awm add` flow, neither of which goes
211
+ * through install-planner.ts/install-transaction.ts's render-at-stage-time
212
+ * pipeline. Throws for ANY non-'link' renderer, including cursor-mdc/
213
+ * copilot-instructions (Task 4.3): a raw, unrendered copy of a SKILL.md into
214
+ * `.cursor/rules/` or `.github/instructions/` is not a degraded-but-usable
215
+ * install the way it might first appear — it lacks the frontmatter
216
+ * (`alwaysApply`/`applyTo`) and filename extension (`.mdc`/`.instructions.md`)
217
+ * both providers require to even recognize the file, so it would silently
218
+ * install something neither Cursor nor Copilot ever reads. These two
219
+ * renderers are only reachable through commands/add.ts's proper pipeline,
220
+ * which never calls this function.
221
+ */
155
222
  function assertLinkRenderer(type, agent) {
156
223
  if (!['skill', 'workflow', 'agent'].includes(type)) {
157
224
  throw new Error(`Unknown artifact type: ${String(type)}`);
@@ -0,0 +1,96 @@
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/add.test.ts
7
+ //
8
+ // Real, unstubbed `awm add <bundle>` end-to-end for the Task 4.3 renderers:
9
+ // runAddBundleCore → (real) addBundle → installBundle → planInstall
10
+ // (install-planner.ts) → applyInstallPlan (install-transaction.ts) →
11
+ // renderCursorMdc/renderCopilotInstructions — proving the full pipeline
12
+ // wires together at the actual CLI command entrypoint, not just at the
13
+ // bundle-install.ts layer (see tests/core/bundle-install.test.ts for the
14
+ // lower-level equivalent). Per CLAUDE.md: isolated HOME/AWM_HOME tmpdirs,
15
+ // never the real ~/.awm.
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const os_1 = __importDefault(require("os"));
18
+ const path_1 = __importDefault(require("path"));
19
+ const add_1 = require("../../src/commands/add");
20
+ const bundles_1 = require("../../src/core/bundles");
21
+ let tmpHome;
22
+ let originalHome;
23
+ let originalAwmHome;
24
+ beforeEach(() => {
25
+ tmpHome = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-add-e2e-home-'));
26
+ originalHome = process.env.HOME;
27
+ originalAwmHome = process.env.AWM_HOME;
28
+ process.env.HOME = tmpHome;
29
+ process.env.AWM_HOME = path_1.default.join(tmpHome, '.awm');
30
+ });
31
+ afterEach(() => {
32
+ fs_1.default.rmSync(tmpHome, { recursive: true, force: true });
33
+ if (originalHome === undefined)
34
+ delete process.env.HOME;
35
+ else
36
+ process.env.HOME = originalHome;
37
+ if (originalAwmHome === undefined)
38
+ delete process.env.AWM_HOME;
39
+ else
40
+ process.env.AWM_HOME = originalAwmHome;
41
+ });
42
+ /** A single-bundle content registry: one skill with real description+body. */
43
+ function makeContentFixture() {
44
+ const tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-add-e2e-registry-'));
45
+ fs_1.default.mkdirSync(path_1.default.join(tmp, 'bundles', 'demo'), { recursive: true });
46
+ fs_1.default.mkdirSync(path_1.default.join(tmp, 'skills', 'demo-skill'), { recursive: true });
47
+ fs_1.default.writeFileSync(path_1.default.join(tmp, 'skills', 'demo-skill', 'SKILL.md'), '---\nname: demo-skill\ndescription: A demo skill for add.test.ts e2e\n---\n\nFollow the demo skill body.\n');
48
+ fs_1.default.writeFileSync(path_1.default.join(tmp, 'catalog.json'), JSON.stringify({
49
+ version: 1,
50
+ bundles: [{ name: 'demo', source: './bundles/demo', version: '1.0.0', scope: 'project' }],
51
+ }));
52
+ fs_1.default.writeFileSync(path_1.default.join(tmp, 'bundles', 'demo', 'bundle.json'), JSON.stringify({
53
+ name: 'demo', version: '1.0.0', description: 'Demo', scope: 'project', dependsOn: [],
54
+ skills: ['demo-skill'], workflows: [], agents: [],
55
+ }));
56
+ return tmp;
57
+ }
58
+ function prefsFor(agent) {
59
+ return { defaultAgent: agent, enabledAgents: [agent], installMethod: 'symlink', defaultScope: 'local' };
60
+ }
61
+ /** findProjectRoot (core/profile.ts) needs a recognizable project marker
62
+ * (.git/, package.json, or .awm/profile.json) — a bare tmpdir isn't one. */
63
+ function makeProjectRoot() {
64
+ const projectRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-add-e2e-project-'));
65
+ fs_1.default.writeFileSync(path_1.default.join(projectRoot, 'package.json'), JSON.stringify({ name: 'fixture' }));
66
+ return projectRoot;
67
+ }
68
+ it('awm add demo materializes a real Cursor .mdc file end-to-end', () => {
69
+ const content = makeContentFixture();
70
+ const projectRoot = makeProjectRoot();
71
+ const bundles = (0, bundles_1.discoverBundles)(content);
72
+ const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'cursor', cwd: projectRoot }, prefsFor('cursor'), bundles);
73
+ expect(outcome.code).toBe(0);
74
+ const mdcPath = path_1.default.join(projectRoot, '.cursor/rules/demo-skill.mdc');
75
+ expect(fs_1.default.existsSync(mdcPath)).toBe(true);
76
+ const rendered = fs_1.default.readFileSync(mdcPath, 'utf8');
77
+ expect(rendered).toContain('description: A demo skill for add.test.ts e2e');
78
+ expect(rendered).toContain('alwaysApply: false');
79
+ expect(rendered).toContain('Follow the demo skill body.');
80
+ fs_1.default.rmSync(content, { recursive: true, force: true });
81
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
82
+ });
83
+ it('awm add demo materializes a real Copilot .instructions.md file end-to-end', () => {
84
+ const content = makeContentFixture();
85
+ const projectRoot = makeProjectRoot();
86
+ const bundles = (0, bundles_1.discoverBundles)(content);
87
+ const outcome = (0, add_1.runAddBundleCore)({ name: 'demo', agent: 'copilot', cwd: projectRoot }, prefsFor('copilot'), bundles);
88
+ expect(outcome.code).toBe(0);
89
+ const instructionsPath = path_1.default.join(projectRoot, '.github/instructions/demo-skill.instructions.md');
90
+ expect(fs_1.default.existsSync(instructionsPath)).toBe(true);
91
+ const rendered = fs_1.default.readFileSync(instructionsPath, 'utf8');
92
+ expect(rendered).toContain('applyTo: "**"');
93
+ expect(rendered).toContain('Follow the demo skill body.');
94
+ fs_1.default.rmSync(content, { recursive: true, force: true });
95
+ fs_1.default.rmSync(projectRoot, { recursive: true, force: true });
96
+ });
@@ -58,6 +58,22 @@ describe('renderReport', () => {
58
58
  expect(out).toContain('→ skill: project-constitution');
59
59
  });
60
60
  });
61
+ describe('renderProviderReport — capability tier (Task 4.4)', () => {
62
+ it('shows the tier next to each provider label', () => {
63
+ const report = {
64
+ overall: 'healthy',
65
+ providers: [
66
+ { id: 'claude-code', label: 'Claude Code', tier: 'hooks-native', checks: [] },
67
+ { id: 'cursor', label: 'Cursor', tier: 'agents-md-managed', checks: [] },
68
+ { id: 'antigravity', label: 'Antigravity', tier: 'context-only', checks: [] },
69
+ ],
70
+ };
71
+ const out = (0, doctor_1.renderProviderReport)(report);
72
+ expect(out).toContain('Provider: Claude Code (hooks-native)');
73
+ expect(out).toContain('Provider: Cursor (agents-md-managed)');
74
+ expect(out).toContain('Provider: Antigravity (context-only)');
75
+ });
76
+ });
61
77
  describe('runDoctor', () => {
62
78
  let tmpHome;
63
79
  let tmpWork;
@@ -120,6 +136,7 @@ describe('runDoctor', () => {
120
136
  .toEqual(['claude-code', 'opencode', 'codex']); // verifies R12
121
137
  expect(report.providers.find((provider) => provider.id === 'codex'))
122
138
  .toMatchObject({
139
+ tier: 'hooks-native', // Task 4.4
123
140
  checks: expect.arrayContaining([
124
141
  expect.objectContaining({ id: 'binary.version' }),
125
142
  expect.objectContaining({ id: 'skills.global' }),
@@ -129,6 +146,14 @@ describe('runDoctor', () => {
129
146
  }); // verifies R2, R7, R8, R18
130
147
  expect(code).toBe(1);
131
148
  });
149
+ it('includes tier for every provider in JSON output (Task 4.4)', () => {
150
+ writePrefs(prefsWith(['claude-code', 'opencode', 'codex']));
151
+ const code = (0, doctor_1.runDoctor)({ cwd: tmpWork, json: true });
152
+ const report = JSON.parse(stdout());
153
+ expect(report.providers.map((provider) => provider.tier))
154
+ .toEqual(['hooks-native', 'config-managed', 'hooks-native']);
155
+ expect(code).toBe(1);
156
+ });
132
157
  it('reports a clear, specific error for an invalid --agent value, not a generic "internal error"', () => {
133
158
  const errSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
134
159
  try {