@rrlab/cli 0.0.1-git-87d22db.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 (43) hide show
  1. package/README.md +110 -0
  2. package/bin +54 -0
  3. package/dist/cli.usage.kdl +71 -0
  4. package/dist/config.d.mts +11 -0
  5. package/dist/config.mjs +6 -0
  6. package/dist/plugin.d.mts +52 -0
  7. package/dist/plugin.mjs +65 -0
  8. package/dist/run.mjs +1137 -0
  9. package/dist/types-C3V27_kd.d.mts +173 -0
  10. package/package.json +74 -0
  11. package/src/lib/config.ts +5 -0
  12. package/src/lib/plugin.ts +31 -0
  13. package/src/plugin/define-plugin.ts +5 -0
  14. package/src/plugin/registry.ts +47 -0
  15. package/src/plugin/tool-service.ts +77 -0
  16. package/src/plugin/types.ts +139 -0
  17. package/src/program/commands/check.ts +63 -0
  18. package/src/program/commands/clean.ts +53 -0
  19. package/src/program/commands/completion.ts +26 -0
  20. package/src/program/commands/config.ts +15 -0
  21. package/src/program/commands/doctor.ts +85 -0
  22. package/src/program/commands/format.ts +35 -0
  23. package/src/program/commands/jscheck.ts +44 -0
  24. package/src/program/commands/lint.ts +37 -0
  25. package/src/program/commands/pack.ts +27 -0
  26. package/src/program/commands/plugins.ts +359 -0
  27. package/src/program/commands/tscheck.ts +112 -0
  28. package/src/program/composed-jsc.ts +35 -0
  29. package/src/program/index.ts +50 -0
  30. package/src/program/missing-plugin.ts +18 -0
  31. package/src/program/ui.ts +59 -0
  32. package/src/run.ts +11 -0
  33. package/src/services/config-ast.ts +202 -0
  34. package/src/services/config.ts +54 -0
  35. package/src/services/ctx.ts +72 -0
  36. package/src/services/json-edit.ts +147 -0
  37. package/src/services/logger.ts +5 -0
  38. package/src/services/plugins-registry.ts +21 -0
  39. package/src/services/prompts.ts +26 -0
  40. package/src/services/workspace-target.ts +27 -0
  41. package/src/types/config.ts +13 -0
  42. package/src/types/tool.ts +57 -0
  43. package/tsconfig.json +3 -0
@@ -0,0 +1,26 @@
1
+ import { Argument, createCommand } from "commander";
2
+ import { TOOL_LABELS } from "../ui.ts";
3
+
4
+ const SHELLS = ["bash", "zsh", "fish"] as const;
5
+
6
+ // Ghost command: registered with Commander purely for discoverability — it surfaces
7
+ // in `rr --help` and is baked into dist/cli.usage.kdl so the completion itself can
8
+ // suggest "completion" after `rr <TAB>`. The actual handler lives in the bash bin
9
+ // dispatcher, which intercepts `rr completion <shell>` before reaching Node.
10
+ export function createCompletionCommand() {
11
+ return createCommand("completion")
12
+ .summary(`print shell completion script (${TOOL_LABELS.USAGE})`)
13
+ .description(
14
+ `Prints a shell completion script for rr. Add to your shell rc file:
15
+
16
+ bash: eval "$(rr completion bash)"
17
+ zsh: eval "$(rr completion zsh)"
18
+ fish: rr completion fish | source`,
19
+ )
20
+ .addArgument(new Argument("<shell>", `target shell`).choices(SHELLS))
21
+ .addHelpText(
22
+ "afterAll",
23
+ `\nUnder the hood, this command uses ${TOOL_LABELS.USAGE} (https://usage.jdx.dev).
24
+ Make sure to have it installed and available in your PATH.`,
25
+ );
26
+ }
@@ -0,0 +1,15 @@
1
+ import { palette } from "@vlandoss/clibuddy";
2
+ import { createCommand } from "commander";
3
+ import type { Context } from "#src/services/ctx.ts";
4
+
5
+ export function createConfigCommand(ctx: Context) {
6
+ return createCommand("config")
7
+ .summary("display the current config")
8
+ .description("Displays the current configuration settings, including their source file path if available.")
9
+ .action(async function configAction() {
10
+ const { config, meta } = ctx.config;
11
+ console.log(palette.muted("Config:"));
12
+ console.log(config);
13
+ console.log(palette.muted(`Loaded from ${meta.filepath ? palette.link(meta.filepath) : "n/a"}`));
14
+ });
15
+ }
@@ -0,0 +1,85 @@
1
+ import { createCommand } from "commander";
2
+ import type { Doctor, DoctorResult } from "#src/plugin/types.ts";
3
+ import { PLUGIN_KINDS } from "#src/plugin/types.ts";
4
+ import type { Context } from "#src/services/ctx.ts";
5
+ import { logger } from "#src/services/logger.ts";
6
+
7
+ /**
8
+ * Subcommand factory used by every plugin-backed command (lint, format, jsc,
9
+ * tsc, pack) to expose a `doctor` subcommand that verifies the underlying
10
+ * tool is wired correctly. Each calls this with its own provider.
11
+ */
12
+ export function createDoctorSubcommand(service: Doctor) {
13
+ return createCommand("doctor")
14
+ .summary("check if the underlying tool is working correctly")
15
+ .action(async function doctorAction() {
16
+ const debug = logger.subdebug("doctor");
17
+ const { ok, output } = await service.doctor();
18
+
19
+ if (ok) {
20
+ logger.success(`${service.ui} ok`);
21
+ debug("%O", output);
22
+ } else {
23
+ logger.error(`${service.ui} not working`);
24
+ debug("%O", output);
25
+ process.exit(output.exitCode ?? 1);
26
+ }
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Top-level `rr doctor` — runs the `doctor()` of every distinct capability
32
+ * impl registered with the kernel. Distinct because a single plugin (e.g.
33
+ * biome) often serves multiple kinds (`lint`, `format`, `jsc`) from the same
34
+ * `BiomeService` instance; running its doctor three times is wasteful.
35
+ */
36
+ export function createDoctorCommand(ctx: Context) {
37
+ return createCommand("doctor")
38
+ .summary("run all plugin doctors")
39
+ .description(
40
+ "Runs the `doctor()` of every configured plugin capability. Each plugin reports ok / not working. The exit code is non-zero if any reports not working.",
41
+ )
42
+ .action(async () => {
43
+ const services = collectDistinctDoctors(ctx);
44
+ if (services.length === 0) {
45
+ logger.info("No plugins configured. Use `rr plugins add <name>` to install one.");
46
+ return;
47
+ }
48
+
49
+ const debug = logger.subdebug("doctor");
50
+ const results = await Promise.all(
51
+ services.map(async (svc) => {
52
+ const result = await svc.doctor();
53
+ return { svc, result };
54
+ }),
55
+ );
56
+
57
+ let failures = 0;
58
+ for (const { svc, result } of results) {
59
+ if (result.ok) {
60
+ logger.success(`${svc.ui} ok`);
61
+ debug("%s: %O", svc.ui, result.output);
62
+ } else {
63
+ logger.error(`${svc.ui} not working`);
64
+ debug("%s: %O", svc.ui, result.output);
65
+ failures++;
66
+ }
67
+ }
68
+
69
+ if (failures > 0) process.exitCode = 1;
70
+ });
71
+ }
72
+
73
+ function collectDistinctDoctors(ctx: Context): Doctor[] {
74
+ const seen = new Set<Doctor>();
75
+ for (const kind of PLUGIN_KINDS) {
76
+ for (const { impl } of ctx.registry.providersOf(kind)) {
77
+ // Capability impls carry `doctor` via the Doctor intersection; dedup
78
+ // by reference so a single service that backs multiple kinds runs once.
79
+ seen.add(impl as unknown as Doctor);
80
+ }
81
+ }
82
+ return [...seen];
83
+ }
84
+
85
+ export type { Doctor as _Doctor, DoctorResult as _DoctorResult };
@@ -0,0 +1,35 @@
1
+ import { createCommand } from "commander";
2
+ import type { Context } from "#src/services/ctx.ts";
3
+ import { missingPluginError } from "../missing-plugin.ts";
4
+ import { pluginAnnotation } from "../ui.ts";
5
+ import { createDoctorSubcommand } from "./doctor.ts";
6
+
7
+ type ActionOptions = {
8
+ fix?: boolean;
9
+ };
10
+
11
+ export function createFormatCommand(ctx: Context) {
12
+ const formatter = ctx.registry.get("format");
13
+
14
+ const cmd = createCommand("format")
15
+ .summary(`check & fix format errors${pluginAnnotation(formatter)}`)
16
+ .description(
17
+ "Checks the code for formatting issues and optionally fixes them, ensuring it adheres to the defined style standards.",
18
+ )
19
+ .option("--fix", "format all the code");
20
+
21
+ if (formatter) {
22
+ cmd.addCommand(createDoctorSubcommand(formatter));
23
+ }
24
+
25
+ cmd.action(async (options: ActionOptions = {}) => {
26
+ if (!formatter) throw missingPluginError("format");
27
+ await formatter.format(options);
28
+ });
29
+
30
+ if (formatter) {
31
+ cmd.addHelpText("afterAll", `\nUnder the hood, this command uses the ${formatter.ui} CLI to format the code.`);
32
+ }
33
+
34
+ return cmd;
35
+ }
@@ -0,0 +1,44 @@
1
+ import { createCommand } from "commander";
2
+ import type { Context } from "#src/services/ctx.ts";
3
+ import { composedJscProvider } from "../composed-jsc.ts";
4
+ import { missingPluginError } from "../missing-plugin.ts";
5
+ import { pluginAnnotation } from "../ui.ts";
6
+ import { createDoctorSubcommand } from "./doctor.ts";
7
+
8
+ type ActionOptions = {
9
+ fix?: boolean;
10
+ fixStaged?: boolean;
11
+ };
12
+
13
+ export function createJsCheckCommand(ctx: Context) {
14
+ const direct = ctx.registry.get("jsc");
15
+ const linter = ctx.registry.get("lint");
16
+ const formatter = ctx.registry.get("format");
17
+ // Compose only when no plugin claims `jsc` directly and we have both
18
+ // building blocks separately (e.g. oxc, or eslint + prettier).
19
+ const checker = direct ?? (linter && formatter ? composedJscProvider(linter, formatter) : undefined);
20
+
21
+ const cmd = createCommand("jsc")
22
+ .alias("jscheck")
23
+ .summary(`check format and lint${pluginAnnotation(checker)}`)
24
+ .description(
25
+ "Checks the code for formatting and linting issues, ensuring it adheres to the defined style and quality standards.",
26
+ )
27
+ .option("--fix", "try to fix issues automatically")
28
+ .option("--fix-staged", "try to fix staged files only");
29
+
30
+ if (checker) {
31
+ cmd.addCommand(createDoctorSubcommand(checker));
32
+ }
33
+
34
+ cmd.action(async (options: ActionOptions = {}) => {
35
+ if (!checker) throw missingPluginError("jsc");
36
+ await checker.check(options);
37
+ });
38
+
39
+ if (checker) {
40
+ cmd.addHelpText("afterAll", `\nUnder the hood, this command uses the ${checker.ui} CLI to check the code.`);
41
+ }
42
+
43
+ return cmd;
44
+ }
@@ -0,0 +1,37 @@
1
+ import { createCommand } from "commander";
2
+ import type { Context } from "#src/services/ctx.ts";
3
+ import { missingPluginError } from "../missing-plugin.ts";
4
+ import { pluginAnnotation } from "../ui.ts";
5
+ import { createDoctorSubcommand } from "./doctor.ts";
6
+
7
+ type ActionOptions = {
8
+ check?: boolean;
9
+ fix?: boolean;
10
+ };
11
+
12
+ export function createLintCommand(ctx: Context) {
13
+ const linter = ctx.registry.get("lint");
14
+
15
+ const cmd = createCommand("lint")
16
+ .summary(`check & fix lint errors${pluginAnnotation(linter)}`)
17
+ .description(
18
+ "Checks the code for linting issues and optionally fixes them, ensuring it adheres to the defined quality standards.",
19
+ )
20
+ .option("-c, --check", "check if the code is valid", true)
21
+ .option("--fix", "try to fix all the code");
22
+
23
+ if (linter) {
24
+ cmd.addCommand(createDoctorSubcommand(linter));
25
+ }
26
+
27
+ cmd.action(async (options: ActionOptions = {}) => {
28
+ if (!linter) throw missingPluginError("lint");
29
+ await linter.lint(options);
30
+ });
31
+
32
+ if (linter) {
33
+ cmd.addHelpText("afterAll", `\nUnder the hood, this command uses the ${linter.ui} CLI to lint the code.`);
34
+ }
35
+
36
+ return cmd;
37
+ }
@@ -0,0 +1,27 @@
1
+ import { createCommand } from "commander";
2
+ import type { Context } from "#src/services/ctx.ts";
3
+ import { missingPluginError } from "../missing-plugin.ts";
4
+ import { pluginAnnotation } from "../ui.ts";
5
+ import { createDoctorSubcommand } from "./doctor.ts";
6
+
7
+ export function createPackCommand(ctx: Context) {
8
+ const packer = ctx.registry.get("pack");
9
+
10
+ const cmd = createCommand("pack")
11
+ .summary(`pack a ts library${pluginAnnotation(packer)}`)
12
+ .description(
13
+ "Compiles TypeScript code into JavaScript and generates type declaration files, packaging the library for distribution.",
14
+ );
15
+
16
+ if (packer) {
17
+ cmd.addCommand(createDoctorSubcommand(packer));
18
+ cmd.addHelpText("afterAll", `\nUnder the hood, this command uses the ${packer.ui} CLI to pack the project.`);
19
+ }
20
+
21
+ cmd.action(async () => {
22
+ if (!packer) throw missingPluginError("pack");
23
+ await packer.pack();
24
+ });
25
+
26
+ return cmd;
27
+ }
@@ -0,0 +1,359 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import * as clack from "@clack/prompts";
4
+ import { Argument, createCommand } from "commander";
5
+ import { addDependency, detectPackageManager, removeDependency } from "nypm";
6
+ import type { FileOp, InstallContext, InstallResult, UninstallContext, UninstallResult } from "#src/plugin/types.ts";
7
+ import { ConfigAstService } from "#src/services/config-ast.ts";
8
+ import type { Context } from "#src/services/ctx.ts";
9
+ import { applyJsonEdits } from "#src/services/json-edit.ts";
10
+ import { logger } from "#src/services/logger.ts";
11
+ import { OFFICIAL_PLUGINS, type OfficialAlias, officialAliases } from "#src/services/plugins-registry.ts";
12
+ import { createClackPrompts } from "#src/services/prompts.ts";
13
+ import { describeWorkspaceChoice, resolveWorkspaceChoice, toNypmWorkspace } from "#src/services/workspace-target.ts";
14
+
15
+ type AddOptions = {
16
+ force?: boolean;
17
+ yes?: boolean;
18
+ dryRun?: boolean;
19
+ };
20
+
21
+ type RemoveOptions = {
22
+ yes?: boolean;
23
+ dryRun?: boolean;
24
+ };
25
+
26
+ type InstallHook = (ctx: InstallContext) => Promise<InstallResult>;
27
+ type UninstallHook = (ctx: UninstallContext) => Promise<UninstallResult>;
28
+ type PluginModule = { default?: (opts?: unknown) => { install?: InstallHook; uninstall?: UninstallHook } };
29
+
30
+ export function createPluginsCommand(ctx: Context) {
31
+ const cmd = createCommand("plugins").description("manage @rrlab plugins");
32
+
33
+ cmd
34
+ .command("list")
35
+ .description("list plugins configured in run-run.config.{ts,mts}")
36
+ .action(() => runList(ctx));
37
+
38
+ cmd
39
+ .command("add")
40
+ .description("install and configure an @rrlab plugin")
41
+ .addArgument(new Argument("<name>", "plugin alias").choices(officialAliases()))
42
+ .option("--force", "re-run install even if the plugin is already configured")
43
+ .option("--yes", "skip prompts and use defaults (non-interactive)")
44
+ .option("--dry-run", "show what would happen, without applying changes")
45
+ .action((name: OfficialAlias, opts: AddOptions) => runAdd(ctx, name, opts));
46
+
47
+ cmd
48
+ .command("remove")
49
+ .description("uninstall an @rrlab plugin and undo its config files + deps")
50
+ .addArgument(new Argument("<name>", "plugin alias to remove").choices(officialAliases()))
51
+ .option("--yes", "skip the confirmation prompt")
52
+ .option("--dry-run", "print the plan without applying changes")
53
+ .action((name: OfficialAlias, opts: RemoveOptions) => runRemove(ctx, name, opts));
54
+
55
+ return cmd;
56
+ }
57
+
58
+ async function runList(ctx: Context) {
59
+ const ast = new ConfigAstService();
60
+ const loaded = await ast.load(ctx.appPkg.dirPath);
61
+ if (loaded.isNew) {
62
+ logger.info("No run-run.config.{ts,mts} found. Use `rr plugins add <name>` to start.");
63
+ return;
64
+ }
65
+ const plugins = ast.listPlugins(loaded.mod);
66
+ const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
67
+ if (plugins.length === 0) {
68
+ logger.info(`${rel}: no plugins configured.`);
69
+ return;
70
+ }
71
+ logger.info(`${rel}:`);
72
+ for (const name of plugins) {
73
+ logger.info(` - ${name}`);
74
+ }
75
+ }
76
+
77
+ async function runAdd(ctx: Context, alias: OfficialAlias, opts: AddOptions) {
78
+ const { pkg: pkgName, exportName } = OFFICIAL_PLUGINS[alias];
79
+
80
+ clack.intro(` rr plugins add ${alias} `);
81
+
82
+ const inPkg = hasInPackageJson(ctx, pkgName);
83
+ const ast = new ConfigAstService();
84
+ const loaded = await ast.load(ctx.appPkg.dirPath);
85
+ const inConfig = !loaded.isNew && ast.hasPlugin(loaded.mod, exportName);
86
+
87
+ if (inPkg && inConfig && !opts.force) {
88
+ clack.log.warn(`${pkgName} is already installed and configured. Use --force to re-run install.`);
89
+ clack.outro("Nothing to do.");
90
+ return;
91
+ }
92
+
93
+ const pm = await detectPackageManager(ctx.appPkg.dirPath);
94
+ const wsChoice = resolveWorkspaceChoice(ctx.appPkg, pm);
95
+ const workspace = toNypmWorkspace(wsChoice);
96
+ const targetLabel = describeWorkspaceChoice(wsChoice);
97
+
98
+ if (opts.dryRun) {
99
+ clack.log.info(
100
+ `Would: install ${pkgName} as a devDependency in ${targetLabel}${inPkg ? " (already present, skipped)" : ""}.`,
101
+ );
102
+ if (!inConfig) {
103
+ const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
104
+ clack.log.info(`Would: add ${exportName}() to ${rel} (plugins[]).`);
105
+ }
106
+ clack.log.info("Would: run the plugin's install() hook (if any) to fetch peer deps and create files.");
107
+ clack.outro("Dry run complete.");
108
+ return;
109
+ }
110
+
111
+ let installedNow = false;
112
+ if (!inPkg) {
113
+ await withSpinner(`Installing ${pkgName}`, async () => {
114
+ await addDependency([pkgName], { cwd: ctx.appPkg.dirPath, dev: true, silent: true, workspace });
115
+ });
116
+ installedNow = true;
117
+ }
118
+
119
+ let installResult: InstallResult | undefined;
120
+ try {
121
+ const mod = (await import(pkgName)) as PluginModule;
122
+ const factory = mod.default;
123
+ if (typeof factory !== "function") {
124
+ throw new Error(`Plugin '${pkgName}' did not export a default factory function.`);
125
+ }
126
+ const plugin = factory();
127
+ if (plugin.install) {
128
+ const installCtx: InstallContext = {
129
+ shell: ctx.shell,
130
+ logger,
131
+ appPkg: ctx.appPkg,
132
+ prompts: createClackPrompts(),
133
+ flags: {
134
+ force: !!opts.force,
135
+ yes: !!opts.yes,
136
+ nonInteractive: !!opts.yes,
137
+ },
138
+ };
139
+ installResult = await plugin.install(installCtx);
140
+ }
141
+ } catch (err) {
142
+ if (installedNow) {
143
+ try {
144
+ await removeDependency(pkgName, { cwd: ctx.appPkg.dirPath, workspace });
145
+ } catch {
146
+ // best-effort rollback — don't mask the original error
147
+ }
148
+ }
149
+ throw err;
150
+ }
151
+
152
+ if (installResult?.devDependencies && Object.keys(installResult.devDependencies).length > 0) {
153
+ const names = Object.keys(installResult.devDependencies);
154
+ const deps = Object.entries(installResult.devDependencies).map(([k, v]) => `${k}@${v}`);
155
+ await withSpinner(`Installing ${names.join(", ")}`, async () => {
156
+ await addDependency(deps, { cwd: ctx.appPkg.dirPath, dev: true, silent: true, workspace });
157
+ });
158
+ }
159
+ for (const op of installResult?.files ?? []) {
160
+ await applyFileOp(ctx.appPkg.dirPath, op, !!opts.force);
161
+ }
162
+
163
+ if (!inConfig) {
164
+ ast.addPlugin(loaded.mod, { exportName, pkgName });
165
+ await ast.save(loaded);
166
+ const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
167
+ clack.log.success(`Updated ${rel}`);
168
+ }
169
+
170
+ clack.outro(`Plugin '${alias}' ready 🎉`);
171
+ }
172
+
173
+ async function runRemove(ctx: Context, alias: OfficialAlias, opts: RemoveOptions) {
174
+ const { pkg: pkgName, exportName } = OFFICIAL_PLUGINS[alias];
175
+
176
+ clack.intro(` rr plugins remove ${alias} `);
177
+
178
+ const ast = new ConfigAstService();
179
+ const loaded = await ast.load(ctx.appPkg.dirPath);
180
+ const inConfig = !loaded.isNew && ast.hasPlugin(loaded.mod, exportName);
181
+
182
+ // Collect plugin's uninstall plan (only if pkg is installed + has uninstall hook).
183
+ let uninstallResult: UninstallResult | undefined;
184
+ if (hasInPackageJson(ctx, pkgName)) {
185
+ try {
186
+ const mod = (await import(pkgName)) as PluginModule;
187
+ const factory = mod.default;
188
+ const plugin = typeof factory === "function" ? factory() : undefined;
189
+ if (plugin?.uninstall) {
190
+ uninstallResult = await plugin.uninstall({
191
+ shell: ctx.shell,
192
+ logger,
193
+ appPkg: ctx.appPkg,
194
+ prompts: createClackPrompts(),
195
+ flags: { yes: !!opts.yes, nonInteractive: !!opts.yes },
196
+ });
197
+ }
198
+ } catch (err) {
199
+ clack.log.warn(`Could not load ${pkgName} for uninstall hook: ${err instanceof Error ? err.message : err}`);
200
+ }
201
+ }
202
+
203
+ const planSteps: string[] = [];
204
+ if (inConfig) {
205
+ const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
206
+ planSteps.push(`Remove ${exportName}() from ${rel}`);
207
+ }
208
+ for (const op of uninstallResult?.files ?? []) {
209
+ planSteps.push(describeFileOp(op));
210
+ }
211
+ const depsToRemove = (uninstallResult?.removeDependencies ?? []).filter((dep) => hasInPackageJson(ctx, dep));
212
+ if (hasInPackageJson(ctx, pkgName) && !depsToRemove.includes(pkgName)) {
213
+ depsToRemove.unshift(pkgName);
214
+ }
215
+ const pm = await detectPackageManager(ctx.appPkg.dirPath);
216
+ const wsChoice = resolveWorkspaceChoice(ctx.appPkg, pm);
217
+ const workspace = toNypmWorkspace(wsChoice);
218
+
219
+ if (depsToRemove.length > 0) {
220
+ planSteps.push(`Uninstall: ${depsToRemove.join(", ")} (from ${describeWorkspaceChoice(wsChoice)})`);
221
+ }
222
+
223
+ if (planSteps.length === 0) {
224
+ clack.log.warn(`Plugin '${alias}' is not installed nor configured.`);
225
+ clack.outro("Nothing to do.");
226
+ return;
227
+ }
228
+
229
+ clack.log.message(`Plan:\n${planSteps.map((s) => ` • ${s}`).join("\n")}`);
230
+
231
+ if (opts.dryRun) {
232
+ clack.outro("Dry run complete.");
233
+ return;
234
+ }
235
+
236
+ if (!opts.yes) {
237
+ const choice = await clack.confirm({ message: "Proceed?", initialValue: false });
238
+ if (clack.isCancel(choice) || choice !== true) {
239
+ clack.outro("Aborted.");
240
+ return;
241
+ }
242
+ }
243
+
244
+ // Apply file ops first (they may need the plugin's source to still be importable).
245
+ for (const op of uninstallResult?.files ?? []) {
246
+ await applyFileOp(ctx.appPkg.dirPath, op, /* force */ true);
247
+ }
248
+ if (inConfig) {
249
+ ast.removePlugin(loaded.mod, exportName);
250
+ await ast.save(loaded);
251
+ const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
252
+ clack.log.success(`Removed ${exportName}() from ${rel}`);
253
+ }
254
+ for (const dep of depsToRemove) {
255
+ await withSpinner(`Uninstalling ${dep}`, async () => {
256
+ await removeDependency(dep, { cwd: ctx.appPkg.dirPath, workspace });
257
+ });
258
+ }
259
+
260
+ clack.outro(`Plugin '${alias}' removed.`);
261
+ }
262
+
263
+ function hasInPackageJson(ctx: Context, pkgName: string): boolean {
264
+ const pkg = ctx.appPkg.packageJson;
265
+ const deps = {
266
+ ...(pkg.dependencies ?? {}),
267
+ ...(pkg.devDependencies ?? {}),
268
+ ...(pkg.peerDependencies ?? {}),
269
+ };
270
+ return pkgName in deps;
271
+ }
272
+
273
+ async function withSpinner<T>(message: string, fn: () => Promise<T>): Promise<T> {
274
+ const sp = clack.spinner();
275
+ sp.start(message);
276
+ try {
277
+ const result = await fn();
278
+ sp.stop(message);
279
+ return result;
280
+ } catch (err) {
281
+ sp.stop(`${message} — failed`, 1);
282
+ throw err;
283
+ }
284
+ }
285
+
286
+ function describeFileOp(op: FileOp): string {
287
+ switch (op.kind) {
288
+ case "create":
289
+ return `${op.overwrite ? "Overwrite" : "Create"} ${op.path}`;
290
+ case "edit-json":
291
+ return `Edit ${op.path} (${op.edits.length} change${op.edits.length === 1 ? "" : "s"})`;
292
+ case "edit-text":
293
+ return `Edit ${op.path}`;
294
+ case "delete":
295
+ return `Delete ${op.path}`;
296
+ }
297
+ }
298
+
299
+ async function applyFileOp(cwd: string, op: FileOp, force: boolean) {
300
+ const abs = path.join(cwd, op.path);
301
+ if (op.kind === "create") {
302
+ const exists = await pathExists(abs);
303
+ if (exists && !op.overwrite && !force) {
304
+ clack.log.warn(`Skipping ${op.path} — already exists. Use --force to overwrite.`);
305
+ return;
306
+ }
307
+ await fs.mkdir(path.dirname(abs), { recursive: true });
308
+ await fs.writeFile(abs, op.content, "utf8");
309
+ clack.log.success(`${exists ? "Overwrote" : "Created"} ${op.path}`);
310
+ return;
311
+ }
312
+ if (op.kind === "edit-json") {
313
+ if (!(await pathExists(abs))) {
314
+ clack.log.warn(`Skipping ${op.path} — file does not exist.`);
315
+ return;
316
+ }
317
+ const source = await fs.readFile(abs, "utf8");
318
+ const next = applyJsonEdits(source, op.edits);
319
+ if (next !== source) {
320
+ await fs.writeFile(abs, next, "utf8");
321
+ clack.log.success(`Edited ${op.path}`);
322
+ } else {
323
+ clack.log.info(`No changes for ${op.path}.`);
324
+ }
325
+ return;
326
+ }
327
+ if (op.kind === "edit-text") {
328
+ if (!(await pathExists(abs))) {
329
+ clack.log.warn(`Skipping ${op.path} — file does not exist.`);
330
+ return;
331
+ }
332
+ const source = await fs.readFile(abs, "utf8");
333
+ const next = op.edit(source);
334
+ if (next !== source) {
335
+ await fs.writeFile(abs, next, "utf8");
336
+ clack.log.success(`Edited ${op.path}`);
337
+ } else {
338
+ clack.log.info(`No changes for ${op.path}.`);
339
+ }
340
+ return;
341
+ }
342
+ if (op.kind === "delete") {
343
+ if (!(await pathExists(abs))) return;
344
+ await fs.unlink(abs);
345
+ clack.log.success(`Deleted ${op.path}`);
346
+ return;
347
+ }
348
+ }
349
+
350
+ async function pathExists(p: string): Promise<boolean> {
351
+ try {
352
+ await fs.access(p);
353
+ return true;
354
+ } catch {
355
+ return false;
356
+ }
357
+ }
358
+
359
+ export { OFFICIAL_PLUGINS };