@savvy-web/cli 0.3.1 → 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.
Files changed (39) hide show
  1. package/bin/savvy.d.ts +1 -0
  2. package/bin/savvy.js +17 -1
  3. package/cli/index.js +123 -0
  4. package/commands/changeset/commands/analyze-branch.js +108 -0
  5. package/commands/changeset/commands/check.js +71 -0
  6. package/commands/changeset/commands/classify.js +69 -0
  7. package/commands/changeset/commands/config-show.js +100 -0
  8. package/commands/changeset/commands/config-validate.js +63 -0
  9. package/commands/changeset/commands/deps-detect.js +103 -0
  10. package/commands/changeset/commands/deps-regen.js +277 -0
  11. package/commands/changeset/commands/init.js +634 -0
  12. package/commands/changeset/commands/lint.js +62 -0
  13. package/commands/changeset/commands/release-surface.js +96 -0
  14. package/commands/changeset/commands/transform.js +88 -0
  15. package/commands/changeset/commands/validate-file.js +52 -0
  16. package/commands/changeset/commands/version.js +178 -0
  17. package/commands/changeset/index.js +42 -0
  18. package/commands/changeset/utils/config-gate.js +59 -0
  19. package/commands/check.js +74 -0
  20. package/commands/clean.js +186 -0
  21. package/commands/commit/check.js +170 -0
  22. package/commands/commit/constants.js +10 -0
  23. package/commands/commit/hook.js +22 -0
  24. package/commands/commit/hooks/post-commit-verify.js +121 -0
  25. package/commands/commit/hooks/pre-commit-message.js +64 -0
  26. package/commands/commit/hooks/session-start.js +69 -0
  27. package/commands/commit/hooks/user-prompt-submit.js +42 -0
  28. package/commands/commit/index.js +20 -0
  29. package/commands/commit/init.js +127 -0
  30. package/commands/init.js +88 -0
  31. package/commands/lint/check.js +306 -0
  32. package/commands/lint/fmt.js +64 -0
  33. package/commands/lint/index.js +20 -0
  34. package/commands/lint/init.js +221 -0
  35. package/index.d.ts +237 -244
  36. package/index.js +14 -1
  37. package/package.json +39 -51
  38. package/841.js +0 -2394
  39. package/tsdoc-metadata.json +0 -11
@@ -0,0 +1,186 @@
1
+ import { Command, Options } from "@effect/cli";
2
+ import { Data, Effect } from "effect";
3
+ import { WorkspaceDiscovery } from "workspaces-effect";
4
+ import { join, sep } from "node:path";
5
+ import { glob, realpath, rm } from "node:fs/promises";
6
+
7
+ //#region src/commands/clean.ts
8
+ /**
9
+ * `savvy clean` — remove build/cache artifacts across a silk workspace.
10
+ *
11
+ * Globs a set of patterns at the top level of each workspace root (leaves
12
+ * first, monorepo root last) and deletes matches. `--dry-run` previews without
13
+ * touching disk. Uses Node's native `fs.promises.glob` (no third-party glob).
14
+ *
15
+ * @internal
16
+ */
17
+ /** Default patterns cleaned when `--globs` is omitted. */
18
+ const DEFAULT_GLOBS = [
19
+ "dist",
20
+ ".turbo",
21
+ "coverage",
22
+ "node_modules",
23
+ ".rslib"
24
+ ];
25
+ /** Directory names a recursive (`**`) glob must never descend into. */
26
+ const NO_DESCEND = new Set(["node_modules", ".git"]);
27
+ /** Unexpected failure while planning or removing artifacts. */
28
+ var CleanError = class extends Data.TaggedError("CleanError") {};
29
+ /**
30
+ * Glob `patterns` at the top of `pkgPath`, classify dir vs file, and enforce
31
+ * that every match stays within `pkgPath` (rejecting symlink/`..` escapes and
32
+ * the root directory itself).
33
+ */
34
+ function collectTargets(pkgPath, patterns) {
35
+ return Effect.tryPromise({
36
+ try: async () => {
37
+ const rootReal = await realpath(pkgPath);
38
+ const seen = /* @__PURE__ */ new Map();
39
+ for await (const entry of glob(patterns, {
40
+ cwd: pkgPath,
41
+ withFileTypes: true,
42
+ exclude: (dirent) => NO_DESCEND.has(dirent.name) && dirent.isDirectory()
43
+ })) {
44
+ const abs = join(entry.parentPath, entry.name);
45
+ let real;
46
+ try {
47
+ real = await realpath(abs);
48
+ } catch {
49
+ continue;
50
+ }
51
+ if (real === rootReal || !real.startsWith(rootReal + sep)) continue;
52
+ if (real === join(rootReal, "package.json")) continue;
53
+ seen.set(abs, {
54
+ path: abs,
55
+ kind: entry.isDirectory() ? "dir" : "file"
56
+ });
57
+ }
58
+ return [...seen.values()];
59
+ },
60
+ catch: (e) => new CleanError({
61
+ step: `glob ${pkgPath}`,
62
+ reason: e instanceof Error ? e.message : String(e)
63
+ })
64
+ });
65
+ }
66
+ /** Max concurrent deletions. */
67
+ const REMOVE_CONCURRENCY = 8;
68
+ /**
69
+ * Delete each target (`rm -rf`, missing paths are no-ops via `force`). On
70
+ * `dryRun`, report without deleting. Per-target failures are collected, not
71
+ * thrown, so one unremovable path does not abort the rest.
72
+ */
73
+ function removeTargets(targets, dryRun) {
74
+ return Effect.gen(function* () {
75
+ const results = yield* Effect.forEach(targets, (target) => dryRun ? Effect.succeed({
76
+ target,
77
+ reason: null
78
+ }) : Effect.tryPromise(() => rm(target.path, {
79
+ recursive: true,
80
+ force: true
81
+ })).pipe(Effect.match({
82
+ onSuccess: () => ({
83
+ target,
84
+ reason: null
85
+ }),
86
+ onFailure: (e) => ({
87
+ target,
88
+ reason: e instanceof Error ? e.message : String(e)
89
+ })
90
+ })), { concurrency: REMOVE_CONCURRENCY });
91
+ return {
92
+ removed: results.filter((r) => r.reason === null).map((r) => r.target),
93
+ failed: results.filter((r) => r.reason !== null).map((r) => ({
94
+ target: r.target,
95
+ reason: r.reason
96
+ }))
97
+ };
98
+ });
99
+ }
100
+ /** Unicode symbols for output. */
101
+ const CHECK_MARK = "✓";
102
+ const BULLET = "•";
103
+ const WARN_MARK = "⚠";
104
+ /** Split the comma-separated `--globs` value; fall back to defaults when empty. */
105
+ function parseGlobs(raw) {
106
+ const parts = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
107
+ return parts.length > 0 ? parts : DEFAULT_GLOBS;
108
+ }
109
+ /**
110
+ * Discover packages, plan targets per workspace (leaves first, root last),
111
+ * dedup across overlapping roots, remove (or preview), and report.
112
+ */
113
+ function runClean(opts) {
114
+ const patterns = parseGlobs(opts.globs);
115
+ return Effect.gen(function* () {
116
+ const packages = yield* (yield* WorkspaceDiscovery).listPackages(process.cwd()).pipe(Effect.mapError((e) => new CleanError({
117
+ step: "discover workspaces",
118
+ reason: e.message
119
+ })));
120
+ const leaves = packages.filter((p) => !p.isRootWorkspace);
121
+ const roots = packages.filter((p) => p.isRootWorkspace);
122
+ const ordered = [...leaves, ...roots];
123
+ const planned = yield* Effect.forEach(ordered, (pkg) => collectTargets(pkg.path, patterns).pipe(Effect.map((targets) => ({
124
+ pkg,
125
+ targets
126
+ }))));
127
+ const seen = /* @__PURE__ */ new Set();
128
+ const groups = planned.map(({ pkg, targets }) => {
129
+ return {
130
+ pkg,
131
+ targets: targets.filter((t) => {
132
+ if (seen.has(t.path)) return false;
133
+ seen.add(t.path);
134
+ return true;
135
+ })
136
+ };
137
+ });
138
+ const leafGroups = groups.filter((g) => !g.pkg.isRootWorkspace);
139
+ const rootGroups = groups.filter((g) => g.pkg.isRootWorkspace);
140
+ const verb = opts.dryRun ? "would remove" : "removed";
141
+ let total = 0;
142
+ const failures = [];
143
+ for (const phase of [leafGroups, rootGroups]) {
144
+ const reports = yield* Effect.forEach(phase, (g) => removeTargets(g.targets, opts.dryRun).pipe(Effect.map((report) => ({
145
+ g,
146
+ report
147
+ }))));
148
+ for (const { g, report } of reports) {
149
+ if (g.targets.length === 0) continue;
150
+ yield* Effect.log(`\n${g.pkg.relativePath === "." ? "<root>" : g.pkg.relativePath}`);
151
+ for (const t of report.removed) yield* Effect.log(` ${BULLET} ${verb} [${t.kind}] ${t.path}`);
152
+ for (const f of report.failed) yield* Effect.log(` ${WARN_MARK} failed [${f.target.kind}] ${f.target.path}: ${f.reason}`);
153
+ total += report.removed.length;
154
+ failures.push(...report.failed);
155
+ }
156
+ }
157
+ yield* Effect.log(`\n${CHECK_MARK} ${opts.dryRun ? "Would remove" : "Removed"} ${total} item(s).`);
158
+ if (failures.length > 0) {
159
+ for (const f of failures) yield* Effect.logError(`Failed to remove ${f.target.path}: ${f.reason}`);
160
+ return yield* Effect.fail(new CleanError({
161
+ step: "remove",
162
+ reason: `${failures.length} target(s) could not be removed`
163
+ }));
164
+ }
165
+ });
166
+ }
167
+ /* v8 ignore start -- CLI option/registration; orchestration tested via runClean */
168
+ const globsOption = Options.text("globs").pipe(Options.withAlias("g"), Options.withDescription(`Comma-separated glob patterns to remove from each workspace root (default: ${DEFAULT_GLOBS.join(",")})`), Options.withDefault(DEFAULT_GLOBS.join(",")));
169
+ const dryRunOption = Options.boolean("dry-run").pipe(Options.withAlias("n"), Options.withDescription("Report what would be removed without deleting anything"), Options.withDefault(false));
170
+ const _cleanCommand = Command.make("clean", {
171
+ globs: globsOption,
172
+ dryRun: dryRunOption
173
+ }, (opts) => runClean(opts)).pipe(Command.withDescription("Remove build/cache artifacts across the workspace (leaves first, root last)"));
174
+ /* v8 ignore stop */
175
+ /**
176
+ * The `savvy clean` command for the root assembly.
177
+ *
178
+ * @remarks
179
+ * Typed with `any` at the export boundary to avoid TypeScript declaration-emit
180
+ * errors from Effect's internal Command types, matching the other top-level
181
+ * command exports.
182
+ */
183
+ const cleanCommand = _cleanCommand;
184
+
185
+ //#endregion
186
+ export { cleanCommand };
@@ -0,0 +1,170 @@
1
+ import { HUSKY_HOOK_PATH, POST_CHECKOUT_HOOK_PATH, POST_MERGE_HOOK_PATH } from "./constants.js";
2
+ import { SECTION_DEF, savvyCommitBlock } from "./init.js";
3
+ import { Command } from "@effect/cli";
4
+ import { CheckResult, Commitlint, ManagedSection, SavvyBaseSection, SavvyHooksSection, VersioningStrategy, savvyBasePreamble, savvyHooksHygiene } from "@savvy-web/silk-effects";
5
+ import { Effect } from "effect";
6
+ import { WorkspaceDiscovery } from "workspaces-effect";
7
+ import { FileSystem } from "@effect/platform";
8
+
9
+ //#region src/commands/commit/check.ts
10
+ /**
11
+ * Check command - validate current commitlint setup.
12
+ *
13
+ * @internal
14
+ */
15
+ /** Unicode cross symbol. */
16
+ const CROSS_MARK = "✗";
17
+ /** Unicode bullet symbol. */
18
+ const BULLET = "•";
19
+ /** Possible commitlint configuration file names, in priority order. */
20
+ const CONFIG_FILES = [
21
+ "commitlint.config.ts",
22
+ "commitlint.config.mts",
23
+ "commitlint.config.cts",
24
+ "commitlint.config.js",
25
+ "commitlint.config.mjs",
26
+ "commitlint.config.cjs",
27
+ "lib/configs/commitlint.config.ts",
28
+ "lib/configs/commitlint.config.mts",
29
+ "lib/configs/commitlint.config.cts",
30
+ "lib/configs/commitlint.config.js",
31
+ "lib/configs/commitlint.config.mjs",
32
+ "lib/configs/commitlint.config.cjs",
33
+ ".commitlintrc",
34
+ ".commitlintrc.json",
35
+ ".commitlintrc.yaml",
36
+ ".commitlintrc.yml",
37
+ ".commitlintrc.js",
38
+ ".commitlintrc.cjs",
39
+ ".commitlintrc.mjs",
40
+ ".commitlintrc.ts",
41
+ ".commitlintrc.cts",
42
+ ".commitlintrc.mts"
43
+ ];
44
+ /** DCO file path. */
45
+ const DCO_FILE_PATH = "DCO";
46
+ /** Maps versioning strategy types to release formats. */
47
+ const STRATEGY_TO_FORMAT = {
48
+ single: "semver",
49
+ "fixed-group": "semver",
50
+ independent: "packages"
51
+ };
52
+ /**
53
+ * Find the first existing config file.
54
+ *
55
+ * @param fs - FileSystem service
56
+ * @returns Effect yielding the config file name or null
57
+ */
58
+ function findConfigFile(fs) {
59
+ return Effect.gen(function* () {
60
+ for (const file of CONFIG_FILES) if (yield* fs.exists(file)) return file;
61
+ return null;
62
+ });
63
+ }
64
+ /**
65
+ * Extract the config path from managed section content.
66
+ *
67
+ * @param managedContent - The content between managed section markers
68
+ * @returns The config path found, or null if not found
69
+ */
70
+ function extractConfigPathFromManaged(managedContent) {
71
+ const match = managedContent.match(/commitlint --config "\$ROOT\/([^"]+)"/);
72
+ return match ? match[1] : null;
73
+ }
74
+ /**
75
+ * Detect the release format using silk-effects versioning service.
76
+ *
77
+ * @returns Effect yielding the release format string
78
+ */
79
+ const detectReleaseFormat = Effect.gen(function* () {
80
+ const versioning = yield* VersioningStrategy;
81
+ const discovery = yield* WorkspaceDiscovery;
82
+ const publishableNames = (yield* Effect.catchAll(discovery.listPackages(), () => Effect.succeed([]))).filter((pkg) => !pkg.private || pkg.publishConfig?.access !== void 0).map((pkg) => pkg.name);
83
+ return STRATEGY_TO_FORMAT[(yield* Effect.catchAll(versioning.detect(publishableNames, process.cwd()), () => Effect.succeed({ type: "single" }))).type] ?? "semver";
84
+ });
85
+ /**
86
+ * Run the check validation pipeline.
87
+ *
88
+ * Exported so Task B6's unified `savvy check` orchestrator can invoke the
89
+ * commitlint check step directly without going through the CLI command layer.
90
+ *
91
+ * @returns An Effect that performs validation and logs results
92
+ *
93
+ * @internal
94
+ */
95
+ function runCommitCheck() {
96
+ return Effect.gen(function* () {
97
+ const fs = yield* FileSystem.FileSystem;
98
+ const ms = yield* ManagedSection;
99
+ yield* Effect.log("Checking commitlint configuration...\n");
100
+ const foundConfig = yield* findConfigFile(fs);
101
+ if (foundConfig) yield* Effect.log(`${"✓"} Config file: ${foundConfig}`);
102
+ else yield* Effect.log(`${CROSS_MARK} No commitlint config file found`);
103
+ const hasHuskyHook = yield* fs.exists(HUSKY_HOOK_PATH);
104
+ if (hasHuskyHook) yield* Effect.log(`${"✓"} Husky hook: ${HUSKY_HOOK_PATH}`);
105
+ else yield* Effect.log(`${CROSS_MARK} No husky commit-msg hook found`);
106
+ let sectionsHealthy = true;
107
+ if (hasHuskyHook) {
108
+ const baseStatus = yield* ms.check(HUSKY_HOOK_PATH, SavvyBaseSection.block(savvyBasePreamble()));
109
+ if (CheckResult.$is("Found")(baseStatus) && baseStatus.isUpToDate) yield* Effect.log(`${"✓"} Base section: up-to-date`);
110
+ else if (CheckResult.$is("Found")(baseStatus)) {
111
+ sectionsHealthy = false;
112
+ yield* Effect.log(`${"⚠"} Base section: outdated (run 'savvy init' to update)`);
113
+ } else {
114
+ sectionsHealthy = false;
115
+ yield* Effect.log(`${BULLET} Base section: not found (run 'savvy init' to add)`);
116
+ }
117
+ const block = yield* ms.read(HUSKY_HOOK_PATH, SECTION_DEF);
118
+ if (block) {
119
+ const configPath = extractConfigPathFromManaged(block.content);
120
+ if (configPath) {
121
+ const status = yield* ms.check(HUSKY_HOOK_PATH, savvyCommitBlock(configPath));
122
+ if (CheckResult.$is("Found")(status) && status.isUpToDate) yield* Effect.log(`${"✓"} Commit section: up-to-date`);
123
+ else {
124
+ sectionsHealthy = false;
125
+ yield* Effect.log(`${"⚠"} Commit section: outdated (run 'savvy init' to update)`);
126
+ }
127
+ } else {
128
+ sectionsHealthy = false;
129
+ yield* Effect.log(`${"⚠"} Commit section: outdated (run 'savvy init' to update)`);
130
+ }
131
+ } else {
132
+ sectionsHealthy = false;
133
+ yield* Effect.log(`${BULLET} Commit section: not found (run 'savvy init' to add)`);
134
+ }
135
+ }
136
+ for (const hookPath of [POST_CHECKOUT_HOOK_PATH, POST_MERGE_HOOK_PATH]) {
137
+ if (!(yield* fs.exists(hookPath))) {
138
+ sectionsHealthy = false;
139
+ yield* Effect.log(`${BULLET} Hygiene hook: ${hookPath} not found (run 'savvy init' to add)`);
140
+ continue;
141
+ }
142
+ const hygieneStatus = yield* ms.check(hookPath, SavvyHooksSection.block(savvyHooksHygiene()));
143
+ if (CheckResult.$is("Found")(hygieneStatus) && hygieneStatus.isUpToDate) yield* Effect.log(`${"✓"} Hygiene hook: ${hookPath}`);
144
+ else if (CheckResult.$is("Found")(hygieneStatus)) {
145
+ sectionsHealthy = false;
146
+ yield* Effect.log(`${"⚠"} Hygiene hook: ${hookPath} outdated (run 'savvy init' to update)`);
147
+ } else {
148
+ sectionsHealthy = false;
149
+ yield* Effect.log(`${BULLET} Hygiene hook: ${hookPath} section not found (run 'savvy init' to add)`);
150
+ }
151
+ }
152
+ if (yield* fs.exists(DCO_FILE_PATH)) yield* Effect.log(`${"✓"} DCO file: ${DCO_FILE_PATH}`);
153
+ else yield* Effect.log(`${BULLET} No DCO file (signoff not required)`);
154
+ yield* Effect.log("\nDetected settings:");
155
+ yield* Effect.log(` DCO required: ${Commitlint.detectDCO()}`);
156
+ const releaseFormat = yield* detectReleaseFormat;
157
+ yield* Effect.log(` Release format: ${releaseFormat}`);
158
+ const scopes = yield* Effect.catchAll(Commitlint.detectScopes, () => Effect.succeed([]));
159
+ const scopeDisplay = scopes.length > 0 ? scopes.join(", ") : "(none - not a monorepo or no packages found)";
160
+ yield* Effect.log(` Detected scopes: ${scopeDisplay}`);
161
+ yield* Effect.log("");
162
+ if (!foundConfig || !hasHuskyHook || !sectionsHealthy) yield* Effect.log(`${CROSS_MARK} Commitlint needs configuration. Run: savvy init`);
163
+ else yield* Effect.log(`${"✓"} Commitlint is configured correctly.`);
164
+ });
165
+ }
166
+ /* v8 ignore next 3 -- CLI registration; handler tested via runCommitCheck */
167
+ const checkCommand = Command.make("check", {}, () => runCommitCheck()).pipe(Command.withDescription("Check current commitlint configuration and detected settings"));
168
+
169
+ //#endregion
170
+ export { runCommitCheck };
@@ -0,0 +1,10 @@
1
+ //#region src/commands/commit/constants.ts
2
+ /** Husky commit-msg hook path (savvy-base + savvy-commit sections). */
3
+ const HUSKY_HOOK_PATH = ".husky/commit-msg";
4
+ /** Husky post-checkout hook path (savvy-hooks hygiene). */
5
+ const POST_CHECKOUT_HOOK_PATH = ".husky/post-checkout";
6
+ /** Husky post-merge hook path (savvy-hooks hygiene). */
7
+ const POST_MERGE_HOOK_PATH = ".husky/post-merge";
8
+
9
+ //#endregion
10
+ export { HUSKY_HOOK_PATH, POST_CHECKOUT_HOOK_PATH, POST_MERGE_HOOK_PATH };
@@ -0,0 +1,22 @@
1
+ import { postCommitVerifyCommand } from "./hooks/post-commit-verify.js";
2
+ import { preCommitMessageCommand } from "./hooks/pre-commit-message.js";
3
+ import { sessionStartCommand } from "./hooks/session-start.js";
4
+ import { userPromptSubmitCommand } from "./hooks/user-prompt-submit.js";
5
+ import { Command } from "@effect/cli";
6
+
7
+ //#region src/commands/commit/hook.ts
8
+ /**
9
+ * `savvy commit hook` parent command — internal CLI surface used by the
10
+ * commitlint plugin's bash hooks.
11
+ *
12
+ * @internal
13
+ */
14
+ const hookCommand = Command.make("hook").pipe(Command.withSubcommands([
15
+ sessionStartCommand,
16
+ preCommitMessageCommand,
17
+ postCommitVerifyCommand,
18
+ userPromptSubmitCommand
19
+ ])).pipe(Command.withDescription("Internal hook handlers used by the @savvy-web/commitlint plugin"));
20
+
21
+ //#endregion
22
+ export { hookCommand };
@@ -0,0 +1,121 @@
1
+ import { Command } from "@effect/cli";
2
+ import { Commitlint } from "@savvy-web/silk-effects";
3
+ import { Effect } from "effect";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+
7
+ //#region src/commands/commit/hooks/post-commit-verify.ts
8
+ /**
9
+ * `savvy commit hook post-commit-verify` — replays commitlint, checks the
10
+ * signature, and verifies the closes trailer if the branch implies one.
11
+ *
12
+ * @internal
13
+ */
14
+ const execFileP = promisify(execFile);
15
+ function buildPostCommitAdvice(i) {
16
+ const lines = [];
17
+ if (i.commitlintFailed) lines.push("The commit you just created fails commitlint validation. Run pnpm exec commitlint --last to see the violations, then fix with git commit --amend.");
18
+ if (i.autoSignEnabled) {
19
+ if (i.sigStatus === "N") lines.push("The commit was unsigned but commit.gpgsign=true is configured. Re-sign with: git commit --amend --no-edit -S");
20
+ else if ([
21
+ "B",
22
+ "X",
23
+ "Y",
24
+ "R",
25
+ "E"
26
+ ].includes(i.sigStatus)) lines.push(`The commit signature status is '${i.sigStatus}' (B=bad, X=expired sig, Y=expired key, R=revoked, E=missing key). Investigate your signing setup and amend.`);
27
+ }
28
+ if (i.branchTicketId !== null && !i.bodyHasClosing) lines.push(`Branch implies ticket #${i.branchTicketId} but the commit body has no Closes/Fixes/Resolves trailer for it. If this commit closes #${i.branchTicketId}, amend with: git commit --amend --no-edit --trailer "Closes: #${i.branchTicketId}"`);
29
+ return lines.length === 0 ? null : lines.join("\n\n");
30
+ }
31
+ function buildCommitlintInvocation(pm, configPath) {
32
+ const tail = configPath ? [
33
+ "commitlint",
34
+ "--config",
35
+ configPath,
36
+ "--last"
37
+ ] : ["commitlint", "--last"];
38
+ switch (pm) {
39
+ case "pnpm": return {
40
+ command: "pnpm",
41
+ args: ["exec", ...tail]
42
+ };
43
+ case "yarn": return {
44
+ command: "yarn",
45
+ args: ["exec", ...tail]
46
+ };
47
+ case "bun": return {
48
+ command: "bunx",
49
+ args: tail
50
+ };
51
+ case "npm": return {
52
+ command: "npx",
53
+ args: [
54
+ "--no",
55
+ "--",
56
+ ...tail
57
+ ]
58
+ };
59
+ }
60
+ }
61
+ async function getRepoRoot() {
62
+ try {
63
+ const { stdout } = await execFileP("git", ["rev-parse", "--show-toplevel"]);
64
+ return stdout.trim();
65
+ } catch {
66
+ return process.cwd();
67
+ }
68
+ }
69
+ async function runCommitlintLast() {
70
+ const root = await getRepoRoot();
71
+ const { command, args } = buildCommitlintInvocation(await Commitlint.detectPackageManager(root), await Commitlint.readCommitlintConfigPath(root));
72
+ try {
73
+ await execFileP(command, args, { cwd: root });
74
+ return false;
75
+ } catch {
76
+ return true;
77
+ }
78
+ }
79
+ async function readSignatureStatus() {
80
+ try {
81
+ const { stdout } = await execFileP("git", [
82
+ "log",
83
+ "-1",
84
+ "--format=%G?"
85
+ ]);
86
+ return stdout.trim();
87
+ } catch {
88
+ return "N";
89
+ }
90
+ }
91
+ async function readLastCommitBody() {
92
+ try {
93
+ const { stdout } = await execFileP("git", [
94
+ "log",
95
+ "-1",
96
+ "--format=%B"
97
+ ]);
98
+ return stdout;
99
+ } catch {
100
+ return "";
101
+ }
102
+ }
103
+ const postCommitVerifyCommand = Command.make("post-commit-verify", {}, () => Effect.gen(function* () {
104
+ const branch = yield* Commitlint.readBranchInfo();
105
+ const signing = yield* Commitlint.readSigningDiagnostic();
106
+ const commitlintFailed = yield* Effect.promise(runCommitlintLast);
107
+ const sigStatus = yield* Effect.promise(readSignatureStatus);
108
+ const body = yield* Effect.promise(readLastCommitBody);
109
+ const bodyHasClosing = branch.inferredTicketId !== null && Commitlint.hasClosingTrailer(body, branch.inferredTicketId);
110
+ const advice = buildPostCommitAdvice({
111
+ commitlintFailed,
112
+ sigStatus,
113
+ autoSignEnabled: signing.autoSignEnabled,
114
+ branchTicketId: branch.inferredTicketId,
115
+ bodyHasClosing
116
+ });
117
+ if (advice !== null) yield* Effect.sync(() => process.stdout.write(`${JSON.stringify(Commitlint.postToolUseAdvise(advice))}\n`));
118
+ }).pipe(Effect.provide(Commitlint.HookSilencer))).pipe(Command.withDescription("Verify the most recent commit (commitlint replay + signature + closes trailer)"));
119
+
120
+ //#endregion
121
+ export { postCommitVerifyCommand };
@@ -0,0 +1,64 @@
1
+ import { Command } from "@effect/cli";
2
+ import { Commitlint } from "@savvy-web/silk-effects";
3
+ import { Effect, Schema } from "effect";
4
+ import { resolve } from "node:path";
5
+
6
+ //#region src/commands/commit/hooks/pre-commit-message.ts
7
+ /**
8
+ * `savvy commit hook pre-commit-message` — reads a PreToolUse envelope on
9
+ * stdin and emits deny/advise/silent output for the bash hook.
10
+ *
11
+ * @internal
12
+ */
13
+ async function evaluateMessage(command, ctx) {
14
+ const parsed = Commitlint.parseBashCommand(command);
15
+ if (parsed.kind === "unknown") return Commitlint.preToolUseSilent();
16
+ if (parsed.message === null) return Commitlint.preToolUseSilent();
17
+ const hits = [];
18
+ const collect = async (eff) => {
19
+ const h = await Effect.runPromise(eff);
20
+ if (h) hits.push(h);
21
+ };
22
+ await collect(Commitlint.forbiddenContentRule.check({ message: parsed.message }, void 0));
23
+ await collect(Commitlint.planLeakageRule.check({ message: parsed.message }, void 0));
24
+ await collect(Commitlint.softWrapRule.check({ message: parsed.message }, void 0));
25
+ await collect(Commitlint.verbosityRule.check({ message: parsed.message }, void 0));
26
+ await collect(Commitlint.closesTrailerRule.check({ message: parsed.message }, {
27
+ branchInfo: ctx.branchInfo,
28
+ openIssues: ctx.openIssues
29
+ }));
30
+ await collect(Commitlint.signingFlagConflictRule.check({ flags: parsed.flags }, { autoSignEnabled: ctx.autoSignEnabled }));
31
+ const { deny, advise } = Commitlint.partitionHits(hits);
32
+ if (deny.length > 0) return Commitlint.preToolUseDeny(["The following rules denied this commit message:", ...deny.map((h) => `- ${h.message}`)].join("\n"));
33
+ if (advise.length > 0) return Commitlint.preToolUseAdvise(["The commit message you are about to write has issues that should be addressed:", ...advise.map((h) => `- ${h.message}`)].join("\n"));
34
+ return Commitlint.preToolUseSilent();
35
+ }
36
+ const preCommitMessageCommand = Command.make("pre-commit-message", {}, () => Effect.gen(function* () {
37
+ const stdin = yield* Effect.promise(readStdin);
38
+ let envelope;
39
+ try {
40
+ envelope = Schema.decodeUnknownSync(Commitlint.PreToolUseEnvelope)(JSON.parse(stdin));
41
+ } catch {
42
+ return;
43
+ }
44
+ const command = String(envelope.tool_input.command ?? "");
45
+ if (!command) return;
46
+ const branchInfo = yield* Commitlint.readBranchInfo();
47
+ const signing = yield* Commitlint.readSigningDiagnostic();
48
+ const issuesPath = resolve(process.env.CLAUDE_PROJECT_DIR ?? process.cwd(), Commitlint.ISSUES_CACHE_RELATIVE_PATH);
49
+ const issues = (yield* Commitlint.readOpenIssuesFromCache(issuesPath)) ?? [];
50
+ const out = yield* Effect.promise(() => evaluateMessage(command, {
51
+ branchInfo,
52
+ openIssues: issues,
53
+ autoSignEnabled: signing.autoSignEnabled
54
+ }));
55
+ if (out !== null) yield* Effect.sync(() => process.stdout.write(`${JSON.stringify(out)}\n`));
56
+ }).pipe(Effect.provide(Commitlint.HookSilencer))).pipe(Command.withDescription("Validate a candidate git commit / gh pr command's message"));
57
+ async function readStdin() {
58
+ const chunks = [];
59
+ for await (const chunk of process.stdin) chunks.push(chunk);
60
+ return Buffer.concat(chunks).toString("utf8");
61
+ }
62
+
63
+ //#endregion
64
+ export { preCommitMessageCommand };
@@ -0,0 +1,69 @@
1
+ import { Command } from "@effect/cli";
2
+ import { Commitlint } from "@savvy-web/silk-effects";
3
+ import { Effect } from "effect";
4
+ import { resolve } from "node:path";
5
+
6
+ //#region src/commands/commit/hooks/session-start.ts
7
+ /**
8
+ * `savvy commit hook session-start` — emits the additionalContext block
9
+ * the SessionStart hook injects into the agent's context.
10
+ *
11
+ * @internal
12
+ */
13
+ const sessionStartCommand = Command.make("session-start", {}, () => Effect.gen(function* () {
14
+ const branch = yield* Commitlint.readBranchInfo();
15
+ const signing = yield* Commitlint.readSigningDiagnostic();
16
+ const issuesCachePath = resolve(process.env.CLAUDE_PROJECT_DIR ?? process.cwd(), Commitlint.ISSUES_CACHE_RELATIVE_PATH);
17
+ const issues = yield* Commitlint.readOrFetchOpenIssues(issuesCachePath);
18
+ const blocks = [];
19
+ blocks.push(buildSkillDirectiveBlock());
20
+ if (branch.branch) blocks.push(buildBranchBlock(branch.branch, branch.inferredTicketId, issues));
21
+ blocks.push(buildSigningBlock(signing));
22
+ const ctx = `<EXTREMELY_IMPORTANT>\n${blocks.join("\n\n")}\n</EXTREMELY_IMPORTANT>`;
23
+ const out = Commitlint.sessionStartContext(ctx);
24
+ yield* Effect.sync(() => process.stdout.write(`${JSON.stringify(out)}\n`));
25
+ }).pipe(Effect.provide(Commitlint.HookSilencer))).pipe(Command.withDescription("Emit the SessionStart additionalContext for the commitlint plugin"));
26
+ function buildSkillDirectiveBlock() {
27
+ return [
28
+ "Before you run git commit, gh pr create, gh pr edit, amend a commit, or compose",
29
+ "any commit message, you MUST invoke the commitlint:commit-create skill. It",
30
+ "contains the complete type enum, tdd scope grammar, subject and body rules, DCO",
31
+ "signoff format, Closes trailer pattern, and signing guidance.",
32
+ "",
33
+ "YOU DO NOT HAVE A CHOICE. Even if you believe the commit message is obvious,",
34
+ "you MUST load the skill first. This is not negotiable. You cannot rationalize",
35
+ "your way around it. The commit-msg hook will reject messages that violate the",
36
+ "rules defined in that skill."
37
+ ].join("\n");
38
+ }
39
+ function buildBranchBlock(branch, ticketId, issues) {
40
+ const lines = [
41
+ "<branch_context>",
42
+ `<current_branch>${branch}</current_branch>`,
43
+ `<inferred_ticket_id>${ticketId ?? "null"}</inferred_ticket_id>`
44
+ ];
45
+ if (issues && issues.length > 0) {
46
+ lines.push("<open_issues_in_repo>");
47
+ for (const i of issues) lines.push(` - #${i.number} ${i.title}`);
48
+ lines.push("</open_issues_in_repo>");
49
+ }
50
+ lines.push("</branch_context>");
51
+ return lines.join("\n");
52
+ }
53
+ function buildSigningBlock(d) {
54
+ const lines = [
55
+ "<signing_diagnostic>",
56
+ `<format>${d.format}</format>`,
57
+ `<auto_sign_enabled>${d.autoSignEnabled}</auto_sign_enabled>`,
58
+ `<signing_key_configured>${d.signingKeyConfigured}</signing_key_configured>`,
59
+ `<key_resolves>${d.keyResolves}</key_resolves>`,
60
+ `<agent_responsive>${d.agentResponsive}</agent_responsive>`,
61
+ "<warnings>"
62
+ ];
63
+ for (const w of d.warnings) lines.push(` - ${w}`);
64
+ lines.push("</warnings>", "</signing_diagnostic>");
65
+ return lines.join("\n");
66
+ }
67
+
68
+ //#endregion
69
+ export { sessionStartCommand };