@awarebydefault/display-case 1.0.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.
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/display-case.prompt.md +64 -0
- package/docs/ai-agents.md +126 -0
- package/docs/cli.md +99 -0
- package/docs/configuration.md +410 -0
- package/docs/documentation-panel.md +50 -0
- package/docs/examples/README.md +14 -0
- package/docs/examples/multi-variant.case.tsx +30 -0
- package/docs/examples/plain.case.tsx +22 -0
- package/docs/examples/tweak-control.placard.md +80 -0
- package/docs/examples/tweaks.case.tsx +39 -0
- package/docs/hierarchy.md +59 -0
- package/docs/quick-start.md +78 -0
- package/docs/style-engines.md +180 -0
- package/docs/testing.md +245 -0
- package/docs/theming.md +97 -0
- package/docs/tweaks.md +75 -0
- package/docs/writing-cases.md +144 -0
- package/docs/writing-placard-docs.md +194 -0
- package/package.json +113 -0
- package/skills/display-case-author-case/README.md +20 -0
- package/skills/display-case-author-case/SKILL.md +40 -0
- package/skills/display-case-author-placard-doc/README.md +24 -0
- package/skills/display-case-author-placard-doc/SKILL.md +65 -0
- package/skills/display-case-review/README.md +19 -0
- package/skills/display-case-review/SKILL.md +30 -0
- package/skills/display-case-snapshot/README.md +20 -0
- package/skills/display-case-snapshot/SKILL.md +29 -0
- package/src/checks/a11y-scanner.test.ts +240 -0
- package/src/checks/a11y-scanner.ts +410 -0
- package/src/checks/check-text.test.ts +53 -0
- package/src/checks/check-text.ts +78 -0
- package/src/checks/check.test.ts +194 -0
- package/src/checks/check.ts +473 -0
- package/src/checks/providers/pixelmatch-diff.test.ts +79 -0
- package/src/checks/providers/pixelmatch-diff.ts +30 -0
- package/src/checks/providers/playwright-driver.ts +104 -0
- package/src/checks/ssr-check.test.ts +73 -0
- package/src/checks/ssr-check.ts +96 -0
- package/src/checks/structure-check.cross-package.test.ts +165 -0
- package/src/checks/structure-check.test.ts +651 -0
- package/src/checks/structure-check.ts +988 -0
- package/src/checks/tokens-check.test.ts +159 -0
- package/src/checks/tokens-check.ts +162 -0
- package/src/cli.ts +218 -0
- package/src/commands/agents.test.ts +24 -0
- package/src/commands/agents.ts +28 -0
- package/src/commands/init-run.test.ts +123 -0
- package/src/commands/init.test.ts +63 -0
- package/src/commands/init.ts +412 -0
- package/src/commands/publish.test.ts +210 -0
- package/src/commands/publish.ts +292 -0
- package/src/core/affected.test.ts +99 -0
- package/src/core/affected.ts +144 -0
- package/src/core/catalog.test.ts +152 -0
- package/src/core/catalog.ts +92 -0
- package/src/core/discovery.test.ts +184 -0
- package/src/core/discovery.ts +250 -0
- package/src/core/manifest.ts +41 -0
- package/src/core/mdx-lite/__fixtures__/box-stub.tsx +7 -0
- package/src/core/mdx-lite/index.ts +393 -0
- package/src/core/mdx-lite/mdx-lite.test.ts +345 -0
- package/src/core/mdx-plugin.test.ts +60 -0
- package/src/core/mdx-plugin.ts +30 -0
- package/src/flow-step.test-d.ts +39 -0
- package/src/index.test.ts +100 -0
- package/src/index.ts +564 -0
- package/src/render/collect-styles.emotion.test.tsx +114 -0
- package/src/render/collect-styles.test.tsx +72 -0
- package/src/render/collect-styles.ts +33 -0
- package/src/render/documents.test.ts +184 -0
- package/src/render/documents.ts +88 -0
- package/src/render/render-node.test.tsx +160 -0
- package/src/render/render-node.tsx +133 -0
- package/src/render/ssr-primer.test.tsx +25 -0
- package/src/render/ssr-primer.tsx +54 -0
- package/src/render/ssr-render.test.tsx +142 -0
- package/src/render/ssr-render.tsx +63 -0
- package/src/render/ssr-shell.test.tsx +57 -0
- package/src/render/ssr-shell.tsx +54 -0
- package/src/server/prod-server.ts +237 -0
- package/src/server/server.test.ts +117 -0
- package/src/server/server.ts +1039 -0
- package/src/style-engine.test-d.ts +37 -0
- package/src/testing/test-helpers.ts +27 -0
- package/src/types/pixelmatch.d.ts +12 -0
- package/src/ui/browser-entry.tsx +51 -0
- package/src/ui/chrome.css +485 -0
- package/src/ui/design-system/README.md +88 -0
- package/src/ui/design-system/components/controls/Button.case.tsx +52 -0
- package/src/ui/design-system/components/controls/Button.css +89 -0
- package/src/ui/design-system/components/controls/Button.placard.md +14 -0
- package/src/ui/design-system/components/controls/Button.test.tsx +45 -0
- package/src/ui/design-system/components/controls/Button.tsx +41 -0
- package/src/ui/design-system/components/controls/IconButton.case.tsx +52 -0
- package/src/ui/design-system/components/controls/IconButton.css +67 -0
- package/src/ui/design-system/components/controls/IconButton.placard.md +13 -0
- package/src/ui/design-system/components/controls/IconButton.test.tsx +39 -0
- package/src/ui/design-system/components/controls/IconButton.tsx +47 -0
- package/src/ui/design-system/components/controls/Input.case.tsx +50 -0
- package/src/ui/design-system/components/controls/Input.css +52 -0
- package/src/ui/design-system/components/controls/Input.placard.md +12 -0
- package/src/ui/design-system/components/controls/Input.test.tsx +43 -0
- package/src/ui/design-system/components/controls/Input.tsx +45 -0
- package/src/ui/design-system/components/controls/Select.case.tsx +48 -0
- package/src/ui/design-system/components/controls/Select.css +44 -0
- package/src/ui/design-system/components/controls/Select.placard.md +15 -0
- package/src/ui/design-system/components/controls/Select.test.tsx +57 -0
- package/src/ui/design-system/components/controls/Select.tsx +58 -0
- package/src/ui/design-system/components/controls/SelectMenu.case.tsx +100 -0
- package/src/ui/design-system/components/controls/SelectMenu.css +72 -0
- package/src/ui/design-system/components/controls/SelectMenu.placard.md +18 -0
- package/src/ui/design-system/components/controls/SelectMenu.test.tsx +66 -0
- package/src/ui/design-system/components/controls/SelectMenu.tsx +377 -0
- package/src/ui/design-system/components/index.ts +66 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.case.tsx +44 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.tsx +51 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.case.tsx +38 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.tsx +41 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.case.tsx +24 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.placard.md +12 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.tsx +41 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.case.tsx +27 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.placard.md +13 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.tsx +34 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.case.tsx +36 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.placard.md +7 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.tsx +36 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.case.tsx +20 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.placard.md +12 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.tsx +33 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.case.tsx +56 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.placard.md +17 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.tsx +45 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.case.tsx +17 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.placard.md +16 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.tsx +39 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.case.tsx +26 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.tsx +42 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.case.tsx +23 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.placard.md +14 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.tsx +34 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.case.tsx +28 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.tsx +46 -0
- package/src/ui/design-system/components/primer-specimen/index.ts +31 -0
- package/src/ui/design-system/components/primer-specimen/styles.css +476 -0
- package/src/ui/design-system/components/shell/A11yPage.case.tsx +237 -0
- package/src/ui/design-system/components/shell/A11yPage.placard.md +15 -0
- package/src/ui/design-system/components/shell/CaseTemplate.case.tsx +32 -0
- package/src/ui/design-system/components/shell/CaseTemplate.placard.md +5 -0
- package/src/ui/design-system/components/shell/CasesPage.case.tsx +141 -0
- package/src/ui/design-system/components/shell/CasesPage.placard.md +12 -0
- package/src/ui/design-system/components/shell/PrimerPage.case.tsx +22 -0
- package/src/ui/design-system/components/shell/PrimerPage.placard.md +3 -0
- package/src/ui/design-system/components/shell/PrimerTemplate.case.tsx +22 -0
- package/src/ui/design-system/components/shell/PrimerTemplate.placard.md +5 -0
- package/src/ui/design-system/components/shell/ShellView.case.tsx +57 -0
- package/src/ui/design-system/components/shell/ShellView.placard.md +5 -0
- package/src/ui/design-system/components/shell/ShellView.tsx +678 -0
- package/src/ui/design-system/components/shell/shell-fixtures.tsx +727 -0
- package/src/ui/design-system/components/showcase/A11yBadge.case.tsx +46 -0
- package/src/ui/design-system/components/showcase/A11yBadge.css +27 -0
- package/src/ui/design-system/components/showcase/A11yBadge.placard.md +11 -0
- package/src/ui/design-system/components/showcase/A11yBadge.test.tsx +31 -0
- package/src/ui/design-system/components/showcase/A11yBadge.tsx +41 -0
- package/src/ui/design-system/components/showcase/A11yPanel.case.tsx +121 -0
- package/src/ui/design-system/components/showcase/A11yPanel.css +198 -0
- package/src/ui/design-system/components/showcase/A11yPanel.placard.md +19 -0
- package/src/ui/design-system/components/showcase/A11yPanel.test.tsx +81 -0
- package/src/ui/design-system/components/showcase/A11yPanel.tsx +144 -0
- package/src/ui/design-system/components/showcase/Chip.case.tsx +48 -0
- package/src/ui/design-system/components/showcase/Chip.css +51 -0
- package/src/ui/design-system/components/showcase/Chip.placard.md +13 -0
- package/src/ui/design-system/components/showcase/Chip.test.tsx +46 -0
- package/src/ui/design-system/components/showcase/Chip.tsx +54 -0
- package/src/ui/design-system/components/showcase/Eyebrow.case.tsx +30 -0
- package/src/ui/design-system/components/showcase/Eyebrow.css +16 -0
- package/src/ui/design-system/components/showcase/Eyebrow.placard.md +10 -0
- package/src/ui/design-system/components/showcase/Eyebrow.test.tsx +38 -0
- package/src/ui/design-system/components/showcase/Eyebrow.tsx +29 -0
- package/src/ui/design-system/components/showcase/FlowNav.case.tsx +35 -0
- package/src/ui/design-system/components/showcase/FlowNav.css +29 -0
- package/src/ui/design-system/components/showcase/FlowNav.placard.md +13 -0
- package/src/ui/design-system/components/showcase/FlowNav.test.tsx +48 -0
- package/src/ui/design-system/components/showcase/FlowNav.tsx +58 -0
- package/src/ui/design-system/components/showcase/ImpactTag.case.tsx +19 -0
- package/src/ui/design-system/components/showcase/ImpactTag.css +36 -0
- package/src/ui/design-system/components/showcase/ImpactTag.placard.md +14 -0
- package/src/ui/design-system/components/showcase/ImpactTag.test.tsx +40 -0
- package/src/ui/design-system/components/showcase/ImpactTag.tsx +35 -0
- package/src/ui/design-system/components/showcase/NavItem.case.tsx +86 -0
- package/src/ui/design-system/components/showcase/NavItem.css +111 -0
- package/src/ui/design-system/components/showcase/NavItem.placard.md +13 -0
- package/src/ui/design-system/components/showcase/NavItem.test.tsx +65 -0
- package/src/ui/design-system/components/showcase/NavItem.tsx +95 -0
- package/src/ui/design-system/components/showcase/RenderAddress.case.tsx +21 -0
- package/src/ui/design-system/components/showcase/RenderAddress.css +35 -0
- package/src/ui/design-system/components/showcase/RenderAddress.placard.md +7 -0
- package/src/ui/design-system/components/showcase/RenderAddress.test.tsx +26 -0
- package/src/ui/design-system/components/showcase/RenderAddress.tsx +43 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.case.tsx +84 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.css +61 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.placard.md +21 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.test.tsx +81 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.tsx +75 -0
- package/src/ui/design-system/components/showcase/Sidebar.case.tsx +67 -0
- package/src/ui/design-system/components/showcase/Sidebar.css +6 -0
- package/src/ui/design-system/components/showcase/Sidebar.placard.md +14 -0
- package/src/ui/design-system/components/showcase/Sidebar.test.tsx +32 -0
- package/src/ui/design-system/components/showcase/Sidebar.tsx +30 -0
- package/src/ui/design-system/components/showcase/Stage.case.tsx +51 -0
- package/src/ui/design-system/components/showcase/Stage.css +91 -0
- package/src/ui/design-system/components/showcase/Stage.placard.md +15 -0
- package/src/ui/design-system/components/showcase/Stage.test.tsx +84 -0
- package/src/ui/design-system/components/showcase/Stage.tsx +97 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.case.tsx +81 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.css +169 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.placard.md +20 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.tsx +230 -0
- package/src/ui/design-system/components/showcase/Wordmark.case.tsx +42 -0
- package/src/ui/design-system/components/showcase/Wordmark.css +31 -0
- package/src/ui/design-system/components/showcase/Wordmark.placard.md +10 -0
- package/src/ui/design-system/components/showcase/Wordmark.test.tsx +22 -0
- package/src/ui/design-system/components/showcase/Wordmark.tsx +22 -0
- package/src/ui/design-system/primer-specimens/brand.tsx +26 -0
- package/src/ui/design-system/primer-specimens/colors.tsx +83 -0
- package/src/ui/design-system/primer-specimens/components.tsx +308 -0
- package/src/ui/design-system/primer-specimens/foundations.tsx +71 -0
- package/src/ui/design-system/primer-specimens/index.ts +25 -0
- package/src/ui/design-system/primer-specimens/showcase.tsx +68 -0
- package/src/ui/design-system/primer-specimens/spacing.tsx +101 -0
- package/src/ui/design-system/primer-specimens/type.tsx +75 -0
- package/src/ui/design-system/primer.mdx +236 -0
- package/src/ui/design-system/styles.css +14 -0
- package/src/ui/design-system/tokens/colors.css +172 -0
- package/src/ui/design-system/tokens/fonts.css +18 -0
- package/src/ui/design-system/tokens/spacing.css +48 -0
- package/src/ui/design-system/tokens/typography.css +49 -0
- package/src/ui/markdown.test.tsx +54 -0
- package/src/ui/markdown.tsx +19 -0
- package/src/ui/primer-mount.tsx +76 -0
- package/src/ui/primer.css +175 -0
- package/src/ui/primer.tsx +277 -0
- package/src/ui/render-mount.tsx +284 -0
- package/src/ui/shell-core.test.ts +340 -0
- package/src/ui/shell-core.ts +295 -0
- package/src/ui/shell.tsx +60 -0
- package/src/ui/test-ids.ts +53 -0
- package/src/ui/use-shell.ts +1230 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { rm } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { makeTempDir, writeFiles } from '../testing/test-helpers'
|
|
6
|
+
import { runInit, runUninstall } from './init'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* End-to-end exercise of the scaffolder against a throwaway repo. A `.git`
|
|
10
|
+
* marker makes the temp dir its own repo root so `findRepoRoot` stops there and
|
|
11
|
+
* every artifact lands inside the sandbox.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const dirs: string[] = []
|
|
15
|
+
const opts = { agent: 'claude', json: false }
|
|
16
|
+
|
|
17
|
+
const makeRepo = async (extra: Record<string, string> = {}) => {
|
|
18
|
+
const dir = await makeTempDir()
|
|
19
|
+
dirs.push(dir)
|
|
20
|
+
await writeFiles(dir, { '.git/HEAD': 'ref: refs/heads/main\n', ...extra })
|
|
21
|
+
return dir
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
while (dirs.length)
|
|
26
|
+
await rm(dirs.pop() as string, { recursive: true, force: true })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('runInit / runUninstall', () => {
|
|
30
|
+
test('a dry run plans changes without writing anything', async () => {
|
|
31
|
+
const dir = await makeRepo()
|
|
32
|
+
const res = await runInit(dir, { ...opts, dryRun: true })
|
|
33
|
+
expect(res.command).toBe('init')
|
|
34
|
+
expect(existsSync(join(dir, '.claude/launch.json'))).toBe(false)
|
|
35
|
+
expect(existsSync(join(dir, 'AGENTS.md'))).toBe(false)
|
|
36
|
+
const launch = res.items.find((i) => i.artifact.endsWith('launch.json'))
|
|
37
|
+
expect(launch?.action).toBe('created')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('init writes the launch config and the instructions pointer', async () => {
|
|
41
|
+
const dir = await makeRepo()
|
|
42
|
+
await runInit(dir, { ...opts, dryRun: false })
|
|
43
|
+
expect(existsSync(join(dir, '.claude/launch.json'))).toBe(true)
|
|
44
|
+
const agents = await Bun.file(join(dir, 'AGENTS.md')).text()
|
|
45
|
+
expect(agents).toContain('## Display Case (for agents)')
|
|
46
|
+
expect(agents).toContain('display-case:agent-guide:start')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('re-running init is idempotent (the second run skips everything)', async () => {
|
|
50
|
+
const dir = await makeRepo()
|
|
51
|
+
await runInit(dir, { ...opts, dryRun: false })
|
|
52
|
+
const again = await runInit(dir, { ...opts, dryRun: false })
|
|
53
|
+
expect(again.items.every((i) => i.action === 'skipped')).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('init merges into an existing launch.json without clobbering entries', async () => {
|
|
57
|
+
const dir = await makeRepo({
|
|
58
|
+
'.claude/launch.json': `${JSON.stringify(
|
|
59
|
+
{ version: '0.0.1', configurations: [{ name: 'other', port: 9 }] },
|
|
60
|
+
null,
|
|
61
|
+
2,
|
|
62
|
+
)}\n`,
|
|
63
|
+
})
|
|
64
|
+
await runInit(dir, { ...opts, dryRun: false })
|
|
65
|
+
const launch = JSON.parse(
|
|
66
|
+
await Bun.file(join(dir, '.claude/launch.json')).text(),
|
|
67
|
+
)
|
|
68
|
+
const names = (launch.configurations as { name: string }[]).map(
|
|
69
|
+
(c) => c.name,
|
|
70
|
+
)
|
|
71
|
+
expect(names).toContain('other')
|
|
72
|
+
expect(names).toContain('display-case')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('init appends to an existing AGENTS.md, preserving prior content', async () => {
|
|
76
|
+
const dir = await makeRepo({ 'AGENTS.md': '# Project\n' })
|
|
77
|
+
await runInit(dir, { ...opts, dryRun: false })
|
|
78
|
+
const agents = await Bun.file(join(dir, 'AGENTS.md')).text()
|
|
79
|
+
expect(agents).toContain('# Project')
|
|
80
|
+
expect(agents).toContain('## Display Case (for agents)')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('uninstall removes exactly what init wrote and is idempotent', async () => {
|
|
84
|
+
const dir = await makeRepo()
|
|
85
|
+
await runInit(dir, { ...opts, dryRun: false })
|
|
86
|
+
|
|
87
|
+
const out = await runUninstall(dir, { ...opts, dryRun: false })
|
|
88
|
+
expect(out.command).toBe('uninstall')
|
|
89
|
+
|
|
90
|
+
const launch = JSON.parse(
|
|
91
|
+
await Bun.file(join(dir, '.claude/launch.json')).text(),
|
|
92
|
+
)
|
|
93
|
+
const entry = (launch.configurations as { name: string }[]).find(
|
|
94
|
+
(c) => c.name === 'display-case',
|
|
95
|
+
)
|
|
96
|
+
expect(entry).toBeUndefined()
|
|
97
|
+
|
|
98
|
+
const agents = await Bun.file(join(dir, 'AGENTS.md')).text()
|
|
99
|
+
expect(agents).not.toContain('display-case:agent-guide:start')
|
|
100
|
+
|
|
101
|
+
const again = await runUninstall(dir, { ...opts, dryRun: false })
|
|
102
|
+
expect(again.items.every((i) => i.action === 'skipped')).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('uninstall preserves an unrelated launch entry', async () => {
|
|
106
|
+
const dir = await makeRepo({
|
|
107
|
+
'.claude/launch.json': `${JSON.stringify(
|
|
108
|
+
{ version: '0.0.1', configurations: [{ name: 'other', port: 9 }] },
|
|
109
|
+
null,
|
|
110
|
+
2,
|
|
111
|
+
)}\n`,
|
|
112
|
+
})
|
|
113
|
+
await runInit(dir, { ...opts, dryRun: false })
|
|
114
|
+
await runUninstall(dir, { ...opts, dryRun: false })
|
|
115
|
+
const launch = JSON.parse(
|
|
116
|
+
await Bun.file(join(dir, '.claude/launch.json')).text(),
|
|
117
|
+
)
|
|
118
|
+
const names = (launch.configurations as { name: string }[]).map(
|
|
119
|
+
(c) => c.name,
|
|
120
|
+
)
|
|
121
|
+
expect(names).toEqual(['other'])
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { reconcilePointer } from './init'
|
|
3
|
+
|
|
4
|
+
// The sentinel contract reconcilePointer keys off of. Hardcoded here on purpose:
|
|
5
|
+
// the test pins the exact marker format that init writes and uninstall strips.
|
|
6
|
+
const START = '<!-- display-case:agent-guide:start -->'
|
|
7
|
+
const END = '<!-- display-case:agent-guide:end -->'
|
|
8
|
+
const block = (body: string) => `${START}\n## Display Case\n\n${body}\n${END}`
|
|
9
|
+
|
|
10
|
+
describe('reconcilePointer', () => {
|
|
11
|
+
test('creates the block in an empty file', () => {
|
|
12
|
+
const b = block('v1')
|
|
13
|
+
const r = reconcilePointer('', b)
|
|
14
|
+
expect(r.action).toBe('created')
|
|
15
|
+
expect(r.next).toBe(`${b}\n`)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('appends to existing content with a blank-line separator', () => {
|
|
19
|
+
const b = block('v1')
|
|
20
|
+
const r = reconcilePointer('# Project', b)
|
|
21
|
+
expect(r.action).toBe('created')
|
|
22
|
+
expect(r.next).toBe(`# Project\n\n${b}\n`)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('normalizes to one blank line when content already ends in a newline', () => {
|
|
26
|
+
const b = block('v1')
|
|
27
|
+
const r = reconcilePointer('# Project\n', b)
|
|
28
|
+
expect(r.action).toBe('created')
|
|
29
|
+
expect(r.next).toBe(`# Project\n\n${b}\n`)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('skips when the present block is byte-identical', () => {
|
|
33
|
+
const b = block('v1')
|
|
34
|
+
const current = `intro\n\n${b}\n`
|
|
35
|
+
const r = reconcilePointer(current, b)
|
|
36
|
+
expect(r.action).toBe('skipped')
|
|
37
|
+
expect(r.next).toBe(current) // unchanged → runInit writes nothing
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('refreshes a drifted block in place', () => {
|
|
41
|
+
const current = `intro\n\n${block('OLD wording')}\n`
|
|
42
|
+
const next = block('NEW wording')
|
|
43
|
+
const r = reconcilePointer(current, next)
|
|
44
|
+
expect(r.action).toBe('updated')
|
|
45
|
+
expect(r.next).toBe(`intro\n\n${next}\n`)
|
|
46
|
+
expect(r.next).not.toContain('OLD wording')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('replaces only the block, preserving text before and after', () => {
|
|
50
|
+
const current = `before\n${block('OLD')}\nafter\n`
|
|
51
|
+
const next = block('NEW')
|
|
52
|
+
const r = reconcilePointer(current, next)
|
|
53
|
+
expect(r.action).toBe('updated')
|
|
54
|
+
expect(r.next).toBe(`before\n${next}\nafter\n`)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('does not let a `$` in the block corrupt the replacement', () => {
|
|
58
|
+
const current = block('OLD')
|
|
59
|
+
const next = block('cost is $1 — see $& and $1 literally')
|
|
60
|
+
const r = reconcilePointer(current, next)
|
|
61
|
+
expect(r.next).toBe(next)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { mkdir, readdir, rm } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
4
|
+
import type { AgentTarget } from './agents'
|
|
5
|
+
import { AGENT_TARGETS } from './agents'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Scaffolds (and removes) Display Case's AI-agent integration in a target repo.
|
|
9
|
+
* Every write is idempotent and merge-aware; every removal is owns-only —
|
|
10
|
+
* artifacts are identified the same way they are written (a fixed launch
|
|
11
|
+
* `name`, the bundled skill ids, and sentinel-delimited instruction markers),
|
|
12
|
+
* so the two commands stay in lock-step without an install manifest.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const PKG_ROOT = resolve(import.meta.dir, '..', '..')
|
|
16
|
+
const SKILLS_SRC = join(PKG_ROOT, 'skills')
|
|
17
|
+
const PROMPT_FILE = join(PKG_ROOT, 'display-case.prompt.md')
|
|
18
|
+
|
|
19
|
+
const LAUNCH_NAME = 'display-case'
|
|
20
|
+
const SENTINEL_START = '<!-- display-case:agent-guide:start -->'
|
|
21
|
+
const SENTINEL_END = '<!-- display-case:agent-guide:end -->'
|
|
22
|
+
|
|
23
|
+
export type Action = 'created' | 'updated' | 'skipped' | 'removed'
|
|
24
|
+
|
|
25
|
+
export interface PlanItem {
|
|
26
|
+
artifact: string
|
|
27
|
+
action: Action
|
|
28
|
+
detail?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RunOptions {
|
|
32
|
+
agent: string
|
|
33
|
+
dryRun: boolean
|
|
34
|
+
json: boolean
|
|
35
|
+
/** init only: also set up the default visual-regression toolchain. */
|
|
36
|
+
withVisual?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const VISUAL_PACKAGES = [
|
|
40
|
+
'playwright',
|
|
41
|
+
'@axe-core/playwright',
|
|
42
|
+
'pixelmatch',
|
|
43
|
+
'pngjs',
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
async function setupVisualToolchain(repoRoot: string): Promise<void> {
|
|
47
|
+
const run = async (cmd: string[]) => {
|
|
48
|
+
const proc = Bun.spawn(cmd, {
|
|
49
|
+
cwd: repoRoot,
|
|
50
|
+
stdout: 'inherit',
|
|
51
|
+
stderr: 'inherit',
|
|
52
|
+
})
|
|
53
|
+
const code = await proc.exited
|
|
54
|
+
if (code !== 0)
|
|
55
|
+
throw new Error(`\`${cmd.join(' ')}\` failed (exit ${code})`)
|
|
56
|
+
}
|
|
57
|
+
await run(['bun', 'add', '--dev', ...VISUAL_PACKAGES])
|
|
58
|
+
await run(['bunx', 'playwright', 'install', 'chromium'])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RunResult {
|
|
62
|
+
command: 'init' | 'uninstall'
|
|
63
|
+
agent: string
|
|
64
|
+
dryRun: boolean
|
|
65
|
+
json: boolean
|
|
66
|
+
items: PlanItem[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findRepoRoot(start: string): string {
|
|
70
|
+
let dir = start
|
|
71
|
+
for (let i = 0; i < 12; i++) {
|
|
72
|
+
if (existsSync(join(dir, '.git'))) return dir
|
|
73
|
+
const parent = resolve(dir, '..')
|
|
74
|
+
if (parent === dir) break
|
|
75
|
+
dir = parent
|
|
76
|
+
}
|
|
77
|
+
return start
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function bundledSkillIds(): Promise<string[]> {
|
|
81
|
+
if (!existsSync(SKILLS_SRC)) return []
|
|
82
|
+
const entries = await readdir(SKILLS_SRC, { withFileTypes: true })
|
|
83
|
+
return entries
|
|
84
|
+
.filter((e) => e.isDirectory())
|
|
85
|
+
.map((e) => e.name)
|
|
86
|
+
.sort()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readJson(path: string): Promise<Record<string, unknown> | null> {
|
|
90
|
+
if (!(await Bun.file(path).exists())) return null
|
|
91
|
+
return JSON.parse(await Bun.file(path).text()) as Record<string, unknown>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveInstructionsFile(
|
|
95
|
+
repoRoot: string,
|
|
96
|
+
target: AgentTarget,
|
|
97
|
+
): string {
|
|
98
|
+
const found = target.instructionsFiles.find((f) =>
|
|
99
|
+
existsSync(join(repoRoot, f)),
|
|
100
|
+
)
|
|
101
|
+
return join(repoRoot, found ?? target.instructionsFiles[0])
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function launchEntry(repoRoot: string, pkgDir: string) {
|
|
105
|
+
const cliRel = relative(repoRoot, join(PKG_ROOT, 'src', 'cli.ts'))
|
|
106
|
+
const showcaseRel = relative(repoRoot, pkgDir)
|
|
107
|
+
return {
|
|
108
|
+
name: LAUNCH_NAME,
|
|
109
|
+
runtimeExecutable: 'bun',
|
|
110
|
+
// `--dev` enables watching Display Case's own chrome (not just the showcased
|
|
111
|
+
// package), so editing the shell hot-reloads even when `showcaseRel` points
|
|
112
|
+
// elsewhere. It's a no-op when showcasing Display Case itself.
|
|
113
|
+
runtimeArgs: [cliRel, showcaseRel, '--port=3100', '--dev'],
|
|
114
|
+
port: 3100,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function guideBlock(repoRoot: string): string {
|
|
119
|
+
const promptRel = relative(repoRoot, PROMPT_FILE)
|
|
120
|
+
const docsRel = relative(repoRoot, join(PKG_ROOT, 'docs', 'ai-agents.md'))
|
|
121
|
+
return [
|
|
122
|
+
SENTINEL_START,
|
|
123
|
+
'## Display Case (for agents)',
|
|
124
|
+
'',
|
|
125
|
+
'Browse the component showcase with `bun run display-case`; `--print-manifest` lists every component/case as JSON.',
|
|
126
|
+
'Snapshot one case in isolation at `/render/<component>/<case>?theme=light|dark` (chrome-free HTML).',
|
|
127
|
+
`See [${promptRel}](${promptRel}) for authoring and [${docsRel}](${docsRel}) for the agent workflow.`,
|
|
128
|
+
SENTINEL_END,
|
|
129
|
+
].join('\n')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Reconcile the sentinel-delimited agent-guide block in an instructions file
|
|
134
|
+
* against the freshly-rendered `block`. Pure — returns the action to report and
|
|
135
|
+
* the next file contents, so `runInit` only has to write when `next` changed.
|
|
136
|
+
*
|
|
137
|
+
* - No block present → append it (`created`).
|
|
138
|
+
* - Block present and byte-identical → `skipped`.
|
|
139
|
+
* - Block present but drifted → replace just that block in place (`updated`),
|
|
140
|
+
* so a re-init always converges the pointer to the bundled content.
|
|
141
|
+
*/
|
|
142
|
+
export function reconcilePointer(
|
|
143
|
+
current: string,
|
|
144
|
+
block: string,
|
|
145
|
+
): { action: Action; detail: string; next: string } {
|
|
146
|
+
if (current.includes(SENTINEL_START) && current.includes(SENTINEL_END)) {
|
|
147
|
+
const blockRe = new RegExp(`${SENTINEL_START}[\\s\\S]*?${SENTINEL_END}`)
|
|
148
|
+
if (current.match(blockRe)?.[0] === block) {
|
|
149
|
+
return {
|
|
150
|
+
action: 'skipped',
|
|
151
|
+
detail: 'pointer already up to date',
|
|
152
|
+
next: current,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Replace via a function so a `$` in the block is never read as a
|
|
156
|
+
// replacement pattern; the regex is non-global → only the first block.
|
|
157
|
+
return {
|
|
158
|
+
action: 'updated',
|
|
159
|
+
detail: 'refreshed agent-guide pointer',
|
|
160
|
+
next: current.replace(blockRe, () => block),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
let sep = ''
|
|
164
|
+
if (current.length) sep = current.endsWith('\n') ? '\n' : '\n\n'
|
|
165
|
+
return {
|
|
166
|
+
action: 'created',
|
|
167
|
+
detail: 'added agent-guide pointer',
|
|
168
|
+
next: `${current}${sep}${block}\n`,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Are two directories' files byte-identical (used to decide skip vs update)? */
|
|
173
|
+
async function dirsEqual(a: string, b: string): Promise<boolean> {
|
|
174
|
+
if (!existsSync(b)) return false
|
|
175
|
+
const list = async (d: string) =>
|
|
176
|
+
(await readdir(d, { withFileTypes: true }))
|
|
177
|
+
.filter((e) => e.isFile())
|
|
178
|
+
.map((e) => e.name)
|
|
179
|
+
.sort()
|
|
180
|
+
const [fa, fb] = await Promise.all([list(a), list(b)])
|
|
181
|
+
if (fa.join('|') !== fb.join('|')) return false
|
|
182
|
+
for (const name of fa) {
|
|
183
|
+
const [ta, tb] = await Promise.all([
|
|
184
|
+
Bun.file(join(a, name)).text(),
|
|
185
|
+
Bun.file(join(b, name)).text(),
|
|
186
|
+
])
|
|
187
|
+
if (ta !== tb) return false
|
|
188
|
+
}
|
|
189
|
+
return true
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function copyDir(src: string, dest: string): Promise<void> {
|
|
193
|
+
await mkdir(dest, { recursive: true })
|
|
194
|
+
for (const e of await readdir(src, { withFileTypes: true })) {
|
|
195
|
+
if (e.isFile())
|
|
196
|
+
await Bun.write(join(dest, e.name), Bun.file(join(src, e.name)))
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function runInit(
|
|
201
|
+
pkgDir: string,
|
|
202
|
+
opts: RunOptions,
|
|
203
|
+
): Promise<RunResult> {
|
|
204
|
+
const target = AGENT_TARGETS[opts.agent] as AgentTarget
|
|
205
|
+
const repoRoot = findRepoRoot(pkgDir)
|
|
206
|
+
const items: PlanItem[] = []
|
|
207
|
+
|
|
208
|
+
// 1. Launch config — merge, never clobber other entries.
|
|
209
|
+
const launchPath = join(repoRoot, target.launchConfigPath)
|
|
210
|
+
const existing = await readJson(launchPath)
|
|
211
|
+
const config = existing ?? { version: '0.0.1', configurations: [] }
|
|
212
|
+
const configs =
|
|
213
|
+
(config.configurations as Record<string, unknown>[] | undefined) ?? []
|
|
214
|
+
const entry = launchEntry(repoRoot, pkgDir)
|
|
215
|
+
const idx = configs.findIndex((c) => c.name === LAUNCH_NAME)
|
|
216
|
+
if (idx === -1) {
|
|
217
|
+
configs.push(entry)
|
|
218
|
+
items.push({
|
|
219
|
+
artifact: target.launchConfigPath,
|
|
220
|
+
action: 'created',
|
|
221
|
+
detail: `added "${LAUNCH_NAME}" entry`,
|
|
222
|
+
})
|
|
223
|
+
} else if (JSON.stringify(configs[idx]) !== JSON.stringify(entry)) {
|
|
224
|
+
configs[idx] = entry
|
|
225
|
+
items.push({
|
|
226
|
+
artifact: target.launchConfigPath,
|
|
227
|
+
action: 'updated',
|
|
228
|
+
detail: `refreshed "${LAUNCH_NAME}" entry`,
|
|
229
|
+
})
|
|
230
|
+
} else {
|
|
231
|
+
items.push({
|
|
232
|
+
artifact: target.launchConfigPath,
|
|
233
|
+
action: 'skipped',
|
|
234
|
+
detail: 'entry already present',
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
config.configurations = configs
|
|
238
|
+
if (!opts.dryRun && items[items.length - 1].action !== 'skipped') {
|
|
239
|
+
await mkdir(dirname(launchPath), { recursive: true })
|
|
240
|
+
await Bun.write(launchPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 2. Skills — copy each bundled skill, skip identical, update changed.
|
|
244
|
+
for (const id of await bundledSkillIds()) {
|
|
245
|
+
const src = join(SKILLS_SRC, id)
|
|
246
|
+
const dest = join(repoRoot, target.skillsDir, id)
|
|
247
|
+
const rel = join(target.skillsDir, id)
|
|
248
|
+
if (await dirsEqual(src, dest)) {
|
|
249
|
+
items.push({ artifact: rel, action: 'skipped', detail: 'identical' })
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
const action: Action = existsSync(dest) ? 'updated' : 'created'
|
|
253
|
+
items.push({ artifact: rel, action })
|
|
254
|
+
if (!opts.dryRun) await copyDir(src, dest)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 3. Instructions pointer — sentinel-marked, idempotent, and self-refreshing.
|
|
258
|
+
// reconcilePointer converges an existing block to the bundled content (like
|
|
259
|
+
// the skills above): byte-identical → skipped; drifted → replaced in place.
|
|
260
|
+
const instrPath = resolveInstructionsFile(repoRoot, target)
|
|
261
|
+
const instrRel = relative(repoRoot, instrPath)
|
|
262
|
+
const current = (await Bun.file(instrPath).exists())
|
|
263
|
+
? await Bun.file(instrPath).text()
|
|
264
|
+
: ''
|
|
265
|
+
const { action, detail, next } = reconcilePointer(
|
|
266
|
+
current,
|
|
267
|
+
guideBlock(repoRoot),
|
|
268
|
+
)
|
|
269
|
+
items.push({ artifact: instrRel, action, detail })
|
|
270
|
+
if (!opts.dryRun && next !== current) await Bun.write(instrPath, next)
|
|
271
|
+
|
|
272
|
+
// 4. Optional visual-regression toolchain (opt-in).
|
|
273
|
+
if (opts.withVisual) {
|
|
274
|
+
items.push({
|
|
275
|
+
artifact: 'visual-regression toolchain',
|
|
276
|
+
action: 'created',
|
|
277
|
+
detail: 'Playwright Chromium + pixelmatch/pngjs',
|
|
278
|
+
})
|
|
279
|
+
if (!opts.dryRun) await setupVisualToolchain(repoRoot)
|
|
280
|
+
} else {
|
|
281
|
+
items.push({
|
|
282
|
+
artifact: 'visual-regression toolchain',
|
|
283
|
+
action: 'skipped',
|
|
284
|
+
detail: 'run `init --with-visual` to set up',
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
command: 'init',
|
|
290
|
+
agent: opts.agent,
|
|
291
|
+
dryRun: opts.dryRun,
|
|
292
|
+
json: opts.json,
|
|
293
|
+
items,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function runUninstall(
|
|
298
|
+
pkgDir: string,
|
|
299
|
+
opts: RunOptions,
|
|
300
|
+
): Promise<RunResult> {
|
|
301
|
+
const target = AGENT_TARGETS[opts.agent] as AgentTarget
|
|
302
|
+
const repoRoot = findRepoRoot(pkgDir)
|
|
303
|
+
const items: PlanItem[] = []
|
|
304
|
+
|
|
305
|
+
// 1. Launch config — drop only our entry; keep the file and other entries.
|
|
306
|
+
const launchPath = join(repoRoot, target.launchConfigPath)
|
|
307
|
+
const config = await readJson(launchPath)
|
|
308
|
+
if (config) {
|
|
309
|
+
const configs =
|
|
310
|
+
(config.configurations as Record<string, unknown>[] | undefined) ?? []
|
|
311
|
+
const idx = configs.findIndex((c) => c.name === LAUNCH_NAME)
|
|
312
|
+
if (idx === -1) {
|
|
313
|
+
items.push({
|
|
314
|
+
artifact: target.launchConfigPath,
|
|
315
|
+
action: 'skipped',
|
|
316
|
+
detail: 'no entry to remove',
|
|
317
|
+
})
|
|
318
|
+
} else {
|
|
319
|
+
configs.splice(idx, 1)
|
|
320
|
+
config.configurations = configs
|
|
321
|
+
items.push({
|
|
322
|
+
artifact: target.launchConfigPath,
|
|
323
|
+
action: 'removed',
|
|
324
|
+
detail: `removed "${LAUNCH_NAME}" entry`,
|
|
325
|
+
})
|
|
326
|
+
if (!opts.dryRun)
|
|
327
|
+
await Bun.write(launchPath, `${JSON.stringify(config, null, 2)}\n`)
|
|
328
|
+
}
|
|
329
|
+
} else {
|
|
330
|
+
items.push({
|
|
331
|
+
artifact: target.launchConfigPath,
|
|
332
|
+
action: 'skipped',
|
|
333
|
+
detail: 'no launch config',
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 2. Skills — remove only directories matching bundled skill ids.
|
|
338
|
+
for (const id of await bundledSkillIds()) {
|
|
339
|
+
const dest = join(repoRoot, target.skillsDir, id)
|
|
340
|
+
const rel = join(target.skillsDir, id)
|
|
341
|
+
if (existsSync(dest)) {
|
|
342
|
+
items.push({ artifact: rel, action: 'removed' })
|
|
343
|
+
if (!opts.dryRun) await rm(dest, { recursive: true, force: true })
|
|
344
|
+
} else {
|
|
345
|
+
items.push({ artifact: rel, action: 'skipped', detail: 'not installed' })
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 3. Instructions pointer — strip only the sentinel-delimited block.
|
|
350
|
+
const instrPath = resolveInstructionsFile(repoRoot, target)
|
|
351
|
+
const instrRel = relative(repoRoot, instrPath)
|
|
352
|
+
const current = (await Bun.file(instrPath).exists())
|
|
353
|
+
? await Bun.file(instrPath).text()
|
|
354
|
+
: ''
|
|
355
|
+
if (current.includes(SENTINEL_START) && current.includes(SENTINEL_END)) {
|
|
356
|
+
const re = new RegExp(
|
|
357
|
+
`\\n*${SENTINEL_START}[\\s\\S]*?${SENTINEL_END}\\n*`,
|
|
358
|
+
'g',
|
|
359
|
+
)
|
|
360
|
+
const stripped = `${current
|
|
361
|
+
.replace(re, '\n')
|
|
362
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
363
|
+
.trimEnd()}\n`
|
|
364
|
+
items.push({
|
|
365
|
+
artifact: instrRel,
|
|
366
|
+
action: 'removed',
|
|
367
|
+
detail: 'removed agent-guide pointer',
|
|
368
|
+
})
|
|
369
|
+
if (!opts.dryRun) await Bun.write(instrPath, stripped)
|
|
370
|
+
} else {
|
|
371
|
+
items.push({
|
|
372
|
+
artifact: instrRel,
|
|
373
|
+
action: 'skipped',
|
|
374
|
+
detail: 'no pointer present',
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
command: 'uninstall',
|
|
380
|
+
agent: opts.agent,
|
|
381
|
+
dryRun: opts.dryRun,
|
|
382
|
+
json: opts.json,
|
|
383
|
+
items,
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const MARK: Record<Action, string> = {
|
|
388
|
+
created: '+',
|
|
389
|
+
updated: '~',
|
|
390
|
+
removed: '-',
|
|
391
|
+
skipped: '·',
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Print the result as a human report, or JSON when requested. */
|
|
395
|
+
export function report(result: RunResult): void {
|
|
396
|
+
if (result.json) {
|
|
397
|
+
console.log(JSON.stringify(result, null, 2))
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
const verb = result.command === 'init' ? 'init' : 'uninstall'
|
|
401
|
+
const dry = result.dryRun ? ' (dry run)' : ''
|
|
402
|
+
console.log(`\n display-case ${verb} → ${result.agent}${dry}`)
|
|
403
|
+
for (const it of result.items) {
|
|
404
|
+
console.log(
|
|
405
|
+
` ${MARK[it.action]} ${it.action.padEnd(7)} ${it.artifact}${it.detail ? ` (${it.detail})` : ''}`,
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
const changed = result.items.filter((i) => i.action !== 'skipped').length
|
|
409
|
+
console.log(
|
|
410
|
+
` ${changed} change(s)${result.dryRun ? ' planned' : ''}, ${result.items.length - changed} skipped\n`,
|
|
411
|
+
)
|
|
412
|
+
}
|