Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,64 @@ Or generate a `.d.ts` from the parsed schema:
npx cli-to-js git --dts --subcommands -o git.d.ts
```

## TypeScript plugin (automatic types in your editor)

If you don't want to pass a generic or check in a `.d.ts`, register the bundled TypeScript language-service plugin. It runs inside `tsserver`, scans your source for `convertCliToJs("<literal>")` and `fromHelpText("<literal>", ...)` calls, runs each binary's `--help` in the background, and injects real types — zero codegen, zero generic.

```jsonc
// tsconfig.json
{
"compilerOptions": {
"plugins": [{ "name": "cli-to-js/plugin" }],
},
}
```

```ts
import { convertCliToJs } from "cli-to-js";

const git = await convertCliToJs("git"); // ← plugin augments the return type
await git.commit({ message: "hi" }); // ← flags autocomplete, unknown keys error
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README claims the plugin “rejects unknown keys error”, but the public types currently allow arbitrary option keys via an index signature ([key: string]: unknown) in SubcommandFn/SpawnFn, so unknown properties won’t be a type error (you’ll still get autocomplete for known flags). Either adjust the wording here to avoid promising unknown-key rejection, or tighten the option types to remove the permissive index signature when using KnownCliOptions.

Suggested change
await git.commit({ message: "hi" }); // ← flags autocomplete, unknown keys error
await git.commit({ message: "hi" }); // ← known flags autocomplete with typed option shapes

Copilot uses AI. Check for mistakes.
```

VS Code picks this up automatically. Other editors may need to be told to use the workspace TypeScript version.

### Configuration

```jsonc
{
"plugins": [
{
"name": "cli-to-js/plugin",
"disabled": false, // kill switch
"timeout": 3000, // ms to wait for `<bin> --help`
"helpFlag": "--help", // override per workspace if needed
"allowList": ["git", "claude"], // only resolve these binaries
"denyList": ["rm"], // never resolve these
},
],
}
```

Set `CLI_TO_JS_PLUGIN_DISABLE=1` in the environment to disable it globally.

### How it compares

| | generic | `--dts` codegen | plugin |
| ------------------------- | ----------------- | --------------- | -------------- |
| Editor autocomplete | ✅ (you write it) | ✅ | ✅ (automatic) |
| `tsc` / CI type-checking | ✅ | ✅ | ❌ editor only |
| Manual step | write types | run codegen | none |
| Stays in sync with binary | manual | re-run codegen | automatic |

The plugin is the best DX during development. For CI builds, still use `--dts` or the generic — `tsc` does not run TypeScript language-service plugins.

### Limits

- Only string-literal binary names are resolved: `convertCliToJs("git")` works, `convertCliToJs(binaryFromVar)` falls back to the loose default type.
- First paint shows the loose type for a moment while `<bin> --help` runs in the background, then upgrades.
- The plugin spawns the binaries it finds in your source. Use `allowList`/`denyList` if that matters for your environment.

## From a help text string

If you already have the help text, skip the binary lookup:
Expand Down
17 changes: 17 additions & 0 deletions packages/cli-to-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
},
"files": [
"dist",
"plugin",
"README.md"
],
"type": "module",
Expand All @@ -38,6 +39,11 @@
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
},
"./plugin": {
"types": "./plugin/index.d.ts",
"require": "./plugin/index.js",
"default": "./plugin/index.js"
},
"./package.json": "./package.json"
},
"publishConfig": {
Expand All @@ -52,6 +58,17 @@
"dependencies": {
"commander": "^14.0.3"
},
"devDependencies": {
"typescript": "^6.0.2"
},
"peerDependencies": {
"typescript": ">=4.9"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
},
"engines": {
"node": ">=18"
}
Expand Down
3 changes: 3 additions & 0 deletions packages/cli-to-js/plugin/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { server } from "typescript";
declare const pluginInit: server.PluginModuleFactory;
export = pluginInit;
1 change: 1 addition & 0 deletions packages/cli-to-js/plugin/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require("../dist/plugin/index.cjs");
5 changes: 5 additions & 0 deletions packages/cli-to-js/plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "commonjs",
"main": "./index.js",
"types": "./index.d.ts"
}
10 changes: 2 additions & 8 deletions packages/cli-to-js/src/cli-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,11 @@ interface CommandPromise extends Promise<CommandResult> {
}

interface SubcommandFn<TOptions = Record<string, unknown>> {
(
options?: TOptions & { _?: string | string[]; [key: string]: unknown },
config?: RunConfig,
): CommandPromise;
(options?: TOptions & { _?: string | string[] }, config?: RunConfig): CommandPromise;
}

interface SpawnFn<TOptions = Record<string, unknown>> {
(
options?: TOptions & { _?: string | string[]; [key: string]: unknown },
config?: RunConfig,
): CommandProcess;
(options?: TOptions & { _?: string | string[] }, config?: RunConfig): CommandProcess;
}

interface CliApiBase {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-to-js/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export const COLUMN_SEPARATOR_MIN_SPACES = 2;
export const SHORT_FLAG_MAX_LENGTH = 1;
export const DEFAULT_FAILURE_EXIT_CODE = 1;
export const MAX_SUGGESTION_DISTANCE = 3;
export const PLUGIN_RESOLVE_TIMEOUT_MS = 3_000;
export const PLUGIN_REGENERATE_DEBOUNCE_MS = 150;
40 changes: 38 additions & 2 deletions packages/cli-to-js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,43 @@ export interface CliToJsOptions {
subcommands?: boolean;
}

export const convertCliToJs = async <
export interface KnownCliOptions {}

export interface ConvertCliToJs {
<N extends keyof KnownCliOptions & string>(
binaryName: N,
options?: CliToJsOptions,
): Promise<
CliApi<
KnownCliOptions[N] extends Record<string, Record<string, unknown>>
? KnownCliOptions[N]
: Record<string, Record<string, unknown>>
>
>;
<T extends Record<string, Record<string, unknown>> = Record<string, Record<string, unknown>>>(
binaryName: string,
options?: CliToJsOptions,
): Promise<CliApi<T>>;
}

export interface FromHelpText {
<N extends keyof KnownCliOptions & string>(
binaryName: N,
helpText: string,
options?: CliToJsOptions,
): CliApi<
KnownCliOptions[N] extends Record<string, Record<string, unknown>>
? KnownCliOptions[N]
: Record<string, Record<string, unknown>>
>;
<T extends Record<string, Record<string, unknown>> = Record<string, Record<string, unknown>>>(
binaryName: string,
helpText: string,
options?: CliToJsOptions,
): CliApi<T>;
}

export const convertCliToJs: ConvertCliToJs = async <
T extends Record<string, Record<string, unknown>> = Record<string, Record<string, unknown>>,
>(
binaryName: string,
Expand All @@ -21,7 +57,7 @@ export const convertCliToJs = async <
return buildApi<T>(binaryName, schema, { cwd: options.cwd, env: options.env });
};

export const fromHelpText = <
export const fromHelpText: FromHelpText = <
T extends Record<string, Record<string, unknown>> = Record<string, Record<string, unknown>>,
>(
binaryName: string,
Expand Down
70 changes: 70 additions & 0 deletions packages/cli-to-js/src/plugin/emit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { CliSchema, ParsedFlag } from "../parse-help-text.js";
import { kebabToCamel } from "../utils/kebab-to-camel.js";

export interface EmitInput {
binaryName: string;
schema: CliSchema | null;
error: string | null;
}

const escapeStringLiteral = (value: string): string => `"${value.replace(/["\\]/g, "\\$&")}"`;

const propertyKey = (name: string): string => {
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) return name;
return escapeStringLiteral(name);
};
Comment on lines +10 to +15
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

escapeStringLiteral only escapes " and \\, but does not escape newlines, carriage returns, tabs, or other characters that can make the emitted .d.ts invalid. Since this plugin consumes arbitrary --help output (including choices), prefer a more complete escaping strategy (e.g., using JSON.stringify(value) for string literal emission) to ensure valid TypeScript in all cases.

Copilot uses AI. Check for mistakes.

const flagTypeString = (flag: ParsedFlag): string => {
if (!flag.takesValue) return "boolean";
if (flag.choices && flag.choices.length > 0) {
return flag.choices.map(escapeStringLiteral).join(" | ");
}
return "string";
};

const emitOptionsInline = (flags: ParsedFlag[]): string => {
if (flags.length === 0) return "Record<string, unknown>";
const properties = flags.map((flag) => {
const propertyName = kebabToCamel(flag.longName);
const optionalMarker = flag.isRequired ? "" : "?";
return `${propertyKey(propertyName)}${optionalMarker}: ${flagTypeString(flag)}`;
});
return `{ ${properties.join("; ")} }`;
};

const emitSubcommandMap = (schema: CliSchema): string => {
if (schema.command.subcommands.length === 0) return "{}";
const entries = schema.command.subcommands.map((subcommand) => {
const optionsType = emitOptionsInline(subcommand.flags ?? []);
return `${propertyKey(subcommand.name)}: ${optionsType}`;
});
return `{ ${entries.join("; ")} }`;
};

export const emitAugmentation = (inputs: EmitInput[]): string => {
const preamble = [
"// Generated by cli-to-js/plugin — do not edit.",
"// Refreshes automatically when the editor rescans source files.",
];

const readyInputs = inputs.filter((input) => input.schema !== null);
if (readyInputs.length === 0) {
return `${preamble.join("\n")}\nexport {};\n`;
}

const mapEntries = readyInputs
.map((input) => {
const shape = emitSubcommandMap(input.schema as CliSchema);
return ` ${propertyKey(input.binaryName)}: ${shape};`;
})
.join("\n");

return `${preamble.join("\n")}
declare module "cli-to-js" {
interface KnownCliOptions {
${mapEntries}
}
}
export {};
`;
};
Loading
Loading