basalt-ui 1.40.2 → 1.41.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/AGENTS.md +4 -2
- package/CHANGELOG.md +22 -0
- package/MIGRATING.md +40 -12
- package/README.md +1 -1
- package/agent/rules/basalt-batteries.md +1 -1
- package/agent/skills/basalt-app/SKILL.md +1 -1
- package/agent/skills/basalt-design/SKILL.md +1 -1
- package/agent/templates/DESIGN.md.tpl +1 -1
- package/configs/check.yml +1 -1
- package/configs/oxlint-plugin.js +5 -5
- package/configs/oxlint-plugin.test.ts +1 -1
- package/dist/agent/foreign.d.ts +3 -2
- package/dist/agent/foreign.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
- package/dist/charts/primitives/HoverOverlay.js +2 -1
- package/dist/cli/doctor.d.ts +5 -2
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +29 -0
- package/dist/cli/index.d.ts +61 -4
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +112 -6
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +10 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +18 -1
- package/dist/commands/shortcut-format.d.ts +9 -4
- package/dist/commands/shortcut-format.d.ts.map +1 -1
- package/dist/commands/shortcut-format.js +35 -5
- package/dist/content/copy-action.d.ts +1 -1
- package/dist/content/glyphs.d.ts +1 -1
- package/dist/data/virtual-list.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +14 -1
- package/dist/theme/segmented-control.module.css +8 -0
- package/dist/tokens/derive.d.ts.map +1 -1
- package/dist/tokens/derive.js +3 -1
- package/dist/tokens.css +1 -1
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/agent/foreign.ts +3 -2
- package/src/charts/index.ts +2 -2
- package/src/charts/primitives/HoverOverlay.tsx +2 -1
- package/src/cli/doctor.ts +47 -4
- package/src/cli/index.ts +180 -11
- package/src/cli/init.ts +14 -0
- package/src/cli/sync.ts +24 -1
- package/src/commands/shortcut-format.ts +49 -10
- package/src/content/copy-action.tsx +1 -1
- package/src/content/glyphs.tsx +1 -1
- package/src/data/virtual-list.tsx +1 -1
- package/src/guard/index.ts +1 -1
- package/src/theme/index.ts +17 -1
- package/src/theme/segmented-control.module.css +8 -0
- package/src/tokens/derive.ts +3 -1
- /package/agent/templates/{CLAUDE-block.md.tpl → AGENTS-block.md.tpl} +0 -0
package/src/cli/doctor.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { relative, resolve } from 'node:path'
|
|
|
8
8
|
|
|
9
9
|
import type { BasaltConfig } from './config'
|
|
10
10
|
import {
|
|
11
|
+
AGENTS_MD,
|
|
12
|
+
LEGACY_BLOCK_HOST,
|
|
11
13
|
MANIFEST_PATH,
|
|
12
14
|
basaltBinCommand,
|
|
13
15
|
basaltPresetEntry,
|
|
@@ -15,6 +17,7 @@ import {
|
|
|
15
17
|
declaredProfile,
|
|
16
18
|
findBasaltInstall,
|
|
17
19
|
findRepoRoot,
|
|
20
|
+
importsAgentsMd,
|
|
18
21
|
inspectLefthookGate,
|
|
19
22
|
OXLINT_FILES,
|
|
20
23
|
OXLINT_NO_BIN,
|
|
@@ -24,12 +27,23 @@ import {
|
|
|
24
27
|
readIfExists,
|
|
25
28
|
readManifest,
|
|
26
29
|
resolveProjectDir,
|
|
30
|
+
sameFile,
|
|
27
31
|
scannableFiles,
|
|
32
|
+
scanBlock,
|
|
28
33
|
shippedAssetPath,
|
|
29
34
|
} from './index'
|
|
30
35
|
import { resolveRoots } from './config'
|
|
31
36
|
import { findManifestAbove, parentInstallAdvice } from './sync'
|
|
32
37
|
|
|
38
|
+
/** `sameFile`, but doctor never crashes: a path that cannot be inspected is "not provably the same". */
|
|
39
|
+
function isSameFile(a: string, b: string): boolean {
|
|
40
|
+
try {
|
|
41
|
+
return sameFile(a, b)
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
export type DoctorResult = {
|
|
34
48
|
/** Number of hard failures (exit non-zero). */
|
|
35
49
|
hardFailures: number
|
|
@@ -108,7 +122,7 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
|
|
|
108
122
|
* Check a consumer repo's basalt integration and print a pass/warn report.
|
|
109
123
|
*
|
|
110
124
|
* Narrowed to what a single-directory-scoped run can actually verify — no workspace-glob walking,
|
|
111
|
-
* no ascend/descend project discovery (see {@link resolveProjectDir}).
|
|
125
|
+
* no ascend/descend project discovery (see {@link resolveProjectDir}). Seven checks, numbered in
|
|
112
126
|
* the order they run and print:
|
|
113
127
|
*
|
|
114
128
|
* Hard failures (exit non-zero):
|
|
@@ -122,7 +136,7 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
|
|
|
122
136
|
* preset AND that path resolves (warns when no `oxlint` bin does). `init` keeps an existing config, so the framework's whole
|
|
123
137
|
* lint half can be silently off — one repo carried six real violations invisibly across five
|
|
124
138
|
* minors this way.
|
|
125
|
-
*
|
|
139
|
+
* 7. `ai-major-parity`: within THIS package.json, `dependencies`/`devDependencies`/
|
|
126
140
|
* `peerDependencies` agree on the `ai` package's major — the shape `basalt/ai-sdk-major` (per
|
|
127
141
|
* linted FILE) cannot see. A skew is exempt-able via `basalt.aiMajorSkewReason` (a non-empty
|
|
128
142
|
* reason, not a bare `true`); a declared reason when the majors agree warns it is stale.
|
|
@@ -134,6 +148,9 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
|
|
|
134
148
|
* gate elsewhere); a config that resolves but wires no guard command warns; a config lefthook
|
|
135
149
|
* dump could not read at all (binary missing, or the dump failed) warns and says so, rather
|
|
136
150
|
* than inventing a verdict.
|
|
151
|
+
* 6. `claude-md`: a project-root `CLAUDE.md` that does not import `@AGENTS.md` suppresses
|
|
152
|
+
* `AGENTS.md` (and with it the managed block); one that still carries a basalt block — or a
|
|
153
|
+
* malformed one — says so. A `CLAUDE.md` symlinked to `AGENTS.md` is one file and passes.
|
|
137
154
|
*
|
|
138
155
|
* Returns the exit code: 0 = all good, 1 = one or more hard failures.
|
|
139
156
|
*/
|
|
@@ -193,7 +210,7 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
193
210
|
|
|
194
211
|
const install = findBasaltInstall(cwd)
|
|
195
212
|
|
|
196
|
-
// ── Hard check 2: install matches the manifest, and doctor says WHERE (the
|
|
213
|
+
// ── Hard check 2: install matches the manifest, and doctor says WHERE (the AGENTS-block's claim)
|
|
197
214
|
const manifestVersion = profile === 'tokens-only' ? undefined : readManifest(cwd).basaltVersion
|
|
198
215
|
const at = install.dir === null ? '' : `${relative(cwd, install.dir)} (${install.version})`
|
|
199
216
|
if (install.dir === null) {
|
|
@@ -300,7 +317,33 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
|
|
|
300
317
|
)
|
|
301
318
|
}
|
|
302
319
|
|
|
303
|
-
// ──
|
|
320
|
+
// ── Warn check 6: a project-root CLAUDE.md that shadows AGENTS.md ───────────
|
|
321
|
+
// The managed block lives in AGENTS.md. Claude Code loads a repo-root AGENTS.md directly, but ANY
|
|
322
|
+
// CLAUDE.md in or above the cwd suppresses it unless that file imports `@AGENTS.md` — so a
|
|
323
|
+
// leftover CLAUDE.md means the block (and the rest of AGENTS.md) is not loaded, whether or not it
|
|
324
|
+
// still carries a block. A symlink to AGENTS.md is one file and is fine. Warn only: `sync`
|
|
325
|
+
// migrates the block, `sync --check` is the gate, and what else CLAUDE.md holds is the consumer's.
|
|
326
|
+
const legacyHost = resolve(cwd, LEGACY_BLOCK_HOST)
|
|
327
|
+
const legacy = profile === 'tokens-only' ? null : readIfExists(legacyHost)
|
|
328
|
+
if (legacy !== null && !isSameFile(legacyHost, resolve(cwd, AGENTS_MD))) {
|
|
329
|
+
const scan = scanBlock(legacy)
|
|
330
|
+
const issues: string[] = []
|
|
331
|
+
if (scan.problem !== null) {
|
|
332
|
+
issues.push(`has a malformed basalt block (${scan.problem}) — fix it by hand, then \`sync\``)
|
|
333
|
+
} else if (scan.region !== null) {
|
|
334
|
+
issues.push(`still carries a stale basalt block — run \`basalt-ui sync\` to move it`)
|
|
335
|
+
}
|
|
336
|
+
if (!importsAgentsMd(legacy)) {
|
|
337
|
+
issues.push(
|
|
338
|
+
`does not import \`@${AGENTS_MD}\`, so Claude Code loads it INSTEAD of ${AGENTS_MD} and the ` +
|
|
339
|
+
`basalt block never reaches the agent — move its content into ${AGENTS_MD} and delete it, ` +
|
|
340
|
+
`or add \`@${AGENTS_MD}\``,
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
if (issues.length > 0) warn(`${LEGACY_BLOCK_HOST} ${issues.join('; and ')}.`)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Hard check 7: ai package major version parity within THIS package.json ─
|
|
304
347
|
// basalt/ai-sdk-major (the lint rule) is per-file against the NEAREST package.json, so a lint
|
|
305
348
|
// run scoped to one workspace package only ever sees that package's own `ai` major and is
|
|
306
349
|
// perfectly happy. This check reads the SAME package.json across its dependencies/
|
package/src/cli/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
readFileSync,
|
|
15
15
|
rmdirSync,
|
|
16
16
|
statSync,
|
|
17
|
+
realpathSync,
|
|
17
18
|
unlinkSync,
|
|
18
19
|
writeFileSync,
|
|
19
20
|
} from 'node:fs'
|
|
@@ -338,7 +339,7 @@ export function resolveProjectDir(cwd: string): ProjectResolution {
|
|
|
338
339
|
/**
|
|
339
340
|
* Which shape of consumer a project-scoped command is looking at — DECLARED, never inferred
|
|
340
341
|
* (`--tokens-only`, or `"basalt": { "profile": "tokens-only" }`), because the answer switches the
|
|
341
|
-
* Mantine-remedy guard kinds off. Why that direction: package
|
|
342
|
+
* Mantine-remedy guard kinds off. Why that direction: package MAINTAINING.md § CLI.
|
|
342
343
|
*/
|
|
343
344
|
export function declaredProfile(cfg: BasaltConfig, flags: readonly string[]): DoctorProfile {
|
|
344
345
|
if (flags.includes('--tokens-only')) return 'tokens-only'
|
|
@@ -452,7 +453,7 @@ export function waiverHintFor(relPath: string, profile: DoctorProfile): string {
|
|
|
452
453
|
*
|
|
453
454
|
* - `managed` : basalt owns it. sync refreshes it to the shipped version; a local edit is skipped
|
|
454
455
|
* and reported (`--force` overwrites); `--check` exits 1 on drift. Applies to exactly
|
|
455
|
-
* what Claude reads (.claude/rules/*, .claude/skills/*, the
|
|
456
|
+
* what Claude reads (.claude/rules/*, .claude/skills/*, the AGENTS.md block) —
|
|
456
457
|
* Claude Code cannot load rules or skills from node_modules, and that platform limit
|
|
457
458
|
* is the only reason anything is copied at all. The sync diff is the review gate.
|
|
458
459
|
* - `seed` : written once if absent, then owned entirely by the consumer. sync never overwrites
|
|
@@ -462,7 +463,7 @@ export function waiverHintFor(relPath: string, profile: DoctorProfile): string {
|
|
|
462
463
|
* the package while the consumer still owns the file.
|
|
463
464
|
*
|
|
464
465
|
* A managed file with `markers: true` is spliced as a `<!-- basalt:begin -->…<!-- basalt:end -->`
|
|
465
|
-
* region inside a host file the consumer otherwise owns (
|
|
466
|
+
* region inside a host file the consumer otherwise owns (AGENTS.md) — not a third mode, just
|
|
466
467
|
* managed ownership scoped to a region.
|
|
467
468
|
*/
|
|
468
469
|
type Mode = 'managed' | 'seed'
|
|
@@ -518,11 +519,15 @@ type TemplateVars = {
|
|
|
518
519
|
BASALT_BIN: string
|
|
519
520
|
}
|
|
520
521
|
|
|
521
|
-
// The block markers the
|
|
522
|
+
// The block markers the AGENTS.md template emits. The begin marker carries the framework version
|
|
522
523
|
// (`<!-- basalt:begin 1.0.0 -->`), so the region is matched by the begin PREFIX, not an exact string.
|
|
523
524
|
const BLOCK_BEGIN_PREFIX = '<!-- basalt:begin'
|
|
524
525
|
const BLOCK_END = '<!-- basalt:end -->'
|
|
525
526
|
export const MANIFEST_PATH = '.basalt/manifest.json'
|
|
527
|
+
/** The host file the managed block is spliced into (and its manifest key). */
|
|
528
|
+
export const AGENTS_MD = 'AGENTS.md'
|
|
529
|
+
/** Where the block lived before it moved to {@link AGENTS_MD} — read only to migrate out of it. */
|
|
530
|
+
export const LEGACY_BLOCK_HOST = 'CLAUDE.md'
|
|
526
531
|
|
|
527
532
|
export function sha256(text: string): string {
|
|
528
533
|
return createHash('sha256').update(text, 'utf8').digest('hex')
|
|
@@ -633,6 +638,156 @@ function applyBlock(host: string, block: string): string {
|
|
|
633
638
|
return base.length === 0 ? `${block.trim()}\n` : `${base}\n\n${block.trim()}\n`
|
|
634
639
|
}
|
|
635
640
|
|
|
641
|
+
/** True when both paths resolve to one file (a symlinked pair). A missing path is simply "no". */
|
|
642
|
+
export function sameFile(a: string, b: string): boolean {
|
|
643
|
+
try {
|
|
644
|
+
return realpathSync(a) === realpathSync(b)
|
|
645
|
+
} catch (error) {
|
|
646
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
|
|
647
|
+
throw error
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** True when the host text carries a managed block (begin marker present). */
|
|
652
|
+
export function hasManagedBlock(host: string | null): boolean {
|
|
653
|
+
return host !== null && host.includes(BLOCK_BEGIN_PREFIX)
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* The block region of a host, or why it cannot be read: duplicate begin markers, or a begin marker
|
|
658
|
+
* with no end. The ONE detector `migrateBlockToAgentsMd` and `doctor` share, so they cannot disagree
|
|
659
|
+
* about whether a `CLAUDE.md` is well-formed.
|
|
660
|
+
*/
|
|
661
|
+
export function scanBlock(host: string): {
|
|
662
|
+
region: { start: number; end: number } | null
|
|
663
|
+
problem: string | null
|
|
664
|
+
} {
|
|
665
|
+
const begin = hasManagedBlock(host)
|
|
666
|
+
if (!begin && !host.includes(BLOCK_END)) return { region: null, problem: null }
|
|
667
|
+
const bad = (problem: string): { region: null; problem: string } => ({ region: null, problem })
|
|
668
|
+
if (!begin) return bad(`\`${BLOCK_END}\` with no \`${BLOCK_BEGIN_PREFIX}\``)
|
|
669
|
+
let region: { start: number; end: number } | null
|
|
670
|
+
try {
|
|
671
|
+
region = findBlockRegion(host)
|
|
672
|
+
} catch (error) {
|
|
673
|
+
return bad((error as Error).message)
|
|
674
|
+
}
|
|
675
|
+
if (region === null) return bad(`\`${BLOCK_BEGIN_PREFIX}\` has no matching \`${BLOCK_END}\``)
|
|
676
|
+
// Exactly one end marker, after the begin: an orphan or extra one means the region is a guess.
|
|
677
|
+
if (host.indexOf(BLOCK_END) < region.start || host.indexOf(BLOCK_END, region.end) !== -1) {
|
|
678
|
+
return bad(`an extra or misplaced \`${BLOCK_END}\` outside the basalt block`)
|
|
679
|
+
}
|
|
680
|
+
return { region, problem: null }
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** One line that is exactly the `@AGENTS.md` import — not a prose or code-span mention, not `.bak`. */
|
|
684
|
+
export function isAgentsImportLine(line: string): boolean {
|
|
685
|
+
return line.trim() === `@${AGENTS_MD}`
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Whether a `CLAUDE.md` imports `AGENTS.md`, so Claude Code still loads it. */
|
|
689
|
+
export function importsAgentsMd(text: string): boolean {
|
|
690
|
+
return text.split(/\r?\n/).some(isAgentsImportLine)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export type BlockMigration = {
|
|
694
|
+
/** The legacy host (`CLAUDE.md`) carried a managed block that has to move. */
|
|
695
|
+
pending: boolean
|
|
696
|
+
/** The legacy host was left empty (or `@AGENTS.md` only) and removed. */
|
|
697
|
+
deletedHost: boolean
|
|
698
|
+
/** Why nothing was moved and the run must fail: malformed markers, or two differing blocks. */
|
|
699
|
+
problem: string | null
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Move the managed block out of `CLAUDE.md` into `AGENTS.md` (an install from before the move).
|
|
704
|
+
* Idempotent: a second run finds nothing and returns `pending: false`.
|
|
705
|
+
* 1. The ledger entry is re-keyed `CLAUDE.md` → `AGENTS.md` in memory (the caller persists it), so
|
|
706
|
+
* an unedited legacy block classifies `unchanged`, not untracked.
|
|
707
|
+
* 2. The region is cut out of `CLAUDE.md` and — unless `AGENTS.md` already has one — spliced into
|
|
708
|
+
* `AGENTS.md` AS IT IS, not as the new rendering: the normal three-way classify then sees a
|
|
709
|
+
* hand-edit and skips it like any drifted unit. The rest of `CLAUDE.md` is consumer-owned and
|
|
710
|
+
* stays, except the file is deleted when nothing but whitespace or `@AGENTS.md` is left.
|
|
711
|
+
* 3. Nothing is discarded silently: if `AGENTS.md` already has a block that differs (normalized,
|
|
712
|
+
* as the ledger compares) from the legacy one, or either file's markers are malformed, BOTH
|
|
713
|
+
* files are left byte-identical and `problem` says what to resolve by hand.
|
|
714
|
+
* `dryRun` (`sync --check`) writes nothing but still reports `pending` and `problem`.
|
|
715
|
+
*/
|
|
716
|
+
export function migrateBlockToAgentsMd(
|
|
717
|
+
cwd: string,
|
|
718
|
+
manifest: Manifest,
|
|
719
|
+
opts: { dryRun?: boolean } = {},
|
|
720
|
+
): BlockMigration {
|
|
721
|
+
const legacyHash = manifest.files[LEGACY_BLOCK_HOST]
|
|
722
|
+
if (legacyHash !== undefined) {
|
|
723
|
+
manifest.files[AGENTS_MD] ??= legacyHash
|
|
724
|
+
delete manifest.files[LEGACY_BLOCK_HOST]
|
|
725
|
+
}
|
|
726
|
+
const none: BlockMigration = { pending: false, deletedHost: false, problem: null }
|
|
727
|
+
const legacyAbs = resolve(cwd, LEGACY_BLOCK_HOST)
|
|
728
|
+
const agentsAbs = resolve(cwd, AGENTS_MD)
|
|
729
|
+
const legacy = readIfExists(legacyAbs)
|
|
730
|
+
if (legacy === null) return none
|
|
731
|
+
// A symlinked pair is one file: cutting the block out of "CLAUDE.md" would cut AGENTS.md too.
|
|
732
|
+
// Fail closed when that cannot be proven (EACCES, ELOOP, …) — and say so, not a stack trace.
|
|
733
|
+
try {
|
|
734
|
+
if (sameFile(legacyAbs, agentsAbs)) return none
|
|
735
|
+
} catch (error) {
|
|
736
|
+
const why = (error as NodeJS.ErrnoException).code ?? (error as Error).message
|
|
737
|
+
return { ...none, problem: `cannot tell whether ${LEGACY_BLOCK_HOST} is ${AGENTS_MD} (${why})` }
|
|
738
|
+
}
|
|
739
|
+
const legacyScan = scanBlock(legacy)
|
|
740
|
+
const { region } = legacyScan
|
|
741
|
+
if (legacyScan.problem !== null) {
|
|
742
|
+
return { ...none, problem: `${LEGACY_BLOCK_HOST}: ${legacyScan.problem}` }
|
|
743
|
+
}
|
|
744
|
+
if (region === null) return none
|
|
745
|
+
|
|
746
|
+
const agents = readIfExists(agentsAbs) ?? ''
|
|
747
|
+
const agentsScan = scanBlock(agents)
|
|
748
|
+
if (agentsScan.problem !== null)
|
|
749
|
+
return { ...none, problem: `${AGENTS_MD}: ${agentsScan.problem}` }
|
|
750
|
+
const legacyBlock = legacy.slice(region.start, region.end)
|
|
751
|
+
if (
|
|
752
|
+
agentsScan.region !== null &&
|
|
753
|
+
normalizeForLedger(agents.slice(agentsScan.region.start, agentsScan.region.end)) !==
|
|
754
|
+
normalizeForLedger(legacyBlock)
|
|
755
|
+
) {
|
|
756
|
+
return {
|
|
757
|
+
...none,
|
|
758
|
+
problem:
|
|
759
|
+
`${LEGACY_BLOCK_HOST} and ${AGENTS_MD} each carry a basalt block and they differ — ` +
|
|
760
|
+
`delete the stale block from ${LEGACY_BLOCK_HOST} (or the one in ${AGENTS_MD}, if ` +
|
|
761
|
+
`${LEGACY_BLOCK_HOST}'s is the one you want kept), then re-run.`,
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const before = legacy.slice(0, region.start).trimEnd()
|
|
766
|
+
const after = legacy.slice(region.end).trimStart()
|
|
767
|
+
const remainder = before === '' || after === '' ? `${before}${after}` : `${before}\n\n${after}`
|
|
768
|
+
const deletedHost = remainder
|
|
769
|
+
.split(/\r?\n/)
|
|
770
|
+
.every((l) => l.trim() === '' || isAgentsImportLine(l))
|
|
771
|
+
if (opts.dryRun === true) return { pending: true, deletedHost, problem: null }
|
|
772
|
+
|
|
773
|
+
if (agentsScan.region === null) writeFileEnsuringDir(agentsAbs, applyBlock(agents, legacyBlock))
|
|
774
|
+
if (deletedHost) unlinkSync(legacyAbs)
|
|
775
|
+
else writeFileSync(legacyAbs, `${remainder.trimEnd()}\n`)
|
|
776
|
+
return { pending: true, deletedHost, problem: null }
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* The refusal `init` and `sync` run BEFORE any side effect (roots patch, package.json script,
|
|
781
|
+
* manifest): a dry-run migration against a scratch manifest, so a block that cannot be moved
|
|
782
|
+
* safely stops the command with nothing written. Null = safe to proceed.
|
|
783
|
+
*/
|
|
784
|
+
export function blockMigrationProblem(cwd: string): string | null {
|
|
785
|
+
const { problem } = migrateBlockToAgentsMd(cwd, { version: 1, files: {} }, { dryRun: true })
|
|
786
|
+
return problem === null
|
|
787
|
+
? null
|
|
788
|
+
: `${problem.replace(/\.$/, '')}. Nothing else was reconciled — resolve this by hand, then re-run.`
|
|
789
|
+
}
|
|
790
|
+
|
|
636
791
|
/**
|
|
637
792
|
* Explicit `--with-router` / `--with-query` CLI flags force-include a router/query scaffold even
|
|
638
793
|
* when the peer isn't detected in the consumer's package.json yet (e.g. it's about to be added).
|
|
@@ -809,15 +964,17 @@ export function managedFiles(
|
|
|
809
964
|
render: (ctx: RenderContext) => readSource(ctx.pkgRoot, `agent/skills/${name}/SKILL.md`),
|
|
810
965
|
}))
|
|
811
966
|
|
|
812
|
-
|
|
813
|
-
|
|
967
|
+
// AGENTS.md, not CLAUDE.md: Claude Code loads a repo-root AGENTS.md directly, but ANY CLAUDE.md
|
|
968
|
+
// in or above the cwd suppresses it — so the block must not be the reason a CLAUDE.md exists.
|
|
969
|
+
const agentsBlock: ManagedFile = {
|
|
970
|
+
dest: AGENTS_MD,
|
|
814
971
|
mode: 'managed',
|
|
815
972
|
markers: true,
|
|
816
|
-
source: 'agent/templates/
|
|
973
|
+
source: 'agent/templates/AGENTS-block.md.tpl',
|
|
817
974
|
// With markers, render() returns the fully-rendered region INCLUDING its begin/end markers.
|
|
818
|
-
// The writer splices it into the consumer's host
|
|
975
|
+
// The writer splices it into the consumer's host AGENTS.md at apply time.
|
|
819
976
|
render: (ctx) => {
|
|
820
|
-
const tpl = readSource(ctx.pkgRoot, 'agent/templates/
|
|
977
|
+
const tpl = readSource(ctx.pkgRoot, 'agent/templates/AGENTS-block.md.tpl')
|
|
821
978
|
if (tpl === null) return null
|
|
822
979
|
return fillTemplate(tpl, ctx.vars).trim()
|
|
823
980
|
},
|
|
@@ -946,7 +1103,7 @@ export function managedFiles(
|
|
|
946
1103
|
// lefthook/CI config, and writing over or beside it is worse than not writing at all.
|
|
947
1104
|
const rootTooling: ManagedFile[] = placement.isPackageRepoRoot ? [lefthook, ci] : []
|
|
948
1105
|
|
|
949
|
-
return [...rules, ...skills,
|
|
1106
|
+
return [...rules, ...skills, agentsBlock, design, oxfmt, ...rootTooling, oxlintrc, ...scaffolds]
|
|
950
1107
|
}
|
|
951
1108
|
|
|
952
1109
|
/**
|
|
@@ -1086,6 +1243,18 @@ export function reportPrune(pruned: PruneResult): void {
|
|
|
1086
1243
|
)
|
|
1087
1244
|
}
|
|
1088
1245
|
|
|
1246
|
+
/** Name what {@link migrateBlockToAgentsMd} did — a file leaving a consumer's tree is never silent. */
|
|
1247
|
+
export function reportBlockMigration(migration: BlockMigration, command: 'init' | 'sync'): void {
|
|
1248
|
+
if (!migration.pending) return
|
|
1249
|
+
const tail = migration.deletedHost
|
|
1250
|
+
? `${LEGACY_BLOCK_HOST} held nothing else and was deleted.`
|
|
1251
|
+
: `the rest of ${LEGACY_BLOCK_HOST} is yours; while it exists Claude Code loads it INSTEAD of ` +
|
|
1252
|
+
`${AGENTS_MD} unless it imports @${AGENTS_MD} — move your content over and delete it.`
|
|
1253
|
+
console.log(
|
|
1254
|
+
`\nbasalt-ui ${command}: moved the basalt block from ${LEGACY_BLOCK_HOST} to ${AGENTS_MD} — ${tail}`,
|
|
1255
|
+
)
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1089
1258
|
/**
|
|
1090
1259
|
* Delete the managed rule/skill files this basalt version retired, and drop their manifest entries.
|
|
1091
1260
|
*
|
|
@@ -1194,7 +1363,7 @@ type Classification = 'unchanged' | 'drifted' | 'missing' | 'current'
|
|
|
1194
1363
|
* - Blank out the per-release `BASALT_VERSION` token in a marker's begin line
|
|
1195
1364
|
* (`<!-- basalt:begin 1.0.1 -->` → `<!-- basalt:begin -->`). It changes on every release and is
|
|
1196
1365
|
* the only per-release variable that lands INSIDE the hashed region (the rest of
|
|
1197
|
-
* `
|
|
1366
|
+
* `AGENTS-block.md.tpl` has no other `{{…}}` placeholders today), so stripping it is what lets
|
|
1198
1367
|
* one normalized rendering stand in for every version of a given template body instead of
|
|
1199
1368
|
* needing one ledger entry per release. If a future marker template interpolates another
|
|
1200
1369
|
* per-release var inside its region, extend this normalization the same way.
|
package/src/cli/init.ts
CHANGED
|
@@ -10,9 +10,11 @@ import { deriveSpacing } from '../tokens'
|
|
|
10
10
|
import type { MergeLintResult, ScaffoldFlags } from './index'
|
|
11
11
|
import {
|
|
12
12
|
MANIFEST_PATH,
|
|
13
|
+
blockMigrationProblem,
|
|
13
14
|
findBasaltInstall,
|
|
14
15
|
managedFiles,
|
|
15
16
|
mergeOxlintExtends,
|
|
17
|
+
migrateBlockToAgentsMd,
|
|
16
18
|
normalizeForLedger,
|
|
17
19
|
patchPackageJson,
|
|
18
20
|
placementNotices,
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
readManifest,
|
|
21
23
|
reconcileRoots,
|
|
22
24
|
renderContext,
|
|
25
|
+
reportBlockMigration,
|
|
23
26
|
reportPrune,
|
|
24
27
|
resolvePeerFlags,
|
|
25
28
|
resolvePlacement,
|
|
@@ -52,6 +55,13 @@ const KEPT_FILE_COST: Record<string, string> = {
|
|
|
52
55
|
|
|
53
56
|
/** Scaffold all managed files into the consumer repo, then write the manifest. Idempotent. Returns 0. */
|
|
54
57
|
export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags = {}): number {
|
|
58
|
+
// First, before anything is written: a block that cannot be moved safely refuses the whole run.
|
|
59
|
+
const blockProblem = blockMigrationProblem(cwd)
|
|
60
|
+
if (blockProblem !== null) {
|
|
61
|
+
console.error(`✖ basalt-ui init: ${blockProblem}`)
|
|
62
|
+
return 1
|
|
63
|
+
}
|
|
64
|
+
|
|
55
65
|
// ── Describe the repo BEFORE rendering anything ───────────────────────────
|
|
56
66
|
// Every roots-derived seed (the CI oxfmt globs, DESIGN.md's series path, the default scan
|
|
57
67
|
// exemption) renders from `basalt.roots`, so the key has to exist first. Writing it is what
|
|
@@ -86,6 +96,9 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
86
96
|
const placement = resolvePlacement(cwd)
|
|
87
97
|
const files = managedFiles(peers, placement)
|
|
88
98
|
|
|
99
|
+
// Before the loop: an install from before the move carries the block in CLAUDE.md.
|
|
100
|
+
const migration = migrateBlockToAgentsMd(cwd, manifest)
|
|
101
|
+
|
|
89
102
|
const writtenFiles: string[] = []
|
|
90
103
|
const keptFiles: string[] = []
|
|
91
104
|
const missingSources: string[] = []
|
|
@@ -131,6 +144,7 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
|
|
|
131
144
|
`basalt-ui init: ${writtenFiles.length} written, ${keptFiles.length} kept, manifest at ${MANIFEST_PATH}`,
|
|
132
145
|
)
|
|
133
146
|
reportPrune(pruned)
|
|
147
|
+
reportBlockMigration(migration, 'init')
|
|
134
148
|
if (missingSources.length > 0) {
|
|
135
149
|
console.log(
|
|
136
150
|
`basalt-ui init: ${missingSources.length} shipped asset(s) not present, skipped: ${missingSources.join(', ')}`,
|
package/src/cli/sync.ts
CHANGED
|
@@ -17,8 +17,12 @@ import {
|
|
|
17
17
|
diffSummary,
|
|
18
18
|
findBasaltInstall,
|
|
19
19
|
inspectLefthookGate,
|
|
20
|
+
AGENTS_MD,
|
|
21
|
+
blockMigrationProblem,
|
|
20
22
|
isStringArray,
|
|
23
|
+
LEGACY_BLOCK_HOST,
|
|
21
24
|
managedFiles,
|
|
25
|
+
migrateBlockToAgentsMd,
|
|
22
26
|
normalizeForLedger,
|
|
23
27
|
noteRelocation,
|
|
24
28
|
OXLINT_FILES,
|
|
@@ -32,6 +36,7 @@ import {
|
|
|
32
36
|
reconcileRoots,
|
|
33
37
|
relativePosix,
|
|
34
38
|
renderContext,
|
|
39
|
+
reportBlockMigration,
|
|
35
40
|
reportPrune,
|
|
36
41
|
resolvePeerFlags,
|
|
37
42
|
resolvePlacement,
|
|
@@ -149,6 +154,13 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
149
154
|
return 1
|
|
150
155
|
}
|
|
151
156
|
|
|
157
|
+
// A block that cannot be moved safely refuses the whole run BEFORE reconcileRoots writes anything.
|
|
158
|
+
const blockProblem = blockMigrationProblem(cwd)
|
|
159
|
+
if (blockProblem !== null) {
|
|
160
|
+
console.error(`✖ basalt-ui sync: ${blockProblem}`)
|
|
161
|
+
return 1
|
|
162
|
+
}
|
|
163
|
+
|
|
152
164
|
// Before renderContext, which reads `basalt.roots` for the roots-derived template variables.
|
|
153
165
|
const rootsState = reconcileRoots(cwd, { write: opts.check !== true })
|
|
154
166
|
const ctx = renderContext(cwd)
|
|
@@ -157,6 +169,10 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
157
169
|
const placement = resolvePlacement(cwd)
|
|
158
170
|
const files = managedFiles(peers, placement)
|
|
159
171
|
|
|
172
|
+
// Before the loop: the block moved from CLAUDE.md to AGENTS.md, and the loop reads AGENTS.md.
|
|
173
|
+
// `--check` only reports it (and stays red until a real sync moves it).
|
|
174
|
+
const migration = migrateBlockToAgentsMd(cwd, manifest, { dryRun: opts.check === true })
|
|
175
|
+
|
|
160
176
|
let updated = 0
|
|
161
177
|
// `created` and `recreated` were one counter, and `0 updated, 20 recreated` is what a full
|
|
162
178
|
// scaffold printed — a word that means "it was here, it went missing, I put it back" describing
|
|
@@ -272,10 +288,16 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
272
288
|
`${retired.join(', ')} — run \`basalt-ui sync\`.`,
|
|
273
289
|
)
|
|
274
290
|
}
|
|
291
|
+
if (migration.pending) {
|
|
292
|
+
console.error(
|
|
293
|
+
`basalt-ui sync --check: the basalt block is still in ${LEGACY_BLOCK_HOST} — it now ` +
|
|
294
|
+
`lives in ${AGENTS_MD}. Run \`basalt-ui sync\` to move it.`,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
275
297
|
if (staleForCheck > 0) {
|
|
276
298
|
console.error(`basalt-ui sync --check: ${staleForCheck} managed file(s) out of date.`)
|
|
277
|
-
return 1
|
|
278
299
|
}
|
|
300
|
+
if (staleForCheck > 0 || migration.pending) return 1
|
|
279
301
|
const n = placementSkips.length
|
|
280
302
|
const skips = n === 0 ? '' : ` (${n} permanent placement skip(s) — --verbose for detail)`
|
|
281
303
|
console.log(`✓ basalt-ui sync --check: all managed files current.${skips}`)
|
|
@@ -291,6 +313,7 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
|
|
|
291
313
|
`${skippedDrift} skipped (drift), ${pruned.removed.length} retired.`,
|
|
292
314
|
)
|
|
293
315
|
reportPrune(pruned)
|
|
316
|
+
reportBlockMigration(migration, 'sync')
|
|
294
317
|
if (driftLines.length > 0) {
|
|
295
318
|
console.log('Locally-edited files were skipped (run with --force to overwrite):')
|
|
296
319
|
for (const l of driftLines) console.log(l)
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* parseShortcut('Mod+S', false) // ['Ctrl', 'S']
|
|
10
10
|
*
|
|
11
11
|
* // Single display string for Spotlight description:
|
|
12
|
-
* formatShortcutDisplay('Mod+S', true) // '⌘
|
|
12
|
+
* formatShortcutDisplay('Mod+S', true) // '⌘S' on mac
|
|
13
13
|
* formatShortcutDisplay('Mod+S', false) // 'Ctrl+S' on windows
|
|
14
14
|
*/
|
|
15
15
|
|
|
@@ -30,19 +30,54 @@ export function detectMac(): boolean {
|
|
|
30
30
|
|
|
31
31
|
// ── parseShortcut ─────────────────────────────────────────────────────────────
|
|
32
32
|
|
|
33
|
+
/** Named keys → [mac glyph, other-platform word]. Keys are the lower-cased shortcut token. */
|
|
34
|
+
const NAMED_KEYS: Record<string, readonly [mac: string, other: string]> = {
|
|
35
|
+
backspace: ['⌫', 'Backspace'],
|
|
36
|
+
delete: ['⌦', 'Delete'],
|
|
37
|
+
del: ['⌦', 'Delete'],
|
|
38
|
+
arrowleft: ['←', 'Left'],
|
|
39
|
+
arrowright: ['→', 'Right'],
|
|
40
|
+
arrowup: ['↑', 'Up'],
|
|
41
|
+
arrowdown: ['↓', 'Down'],
|
|
42
|
+
left: ['←', 'Left'],
|
|
43
|
+
right: ['→', 'Right'],
|
|
44
|
+
up: ['↑', 'Up'],
|
|
45
|
+
down: ['↓', 'Down'],
|
|
46
|
+
escape: ['⎋', 'Esc'],
|
|
47
|
+
esc: ['⎋', 'Esc'],
|
|
48
|
+
enter: ['↩', 'Enter'],
|
|
49
|
+
return: ['↩', 'Enter'],
|
|
50
|
+
tab: ['⇥', 'Tab'],
|
|
51
|
+
space: ['␣', 'Space'],
|
|
52
|
+
home: ['↖', 'Home'],
|
|
53
|
+
end: ['↘', 'End'],
|
|
54
|
+
pageup: ['⇞', 'PgUp'],
|
|
55
|
+
pagedown: ['⇟', 'PgDn'],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A key that is not a modifier or a named key: a single character upper-cases (`s` → `S`), any
|
|
59
|
+
* other word title-cases (`f5` → `F5`, `insert` → `Insert`). */
|
|
60
|
+
function plainKey(key: string): string {
|
|
61
|
+
return key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1)
|
|
62
|
+
}
|
|
63
|
+
|
|
33
64
|
/**
|
|
34
65
|
* Convert a shortcut string to a list of Kbd-friendly tokens.
|
|
35
66
|
* 'Mod+S' → ['⌘', 'S'] (mac) or ['Ctrl', 'S'] (other).
|
|
36
67
|
* 'Shift+Mod+P' → ['⇧', '⌘', 'P'] (mac) or ['Shift', 'Ctrl', 'P'] (other).
|
|
68
|
+
* Named keys read as glyphs on mac and Title-case words elsewhere:
|
|
69
|
+
* 'Mod+Backspace' → ['⌘', '⌫'] (mac) or ['Ctrl', 'Backspace'] (other).
|
|
37
70
|
* isMac is the post-mount platform flag; defaults false on SSR / first render.
|
|
38
71
|
*
|
|
39
72
|
* @example
|
|
40
73
|
* parseShortcut('Mod+S', true) // ['⌘', 'S']
|
|
41
74
|
* parseShortcut('Shift+Mod+P', false) // ['Shift', 'Ctrl', 'P']
|
|
75
|
+
* parseShortcut('ArrowLeft', true) // ['←']
|
|
42
76
|
*/
|
|
43
77
|
export function parseShortcut(shortcut: string, isMac: boolean): string[] {
|
|
44
78
|
return shortcut.split('+').map((key) => {
|
|
45
|
-
|
|
79
|
+
const lower = key.toLowerCase()
|
|
80
|
+
switch (lower) {
|
|
46
81
|
case 'mod':
|
|
47
82
|
return isMac ? '⌘' : 'Ctrl'
|
|
48
83
|
case 'shift':
|
|
@@ -52,9 +87,12 @@ export function parseShortcut(shortcut: string, isMac: boolean): string[] {
|
|
|
52
87
|
case 'meta':
|
|
53
88
|
return isMac ? '⌘' : 'Meta'
|
|
54
89
|
case 'ctrl':
|
|
55
|
-
return isMac ? '
|
|
56
|
-
default:
|
|
57
|
-
|
|
90
|
+
return isMac ? '⌃' : 'Ctrl'
|
|
91
|
+
default: {
|
|
92
|
+
const named = NAMED_KEYS[lower]
|
|
93
|
+
if (named !== undefined) return isMac ? named[0] : named[1]
|
|
94
|
+
return plainKey(key)
|
|
95
|
+
}
|
|
58
96
|
}
|
|
59
97
|
})
|
|
60
98
|
}
|
|
@@ -63,7 +101,8 @@ export function parseShortcut(shortcut: string, isMac: boolean): string[] {
|
|
|
63
101
|
|
|
64
102
|
/**
|
|
65
103
|
* Format a shortcut string for inline display (e.g. Spotlight action description).
|
|
66
|
-
* Returns a compact string like '⌘
|
|
104
|
+
* Returns a compact string like '⌘S' / '⌘⌫' (mac, glyphs with no separator) or 'Ctrl+S' /
|
|
105
|
+
* 'Ctrl+Backspace' (elsewhere, Title-case words joined with '+').
|
|
67
106
|
*
|
|
68
107
|
* isMac is the post-mount platform flag. Pass false on SSR / first client render.
|
|
69
108
|
*
|
|
@@ -72,12 +111,12 @@ export function parseShortcut(shortcut: string, isMac: boolean): string[] {
|
|
|
72
111
|
* always-on and produces the same tokens via parseShortcut.
|
|
73
112
|
*
|
|
74
113
|
* @example
|
|
75
|
-
* formatShortcutDisplay('Mod+S', true)
|
|
114
|
+
* formatShortcutDisplay('Mod+S', true) // '⌘S'
|
|
115
|
+
* formatShortcutDisplay('Mod+Backspace', true) // '⌘⌫'
|
|
76
116
|
* formatShortcutDisplay('Mod+S', false) // 'Ctrl+S'
|
|
77
|
-
* formatShortcutDisplay('Shift+Mod+P', true) // '
|
|
117
|
+
* formatShortcutDisplay('Shift+Mod+P', true) // '⇧⌘P'
|
|
78
118
|
*/
|
|
79
119
|
export function formatShortcutDisplay(shortcut: string, isMac: boolean): string {
|
|
80
120
|
const tokens = parseShortcut(shortcut, isMac)
|
|
81
|
-
|
|
82
|
-
return isMac ? tokens.join(' ') : tokens.join('+')
|
|
121
|
+
return tokens.join(isMac ? '' : '+')
|
|
83
122
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* The check + tooltip are the entire success signal — no notification (docs/CONTENT-SPEC.md §7).
|
|
8
8
|
* `color="teal"` resolves through the bridged Mantine family, so it stays on the token layer in
|
|
9
|
-
* both schemes (see the package
|
|
9
|
+
* both schemes (see the package MAINTAINING.md on `cssVariablesResolver`).
|
|
10
10
|
*
|
|
11
11
|
* Not part of the public surface — reach it through `CodeBlock` / `HeadingAnchor`.
|
|
12
12
|
*/
|
package/src/content/glyphs.tsx
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* hand-drawn ones that drift apart.
|
|
5
5
|
*
|
|
6
6
|
* Inline SVG rather than an icon dep: the framework takes icons as `ReactNode` from the consumer
|
|
7
|
-
* (see the package
|
|
7
|
+
* (see the package MAINTAINING.md) and ships none, but these are internal chrome of a shipped component,
|
|
8
8
|
* not a consumer-facing slot. `currentColor` + `1em`-agnostic sizing keeps them steerable from CSS
|
|
9
9
|
* — the heading anchor sizes its glyph as an optical ratio of whatever step the heading lands on
|
|
10
10
|
* (docs/DESIGN-SPEC.md §3), while the code-block chrome pins a fixed 14px.
|
|
@@ -146,7 +146,7 @@ export type BasaltVirtualListHandle = {
|
|
|
146
146
|
/**
|
|
147
147
|
* The underlying `@tanstack/react-virtual` instance — the full escape hatch. Returns the RAW
|
|
148
148
|
* `@tanstack/react-virtual` `Virtualizer`, so a future major of that library reaching this shape
|
|
149
|
-
* ships here as a plain `feat:` under the no-majors doctrine (
|
|
149
|
+
* ships here as a plain `feat:` under the no-majors doctrine (root `AGENTS.md` "Commit type
|
|
150
150
|
* discipline"), not a silent break.
|
|
151
151
|
*/
|
|
152
152
|
getVirtualizer: () => Virtualizer<HTMLDivElement, Element>
|
package/src/guard/index.ts
CHANGED
|
@@ -788,7 +788,7 @@ function insideHostTag(codeText: string, offset: number): boolean {
|
|
|
788
788
|
*
|
|
789
789
|
* KEBAB and PascalCase both count, because a repo picks one file-naming law and the convention has
|
|
790
790
|
* to survive either: `edit-session-modal.tsx`, and `CbbiPanel.tsx` in a repo mandating
|
|
791
|
-
* `PascalCase.tsx` for component files (basalt's own root `
|
|
791
|
+
* `PascalCase.tsx` for component files (basalt's own root `AGENTS.md` included). Each needs the
|
|
792
792
|
* leading SUBJECT — a bare `modal.tsx` / `Panel.tsx` is a page module, not the convention.
|
|
793
793
|
*
|
|
794
794
|
* The trade is that a whole file goes unscanned on a naming convention — the same bargain
|