@astryxdesign/cli 0.1.7 → 0.1.8-canary.0c4d8d7

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 (134) 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/elevation.doc.mjs +79 -1
  5. package/docs/getting-started.doc.mjs +9 -9
  6. package/docs/migration.doc.mjs +18 -18
  7. package/docs/principles.doc.dense.mjs +1 -1
  8. package/docs/principles.doc.mjs +6 -6
  9. package/docs/principles.doc.zh.mjs +1 -1
  10. package/docs/styling-libraries.doc.mjs +3 -3
  11. package/docs/styling.doc.mjs +4 -4
  12. package/docs/theme.doc.dense.mjs +2 -2
  13. package/docs/theme.doc.mjs +7 -7
  14. package/docs/theme.doc.zh.mjs +1 -1
  15. package/docs/tokens.doc.mjs +1 -1
  16. package/docs/working-with-ai.doc.mjs +19 -19
  17. package/package.json +13 -11
  18. package/scripts/postinstall.mjs +74 -0
  19. package/src/api/doctor.mjs +3 -3
  20. package/src/api/error.mjs +2 -2
  21. package/src/codemods/__tests__/registry.test.mjs +1 -0
  22. package/src/codemods/ensure-jscodeshift.mjs +11 -27
  23. package/src/codemods/registry.mjs +1 -0
  24. package/src/codemods/run-codemod.mjs +1 -1
  25. package/src/codemods/runner.mjs +2 -2
  26. package/src/codemods/transforms/v0.1.0/__tests__/drop-xds-prefix-imports.test.mjs +42 -7
  27. package/src/codemods/transforms/v0.1.0/__tests__/migrate-xds-module-specifiers.test.mjs +43 -0
  28. package/src/codemods/transforms/v0.1.0/drop-xds-prefix-imports.mjs +102 -3
  29. package/src/codemods/transforms/v0.1.0/migrate-xds-module-specifiers.mjs +72 -6
  30. package/src/codemods/transforms/v0.1.8/__tests__/rename-avatar-size-scale.test.mjs +161 -0
  31. package/src/codemods/transforms/v0.1.8/index.mjs +19 -0
  32. package/src/codemods/transforms/v0.1.8/rename-avatar-size-scale.mjs +234 -0
  33. package/src/commands/agent-docs.mjs +136 -20
  34. package/src/commands/agent-docs.test.mjs +123 -10
  35. package/src/commands/build-theme.mjs +10 -71
  36. package/src/commands/build.mjs +15 -15
  37. package/src/commands/cli-postinstall.test.mjs +42 -0
  38. package/src/commands/component/index.mjs +4 -4
  39. package/src/commands/discover.mjs +7 -5
  40. package/src/commands/docs.mjs +4 -4
  41. package/src/commands/hook/index.mjs +4 -4
  42. package/src/commands/init.mjs +48 -152
  43. package/src/commands/init.next-steps.test.mjs +1 -1
  44. package/src/commands/interactive-guard.test.mjs +19 -22
  45. package/src/commands/json-contract.test.mjs +2 -2
  46. package/src/commands/layout.mjs +1 -1
  47. package/src/commands/search.mjs +4 -4
  48. package/src/commands/setup-nudge.test.mjs +108 -0
  49. package/src/commands/swizzle.mjs +12 -35
  50. package/src/commands/template.mjs +11 -31
  51. package/src/commands/upgrade.mjs +138 -32
  52. package/src/commands/upgrade.test.mjs +155 -1
  53. package/src/index.mjs +40 -6
  54. package/src/lib/cli-error.mjs +2 -2
  55. package/src/lib/component-format.mjs +2 -1
  56. package/src/lib/json-shim.mjs +1 -1
  57. package/src/lib/json.mjs +3 -3
  58. package/src/lib/term-log.mjs +48 -0
  59. package/src/types/api.d.ts +4 -9
  60. package/src/types/base.d.ts +24 -3
  61. package/src/types/build.d.ts +23 -0
  62. package/src/types/index.d.ts +1 -0
  63. package/src/types/swizzle.d.ts +4 -0
  64. package/src/types/template-api.d.ts +4 -1
  65. package/src/types/upgrade.d.ts +39 -2
  66. package/src/utils/package-manager.mjs +108 -4
  67. package/src/utils/package-manager.test.mjs +108 -1
  68. package/src/utils/path-safety.mjs +0 -18
  69. package/src/utils/update-check.mjs +5 -4
  70. package/src/utils/update-check.test.mjs +3 -3
  71. package/templates/blocks/components/Avatar/AvatarFallbackChain.tsx +4 -4
  72. package/templates/blocks/components/Avatar/AvatarGroup.tsx +2 -2
  73. package/templates/blocks/components/Avatar/AvatarInitialsFallback.tsx +1 -1
  74. package/templates/blocks/components/Avatar/AvatarShowcase.tsx +4 -4
  75. package/templates/blocks/components/Avatar/AvatarUserCard.tsx +1 -1
  76. package/templates/blocks/components/Avatar/AvatarWithImage.tsx +4 -4
  77. package/templates/blocks/components/Avatar/AvatarWithStatus.tsx +3 -3
  78. package/templates/blocks/components/AvatarGroup/AvatarGroupShowcase.tsx +2 -2
  79. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowCustomText.tsx +1 -1
  80. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowDefault.tsx +1 -1
  81. package/templates/blocks/components/AvatarGroupOverflow/AvatarGroupOverflowShowcase.tsx +2 -2
  82. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotShowcase.tsx +3 -3
  83. package/templates/blocks/components/AvatarStatusDot/AvatarStatusDotVariants.tsx +3 -3
  84. package/templates/blocks/components/Banner/BannerFloating.doc.mjs +14 -0
  85. package/templates/blocks/components/Banner/BannerFloating.tsx +16 -0
  86. package/templates/blocks/components/Button/ButtonFloating.doc.mjs +14 -0
  87. package/templates/blocks/components/Button/ButtonFloating.tsx +37 -0
  88. package/templates/blocks/components/ButtonGroup/ButtonGroupFloating.doc.mjs +14 -0
  89. package/templates/blocks/components/ButtonGroup/ButtonGroupFloating.tsx +23 -0
  90. package/templates/blocks/components/Card/CardElevations.doc.mjs +14 -0
  91. package/templates/blocks/components/Card/CardElevations.tsx +32 -0
  92. package/templates/blocks/components/Card/ClickableCardElevated.doc.mjs +14 -0
  93. package/templates/blocks/components/Card/ClickableCardElevated.tsx +21 -0
  94. package/templates/blocks/components/Card/SelectableCardElevated.doc.mjs +14 -0
  95. package/templates/blocks/components/Card/SelectableCardElevated.tsx +37 -0
  96. package/templates/blocks/components/Carousel/CarouselSnap.tsx +1 -1
  97. package/templates/blocks/components/ChatComposer/ChatComposerFlat.doc.mjs +14 -0
  98. package/templates/blocks/components/ChatComposer/ChatComposerFlat.tsx +75 -0
  99. package/templates/blocks/components/ChatMessage/ChatMessageAvatarName.tsx +2 -2
  100. package/templates/blocks/components/ChatMessage/ChatMessageMultiBubble.tsx +1 -1
  101. package/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleGrouping.tsx +1 -1
  102. package/templates/blocks/components/ChatMessageBubble/ChatMessageBubbleMetadata.tsx +1 -1
  103. package/templates/blocks/components/ChatMessageList/ChatMessageListDensity.tsx +5 -9
  104. package/templates/blocks/components/ChatMessageList/ChatMessageListFullFeatured.tsx +1 -1
  105. package/templates/blocks/components/CodeBlock/CodeBlockTerminal.tsx +1 -1
  106. package/templates/blocks/components/HoverCard/HoverCardShowcase.tsx +1 -1
  107. package/templates/blocks/components/IconButton/IconButtonFloating.doc.mjs +14 -0
  108. package/templates/blocks/components/IconButton/IconButtonFloating.tsx +37 -0
  109. package/templates/blocks/components/Item/ItemWithMedia.tsx +2 -2
  110. package/templates/blocks/components/ListItem/ListItemWithMedia.tsx +2 -2
  111. package/templates/blocks/components/OverflowList/OverflowListCappedToolbar.doc.mjs +14 -0
  112. package/templates/blocks/components/OverflowList/OverflowListCappedToolbar.tsx +39 -0
  113. package/templates/blocks/components/OverflowList/OverflowListMultiRowTags.doc.mjs +14 -0
  114. package/templates/blocks/components/OverflowList/OverflowListMultiRowTags.tsx +44 -0
  115. package/templates/blocks/components/Pagination/PaginationDotsCarousel.tsx +2 -6
  116. package/templates/blocks/components/Stack/StackFillItem.tsx +2 -6
  117. package/templates/blocks/components/TabList/TabListTabsWithActions.doc.mjs +1 -1
  118. package/templates/blocks/components/TabList/TabListTabsWithActions.tsx +2 -7
  119. package/templates/blocks/components/Table/TableRowStatusTable.doc.mjs +14 -0
  120. package/templates/blocks/components/Table/TableRowStatusTable.tsx +59 -0
  121. package/templates/blocks/components/Thumbnail/ThumbnailElevated.doc.mjs +14 -0
  122. package/templates/blocks/components/Thumbnail/ThumbnailElevated.tsx +31 -0
  123. package/templates/blocks/components/TypeaheadItem/TypeaheadItemShowcase.tsx +1 -1
  124. package/templates/pages/ai-chat/page.tsx +4 -4
  125. package/templates/pages/dashboard-portfolio/page.tsx +3 -11
  126. package/templates/pages/detail-page/page.tsx +5 -12
  127. package/templates/pages/documentation-design/page.tsx +1 -1
  128. package/templates/pages/messaging-shell/page.tsx +6 -8
  129. package/templates/pages/table-grouped/page.tsx +9 -14
  130. package/templates/pages/table-page/page.tsx +7 -9
  131. package/templates/pages/table-page-heatmap-status/page.tsx +5 -13
  132. package/docs/integration-authoring.md +0 -105
  133. package/src/utils/interactive.mjs +0 -76
  134. package/src/utils/interactive.test.mjs +0 -70
@@ -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;
@@ -6,6 +6,7 @@ import * as path from 'node:path';
6
6
  import * as os from 'node:os';
7
7
  import {Command} from 'commander';
8
8
  import {registerUpgrade} from './upgrade.mjs';
9
+ import {generateCompressedIndex} from './agent-docs.mjs';
9
10
 
10
11
  let tmpDir;
11
12
  let originalCwd;
@@ -24,7 +25,7 @@ beforeEach(() => {
24
25
  logCalls.push(args.join(' '));
25
26
  });
26
27
  vi.spyOn(console, 'error').mockImplementation(() => {});
27
- // @clack/prompts writes directly to process.stdout — capture that too.
28
+ // Some human logs are written straight to process.stdout — capture that too.
28
29
  vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
29
30
  stdoutCalls.push(typeof chunk === 'string' ? chunk : chunk.toString());
30
31
  return true;
@@ -158,3 +159,156 @@ describe('upgrade --list dedup', () => {
158
159
  expect(names.length).toBe(unique.size);
159
160
  });
160
161
  });
162
+
163
+ // Issue #4168 — `upgrade` must refresh the managed agent-docs block after a
164
+ // version bump, on EVERY path (the block documents the installed library, not
165
+ // the codemod outcome), and must NOT write during a dry run.
166
+ describe('upgrade agent-docs refresh (#4168)', () => {
167
+ /** Write an agent-doc file with a managed block generated for `version`. */
168
+ function writeAgentBlock(rel, version) {
169
+ const filePath = path.join(tmpDir, rel);
170
+ fs.mkdirSync(path.dirname(filePath), {recursive: true});
171
+ fs.writeFileSync(filePath, `# Doc\n\n${generateCompressedIndex(version)}\n`);
172
+ }
173
+
174
+ it('refreshes a stale block with --apply, even when there are no codemods (up-to-date path)', async () => {
175
+ writePkg();
176
+ writeInstalledCore('0.0.15');
177
+ writeAgentBlock('AGENTS.md', '0.0.1');
178
+
179
+ // --from == installed → up-to-date short-circuit: no codemods run, yet the
180
+ // stale block must still be brought current. This is the exact gap in #4168.
181
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15', '--apply']);
182
+
183
+ expect(result.type).toBe('upgrade.status');
184
+ expect(result.data.status).toBe('up_to_date');
185
+ expect(result.data.agentDocs.status).toBe('stale');
186
+ expect(result.data.agentDocs.action).toBe('refreshed');
187
+ expect(result.data.agentDocs.refreshed).toBe(true);
188
+ expect(result.data.agentDocs.fromVersions).toEqual(['0.0.1']);
189
+ expect(result.data.agentDocs.files).toContain('AGENTS.md');
190
+
191
+ const content = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8');
192
+ expect(content).toMatch(/Astryx v0\.0\.15 ·/);
193
+ expect(content).not.toMatch(/Astryx v0\.0\.1 ·/);
194
+ });
195
+
196
+ it('reports a stale block on a dry run WITHOUT writing', async () => {
197
+ writePkg();
198
+ writeInstalledCore('0.0.15');
199
+ writeAgentBlock('AGENTS.md', '0.0.1');
200
+ const before = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8');
201
+
202
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15']); // no --apply
203
+
204
+ expect(result.data.agentDocs.status).toBe('stale');
205
+ expect(result.data.agentDocs.action).toBe('would-refresh');
206
+ expect(result.data.agentDocs.refreshed).toBe(false);
207
+ // The file must be untouched by a dry run.
208
+ expect(fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8')).toBe(before);
209
+ });
210
+
211
+ it('refreshes a stale block on the no-codemods path (--apply)', async () => {
212
+ writePkg();
213
+ writeInstalledCore('0.0.11'); // (0.0.10, 0.0.11] has no registered codemods
214
+ writeSourceFile();
215
+ writeAgentBlock('CLAUDE.md', '0.0.1');
216
+
217
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.10', '--apply', '--path', 'src']);
218
+
219
+ expect(result.type).toBe('upgrade.status');
220
+ expect(result.data.status).toBe('no_codemods');
221
+ expect(result.data.agentDocs.refreshed).toBe(true);
222
+ expect(fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8')).toMatch(/Astryx v0\.0\.11 ·/);
223
+ });
224
+
225
+ it('nudges to run init when core is installed but no managed block exists', async () => {
226
+ writePkg();
227
+ writeInstalledCore('0.0.15');
228
+ // No agent-doc files at all.
229
+
230
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15', '--apply']);
231
+
232
+ expect(result.data.agentDocs.status).toBe('missing');
233
+ expect(result.data.agentDocs.action).toBe('nudge-init');
234
+ expect(result.data.agentDocs.refreshed).toBe(false);
235
+ // Must NOT silently create docs during an upgrade.
236
+ expect(fs.existsSync(path.join(tmpDir, 'AGENTS.md'))).toBe(false);
237
+ expect(fs.existsSync(path.join(tmpDir, '.claude', 'CLAUDE.md'))).toBe(false);
238
+ });
239
+
240
+ it('treats an agent file without our markers as "never initialized" (missing)', async () => {
241
+ writePkg();
242
+ writeInstalledCore('0.0.15');
243
+ fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), '# Agents\n\nHand-written, no astryx block.\n');
244
+ const before = fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8');
245
+
246
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15', '--apply']);
247
+
248
+ expect(result.data.agentDocs.status).toBe('missing');
249
+ expect(result.data.agentDocs.action).toBe('nudge-init');
250
+ // An unmarked file is left exactly as-is.
251
+ expect(fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8')).toBe(before);
252
+ });
253
+
254
+ it('does not silently no-op a stale block with malformed markers (START without END)', async () => {
255
+ writePkg();
256
+ writeInstalledCore('0.0.15');
257
+ // A corrupted block: START + version header but no matching END marker, so
258
+ // the writer can't safely splice it. This must not crash, corrupt the file,
259
+ // or falsely claim success — it should surface an error and nudge re-init.
260
+ const corrupted =
261
+ '# A\n\n<!-- ASTRYX:START -->\nAstryx v0.0.1 · 9 components\nguidance, but no end marker\n';
262
+ fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), corrupted);
263
+
264
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15', '--apply']);
265
+
266
+ expect(result.data.agentDocs.status).toBe('stale');
267
+ expect(result.data.agentDocs.action).toBe('error');
268
+ expect(result.data.agentDocs.refreshed).toBe(false);
269
+ // File left byte-for-byte intact — never corrupted.
270
+ expect(fs.readFileSync(path.join(tmpDir, 'AGENTS.md'), 'utf-8')).toBe(corrupted);
271
+ });
272
+
273
+ it('stays silent when the block already matches the installed version', async () => {
274
+ writePkg();
275
+ writeInstalledCore('0.0.15');
276
+ writeAgentBlock('AGENTS.md', '0.0.15');
277
+
278
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15', '--apply']);
279
+
280
+ expect(result.data.agentDocs.status).toBe('current');
281
+ expect(result.data.agentDocs.action).toBe('none');
282
+ expect(result.data.agentDocs.refreshed).toBe(false);
283
+ });
284
+
285
+ it('migrates a legacy XDS block to the current Astryx block (--apply)', async () => {
286
+ writePkg();
287
+ writeInstalledCore('0.0.15');
288
+ fs.writeFileSync(
289
+ path.join(tmpDir, 'CLAUDE.md'),
290
+ '# Claude\n\n<!-- XDS:START -->\nlegacy index\n<!-- XDS:END -->\n\nMore rules.\n',
291
+ );
292
+
293
+ const result = await runJson(['--json', 'upgrade', '--from', '0.0.15', '--apply']);
294
+
295
+ expect(result.data.agentDocs.status).toBe('stale');
296
+ expect(result.data.agentDocs.refreshed).toBe(true);
297
+ const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
298
+ expect(content).toContain('<!-- ASTRYX:START -->');
299
+ expect(content).toMatch(/Astryx v0\.0\.15 ·/);
300
+ expect(content).not.toContain('legacy index');
301
+ expect(content).toContain('More rules.');
302
+ });
303
+
304
+ it('surfaces agentDocs in the human output on the up-to-date path', async () => {
305
+ writePkg();
306
+ writeInstalledCore('0.0.15');
307
+ writeAgentBlock('AGENTS.md', '0.0.1');
308
+
309
+ const program = createProgram();
310
+ await program.parseAsync(['node', 'astryx', 'upgrade', '--from', '0.0.15', '--apply']);
311
+ const output = stdoutCalls.join('') + logCalls.join('\n');
312
+ expect(output).toMatch(/Agent docs refreshed/i);
313
+ });
314
+ });
package/src/index.mjs CHANGED
@@ -12,13 +12,14 @@ import {fileURLToPath} from 'node:url';
12
12
  import * as fs from 'node:fs';
13
13
  import * as path from 'node:path';
14
14
  import {checkForUpdate} from './utils/update-check.mjs';
15
- import {getRunPrefix} from './utils/package-manager.mjs';
15
+ import {getCliInvocation} from './utils/package-manager.mjs';
16
16
  import {API_VERSION, setJsonMode} from './lib/json.mjs';
17
17
  import {buildManifest} from './lib/manifest.mjs';
18
18
  import {cliError} from './lib/cli-error.mjs';
19
19
  import {ERROR_CODES} from './lib/error-codes.mjs';
20
20
  import {levenshteinDistance} from './lib/string-utils.mjs';
21
21
  import {installJsonShim} from './lib/json-shim.mjs';
22
+ import {isAstryxInitialized} from './commands/agent-docs.mjs';
22
23
 
23
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
25
 
@@ -174,7 +175,7 @@ function fullCommandName(actionCommand) {
174
175
  *
175
176
  * If --json is set on a command that is not on the JSON_SUPPORTED allowlist,
176
177
  * emit a structured error envelope and exit 1 — without running the command's
177
- * action (so no filesystem mutations, no clack prompts, no spawned processes).
178
+ * action (so no filesystem mutations, no interactive prompts, no spawned processes).
178
179
  *
179
180
  * This is the single source of truth for "command does not support --json".
180
181
  * Individual commands should NOT re-check this; they may assume that if their
@@ -237,6 +238,40 @@ program.hook('postAction', (thisCommand, actionCommand) => {
237
238
  }
238
239
  });
239
240
 
241
+ /**
242
+ * Enforcement layer 3 — setup nudge. If this project hasn't run `astryx init`
243
+ * yet (no Astryx marker in any agent-doc file — see isAstryxInitialized), remind
244
+ * the user/agent that setup is missing.
245
+ *
246
+ * Uses `preAction` (not postAction) so it fires for EVERY valid command — even
247
+ * ones whose action errors or calls process.exit (postAction is skipped then).
248
+ *
249
+ * Suppressed in --json: that is machine output with a strict clean stdout+stderr
250
+ * contract (json-shim.test: "error envelopes have empty stderr"), and --json
251
+ * consumers parse stdout, not stderr — a stderr nudge would not reach them anyway.
252
+ * The core/cli postinstall layers already nudge at install time regardless of
253
+ * --json; a machine-readable nudge could later be an envelope field. Also skipped
254
+ * for the installer commands themselves and outside a project (no package.json).
255
+ */
256
+ const SETUP_NUDGE_EXEMPT = new Set(['init', 'agent-docs']);
257
+ program.hook('preAction', (thisCommand, actionCommand) => {
258
+ try {
259
+ if (program.opts().json) return; // machine mode — keep --json output clean
260
+ if (SETUP_NUDGE_EXEMPT.has(actionCommand.name())) return;
261
+ const cwd = process.cwd();
262
+ if (!fs.existsSync(path.join(cwd, 'package.json'))) return; // not a project
263
+ if (isAstryxInitialized(cwd)) return; // already set up — stay quiet
264
+ // Same wording as the core/cli postinstall nudges. #4151's getCliInvocation()
265
+ // renders the correct form for THIS project — scoped `npx @astryxdesign/cli`
266
+ // one-off, or `<pm> astryx` when installed — never the bare `npx astryx` footgun.
267
+ console.error(
268
+ `\nNext step: run \`${getCliInvocation(cwd)} init\` to finish setup and install the Astryx agent prompt.`,
269
+ );
270
+ } catch {
271
+ // Never let the nudge break a command.
272
+ }
273
+ });
274
+
240
275
  /**
241
276
  * Command registry — each command is lazy-loaded so a broken command
242
277
  * doesn't take down the entire CLI.
@@ -305,15 +340,14 @@ program
305
340
  console.log(` ${c.name}${tag}`);
306
341
  if (c.description) console.log(` ${c.description}`);
307
342
  }
308
- console.log(`\nRun \`astryx manifest --json\` for the full structured manifest.\n`);
343
+ console.log(`\nRun \`${getCliInvocation()} manifest --json\` for the full structured manifest.\n`);
309
344
  });
310
345
 
311
346
  // Hidden command used by package.json postinstall scripts
312
347
  program
313
348
  .command('postinstall', {hidden: true})
314
349
  .action(() => {
315
- const run = getRunPrefix();
316
- const r = `${run} xds`;
350
+ const r = getCliInvocation();
317
351
  const pad = (s, len) => s + ' '.repeat(Math.max(0, len - s.length));
318
352
  const W = 49; // inner width of the box
319
353
  const line = (s) => ` │ ${pad(s, W)}│`;
@@ -323,7 +357,7 @@ ${line('')}
323
357
  ${line(' Design system installed!')}
324
358
  ${line('')}
325
359
  ${line(' Get started:')}
326
- ${line(` ${r} init Interactive setup`)}
360
+ ${line(` ${r} init Setup + AI agent docs`)}
327
361
  ${line(` ${r} --help See all commands`)}
328
362
  ${line('')}
329
363
  ${line(' Or run directly:')}
@@ -55,8 +55,8 @@ import {ERROR_CODES} from './error-codes.mjs';
55
55
 
56
56
  /**
57
57
  * Suggestion object — matches the shape used by API errors and the JSON
58
- * envelope's `suggestions` field.
59
- * @typedef {{name: string, reason?: string}} Suggestion
58
+ * envelope's `suggestions` field. Canonical definition lives in types/base.
59
+ * @typedef {import('../types/base').Suggestion} Suggestion
60
60
  */
61
61
 
62
62
  /**
@@ -6,6 +6,7 @@
6
6
 
7
7
  import {discoverComponents, findComponentReadme, resolveImportPath} from './component-discovery.mjs';
8
8
  import {loadDocs} from './component-loader.mjs';
9
+ import {getCliInvocation} from '../utils/package-manager.mjs';
9
10
 
10
11
  /**
11
12
  * Derive the `defineTheme` component-override key from a theming target.
@@ -81,7 +82,7 @@ function formatSubComponent(comp) {
81
82
  if (table) {
82
83
  out.push(table + '\n');
83
84
  } else {
84
- out.push(`See \`astryx component ${comp.name}\` for props and usage.\n`);
85
+ out.push(`See \`${getCliInvocation()} component ${comp.name}\` for props and usage.\n`);
85
86
  }
86
87
  return out;
87
88
  }
@@ -102,7 +102,7 @@ export function buildHelpEnvelope(cmd) {
102
102
  * for both success and error).
103
103
  *
104
104
  * @param {string} message
105
- * @param {Array<{name: string, reason: string}>} [suggestions]
105
+ * @param {import('../types/base').Suggestion[]} [suggestions]
106
106
  * @param {string} [code] - Stable machine-readable error code (error-codes.mjs).
107
107
  */
108
108
  function emitJsonError(message, suggestions, code) {