feat(cli): add machine-readable CLI help, recommendation, and configuration flows

This commit is contained in:
2026-04-16 18:54:07 +00:00
parent f43f88a3cb
commit fd7a73398c
14 changed files with 2482 additions and 786 deletions
+277 -88
View File
@@ -1,44 +1,60 @@
import * as plugins from './mod.plugins.js';
import { Project } from '../classes.project.js';
import { FormatContext } from './classes.formatcontext.js';
import { FormatPlanner } from './classes.formatplanner.js';
import { BaseFormatter } from './classes.baseformatter.js';
import { logger, setVerboseMode } from '../gitzone.logging.js';
import * as plugins from "./mod.plugins.js";
import { Project } from "../classes.project.js";
import { FormatContext } from "./classes.formatcontext.js";
import { FormatPlanner } from "./classes.formatplanner.js";
import { BaseFormatter } from "./classes.baseformatter.js";
import { logger, setVerboseMode } from "../gitzone.logging.js";
import type { ICliMode } from "../helpers.climode.js";
import {
getCliMode,
printJson,
runWithSuppressedOutput,
} from "../helpers.climode.js";
import { getCliConfigValue } from "../helpers.smartconfig.js";
import { CleanupFormatter } from './formatters/cleanup.formatter.js';
import { SmartconfigFormatter } from './formatters/smartconfig.formatter.js';
import { LicenseFormatter } from './formatters/license.formatter.js';
import { PackageJsonFormatter } from './formatters/packagejson.formatter.js';
import { TemplatesFormatter } from './formatters/templates.formatter.js';
import { GitignoreFormatter } from './formatters/gitignore.formatter.js';
import { TsconfigFormatter } from './formatters/tsconfig.formatter.js';
import { PrettierFormatter } from './formatters/prettier.formatter.js';
import { ReadmeFormatter } from './formatters/readme.formatter.js';
import { CopyFormatter } from './formatters/copy.formatter.js';
import { CleanupFormatter } from "./formatters/cleanup.formatter.js";
import { SmartconfigFormatter } from "./formatters/smartconfig.formatter.js";
import { LicenseFormatter } from "./formatters/license.formatter.js";
import { PackageJsonFormatter } from "./formatters/packagejson.formatter.js";
import { TemplatesFormatter } from "./formatters/templates.formatter.js";
import { GitignoreFormatter } from "./formatters/gitignore.formatter.js";
import { TsconfigFormatter } from "./formatters/tsconfig.formatter.js";
import { PrettierFormatter } from "./formatters/prettier.formatter.js";
import { ReadmeFormatter } from "./formatters/readme.formatter.js";
import { CopyFormatter } from "./formatters/copy.formatter.js";
/**
* Rename npmextra.json or smartconfig.json to .smartconfig.json
* before any formatter tries to read config.
*/
async function migrateConfigFile(): Promise<void> {
const target = '.smartconfig.json';
async function migrateConfigFile(allowWrite: boolean): Promise<void> {
const target = ".smartconfig.json";
const targetExists = await plugins.smartfs.file(target).exists();
if (targetExists) return;
for (const oldName of ['smartconfig.json', 'npmextra.json']) {
for (const oldName of ["smartconfig.json", "npmextra.json"]) {
const exists = await plugins.smartfs.file(oldName).exists();
if (exists) {
const content = await plugins.smartfs.file(oldName).encoding('utf8').read() as string;
await plugins.smartfs.file(`./${target}`).encoding('utf8').write(content);
if (!allowWrite) {
return;
}
const content = (await plugins.smartfs
.file(oldName)
.encoding("utf8")
.read()) as string;
await plugins.smartfs.file(`./${target}`).encoding("utf8").write(content);
await plugins.smartfs.file(oldName).delete();
logger.log('info', `Migrated ${oldName} to ${target}`);
logger.log("info", `Migrated ${oldName} to ${target}`);
return;
}
}
}
// Shared formatter class map used by both run() and runFormatter()
const formatterMap: Record<string, new (ctx: FormatContext, proj: Project) => BaseFormatter> = {
const formatterMap: Record<
string,
new (ctx: FormatContext, proj: Project) => BaseFormatter
> = {
cleanup: CleanupFormatter,
smartconfig: SmartconfigFormatter,
license: LicenseFormatter,
@@ -52,7 +68,104 @@ const formatterMap: Record<string, new (ctx: FormatContext, proj: Project) => Ba
};
// Formatters that don't require projectType to be set
const formattersNotRequiringProjectType = ['smartconfig', 'prettier', 'cleanup', 'packagejson'];
const formattersNotRequiringProjectType = [
"smartconfig",
"prettier",
"cleanup",
"packagejson",
];
const getFormatConfig = async () => {
const rawFormatConfig = await getCliConfigValue<Record<string, any>>(
"format",
{},
);
return {
interactive: true,
showDiffs: false,
autoApprove: false,
showStats: true,
modules: {
skip: [],
only: [],
...(rawFormatConfig.modules || {}),
},
...rawFormatConfig,
};
};
const createActiveFormatters = async (options: {
interactive: boolean;
jsonOutput: boolean;
}) => {
const project = await Project.fromCwd({ requireProjectType: false });
const context = new FormatContext(options);
const planner = new FormatPlanner();
const formatConfig = await getFormatConfig();
const formatters = Object.entries(formatterMap).map(
([, FormatterClass]) => new FormatterClass(context, project),
);
const activeFormatters = formatters.filter((formatter) => {
if (formatConfig.modules.only.length > 0) {
return formatConfig.modules.only.includes(formatter.name);
}
if (formatConfig.modules.skip.includes(formatter.name)) {
return false;
}
return true;
});
return {
context,
planner,
formatConfig,
activeFormatters,
};
};
const buildFormatPlan = async (options: {
fromPlan?: string;
interactive: boolean;
jsonOutput: boolean;
}) => {
const { context, planner, formatConfig, activeFormatters } =
await createActiveFormatters({
interactive: options.interactive,
jsonOutput: options.jsonOutput,
});
const plan = options.fromPlan
? JSON.parse(
(await plugins.smartfs
.file(options.fromPlan)
.encoding("utf8")
.read()) as string,
)
: await planner.planFormat(activeFormatters);
return {
context,
planner,
formatConfig,
activeFormatters,
plan,
};
};
const serializePlan = (plan: any) => {
return {
summary: plan.summary,
warnings: plan.warnings,
changes: plan.changes.map((change: any) => ({
type: change.type,
path: change.path,
module: change.module,
description: change.description,
})),
};
};
export let run = async (
options: {
@@ -66,62 +179,61 @@ export let run = async (
interactive?: boolean;
verbose?: boolean;
diff?: boolean;
[key: string]: any;
} = {},
): Promise<any> => {
const mode = await getCliMode(options as any);
const subcommand = (options as any)?._?.[1];
if (mode.help || subcommand === "help") {
showHelp(mode);
return;
}
if (options.verbose) {
setVerboseMode(true);
}
const shouldWrite = options.write ?? (options.dryRun === false);
const shouldWrite = options.write ?? options.dryRun === false;
const treatAsPlan = subcommand === "plan";
if (mode.json && shouldWrite) {
printJson({
ok: false,
error:
"JSON output is only supported for read-only format planning. Use `gitzone format plan --json` or omit `--json` when applying changes.",
});
return;
}
// Migrate config file before anything reads it
await migrateConfigFile();
await migrateConfigFile(shouldWrite);
const project = await Project.fromCwd({ requireProjectType: false });
const context = new FormatContext();
const planner = new FormatPlanner();
const smartconfigInstance = new plugins.smartconfig.Smartconfig();
const formatConfig = smartconfigInstance.dataFor<any>('@git.zone/cli.format', {
interactive: true,
showDiffs: false,
autoApprove: false,
modules: {
skip: [],
only: [],
},
});
const interactive = options.interactive ?? formatConfig.interactive;
const formatConfig = await getFormatConfig();
const interactive =
options.interactive ?? (mode.interactive && formatConfig.interactive);
const autoApprove = options.yes ?? formatConfig.autoApprove;
try {
// Initialize formatters in execution order
const formatters = Object.entries(formatterMap).map(
([, FormatterClass]) => new FormatterClass(context, project),
);
const planBuilder = async () => {
return await buildFormatPlan({
fromPlan: options.fromPlan,
interactive,
jsonOutput: mode.json,
});
};
// Filter formatters based on configuration
const activeFormatters = formatters.filter((formatter) => {
if (formatConfig.modules.only.length > 0) {
return formatConfig.modules.only.includes(formatter.name);
}
if (formatConfig.modules.skip.includes(formatter.name)) {
return false;
}
return true;
});
if (!mode.json) {
logger.log("info", "Analyzing project for format operations...");
}
const { context, planner, activeFormatters, plan } = mode.json
? await runWithSuppressedOutput(planBuilder)
: await planBuilder();
// Plan phase
logger.log('info', 'Analyzing project for format operations...');
let plan = options.fromPlan
? JSON.parse(
(await plugins.smartfs
.file(options.fromPlan)
.encoding('utf8')
.read()) as string,
)
: await planner.planFormat(activeFormatters);
if (mode.json) {
printJson(serializePlan(plan));
return;
}
// Display plan
await planner.displayPlan(plan, options.detailed);
@@ -130,34 +242,35 @@ export let run = async (
if (options.savePlan) {
await plugins.smartfs
.file(options.savePlan)
.encoding('utf8')
.encoding("utf8")
.write(JSON.stringify(plan, null, 2));
logger.log('info', `Plan saved to ${options.savePlan}`);
logger.log("info", `Plan saved to ${options.savePlan}`);
}
if (options.planOnly) {
if (options.planOnly || treatAsPlan) {
return;
}
// Show diffs if explicitly requested or before interactive write confirmation
const showDiffs = options.diff || (shouldWrite && interactive && !autoApprove);
const showDiffs =
options.diff || (shouldWrite && interactive && !autoApprove);
if (showDiffs) {
logger.log('info', 'Showing file diffs:');
console.log('');
logger.log("info", "Showing file diffs:");
console.log("");
for (const formatter of activeFormatters) {
const checkResult = await formatter.check();
if (checkResult.hasDiff) {
logger.log('info', `[${formatter.name}]`);
logger.log("info", `[${formatter.name}]`);
formatter.displayAllDiffs(checkResult);
console.log('');
console.log("");
}
}
}
// Dry-run mode (default behavior)
if (!shouldWrite) {
logger.log('info', 'Dry-run mode - use --write (-w) to apply changes');
logger.log("info", "Dry-run mode - use --write (-w) to apply changes");
return;
}
@@ -165,25 +278,25 @@ export let run = async (
if (interactive && !autoApprove) {
const interactInstance = new plugins.smartinteract.SmartInteract();
const response = await interactInstance.askQuestion({
type: 'confirm',
name: 'proceed',
message: 'Proceed with formatting?',
type: "confirm",
name: "proceed",
message: "Proceed with formatting?",
default: true,
});
if (!(response as any).value) {
logger.log('info', 'Format operation cancelled by user');
logger.log("info", "Format operation cancelled by user");
return;
}
}
// Execute phase
logger.log('info', 'Executing format operations...');
logger.log("info", "Executing format operations...");
await planner.executePlan(plan, activeFormatters, context);
context.getFormatStats().finish();
const showStats = smartconfigInstance.dataFor('gitzone.format.showStats', true);
const showStats = formatConfig.showStats ?? true;
if (showStats) {
context.getFormatStats().displayStats();
}
@@ -193,14 +306,15 @@ export let run = async (
await context.getFormatStats().saveReport(statsPath);
}
logger.log('success', 'Format operations completed successfully!');
logger.log("success", "Format operations completed successfully!");
} catch (error) {
logger.log('error', `Format operation failed: ${error.message}`);
const errorMessage = error instanceof Error ? error.message : String(error);
logger.log("error", `Format operation failed: ${errorMessage}`);
throw error;
}
};
import type { ICheckResult } from './interfaces.format.js';
import type { ICheckResult } from "./interfaces.format.js";
export type { ICheckResult };
/**
@@ -212,11 +326,12 @@ export const runFormatter = async (
silent?: boolean;
checkOnly?: boolean;
showDiff?: boolean;
} = {}
} = {},
): Promise<ICheckResult | void> => {
const requireProjectType = !formattersNotRequiringProjectType.includes(formatterName);
const requireProjectType =
!formattersNotRequiringProjectType.includes(formatterName);
const project = await Project.fromCwd({ requireProjectType });
const context = new FormatContext();
const context = new FormatContext({ interactive: true, jsonOutput: false });
const FormatterClass = formatterMap[formatterName];
if (!FormatterClass) {
@@ -240,6 +355,80 @@ export const runFormatter = async (
}
if (!options.silent) {
logger.log('success', `Formatter '${formatterName}' completed`);
logger.log("success", `Formatter '${formatterName}' completed`);
}
};
export function showHelp(mode?: ICliMode): void {
if (mode?.json) {
printJson({
command: "format",
usage: "gitzone format [plan] [options]",
description:
"Plans formatting changes by default and applies them only with --write.",
flags: [
{ flag: "--write, -w", description: "Apply planned changes" },
{
flag: "--yes",
description: "Skip the interactive confirmation before writing",
},
{
flag: "--plan-only",
description: "Show the plan without applying changes",
},
{
flag: "--save-plan <file>",
description: "Write the format plan to a file",
},
{
flag: "--from-plan <file>",
description: "Load a previously saved plan",
},
{
flag: "--detailed",
description: "Show detailed diffs and save stats",
},
{ flag: "--verbose", description: "Enable verbose logging" },
{
flag: "--diff",
description: "Show per-file diffs before applying changes",
},
{ flag: "--json", description: "Emit a read-only format plan as JSON" },
],
examples: [
"gitzone format",
"gitzone format plan --json",
"gitzone format --write --yes",
],
});
return;
}
console.log("");
console.log("Usage: gitzone format [plan] [options]");
console.log("");
console.log(
"Plans formatting changes by default and applies them only with --write.",
);
console.log("");
console.log("Flags:");
console.log(" --write, -w Apply planned changes");
console.log(
" --yes Skip the interactive confirmation before writing",
);
console.log(" --plan-only Show the plan without applying changes");
console.log(" --save-plan <file> Write the format plan to a file");
console.log(" --from-plan <file> Load a previously saved plan");
console.log(" --detailed Show detailed diffs and save stats");
console.log(" --verbose Enable verbose logging");
console.log(
" --diff Show per-file diffs before applying changes",
);
console.log(" --json Emit a read-only format plan as JSON");
console.log("");
console.log("Examples:");
console.log(" gitzone format");
console.log(" gitzone format plan --json");
console.log(" gitzone format --write --yes");
console.log("");
}