413 lines
12 KiB
JavaScript
Executable File
413 lines
12 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
|
||
import { cpSync, existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
||
import { homedir } from "node:os";
|
||
import { basename, dirname, join, resolve, sep } from "node:path";
|
||
import { spawn } from "node:child_process";
|
||
import { stdin as input, stdout as output } from "node:process";
|
||
import { fileURLToPath } from "node:url";
|
||
import { startEditorServer } from "../lib/editor-server.js";
|
||
|
||
const SKILL_NAME = "open-kimi-ppt";
|
||
const MIN_NODE_MAJOR = 18;
|
||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||
const sourceDirectory = join(packageRoot, "skills", SKILL_NAME);
|
||
const packageVersion = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version;
|
||
|
||
const KNOWN_TARGETS = [
|
||
{ id: "agents", label: "Shared / default", relative: [".agents", "skills"] },
|
||
{ id: "codex", label: "Codex", relative: [".codex", "skills"] },
|
||
{ id: "claude", label: "Claude Code", relative: [".claude", "skills"] },
|
||
{ id: "cursor", label: "Cursor", relative: [".cursor", "skills"] },
|
||
{ id: "workbuddy", label: "WorkBuddy", relative: [".workbuddy", "skills"] },
|
||
];
|
||
|
||
function assertNodeVersion() {
|
||
const major = Number.parseInt(process.versions.node.split(".")[0], 10);
|
||
if (!Number.isInteger(major) || major < MIN_NODE_MAJOR) {
|
||
throw new Error(
|
||
`Node.js ${MIN_NODE_MAJOR}+ is required; found ${process.version}. Install from https://nodejs.org`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function printHelp(command) {
|
||
if (command === "serve") {
|
||
console.log(`Start the local PPTD editor and exporter.
|
||
|
||
Usage:
|
||
open-kimi-ppt-skill serve [options]
|
||
|
||
Options:
|
||
--port <number> HTTP port (default: 55173)
|
||
--open Open the editor in the default browser
|
||
-h, --help Show this help
|
||
-V, --version Show version
|
||
`);
|
||
return;
|
||
}
|
||
|
||
console.log(`Install ${SKILL_NAME} for your AI coding agent or start its local editor.
|
||
|
||
Usage:
|
||
open-kimi-ppt-skill [install] [options]
|
||
open-kimi-ppt-skill serve [options]
|
||
|
||
Install options:
|
||
--target <directory> Skills directory (repeatable)
|
||
-y, --yes Non-interactive; install to ~/.agents/skills when no --target
|
||
--all Install to all detected agent skill directories
|
||
(agents whose home directory is missing are skipped)
|
||
-h, --help Show this help
|
||
-V, --version Show version
|
||
|
||
In an interactive terminal (no --target / --yes / --all), a checklist is shown:
|
||
↑/↓ move space select a all enter confirm
|
||
|
||
For agents / CI, prefer:
|
||
npx open-kimi-ppt-skill@latest install -y
|
||
|
||
Re-running install replaces an existing open-kimi-ppt installation.
|
||
Run "open-kimi-ppt-skill serve --help" for server options.
|
||
`);
|
||
}
|
||
|
||
function printVersion() {
|
||
console.log(packageVersion);
|
||
}
|
||
|
||
function parseArguments(arguments_) {
|
||
const args = [...arguments_];
|
||
const command = args[0] === "install" || args[0] === "serve" ? args.shift() : "install";
|
||
const options = command === "serve"
|
||
? { command, open: false, port: 55173 }
|
||
: { command, targets: [], yes: false, all: false };
|
||
|
||
while (args.length > 0) {
|
||
const argument = args.shift();
|
||
|
||
// Accepted for backward compatibility; install always overwrites.
|
||
if (command === "install" && argument === "--force") {
|
||
continue;
|
||
}
|
||
|
||
if (command === "install" && (argument === "-y" || argument === "--yes")) {
|
||
options.yes = true;
|
||
continue;
|
||
}
|
||
|
||
if (command === "install" && argument === "--all") {
|
||
options.all = true;
|
||
continue;
|
||
}
|
||
|
||
if (command === "install" && argument === "--target") {
|
||
const target = args.shift();
|
||
if (!target || target.startsWith("-")) {
|
||
throw new Error("--target requires a directory");
|
||
}
|
||
options.targets.push(resolve(target));
|
||
continue;
|
||
}
|
||
|
||
if (command === "serve" && argument === "--port") {
|
||
const port = Number(args.shift());
|
||
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
||
throw new Error("--port must be an integer between 1 and 65535");
|
||
}
|
||
options.port = port;
|
||
continue;
|
||
}
|
||
|
||
if (command === "serve" && argument === "--open") {
|
||
options.open = true;
|
||
continue;
|
||
}
|
||
|
||
if (argument === "--help" || argument === "-h") {
|
||
options.help = true;
|
||
continue;
|
||
}
|
||
|
||
if (argument === "--version" || argument === "-V") {
|
||
options.version = true;
|
||
continue;
|
||
}
|
||
|
||
throw new Error(`unknown argument: ${argument}`);
|
||
}
|
||
|
||
return options;
|
||
}
|
||
|
||
function openBrowser(url) {
|
||
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
||
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
||
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
||
child.on("error", (error) => console.warn(`Could not open the browser: ${error.message}`));
|
||
child.unref();
|
||
}
|
||
|
||
function defaultSkillsDirectory() {
|
||
return join(homedir(), ".agents", "skills");
|
||
}
|
||
|
||
function knownTargetDirectories() {
|
||
const home = homedir();
|
||
return KNOWN_TARGETS.map((entry) => ({
|
||
...entry,
|
||
directory: join(home, ...entry.relative),
|
||
}));
|
||
}
|
||
|
||
function displayPath(absolutePath) {
|
||
const home = homedir();
|
||
const normalized = absolutePath.split(sep).join("/");
|
||
const homeNormalized = home.split(sep).join("/");
|
||
if (normalized === homeNormalized || normalized.startsWith(`${homeNormalized}/`)) {
|
||
return `~${normalized.slice(homeNormalized.length)}`;
|
||
}
|
||
return absolutePath;
|
||
}
|
||
|
||
function isInteractiveInstall() {
|
||
return Boolean(input.isTTY && output.isTTY);
|
||
}
|
||
|
||
function uniqueDirectories(directories) {
|
||
const seen = new Set();
|
||
const unique = [];
|
||
for (const directory of directories) {
|
||
const key = resolve(directory);
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
unique.push(key);
|
||
}
|
||
return unique;
|
||
}
|
||
|
||
async function promptInstallTargets() {
|
||
const choices = knownTargetDirectories();
|
||
const selected = new Set([0]);
|
||
let cursor = 0;
|
||
// title + help + one line per choice (must match what render() writes)
|
||
const lines = choices.length + 2;
|
||
|
||
const writeLine = (text) => {
|
||
output.write(`\u001b[2K${text}\n`);
|
||
};
|
||
|
||
const render = (initial = false) => {
|
||
if (!initial) {
|
||
output.write(`\u001b[${lines}A`);
|
||
}
|
||
writeLine("Install open-kimi-ppt to which skills directories?");
|
||
writeLine("↑/↓ move · space select · a all · enter confirm · ctrl+c cancel");
|
||
for (const [index, choice] of choices.entries()) {
|
||
const pointer = index === cursor ? "❯" : " ";
|
||
const mark = selected.has(index) ? "◉" : "◯";
|
||
const installed = existsSync(join(choice.directory, SKILL_NAME, "SKILL.md"))
|
||
? " (installed)"
|
||
: "";
|
||
writeLine(
|
||
`${pointer}${mark} ${displayPath(choice.directory).padEnd(28)} ${choice.label}${installed}`,
|
||
);
|
||
}
|
||
};
|
||
|
||
return new Promise((resolvePromise, reject) => {
|
||
if (typeof input.setRawMode !== "function") {
|
||
resolvePromise([defaultSkillsDirectory()]);
|
||
return;
|
||
}
|
||
|
||
render(true);
|
||
input.setRawMode(true);
|
||
input.resume();
|
||
input.setEncoding("utf8");
|
||
|
||
const cleanup = () => {
|
||
input.off("data", onData);
|
||
input.setRawMode(false);
|
||
input.pause();
|
||
};
|
||
|
||
const onData = (key) => {
|
||
if (key === "\u0003") {
|
||
cleanup();
|
||
output.write("\n");
|
||
reject(new Error("install cancelled"));
|
||
return;
|
||
}
|
||
|
||
if (key === "\u001b[A" || key === "k") {
|
||
cursor = (cursor - 1 + choices.length) % choices.length;
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (key === "\u001b[B" || key === "j") {
|
||
cursor = (cursor + 1) % choices.length;
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (key === " ") {
|
||
if (selected.has(cursor)) selected.delete(cursor);
|
||
else selected.add(cursor);
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (key === "a" || key === "A") {
|
||
if (selected.size === choices.length) selected.clear();
|
||
else for (let index = 0; index < choices.length; index += 1) selected.add(index);
|
||
render();
|
||
return;
|
||
}
|
||
|
||
if (key === "\r" || key === "\n") {
|
||
if (selected.size === 0) {
|
||
output.write("\u0007");
|
||
return;
|
||
}
|
||
cleanup();
|
||
output.write("\n");
|
||
resolvePromise([...selected].sort((a, b) => a - b).map((index) => choices[index].directory));
|
||
}
|
||
};
|
||
|
||
input.on("data", onData);
|
||
});
|
||
}
|
||
|
||
function detectedTargetDirectories() {
|
||
const home = homedir();
|
||
const detected = [];
|
||
const skipped = [];
|
||
for (const entry of knownTargetDirectories()) {
|
||
if (existsSync(join(home, entry.relative[0]))) detected.push(entry);
|
||
else skipped.push(entry);
|
||
}
|
||
return { detected, skipped };
|
||
}
|
||
|
||
async function resolveInstallTargets(options) {
|
||
if (options.targets.length > 0) {
|
||
return uniqueDirectories(options.targets);
|
||
}
|
||
|
||
if (options.all) {
|
||
const { detected, skipped } = detectedTargetDirectories();
|
||
for (const entry of skipped) {
|
||
console.log(`Skipped ${entry.directory} (${entry.label} not found)`);
|
||
}
|
||
if (detected.length === 0) {
|
||
console.warn(
|
||
"No known agent directories found; nothing installed. Use -y for ~/.agents/skills or --target <directory>.",
|
||
);
|
||
}
|
||
return uniqueDirectories(detected.map((entry) => entry.directory));
|
||
}
|
||
|
||
if (options.yes || !isInteractiveInstall()) {
|
||
return [defaultSkillsDirectory()];
|
||
}
|
||
|
||
return promptInstallTargets();
|
||
}
|
||
|
||
const CANONICAL_WASM = join(
|
||
packageRoot,
|
||
"editor",
|
||
"neo-ppt",
|
||
"assets",
|
||
"pptd_wasm_bg-DPPWdROu.wasm",
|
||
);
|
||
|
||
function installSkillTo(skillsDirectory) {
|
||
if (!existsSync(join(sourceDirectory, "SKILL.md"))) {
|
||
throw new Error(`packaged skill is incomplete: ${sourceDirectory}`);
|
||
}
|
||
if (!existsSync(CANONICAL_WASM)) {
|
||
throw new Error(`packaged patched WASM is missing: ${CANONICAL_WASM}`);
|
||
}
|
||
|
||
const destination = join(skillsDirectory, SKILL_NAME);
|
||
const replaced = existsSync(destination);
|
||
|
||
mkdirSync(skillsDirectory, { recursive: true });
|
||
const stagingRoot = mkdtempSync(join(skillsDirectory, `.${SKILL_NAME}-`));
|
||
const stagedSkill = join(stagingRoot, SKILL_NAME);
|
||
|
||
try {
|
||
cpSync(sourceDirectory, stagedSkill, {
|
||
recursive: true,
|
||
filter: (source) => ![".DS_Store", "_user_meta.json"].includes(basename(source)),
|
||
});
|
||
// Single canonical WASM in editor/; copy into skill tree for agent scripts.
|
||
const stagedWasmDir = join(stagedSkill, "scripts", "local-export");
|
||
mkdirSync(stagedWasmDir, { recursive: true });
|
||
cpSync(CANONICAL_WASM, join(stagedWasmDir, "pptd_wasm_bg.wasm"));
|
||
// Offline image/PPTX browser path needs the local neo-ppt mirror.
|
||
const packagedEditor = join(packageRoot, "editor");
|
||
if (!existsSync(join(packagedEditor, "index.html"))) {
|
||
throw new Error(`packaged editor is missing: ${packagedEditor}`);
|
||
}
|
||
cpSync(packagedEditor, join(stagedSkill, "editor"), {
|
||
recursive: true,
|
||
filter: (source) => basename(source) !== ".DS_Store",
|
||
});
|
||
|
||
rmSync(destination, { recursive: true, force: true });
|
||
renameSync(stagedSkill, destination);
|
||
} finally {
|
||
rmSync(stagingRoot, { recursive: true, force: true });
|
||
}
|
||
|
||
console.log(
|
||
replaced
|
||
? `Updated ${SKILL_NAME} at ${destination}`
|
||
: `Installed ${SKILL_NAME} to ${destination}`,
|
||
);
|
||
}
|
||
|
||
async function installSkill(options) {
|
||
const targets = await resolveInstallTargets(options);
|
||
for (const target of targets) {
|
||
installSkillTo(target);
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
assertNodeVersion();
|
||
const options = parseArguments(process.argv.slice(2));
|
||
if (options.version) {
|
||
printVersion();
|
||
return;
|
||
}
|
||
if (options.help) {
|
||
printHelp(options.command);
|
||
return;
|
||
}
|
||
|
||
if (options.command === "install") {
|
||
await installSkill(options);
|
||
return;
|
||
}
|
||
|
||
const { server, url } = await startEditorServer({ port: options.port });
|
||
console.log(`Open Kimi PPT editor is running at ${url}`);
|
||
console.log("Press Ctrl+C to stop the server.");
|
||
if (options.open) openBrowser(url);
|
||
|
||
const shutdown = () => server.close(() => process.exit(0));
|
||
process.once("SIGINT", shutdown);
|
||
process.once("SIGTERM", shutdown);
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(`Error: ${error.message}`);
|
||
process.exitCode = 1;
|
||
});
|