create-chanom-app 0.1.0 → 0.3.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 +7 -1
  2. package/dist/index.js +166 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -31,7 +31,8 @@ Run with no flags, the CLI walks you through:
31
31
  1. **Project name** - the target directory (kebab-cased). Defaults to `my-chanom-app`.
32
32
  2. **Initialize a git repository?** - `git init` plus an initial commit.
33
33
  3. **Add commit hooks?** - husky + lint-staged + commitlint (only asked when git is enabled).
34
- 4. **Install dependencies now?** - runs `<pm> install` after scaffolding.
34
+ 4. **Add agent coding standards?** - the `coding-standards` skill in `.agents/skills`, linked into `.claude/skills`.
35
+ 5. **Install dependencies now?** - runs `<pm> install` after scaffolding.
35
36
 
36
37
  Every prompt has a matching flag so you can pre-answer any of them (or all, with `--yes`).
37
38
 
@@ -43,6 +44,8 @@ Every prompt has a matching flag so you can pre-answer any of them (or all, with
43
44
  | `--git` / `--no-git` | Initialize a git repository (default: yes) |
44
45
  | `--commit-hooks` | Add husky + lint-staged + commitlint (requires git) |
45
46
  | `--no-commit-hooks` | Skip the commit-hooks topping |
47
+ | `--agent-skills` | Add the coding-standards skill (default: yes) |
48
+ | `--no-agent-skills` | Skip the agent-skills topping |
46
49
  | `--install` / `--no-install` | Install dependencies after scaffolding (default: install) |
47
50
  | `--pm <pnpm\|npm\|yarn\|bun>` | Package manager to record (default: detected) |
48
51
  | `-y`, `--yes` | Accept all defaults, no prompts (CI-friendly) |
@@ -71,10 +74,13 @@ A minimal Vite + React + TS app wired to the chanom shared configs:
71
74
  - `oxlint.config.ts` / `oxfmt.config.ts` - re-exporting the [`@chanom/dev-config`](../dev-config) presets
72
75
  - `vitest.config.ts` and a sample `src/app.test.tsx`
73
76
  - `src/` - `main.tsx`, `app.tsx`, `index.css`, and type declarations
77
+ - `.vscode/settings.json` and `.vscode/extensions.json` - editor settings that run oxfmt formatting and oxlint fixes on save through the [oxc extension](https://marketplace.visualstudio.com/items?itemName=oxc.oxc-vscode), which is recommended so teammates are prompted to install it
74
78
  - `index.html`, `.gitignore`, and a starter `README.md`
75
79
 
76
80
  With the **commit-hooks** topping, it also adds husky, lint-staged, and commitlint as dev dependencies plus their config files (`.husky/pre-commit`, `.husky/commit-msg`, `.lintstagedrc.json`, `.commitlintrc.json`). The hooks materialize on the first install via the generated `prepare: "husky"` script.
77
81
 
82
+ With the **agent-skills** topping, it adds the `coding-standards` agent skill - a router `SKILL.md` plus TypeScript, React, and Vitest references written for exactly the stack scaffolded here. The real files land in `.agents/skills/coding-standards/` so any agent tool can read them, and `.claude/skills/coding-standards` is a relative symlink pointing at that directory rather than a second copy. It contributes no packages and no scripts, so skipping it leaves both directories out entirely.
83
+
78
84
  ## Development
79
85
 
80
86
  ```sh
package/dist/index.js CHANGED
@@ -39,6 +39,43 @@ function resolvePm(hints) {
39
39
  return parsePmField(hints.packageManagerField) ?? parsePmUserAgent(hints.userAgent) ?? hints.fallback ?? "pnpm";
40
40
  }
41
41
  //#endregion
42
+ //#region ../internal/src/domain/vscode.ts
43
+ /** Keys VS Code reads as a per-language override, e.g. `[typescript]`. */
44
+ const LANGUAGES = [
45
+ "markdown",
46
+ "json",
47
+ "typescriptreact",
48
+ "typescript",
49
+ "javascript"
50
+ ];
51
+ const OXC_FORMATTER = "oxc.oxc-vscode";
52
+ /** The extensions a chanom project recommends; unioned into an existing list. */
53
+ const RECOMMENDED_EXTENSIONS = [OXC_FORMATTER];
54
+ /**
55
+ * The VS Code settings chanom contributes. Every value is portable across
56
+ * projects - repo-specific entries (`cSpell.words`, `git.*`) are deliberately
57
+ * excluded so this can be dropped into any project without carrying chanom's
58
+ * own choices.
59
+ *
60
+ * `editor.formatOnSave` is false on purpose: formatting runs through the oxc
61
+ * code action instead, so formatting and lint fixes apply in one pass rather
62
+ * than racing each other.
63
+ */
64
+ function chanomSettings(oxfmtConfigPath) {
65
+ return {
66
+ "editor.defaultFormatter": OXC_FORMATTER,
67
+ "editor.formatOnSave": false,
68
+ "editor.codeActionsOnSave": {
69
+ "source.format.oxc": "always",
70
+ "source.fixAll.oxc": "always",
71
+ "source.removeUnusedImports": "always"
72
+ },
73
+ "oxc.fmt.configPath": oxfmtConfigPath,
74
+ "oxc.typeAware": true,
75
+ ...Object.fromEntries(LANGUAGES.map((language) => [`[${language}]`, { "editor.defaultFormatter": OXC_FORMATTER }]))
76
+ };
77
+ }
78
+ //#endregion
42
79
  //#region ../internal/src/services/command-runner.ts
43
80
  const collectText = (stream) => stream.pipe(Stream.decodeText(), Stream.mkString);
44
81
  /**
@@ -169,6 +206,58 @@ var Prompter = class extends Effect.Service()("internal/Prompter", { succeed: {
169
206
  })
170
207
  } }) {};
171
208
  //#endregion
209
+ //#region ../internal/src/skills/index.ts
210
+ /**
211
+ * The coding-standards skill, targeting the stack `create-chanom-app` scaffolds
212
+ * (Vite + React + TypeScript, oxlint, oxfmt, vitest). Its markdown is authored
213
+ * as real `.md` files under `skills/coding-standards/` and inlined here at
214
+ * build time through the `__CODING_STANDARDS_FILES__` define, so `chanom brew`
215
+ * and `create-chanom-app` install byte-identical content from one source.
216
+ */
217
+ const codingStandardsSkill = {
218
+ name: "coding-standards",
219
+ files: [
220
+ {
221
+ "path": "references/react.md",
222
+ "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"
223
+ },
224
+ {
225
+ "path": "references/typescript.md",
226
+ "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"
227
+ },
228
+ {
229
+ "path": "references/vitest.md",
230
+ "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"
231
+ },
232
+ {
233
+ "path": "SKILL.md",
234
+ "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"
235
+ }
236
+ ]
237
+ };
238
+ /** Directory holding the real skill files, agent-agnostic. */
239
+ const SKILLS_ROOT = ".agents/skills";
240
+ /** Directory Claude Code reads skills from; its entries are links into {@link SKILLS_ROOT}. */
241
+ const CLAUDE_SKILLS_ROOT = ".claude/skills";
242
+ /** Files a skill writes, rooted at `.agents/skills/<name>/`. */
243
+ function skillFiles(skill) {
244
+ return skill.files.map((file) => ({
245
+ path: `${SKILLS_ROOT}/${skill.name}/${file.path}`,
246
+ contents: file.contents
247
+ }));
248
+ }
249
+ /**
250
+ * The `.claude/skills/<name>` link for a skill. Real files live under
251
+ * `.agents/skills/` so any agent tool can read them; Claude Code only looks in
252
+ * `.claude/skills/`, so it gets a link rather than a second copy.
253
+ */
254
+ function skillLink(skill) {
255
+ return {
256
+ path: `${CLAUDE_SKILLS_ROOT}/${skill.name}`,
257
+ target: `../../${SKILLS_ROOT}/${skill.name}`
258
+ };
259
+ }
260
+ //#endregion
172
261
  //#region src/bundled-versions.ts
173
262
  const depVersions = {
174
263
  "react": "19.2.8",
@@ -195,7 +284,7 @@ const depVersions = {
195
284
  "@chanom/vite-config": "0.1.0"
196
285
  };
197
286
  /** This package's own version, for `--version`. */
198
- const pkgVersion = "0.1.0";
287
+ const pkgVersion = "0.3.0";
199
288
  //#endregion
200
289
  //#region src/domain/args.ts
201
290
  const PACKAGE_MANAGERS = [
@@ -243,6 +332,12 @@ function parseArgs(argv) {
243
332
  case "--no-commit-hooks":
244
333
  args.commitHooks = false;
245
334
  break;
335
+ case "--agent-skills":
336
+ args.agentSkills = true;
337
+ break;
338
+ case "--no-agent-skills":
339
+ args.agentSkills = false;
340
+ break;
246
341
  case "--install":
247
342
  args.install = true;
248
343
  break;
@@ -305,16 +400,27 @@ function toKebabCase(name) {
305
400
  }
306
401
  //#endregion
307
402
  //#region src/domain/contribution.ts
403
+ const emptyContribution = {
404
+ dependencies: [],
405
+ devDependencies: [],
406
+ scripts: {},
407
+ viteImports: [],
408
+ vitePlugins: [],
409
+ files: [],
410
+ links: []
411
+ };
308
412
  const dedupe = (values) => [...new Set(values)];
309
413
  /**
310
414
  * Folds contributions in order into a single value. Package names, vite imports,
311
415
  * and plugins are concatenated then de-duplicated (first occurrence wins its
312
416
  * position); scripts merge with later contributions overriding earlier keys;
313
- * files de-duplicate by path (first writer wins).
417
+ * files and links de-duplicate by path (first writer wins).
314
418
  */
315
419
  function mergeContributions(contributions) {
316
420
  const filesByPath = /* @__PURE__ */ new Map();
317
421
  for (const contribution of contributions) for (const file of contribution.files) if (!filesByPath.has(file.path)) filesByPath.set(file.path, file);
422
+ const linksByPath = /* @__PURE__ */ new Map();
423
+ for (const contribution of contributions) for (const link of contribution.links) if (!linksByPath.has(link.path)) linksByPath.set(link.path, link);
318
424
  return {
319
425
  dependencies: dedupe(contributions.flatMap((c) => c.dependencies)),
320
426
  devDependencies: dedupe(contributions.flatMap((c) => c.devDependencies)),
@@ -324,7 +430,8 @@ function mergeContributions(contributions) {
324
430
  }), {}),
325
431
  viteImports: dedupe(contributions.flatMap((c) => c.viteImports)),
326
432
  vitePlugins: dedupe(contributions.flatMap((c) => c.vitePlugins)),
327
- files: [...filesByPath.values()]
433
+ files: [...filesByPath.values()],
434
+ links: [...linksByPath.values()]
328
435
  };
329
436
  }
330
437
  //#endregion
@@ -515,6 +622,7 @@ const TEMPLATE_FILES = [
515
622
  ];
516
623
  //#endregion
517
624
  //#region src/toppings.ts
625
+ const asJson = (value) => JSON.stringify(value, null, 2) + "\n";
518
626
  /**
519
627
  * The always-present baseline: a minimal Vite + React + TS app wired to the
520
628
  * chanom shared configs. Its static source files ship as verbatim templates;
@@ -553,7 +661,14 @@ const baseline = {
553
661
  },
554
662
  viteImports: ["import react from '@vitejs/plugin-react';"],
555
663
  vitePlugins: ["react()"],
556
- files: []
664
+ files: [{
665
+ path: ".vscode/settings.json",
666
+ contents: asJson(chanomSettings("oxfmt.config.ts"))
667
+ }, {
668
+ path: ".vscode/extensions.json",
669
+ contents: asJson({ recommendations: RECOMMENDED_EXTENSIONS })
670
+ }],
671
+ links: []
557
672
  };
558
673
  const LINT_STAGED_CONFIG = {
559
674
  "**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}": ["oxlint --fix --no-error-on-unmatched-pattern"],
@@ -600,12 +715,27 @@ function commitHooks(pm) {
600
715
  path: ".commitlintrc.json",
601
716
  contents: JSON.stringify(COMMITLINT_CONFIG, null, 2) + "\n"
602
717
  }
603
- ]
718
+ ],
719
+ links: []
604
720
  };
605
721
  }
722
+ /**
723
+ * The `agent-skills` topping: the chanom coding-standards skill. Real files go
724
+ * to `.agents/skills/` so any agent tool can read them, and `.claude/skills/`
725
+ * gets a symlink rather than a second copy. Contributes files and links only -
726
+ * no packages, no scripts - so projects that skip it get neither directory.
727
+ */
728
+ const agentSkills = {
729
+ ...emptyContribution,
730
+ files: skillFiles(codingStandardsSkill),
731
+ links: [skillLink(codingStandardsSkill)]
732
+ };
606
733
  /** The baseline plus whichever toppings the resolved options select, in fold order. */
607
734
  function selectContributions(selection) {
608
- return selection.commitHooks ? [baseline, commitHooks(selection.pm)] : [baseline];
735
+ const contributions = [baseline];
736
+ if (selection.commitHooks) contributions.push(commitHooks(selection.pm));
737
+ if (selection.agentSkills) contributions.push(agentSkills);
738
+ return contributions;
609
739
  }
610
740
  //#endregion
611
741
  //#region src/scaffold.ts
@@ -627,6 +757,25 @@ const copyTemplates = Effect.fn("scaffold.copyTemplates")(function* (plan) {
627
757
  yield* writeFile(plan.targetDir, file.dest, contents);
628
758
  }
629
759
  });
760
+ /**
761
+ * Creates one topping-contributed symlink, making its parent directory first.
762
+ * Scaffolding into an existing directory (`create chanom-app .`) can meet a link
763
+ * an earlier run - or `chanom add-skills` - already made, and `symlink` fails
764
+ * with EEXIST on anything already there, so an occupied path is left alone and
765
+ * reported instead of aborting the scaffold after the files are written.
766
+ */
767
+ const writeLink = Effect.fn("scaffold.writeLink")(function* (targetDir, link) {
768
+ const fs = yield* FileSystem.FileSystem;
769
+ const path = yield* Path.Path;
770
+ const prompter = yield* Prompter;
771
+ const linkPath = path.join(targetDir, link.path);
772
+ if (yield* fs.exists(linkPath)) {
773
+ yield* prompter.warn(`\`${link.path}\` already exists - skipping link`);
774
+ return;
775
+ }
776
+ yield* fs.makeDirectory(path.dirname(linkPath), { recursive: true });
777
+ yield* fs.symlink(link.target, linkPath);
778
+ });
630
779
  const writeGeneratedFiles = Effect.fn("scaffold.writeGeneratedFiles")(function* (plan, folded) {
631
780
  const appName = toKebabCase(plan.appName);
632
781
  yield* writeFile(plan.targetDir, "package.json", buildPackageJson({
@@ -640,6 +789,7 @@ const writeGeneratedFiles = Effect.fn("scaffold.writeGeneratedFiles")(function*
640
789
  yield* writeFile(plan.targetDir, "tsconfig.json", buildTsconfig());
641
790
  yield* writeFile(plan.targetDir, "index.html", renderIndexHtml(appName));
642
791
  for (const file of folded.files) yield* writeFile(plan.targetDir, file.path, file.contents);
792
+ for (const link of folded.links) yield* writeLink(plan.targetDir, link);
643
793
  });
644
794
  const initGit = Effect.fn("scaffold.initGit")(function* (plan) {
645
795
  const git = yield* Git;
@@ -665,7 +815,8 @@ const scaffold = Effect.fn("scaffold")(function* (plan) {
665
815
  const fs = yield* FileSystem.FileSystem;
666
816
  const folded = mergeContributions(selectContributions({
667
817
  pm: plan.pm,
668
- commitHooks: plan.commitHooks
818
+ commitHooks: plan.commitHooks,
819
+ agentSkills: plan.agentSkills
669
820
  }));
670
821
  yield* fs.makeDirectory(plan.targetDir, { recursive: true });
671
822
  yield* copyTemplates(plan);
@@ -687,6 +838,8 @@ ${pc.bold("Options")}
687
838
  ${pc.cyan("--git / --no-git")} Initialize a git repository (default: yes)
688
839
  ${pc.cyan("--commit-hooks")} Add husky + lint-staged + commitlint (requires git)
689
840
  ${pc.cyan("--no-commit-hooks")} Skip the commit-hooks topping
841
+ ${pc.cyan("--agent-skills")} Add the coding-standards skill to .claude/skills
842
+ ${pc.cyan("--no-agent-skills")} Skip the agent-skills topping
690
843
  ${pc.cyan("--install / --no-install")} Install dependencies after scaffolding (default: install)
691
844
  ${pc.cyan("--pm <pnpm|npm|yarn|bun>")} Package manager to record (default: detected)
692
845
  ${pc.cyan("-y, --yes")} Accept all defaults, no prompts (CI-friendly)
@@ -758,6 +911,10 @@ const program$1 = (argv, cwd, options) => Effect.gen(function* () {
758
911
  message: "Add commit hooks (husky + lint-staged + commitlint)?",
759
912
  initialValue: true
760
913
  })) : false;
914
+ const agentSkills = yield* resolveConfirm(args.agentSkills, args.yes, prompter.confirm({
915
+ message: "Add agent coding standards (.agents/skills)?",
916
+ initialValue: true
917
+ }));
761
918
  const install = yield* resolveConfirm(args.install, args.yes, prompter.confirm({
762
919
  message: "Install dependencies now?",
763
920
  initialValue: true
@@ -771,6 +928,7 @@ const program$1 = (argv, cwd, options) => Effect.gen(function* () {
771
928
  `${pc.dim("package mgr")} ${pm}`,
772
929
  `${pc.dim("git")} ${git ? "yes" : "no"}`,
773
930
  `${pc.dim("commit hooks")} ${git ? commitHooks ? "yes" : "no" : pc.dim("n/a")}`,
931
+ `${pc.dim("agent skills")} ${agentSkills ? "yes" : "no"}`,
774
932
  `${pc.dim("install")} ${install ? "yes" : "no"}`
775
933
  ].join("\n"));
776
934
  yield* scaffold({
@@ -780,6 +938,7 @@ const program$1 = (argv, cwd, options) => Effect.gen(function* () {
780
938
  packageManagerField: packageManagerField(pm, options.userAgent),
781
939
  git,
782
940
  commitHooks,
941
+ agentSkills,
783
942
  install,
784
943
  templatesRoot: options.templatesRoot
785
944
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-chanom-app",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold a Vite + React + TypeScript app, wired to the chanom shared configs.",
5
5
  "keywords": [
6
6
  "create",