import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { ArgDescriptor, CommandCtor, CommandEntry, FlagDescriptor } from "@gajae-code/utils/cli"; export type FigName = string | string[]; export interface FigArg { name: string; description?: string; isOptional?: boolean; isVariadic?: boolean; suggestions?: string[]; } export interface FigOption { name: FigName; description?: string; args?: FigArg; isRepeatable?: boolean; } export interface FigSubcommand { name: FigName; description?: string; options?: FigOption[]; args?: FigArg | FigArg[]; } export interface FigSpec extends FigSubcommand { name: string; subcommands?: FigSubcommand[]; } export type InshellisenseIndexStatus = "created" | "updated" | "already-compatible" | "overwritten"; export interface InstallGjcInshellisenseSpecOptions { dir?: string; force?: boolean; } export interface InstallGjcInshellisenseSpecResult { directory: string; specPath: string; indexPath: string; indexStatus: InshellisenseIndexStatus; } const GENERATED_INDEX_MARKER = "// Generated by gjc completion inshellisense"; const BUILTIN_ROOT_OPTIONS: FigOption[] = [ { name: ["-h", "--help"], description: "Show help" }, { name: ["-v", "--version"], description: "Show version" }, ]; const BUILTIN_COMMAND_OPTIONS: FigOption[] = [{ name: ["-h", "--help"], description: "Show help" }]; export function defaultInshellisenseSpecDir(homeDir: string = os.homedir()): string { return path.join(homeDir, ".fig", "autocomplete", "build"); } function nameList(name: FigName): string[] { return Array.isArray(name) ? name : [name]; } function optionKey(option: FigOption): string { return nameList(option.name).join("\u0000"); } function combineOptions(primary: FigOption[], secondary: FigOption[]): FigOption[] { const seen = new Set(); const combined: FigOption[] = []; for (const option of [...primary, ...secondary]) { const key = optionKey(option); if (seen.has(key)) continue; seen.add(key); combined.push(option); } return combined; } function commandEntryName(entry: CommandEntry): FigName { const names = [entry.name, ...(entry.aliases ?? [])]; return names.length === 1 ? names[0]! : names; } function flagName(name: string, descriptor: FlagDescriptor): FigName { const longName = `--${name}`; if (!descriptor.char) return longName; return [`-${descriptor.char}`, longName]; } function argSuggestions(options: readonly string[] | undefined): string[] | undefined { return options && options.length > 0 ? [...options] : undefined; } function optionArgName(name: string, descriptor: FlagDescriptor): string { if (descriptor.options && descriptor.options.length > 0) return name; return descriptor.kind === "integer" ? "int" : "value"; } function figOptionFromFlag(name: string, descriptor: FlagDescriptor): FigOption { const option: FigOption = { name: flagName(name, descriptor), }; if (descriptor.description) option.description = descriptor.description; if (descriptor.multiple) option.isRepeatable = true; if (descriptor.kind !== "boolean") { const arg: FigArg = { name: optionArgName(name, descriptor) }; const suggestions = argSuggestions(descriptor.options); if (suggestions) arg.suggestions = suggestions; if (descriptor.optionalValue) arg.isOptional = true; option.args = arg; } return option; } function figOptionsFromFlags(flags: Record | undefined): FigOption[] { if (!flags) return []; return Object.entries(flags).map(([name, descriptor]) => figOptionFromFlag(name, descriptor)); } function figArgFromDescriptor(name: string, descriptor: ArgDescriptor): FigArg { const arg: FigArg = { name }; if (descriptor.description) arg.description = descriptor.description; if (!descriptor.required) arg.isOptional = true; if (descriptor.multiple) arg.isVariadic = true; const suggestions = argSuggestions(descriptor.options); if (suggestions) arg.suggestions = suggestions; return arg; } function figArgsFromDescriptors(args: Record | undefined): FigArg | FigArg[] | undefined { if (!args) return undefined; const values = Object.entries(args).map(([name, descriptor]) => figArgFromDescriptor(name, descriptor)); if (values.length === 0) return undefined; return values.length === 1 ? values[0] : values; } function commandToSubcommand(entry: CommandEntry, command: CommandCtor): FigSubcommand { const subcommand: FigSubcommand = { name: commandEntryName(entry) }; if (command.description) subcommand.description = command.description; const options = combineOptions(BUILTIN_COMMAND_OPTIONS, figOptionsFromFlags(command.flags)); if (options.length > 0) subcommand.options = options; const args = figArgsFromDescriptors(command.args); if (args) subcommand.args = args; return subcommand; } export async function buildGjcFigSpec(entries: CommandEntry[], rootCommandOverride?: CommandCtor): Promise { const subcommands: FigSubcommand[] = []; let rootCommand: CommandCtor | undefined = rootCommandOverride; for (const entry of entries) { if (entry.name === "launch" && rootCommandOverride) { continue; } let command: CommandCtor; try { command = await entry.load(); } catch { if (entry.name !== "launch") { subcommands.push({ name: commandEntryName(entry) }); } continue; } if (entry.name === "launch") { rootCommand = command; continue; } if (command.hidden) continue; subcommands.push(commandToSubcommand(entry, command)); } const spec: FigSpec = { name: "gjc", description: rootCommand?.description ?? "Gajae Code CLI", }; const rootOptions = combineOptions(BUILTIN_ROOT_OPTIONS, figOptionsFromFlags(rootCommand?.flags)); if (rootOptions.length > 0) spec.options = rootOptions; const rootArgs = figArgsFromDescriptors(rootCommand?.args); if (rootArgs) spec.args = rootArgs; if (subcommands.length > 0) spec.subcommands = subcommands; return spec; } export function renderFigSpecModule(spec: FigSpec): string { return `const completionSpec = ${JSON.stringify(spec, null, "\t")};\n\nexport default completionSpec;\n`; } export function renderInshellisenseIndexModule(): string { return `${GENERATED_INDEX_MARKER}\n\nexport default ["gjc"];\nexport const diffVersionedCompletions = [];\n`; } async function fileTextIfExists(filePath: string): Promise { try { return await Bun.file(filePath).text(); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined; throw error; } } function indexDeclaresGjc(content: string): boolean { return /["']gjc["']/.test(content); } async function resolveIndexStatus(indexPath: string, force: boolean | undefined): Promise { const existing = await fileTextIfExists(indexPath); if (existing === undefined) return "created"; if (existing.includes(GENERATED_INDEX_MARKER)) { return existing === renderInshellisenseIndexModule() ? "already-compatible" : "updated"; } if (indexDeclaresGjc(existing)) return "already-compatible"; if (force) return "overwritten"; throw new Error( `inshellisense index already exists at ${indexPath} and does not list gjc; rerun with --force to overwrite it or pass --dir to install into a GJC-only specs directory`, ); } export async function installGjcInshellisenseSpec( spec: FigSpec, options: InstallGjcInshellisenseSpecOptions = {}, ): Promise { const directory = path.resolve(options.dir ?? defaultInshellisenseSpecDir()); const specPath = path.join(directory, "gjc.js"); const indexPath = path.join(directory, "index.js"); const indexStatus = await resolveIndexStatus(indexPath, options.force); await fs.mkdir(directory, { recursive: true }); await Bun.write(specPath, renderFigSpecModule(spec)); if (indexStatus !== "already-compatible") { await Bun.write(indexPath, renderInshellisenseIndexModule()); } return { directory, specPath, indexPath, indexStatus }; }