@burmese/cli 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/check-cli-contract.d.ts +2 -0
- package/dist/check-cli-contract.d.ts.map +1 -0
- package/dist/check-cli-contract.js +367 -0
- package/dist/check-cli-contract.js.map +1 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +920 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin-templates.d.ts +26 -0
- package/dist/plugin-templates.d.ts.map +1 -0
- package/dist/plugin-templates.js +730 -0
- package/dist/plugin-templates.js.map +1 -0
- package/dist/plugin-validate.d.ts +19 -0
- package/dist/plugin-validate.d.ts.map +1 -0
- package/dist/plugin-validate.js +190 -0
- package/dist/plugin-validate.js.map +1 -0
- package/package.json +46 -0
- package/schemas/openpets.plugin.schema.json +150 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { allowedReactions, createOpenPetsClient, OpenPetsClientError } from "@burmese/client";
|
|
10
|
+
import { claudeHookEvents, classifyCodexConfigText, doctorClaudeHooks, openPetsHookMarker, removeOpenPetsHooks, runClaudeHookFromStdin, updateCodexConfigText, validateOpenPetsPetArg, writeCodexConfig } from "@burmese/claude";
|
|
11
|
+
import { buildCursorRulesPreview, buildOpenPetsOnlyPreview, classifyCursorMcpStatus, classifyCursorRulesStatus, executeCursorMcpWrite, executeCursorRulesWrite, getCursorProjectMcpPath, getCursorProjectRulesPath, planCursorMcpInstall, planCursorMcpReplace, planCursorRulesInstall, planCursorRulesRemove, planCursorRulesReplace, readCursorMcpConfig, readCursorOpenPetsRules } from "@burmese/cursor";
|
|
12
|
+
import { prepareOpenCodeProjectSetup, writePreparedOpenCodeProjectSetup } from "@burmese/opencode";
|
|
13
|
+
import { pluginTemplateNames, pluginTemplates } from "./plugin-templates.js";
|
|
14
|
+
import { validatePluginFolder } from "./plugin-validate.js";
|
|
15
|
+
export const cliPackageName = "@burmese/cli";
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
async function main() {
|
|
18
|
+
const [command, ...args] = process.argv.slice(2);
|
|
19
|
+
if (!command || command === "--help" || command === "-h") {
|
|
20
|
+
printUsage();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (command === "configure") {
|
|
24
|
+
if (hasHelp(args)) {
|
|
25
|
+
printConfigureUsage();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await configureProject(parseConfigureArgs(args));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (command === "install") {
|
|
32
|
+
if (hasHelp(args)) {
|
|
33
|
+
printInstallUsage();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
await installPetFromCatalog(parseInstallArgs(args));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (command === "status") {
|
|
40
|
+
if (hasHelp(args)) {
|
|
41
|
+
printStatusUsage();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
await showStatus(args);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (command === "pets") {
|
|
48
|
+
if (hasHelp(args)) {
|
|
49
|
+
printPetsUsage();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
await showPets(args);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (command === "doctor") {
|
|
56
|
+
if (hasHelp(args)) {
|
|
57
|
+
printDoctorUsage();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
await runDoctor(parseDoctorArgs(args));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (command === "react") {
|
|
64
|
+
if (hasHelp(args)) {
|
|
65
|
+
printReactUsage();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
await sendReaction(parseReactArgs(args));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (command === "say") {
|
|
72
|
+
if (hasHelp(args)) {
|
|
73
|
+
printSayUsage();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await sendMessage(parseSayArgs(args));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (command === "mcp") {
|
|
80
|
+
if (hasHelp(args)) {
|
|
81
|
+
printMcpUsage();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
await runMcp(args);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (command === "hook") {
|
|
88
|
+
if (hasHelp(args)) {
|
|
89
|
+
printHookUsage();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const hookEvent = readHookEventArg(args);
|
|
93
|
+
const code = await runClaudeHookFromStdin(process.stdin, { configuredPetId: readPetArg(args), clientLabel: readHookClientLabelArg(args, hookEvent), projectLocal: hasProjectLocalArg(args), eventName: hookEvent, debug: process.env.OPENPETS_DEBUG === "1" });
|
|
94
|
+
process.exitCode = code;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (command === "plugin") {
|
|
98
|
+
const [subcommand, ...rest] = args;
|
|
99
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
100
|
+
printPluginUsage();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (subcommand === "new" || subcommand === "init") {
|
|
104
|
+
if (hasHelp(rest)) {
|
|
105
|
+
printPluginUsage();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
scaffoldPlugin(parsePluginNewArgs(rest));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (subcommand === "validate") {
|
|
112
|
+
if (hasHelp(rest)) {
|
|
113
|
+
printPluginUsage();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const target = rest.find((arg) => !arg.startsWith("--")) ?? ".";
|
|
117
|
+
const result = validatePluginFolder(target);
|
|
118
|
+
if (result.ok) {
|
|
119
|
+
process.stdout.write(`Plugin manifest and declared files look valid: ${resolve(target)}\n`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
process.stderr.write(`Plugin validation failed (${result.issues.length} issue${result.issues.length === 1 ? "" : "s"}):\n`);
|
|
123
|
+
for (const issue of result.issues)
|
|
124
|
+
process.stderr.write(` ${issue.path}: ${issue.message}\n`);
|
|
125
|
+
process.exitCode = 1;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
throw new CliError(`Unknown plugin subcommand: ${subcommand}`);
|
|
129
|
+
}
|
|
130
|
+
throw new CliError(`Unknown command: ${command}`);
|
|
131
|
+
}
|
|
132
|
+
async function installPetFromCatalog(options) {
|
|
133
|
+
const client = createOpenPetsClient({ responseTimeoutMs: 60_000 });
|
|
134
|
+
const result = await client.installPet(options.petId);
|
|
135
|
+
process.stdout.write(`Installed OpenPets pet: ${sanitizeTerminalText(result.displayName)} (${result.petId})\n`);
|
|
136
|
+
}
|
|
137
|
+
async function showStatus(args) {
|
|
138
|
+
if (args.length !== 0)
|
|
139
|
+
throw new CliError(`Unknown status option: ${args[0]}`);
|
|
140
|
+
const result = await createOpenPetsClient().status();
|
|
141
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
142
|
+
if (!result.ok || !result.appRunning)
|
|
143
|
+
process.exitCode = 1;
|
|
144
|
+
}
|
|
145
|
+
export function parseDoctorArgs(args) {
|
|
146
|
+
let cwd = process.cwd();
|
|
147
|
+
let json = false;
|
|
148
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
149
|
+
const arg = args[index];
|
|
150
|
+
if (arg === "--json")
|
|
151
|
+
json = true;
|
|
152
|
+
else if (arg === "--cwd") {
|
|
153
|
+
cwd = readRequiredArg(args, index, "--cwd");
|
|
154
|
+
index += 1;
|
|
155
|
+
}
|
|
156
|
+
else if (arg.startsWith("--cwd="))
|
|
157
|
+
cwd = arg.slice("--cwd=".length);
|
|
158
|
+
else
|
|
159
|
+
throw new CliError(`Unknown doctor option: ${arg}`);
|
|
160
|
+
}
|
|
161
|
+
return { cwd, json };
|
|
162
|
+
}
|
|
163
|
+
export async function runDoctor(options) {
|
|
164
|
+
const claude = doctorClaudeHooks();
|
|
165
|
+
const projectDir = resolveProjectDir(options.cwd);
|
|
166
|
+
const cursorConfigPath = getCursorProjectMcpPath(projectDir);
|
|
167
|
+
const cursorRead = readCursorMcpConfig(cursorConfigPath);
|
|
168
|
+
const cursor = classifyCursorMcpStatus(cursorRead, cursorConfigPath, { mcpVersion: getPackageVersion() });
|
|
169
|
+
const codexConfigPath = getProjectCodexConfigPath(projectDir);
|
|
170
|
+
const codex = classifyCodexConfigText(existsSync(codexConfigPath) ? readFileSync(codexConfigPath, "utf8") : "", codexConfigPath);
|
|
171
|
+
const appStatus = await createOpenPetsClient().status();
|
|
172
|
+
const app = { running: appStatus.appRunning, reason: appStatus.unavailableReason };
|
|
173
|
+
if (options.json) {
|
|
174
|
+
process.stdout.write(`${JSON.stringify({
|
|
175
|
+
claude: { status: claude.status, settingsPath: claude.settingsPath, asyncSupported: claude.asyncSupported },
|
|
176
|
+
codex: { status: codex.status, configPath: codex.configPath, hookTrustStatus: codex.hookTrustStatus, hookTrustMessage: codex.hookTrustMessage },
|
|
177
|
+
cursor: { status: cursor.status, configPath: cursor.configPath },
|
|
178
|
+
app,
|
|
179
|
+
}, null, 2)}\n`);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
process.stdout.write(`- Claude hooks: ${claude.status} (${claude.settingsPath})\n`);
|
|
183
|
+
process.stdout.write(`- Codex setup: ${codex.status} (${codex.configPath})\n`);
|
|
184
|
+
if (codex.hookTrustStatus !== "unknown")
|
|
185
|
+
process.stdout.write(`- Codex hook permission: ${codex.hookTrustStatus} (${codex.hookTrustMessage})\n`);
|
|
186
|
+
process.stdout.write(`- Cursor MCP: ${cursor.status} (${cursor.configPath})\n`);
|
|
187
|
+
process.stdout.write(`- OpenPets app: ${app.running ? "running" : `not running${app.reason ? ` (${app.reason})` : ""}`}\n`);
|
|
188
|
+
}
|
|
189
|
+
if (claude.status === "error" || codex.status === "error" || cursor.status === "error" || cursor.status === "invalid")
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
}
|
|
192
|
+
async function showPets(args) {
|
|
193
|
+
if (args.length !== 0)
|
|
194
|
+
throw new CliError(`Unknown pets option: ${args[0]}`);
|
|
195
|
+
const result = await createOpenPetsClient().listPets();
|
|
196
|
+
for (const pet of result.pets) {
|
|
197
|
+
const flags = [pet.id === result.defaultPetId ? "default" : undefined, pet.broken ? "broken" : undefined].filter(Boolean).join(", ");
|
|
198
|
+
process.stdout.write(`${sanitizeTerminalText(pet.displayName)} (${pet.id})${flags ? ` [${flags}]` : ""}\n`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function sendReaction(options) {
|
|
202
|
+
await createOpenPetsClient({ source: "cli" }).react(options.reaction);
|
|
203
|
+
process.stdout.write(`OpenPets reaction sent: ${options.reaction}\n`);
|
|
204
|
+
}
|
|
205
|
+
async function sendMessage(options) {
|
|
206
|
+
await createOpenPetsClient({ source: "cli" }).say(options.message, options.reaction ? { reaction: options.reaction } : undefined);
|
|
207
|
+
process.stdout.write("OpenPets message sent.\n");
|
|
208
|
+
}
|
|
209
|
+
export async function configureProject(options) {
|
|
210
|
+
const projectDir = resolveProjectDir(options.cwd);
|
|
211
|
+
if (options.agent === "cursor") {
|
|
212
|
+
await configureCursorProject(options, projectDir);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (options.agent === "opencode") {
|
|
216
|
+
await configureOpenCodeProject(options, projectDir);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (options.agent === "codex") {
|
|
220
|
+
await configureCodexProject(options, projectDir);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
assertClaudeAvailable();
|
|
224
|
+
assertSafeProjectHookPath(projectDir);
|
|
225
|
+
const client = createOpenPetsClient();
|
|
226
|
+
const selectedPet = await resolveConfiguredPet(client, options.petId);
|
|
227
|
+
const petId = selectedPet.id;
|
|
228
|
+
const packageVersion = getPackageVersion();
|
|
229
|
+
const mcpCommand = options.localDev ? createLocalDevCliCommand(["mcp", "--pet", petId]) : createVersionPinnedCliCommand(packageVersion, ["mcp", "--pet", petId]);
|
|
230
|
+
const hookCommand = formatShellCommand(options.localDev ? createLocalDevCliCommand(["hook", openPetsHookMarker, "--project-local", "--pet", petId]) : createVersionPinnedCliCommand(packageVersion, ["hook", openPetsHookMarker, "--project-local", "--pet", petId]));
|
|
231
|
+
const mcpConfig = { type: "stdio", command: mcpCommand.command, args: mcpCommand.args, env: {} };
|
|
232
|
+
const preparedHooks = prepareProjectLocalHooks(projectDir, hookCommand);
|
|
233
|
+
runClaudeMcpAddJson(projectDir, mcpConfig, options.force);
|
|
234
|
+
writePreparedHooks(preparedHooks);
|
|
235
|
+
process.stdout.write(`OpenPets configured for Claude in ${projectDir}.\nPet: ${sanitizeTerminalText(selectedPet.displayName)} (${selectedPet.id})\n`);
|
|
236
|
+
}
|
|
237
|
+
async function configureCodexProject(options, projectDir) {
|
|
238
|
+
assertSafeProjectCodexConfigPath(projectDir);
|
|
239
|
+
const client = createOpenPetsClient();
|
|
240
|
+
const selectedPet = await resolveConfiguredPet(client, options.petId);
|
|
241
|
+
const petId = selectedPet.id;
|
|
242
|
+
const packageVersion = getPackageVersion();
|
|
243
|
+
const mcpCommand = options.localDev ? createLocalDevCliCommand(["mcp", "--pet", petId]) : createVersionPinnedCliCommand(packageVersion, ["mcp", "--pet", petId]);
|
|
244
|
+
const hookCommand = options.localDev ? createLocalDevCliCommand(["hook", openPetsHookMarker, "--project-local", "--pet", petId]) : createVersionPinnedCliCommand(packageVersion, ["hook", openPetsHookMarker, "--project-local", "--pet", petId]);
|
|
245
|
+
const configPath = getProjectCodexConfigPath(projectDir);
|
|
246
|
+
const current = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
247
|
+
const next = updateCodexConfigText(current, { mcpCommand, hookCommand }, options.force);
|
|
248
|
+
writeCodexConfig(configPath, next);
|
|
249
|
+
process.stdout.write(`OpenPets configured for Codex in ${projectDir}.\nPet: ${sanitizeTerminalText(selectedPet.displayName)} (${selectedPet.id})\nConfig: ${configPath}\nRestart Codex in this project, run /hooks, and trust the OpenPets hooks before reactions can run.\n`);
|
|
250
|
+
}
|
|
251
|
+
async function configureCursorProject(options, projectDir) {
|
|
252
|
+
const configPath = getCursorProjectMcpPath(projectDir);
|
|
253
|
+
const rulesPath = getCursorProjectRulesPath(projectDir);
|
|
254
|
+
if (options.cursorRulesMode === "only") {
|
|
255
|
+
configureCursorRulesOnly(projectDir, rulesPath, options.force);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (options.cursorRulesMode === "remove") {
|
|
259
|
+
removeCursorRulesOnly(projectDir, rulesPath);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const client = createOpenPetsClient();
|
|
263
|
+
const selectedPet = await resolveConfiguredPet(client, options.petId);
|
|
264
|
+
const packageVersion = getPackageVersion();
|
|
265
|
+
const previewOptions = { mcpVersion: packageVersion, petId: selectedPet.id, commandMode: options.localDev ? "local" : "published", mcpEntryPath: options.localDev ? require.resolve("@burmese/mcp") : undefined };
|
|
266
|
+
const readResult = readCursorMcpConfig(configPath);
|
|
267
|
+
const status = classifyCursorMcpStatus(readResult, configPath, previewOptions);
|
|
268
|
+
process.stdout.write(`Cursor config: ${configPath}\nStatus: ${status.status} - ${status.message}\nOpenPets MCP preview:\n${JSON.stringify(buildOpenPetsOnlyPreview(previewOptions), null, 2)}\n`);
|
|
269
|
+
const rulesRequested = options.cursorRulesMode === "with";
|
|
270
|
+
const rulesReadResult = rulesRequested ? readCursorOpenPetsRules(projectDir) : undefined;
|
|
271
|
+
const rulesStatus = rulesReadResult ? classifyCursorRulesStatus(rulesReadResult, rulesPath) : undefined;
|
|
272
|
+
if (rulesStatus) {
|
|
273
|
+
process.stdout.write(`Cursor rules: ${rulesPath}\nRules status: ${rulesStatus.status} - ${rulesStatus.message}\nOpenPets rules preview:\n${buildCursorRulesPreview()}\n`);
|
|
274
|
+
}
|
|
275
|
+
if (status.status === "installed" && (!rulesRequested || rulesStatus?.status === "installed")) {
|
|
276
|
+
process.stdout.write(`OpenPets is already configured for Cursor in ${projectDir}.\nRestart or reload Cursor or start a new chat in this project to load OpenPets.\n`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (status.status === "invalid" || status.status === "error") {
|
|
280
|
+
throw new CliError(`${status.message} Fix ${configPath}, then rerun setup.`);
|
|
281
|
+
}
|
|
282
|
+
if (status.status === "conflict" && !options.force) {
|
|
283
|
+
throw new CliError(`Cursor already has a non-OpenPets openpets MCP entry. Rerun with --force to replace only mcpServers.openpets.`);
|
|
284
|
+
}
|
|
285
|
+
if (rulesStatus && (rulesStatus.status === "invalid" || rulesStatus.status === "error")) {
|
|
286
|
+
throw new CliError(`${rulesStatus.message} Fix ${rulesPath}, then rerun setup.`);
|
|
287
|
+
}
|
|
288
|
+
if (rulesStatus?.status === "conflict" && !options.force) {
|
|
289
|
+
throw new CliError("Cursor already has .cursor/rules/openpets.mdc with user content. Rerun with --force to replace only that file.");
|
|
290
|
+
}
|
|
291
|
+
const plan = status.status === "installed" ? undefined : status.status === "conflict" ? planCursorMcpReplace(configPath, previewOptions) : planCursorMcpInstall(configPath, previewOptions, options.force);
|
|
292
|
+
if (plan && "ok" in plan)
|
|
293
|
+
throw new CliError(plan.message);
|
|
294
|
+
const rulesPlan = rulesRequested && rulesStatus?.status !== "installed" ? rulesStatus?.status === "conflict" ? planCursorRulesReplace(projectDir) : planCursorRulesInstall(projectDir, options.force) : undefined;
|
|
295
|
+
if (rulesPlan && "ok" in rulesPlan)
|
|
296
|
+
throw new CliError(rulesPlan.message);
|
|
297
|
+
if (plan)
|
|
298
|
+
executeCursorMcpWrite(plan);
|
|
299
|
+
if (rulesPlan)
|
|
300
|
+
executeCursorRulesWrite(rulesPlan);
|
|
301
|
+
const backups = [plan?.backupPath ? `MCP backup: ${plan.backupPath}` : undefined, rulesPlan?.backupPath ? `Rules backup: ${rulesPlan.backupPath}` : undefined].filter(Boolean).join("\n");
|
|
302
|
+
process.stdout.write(`OpenPets configured for Cursor in ${projectDir}.\nPet: ${sanitizeTerminalText(selectedPet.displayName)} (${selectedPet.id})\n${backups ? `${backups}\n` : ""}Restart or reload Cursor or start a new chat in this project to load OpenPets.\nTo remove MCP, delete mcpServers.openpets from ${configPath}. To remove rules, run with --remove-rules.\n`);
|
|
303
|
+
}
|
|
304
|
+
function configureCursorRulesOnly(projectDir, rulesPath, force) {
|
|
305
|
+
const readResult = readCursorOpenPetsRules(projectDir);
|
|
306
|
+
const status = classifyCursorRulesStatus(readResult, rulesPath);
|
|
307
|
+
process.stdout.write(`Cursor rules: ${rulesPath}\nRules status: ${status.status} - ${status.message}\nOpenPets rules preview:\n${buildCursorRulesPreview()}\n`);
|
|
308
|
+
if (status.status === "installed") {
|
|
309
|
+
process.stdout.write("OpenPets Cursor rules are already installed. Cursor may use changed rules in a new or refreshed chat.\n");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (status.status === "invalid" || status.status === "error")
|
|
313
|
+
throw new CliError(`${status.message} Fix ${rulesPath}, then rerun setup.`);
|
|
314
|
+
if (status.status === "conflict" && !force)
|
|
315
|
+
throw new CliError("Cursor already has .cursor/rules/openpets.mdc with user content. Rerun with --force to replace only that file.");
|
|
316
|
+
const plan = status.status === "conflict" ? planCursorRulesReplace(projectDir) : planCursorRulesInstall(projectDir, force);
|
|
317
|
+
if ("ok" in plan)
|
|
318
|
+
throw new CliError(plan.message);
|
|
319
|
+
executeCursorRulesWrite(plan);
|
|
320
|
+
process.stdout.write(`Installed OpenPets Cursor rules in ${projectDir}.\nRules file: ${rulesPath}\n${plan.backupPath ? `Backup: ${plan.backupPath}\n` : ""}Cursor may use changed rules in a new or refreshed chat.\n`);
|
|
321
|
+
}
|
|
322
|
+
function removeCursorRulesOnly(projectDir, rulesPath) {
|
|
323
|
+
const readResult = readCursorOpenPetsRules(projectDir);
|
|
324
|
+
const status = classifyCursorRulesStatus(readResult, rulesPath);
|
|
325
|
+
process.stdout.write(`Cursor rules: ${rulesPath}\nRules status: ${status.status} - ${status.message}\n`);
|
|
326
|
+
if (status.status === "missing") {
|
|
327
|
+
process.stdout.write("OpenPets Cursor rules are already absent.\n");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (status.status === "invalid" || status.status === "error")
|
|
331
|
+
throw new CliError(`${status.message} Fix ${rulesPath}, then rerun setup.`);
|
|
332
|
+
if (status.status === "conflict")
|
|
333
|
+
throw new CliError("Cannot remove .cursor/rules/openpets.mdc because it is not managed by OpenPets.");
|
|
334
|
+
const plan = planCursorRulesRemove(projectDir);
|
|
335
|
+
if ("ok" in plan)
|
|
336
|
+
throw new CliError(plan.message);
|
|
337
|
+
executeCursorRulesWrite(plan);
|
|
338
|
+
process.stdout.write(`Removed OpenPets Cursor rules from ${projectDir}.\n${plan.backupPath ? `Backup: ${plan.backupPath}\n` : ""}Cursor may use changed rules in a new or refreshed chat.\n`);
|
|
339
|
+
}
|
|
340
|
+
async function configureOpenCodeProject(options, projectDir) {
|
|
341
|
+
const client = createOpenPetsClient();
|
|
342
|
+
const selectedPet = await resolveConfiguredPet(client, options.petId);
|
|
343
|
+
const packageVersion = getPackageVersion();
|
|
344
|
+
const prepared = prepareOpenCodeProjectSetup({ projectDir, petId: selectedPet.id, cliVersion: packageVersion, commandMode: options.localDev ? "local" : "published", cliEntryPath: options.localDev ? fileURLToPath(import.meta.url) : undefined });
|
|
345
|
+
writePreparedOpenCodeProjectSetup(prepared);
|
|
346
|
+
process.stdout.write(`OpenPets configured for OpenCode in ${projectDir}.\nPet: ${sanitizeTerminalText(selectedPet.displayName)} (${selectedPet.id})\nConfig: ${prepared.configPath}\nInstructions: ${prepared.instructionPath}\nWarning: .opencode config/instructions can be committed and include the selected pet id.\nRestart OpenCode in this project to load OpenPets.\n`);
|
|
347
|
+
}
|
|
348
|
+
export async function resolveConfiguredPet(client, petId) {
|
|
349
|
+
if (petId) {
|
|
350
|
+
const id = validateOpenPetsPetArg(petId);
|
|
351
|
+
return { id, displayName: id };
|
|
352
|
+
}
|
|
353
|
+
const petList = await getInstalledPets(client);
|
|
354
|
+
const id = validateOpenPetsPetArg(await pickPet(petList.pets));
|
|
355
|
+
const selectedPet = petList.pets.find((pet) => pet.id === id);
|
|
356
|
+
if (!selectedPet || selectedPet.broken)
|
|
357
|
+
throw new CliError(`Pet is not installed or usable: ${id}`);
|
|
358
|
+
return { id: selectedPet.id, displayName: selectedPet.displayName };
|
|
359
|
+
}
|
|
360
|
+
export function parseConfigureArgs(args) {
|
|
361
|
+
let agent = "claude";
|
|
362
|
+
let petId;
|
|
363
|
+
let cwd = process.cwd();
|
|
364
|
+
let yes = false;
|
|
365
|
+
let force = false;
|
|
366
|
+
let localDev = false;
|
|
367
|
+
let cursorRulesMode;
|
|
368
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
369
|
+
const arg = args[index];
|
|
370
|
+
if (arg === "--yes" || arg === "-y")
|
|
371
|
+
yes = true;
|
|
372
|
+
else if (arg === "--force" || arg === "--replace")
|
|
373
|
+
force = true;
|
|
374
|
+
else if (arg === "--local-dev")
|
|
375
|
+
localDev = true;
|
|
376
|
+
else if (arg === "--with-rules")
|
|
377
|
+
cursorRulesMode = setCursorRulesMode(cursorRulesMode, "with");
|
|
378
|
+
else if (arg === "--rules-only")
|
|
379
|
+
cursorRulesMode = setCursorRulesMode(cursorRulesMode, "only");
|
|
380
|
+
else if (arg === "--remove-rules")
|
|
381
|
+
cursorRulesMode = setCursorRulesMode(cursorRulesMode, "remove");
|
|
382
|
+
else if (arg === "--agent") {
|
|
383
|
+
agent = readRequiredArg(args, index, "--agent");
|
|
384
|
+
index += 1;
|
|
385
|
+
}
|
|
386
|
+
else if (arg.startsWith("--agent="))
|
|
387
|
+
agent = arg.slice("--agent=".length);
|
|
388
|
+
else if (arg === "--pet") {
|
|
389
|
+
petId = validateOpenPetsPetArg(readRequiredArg(args, index, "--pet"));
|
|
390
|
+
index += 1;
|
|
391
|
+
}
|
|
392
|
+
else if (arg.startsWith("--pet="))
|
|
393
|
+
petId = validateOpenPetsPetArg(arg.slice("--pet=".length));
|
|
394
|
+
else if (arg === "--cwd") {
|
|
395
|
+
cwd = readRequiredArg(args, index, "--cwd");
|
|
396
|
+
index += 1;
|
|
397
|
+
}
|
|
398
|
+
else if (arg.startsWith("--cwd="))
|
|
399
|
+
cwd = arg.slice("--cwd=".length);
|
|
400
|
+
else
|
|
401
|
+
throw new CliError(`Unknown configure option: ${arg}`);
|
|
402
|
+
}
|
|
403
|
+
if (agent !== "claude" && agent !== "opencode" && agent !== "cursor" && agent !== "codex")
|
|
404
|
+
throw new CliError(`Unsupported agent: ${agent}. Supported agents: claude, codex, opencode, cursor.`);
|
|
405
|
+
if (cursorRulesMode && agent !== "cursor")
|
|
406
|
+
throw new CliError("Cursor rules flags require --agent cursor.");
|
|
407
|
+
return { agent, petId, cwd, yes, force, localDev, cursorRulesMode };
|
|
408
|
+
}
|
|
409
|
+
function setCursorRulesMode(current, next) {
|
|
410
|
+
if (current && current !== next)
|
|
411
|
+
throw new CliError("Use only one of --with-rules, --rules-only, or --remove-rules.");
|
|
412
|
+
return next;
|
|
413
|
+
}
|
|
414
|
+
export function parseInstallArgs(args) {
|
|
415
|
+
if (args.length !== 1)
|
|
416
|
+
throw new CliError("Usage: openpets install <pet-id>");
|
|
417
|
+
return { petId: validateOpenPetsPetArg(args[0] ?? "") };
|
|
418
|
+
}
|
|
419
|
+
export function parsePluginNewArgs(args) {
|
|
420
|
+
let name;
|
|
421
|
+
let id;
|
|
422
|
+
let dir;
|
|
423
|
+
let author;
|
|
424
|
+
let template;
|
|
425
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
426
|
+
const arg = args[index];
|
|
427
|
+
if (arg === "--id") {
|
|
428
|
+
id = readRequiredArg(args, index, "--id");
|
|
429
|
+
index += 1;
|
|
430
|
+
}
|
|
431
|
+
else if (arg.startsWith("--id="))
|
|
432
|
+
id = arg.slice("--id=".length);
|
|
433
|
+
else if (arg === "--dir") {
|
|
434
|
+
dir = readRequiredArg(args, index, "--dir");
|
|
435
|
+
index += 1;
|
|
436
|
+
}
|
|
437
|
+
else if (arg.startsWith("--dir="))
|
|
438
|
+
dir = arg.slice("--dir=".length);
|
|
439
|
+
else if (arg === "--author") {
|
|
440
|
+
author = readRequiredArg(args, index, "--author");
|
|
441
|
+
index += 1;
|
|
442
|
+
}
|
|
443
|
+
else if (arg.startsWith("--author="))
|
|
444
|
+
author = arg.slice("--author=".length);
|
|
445
|
+
else if (arg === "--template") {
|
|
446
|
+
template = readRequiredArg(args, index, "--template");
|
|
447
|
+
index += 1;
|
|
448
|
+
}
|
|
449
|
+
else if (arg.startsWith("--template="))
|
|
450
|
+
template = arg.slice("--template=".length);
|
|
451
|
+
else if (arg.startsWith("--"))
|
|
452
|
+
throw new CliError(`Unknown plugin new option: ${arg}`);
|
|
453
|
+
else if (name === undefined)
|
|
454
|
+
name = arg;
|
|
455
|
+
else
|
|
456
|
+
throw new CliError(`Unexpected argument: ${arg}`);
|
|
457
|
+
}
|
|
458
|
+
const cleanName = (name ?? "").trim();
|
|
459
|
+
if (!cleanName)
|
|
460
|
+
throw new CliError("Usage: openpets plugin new <name> [--template <template>] [--id <id>] [--dir <path>] [--author <name>]");
|
|
461
|
+
if (cleanName.length > 60 || /[\x00-\x1F\x7F]/.test(cleanName))
|
|
462
|
+
throw new CliError("Plugin name must be 1-60 printable characters.");
|
|
463
|
+
const slug = slugifyPluginName(cleanName);
|
|
464
|
+
if (!slug)
|
|
465
|
+
throw new CliError("Plugin name must contain at least one letter or number.");
|
|
466
|
+
const finalId = (id ?? `local.${slug}`).trim();
|
|
467
|
+
if (!isValidPluginId(finalId))
|
|
468
|
+
throw new CliError("Plugin id must be 1-64 chars (letters, numbers, dot, dash, underscore) and cannot start with a dot.");
|
|
469
|
+
const finalTemplate = (template ?? "blank").trim();
|
|
470
|
+
if (!pluginTemplateNames.includes(finalTemplate))
|
|
471
|
+
throw new CliError(`Unknown plugin template: ${finalTemplate}. Templates: ${pluginTemplateNames.join(", ")}.`);
|
|
472
|
+
return { name: cleanName, id: finalId, dir: dir ?? slug, author: author?.trim() || undefined, template: finalTemplate };
|
|
473
|
+
}
|
|
474
|
+
function slugifyPluginName(name) {
|
|
475
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
476
|
+
}
|
|
477
|
+
function isValidPluginId(id) {
|
|
478
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id);
|
|
479
|
+
}
|
|
480
|
+
export function scaffoldPlugin(options) {
|
|
481
|
+
const targetDir = resolve(options.dir);
|
|
482
|
+
const manifestPath = join(targetDir, "openpets.plugin.json");
|
|
483
|
+
const entryPath = join(targetDir, "index.js");
|
|
484
|
+
if (existsSync(manifestPath) || existsSync(entryPath))
|
|
485
|
+
throw new CliError(`A plugin already exists at ${targetDir}. Choose another --dir.`);
|
|
486
|
+
mkdirSync(targetDir, { recursive: true });
|
|
487
|
+
const dirStats = lstatSync(targetDir);
|
|
488
|
+
if (dirStats.isSymbolicLink() || !dirStats.isDirectory())
|
|
489
|
+
throw new CliError("Target plugin path must be a directory.");
|
|
490
|
+
const template = pluginTemplates[options.template];
|
|
491
|
+
const manifest = {
|
|
492
|
+
$schema: "https://openpets.dev/schemas/openpets.plugin.schema.json",
|
|
493
|
+
manifestVersion: 3,
|
|
494
|
+
id: options.id,
|
|
495
|
+
name: options.name,
|
|
496
|
+
version: "1.0.0",
|
|
497
|
+
description: `${options.name} — ${template.description}`,
|
|
498
|
+
runtime: "javascript",
|
|
499
|
+
entry: "index.js",
|
|
500
|
+
sdkVersion: "3.0.0",
|
|
501
|
+
permissions: template.permissions,
|
|
502
|
+
configSchema: template.configSchema,
|
|
503
|
+
};
|
|
504
|
+
const templateContext = { id: options.id, name: options.name };
|
|
505
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
506
|
+
writeFileSync(entryPath, template.entry(templateContext), { encoding: "utf8", flag: "wx" });
|
|
507
|
+
writeFileSync(join(targetDir, "test.js"), template.test(templateContext), { encoding: "utf8", flag: "wx" });
|
|
508
|
+
// Templates that localize host-rendered strings ($t:) or runtime bodies
|
|
509
|
+
// (ctx.t) ship a source locales/en.json; the host loads locales/<locale>.json
|
|
510
|
+
// and falls back to en. Write it whenever the template declares one.
|
|
511
|
+
if (template.locales) {
|
|
512
|
+
const localesDir = join(targetDir, "locales");
|
|
513
|
+
mkdirSync(localesDir, { recursive: true });
|
|
514
|
+
writeFileSync(join(localesDir, "en.json"), `${JSON.stringify(template.locales(templateContext), null, 2)}\n`, { encoding: "utf8", flag: "wx" });
|
|
515
|
+
}
|
|
516
|
+
const packageJsonPath = join(targetDir, "package.json");
|
|
517
|
+
if (!existsSync(packageJsonPath)) {
|
|
518
|
+
writeFileSync(packageJsonPath, `${JSON.stringify({ name: slugifyPluginName(options.name) || "openpets-plugin", private: true, type: "module", scripts: { test: "node test.js" }, devDependencies: { "@burmese/plugin-sdk": "^3.0.0" } }, null, 2)}\n`, { encoding: "utf8" });
|
|
519
|
+
}
|
|
520
|
+
const readmePath = join(targetDir, "README.md");
|
|
521
|
+
if (!existsSync(readmePath))
|
|
522
|
+
writeFileSync(readmePath, pluginReadmeTemplate(options, targetDir), { encoding: "utf8" });
|
|
523
|
+
process.stdout.write(`Created OpenPets plugin "${sanitizeTerminalText(options.name)}" (${options.id}) from the ${options.template} template\n ${targetDir}\n\n` +
|
|
524
|
+
"Next steps:\n" +
|
|
525
|
+
" 1. npm install # pulls @burmese/plugin-sdk for types + the test kit\n" +
|
|
526
|
+
" 2. npm test # deterministic harness, no app needed\n" +
|
|
527
|
+
" 3. From the OpenPets repo root, run it live with hot reload:\n" +
|
|
528
|
+
` OPENPETS_DEV_PLUGIN_PATHS=${targetDir} pnpm dev:desktop\n` +
|
|
529
|
+
" 4. Open Tray → Plugins, enable it, then right-click your pet.\n\n" +
|
|
530
|
+
`Validate anytime: openpets plugin validate ${targetDir}\n` +
|
|
531
|
+
"Docs: https://openpets.dev/sdk\n");
|
|
532
|
+
return { dir: targetDir, manifestPath, entryPath };
|
|
533
|
+
}
|
|
534
|
+
function pluginReadmeTemplate(options, targetDir) {
|
|
535
|
+
return `# ${options.name}
|
|
536
|
+
|
|
537
|
+
An OpenPets plugin (\`${options.id}\`).
|
|
538
|
+
|
|
539
|
+
## Develop
|
|
540
|
+
|
|
541
|
+
\`\`\`bash
|
|
542
|
+
# optional: editor autocomplete + type-checking
|
|
543
|
+
npm i -D @burmese/plugin-sdk
|
|
544
|
+
|
|
545
|
+
# from the OpenPets repo root, load this folder and launch the app
|
|
546
|
+
OPENPETS_DEV_PLUGIN_PATHS=${targetDir} pnpm dev:desktop
|
|
547
|
+
\`\`\`
|
|
548
|
+
|
|
549
|
+
Then open **Tray → Plugins**, enable the plugin, and right-click your pet to
|
|
550
|
+
run its commands.
|
|
551
|
+
|
|
552
|
+
## Learn more
|
|
553
|
+
|
|
554
|
+
- SDK guide: https://openpets.dev/sdk
|
|
555
|
+
- Reference: https://openpets.dev/docs/plugin-sdk
|
|
556
|
+
`;
|
|
557
|
+
}
|
|
558
|
+
export function parseReactArgs(args) {
|
|
559
|
+
if (args.length !== 1)
|
|
560
|
+
throw new CliError("Usage: openpets react <reaction>");
|
|
561
|
+
return { reaction: parseReaction(args[0] ?? "") };
|
|
562
|
+
}
|
|
563
|
+
export function parseSayArgs(args) {
|
|
564
|
+
let reaction;
|
|
565
|
+
const messageParts = [];
|
|
566
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
567
|
+
const arg = args[index];
|
|
568
|
+
if (arg === "--reaction") {
|
|
569
|
+
reaction = parseReaction(readRequiredArg(args, index, "--reaction"));
|
|
570
|
+
index += 1;
|
|
571
|
+
}
|
|
572
|
+
else if (arg.startsWith("--reaction=")) {
|
|
573
|
+
reaction = parseReaction(arg.slice("--reaction=".length));
|
|
574
|
+
}
|
|
575
|
+
else if (arg.startsWith("--")) {
|
|
576
|
+
throw new CliError(`Unknown say option: ${arg}`);
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
messageParts.push(arg);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const message = messageParts.join(" ").trim();
|
|
583
|
+
if (!message)
|
|
584
|
+
throw new CliError("Usage: openpets say <message> [--reaction <reaction>]");
|
|
585
|
+
return { message, reaction };
|
|
586
|
+
}
|
|
587
|
+
function parseReaction(value) {
|
|
588
|
+
if (!allowedReactions.includes(value)) {
|
|
589
|
+
throw new CliError(`Invalid OpenPets reaction: ${value}. Allowed reactions: ${allowedReactions.join(", ")}.`);
|
|
590
|
+
}
|
|
591
|
+
return value;
|
|
592
|
+
}
|
|
593
|
+
export function createVersionPinnedCliCommand(version, args) {
|
|
594
|
+
return { command: "npx", args: ["-y", `${cliPackageName}@${version}`, ...args] };
|
|
595
|
+
}
|
|
596
|
+
export function createLocalDevCliCommand(args) {
|
|
597
|
+
return { command: process.execPath, args: [fileURLToPath(import.meta.url), ...args] };
|
|
598
|
+
}
|
|
599
|
+
export function createClaudeMcpAddJsonArgs(config) {
|
|
600
|
+
return ["mcp", "add-json", "openpets", JSON.stringify(config), "--scope", "local"];
|
|
601
|
+
}
|
|
602
|
+
export function installProjectLocalHooks(projectDir, hookCommand) {
|
|
603
|
+
writePreparedHooks(prepareProjectLocalHooks(projectDir, hookCommand));
|
|
604
|
+
}
|
|
605
|
+
export function prepareProjectLocalHooks(projectDir, hookCommand) {
|
|
606
|
+
assertSafeProjectHookPath(projectDir);
|
|
607
|
+
const settingsPath = getProjectLocalSettingsPath(realpathSync(projectDir));
|
|
608
|
+
const current = readJsonObject(settingsPath);
|
|
609
|
+
const cleaned = removeOpenPetsHooks(current);
|
|
610
|
+
const hooks = isRecord(cleaned.hooks) ? { ...cleaned.hooks } : {};
|
|
611
|
+
for (const event of claudeHookEvents) {
|
|
612
|
+
if (hooks[event] !== undefined && !Array.isArray(hooks[event]))
|
|
613
|
+
throw new CliError(`Claude local settings hooks.${event} must be an array.`);
|
|
614
|
+
const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
615
|
+
hooks[event] = [...existing, { hooks: [createHookCommandEntry(hookCommand)] }];
|
|
616
|
+
}
|
|
617
|
+
return { settingsPath, settings: { ...cleaned, hooks } };
|
|
618
|
+
}
|
|
619
|
+
function writePreparedHooks(prepared) {
|
|
620
|
+
writeJsonFile(prepared.settingsPath, prepared.settings);
|
|
621
|
+
}
|
|
622
|
+
function createHookCommandEntry(command) {
|
|
623
|
+
return { type: "command", command, timeout: 10, async: true, asyncRewake: false };
|
|
624
|
+
}
|
|
625
|
+
export function runClaudeMcpAddJson(projectDir, config, force = false) {
|
|
626
|
+
if (force)
|
|
627
|
+
runClaudeMcpRemove(projectDir);
|
|
628
|
+
const result = spawnSync("claude", createClaudeMcpAddJsonArgs(config), { cwd: projectDir, encoding: "utf8", shell: false, stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 });
|
|
629
|
+
if (result.error)
|
|
630
|
+
throw new CliError(`Claude Code is unavailable on PATH: ${result.error.message}`);
|
|
631
|
+
if (result.status !== 0)
|
|
632
|
+
throw new CliError(`Claude MCP configuration failed: ${(result.stderr || result.stdout || "unknown error").trim()}`);
|
|
633
|
+
}
|
|
634
|
+
function runClaudeMcpRemove(projectDir) {
|
|
635
|
+
const result = spawnSync("claude", ["mcp", "remove", "openpets", "--scope", "local"], { cwd: projectDir, encoding: "utf8", shell: false, stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 });
|
|
636
|
+
if (result.error)
|
|
637
|
+
throw new CliError(`Claude Code is unavailable on PATH: ${result.error.message}`);
|
|
638
|
+
const output = `${result.stderr || ""}\n${result.stdout || ""}`;
|
|
639
|
+
if (result.status !== 0 && !/not found|does not exist|no server|unknown/i.test(output)) {
|
|
640
|
+
throw new CliError(`Claude MCP remove failed: ${(result.stderr || result.stdout || "unknown error").trim()}`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
async function runMcp(args) {
|
|
644
|
+
const entry = require.resolve("@burmese/mcp");
|
|
645
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
646
|
+
const child = spawn(process.execPath, [entry, ...args], { stdio: "inherit" });
|
|
647
|
+
const forwardSigint = () => { child.kill("SIGINT"); };
|
|
648
|
+
const forwardSigterm = () => { child.kill("SIGTERM"); };
|
|
649
|
+
process.once("SIGINT", forwardSigint);
|
|
650
|
+
process.once("SIGTERM", forwardSigterm);
|
|
651
|
+
child.on("error", rejectPromise);
|
|
652
|
+
child.on("exit", (code, signal) => {
|
|
653
|
+
process.off("SIGINT", forwardSigint);
|
|
654
|
+
process.off("SIGTERM", forwardSigterm);
|
|
655
|
+
if (signal) {
|
|
656
|
+
process.kill(process.pid, signal);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
process.exitCode = code ?? 1;
|
|
660
|
+
resolvePromise();
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
async function getInstalledPets(client) {
|
|
665
|
+
try {
|
|
666
|
+
return await client.listPets();
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
if (error instanceof OpenPetsClientError && error.code === "unknown_method")
|
|
670
|
+
throw new CliError("OpenPets desktop app is too old for project setup. Update/restart OpenPets and try again.");
|
|
671
|
+
throw new CliError("OpenPets desktop app is not running. Open OpenPets, then run this command again.");
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
async function pickPet(pets) {
|
|
675
|
+
const usable = pets.filter((pet) => !pet.broken);
|
|
676
|
+
if (usable.length === 0)
|
|
677
|
+
throw new CliError("No usable installed pets found. Open OpenPets and install a pet first.");
|
|
678
|
+
if (!process.stdin.isTTY)
|
|
679
|
+
throw new CliError("Missing --pet <id>. Non-interactive shells must pass --pet.");
|
|
680
|
+
process.stdout.write("Pick pet for this project:\n");
|
|
681
|
+
usable.forEach((pet, index) => process.stdout.write(` ${index + 1}. ${sanitizeTerminalText(pet.displayName)} (${pet.id})\n`));
|
|
682
|
+
const rl = createInterface({ input, output });
|
|
683
|
+
try {
|
|
684
|
+
const answer = await rl.question("Pet number: ");
|
|
685
|
+
const index = Number(answer.trim()) - 1;
|
|
686
|
+
if (!Number.isInteger(index) || !usable[index])
|
|
687
|
+
throw new CliError("Invalid pet selection.");
|
|
688
|
+
return usable[index].id;
|
|
689
|
+
}
|
|
690
|
+
finally {
|
|
691
|
+
rl.close();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function sanitizeTerminalText(value) {
|
|
695
|
+
return value.replace(/[\x00-\x1F\x7F]/g, "").slice(0, 100);
|
|
696
|
+
}
|
|
697
|
+
function resolveProjectDir(cwd) {
|
|
698
|
+
const resolved = resolve(cwd);
|
|
699
|
+
const stats = lstatSync(resolved);
|
|
700
|
+
if (stats.isSymbolicLink())
|
|
701
|
+
throw new CliError("Project directory cannot be a symlink.");
|
|
702
|
+
if (!stats.isDirectory())
|
|
703
|
+
throw new CliError("Project path must be a directory.");
|
|
704
|
+
return realpathSync(resolved);
|
|
705
|
+
}
|
|
706
|
+
function assertClaudeAvailable() {
|
|
707
|
+
const result = spawnSync("claude", ["--version"], { shell: false, stdio: "ignore", timeout: 5_000 });
|
|
708
|
+
if (result.error || result.status !== 0)
|
|
709
|
+
throw new CliError("Claude Code is unavailable on PATH. Install Claude Code, then try again.");
|
|
710
|
+
}
|
|
711
|
+
export function assertSafeProjectHookPath(projectDir) {
|
|
712
|
+
const projectReal = realpathSync(projectDir);
|
|
713
|
+
const claudeDir = join(projectReal, ".claude");
|
|
714
|
+
if (existsSync(claudeDir)) {
|
|
715
|
+
const claudeStats = lstatSync(claudeDir);
|
|
716
|
+
if (claudeStats.isSymbolicLink())
|
|
717
|
+
throw new CliError("Project .claude directory cannot be a symlink.");
|
|
718
|
+
if (!claudeStats.isDirectory())
|
|
719
|
+
throw new CliError("Project .claude path must be a directory.");
|
|
720
|
+
const rel = relative(projectReal, realpathSync(claudeDir));
|
|
721
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
722
|
+
throw new CliError("Project .claude directory escapes the project.");
|
|
723
|
+
}
|
|
724
|
+
const settingsPath = getProjectLocalSettingsPath(projectReal);
|
|
725
|
+
if (existsSync(settingsPath)) {
|
|
726
|
+
const settingsStats = lstatSync(settingsPath);
|
|
727
|
+
if (settingsStats.isSymbolicLink())
|
|
728
|
+
throw new CliError("Project Claude local settings file cannot be a symlink.");
|
|
729
|
+
if (!settingsStats.isFile())
|
|
730
|
+
throw new CliError("Project Claude local settings path must be a file.");
|
|
731
|
+
}
|
|
732
|
+
const settingsRel = relative(projectReal, resolve(settingsPath));
|
|
733
|
+
if (settingsRel.startsWith("..") || isAbsolute(settingsRel))
|
|
734
|
+
throw new CliError("Project Claude local settings path escapes the project.");
|
|
735
|
+
}
|
|
736
|
+
export function assertSafeProjectCodexConfigPath(projectDir) {
|
|
737
|
+
const projectReal = realpathSync(projectDir);
|
|
738
|
+
const codexDir = join(projectReal, ".codex");
|
|
739
|
+
if (existsSync(codexDir)) {
|
|
740
|
+
const codexStats = lstatSync(codexDir);
|
|
741
|
+
if (codexStats.isSymbolicLink())
|
|
742
|
+
throw new CliError("Project .codex directory cannot be a symlink.");
|
|
743
|
+
if (!codexStats.isDirectory())
|
|
744
|
+
throw new CliError("Project .codex path must be a directory.");
|
|
745
|
+
const rel = relative(projectReal, realpathSync(codexDir));
|
|
746
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
747
|
+
throw new CliError("Project .codex directory escapes the project.");
|
|
748
|
+
}
|
|
749
|
+
const configPath = getProjectCodexConfigPath(projectReal);
|
|
750
|
+
if (existsSync(configPath)) {
|
|
751
|
+
const configStats = lstatSync(configPath);
|
|
752
|
+
if (configStats.isSymbolicLink())
|
|
753
|
+
throw new CliError("Project Codex config file cannot be a symlink.");
|
|
754
|
+
if (!configStats.isFile())
|
|
755
|
+
throw new CliError("Project Codex config path must be a file.");
|
|
756
|
+
}
|
|
757
|
+
const configRel = relative(projectReal, resolve(configPath));
|
|
758
|
+
if (configRel.startsWith("..") || isAbsolute(configRel))
|
|
759
|
+
throw new CliError("Project Codex config path escapes the project.");
|
|
760
|
+
}
|
|
761
|
+
function getProjectLocalSettingsPath(projectDir) {
|
|
762
|
+
return join(projectDir, ".claude", "settings.local.json");
|
|
763
|
+
}
|
|
764
|
+
function getProjectCodexConfigPath(projectDir) {
|
|
765
|
+
return join(projectDir, ".codex", "config.toml");
|
|
766
|
+
}
|
|
767
|
+
function readJsonObject(path) {
|
|
768
|
+
if (!existsSync(path))
|
|
769
|
+
return {};
|
|
770
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
771
|
+
if (!isRecord(parsed) || Array.isArray(parsed))
|
|
772
|
+
throw new CliError("Claude local settings must be a JSON object.");
|
|
773
|
+
if (parsed.hooks !== undefined && !isRecord(parsed.hooks))
|
|
774
|
+
throw new CliError("Claude local settings hooks field must be an object.");
|
|
775
|
+
return parsed;
|
|
776
|
+
}
|
|
777
|
+
function writeJsonFile(path, value) {
|
|
778
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
779
|
+
const parentStats = lstatSync(dirname(path));
|
|
780
|
+
if (parentStats.isSymbolicLink() || !parentStats.isDirectory())
|
|
781
|
+
throw new CliError("Project .claude directory is unsafe after creation.");
|
|
782
|
+
const tempPath = `${path}.${process.pid}.tmp`;
|
|
783
|
+
writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
784
|
+
renameSync(tempPath, path);
|
|
785
|
+
try {
|
|
786
|
+
chmodSync(path, 0o600);
|
|
787
|
+
}
|
|
788
|
+
catch { /* best effort */ }
|
|
789
|
+
}
|
|
790
|
+
function readPetArg(args) {
|
|
791
|
+
const equals = args.find((arg) => arg.startsWith("--pet="));
|
|
792
|
+
if (equals)
|
|
793
|
+
return validateOpenPetsPetArg(equals.slice("--pet=".length));
|
|
794
|
+
const index = args.indexOf("--pet");
|
|
795
|
+
const value = index >= 0 ? args[index + 1] : undefined;
|
|
796
|
+
if (index >= 0 && (!value || value.startsWith("--")))
|
|
797
|
+
throw new CliError("Missing value for --pet.");
|
|
798
|
+
return value && value.length > 0 ? validateOpenPetsPetArg(value) : undefined;
|
|
799
|
+
}
|
|
800
|
+
function hasProjectLocalArg(args) {
|
|
801
|
+
return args.includes("--project-local");
|
|
802
|
+
}
|
|
803
|
+
function readHookEventArg(args) {
|
|
804
|
+
const equals = args.find((arg) => arg.startsWith("--hook-event="));
|
|
805
|
+
const value = equals ? equals.slice("--hook-event=".length) : args.includes("--hook-event") ? args[args.indexOf("--hook-event") + 1] : undefined;
|
|
806
|
+
if (args.includes("--hook-event") && (!value || value.startsWith("--")))
|
|
807
|
+
throw new CliError("Missing value for --hook-event.");
|
|
808
|
+
if (value === undefined)
|
|
809
|
+
return undefined;
|
|
810
|
+
if (!["UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest", "Notification", "Stop", "StopFailure"].includes(value))
|
|
811
|
+
throw new CliError(`Unsupported hook event: ${value}.`);
|
|
812
|
+
return value;
|
|
813
|
+
}
|
|
814
|
+
function readHookClientLabelArg(args, eventName) {
|
|
815
|
+
const equals = args.find((arg) => arg.startsWith("--client-label="));
|
|
816
|
+
const value = equals ? equals.slice("--client-label=".length) : args.includes("--client-label") ? args[args.indexOf("--client-label") + 1] : undefined;
|
|
817
|
+
if (args.includes("--client-label") && (!value || value.startsWith("--")))
|
|
818
|
+
throw new CliError("Missing value for --client-label.");
|
|
819
|
+
if (value !== undefined)
|
|
820
|
+
return sanitizeHookClientLabel(value);
|
|
821
|
+
return eventName ? "Codex" : undefined;
|
|
822
|
+
}
|
|
823
|
+
function sanitizeHookClientLabel(value) {
|
|
824
|
+
const label = value.replace(/[\x00-\x1F\x7F]/g, "").trim().slice(0, 40);
|
|
825
|
+
if (label.length === 0)
|
|
826
|
+
throw new CliError("Invalid --client-label.");
|
|
827
|
+
return label;
|
|
828
|
+
}
|
|
829
|
+
function readRequiredArg(args, index, flag) {
|
|
830
|
+
const value = args[index + 1];
|
|
831
|
+
if (!value || value.startsWith("--"))
|
|
832
|
+
throw new CliError(`Missing value for ${flag}.`);
|
|
833
|
+
return value;
|
|
834
|
+
}
|
|
835
|
+
function formatShellCommand(command) {
|
|
836
|
+
return [command.command, ...command.args].map(shellQuote).join(" ");
|
|
837
|
+
}
|
|
838
|
+
function shellQuote(value) {
|
|
839
|
+
if (/^[a-zA-Z0-9_@%+=:,./-]+$/.test(value))
|
|
840
|
+
return value;
|
|
841
|
+
if (/[\r\n"]/.test(value) || value.includes("\0"))
|
|
842
|
+
throw new CliError("Command argument contains unsupported shell characters.");
|
|
843
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll("$", "\\$").replaceAll("`", "\\`")}"`;
|
|
844
|
+
}
|
|
845
|
+
function getPackageVersion() {
|
|
846
|
+
const parsed = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
847
|
+
if (!isRecord(parsed) || typeof parsed.version !== "string")
|
|
848
|
+
throw new CliError("Cannot read OpenPets CLI package version.");
|
|
849
|
+
return parsed.version;
|
|
850
|
+
}
|
|
851
|
+
function printUsage() {
|
|
852
|
+
process.stdout.write("Usage:\n openpets status\n openpets doctor [--cwd <path>] [--json]\n openpets pets\n openpets react <reaction>\n openpets say <message> [--reaction <reaction>]\n openpets install <pet-id>\n openpets configure [--agent claude|codex|opencode|cursor] [--pet <id>] [--cwd <path>] [--yes] [--force] [--with-rules|--rules-only|--remove-rules]\n openpets plugin new <name> [--id <id>] [--dir <path>] [--author <name>]\n openpets mcp [--pet <id>]\n openpets hook --openpets-managed [--pet <id>] [--hook-event <event>]\n\nRun `openpets <command> --help` for command options.\n");
|
|
853
|
+
}
|
|
854
|
+
function printPluginUsage() {
|
|
855
|
+
process.stdout.write("Usage:\n" +
|
|
856
|
+
" openpets plugin new <name> [--template <template>] [--id <id>] [--dir <path>] [--author <name>]\n" +
|
|
857
|
+
" openpets plugin validate [dir]\n\n" +
|
|
858
|
+
"plugin new scaffolds a typed SDK v3 plugin with a manifest, a working entry, and a passing\n" +
|
|
859
|
+
"test built on @burmese/plugin-sdk/testing. plugin validate checks the manifest, config\n" +
|
|
860
|
+
"schema, declared assets/panels, permissions, and network hosts at author time.\n\n" +
|
|
861
|
+
"Options:\n" +
|
|
862
|
+
` --template <t> Template: ${pluginTemplateNames.join(", ")}. Defaults to blank.\n` +
|
|
863
|
+
" --id <id> Plugin id (reverse-DNS style). Defaults to local.<name-slug>.\n" +
|
|
864
|
+
" --dir <path> Target directory. Defaults to ./<name-slug>.\n" +
|
|
865
|
+
" --author <name> Author name (informational).\n" +
|
|
866
|
+
" -h, --help Show this help.\n\n" +
|
|
867
|
+
"Learn more: https://openpets.dev/sdk\n");
|
|
868
|
+
}
|
|
869
|
+
function printInstallUsage() {
|
|
870
|
+
process.stdout.write("Usage:\n openpets install <pet-id>\n\nDownloads a gallery pet through the running OpenPets desktop app and installs it locally.\n");
|
|
871
|
+
}
|
|
872
|
+
function printStatusUsage() {
|
|
873
|
+
process.stdout.write("Usage:\n openpets status\n\nChecks whether the OpenPets desktop app is reachable and prints the status response as JSON.\n");
|
|
874
|
+
}
|
|
875
|
+
function printDoctorUsage() {
|
|
876
|
+
process.stdout.write("Usage:\n openpets doctor [--cwd <path>] [--json]\n\nReports whether Claude hooks, project Codex setup, project Cursor MCP, and the OpenPets desktop app are installed, need an update, or are broken.\n\nOptions:\n --cwd <path> Project directory to inspect for .codex/config.toml and .cursor/mcp.json. Defaults to current directory.\n --json Print the report as JSON instead of labeled lines.\n -h, --help Show this help.\n");
|
|
877
|
+
}
|
|
878
|
+
function printPetsUsage() {
|
|
879
|
+
process.stdout.write("Usage:\n openpets pets\n\nLists pets installed in the running OpenPets desktop app.\n");
|
|
880
|
+
}
|
|
881
|
+
function printReactUsage() {
|
|
882
|
+
process.stdout.write(`Usage:\n openpets react <reaction>\n\nSends a reaction to the running OpenPets desktop app.\nAllowed reactions: ${allowedReactions.join(", ")}.\n`);
|
|
883
|
+
}
|
|
884
|
+
function printSayUsage() {
|
|
885
|
+
process.stdout.write(`Usage:\n openpets say <message> [--reaction <reaction>]\n\nShows a short message in the running OpenPets desktop app. Optionally sends a reaction with the message.\nAllowed reactions: ${allowedReactions.join(", ")}.\n`);
|
|
886
|
+
}
|
|
887
|
+
function printConfigureUsage() {
|
|
888
|
+
process.stdout.write("Usage:\n openpets configure [--agent claude|codex|opencode|cursor] [--pet <id>] [--cwd <path>] [--yes] [--force] [--with-rules|--rules-only|--remove-rules]\n\nOptions:\n --pet <id> Pet id to use for this project. If omitted, prompts with installed pets. Cursor --rules-only/--remove-rules do not need a pet.\n --agent <agent> Agent to configure: claude, codex, opencode, or cursor. Defaults to claude.\n --cwd <path> Project directory to configure. Defaults to current directory. Codex uses <cwd>/.codex/config.toml; Cursor uses <cwd>/.cursor/mcp.json and <cwd>/.cursor/rules/openpets.mdc.\n --with-rules For Cursor, install MCP config and project rules after preflighting both writes.\n --rules-only For Cursor, install/update only .cursor/rules/openpets.mdc.\n --remove-rules For Cursor, remove only managed .cursor/rules/openpets.mdc.\n --yes, -y Accepted for scripts; no confirmation prompt is shown.\n --force Replace supported managed entries where applicable. Required for conflicting Cursor rules or custom Codex openpets MCP tables.\n --replace Alias for --force.\n --local-dev Use local development command paths where supported.\n -h, --help Show this help.\n");
|
|
889
|
+
}
|
|
890
|
+
function printMcpUsage() {
|
|
891
|
+
process.stdout.write("Usage:\n openpets mcp [--pet <id>]\n\nStarts the OpenPets MCP server wrapper. This command is written into Claude MCP config by `openpets configure`.\n");
|
|
892
|
+
}
|
|
893
|
+
function printHookUsage() {
|
|
894
|
+
process.stdout.write("Usage:\n openpets hook --openpets-managed [--pet <id>] [--hook-event <event>]\n\nRuns one Claude Code or Codex hook event from stdin. This command is written into managed agent hooks by `openpets configure`.\n");
|
|
895
|
+
}
|
|
896
|
+
function hasHelp(args) {
|
|
897
|
+
return args.includes("--help") || args.includes("-h");
|
|
898
|
+
}
|
|
899
|
+
function isRecord(value) {
|
|
900
|
+
return typeof value === "object" && value !== null;
|
|
901
|
+
}
|
|
902
|
+
class CliError extends Error {
|
|
903
|
+
}
|
|
904
|
+
if (isMainModule()) {
|
|
905
|
+
main().catch((error) => {
|
|
906
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
907
|
+
process.exitCode = 1;
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
function isMainModule() {
|
|
911
|
+
if (!process.argv[1])
|
|
912
|
+
return false;
|
|
913
|
+
try {
|
|
914
|
+
return realpathSync(resolve(process.argv[1])) === realpathSync(fileURLToPath(import.meta.url));
|
|
915
|
+
}
|
|
916
|
+
catch {
|
|
917
|
+
return resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
//# sourceMappingURL=index.js.map
|