Files
oh-my-pi/packages/coding-agent/src/commands/install.ts
T
2026-08-01 00:29:57 +00:00

108 lines
3.6 KiB
TypeScript

/**
* `omp install <target>` — top-level convenience over `omp plugin install` /
* `omp plugin link`.
*
* The docs (omp.sh/docs/extension-authoring) advertise
*
* omp install ./my-extension
*
* as a third loading mechanism that "symlinks the directory into the plugin
* set and watches it for changes". Before this command existed, `install` was
* not a registered subcommand, so the CLI runner forwarded the argv to the
* default `launch` command and the model received `install ./my-extension`
* as an initial prompt — see #1496.
*
* Local-path targets (`./foo`, `/abs/foo`, `~/foo`, or an existing directory)
* route to `plugin link` so they are symlinked into the plugin set, matching
* the documented behavior. Everything else (`pkg`, `pkg@1.2.3`,
* `name@marketplace`) routes to `plugin install`.
*/
import { existsSync } from "node:fs";
import * as path from "node:path";
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
import { installHelp as commandHelp } from "../cli/command-help";
import { type PluginAction, type PluginCommandArgs, runPluginCommand } from "../cli/plugin-cli";
import { initTheme } from "../modes/theme/theme";
/**
* Heuristic used to decide whether `omp install <target>` should `link` a
* local directory or `install` a remote spec. Exported for tests.
*/
export function looksLikeLocalPath(target: string, cwd?: string): boolean {
if (target.startsWith(".") || target.startsWith("/") || target.startsWith("~")) return true;
// Windows drive prefix (e.g. `C:\foo`).
if (/^[a-zA-Z]:[\\/]/.test(target)) return true;
// Bare names that happen to exist as a local directory (relative to `cwd`).
try {
return existsSync(cwd ? path.resolve(cwd, target) : path.resolve(target));
} catch {
return false;
}
}
export default class Install extends Command {
static description = commandHelp.description;
static args = {
targets: Args.string({
description: "Local path, npm spec, or marketplace ref (e.g. ./my-ext, my-pkg@1.2.3, name@marketplace)",
required: false,
multiple: true,
}),
};
static flags = {
json: Flags.boolean({ description: "Output JSON" }),
force: Flags.boolean({ description: "Force install" }),
"dry-run": Flags.boolean({ description: "Show actions without applying changes" }),
scope: Flags.string({
description: 'Install scope: "user" (default) or "project" (marketplace installs only)',
options: ["user", "project"],
}),
};
async run(): Promise<void> {
const { args, flags } = await this.parse(Install);
const targets = Array.isArray(args.targets) ? args.targets : args.targets ? [args.targets] : [];
if (targets.length === 0) {
process.stderr.write("Usage: omp install <path | npm-spec | name@marketplace> [...]\n");
process.exit(1);
}
await initTheme();
// Split into local-paths (→ link) and remote specs (→ install). Each batch
// preserves user-supplied order so progress output reads naturally.
const localPaths: string[] = [];
const remoteSpecs: string[] = [];
for (const target of targets) {
if (looksLikeLocalPath(target)) localPaths.push(target);
else remoteSpecs.push(target);
}
const baseFlags: PluginCommandArgs["flags"] = {
json: flags.json,
force: flags.force,
dryRun: flags["dry-run"],
scope: flags.scope as "user" | "project" | undefined,
};
for (const localPath of localPaths) {
await runPluginCommand({
action: "link" satisfies PluginAction,
args: [localPath],
flags: baseFlags,
});
}
if (remoteSpecs.length > 0) {
await runPluginCommand({
action: "install" satisfies PluginAction,
args: remoteSpecs,
flags: baseFlags,
});
}
}
}