@chanom/cli 0.3.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +20 -7
  2. package/dist/index.js +120 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @chanom/cli
2
2
 
3
- Interactive CLI that brews dev tooling config into your project - oxlint, oxfmt, knip, VS Code settings, and commit hooks, picked with a couple of prompts.
3
+ Interactive CLI that brews dev tooling config into your project - oxlint, oxfmt, knip, VS Code settings, agent coding standards, and commit hooks, picked with a couple of prompts.
4
4
 
5
5
  ## Install
6
6
 
@@ -39,6 +39,7 @@ Debug mode logs each step (detected package manager, planned packages, every com
39
39
  - `oxfmt` - fast Rust-based formatter
40
40
  - `knip` - dead code remover
41
41
  - `vscode` - editor settings + extension recommendations
42
+ - `skills` - coding standards for AI agents (`.claude/skills`)
42
43
  2. **Sweetness** - how strict to be:
43
44
  - `light` - warn only, no blocking
44
45
  - `medium` - block on commit via husky + lint-staged + commitlint
@@ -61,12 +62,13 @@ installing anything. `light` sweetness works anywhere in the repo.
61
62
 
62
63
  ## What gets added
63
64
 
64
- | Topping | Config file | Scripts added |
65
- | -------- | -------------------------------------------------- | ------------------------ |
66
- | `oxlint` | `oxlint.config.ts`\* | `lint`, `lint:fix` |
67
- | `oxfmt` | `oxfmt.config.ts`\* | `format`, `format:check` |
68
- | `knip` | `knip.config.ts`\* | `knip` |
69
- | `vscode` | `.vscode/settings.json`, `.vscode/extensions.json` | none |
65
+ | Topping | Config file | Scripts added |
66
+ | -------- | ------------------------------------------------------------------------------- | ------------------------ |
67
+ | `oxlint` | `oxlint.config.ts`\* | `lint`, `lint:fix` |
68
+ | `oxfmt` | `oxfmt.config.ts`\* | `format`, `format:check` |
69
+ | `knip` | `knip.config.ts`\* | `knip` |
70
+ | `vscode` | `.vscode/settings.json`, `.vscode/extensions.json` | none |
71
+ | `skills` | `.agents/skills/coding-standards/`, `.claude/skills/coding-standards` (symlink) | none |
70
72
 
71
73
  \* `.mts` instead of `.ts` when your project is CommonJS (no `"type": "module"` in `package.json`).
72
74
 
@@ -80,6 +82,13 @@ on save (`editor.formatOnSave` stays off so both run in one pass), points
80
82
  teammates are prompted to install it. Only portable settings are written - no
81
83
  `cSpell.words` or other repo-specific entries.
82
84
 
85
+ The `skills` topping also installs no packages and adds no scripts. It writes
86
+ the `coding-standards` agent skill - a router `SKILL.md` plus TypeScript, React,
87
+ and Vitest references - into `.agents/skills/coding-standards/`, so any agent
88
+ tool can read it, and links `.claude/skills/coding-standards` at that directory
89
+ with a relative symlink rather than keeping a second copy. The standards target
90
+ the stack chanom scaffolds (Vite + React + TypeScript, oxlint, oxfmt, vitest).
91
+
83
92
  `medium` sweetness additionally adds:
84
93
 
85
94
  - `husky` - installed via `husky init`
@@ -105,6 +114,10 @@ Existing setup is never overwritten:
105
114
  setting you already have wins (and is warned about), and existing extension
106
115
  recommendations are kept. A `.vscode` file that can't be parsed as JSON (e.g. it
107
116
  has comments) is left untouched with a warning.
117
+ - Skill files already on disk are warned about and skipped, and an existing
118
+ `.claude/skills/coding-standards` (a link of your own, or a real directory you
119
+ maintain by hand) is left alone - so re-running never overwrites standards you
120
+ have edited.
108
121
 
109
122
  The final commit uses your git identity; if none is configured, a local `Chanom <chanom@local>` identity is set. If committing fails (e.g. a commit hook rejects it), your files stay staged and `brew` tells you to commit manually.
110
123
 
package/dist/index.js CHANGED
@@ -239,6 +239,50 @@ var Prompter = class extends Effect.Service()("internal/Prompter", { succeed: {
239
239
  return { stop: (stopMessage) => Effect.sync(() => s.stop(stopMessage)) };
240
240
  })
241
241
  } }) {};
242
+ /** Every skill chanom can install, in install order. */
243
+ const SKILLS = [{
244
+ name: "coding-standards",
245
+ files: [
246
+ {
247
+ "path": "references/react.md",
248
+ "contents": "# React\n\nReact 19 with the Vite plugin. Entry is `src/main.tsx`, which mounts `App` from\n`src/app.tsx` in `StrictMode`.\n\n## Structure\n\n`src/` grows by role as the app does: `components/`, `hooks/`, `utils/`,\n`pages/`, `providers/`. Feature-scoped code lives under `features/<name>/` and\nmirrors that same layout. Put code in the feature folder unless two features\nshare it.\n\n## Components\n\n- **Container / presentational**: containers fetch data and hold logic;\n presentational components take props and return JSX with no side effects.\n- Props are wrapped in `Readonly<>`:\n `export function UserCard({ name }: Readonly<UserCardProps>)`.\n- Conditional rendering uses a ternary (`isOpen ? <Panel /> : null`), never\n `&&` — a falsy number renders as `0`.\n- Every component exports inline. `default` export is reserved for route-level\n page components.\n- Shared state lives in a context provider under `providers/`. Reach for\n context only once prop-passing spans three levels.\n\n## Hooks\n\nCustom hooks are named `use-<thing>.ts` and return an object, not a positional\ntuple, once they return more than two values. Every effect declares its full\ndependency array; an effect with no cleanup and no async work is usually a\nrender-time computation instead.\n"
249
+ },
250
+ {
251
+ "path": "references/typescript.md",
252
+ "contents": "# TypeScript\n\n## Types\n\n- Never use `any`. Reach for generics and utility types; when you must escape\n the type system use `unknown` and narrow explicitly.\n- Type-only imports use `import type { ... }`. A mixed import splits its type\n part into a separate `import type` line.\n- Derive types from their source rather than restating them — `z.infer` for a\n schema, `ReturnType`/`Parameters` for a function, `typeof` for a const object.\n A hand-written duplicate drifts.\n- Props and params objects are declared as `interface`, named after their owner:\n `interface UserCardProps`, `interface UseCurrentUserParams`.\n\n## Modules\n\n- Functions, not classes. Hooks, utils, factories, and components are plain\n exported functions.\n- Export inline (`export function formatDate(...)`) rather than a trailing\n `export { ... }` block.\n- Import order is enforced by oxlint — run `pnpm lint --fix` rather than\n hand-sorting.\n\n## Errors\n\nThrow `Error` (or a subclass) with a message naming what failed and the value\nthat caused it. An unused catch binding is omitted: `catch {`, not\n`catch (_e) {`.\n"
253
+ },
254
+ {
255
+ "path": "references/vitest.md",
256
+ "contents": "# Vitest\n\nTests run on jsdom with globals enabled — `describe`, `it`, `expect`, and `vi`\nneed no import. `src/test-setup.ts` loads `@testing-library/jest-dom/vitest`, so\nits matchers are available everywhere. Run with `pnpm test`, or `pnpm test:watch`\nwhile iterating.\n\n## Layout\n\nTests are colocated and named `*.test.ts` / `*.test.tsx`, sitting beside the\nfile they cover: `src/app.tsx` → `src/app.test.tsx`.\n\n## Component tests\n\nTesting Library with `user-event`, querying by role and accessible name:\n\n```tsx\ndescribe('UserCard', () => {\n it('should render the user name correctly', () => {\n render(<UserCard name=\"Ada\" />);\n expect(screen.getByRole('heading', { name: 'Ada' })).toBeInTheDocument();\n });\n\n it('should call onSelect when clicked', async () => {\n const user = userEvent.setup();\n const onSelect = vi.fn();\n render(<UserCard name=\"Ada\" onSelect={onSelect} />);\n await user.click(screen.getByRole('button', { name: 'Select' }));\n expect(onSelect).toHaveBeenCalledTimes(1);\n });\n});\n```\n\n- Interactions go through `userEvent.setup()` and `await user.click(...)`, not\n `fireEvent`.\n- Handlers are `vi.fn()` asserted by call count, including the negative case —\n a disabled control asserts `toHaveBeenCalledTimes(0)`.\n- Query by `getByRole` / `getByText`. Reach for `getByTestId` only when no\n accessible query exists, such as a decorative spinner.\n- Assert on what the user observes, not on state or implementation details.\n\n## Naming\n\nTest names read `should <behaviour> correctly` or `should <effect> when\n<action>`. Group a unit's tests in one `describe` named for the unit.\n"
257
+ },
258
+ {
259
+ "path": "SKILL.md",
260
+ "contents": "---\nname: coding-standards\ndescription: Coding standards for this Vite + React + TypeScript app. Use when writing or changing components, hooks, utils, or tests, and before committing.\n---\n\n# Coding standards\n\nVite + React 19 + TypeScript, scaffolded with `create-chanom-app`. oxlint lints,\noxfmt formats, vitest tests. Read the reference for what you touch **before**\nwriting, so new code matches the code already here.\n\n## Which reference to load\n\n| You are writing… | Load |\n| -------------------------------------------- | -------------------------- |\n| Any `.ts` / `.tsx` file | `references/typescript.md` |\n| A component, hook, or anything rendering JSX | `references/react.md` |\n| A `*.test.ts` / `*.test.tsx` file | `references/vitest.md` |\n\nThese layer: a new component with a test reads all three. Load every row that\napplies.\n\n## Universal rules\n\n- Filenames are kebab-case: `user-card.tsx`, `use-current-user.ts`, `format-date.ts`.\n- Before adding a file, open two neighbouring files of the same kind and match\n their shape — import order, export style, test naming.\n- Source lives under `src/`. Colocate a test beside the code it covers:\n `src/utils/format-date.ts` → `src/utils/format-date.test.ts`.\n\n## Verify\n\nAfter implementing, from the project root:\n\n```sh\npnpm lint\npnpm test\npnpm build\n```\n\nDone only when all three exit clean. On failure, fix and rerun until they do.\nRun `pnpm format` before committing. Comment- or doc-only changes may skip all\nfour.\n\n## Committing\n\nAsk before committing. Use conventional commits under 100 characters:\n`<type>(<scope>): <subject>`, e.g. `feat(auth): add sign-out button`. Scope is\nthe area you touched, not the repo name.\n"
261
+ }
262
+ ]
263
+ }];
264
+ /** Directory holding the real skill files, agent-agnostic. */
265
+ const SKILLS_ROOT = ".agents/skills";
266
+ /** Directory Claude Code reads skills from; its entries are links into {@link SKILLS_ROOT}. */
267
+ const CLAUDE_SKILLS_ROOT = ".claude/skills";
268
+ /** Files a skill writes, rooted at `.agents/skills/<name>/`. */
269
+ function skillFiles(skill) {
270
+ return skill.files.map((file) => ({
271
+ path: `${SKILLS_ROOT}/${skill.name}/${file.path}`,
272
+ contents: file.contents
273
+ }));
274
+ }
275
+ /**
276
+ * The `.claude/skills/<name>` link for a skill. Real files live under
277
+ * `.agents/skills/` so any agent tool can read them; Claude Code only looks in
278
+ * `.claude/skills/`, so it gets a link rather than a second copy.
279
+ */
280
+ function skillLink(skill) {
281
+ return {
282
+ path: `${CLAUDE_SKILLS_ROOT}/${skill.name}`,
283
+ target: `../../${SKILLS_ROOT}/${skill.name}`
284
+ };
285
+ }
242
286
  //#endregion
243
287
  //#region src/bundled-versions.ts
244
288
  const bundledVersions = {
@@ -411,7 +455,7 @@ function commitMsgHook(pm) {
411
455
  }
412
456
  //#endregion
413
457
  //#region src/commands/add-commitlint/index.ts
414
- const apply$6 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
458
+ const apply$7 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
415
459
  const fs = yield* FileSystem.FileSystem;
416
460
  const path = yield* Path.Path;
417
461
  const prompter = yield* Prompter;
@@ -433,7 +477,7 @@ function getPackages$4(pkg) {
433
477
  //#endregion
434
478
  //#region src/commands/add-husky/index.ts
435
479
  var HuskyInitFailed = class extends Data.TaggedError("HuskyInitFailed") {};
436
- const apply$5 = Effect.fn("add-husky.apply")(function* (cwd, pm) {
480
+ const apply$6 = Effect.fn("add-husky.apply")(function* (cwd, pm) {
437
481
  const fs = yield* FileSystem.FileSystem;
438
482
  const path = yield* Path.Path;
439
483
  const prompter = yield* Prompter;
@@ -489,7 +533,7 @@ function getScriptPlan$2(scripts) {
489
533
  //#endregion
490
534
  //#region src/commands/add-knip/index.ts
491
535
  /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
492
- const apply$4 = Effect.fn("add-knip.apply")(function* (cwd, esm, pkg) {
536
+ const apply$5 = Effect.fn("add-knip.apply")(function* (cwd, esm, pkg) {
493
537
  const fs = yield* FileSystem.FileSystem;
494
538
  const path = yield* Path.Path;
495
539
  const prompter = yield* Prompter;
@@ -526,7 +570,7 @@ function buildConfig(linters, formatters) {
526
570
  }
527
571
  //#endregion
528
572
  //#region src/commands/add-lint-staged/index.ts
529
- const apply$3 = Effect.fn("add-lint-staged.apply")(function* (cwd, linters = [], formatters = [], overwritePreCommit = false) {
573
+ const apply$4 = Effect.fn("add-lint-staged.apply")(function* (cwd, linters = [], formatters = [], overwritePreCommit = false) {
530
574
  if (linters.length === 0 && formatters.length === 0) {
531
575
  yield* Effect.logDebug("no linters or formatters selected - skipping lint-staged setup");
532
576
  return;
@@ -564,7 +608,7 @@ function getScriptPlan$1(scripts) {
564
608
  //#endregion
565
609
  //#region src/commands/add-oxfmt/index.ts
566
610
  /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
567
- const apply$2 = Effect.fn("add-oxfmt.apply")(function* (cwd, esm, pkg) {
611
+ const apply$3 = Effect.fn("add-oxfmt.apply")(function* (cwd, esm, pkg) {
568
612
  const fs = yield* FileSystem.FileSystem;
569
613
  const path = yield* Path.Path;
570
614
  const prompter = yield* Prompter;
@@ -605,7 +649,7 @@ function getScriptPlan(scripts) {
605
649
  //#endregion
606
650
  //#region src/commands/add-oxlint/index.ts
607
651
  /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
608
- const apply$1 = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
652
+ const apply$2 = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
609
653
  const fs = yield* FileSystem.FileSystem;
610
654
  const path = yield* Path.Path;
611
655
  const prompter = yield* Prompter;
@@ -623,6 +667,64 @@ const apply$1 = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
623
667
  };
624
668
  });
625
669
  //#endregion
670
+ //#region src/commands/add-skills/logic.ts
671
+ /**
672
+ * Splits the skill's files by whether they are already on disk. Existing files
673
+ * always win: a user who has edited the standards to fit their project keeps
674
+ * those edits, so re-running `chanom brew` is safe.
675
+ */
676
+ function planSkillFiles(files, exists) {
677
+ const write = [];
678
+ const skipped = [];
679
+ for (const file of files) if (exists(file.path)) skipped.push(file.path);
680
+ else write.push(file);
681
+ return {
682
+ write,
683
+ skipped
684
+ };
685
+ }
686
+ //#endregion
687
+ //#region src/commands/add-skills/index.ts
688
+ /**
689
+ * Links `.claude/skills/<name>` at the real directory under `.agents/skills/`.
690
+ * Anything already at the link path - a link the user already has, or a real
691
+ * directory they maintain by hand - is left alone and reported.
692
+ */
693
+ const linkSkill = Effect.fn("add-skills.linkSkill")(function* (cwd, link) {
694
+ const fs = yield* FileSystem.FileSystem;
695
+ const path = yield* Path.Path;
696
+ const prompter = yield* Prompter;
697
+ const linkPath = path.join(cwd, link.path);
698
+ if (yield* fs.exists(linkPath)) {
699
+ yield* prompter.warn(`\`${link.path}\` already exists - skipping link`);
700
+ return;
701
+ }
702
+ yield* fs.makeDirectory(path.dirname(linkPath), { recursive: true });
703
+ yield* fs.symlink(link.target, linkPath);
704
+ });
705
+ /**
706
+ * Writes every chanom skill into `.agents/skills/`, then links it into
707
+ * `.claude/skills/`. Files already on disk are left untouched, so re-running is
708
+ * safe. Contributes no packages and no scripts, so - like `add-vscode` - it
709
+ * returns nothing for the caller to persist.
710
+ */
711
+ const apply$1 = Effect.fn("add-skills.apply")(function* (cwd) {
712
+ const fs = yield* FileSystem.FileSystem;
713
+ const path = yield* Path.Path;
714
+ const prompter = yield* Prompter;
715
+ const files = SKILLS.flatMap(skillFiles);
716
+ const present = /* @__PURE__ */ new Set();
717
+ for (const file of files) if (yield* fs.exists(path.join(cwd, file.path))) present.add(file.path);
718
+ const plan = planSkillFiles(files, (filePath) => present.has(filePath));
719
+ for (const skipped of plan.skipped) yield* prompter.warn(`\`${skipped}\` already exists - skipping`);
720
+ for (const file of plan.write) {
721
+ const dest = path.join(cwd, file.path);
722
+ yield* fs.makeDirectory(path.dirname(dest), { recursive: true });
723
+ yield* fs.writeFileString(dest, file.contents);
724
+ }
725
+ for (const skill of SKILLS) yield* linkSkill(cwd, skillLink(skill));
726
+ });
727
+ //#endregion
626
728
  //#region src/commands/add-vscode/index.ts
627
729
  const writeJson = Effect.fn("add-vscode.writeJson")(function* (filePath, value) {
628
730
  yield* (yield* FileSystem.FileSystem).writeFileString(filePath, JSON.stringify(value, null, 2) + "\n");
@@ -785,6 +887,11 @@ const askRecipe = Effect.fn("brew.askRecipe")(function* () {
785
887
  value: "vscode",
786
888
  label: "vscode",
787
889
  hint: "editor settings + extension recommendations"
890
+ },
891
+ {
892
+ value: "skills",
893
+ label: "skills",
894
+ hint: "coding standards for AI agents (.claude/skills)"
788
895
  }
789
896
  ],
790
897
  required: false
@@ -816,10 +923,11 @@ const installPackages = Effect.fn("brew.installPackages")(function* (pm, cwd, pa
816
923
  });
817
924
  const applyToppings = Effect.fn("brew.applyToppings")(function* (cwd, esm, pkg, toppings) {
818
925
  let updated = pkg;
819
- if (toppings.includes("oxlint")) updated = yield* apply$1(cwd, esm, updated);
820
- if (toppings.includes("oxfmt")) updated = yield* apply$2(cwd, esm, updated);
821
- if (toppings.includes("knip")) updated = yield* apply$4(cwd, esm, updated);
926
+ if (toppings.includes("oxlint")) updated = yield* apply$2(cwd, esm, updated);
927
+ if (toppings.includes("oxfmt")) updated = yield* apply$3(cwd, esm, updated);
928
+ if (toppings.includes("knip")) updated = yield* apply$5(cwd, esm, updated);
822
929
  if (toppings.includes("vscode")) yield* apply(cwd, oxfmtConfigPath(esm));
930
+ if (toppings.includes("skills")) yield* apply$1(cwd);
823
931
  return updated;
824
932
  });
825
933
  const persistScripts = Effect.fn("brew.persistScripts")(function* (pkgPath, pkg, updated) {
@@ -851,14 +959,14 @@ const setupCommitGate = Effect.fn("brew.setupCommitGate")(function* (cwd, pm, to
851
959
  const path = yield* Path.Path;
852
960
  const prompter = yield* Prompter;
853
961
  const huskyExisted = yield* fs.exists(path.join(cwd, ".husky"));
854
- yield* apply$5(cwd, pm);
855
- if (wantsLintStaged(toppings)) yield* apply$3(cwd, selectedLinters(toppings), selectedFormatters(toppings), !huskyExisted);
962
+ yield* apply$6(cwd, pm);
963
+ if (wantsLintStaged(toppings)) yield* apply$4(cwd, selectedLinters(toppings), selectedFormatters(toppings), !huskyExisted);
856
964
  else {
857
965
  yield* prompter.warn("No linters or formatters selected - skipping lint-staged");
858
966
  const seededHook = path.join(cwd, ".husky", "pre-commit");
859
967
  if (!huskyExisted && (yield* fs.exists(seededHook))) yield* fs.remove(seededHook);
860
968
  }
861
- yield* apply$6(cwd, pm);
969
+ yield* apply$7(cwd, pm);
862
970
  });
863
971
  const commitChanges = Effect.fn("brew.commitChanges")(function* (cwd) {
864
972
  const git = yield* Git;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chanom/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "repository": {