@rokkit/cli 1.3.15 → 1.4.1

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/src/skills.js CHANGED
@@ -24,12 +24,26 @@ export function parseFrontmatter(md) {
24
24
  // Keep these as literal regexes — do NOT collapse into a dynamic
25
25
  // `new RegExp(`^${key}:...`)` helper: the keys are fixed, so the dynamic form
26
26
  // adds nothing but trips the no-dynamic-regex lint, and the literals are
27
- // plainly linear-time. The `?.[1]?.trim() ?? ''` chain returns '' for a
28
- // missing block, a missing field, or a bare `key:` with no value.
29
- const block = md.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''
30
- const name = block.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? ''
31
- const description = block.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? ''
32
- return { name, description }
27
+ // plainly linear-time.
28
+ //
29
+ // The block is captured UNTRIMMED on purpose: the field regexes are anchored
30
+ // with `^`, so indented frontmatter should miss, exactly as before.
31
+ const block = capture(md, /^---\n([\s\S]*?)\n---/)
32
+ return {
33
+ name: capture(block, /^name:\s*(.+)$/m).trim(),
34
+ description: capture(block, /^description:\s*(.+)$/m).trim()
35
+ }
36
+ }
37
+
38
+ /**
39
+ * First capture group of `re` in `text`, or `''` when there is no match — so a
40
+ * missing block, a missing field and a bare `key:` all collapse to `''`.
41
+ * @param {string} text
42
+ * @param {RegExp} re
43
+ * @returns {string}
44
+ */
45
+ function capture(text, re) {
46
+ return text.match(re)?.[1] ?? ''
33
47
  }
34
48
 
35
49
  /**
@@ -72,7 +86,7 @@ export function installSkills(
72
86
  * Print the catalog, marking already-installed skills.
73
87
  * @param {{ skillsDir?: string, cwd?: string }} [opts]
74
88
  */
75
- export async function runSkillsList({ skillsDir, cwd = process.cwd() } = {}) {
89
+ export function runSkillsList({ skillsDir, cwd = process.cwd() } = {}) {
76
90
  const catalog = listSkills({ skillsDir })
77
91
  if (catalog.length === 0) {
78
92
  console.info('No skills available.')
@@ -98,35 +112,47 @@ export async function runSkillsAdd(
98
112
  { all = false, force = false, skillsDir, cwd = process.cwd() } = {}
99
113
  ) {
100
114
  const catalog = listSkills({ skillsDir })
101
- let selected = names
102
- if (all) {
103
- selected = catalog.map((s) => s.name)
104
- } else if (selected.length === 0) {
105
- const res = await prompts({
106
- type: 'multiselect',
107
- name: 'picked',
108
- message: 'Select skills to add',
109
- choices: catalog.map((s) => ({ title: s.name, value: s.name, description: s.description }))
110
- })
111
- selected = res.picked ?? []
112
- }
115
+ const selected = await resolveSkillSelection(names, catalog, all)
113
116
  if (selected.length === 0) {
114
117
  console.info('No skills selected.')
115
118
  return
116
119
  }
117
120
  const results = installSkills(selected, { cwd, force, skillsDir })
118
- for (const r of results) {
119
- if (r.status === 'added') console.info(` added .claude/skills/${r.name}`)
120
- else if (r.status === 'skipped')
121
- console.info(` skipped ${r.name} (exists — use --force to overwrite)`)
122
- else console.error(` unknown skill: ${r.name}`)
123
- }
121
+ results.forEach(reportSkillResult)
124
122
  if (results.some((r) => r.status === 'unknown')) {
125
123
  console.error(`\nValid skills: ${catalog.map((s) => s.name).join(', ')}`)
126
124
  process.exitCode = 1
127
125
  }
128
126
  }
129
127
 
128
+ /**
129
+ * Which skills to install: everything with `--all`, the named ones, or an
130
+ * interactive pick when neither was given.
131
+ * @param {string[]} names
132
+ * @param {Array<{ name: string, description: string }>} catalog
133
+ * @param {boolean} all
134
+ * @returns {Promise<string[]>}
135
+ */
136
+ async function resolveSkillSelection(names, catalog, all) {
137
+ if (all) return catalog.map((s) => s.name)
138
+ if (names.length > 0) return names
139
+ const res = await prompts({
140
+ type: 'multiselect',
141
+ name: 'picked',
142
+ message: 'Select skills to add',
143
+ choices: catalog.map((s) => ({ title: s.name, value: s.name, description: s.description }))
144
+ })
145
+ return res.picked ?? []
146
+ }
147
+
148
+ /** Report one install outcome. Unknown names go to stderr — they're a user error. */
149
+ function reportSkillResult(r) {
150
+ if (r.status === 'added') console.info(` added .claude/skills/${r.name}`)
151
+ else if (r.status === 'skipped')
152
+ console.info(` skipped ${r.name} (exists — use --force to overwrite)`)
153
+ else console.error(` unknown skill: ${r.name}`)
154
+ }
155
+
130
156
  /**
131
157
  * CLI entry for `rokkit skills` subcommands.
132
158
  * @param {'list' | 'add'} sub
package/src/validate.js CHANGED
@@ -8,41 +8,48 @@
8
8
  * @param {Record<string, unknown> | null} config
9
9
  * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
10
10
  */
11
+ /** True when the config still uses the back-compat `colors:` key instead of `skin:`. */
12
+ function usesLegacyColorsAlias({ colors, skin }) {
13
+ return typeof colors === 'object' && colors !== null && !Array.isArray(colors) && !skin
14
+ }
15
+
16
+ /** True when oklch values are requested but no `palettes` block supplies them. */
17
+ function oklchWithoutPalettes(config) {
18
+ return config.colorSpace === 'oklch' && Object.keys(config.palettes ?? {}).length === 0
19
+ }
20
+
21
+ /**
22
+ * The config-shape rules, in report order: a predicate plus the advisory it
23
+ * emits. Adding a rule is a new entry rather than another `if` block, and each
24
+ * predicate stays small enough to read on its own.
25
+ */
26
+ const SHAPE_RULES = [
27
+ {
28
+ when: ({ colormap }) => !('ink' in colormap),
29
+ id: 'skin-ink-role',
30
+ label: 'skin defines an `ink` role',
31
+ fix: "Add `ink: '<palette>'` (reusing the surface palette is fine) — without it, ink-* text tokens fall back to the surface palette."
32
+ },
33
+ {
34
+ when: ({ config }) => oklchWithoutPalettes(config),
35
+ id: 'oklch-needs-palettes',
36
+ label: 'colorSpace `oklch` has a `palettes` block',
37
+ fix: 'oklch values need a `palettes` block of bare "L C H" components; Tailwind named colors are rgb.'
38
+ },
39
+ {
40
+ when: ({ config }) => usesLegacyColorsAlias(config),
41
+ id: 'colors-alias',
42
+ label: 'config uses `skin` (not the legacy `colors` alias)',
43
+ fix: 'Rename `colors:` to `skin:` — `colors` is a back-compat alias.'
44
+ }
45
+ ]
46
+
11
47
  export function validateConfigShape(config) {
12
48
  if (!config) return []
13
- const checks = []
14
- const usesColorsAlias =
15
- typeof config.colors === 'object' && config.colors !== null && !Array.isArray(config.colors) && !config.skin
16
49
  const colormap = config.skins?.default ?? config.skin ?? config.colors ?? {}
17
-
18
- if (!('ink' in colormap)) {
19
- checks.push({
20
- id: 'skin-ink-role',
21
- label: 'skin defines an `ink` role',
22
- status: 'warn',
23
- fixable: false,
24
- fix: "Add `ink: '<palette>'` (reusing the surface palette is fine) — without it, ink-* text tokens fall back to the surface palette."
25
- })
26
- }
27
- if (config.colorSpace === 'oklch' && Object.keys(config.palettes ?? {}).length === 0) {
28
- checks.push({
29
- id: 'oklch-needs-palettes',
30
- label: 'colorSpace `oklch` has a `palettes` block',
31
- status: 'warn',
32
- fixable: false,
33
- fix: 'oklch values need a `palettes` block of bare "L C H" components; Tailwind named colors are rgb.'
34
- })
35
- }
36
- if (usesColorsAlias) {
37
- checks.push({
38
- id: 'colors-alias',
39
- label: 'config uses `skin` (not the legacy `colors` alias)',
40
- status: 'warn',
41
- fixable: false,
42
- fix: 'Rename `colors:` to `skin:` — `colors` is a back-compat alias.'
43
- })
44
- }
45
- return checks
50
+ return SHAPE_RULES.filter((rule) => rule.when({ config, colormap })).map(
51
+ ({ id, label, fix }) => ({ id, label, status: 'warn', fixable: false, fix })
52
+ )
46
53
  }
47
54
 
48
55
  /**
@@ -62,19 +69,30 @@ const TEXT_MISUSE_PATTERNS = [
62
69
  * @param {{ list?: (dir: string) => string[], exists: (p: string) => boolean, read: (p: string) => string, resolve: (p: string) => string }} fs
63
70
  * @returns {Array<{ id: string, label: string, status: 'warn', fixable: false, fix: string }>}
64
71
  */
72
+ /**
73
+ * `path:line` for every line of `source` that misuses a surface token as text
74
+ * colour. Paths are reported relative to the cwd when they sit under it.
75
+ * @param {string} file
76
+ * @param {string} source
77
+ * @param {string} cwd
78
+ * @returns {string[]}
79
+ */
80
+ function misuseHits(file, source, cwd) {
81
+ const display = file.startsWith(cwd) ? file.slice(cwd.length).replace(/^\//, '') : file
82
+ return source
83
+ .split('\n')
84
+ .map((line, i) => (TEXT_MISUSE_PATTERNS.some((re) => re.test(line)) ? `${display}:${i + 1}` : null))
85
+ .filter(Boolean)
86
+ }
87
+
65
88
  export function checkTextTokenUsage(fs) {
66
89
  if (typeof fs.list !== 'function' || !fs.exists(fs.resolve('src'))) return []
67
90
 
68
91
  const cwd = process.cwd()
69
- const hits = []
70
- for (const file of fs.list(fs.resolve('src'))) {
71
- if (!file.endsWith('.css') && !file.endsWith('.svelte')) continue
72
- const lines = fs.read(file).split('\n')
73
- const display = file.startsWith(cwd) ? file.slice(cwd.length).replace(/^\//, '') : file
74
- lines.forEach((line, i) => {
75
- if (TEXT_MISUSE_PATTERNS.some((re) => re.test(line))) hits.push(`${display}:${i + 1}`)
76
- })
77
- }
92
+ const hits = fs
93
+ .list(fs.resolve('src'))
94
+ .filter((file) => file.endsWith('.css') || file.endsWith('.svelte'))
95
+ .flatMap((file) => misuseHits(file, fs.read(file), cwd))
78
96
 
79
97
  if (hits.length === 0) return []
80
98
  return [