@chanom/cli 0.3.0 → 0.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/README.md +26 -7
- package/dist/index.js +201 -34
- package/package.json +3 -2
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,15 +62,22 @@ installing anything. `light` sweetness works anywhere in the repo.
|
|
|
61
62
|
|
|
62
63
|
## What gets added
|
|
63
64
|
|
|
64
|
-
| Topping | Config file
|
|
65
|
-
| -------- |
|
|
66
|
-
| `oxlint` | `oxlint.config.ts`\*
|
|
67
|
-
| `oxfmt` | `oxfmt.config.ts`\*
|
|
68
|
-
| `knip` | `knip.config.ts`\*
|
|
69
|
-
| `vscode` | `.vscode/settings.json`, `.vscode/extensions.json`
|
|
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
|
|
|
75
|
+
oxlint and oxfmt auto-discover `.js`, `.cjs`, `.mjs`, `.ts`, and the JSON rc files, but not
|
|
76
|
+
`.mts` or `.cts`. When the config in play is one of those two, the generated `oxlint`/`oxfmt`
|
|
77
|
+
scripts - and the lint-staged commands - name it with `-c`. This tracks the file that actually
|
|
78
|
+
ends up on disk, so it applies to a config `brew` detected and skipped just as much as one it
|
|
79
|
+
wrote itself.
|
|
80
|
+
|
|
73
81
|
Config files re-export presets from [`@chanom/dev-config`](../dev-config).
|
|
74
82
|
|
|
75
83
|
The `vscode` topping installs no packages and adds no scripts. It writes editor
|
|
@@ -80,6 +88,13 @@ on save (`editor.formatOnSave` stays off so both run in one pass), points
|
|
|
80
88
|
teammates are prompted to install it. Only portable settings are written - no
|
|
81
89
|
`cSpell.words` or other repo-specific entries.
|
|
82
90
|
|
|
91
|
+
The `skills` topping also installs no packages and adds no scripts. It writes
|
|
92
|
+
the `coding-standards` agent skill - a router `SKILL.md` plus TypeScript, React,
|
|
93
|
+
and Vitest references - into `.agents/skills/coding-standards/`, so any agent
|
|
94
|
+
tool can read it, and links `.claude/skills/coding-standards` at that directory
|
|
95
|
+
with a relative symlink rather than keeping a second copy. The standards target
|
|
96
|
+
the stack chanom scaffolds (Vite + React + TypeScript, oxlint, oxfmt, vitest).
|
|
97
|
+
|
|
83
98
|
`medium` sweetness additionally adds:
|
|
84
99
|
|
|
85
100
|
- `husky` - installed via `husky init`
|
|
@@ -105,6 +120,10 @@ Existing setup is never overwritten:
|
|
|
105
120
|
setting you already have wins (and is warned about), and existing extension
|
|
106
121
|
recommendations are kept. A `.vscode` file that can't be parsed as JSON (e.g. it
|
|
107
122
|
has comments) is left untouched with a warning.
|
|
123
|
+
- Skill files already on disk are warned about and skipped, and an existing
|
|
124
|
+
`.claude/skills/coding-standards` (a link of your own, or a real directory you
|
|
125
|
+
maintain by hand) is left alone - so re-running never overwrites standards you
|
|
126
|
+
have edited.
|
|
108
127
|
|
|
109
128
|
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
129
|
|
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\npnpm typecheck\n```\n\nDone only when all four 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 = {
|
|
@@ -380,6 +424,32 @@ const SETUP_FILE_CANDIDATES = {
|
|
|
380
424
|
"vite.config.mjs"
|
|
381
425
|
]
|
|
382
426
|
};
|
|
427
|
+
/** Config file name a tool gets scaffolded with, matching the project's module system. */
|
|
428
|
+
function configFileName(tool, esm) {
|
|
429
|
+
return `${tool}.config.${esm ? "ts" : "mts"}`;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Extensions oxlint/oxfmt resolve on their own. `.mts`/`.cts` are absent
|
|
433
|
+
* deliberately: the tools only auto-discover the plain `.ts` variant, so a
|
|
434
|
+
* config using one of those has to be named explicitly on the command line.
|
|
435
|
+
*/
|
|
436
|
+
const AUTO_DISCOVERED = /* @__PURE__ */ new Set([
|
|
437
|
+
"js",
|
|
438
|
+
"cjs",
|
|
439
|
+
"mjs",
|
|
440
|
+
"ts",
|
|
441
|
+
"json"
|
|
442
|
+
]);
|
|
443
|
+
/**
|
|
444
|
+
* `-c <config>` argument naming the config file, or an empty string when the
|
|
445
|
+
* tool finds the file by itself. `configFile` is the config that will actually
|
|
446
|
+
* be on disk - the one chanom is about to write, or an existing one it detected
|
|
447
|
+
* and skipped - so the generated scripts never point at a path that isn't there.
|
|
448
|
+
*/
|
|
449
|
+
function configFlag(configFile) {
|
|
450
|
+
const extension = configFile.slice(configFile.lastIndexOf(".") + 1);
|
|
451
|
+
return AUTO_DISCOVERED.has(extension) ? "" : ` -c ${configFile}`;
|
|
452
|
+
}
|
|
383
453
|
//#endregion
|
|
384
454
|
//#region src/utils/detect-setup.ts
|
|
385
455
|
/** Finds an existing config file for the given tool, if any. */
|
|
@@ -411,7 +481,7 @@ function commitMsgHook(pm) {
|
|
|
411
481
|
}
|
|
412
482
|
//#endregion
|
|
413
483
|
//#region src/commands/add-commitlint/index.ts
|
|
414
|
-
const apply$
|
|
484
|
+
const apply$7 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
|
|
415
485
|
const fs = yield* FileSystem.FileSystem;
|
|
416
486
|
const path = yield* Path.Path;
|
|
417
487
|
const prompter = yield* Prompter;
|
|
@@ -433,7 +503,7 @@ function getPackages$4(pkg) {
|
|
|
433
503
|
//#endregion
|
|
434
504
|
//#region src/commands/add-husky/index.ts
|
|
435
505
|
var HuskyInitFailed = class extends Data.TaggedError("HuskyInitFailed") {};
|
|
436
|
-
const apply$
|
|
506
|
+
const apply$6 = Effect.fn("add-husky.apply")(function* (cwd, pm) {
|
|
437
507
|
const fs = yield* FileSystem.FileSystem;
|
|
438
508
|
const path = yield* Path.Path;
|
|
439
509
|
const prompter = yield* Prompter;
|
|
@@ -479,7 +549,7 @@ function getPackages$3(pkg, versions) {
|
|
|
479
549
|
}
|
|
480
550
|
function configFile$2(esm) {
|
|
481
551
|
return {
|
|
482
|
-
fileName:
|
|
552
|
+
fileName: configFileName("knip", esm),
|
|
483
553
|
contents: `export { default } from '@chanom/dev-config/knip/config';\n`
|
|
484
554
|
};
|
|
485
555
|
}
|
|
@@ -489,7 +559,7 @@ function getScriptPlan$2(scripts) {
|
|
|
489
559
|
//#endregion
|
|
490
560
|
//#region src/commands/add-knip/index.ts
|
|
491
561
|
/** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
|
|
492
|
-
const apply$
|
|
562
|
+
const apply$5 = Effect.fn("add-knip.apply")(function* (cwd, esm, pkg) {
|
|
493
563
|
const fs = yield* FileSystem.FileSystem;
|
|
494
564
|
const path = yield* Path.Path;
|
|
495
565
|
const prompter = yield* Prompter;
|
|
@@ -511,22 +581,42 @@ const apply$4 = Effect.fn("add-knip.apply")(function* (cwd, esm, pkg) {
|
|
|
511
581
|
function getPackages$2(pkg) {
|
|
512
582
|
return isPackageInstalled(pkg, "lint-staged") ? [] : ["lint-staged"];
|
|
513
583
|
}
|
|
514
|
-
function buildConfig(linters, formatters) {
|
|
584
|
+
function buildConfig(linters, formatters, configFiles) {
|
|
515
585
|
const config = {};
|
|
516
586
|
const jsGlob = "**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}";
|
|
517
587
|
if (linters.includes("oxlint")) {
|
|
518
588
|
config[jsGlob] ??= [];
|
|
519
|
-
config[jsGlob].push(
|
|
589
|
+
config[jsGlob].push(`oxlint --fix${configFlag(configFiles.oxlint)} --no-error-on-unmatched-pattern`);
|
|
520
590
|
}
|
|
521
591
|
if (formatters.includes("oxfmt")) {
|
|
522
592
|
config["*"] ??= [];
|
|
523
|
-
config["*"].push(
|
|
593
|
+
config["*"].push(`oxfmt${configFlag(configFiles.oxfmt)} --no-error-on-unmatched-pattern`);
|
|
524
594
|
}
|
|
525
595
|
return config;
|
|
526
596
|
}
|
|
527
597
|
//#endregion
|
|
528
598
|
//#region src/commands/add-lint-staged/index.ts
|
|
529
|
-
|
|
599
|
+
/**
|
|
600
|
+
* Config file each tool will run against: an existing one when `add-oxlint` /
|
|
601
|
+
* `add-oxfmt` skipped writing, otherwise the name they scaffold. lint-staged
|
|
602
|
+
* invokes the same binaries as the package scripts, so it has to resolve the
|
|
603
|
+
* config the same way.
|
|
604
|
+
*/
|
|
605
|
+
const resolveConfigFiles = Effect.fn("add-lint-staged.resolveConfigFiles")(function* (cwd, esm) {
|
|
606
|
+
const path = yield* Path.Path;
|
|
607
|
+
const resolve = Effect.fn(function* (tool) {
|
|
608
|
+
const existing = yield* detectSetupFile(tool, cwd);
|
|
609
|
+
return Option.match(existing, {
|
|
610
|
+
onNone: () => configFileName(tool, esm),
|
|
611
|
+
onSome: (file) => path.basename(file)
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
return {
|
|
615
|
+
oxlint: yield* resolve("oxlint"),
|
|
616
|
+
oxfmt: yield* resolve("oxfmt")
|
|
617
|
+
};
|
|
618
|
+
});
|
|
619
|
+
const apply$4 = Effect.fn("add-lint-staged.apply")(function* (cwd, esm, linters = [], formatters = [], overwritePreCommit = false) {
|
|
530
620
|
if (linters.length === 0 && formatters.length === 0) {
|
|
531
621
|
yield* Effect.logDebug("no linters or formatters selected - skipping lint-staged setup");
|
|
532
622
|
return;
|
|
@@ -536,7 +626,10 @@ const apply$3 = Effect.fn("add-lint-staged.apply")(function* (cwd, linters = [],
|
|
|
536
626
|
const prompter = yield* Prompter;
|
|
537
627
|
const existing = yield* detectSetupFile("lint-staged", cwd);
|
|
538
628
|
if (Option.isSome(existing)) yield* prompter.warn(`\`${path.basename(existing.value)}\` already exists - skipping lint-staged config`);
|
|
539
|
-
else
|
|
629
|
+
else {
|
|
630
|
+
const configFiles = yield* resolveConfigFiles(cwd, esm);
|
|
631
|
+
yield* fs.writeFileString(path.join(cwd, ".lintstagedrc.json"), JSON.stringify(buildConfig(linters, formatters, configFiles), null, 2) + "\n");
|
|
632
|
+
}
|
|
540
633
|
const huskyDir = path.join(cwd, ".husky");
|
|
541
634
|
if (yield* fs.exists(huskyDir)) {
|
|
542
635
|
const hookPath = path.join(huskyDir, "pre-commit");
|
|
@@ -551,30 +644,35 @@ function getPackages$1(pkg, versions) {
|
|
|
551
644
|
}
|
|
552
645
|
function configFile$1(esm) {
|
|
553
646
|
return {
|
|
554
|
-
fileName:
|
|
647
|
+
fileName: configFileName("oxfmt", esm),
|
|
555
648
|
contents: `export { default } from '@chanom/dev-config/oxfmt/config';\n`
|
|
556
649
|
};
|
|
557
650
|
}
|
|
558
|
-
function getScriptPlan$1(scripts) {
|
|
651
|
+
function getScriptPlan$1(scripts, configFile) {
|
|
652
|
+
const config = configFlag(configFile);
|
|
559
653
|
return planScripts(scripts, {
|
|
560
|
-
format:
|
|
561
|
-
"format:check":
|
|
654
|
+
format: `oxfmt${config}`,
|
|
655
|
+
"format:check": `oxfmt --check${config}`
|
|
562
656
|
});
|
|
563
657
|
}
|
|
564
658
|
//#endregion
|
|
565
659
|
//#region src/commands/add-oxfmt/index.ts
|
|
566
660
|
/** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
|
|
567
|
-
const apply$
|
|
661
|
+
const apply$3 = Effect.fn("add-oxfmt.apply")(function* (cwd, esm, pkg) {
|
|
568
662
|
const fs = yield* FileSystem.FileSystem;
|
|
569
663
|
const path = yield* Path.Path;
|
|
570
664
|
const prompter = yield* Prompter;
|
|
571
665
|
const existing = yield* detectSetupFile("oxfmt", cwd);
|
|
572
|
-
|
|
573
|
-
|
|
666
|
+
let configFileName;
|
|
667
|
+
if (Option.isSome(existing)) {
|
|
668
|
+
configFileName = path.basename(existing.value);
|
|
669
|
+
yield* prompter.warn(`\`${configFileName}\` already exists - skipping oxfmt config`);
|
|
670
|
+
} else {
|
|
574
671
|
const config = configFile$1(esm);
|
|
672
|
+
configFileName = config.fileName;
|
|
575
673
|
yield* fs.writeFileString(path.join(cwd, config.fileName), config.contents);
|
|
576
674
|
}
|
|
577
|
-
const plan = getScriptPlan$1(pkg.scripts);
|
|
675
|
+
const plan = getScriptPlan$1(pkg.scripts, configFileName);
|
|
578
676
|
for (const key of plan.skipped) yield* prompter.warn(`\`${key}\` script already exists in package.json - skipping`);
|
|
579
677
|
return {
|
|
580
678
|
...pkg,
|
|
@@ -592,30 +690,35 @@ function getPackages(pkg, versions) {
|
|
|
592
690
|
}
|
|
593
691
|
function configFile(esm) {
|
|
594
692
|
return {
|
|
595
|
-
fileName:
|
|
693
|
+
fileName: configFileName("oxlint", esm),
|
|
596
694
|
contents: `export { default } from '@chanom/dev-config/oxlint/config';\n`
|
|
597
695
|
};
|
|
598
696
|
}
|
|
599
|
-
function getScriptPlan(scripts) {
|
|
697
|
+
function getScriptPlan(scripts, configFile) {
|
|
698
|
+
const config = configFlag(configFile);
|
|
600
699
|
return planScripts(scripts, {
|
|
601
|
-
lint:
|
|
602
|
-
"lint:fix":
|
|
700
|
+
lint: `oxlint${config}`,
|
|
701
|
+
"lint:fix": `oxlint --fix${config}`
|
|
603
702
|
});
|
|
604
703
|
}
|
|
605
704
|
//#endregion
|
|
606
705
|
//#region src/commands/add-oxlint/index.ts
|
|
607
706
|
/** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
|
|
608
|
-
const apply$
|
|
707
|
+
const apply$2 = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
|
|
609
708
|
const fs = yield* FileSystem.FileSystem;
|
|
610
709
|
const path = yield* Path.Path;
|
|
611
710
|
const prompter = yield* Prompter;
|
|
612
711
|
const existing = yield* detectSetupFile("oxlint", cwd);
|
|
613
|
-
|
|
614
|
-
|
|
712
|
+
let configFileName;
|
|
713
|
+
if (Option.isSome(existing)) {
|
|
714
|
+
configFileName = path.basename(existing.value);
|
|
715
|
+
yield* prompter.warn(`\`${configFileName}\` already exists - skipping oxlint config`);
|
|
716
|
+
} else {
|
|
615
717
|
const config = configFile(esm);
|
|
718
|
+
configFileName = config.fileName;
|
|
616
719
|
yield* fs.writeFileString(path.join(cwd, config.fileName), config.contents);
|
|
617
720
|
}
|
|
618
|
-
const plan = getScriptPlan(pkg.scripts);
|
|
721
|
+
const plan = getScriptPlan(pkg.scripts, configFileName);
|
|
619
722
|
for (const key of plan.skipped) yield* prompter.warn(`\`${key}\` script already exists in package.json - skipping`);
|
|
620
723
|
return {
|
|
621
724
|
...pkg,
|
|
@@ -623,6 +726,64 @@ const apply$1 = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
|
|
|
623
726
|
};
|
|
624
727
|
});
|
|
625
728
|
//#endregion
|
|
729
|
+
//#region src/commands/add-skills/logic.ts
|
|
730
|
+
/**
|
|
731
|
+
* Splits the skill's files by whether they are already on disk. Existing files
|
|
732
|
+
* always win: a user who has edited the standards to fit their project keeps
|
|
733
|
+
* those edits, so re-running `chanom brew` is safe.
|
|
734
|
+
*/
|
|
735
|
+
function planSkillFiles(files, exists) {
|
|
736
|
+
const write = [];
|
|
737
|
+
const skipped = [];
|
|
738
|
+
for (const file of files) if (exists(file.path)) skipped.push(file.path);
|
|
739
|
+
else write.push(file);
|
|
740
|
+
return {
|
|
741
|
+
write,
|
|
742
|
+
skipped
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
//#endregion
|
|
746
|
+
//#region src/commands/add-skills/index.ts
|
|
747
|
+
/**
|
|
748
|
+
* Links `.claude/skills/<name>` at the real directory under `.agents/skills/`.
|
|
749
|
+
* Anything already at the link path - a link the user already has, or a real
|
|
750
|
+
* directory they maintain by hand - is left alone and reported.
|
|
751
|
+
*/
|
|
752
|
+
const linkSkill = Effect.fn("add-skills.linkSkill")(function* (cwd, link) {
|
|
753
|
+
const fs = yield* FileSystem.FileSystem;
|
|
754
|
+
const path = yield* Path.Path;
|
|
755
|
+
const prompter = yield* Prompter;
|
|
756
|
+
const linkPath = path.join(cwd, link.path);
|
|
757
|
+
if (yield* fs.exists(linkPath)) {
|
|
758
|
+
yield* prompter.warn(`\`${link.path}\` already exists - skipping link`);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
yield* fs.makeDirectory(path.dirname(linkPath), { recursive: true });
|
|
762
|
+
yield* fs.symlink(link.target, linkPath);
|
|
763
|
+
});
|
|
764
|
+
/**
|
|
765
|
+
* Writes every chanom skill into `.agents/skills/`, then links it into
|
|
766
|
+
* `.claude/skills/`. Files already on disk are left untouched, so re-running is
|
|
767
|
+
* safe. Contributes no packages and no scripts, so - like `add-vscode` - it
|
|
768
|
+
* returns nothing for the caller to persist.
|
|
769
|
+
*/
|
|
770
|
+
const apply$1 = Effect.fn("add-skills.apply")(function* (cwd) {
|
|
771
|
+
const fs = yield* FileSystem.FileSystem;
|
|
772
|
+
const path = yield* Path.Path;
|
|
773
|
+
const prompter = yield* Prompter;
|
|
774
|
+
const files = SKILLS.flatMap(skillFiles);
|
|
775
|
+
const present = /* @__PURE__ */ new Set();
|
|
776
|
+
for (const file of files) if (yield* fs.exists(path.join(cwd, file.path))) present.add(file.path);
|
|
777
|
+
const plan = planSkillFiles(files, (filePath) => present.has(filePath));
|
|
778
|
+
for (const skipped of plan.skipped) yield* prompter.warn(`\`${skipped}\` already exists - skipping`);
|
|
779
|
+
for (const file of plan.write) {
|
|
780
|
+
const dest = path.join(cwd, file.path);
|
|
781
|
+
yield* fs.makeDirectory(path.dirname(dest), { recursive: true });
|
|
782
|
+
yield* fs.writeFileString(dest, file.contents);
|
|
783
|
+
}
|
|
784
|
+
for (const skill of SKILLS) yield* linkSkill(cwd, skillLink(skill));
|
|
785
|
+
});
|
|
786
|
+
//#endregion
|
|
626
787
|
//#region src/commands/add-vscode/index.ts
|
|
627
788
|
const writeJson = Effect.fn("add-vscode.writeJson")(function* (filePath, value) {
|
|
628
789
|
yield* (yield* FileSystem.FileSystem).writeFileString(filePath, JSON.stringify(value, null, 2) + "\n");
|
|
@@ -708,7 +869,7 @@ function planPackages(pkg, toppings, sweetness, versions) {
|
|
|
708
869
|
* value for a config the user may add later.
|
|
709
870
|
*/
|
|
710
871
|
function oxfmtConfigPath(esm) {
|
|
711
|
-
return
|
|
872
|
+
return configFileName("oxfmt", esm);
|
|
712
873
|
}
|
|
713
874
|
/** True when at least one topping gives lint-staged something to run. */
|
|
714
875
|
function wantsLintStaged(toppings) {
|
|
@@ -785,6 +946,11 @@ const askRecipe = Effect.fn("brew.askRecipe")(function* () {
|
|
|
785
946
|
value: "vscode",
|
|
786
947
|
label: "vscode",
|
|
787
948
|
hint: "editor settings + extension recommendations"
|
|
949
|
+
},
|
|
950
|
+
{
|
|
951
|
+
value: "skills",
|
|
952
|
+
label: "skills",
|
|
953
|
+
hint: "coding standards for AI agents (.claude/skills)"
|
|
788
954
|
}
|
|
789
955
|
],
|
|
790
956
|
required: false
|
|
@@ -816,10 +982,11 @@ const installPackages = Effect.fn("brew.installPackages")(function* (pm, cwd, pa
|
|
|
816
982
|
});
|
|
817
983
|
const applyToppings = Effect.fn("brew.applyToppings")(function* (cwd, esm, pkg, toppings) {
|
|
818
984
|
let updated = pkg;
|
|
819
|
-
if (toppings.includes("oxlint")) updated = yield* apply$
|
|
820
|
-
if (toppings.includes("oxfmt")) updated = yield* apply$
|
|
821
|
-
if (toppings.includes("knip")) updated = yield* apply$
|
|
985
|
+
if (toppings.includes("oxlint")) updated = yield* apply$2(cwd, esm, updated);
|
|
986
|
+
if (toppings.includes("oxfmt")) updated = yield* apply$3(cwd, esm, updated);
|
|
987
|
+
if (toppings.includes("knip")) updated = yield* apply$5(cwd, esm, updated);
|
|
822
988
|
if (toppings.includes("vscode")) yield* apply(cwd, oxfmtConfigPath(esm));
|
|
989
|
+
if (toppings.includes("skills")) yield* apply$1(cwd);
|
|
823
990
|
return updated;
|
|
824
991
|
});
|
|
825
992
|
const persistScripts = Effect.fn("brew.persistScripts")(function* (pkgPath, pkg, updated) {
|
|
@@ -845,20 +1012,20 @@ const ensureRepoRootForHooks = Effect.fn("brew.ensureRepoRootForHooks")(function
|
|
|
845
1012
|
root: Option.getOrElse(root, () => cwd)
|
|
846
1013
|
});
|
|
847
1014
|
});
|
|
848
|
-
const setupCommitGate = Effect.fn("brew.setupCommitGate")(function* (cwd, pm, toppings, sweetness) {
|
|
1015
|
+
const setupCommitGate = Effect.fn("brew.setupCommitGate")(function* (cwd, pm, esm, toppings, sweetness) {
|
|
849
1016
|
if (sweetness !== "medium") return;
|
|
850
1017
|
const fs = yield* FileSystem.FileSystem;
|
|
851
1018
|
const path = yield* Path.Path;
|
|
852
1019
|
const prompter = yield* Prompter;
|
|
853
1020
|
const huskyExisted = yield* fs.exists(path.join(cwd, ".husky"));
|
|
854
|
-
yield* apply$
|
|
855
|
-
if (wantsLintStaged(toppings)) yield* apply$
|
|
1021
|
+
yield* apply$6(cwd, pm);
|
|
1022
|
+
if (wantsLintStaged(toppings)) yield* apply$4(cwd, esm, selectedLinters(toppings), selectedFormatters(toppings), !huskyExisted);
|
|
856
1023
|
else {
|
|
857
1024
|
yield* prompter.warn("No linters or formatters selected - skipping lint-staged");
|
|
858
1025
|
const seededHook = path.join(cwd, ".husky", "pre-commit");
|
|
859
1026
|
if (!huskyExisted && (yield* fs.exists(seededHook))) yield* fs.remove(seededHook);
|
|
860
1027
|
}
|
|
861
|
-
yield* apply$
|
|
1028
|
+
yield* apply$7(cwd, pm);
|
|
862
1029
|
});
|
|
863
1030
|
const commitChanges = Effect.fn("brew.commitChanges")(function* (cwd) {
|
|
864
1031
|
const git = yield* Git;
|
|
@@ -895,7 +1062,7 @@ const brew = (cwd = process.cwd()) => Effect.gen(function* () {
|
|
|
895
1062
|
yield* installPackages(pm, cwd, packages);
|
|
896
1063
|
const { pkg: installedPkg } = yield* readPkg(cwd);
|
|
897
1064
|
yield* persistScripts(pkgPath, installedPkg, yield* applyToppings(cwd, isEsm(installedPkg), installedPkg, toppings));
|
|
898
|
-
yield* setupCommitGate(cwd, pm, toppings, sweetness);
|
|
1065
|
+
yield* setupCommitGate(cwd, pm, isEsm(installedPkg), toppings, sweetness);
|
|
899
1066
|
yield* commitChanges(cwd);
|
|
900
1067
|
yield* prompter.outro(pc.green(`Your Chanom with ${toppings.join(", ")} toppings and ${sweetness} sweetness is ready! Enjoy! 🧋`));
|
|
901
1068
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chanom/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"keywords": [],
|
|
6
6
|
"repository": {
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"dev": "tsdown --watch",
|
|
41
41
|
"test": "vitest run",
|
|
42
42
|
"test:coverage": "vitest run --coverage",
|
|
43
|
-
"test:watch": "vitest"
|
|
43
|
+
"test:watch": "vitest",
|
|
44
|
+
"typecheck": "tsc --noEmit"
|
|
44
45
|
}
|
|
45
46
|
}
|