@astryxdesign/cli 0.1.7 → 0.1.8-canary.08a0957

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 (101) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +115 -19
  3. package/docs/cli-integrations.doc.mjs +150 -0
  4. package/docs/getting-started.doc.mjs +9 -9
  5. package/docs/migration.doc.mjs +18 -18
  6. package/docs/principles.doc.dense.mjs +1 -1
  7. package/docs/principles.doc.mjs +6 -6
  8. package/docs/principles.doc.zh.mjs +1 -1
  9. package/docs/styling-libraries.doc.mjs +3 -3
  10. package/docs/styling.doc.mjs +4 -4
  11. package/docs/theme.doc.dense.mjs +2 -2
  12. package/docs/theme.doc.mjs +7 -7
  13. package/docs/theme.doc.zh.mjs +1 -1
  14. package/docs/tokens.doc.mjs +1 -1
  15. package/docs/working-with-ai.doc.mjs +19 -19
  16. package/package.json +11 -10
  17. package/scripts/postinstall.mjs +74 -0
  18. package/src/api/doctor.mjs +3 -3
  19. package/src/codemods/__tests__/registry.test.mjs +1 -0
  20. package/src/codemods/ensure-jscodeshift.mjs +11 -27
  21. package/src/codemods/registry.mjs +1 -0
  22. package/src/codemods/run-codemod.mjs +1 -1
  23. package/src/codemods/runner.mjs +2 -2
  24. package/src/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +42 -7
  25. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-module-specifiers.test.mjs +43 -0
  26. package/src/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +102 -3
  27. package/src/codemods/transforms/v0.1.0/migrate-xds-module-specifiers.mjs +72 -6
  28. package/src/codemods/transforms/v0.1.8/__tests__/rename-avatar-size-scale.test.mjs +161 -0
  29. package/src/codemods/transforms/v0.1.8/index.mjs +19 -0
  30. package/src/codemods/transforms/v0.1.8/rename-avatar-size-scale.mjs +234 -0
  31. package/src/commands/agent-docs.mjs +136 -20
  32. package/src/commands/agent-docs.test.mjs +123 -10
  33. package/src/commands/build-theme.mjs +10 -71
  34. package/src/commands/build.mjs +15 -15
  35. package/src/commands/cli-postinstall.test.mjs +42 -0
  36. package/src/commands/component/index.mjs +4 -4
  37. package/src/commands/discover.mjs +7 -5
  38. package/src/commands/docs.mjs +4 -4
  39. package/src/commands/hook/index.mjs +4 -4
  40. package/src/commands/init.mjs +48 -152
  41. package/src/commands/init.next-steps.test.mjs +1 -1
  42. package/src/commands/interactive-guard.test.mjs +19 -22
  43. package/src/commands/json-contract.test.mjs +2 -2
  44. package/src/commands/layout.mjs +1 -1
  45. package/src/commands/search.mjs +4 -4
  46. package/src/commands/setup-nudge.test.mjs +108 -0
  47. package/src/commands/swizzle.mjs +11 -34
  48. package/src/commands/template.mjs +11 -31
  49. package/src/commands/upgrade.mjs +138 -32
  50. package/src/commands/upgrade.test.mjs +155 -1
  51. package/src/index.mjs +40 -6
  52. package/src/lib/component-format.mjs +2 -1
  53. package/src/lib/term-log.mjs +48 -0
  54. package/src/types/template-api.d.ts +4 -1
  55. package/src/types/upgrade.d.ts +39 -2
  56. package/src/utils/package-manager.mjs +78 -0
  57. package/src/utils/package-manager.test.mjs +108 -1
  58. package/src/utils/path-safety.mjs +0 -18
  59. package/src/utils/update-check.mjs +2 -1
  60. package/templates/blocks/components/Avatar/AvatarFallbackChain.tsx +4 -4
  61. package/templates/blocks/components/Avatar/AvatarGroup.tsx +2 -2
  62. package/templates/blocks/components/Avatar/AvatarInitialsFallback.tsx +1 -1
  63. package/templates/blocks/components/Avatar/AvatarShowcase.tsx +4 -4
  64. package/templates/blocks/components/Avatar/AvatarUserCard.tsx +1 -1
  65. package/templates/blocks/components/Avatar/AvatarWithImage.tsx +4 -4
  66. package/templates/blocks/components/Avatar/AvatarWithStatus.tsx +3 -3
  67. package/templates/blocks/components/AvatarGroup/AvatarGroupShowcase.tsx +2 -2
  68. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowCustomText.tsx +1 -1
  69. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowDefault.tsx +1 -1
  70. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowShowcase.tsx +2 -2
  71. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotShowcase.tsx +3 -3
  72. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +3 -3
  73. package/templates/blocks/components/Carousel/CarouselSnap.tsx +1 -1
  74. package/templates/blocks/components/ChatMessage/ChatMessageAvatarName.tsx +2 -2
  75. package/templates/blocks/components/ChatMessage/ChatMessageMultiBubble.tsx +1 -1
  76. package/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleGrouping.tsx +1 -1
  77. package/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleMetadata.tsx +1 -1
  78. package/templates/blocks/components/ChatMessageList/ChatMessageListDensity.tsx +5 -9
  79. package/templates/blocks/components/ChatMessageList/ChatMessageListFullFeatured.tsx +1 -1
  80. package/templates/blocks/components/CodeBlock/CodeBlockTerminal.tsx +1 -1
  81. package/templates/blocks/components/HoverCard/HoverCardShowcase.tsx +1 -1
  82. package/templates/blocks/components/Item/ItemWithMedia.tsx +2 -2
  83. package/templates/blocks/components/ListItem/ListItemWithMedia.tsx +2 -2
  84. package/templates/blocks/components/Pagination/PaginationDotsCarousel.tsx +2 -6
  85. package/templates/blocks/components/Stack/StackFillItem.tsx +2 -6
  86. package/templates/blocks/components/TabList/TabListTabsWithActions.doc.mjs +1 -1
  87. package/templates/blocks/components/TabList/TabListTabsWithActions.tsx +2 -7
  88. package/templates/blocks/components/Table/TableRowStatusTable.doc.mjs +14 -0
  89. package/templates/blocks/components/Table/TableRowStatusTable.tsx +59 -0
  90. package/templates/blocks/components/TypeaheadItem/TypeaheadItemShowcase.tsx +1 -1
  91. package/templates/pages/ai-chat/page.tsx +4 -4
  92. package/templates/pages/dashboard-portfolio/page.tsx +3 -11
  93. package/templates/pages/detail-page/page.tsx +5 -12
  94. package/templates/pages/documentation-design/page.tsx +1 -1
  95. package/templates/pages/messaging-shell/page.tsx +6 -8
  96. package/templates/pages/table-grouped/page.tsx +9 -14
  97. package/templates/pages/table-page/page.tsx +7 -9
  98. package/templates/pages/table-page-heatmap-status/page.tsx +5 -13
  99. package/docs/integration-authoring.md +0 -105
  100. package/src/utils/interactive.mjs +0 -76
  101. package/src/utils/interactive.test.mjs +0 -70
@@ -0,0 +1,108 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Guardrail tests — the centralized setup check + enforcement layer 3.
5
+ *
6
+ * Unit: isAstryxInitialized() detects the Astryx marker across EVERY agent-doc
7
+ * location (including the previously-missed Hermes files) and legacy XDS blocks.
8
+ *
9
+ * Integration: the CLI nudges (stderr) before a command when the project hasn't
10
+ * run init — INCLUDING in --json mode (agents pass --json, and stderr never
11
+ * corrupts the stdout JSON envelope). Quiet once set up, outside a project, and
12
+ * for the installer command itself.
13
+ */
14
+
15
+ import {describe, it, expect, beforeEach, afterEach} from 'vitest';
16
+ import {spawnSync} from 'node:child_process';
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import * as os from 'node:os';
20
+ import {fileURLToPath} from 'node:url';
21
+ import {isAstryxInitialized} from './agent-docs.mjs';
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const CLI = path.resolve(__dirname, '..', '..', 'bin', 'astryx.mjs');
25
+ const MARKER = '<!-- ASTRYX:START -->';
26
+ const NUDGE = /finish setup and install the Astryx agent prompt/;
27
+
28
+ let tmp;
29
+ beforeEach(() => {
30
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-setup-nudge-'));
31
+ });
32
+ afterEach(() => {
33
+ fs.rmSync(tmp, {recursive: true, force: true});
34
+ });
35
+
36
+ function write(rel, body) {
37
+ const p = path.join(tmp, rel);
38
+ fs.mkdirSync(path.dirname(p), {recursive: true});
39
+ fs.writeFileSync(p, body);
40
+ }
41
+
42
+ function run(args, cwd = tmp) {
43
+ return spawnSync(process.execPath, [CLI, ...args], {
44
+ cwd,
45
+ encoding: 'utf8',
46
+ timeout: 30_000,
47
+ env: {...process.env, FORCE_COLOR: '0'},
48
+ });
49
+ }
50
+
51
+ describe('isAstryxInitialized — centralized setup check (one place)', () => {
52
+ it('is false in an empty project', () => {
53
+ expect(isAstryxInitialized(tmp)).toBe(false);
54
+ });
55
+
56
+ // Covers EVERY location a preset can write — .hermes.md/HERMES.md were the gap.
57
+ it.each(['AGENTS.md', 'CLAUDE.md', '.claude/CLAUDE.md', '.cursorrules', '.hermes.md', 'HERMES.md'])(
58
+ 'detects the marker in %s',
59
+ file => {
60
+ write(file, `# doc\n${MARKER}\nbody`);
61
+ expect(isAstryxInitialized(tmp)).toBe(true);
62
+ },
63
+ );
64
+
65
+ it('detects the legacy XDS marker for back-compat', () => {
66
+ write('AGENTS.md', '<!-- XDS:START -->');
67
+ expect(isAstryxInitialized(tmp)).toBe(true);
68
+ });
69
+
70
+ it('is false when a doc file exists WITHOUT the marker', () => {
71
+ write('AGENTS.md', 'project notes, no astryx block');
72
+ expect(isAstryxInitialized(tmp)).toBe(false);
73
+ });
74
+ });
75
+
76
+ describe('enforcement layer 3 — per-command setup nudge', () => {
77
+ const asProject = () => write('package.json', '{"name":"t"}');
78
+
79
+ it('nudges on stderr after a command when not set up', () => {
80
+ asProject();
81
+ const r = run(['docs', 'tokens']);
82
+ expect(r.stderr).toMatch(NUDGE);
83
+ });
84
+
85
+ it('is suppressed in --json (machine mode stays clean), stdout valid JSON', () => {
86
+ asProject();
87
+ const r = run(['--json', 'docs', 'tokens']);
88
+ // --json is machine output with a clean stdout+stderr contract; the human
89
+ // nudge must NOT leak into it (json-shim: error envelopes have empty stderr).
90
+ expect(r.stderr).not.toMatch(NUDGE);
91
+ expect(() => JSON.parse(r.stdout)).not.toThrow();
92
+ });
93
+
94
+ it('is quiet once set up (marker present)', () => {
95
+ asProject();
96
+ write('AGENTS.md', MARKER);
97
+ expect(run(['docs', 'tokens']).stderr).not.toMatch(NUDGE);
98
+ });
99
+
100
+ it('is quiet outside a project (no package.json)', () => {
101
+ expect(run(['docs', 'tokens']).stderr).not.toMatch(NUDGE);
102
+ });
103
+
104
+ it('does not nudge for the installer command itself', () => {
105
+ asProject();
106
+ expect(run(['init']).stderr).not.toMatch(NUDGE);
107
+ });
108
+ });
@@ -15,18 +15,16 @@
15
15
 
16
16
  import * as fs from 'node:fs';
17
17
  import * as path from 'node:path';
18
- import * as p from '@clack/prompts';
19
18
  import {findCoreDir, listComponents} from '../utils/paths.mjs';
20
19
  import {
21
20
  assertWithin,
22
21
  PathSafetyError,
23
- isNonInteractive,
24
22
  } from '../utils/path-safety.mjs';
25
23
  import {jsonOut, humanLog} from '../lib/json.mjs';
26
24
  import {cliError} from '../lib/cli-error.mjs';
27
25
  import {ERROR_CODES} from '../lib/error-codes.mjs';
28
26
  import {checkGhCli} from '../utils/github.mjs';
29
- import {getRunPrefix} from '../utils/package-manager.mjs';
27
+ import {getCliInvocation} from '../utils/package-manager.mjs';
30
28
  import {Project} from '../lib/project.mjs';
31
29
  import {
32
30
  CORE_PACKAGE,
@@ -99,14 +97,6 @@ function buildFeedback(component, issuesUrl) {
99
97
  return feedback;
100
98
  }
101
99
 
102
- function isCancel(value) {
103
- if (p.isCancel(value)) {
104
- p.cancel('Cancelled.');
105
- process.exit(0);
106
- }
107
- return value;
108
- }
109
-
110
100
  /**
111
101
  * Load the configured integrations + core issues URL for `cwd`, swallowing any
112
102
  * config errors so swizzle never hard-fails on a malformed/absent config. An
@@ -190,6 +180,7 @@ export function registerSwizzle(program) {
190
180
  .action(async (component, options) => {
191
181
  const coreDir = findCoreDir(process.cwd());
192
182
  const json = program.opts().json || false;
183
+ const run = getCliInvocation();
193
184
 
194
185
  if (!coreDir) {
195
186
  cliError(
@@ -207,10 +198,10 @@ export function registerSwizzle(program) {
207
198
  for (const name of components) {
208
199
  humanLog(` ${name}`);
209
200
  }
210
- humanLog(`\nUsage: astryx swizzle <component>\n`);
211
- humanLog('Example: astryx swizzle Button');
201
+ humanLog(`\nUsage: ${run} swizzle <component>\n`);
202
+ humanLog(`Example: ${run} swizzle Button`);
212
203
  humanLog(
213
- ' astryx swizzle XDSButton (XDS prefix also works)\n',
204
+ ` ${run} swizzle XDSButton (XDS prefix also works)\n`,
214
205
  );
215
206
  return;
216
207
  }
@@ -314,25 +305,11 @@ export function registerSwizzle(program) {
314
305
 
315
306
  if (existingFiles.length > 0 && !options.overwrite) {
316
307
  const relOutputForMsg = path.relative(process.cwd(), outputDir) || '.';
317
- if (json || isNonInteractive({json})) {
318
- const msg =
319
- `Refusing to overwrite ${existingFiles.length} existing file(s) in ${relOutputForMsg}/. ` +
320
- `Re-run with --overwrite (or -f) to replace them.`;
321
- cliError(msg, {code: ERROR_CODES.ERR_FILE_EXISTS});
322
- return;
323
- }
324
- const confirmed = isCancel(
325
- await p.confirm({
326
- message:
327
- `Overwrite ${existingFiles.length} existing file(s) in ${relOutputForMsg}/? ` +
328
- `(${existingFiles.slice(0, 3).join(', ')}${existingFiles.length > 3 ? ', …' : ''})`,
329
- initialValue: false,
330
- }),
331
- );
332
- if (!confirmed) {
333
- humanLog('Aborted. Re-run with --overwrite to replace files.');
334
- return;
335
- }
308
+ const msg =
309
+ `Refusing to overwrite ${existingFiles.length} existing file(s) in ${relOutputForMsg}/. ` +
310
+ `Re-run with --overwrite (or -f) to replace them.`;
311
+ cliError(msg, {code: ERROR_CODES.ERR_FILE_EXISTS});
312
+ return;
336
313
  }
337
314
 
338
315
  fs.mkdirSync(outputDir, {recursive: true});
@@ -411,7 +388,7 @@ export function registerSwizzle(program) {
411
388
  humanLog(
412
389
  ' Without one they render unstyled (no error). See setup per framework:',
413
390
  );
414
- humanLog(` ${getRunPrefix()} astryx docs styling`);
391
+ humanLog(` ${run} docs styling`);
415
392
  humanLog(
416
393
  ' Next.js note: the StyleX Babel plugin disables SWC and breaks next/font —',
417
394
  );
@@ -6,25 +6,16 @@
6
6
 
7
7
  import * as path from 'node:path';
8
8
  import * as fs from 'node:fs';
9
- import * as p from '@clack/prompts';
10
- import {isNonInteractive} from '../utils/path-safety.mjs';
11
9
  import {jsonOut, humanLog} from '../lib/json.mjs';
12
10
  import {cliError} from '../lib/cli-error.mjs';
13
11
  import {ERROR_CODES} from '../lib/error-codes.mjs';
14
12
  import {template as templateApi} from '../api/template.mjs';
15
13
  import {Project} from '../lib/project.mjs';
16
14
  import {warnOnIntegrationIssues} from '../lib/integration-warnings.mjs';
15
+ import {getCliInvocation} from '../utils/package-manager.mjs';
17
16
 
18
17
  export {discoverTemplates, listTemplates} from '../api/template.mjs';
19
18
 
20
- function isCancel(value) {
21
- if (p.isCancel(value)) {
22
- p.cancel('Cancelled.');
23
- process.exit(0);
24
- }
25
- return value;
26
- }
27
-
28
19
  export function registerTemplate(program) {
29
20
  program
30
21
  .command('template [name] [path]')
@@ -36,6 +27,7 @@ export function registerTemplate(program) {
36
27
  .option('-f, --overwrite', 'Overwrite existing files without prompting')
37
28
  .action(async (name, targetPath, options) => {
38
29
  const json = program.opts().json || false;
30
+ const run = getCliInvocation();
39
31
 
40
32
  // Non-blocking nudge: if any configured integration has validation
41
33
  // issues, print one compact line to stderr pointing at
@@ -59,23 +51,11 @@ export function registerTemplate(program) {
59
51
  const collision = await detectTemplateCollision(name, targetPath);
60
52
  if (collision && !options.overwrite) {
61
53
  const rel = path.relative(process.cwd(), collision) || collision;
62
- if (json || isNonInteractive({json})) {
63
- const msg =
64
- `Refusing to overwrite existing file ${rel}. ` +
65
- `Re-run with --overwrite (or -f) to replace it.`;
66
- cliError(msg, {code: ERROR_CODES.ERR_FILE_EXISTS});
67
- return;
68
- }
69
- const confirmed = isCancel(
70
- await p.confirm({
71
- message: `Overwrite existing file ${rel}?`,
72
- initialValue: false,
73
- }),
74
- );
75
- if (!confirmed) {
76
- humanLog('Aborted. Re-run with --overwrite to replace the file.');
77
- return;
78
- }
54
+ const msg =
55
+ `Refusing to overwrite existing file ${rel}. ` +
56
+ `Re-run with --overwrite (or -f) to replace it.`;
57
+ cliError(msg, {code: ERROR_CODES.ERR_FILE_EXISTS});
58
+ return;
79
59
  }
80
60
  }
81
61
 
@@ -120,10 +100,10 @@ export function registerTemplate(program) {
120
100
  for (const t of blocks) renderEntry(t);
121
101
  }
122
102
  humanLog('\nUsage:');
123
- humanLog(' astryx template <id> [target-path] Scaffold page or block');
124
- humanLog(' astryx template <id> --skeleton Layout reference');
125
- humanLog(' astryx template --list --type block List only blocks');
126
- humanLog(' astryx template --list --package <pkg> List from one package\n');
103
+ humanLog(` ${run} template <id> [target-path] Scaffold page or block`);
104
+ humanLog(` ${run} template <id> --skeleton Layout reference`);
105
+ humanLog(` ${run} template --list --type block List only blocks`);
106
+ humanLog(` ${run} template --list --package <pkg> List from one package\n`);
127
107
  break;
128
108
  }
129
109
 
@@ -11,8 +11,11 @@
11
11
  *
12
12
  * Pipeline (--apply):
13
13
  * 1. Read installed @astryxdesign/core (or legacy @xds/core) version
14
- * 2. Run codemods for --from installed version
15
- * 3. Refresh agent docs (AGENTS.md / CLAUDE.md) if present
14
+ * 2. Refresh the managed agent-docs block (AGENTS.md / CLAUDE.md) to the
15
+ * installed version runs on EVERY path (including the up-to-date /
16
+ * no-codemods short-circuits), because the block documents the installed
17
+ * library, not the codemod outcome. Dry-run reports without writing.
18
+ * 3. Run codemods for --from → installed version
16
19
  *
17
20
  * Options:
18
21
  * --from <version> Previous version before the dependency upgrade
@@ -37,7 +40,7 @@ import * as fs from 'node:fs';
37
40
  import * as path from 'node:path';
38
41
  import {execFile} from 'node:child_process';
39
42
  import {promisify} from 'node:util';
40
- import * as p from '@clack/prompts';
43
+ import * as p from '../lib/term-log.mjs';
41
44
  import {ensureJscodeshift} from '../codemods/ensure-jscodeshift.mjs';
42
45
  import {getTransformsBetween, latestVersion} from '../codemods/registry.mjs';
43
46
  import {runCodemods} from '../codemods/runner.mjs';
@@ -46,8 +49,8 @@ import {
46
49
  selectIntegrationCodemods,
47
50
  } from '../codemods/integration-discovery.mjs';
48
51
  import {runIntegrationCodemods} from '../codemods/integration-runner.mjs';
49
- import {installAgentDocs, discoverAgentDocs} from './agent-docs.mjs';
50
- import {getRunPrefix} from '../utils/package-manager.mjs';
52
+ import {installAgentDocs, inspectAgentDocs} from './agent-docs.mjs';
53
+ import {getCliInvocation, formatCliCommand} from '../utils/package-manager.mjs';
51
54
  import {isValidSemver, semverGte} from '../utils/semver.mjs';
52
55
  import {jsonOut, jsonError} from '../lib/json.mjs';
53
56
  import {Project} from '../lib/project.mjs';
@@ -135,6 +138,103 @@ async function runPostCodemodHooks(hooks, context, silent) {
135
138
  }
136
139
  }
137
140
 
141
+ /**
142
+ * Refresh (or, in dry-run, report) the managed agent-docs block after a version
143
+ * bump. The block (`<!-- ASTRYX:START --> … <!-- ASTRYX:END -->`) documents the
144
+ * INSTALLED library — its version, component index, and agent rules — so it must
145
+ * be re-synced on EVERY upgrade path, including the up-to-date / no-codemods
146
+ * short-circuits where no source file changes. That was the gap in #4168: an
147
+ * agent reading a stale index queries missing components and follows superseded
148
+ * rules. Three cases, one detection pass:
149
+ *
150
+ * - `stale` — a managed block records an older version (or a legacy XDS
151
+ * marker). `--apply` rewrites it; dry-run reports the pending
152
+ * refresh as a loud next step and writes nothing.
153
+ * - `missing` — core is installed but no managed block exists anywhere (the repo
154
+ * never ran `init`, or its agent docs were never marked). We never
155
+ * silently create docs mid-upgrade; we nudge to run `init`.
156
+ * - `current` — every block already matches the installed version: stay silent.
157
+ *
158
+ * @param {{cwd: string, installedVersion: string, apply: boolean, json: boolean}} ctx
159
+ * @returns {import('../types/upgrade').AgentDocsSummary}
160
+ */
161
+ export function refreshAgentDocs({cwd, installedVersion, apply, json}) {
162
+ const inspection = inspectAgentDocs(cwd, installedVersion);
163
+ /** @type {import('../types/upgrade').AgentDocsSummary} */
164
+ const summary = {
165
+ status: inspection.status,
166
+ installedVersion,
167
+ fromVersions: inspection.blockVersions,
168
+ files: [],
169
+ refreshed: false,
170
+ action: 'none',
171
+ };
172
+
173
+ // Never initialized — don't silently create docs during an upgrade; nudge.
174
+ if (inspection.status === 'missing') {
175
+ summary.action = 'nudge-init';
176
+ if (!json) {
177
+ p.log.warn(
178
+ `No Astryx agent-docs block found — AI agents have no component index. Run \`${formatCliCommand('astryx init --features agents')}\` to install it.`,
179
+ );
180
+ }
181
+ return summary;
182
+ }
183
+
184
+ if (inspection.status === 'current') return summary;
185
+
186
+ // Stale.
187
+ summary.files = inspection.staleFiles;
188
+ const fromLabel = summary.fromVersions.length
189
+ ? `v${summary.fromVersions.join(', v')}`
190
+ : 'an unknown version';
191
+
192
+ if (!apply) {
193
+ // Dry-run: report the pending change, never write.
194
+ summary.action = 'would-refresh';
195
+ if (!json) {
196
+ p.log.warn(
197
+ `Agent docs are stale: block is at ${fromLabel}, installed is v${installedVersion}. Re-run with --apply to refresh (${summary.files.join(', ')}).`,
198
+ );
199
+ }
200
+ return summary;
201
+ }
202
+
203
+ // Apply: rewrite only files that already carry a marker (onlyReplace).
204
+ try {
205
+ const written = installAgentDocs(cwd, {onlyReplace: true});
206
+ summary.refreshed = written.length > 0;
207
+ summary.files = written;
208
+ if (summary.refreshed) {
209
+ summary.action = 'refreshed';
210
+ if (!json) {
211
+ p.log.success(
212
+ `Agent docs refreshed → v${installedVersion} (from ${fromLabel}): ${written.join(', ')}`,
213
+ );
214
+ }
215
+ } else {
216
+ // We detected a stale marked block but rewrote nothing, and
217
+ // installAgentDocs did not throw — the block markers are malformed (e.g. a
218
+ // START with no matching END, so the writer can't safely splice it). Don't
219
+ // fail silently: the block is exactly the artifact agents rely on.
220
+ summary.action = 'error';
221
+ if (!json) {
222
+ p.log.warn(
223
+ `Agent docs look stale but couldn't be refreshed — the <!-- ASTRYX:START -->/<!-- ASTRYX:END --> markers may be malformed. Run \`${formatCliCommand('astryx init --features agents')}\` to reinstall the block.`,
224
+ );
225
+ }
226
+ }
227
+ } catch {
228
+ summary.action = 'error';
229
+ if (!json) {
230
+ p.log.warn(
231
+ `Could not refresh agent docs. Run \`${formatCliCommand('astryx init --features agents')}\` to update them manually.`,
232
+ );
233
+ }
234
+ }
235
+ return summary;
236
+ }
237
+
138
238
  /**
139
239
  * Register the `upgrade` command (codemod-driven version migration).
140
240
  */
@@ -176,7 +276,7 @@ export function registerUpgrade(program) {
176
276
 
177
277
  if (!options.list && !options.from) {
178
278
  const msg =
179
- 'Missing required --from. Install the target version first, then run `astryx upgrade --from <old-version>`.';
279
+ `Missing required --from. Install the target version first, then run \`${getCliInvocation()} upgrade --from <old-version>\`.`;
180
280
  if (json)
181
281
  return jsonError(msg, undefined, ERROR_CODES.ERR_INVALID_ARGUMENT);
182
282
  p.log.error(msg);
@@ -237,7 +337,7 @@ export function registerUpgrade(program) {
237
337
  const installed = detectInstalledTargetVersion();
238
338
  if (!installed) {
239
339
  const msg =
240
- 'Could not find installed @astryxdesign/core (or legacy @xds/core). Install the target version first, then rerun `astryx upgrade --from <old-version>`.';
340
+ `Could not find installed @astryxdesign/core (or legacy @xds/core). Install the target version first, then rerun \`${getCliInvocation()} upgrade --from <old-version>\`.`;
241
341
  if (json)
242
342
  return jsonError(msg, undefined, ERROR_CODES.ERR_VERSION_DETECT);
243
343
  p.log.error(msg);
@@ -254,6 +354,19 @@ export function registerUpgrade(program) {
254
354
  );
255
355
  }
256
356
 
357
+ // Sync the managed agent-docs block to the installed version FIRST. It
358
+ // documents the installed library (version + component index + rules),
359
+ // independent of any source codemods, so it must be refreshed on every
360
+ // path below — including the up-to-date / no-codemods short-circuits that
361
+ // return before codemods ever run (issue #4168). Dry-run reports without
362
+ // writing. Folded into every terminal payload as `agentDocs`.
363
+ const agentDocs = refreshAgentDocs({
364
+ cwd: process.cwd(),
365
+ installedVersion: targetVersion,
366
+ apply: options.apply,
367
+ json,
368
+ });
369
+
257
370
  // ───────────────────────────────────────────────────────────────────
258
371
  // PIPELINE ORDERING
259
372
  //
@@ -275,6 +388,7 @@ export function registerUpgrade(program) {
275
388
  status: 'up_to_date',
276
389
  from: currentVersion,
277
390
  to: targetVersion,
391
+ agentDocs,
278
392
  });
279
393
  }
280
394
  p.log.success('Already up to date — no codemods to run.');
@@ -390,6 +504,9 @@ export function registerUpgrade(program) {
390
504
  const codemodFlags = coreConfigCodemodNames
391
505
  .map(name => `--codemod ${name}`)
392
506
  .join(' ');
507
+ // Canonical (bare) form — this is a structured, machine-executable
508
+ // field in the --json envelope. The human print below is made
509
+ // install-aware via formatCliCommand.
393
510
  const suggestedCommand = `astryx upgrade --from ${currentVersion} ${codemodFlags} --apply`;
394
511
  const guidance =
395
512
  'Your astryx.config currently fails strict validation, but a pending ' +
@@ -406,10 +523,11 @@ export function registerUpgrade(program) {
406
523
  suggestedCommand,
407
524
  message: guidance,
408
525
  note: 'Integrations are skipped in this preview; they will be processed on the --apply run.',
526
+ agentDocs,
409
527
  });
410
528
  }
411
529
  p.log.warn(guidance);
412
- p.log.info(` ${suggestedCommand}`);
530
+ p.log.info(` ${formatCliCommand(suggestedCommand)}`);
413
531
  p.log.info(
414
532
  'Integrations are skipped in this preview; they will be processed on the --apply run.',
415
533
  );
@@ -417,11 +535,12 @@ export function registerUpgrade(program) {
417
535
  return;
418
536
  }
419
537
  // Genuine config error (apply mode, OR dry-run with no pending core
420
- // config codemod that would fix it): abort as before.
538
+ // config codemod that would fix it): abort as before. The agent-docs
539
+ // refresh already ran (it's independent of config), so surface it.
421
540
  if (json)
422
541
  return jsonError(
423
542
  err.message,
424
- undefined,
543
+ {agentDocs},
425
544
  ERROR_CODES.ERR_INVALID_ARGUMENT,
426
545
  );
427
546
  p.log.error(err.message);
@@ -499,6 +618,7 @@ export function registerUpgrade(program) {
499
618
  status: 'no_codemods',
500
619
  from: currentVersion,
501
620
  to: targetVersion,
621
+ agentDocs,
502
622
  });
503
623
  }
504
624
  p.log.success('No codemods available for this version range.');
@@ -510,7 +630,7 @@ export function registerUpgrade(program) {
510
630
  if (totalTransforms === 0 && totalOptional === 0) {
511
631
  const msg = `Codemod "${options.codemod}" not found. Use --list to see available codemods.`;
512
632
  if (json)
513
- return jsonError(msg, undefined, ERROR_CODES.ERR_UNKNOWN_CODEMOD);
633
+ return jsonError(msg, {agentDocs}, ERROR_CODES.ERR_UNKNOWN_CODEMOD);
514
634
  p.log.error(msg);
515
635
  p.outro('Aborted');
516
636
  process.exitCode = 1;
@@ -532,7 +652,10 @@ export function registerUpgrade(program) {
532
652
  to: targetVersion,
533
653
  codemods: totalTransforms,
534
654
  integrations: integrations.map(i => i.name ?? i.__spec),
535
- agentDocsRefreshed: false,
655
+ // Refreshed up front (before the gates above), so the receipt just
656
+ // reports what happened. `agentDocsRefreshed` kept for back-compat.
657
+ agentDocsRefreshed: agentDocs.refreshed,
658
+ agentDocs,
536
659
  };
537
660
 
538
661
  // Run file-based integration codemods alongside the core registry
@@ -604,26 +727,9 @@ export function registerUpgrade(program) {
604
727
  }
605
728
  }
606
729
 
607
- // Refresh agent docs if any exist (AGENTS.md, CLAUDE.md, .claude/CLAUDE.md, etc.)
608
- // Always update after --apply; also update during dry-run if files exist,
609
- // since the index reflects the installed CLI version, not the codemods.
610
- const existingDocs = discoverAgentDocs(process.cwd());
611
- if (existingDocs.length > 0) {
612
- try {
613
- // onlyReplace: only update files that already have Astryx markers.
614
- // Don't inject into files that never had Astryx content.
615
- const written = installAgentDocs(process.cwd(), {onlyReplace: true});
616
- receipt.agentDocsRefreshed = written.length > 0;
617
- if (!json && written.length > 0)
618
- p.log.success(`Agent docs updated: ${written.join(', ')}`);
619
- } catch {
620
- if (!json) {
621
- p.log.warn(
622
- `Could not update agent docs. Run \`${getRunPrefix()} astryx init --features agents\` to update manually.`,
623
- );
624
- }
625
- }
626
- }
730
+ // NOTE: the managed agent-docs block was already refreshed up front (see
731
+ // refreshAgentDocs after installed-version detection), so it stays in sync
732
+ // even on the short-circuit paths that return before this point.
627
733
 
628
734
  receipt.filesChanged = mergedFilesChanged;
629
735
  receipt.transformsApplied = mergedTransformsApplied;