@chanom/cli 0.2.2 → 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 +33 -6
  2. package/dist/index.js +384 -100
  3. 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, 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
 
@@ -38,6 +38,8 @@ Debug mode logs each step (detected package manager, planned packages, every com
38
38
  - `oxlint` - fast Rust-based linter
39
39
  - `oxfmt` - fast Rust-based formatter
40
40
  - `knip` - dead code remover
41
+ - `vscode` - editor settings + extension recommendations
42
+ - `skills` - coding standards for AI agents (`.claude/skills`)
41
43
  2. **Sweetness** - how strict to be:
42
44
  - `light` - warn only, no blocking
43
45
  - `medium` - block on commit via husky + lint-staged + commitlint
@@ -60,16 +62,33 @@ installing anything. `light` sweetness works anywhere in the repo.
60
62
 
61
63
  ## What gets added
62
64
 
63
- | Topping | Config file | Scripts added |
64
- | -------- | -------------------- | ------------------------ |
65
- | `oxlint` | `oxlint.config.ts`\* | `lint`, `lint:fix` |
66
- | `oxfmt` | `oxfmt.config.ts`\* | `format`, `format:check` |
67
- | `knip` | `knip.config.ts`\* | `knip` |
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 |
68
72
 
69
73
  \* `.mts` instead of `.ts` when your project is CommonJS (no `"type": "module"` in `package.json`).
70
74
 
71
75
  Config files re-export presets from [`@chanom/dev-config`](../dev-config).
72
76
 
77
+ The `vscode` topping installs no packages and adds no scripts. It writes editor
78
+ settings that route formatting and lint fixes through the
79
+ [oxc extension](https://marketplace.visualstudio.com/items?itemName=oxc.oxc-vscode)
80
+ on save (`editor.formatOnSave` stays off so both run in one pass), points
81
+ `oxc.fmt.configPath` at the oxfmt config, and recommends the extension so
82
+ teammates are prompted to install it. Only portable settings are written - no
83
+ `cSpell.words` or other repo-specific entries.
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
+
73
92
  `medium` sweetness additionally adds:
74
93
 
75
94
  - `husky` - installed via `husky init`
@@ -91,6 +110,14 @@ Existing setup is never overwritten:
91
110
  - If a config file for a tool already exists (any common variant, e.g. `.oxlintrc.json` or `oxlint.config.mjs`), `brew` warns and skips writing it.
92
111
  - If a script name already exists in `package.json`, `brew` warns and leaves it alone.
93
112
  - Existing `.husky/pre-commit` and `.husky/commit-msg` hooks are kept.
113
+ - `.vscode/settings.json` and `.vscode/extensions.json` are merged, not replaced: a
114
+ setting you already have wins (and is warned about), and existing extension
115
+ recommendations are kept. A `.vscode` file that can't be parsed as JSON (e.g. it
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.
94
121
 
95
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.
96
123
 
package/dist/index.js CHANGED
@@ -1,42 +1,122 @@
1
1
  #!/usr/bin/env node
2
- import { NodeContext, NodeRuntime } from "@effect/platform-node";
3
- import { Cause, Chunk, Data, Effect, Layer, LogLevel, Logger, Option, Stream } from "effect";
4
2
  import { Command, CommandExecutor, FileSystem, Path } from "@effect/platform";
5
- import pc from "picocolors";
3
+ import { Cause, Chunk, Data, Effect, Layer, LogLevel, Logger, Option, Stream } from "effect";
6
4
  import * as clack from "@clack/prompts";
7
- //#region src/bundled-versions.ts
8
- const bundledVersions = {
9
- oxlint: "1.73.0",
10
- oxlintTsgolint: "0.24.0",
11
- oxfmt: "0.58.0",
12
- knip: "6.26.0",
13
- devConfig: "0.0.3"
5
+ import pc from "picocolors";
6
+ import { NodeContext, NodeRuntime } from "@effect/platform-node";
7
+ //#region ../internal/src/domain/package-manager.ts
8
+ /** How to run a locally installed binary with each package manager. */
9
+ const PM_EXEC = {
10
+ pnpm: ["pnpm", "exec"],
11
+ npm: [
12
+ "npx",
13
+ "--no",
14
+ "--"
15
+ ],
16
+ yarn: ["yarn"],
17
+ bun: ["bunx"]
14
18
  };
15
- //#endregion
16
- //#region src/domain/pkg.ts
17
- var PkgNotFound = class extends Data.TaggedError("PkgNotFound") {};
18
- var PkgInvalid = class extends Data.TaggedError("PkgInvalid") {};
19
- function isEsm(pkg) {
20
- return pkg.type === "module";
19
+ const KNOWN_PMS = [
20
+ "pnpm",
21
+ "yarn",
22
+ "bun",
23
+ "npm"
24
+ ];
25
+ /** Parses the `packageManager` field of package.json, e.g. `pnpm@11.9.0`. */
26
+ function parsePmField(field) {
27
+ if (!field) return void 0;
28
+ const name = field.split("@")[0];
29
+ return KNOWN_PMS.find((pm) => pm === name);
21
30
  }
22
- function isPackageInstalled(pkg, name) {
23
- return name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {});
31
+ /** Parses the `npm_config_user_agent` environment variable, e.g. `pnpm/11.9.0 npm/? node/v22`. */
32
+ function parsePmUserAgent(agent) {
33
+ if (!agent) return void 0;
34
+ return KNOWN_PMS.find((pm) => agent.startsWith(pm));
24
35
  }
25
- function stripRangePrefix(version) {
26
- return version.replace(/^[\^~]/, "");
36
+ /** The `packageManager` field wins over the user agent, which wins over the fallback. */
37
+ function resolvePm(hints) {
38
+ return parsePmField(hints.packageManagerField) ?? parsePmUserAgent(hints.userAgent) ?? hints.fallback ?? "pnpm";
27
39
  }
28
- function getMismatchedPackage(pkg, name, version) {
29
- const installed = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name];
30
- return installed !== void 0 && stripRangePrefix(installed) === version ? void 0 : `${name}@${version}`;
40
+ //#endregion
41
+ //#region ../internal/src/domain/vscode.ts
42
+ /** Keys VS Code reads as a per-language override, e.g. `[typescript]`. */
43
+ const LANGUAGES = [
44
+ "markdown",
45
+ "json",
46
+ "typescriptreact",
47
+ "typescript",
48
+ "javascript"
49
+ ];
50
+ const OXC_FORMATTER = "oxc.oxc-vscode";
51
+ /** The extensions a chanom project recommends; unioned into an existing list. */
52
+ const RECOMMENDED_EXTENSIONS = [OXC_FORMATTER];
53
+ /**
54
+ * The VS Code settings chanom contributes. Every value is portable across
55
+ * projects - repo-specific entries (`cSpell.words`, `git.*`) are deliberately
56
+ * excluded so this can be dropped into any project without carrying chanom's
57
+ * own choices.
58
+ *
59
+ * `editor.formatOnSave` is false on purpose: formatting runs through the oxc
60
+ * code action instead, so formatting and lint fixes apply in one pass rather
61
+ * than racing each other.
62
+ */
63
+ function chanomSettings(oxfmtConfigPath) {
64
+ return {
65
+ "editor.defaultFormatter": OXC_FORMATTER,
66
+ "editor.formatOnSave": false,
67
+ "editor.codeActionsOnSave": {
68
+ "source.format.oxc": "always",
69
+ "source.fixAll.oxc": "always",
70
+ "source.removeUnusedImports": "always"
71
+ },
72
+ "oxc.fmt.configPath": oxfmtConfigPath,
73
+ "oxc.typeAware": true,
74
+ ...Object.fromEntries(LANGUAGES.map((language) => [`[${language}]`, { "editor.defaultFormatter": OXC_FORMATTER }]))
75
+ };
76
+ }
77
+ /**
78
+ * Merges `wanted` settings into `current` without overwriting anything already
79
+ * set, mirroring `planScripts`. The merge is one level deep by key: a key the
80
+ * user already has is left untouched even when its value is an object, because
81
+ * a partially merged `editor.codeActionsOnSave` is more surprising than one the
82
+ * user fully owns.
83
+ */
84
+ function planSettings(current, wanted) {
85
+ const settings = { ...current };
86
+ const added = [];
87
+ const skipped = [];
88
+ for (const [key, value] of Object.entries(wanted)) if (Object.hasOwn(settings, key)) skipped.push(key);
89
+ else {
90
+ settings[key] = value;
91
+ added.push(key);
92
+ }
93
+ return {
94
+ settings,
95
+ added,
96
+ skipped
97
+ };
98
+ }
99
+ /** Unions `wanted` into the existing recommendations, preserving existing order. */
100
+ function planExtensions(current, wanted) {
101
+ const recommendations = [...current ?? []];
102
+ const added = [];
103
+ for (const extension of wanted) if (!recommendations.includes(extension)) {
104
+ recommendations.push(extension);
105
+ added.push(extension);
106
+ }
107
+ return {
108
+ recommendations,
109
+ added
110
+ };
31
111
  }
32
112
  //#endregion
33
- //#region src/services/command-runner.ts
113
+ //#region ../internal/src/services/command-runner.ts
34
114
  const collectText = (stream) => stream.pipe(Stream.decodeText(), Stream.mkString);
35
115
  /**
36
116
  * Low-level process execution. Everything that shells out (git, package
37
117
  * managers, husky) goes through this service so commands can be stubbed in tests.
38
118
  */
39
- var CommandRunner = class extends Effect.Service()("cli/CommandRunner", { effect: Effect.gen(function* () {
119
+ var CommandRunner = class extends Effect.Service()("internal/CommandRunner", { effect: Effect.gen(function* () {
40
120
  const executor = yield* CommandExecutor.CommandExecutor;
41
121
  const make = (cmd, args, cwd) => Command.make(cmd, ...args).pipe(Command.workingDirectory(cwd));
42
122
  const traced = (cmd, args, cwd, effect) => Effect.logDebug(`spawning \`${cmd} ${args.join(" ")}\` in ${cwd}`).pipe(Effect.andThen(effect), Effect.tap((code) => Effect.logDebug(`\`${cmd} ${args.join(" ")}\` exited with ${code}`)));
@@ -67,9 +147,9 @@ var CommandRunner = class extends Effect.Service()("cli/CommandRunner", { effect
67
147
  };
68
148
  }) }) {};
69
149
  //#endregion
70
- //#region src/services/git.ts
150
+ //#region ../internal/src/services/git.ts
71
151
  /** Git operations, built on top of {@link CommandRunner}. */
72
- var Git = class extends Effect.Service()("cli/Git", { effect: Effect.gen(function* () {
152
+ var Git = class extends Effect.Service()("internal/Git", { effect: Effect.gen(function* () {
73
153
  const runner = yield* CommandRunner;
74
154
  const fs = yield* FileSystem.FileSystem;
75
155
  const path = yield* Path.Path;
@@ -117,35 +197,120 @@ var Git = class extends Effect.Service()("cli/Git", { effect: Effect.gen(functio
117
197
  };
118
198
  }) }) {};
119
199
  //#endregion
120
- //#region src/domain/package-manager.ts
121
- /** How to run a locally installed binary with each package manager. */
122
- const PM_EXEC = {
123
- pnpm: ["pnpm", "exec"],
124
- npm: [
125
- "npx",
126
- "--no",
127
- "--"
128
- ],
129
- yarn: ["yarn"],
130
- bun: ["bunx"]
200
+ //#region ../internal/src/services/prompter.ts
201
+ var Cancelled = class extends Data.TaggedError("Cancelled") {};
202
+ const guardCancel = (make) => Effect.promise(make).pipe(Effect.filterOrFail((result) => typeof result !== "symbol", () => new Cancelled()));
203
+ /**
204
+ * All terminal interaction (prompts, spinners, log messages). Commands never
205
+ * touch @clack/prompts directly, so tests can script answers and capture output.
206
+ */
207
+ var Prompter = class extends Effect.Service()("internal/Prompter", { succeed: {
208
+ intro: (message) => Effect.sync(() => clack.intro(message)),
209
+ outro: (message) => Effect.sync(() => clack.outro(message)),
210
+ warn: (message) => Effect.sync(() => clack.log.warn(message)),
211
+ error: (message) => Effect.sync(() => clack.log.error(message)),
212
+ info: (message) => Effect.sync(() => clack.log.info(message)),
213
+ /** Prints a message with no icon or styling (help text, plan summaries). */
214
+ message: (message) => Effect.sync(() => clack.log.message(message)),
215
+ debug: (message) => Effect.sync(() => clack.log.message(pc.dim(message))),
216
+ text: (params) => guardCancel(() => clack.text({
217
+ message: params.message,
218
+ placeholder: params.placeholder,
219
+ defaultValue: params.defaultValue,
220
+ initialValue: params.initialValue,
221
+ validate: params.validate
222
+ })),
223
+ confirm: (params) => guardCancel(() => clack.confirm({
224
+ message: params.message,
225
+ initialValue: params.initialValue ?? true
226
+ })),
227
+ select: (params) => guardCancel(() => clack.select({
228
+ message: params.message,
229
+ options: [...params.options]
230
+ })),
231
+ multiselect: (params) => guardCancel(() => clack.multiselect({
232
+ message: params.message,
233
+ options: [...params.options],
234
+ required: params.required ?? false
235
+ })),
236
+ spinner: (message) => Effect.sync(() => {
237
+ const s = clack.spinner();
238
+ s.start(message);
239
+ return { stop: (stopMessage) => Effect.sync(() => s.stop(stopMessage)) };
240
+ })
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
+ }
286
+ //#endregion
287
+ //#region src/bundled-versions.ts
288
+ const bundledVersions = {
289
+ oxlint: "1.73.0",
290
+ oxlintTsgolint: "0.24.0",
291
+ oxfmt: "0.58.0",
292
+ knip: "6.26.0",
293
+ devConfig: "0.0.3"
131
294
  };
132
- const KNOWN_PMS = [
133
- "pnpm",
134
- "yarn",
135
- "bun",
136
- "npm"
137
- ];
138
- /** Parses the `packageManager` field of package.json, e.g. `pnpm@11.9.0`. */
139
- function parsePmField(field) {
140
- if (!field) return void 0;
141
- const name = field.split("@")[0];
142
- return KNOWN_PMS.find((pm) => pm === name);
295
+ //#endregion
296
+ //#region src/domain/pkg.ts
297
+ var PkgNotFound = class extends Data.TaggedError("PkgNotFound") {};
298
+ var PkgInvalid = class extends Data.TaggedError("PkgInvalid") {};
299
+ function isEsm(pkg) {
300
+ return pkg.type === "module";
143
301
  }
144
- /** Parses the `npm_config_user_agent` environment variable, e.g. `pnpm/11.9.0 npm/? node/v22`. */
145
- function parsePmUserAgent(agent) {
146
- if (!agent) return void 0;
147
- return KNOWN_PMS.find((pm) => agent.startsWith(pm));
302
+ function isPackageInstalled(pkg, name) {
303
+ return name in (pkg.dependencies ?? {}) || name in (pkg.devDependencies ?? {});
148
304
  }
305
+ function stripRangePrefix(version) {
306
+ return version.replace(/^[\^~]/, "");
307
+ }
308
+ function getMismatchedPackage(pkg, name, version) {
309
+ const installed = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name];
310
+ return installed !== void 0 && stripRangePrefix(installed) === version ? void 0 : `${name}@${version}`;
311
+ }
312
+ //#endregion
313
+ //#region src/domain/package-manager.ts
149
314
  /**
150
315
  * Extra `add` flags needed when installing at a workspace root: pnpm and yarn
151
316
  * classic refuse root installs without `-w` / `-W`. npm, bun, and yarn berry
@@ -159,10 +324,6 @@ function workspaceRootFlags(pm, hints) {
159
324
  case "bun": return [];
160
325
  }
161
326
  }
162
- /** The `packageManager` field wins over the user agent, which wins over the fallback. */
163
- function resolvePm(hints) {
164
- return parsePmField(hints.packageManagerField) ?? parsePmUserAgent(hints.userAgent) ?? hints.fallback ?? "pnpm";
165
- }
166
327
  //#endregion
167
328
  //#region src/services/package-installer.ts
168
329
  var InstallFailed = class extends Data.TaggedError("InstallFailed") {};
@@ -190,35 +351,6 @@ var PackageInstaller = class extends Effect.Service()("cli/PackageInstaller", {
190
351
  ], cwd)), Effect.filterOrFail((code) => code === 0, () => new InstallFailed({ pm })), Effect.asVoid) };
191
352
  }) }) {};
192
353
  //#endregion
193
- //#region src/services/prompter.ts
194
- var Cancelled = class extends Data.TaggedError("Cancelled") {};
195
- const guardCancel = (make) => Effect.promise(make).pipe(Effect.filterOrFail((result) => typeof result !== "symbol", () => new Cancelled()));
196
- /**
197
- * All terminal interaction (prompts, spinners, log messages). Commands never
198
- * touch @clack/prompts directly, so tests can script answers and capture output.
199
- */
200
- var Prompter = class extends Effect.Service()("cli/Prompter", { succeed: {
201
- intro: (message) => Effect.sync(() => clack.intro(message)),
202
- outro: (message) => Effect.sync(() => clack.outro(message)),
203
- warn: (message) => Effect.sync(() => clack.log.warn(message)),
204
- error: (message) => Effect.sync(() => clack.log.error(message)),
205
- debug: (message) => Effect.sync(() => clack.log.message(pc.dim(message))),
206
- select: (params) => guardCancel(() => clack.select({
207
- message: params.message,
208
- options: [...params.options]
209
- })),
210
- multiselect: (params) => guardCancel(() => clack.multiselect({
211
- message: params.message,
212
- options: [...params.options],
213
- required: params.required ?? false
214
- })),
215
- spinner: (message) => Effect.sync(() => {
216
- const s = clack.spinner();
217
- s.start(message);
218
- return { stop: (stopMessage) => Effect.sync(() => s.stop(stopMessage)) };
219
- })
220
- } }) {};
221
- //#endregion
222
354
  //#region src/utils/detect-pm.ts
223
355
  const detectPm = (cwd, userAgent = process.env["npm_config_user_agent"], fallback = "pnpm") => Effect.gen(function* () {
224
356
  const fs = yield* FileSystem.FileSystem;
@@ -323,7 +455,7 @@ function commitMsgHook(pm) {
323
455
  }
324
456
  //#endregion
325
457
  //#region src/commands/add-commitlint/index.ts
326
- const apply$5 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
458
+ const apply$7 = Effect.fn("add-commitlint.apply")(function* (cwd, pm) {
327
459
  const fs = yield* FileSystem.FileSystem;
328
460
  const path = yield* Path.Path;
329
461
  const prompter = yield* Prompter;
@@ -345,7 +477,7 @@ function getPackages$4(pkg) {
345
477
  //#endregion
346
478
  //#region src/commands/add-husky/index.ts
347
479
  var HuskyInitFailed = class extends Data.TaggedError("HuskyInitFailed") {};
348
- const apply$4 = Effect.fn("add-husky.apply")(function* (cwd, pm) {
480
+ const apply$6 = Effect.fn("add-husky.apply")(function* (cwd, pm) {
349
481
  const fs = yield* FileSystem.FileSystem;
350
482
  const path = yield* Path.Path;
351
483
  const prompter = yield* Prompter;
@@ -401,7 +533,7 @@ function getScriptPlan$2(scripts) {
401
533
  //#endregion
402
534
  //#region src/commands/add-knip/index.ts
403
535
  /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
404
- const apply$3 = Effect.fn("add-knip.apply")(function* (cwd, esm, pkg) {
536
+ const apply$5 = Effect.fn("add-knip.apply")(function* (cwd, esm, pkg) {
405
537
  const fs = yield* FileSystem.FileSystem;
406
538
  const path = yield* Path.Path;
407
539
  const prompter = yield* Prompter;
@@ -438,7 +570,7 @@ function buildConfig(linters, formatters) {
438
570
  }
439
571
  //#endregion
440
572
  //#region src/commands/add-lint-staged/index.ts
441
- const apply$2 = 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) {
442
574
  if (linters.length === 0 && formatters.length === 0) {
443
575
  yield* Effect.logDebug("no linters or formatters selected - skipping lint-staged setup");
444
576
  return;
@@ -476,7 +608,7 @@ function getScriptPlan$1(scripts) {
476
608
  //#endregion
477
609
  //#region src/commands/add-oxfmt/index.ts
478
610
  /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
479
- const apply$1 = Effect.fn("add-oxfmt.apply")(function* (cwd, esm, pkg) {
611
+ const apply$3 = Effect.fn("add-oxfmt.apply")(function* (cwd, esm, pkg) {
480
612
  const fs = yield* FileSystem.FileSystem;
481
613
  const path = yield* Path.Path;
482
614
  const prompter = yield* Prompter;
@@ -517,7 +649,7 @@ function getScriptPlan(scripts) {
517
649
  //#endregion
518
650
  //#region src/commands/add-oxlint/index.ts
519
651
  /** Returns a copy of `pkg` with missing scripts added; the caller owns writing package.json. */
520
- const apply = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
652
+ const apply$2 = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
521
653
  const fs = yield* FileSystem.FileSystem;
522
654
  const path = yield* Path.Path;
523
655
  const prompter = yield* Prompter;
@@ -535,6 +667,128 @@ const apply = Effect.fn("add-oxlint.apply")(function* (cwd, esm, pkg) {
535
667
  };
536
668
  });
537
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
728
+ //#region src/commands/add-vscode/index.ts
729
+ const writeJson = Effect.fn("add-vscode.writeJson")(function* (filePath, value) {
730
+ yield* (yield* FileSystem.FileSystem).writeFileString(filePath, JSON.stringify(value, null, 2) + "\n");
731
+ });
732
+ /**
733
+ * Reads and parses an existing `.vscode` file. `.vscode` files are JSONC by
734
+ * convention, so a file we cannot parse is not an error - it is a file the user
735
+ * hand-wrote with comments. Returns `None` for both "absent" and "unparseable";
736
+ * the caller distinguishes them via `fs.exists` to warn appropriately.
737
+ */
738
+ const readJson = Effect.fn("add-vscode.readJson")(function* (filePath) {
739
+ const fs = yield* FileSystem.FileSystem;
740
+ if (!(yield* fs.exists(filePath))) return Option.none();
741
+ const contents = yield* fs.readFileString(filePath);
742
+ return yield* Effect.try(() => JSON.parse(contents)).pipe(Effect.map(Option.some), Effect.orElseSucceed(() => Option.none()));
743
+ });
744
+ const applySettings = Effect.fn("add-vscode.applySettings")(function* (vscodeDir, oxfmtConfigPath) {
745
+ const fs = yield* FileSystem.FileSystem;
746
+ const path = yield* Path.Path;
747
+ const prompter = yield* Prompter;
748
+ const filePath = path.join(vscodeDir, "settings.json");
749
+ const existed = yield* fs.exists(filePath);
750
+ const existing = yield* readJson(filePath);
751
+ if (existed && Option.isNone(existing)) {
752
+ yield* prompter.warn("`.vscode/settings.json` could not be parsed (comments?) - skipping settings");
753
+ return;
754
+ }
755
+ const plan = planSettings(Option.getOrUndefined(existing), chanomSettings(oxfmtConfigPath));
756
+ for (const key of plan.skipped) yield* prompter.warn(`\`${key}\` is already set in .vscode/settings.json - skipping`);
757
+ if (plan.added.length > 0) yield* writeJson(filePath, plan.settings);
758
+ });
759
+ const applyExtensions = Effect.fn("add-vscode.applyExtensions")(function* (vscodeDir) {
760
+ const fs = yield* FileSystem.FileSystem;
761
+ const path = yield* Path.Path;
762
+ const prompter = yield* Prompter;
763
+ const filePath = path.join(vscodeDir, "extensions.json");
764
+ const existed = yield* fs.exists(filePath);
765
+ const existing = yield* readJson(filePath);
766
+ if (existed && Option.isNone(existing)) {
767
+ yield* prompter.warn("`.vscode/extensions.json` could not be parsed (comments?) - skipping recommendations");
768
+ return;
769
+ }
770
+ const plan = planExtensions(Option.getOrUndefined(existing)?.recommendations, RECOMMENDED_EXTENSIONS);
771
+ if (plan.added.length > 0) yield* writeJson(filePath, {
772
+ ...Option.getOrUndefined(existing),
773
+ recommendations: plan.recommendations
774
+ });
775
+ });
776
+ /**
777
+ * Writes `.vscode/settings.json` and `.vscode/extensions.json`, merging into
778
+ * whatever is already there. Existing settings keys and recommendations always
779
+ * win, so re-running is safe and never clobbers the user's editor config.
780
+ *
781
+ * Unlike the other add-* commands this contributes no packages and no scripts,
782
+ * so it returns nothing - there is no `Pkg` for the caller to persist.
783
+ */
784
+ const apply = Effect.fn("add-vscode.apply")(function* (cwd, oxfmtConfigPath) {
785
+ const fs = yield* FileSystem.FileSystem;
786
+ const vscodeDir = (yield* Path.Path).join(cwd, ".vscode");
787
+ yield* fs.makeDirectory(vscodeDir, { recursive: true });
788
+ yield* applySettings(vscodeDir, oxfmtConfigPath);
789
+ yield* applyExtensions(vscodeDir);
790
+ });
791
+ //#endregion
538
792
  //#region src/commands/brew/logic.ts
539
793
  /** Deduplicated list of packages to install for the chosen toppings and sweetness. */
540
794
  function planPackages(pkg, toppings, sweetness, versions) {
@@ -549,6 +803,15 @@ function planPackages(pkg, toppings, sweetness, versions) {
549
803
  }
550
804
  return [...new Set(packages)];
551
805
  }
806
+ /**
807
+ * Path the VS Code oxc extension should read the oxfmt config from. Mirrors the
808
+ * name `add-oxfmt` writes, so the setting points at a real file whenever oxfmt
809
+ * is a selected topping; without oxfmt the default name is still the sensible
810
+ * value for a config the user may add later.
811
+ */
812
+ function oxfmtConfigPath(esm) {
813
+ return `oxfmt.config.${esm ? "ts" : "mts"}`;
814
+ }
552
815
  /** True when at least one topping gives lint-staged something to run. */
553
816
  function wantsLintStaged(toppings) {
554
817
  return selectedLinters(toppings).length > 0 || selectedFormatters(toppings).length > 0;
@@ -619,6 +882,16 @@ const askRecipe = Effect.fn("brew.askRecipe")(function* () {
619
882
  value: "knip",
620
883
  label: "knip",
621
884
  hint: "dead code remover"
885
+ },
886
+ {
887
+ value: "vscode",
888
+ label: "vscode",
889
+ hint: "editor settings + extension recommendations"
890
+ },
891
+ {
892
+ value: "skills",
893
+ label: "skills",
894
+ hint: "coding standards for AI agents (.claude/skills)"
622
895
  }
623
896
  ],
624
897
  required: false
@@ -650,9 +923,11 @@ const installPackages = Effect.fn("brew.installPackages")(function* (pm, cwd, pa
650
923
  });
651
924
  const applyToppings = Effect.fn("brew.applyToppings")(function* (cwd, esm, pkg, toppings) {
652
925
  let updated = pkg;
653
- if (toppings.includes("oxlint")) updated = yield* apply(cwd, esm, updated);
654
- if (toppings.includes("oxfmt")) updated = yield* apply$1(cwd, esm, updated);
655
- if (toppings.includes("knip")) updated = yield* apply$3(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);
929
+ if (toppings.includes("vscode")) yield* apply(cwd, oxfmtConfigPath(esm));
930
+ if (toppings.includes("skills")) yield* apply$1(cwd);
656
931
  return updated;
657
932
  });
658
933
  const persistScripts = Effect.fn("brew.persistScripts")(function* (pkgPath, pkg, updated) {
@@ -684,14 +959,14 @@ const setupCommitGate = Effect.fn("brew.setupCommitGate")(function* (cwd, pm, to
684
959
  const path = yield* Path.Path;
685
960
  const prompter = yield* Prompter;
686
961
  const huskyExisted = yield* fs.exists(path.join(cwd, ".husky"));
687
- yield* apply$4(cwd, pm);
688
- if (wantsLintStaged(toppings)) yield* apply$2(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);
689
964
  else {
690
965
  yield* prompter.warn("No linters or formatters selected - skipping lint-staged");
691
966
  const seededHook = path.join(cwd, ".husky", "pre-commit");
692
967
  if (!huskyExisted && (yield* fs.exists(seededHook))) yield* fs.remove(seededHook);
693
968
  }
694
- yield* apply$5(cwd, pm);
969
+ yield* apply$7(cwd, pm);
695
970
  });
696
971
  const commitChanges = Effect.fn("brew.commitChanges")(function* (cwd) {
697
972
  const git = yield* Git;
@@ -750,10 +1025,19 @@ const traceFailure = (cause) => Effect.gen(function* () {
750
1025
  if (isCancellation(cause)) return;
751
1026
  yield* (yield* Prompter).debug(Cause.pretty(cause));
752
1027
  });
1028
+ /**
1029
+ * Pins the error channel to `never` at this boundary. If `catchTags` above ever
1030
+ * stops handling an error tag, that tag leaks into `E` and this call fails to
1031
+ * compile — forcing every command error to be handled here.
1032
+ */
1033
+ const allErrorsHandled = (effect) => effect;
753
1034
  /** Runs the named command and returns the process exit code. */
754
1035
  const run = (name, cwd = process.cwd(), options = {}) => {
755
1036
  const debug = options.debug ?? false;
756
- return dispatch(name, cwd).pipe(Effect.as(0), debug ? Effect.tapErrorCause(traceFailure) : (effect) => effect, Effect.catchTags({
1037
+ return allErrorsHandled(dispatch(name, cwd).pipe(Effect.as(0), debug ? Effect.tapErrorCause(traceFailure) : (effect) => effect, Effect.catchTags({
1038
+ BadArgument: (e) => reportError(`Unexpected error: ${e.message}`),
1039
+ SystemError: (e) => reportError(`Unexpected error: ${e.message}`)
1040
+ }), Effect.catchTags({
757
1041
  Cancelled: () => Effect.succeed(0),
758
1042
  UnknownCommand: (e) => reportError(`Unknown command: ${e.command ?? "(none)"}\nAvailable commands: ${Object.keys(commands).join(", ")}`),
759
1043
  PkgNotFound: (e) => reportError(`No package.json found in ${e.cwd}. Run this inside a project.`),
@@ -761,7 +1045,7 @@ const run = (name, cwd = process.cwd(), options = {}) => {
761
1045
  InstallFailed: (e) => reportError(`Package installation with ${e.pm} failed.`),
762
1046
  HuskyInitFailed: (e) => reportError(`\`husky init\` failed (exit code ${e.exitCode}).`),
763
1047
  NotAtRepoRoot: (e) => reportError(`Git hooks must be set up at the repository root: ${e.root}\nRun \`chanom brew\` there, or choose light sweetness.`)
764
- }), Effect.catchAll((e) => reportError(`Unexpected error: ${e.message}`)), Logger.withMinimumLogLevel(debug ? LogLevel.Debug : LogLevel.Info));
1048
+ }))).pipe(Logger.withMinimumLogLevel(debug ? LogLevel.Debug : LogLevel.Info));
765
1049
  };
766
1050
  //#endregion
767
1051
  //#region src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chanom/cli",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "",
5
5
  "keywords": [],
6
6
  "repository": {
@@ -32,7 +32,8 @@
32
32
  "tsdown": "0.22.3",
33
33
  "typescript": "6.0.3",
34
34
  "vitest": "3.2.6",
35
- "@chanom/dev-config": "0.0.3"
35
+ "@chanom/dev-config": "0.0.3",
36
+ "@chanom/internal": "0.0.0"
36
37
  },
37
38
  "scripts": {
38
39
  "build": "tsdown",