@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.
- package/README.md +33 -6
- package/dist/index.js +384 -100
- 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
|
|
64
|
-
| -------- |
|
|
65
|
-
| `oxlint` | `oxlint.config.ts`\*
|
|
66
|
-
| `oxfmt` | `oxfmt.config.ts`\*
|
|
67
|
-
| `knip` | `knip.config.ts`\*
|
|
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
|
|
3
|
+
import { Cause, Chunk, Data, Effect, Layer, LogLevel, Logger, Option, Stream } from "effect";
|
|
6
4
|
import * as clack from "@clack/prompts";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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()("
|
|
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()("
|
|
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/
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
655
|
-
if (toppings.includes("knip")) updated = yield* apply$
|
|
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$
|
|
688
|
-
if (wantsLintStaged(toppings)) yield* apply$
|
|
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$
|
|
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
|
-
})
|
|
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.
|
|
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",
|