@writepanda/cli 1.9.3
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/README.md +100 -0
- package/bin/pandastudio.mjs +398 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kamala Kannan Sankarraj
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @writepanda/cli
|
|
2
|
+
|
|
3
|
+
The `pandastudio` command-line interface — drive [PandaStudio](https://www.writepanda.ai) (a desktop video editor for YouTube creators) from your terminal, shell scripts, or AI agents.
|
|
4
|
+
|
|
5
|
+
PandaStudio's signature feature — editing video by deleting words from the transcript — is now scriptable. Plus headless export, motion-graphic generation, FX overlays, lower-thirds, captions, AI title/description generation. Everything the human editor does, callable from the CLI.
|
|
6
|
+
|
|
7
|
+
> **You also need the desktop app installed.** The CLI is a thin HTTP client; the actual rendering happens in PandaStudio's localhost-only automation server. Get the app at [writepanda.ai](https://www.writepanda.ai).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g @writepanda/cli
|
|
13
|
+
# or run without installing:
|
|
14
|
+
npx @writepanda/cli system.status
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The same `pandastudio` binary also ships bundled inside every PandaStudio install (Settings → Local automation → Install to PATH). The npm version is convenient for AI agents and CI pipelines that want to script the editor without going through the in-app install button.
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 1. Install + open PandaStudio (writepanda.ai). Settings → Local automation → toggle ON.
|
|
23
|
+
|
|
24
|
+
# 2. Confirm reachability:
|
|
25
|
+
pandastudio system.status
|
|
26
|
+
|
|
27
|
+
# 3. Discover the surface (62 verb-noun commands):
|
|
28
|
+
pandastudio commands
|
|
29
|
+
|
|
30
|
+
# 4. Render a motion graphic:
|
|
31
|
+
JOB=$(pandastudio motion.generate \
|
|
32
|
+
--templateId=title-card-vox \
|
|
33
|
+
--slots='{"title":"How I Built This","subtitle":"in 24 hours"}' \
|
|
34
|
+
--aspectRatio=16:9 \
|
|
35
|
+
--json | jq -r '.data.jobId')
|
|
36
|
+
|
|
37
|
+
pandastudio job.wait --id="$JOB" --json | jq '.data.job.result.outputPath'
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
If PandaStudio isn't running, the CLI auto-launches it and waits up to 60s for the HTTP listener. Pass `--no-launch` to opt out.
|
|
41
|
+
|
|
42
|
+
## The agentic edit flow
|
|
43
|
+
|
|
44
|
+
The 13-step "agent gets two videos and produces a polished MP4" workflow is documented in full at [writepanda.ai/cli](https://www.writepanda.ai/cli). Headline:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pandastudio project.new --withMedia='[...]' --json
|
|
48
|
+
pandastudio transcript.transcribe --id=$ID
|
|
49
|
+
pandastudio transcript.remove-fillers --id=$ID
|
|
50
|
+
pandastudio motion.generate ...
|
|
51
|
+
pandastudio project.add-motion-graphic ...
|
|
52
|
+
pandastudio caption.toggle --enabled=true
|
|
53
|
+
pandastudio caption.set-template --templateId=bold
|
|
54
|
+
pandastudio export.start --id=$ID
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Headless. No editor window required. Same Skia native render-helper the in-app Export button uses.
|
|
58
|
+
|
|
59
|
+
## For AI agents (Claude Code, Claude Desktop)
|
|
60
|
+
|
|
61
|
+
If you're an agent: install the **bundled Claude Skill** instead — it teaches you the full surface in one auto-discovered SKILL.md. From the running PandaStudio app: Settings → Local automation → Install Skill. The Skill copies to `~/.claude/skills/pandastudio/` and Claude auto-loads it.
|
|
62
|
+
|
|
63
|
+
For Cursor / Continue / Cline / other MCP clients, use the companion package: [`@writepanda/mcp`](https://www.npmjs.com/package/@writepanda/mcp).
|
|
64
|
+
|
|
65
|
+
## Auth
|
|
66
|
+
|
|
67
|
+
Every PandaStudio launch generates a fresh 256-bit token, written 0600 to:
|
|
68
|
+
|
|
69
|
+
| Platform | Path |
|
|
70
|
+
|---|---|
|
|
71
|
+
| macOS / Linux | `~/.config/pandastudio/{token,port,audit.log}` |
|
|
72
|
+
| Windows | `%APPDATA%\pandastudio\{token,port,audit.log}` |
|
|
73
|
+
|
|
74
|
+
The CLI reads both files on every invocation; no env vars, no caching. Closing and reopening the desktop app rotates the token automatically. `PANDASTUDIO_CONFIG_DIR` overrides the path (used in tests).
|
|
75
|
+
|
|
76
|
+
## Licensing
|
|
77
|
+
|
|
78
|
+
The CLI honours the same license gate the desktop app does:
|
|
79
|
+
|
|
80
|
+
| State | What's allowed |
|
|
81
|
+
|---|---|
|
|
82
|
+
| Licensed | Every command |
|
|
83
|
+
| Active trial | Every command |
|
|
84
|
+
| Trial expired, no license | Only `system.*` and `window.focus` |
|
|
85
|
+
|
|
86
|
+
Run `pandastudio system.status` to see `automationGated: true/false` in the response.
|
|
87
|
+
|
|
88
|
+
## Output modes
|
|
89
|
+
|
|
90
|
+
- Default: pretty one-line summaries to stdout
|
|
91
|
+
- `--json`: raw `{ ok, data, error }` envelope — pipe through `jq` for scripting
|
|
92
|
+
|
|
93
|
+
## Documentation
|
|
94
|
+
|
|
95
|
+
- Full command catalogue + recipes: [writepanda.ai/cli](https://www.writepanda.ai/cli)
|
|
96
|
+
- Source: [github.com/kamskans/openscreen](https://github.com/kamskans/openscreen)
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT.
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pandastudio — local CLI for the PandaStudio automation surface.
|
|
3
|
+
//
|
|
4
|
+
// Design goals (per the HuggingFace CLI-vs-MCP analysis):
|
|
5
|
+
// • Token-frugal output. By default we print a one-line success
|
|
6
|
+
// summary, with `--json` opting into machine-readable payloads.
|
|
7
|
+
// • Deterministic dispatch. Every command is a `verb.noun` that maps
|
|
8
|
+
// 1:1 to a registered handler in the main process.
|
|
9
|
+
// • Graceful auto-launch. If the app isn't running, we spawn the
|
|
10
|
+
// installed PandaStudio.app (or the dev binary) and poll
|
|
11
|
+
// `/v1/health` until it's ready before sending the actual call.
|
|
12
|
+
// • No third-party deps — pure Node so it's bundled as a single
|
|
13
|
+
// file inside the installer.
|
|
14
|
+
//
|
|
15
|
+
// Layout:
|
|
16
|
+
// ~/.config/pandastudio/{token,port,audit.log} ← well-known
|
|
17
|
+
// /usr/local/bin/pandastudio (mac, post-install symlink)
|
|
18
|
+
// %ProgramFiles%/PandaStudio/cli/pandastudio.exe (Windows installer)
|
|
19
|
+
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import fs from "node:fs/promises";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import process from "node:process";
|
|
25
|
+
|
|
26
|
+
const VERSION = "1.0.0";
|
|
27
|
+
|
|
28
|
+
function configDir() {
|
|
29
|
+
// PANDASTUDIO_CONFIG_DIR mirrors the override the main process
|
|
30
|
+
// honours in electron/automation/auth.ts. Useful for (a) integration
|
|
31
|
+
// tests that point both sides at a tmp dir, (b) power users with an
|
|
32
|
+
// alternate install location.
|
|
33
|
+
if (process.env.PANDASTUDIO_CONFIG_DIR) {
|
|
34
|
+
return process.env.PANDASTUDIO_CONFIG_DIR;
|
|
35
|
+
}
|
|
36
|
+
if (process.platform === "win32") {
|
|
37
|
+
const appData = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming");
|
|
38
|
+
return path.join(appData, "pandastudio");
|
|
39
|
+
}
|
|
40
|
+
return path.join(os.homedir(), ".config", "pandastudio");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TOKEN_FILE = path.join(configDir(), "token");
|
|
44
|
+
const PORT_FILE = path.join(configDir(), "port");
|
|
45
|
+
|
|
46
|
+
function printHelp() {
|
|
47
|
+
process.stdout.write(`pandastudio v${VERSION} — local CLI for the PandaStudio automation surface
|
|
48
|
+
|
|
49
|
+
USAGE
|
|
50
|
+
pandastudio <command> [--key value …] Run a command
|
|
51
|
+
pandastudio --help This message
|
|
52
|
+
pandastudio --version Print CLI version
|
|
53
|
+
pandastudio commands List every available verb.noun
|
|
54
|
+
|
|
55
|
+
OPTIONS
|
|
56
|
+
--json Emit raw JSON to stdout (machine-readable mode).
|
|
57
|
+
--no-launch Don't auto-launch PandaStudio if it isn't running.
|
|
58
|
+
--timeout=<seconds> How long to wait for auto-launch readiness (default 60).
|
|
59
|
+
--token=<value> Override the on-disk token (rare; for tests).
|
|
60
|
+
--port=<n> Override the on-disk port (rare; for tests).
|
|
61
|
+
--app=<path> Override the path to the PandaStudio binary used for auto-launch.
|
|
62
|
+
|
|
63
|
+
EXAMPLES
|
|
64
|
+
pandastudio system.status
|
|
65
|
+
pandastudio system.status --json
|
|
66
|
+
pandastudio motion.list
|
|
67
|
+
pandastudio motion.generate --templateId=title-card-vox --slots='{"title":"Hello","subtitle":"World"}'
|
|
68
|
+
pandastudio job.wait --id=<jobId>
|
|
69
|
+
|
|
70
|
+
DOCS
|
|
71
|
+
Token + port files live in ${configDir()}.
|
|
72
|
+
Audit log: ${path.join(configDir(), "audit.log")}.
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseArgv(argv) {
|
|
77
|
+
const args = { _: [], flags: {}, json: false, noLaunch: false, timeoutSeconds: 60 };
|
|
78
|
+
for (const raw of argv) {
|
|
79
|
+
if (raw === "--help" || raw === "-h") {
|
|
80
|
+
args.help = true;
|
|
81
|
+
} else if (raw === "--version" || raw === "-v") {
|
|
82
|
+
args.version = true;
|
|
83
|
+
} else if (raw === "--json") {
|
|
84
|
+
args.json = true;
|
|
85
|
+
} else if (raw === "--no-launch") {
|
|
86
|
+
args.noLaunch = true;
|
|
87
|
+
} else if (raw.startsWith("--timeout=")) {
|
|
88
|
+
args.timeoutSeconds = Number(raw.slice("--timeout=".length)) || 60;
|
|
89
|
+
} else if (raw.startsWith("--token=")) {
|
|
90
|
+
args.tokenOverride = raw.slice("--token=".length);
|
|
91
|
+
} else if (raw.startsWith("--port=")) {
|
|
92
|
+
args.portOverride = Number(raw.slice("--port=".length));
|
|
93
|
+
} else if (raw.startsWith("--app=")) {
|
|
94
|
+
args.appOverride = raw.slice("--app=".length);
|
|
95
|
+
} else if (raw.startsWith("--")) {
|
|
96
|
+
// --key=value or --key value (we only support --key=value form
|
|
97
|
+
// to keep things unambiguous; --flag with no value sets true)
|
|
98
|
+
const eq = raw.indexOf("=");
|
|
99
|
+
if (eq < 0) {
|
|
100
|
+
args.flags[raw.slice(2)] = true;
|
|
101
|
+
} else {
|
|
102
|
+
const key = raw.slice(2, eq);
|
|
103
|
+
const value = raw.slice(eq + 1);
|
|
104
|
+
args.flags[key] = parseScalar(value);
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
args._.push(raw);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return args;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseScalar(value) {
|
|
114
|
+
// JSON pass-through for objects/arrays/booleans/numbers; fall back
|
|
115
|
+
// to the literal string. Lets `--slots='{"title":"x"}'` work without
|
|
116
|
+
// a separate `--slots-json` flag.
|
|
117
|
+
const trimmed = value.trim();
|
|
118
|
+
if (
|
|
119
|
+
trimmed.startsWith("{") ||
|
|
120
|
+
trimmed.startsWith("[") ||
|
|
121
|
+
trimmed === "true" ||
|
|
122
|
+
trimmed === "false" ||
|
|
123
|
+
trimmed === "null" ||
|
|
124
|
+
(/^-?\d/.test(trimmed) && !Number.isNaN(Number(trimmed)))
|
|
125
|
+
) {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(trimmed);
|
|
128
|
+
} catch {
|
|
129
|
+
/* fall through */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function readCredentials(opts) {
|
|
136
|
+
const port = opts.portOverride ?? Number((await fs.readFile(PORT_FILE, "utf-8")).trim());
|
|
137
|
+
const token = opts.tokenOverride ?? (await fs.readFile(TOKEN_FILE, "utf-8")).trim();
|
|
138
|
+
if (!Number.isInteger(port) || port <= 0) throw new Error(`invalid port in ${PORT_FILE}`);
|
|
139
|
+
if (!token) throw new Error(`empty token in ${TOKEN_FILE}`);
|
|
140
|
+
return { port, token };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function fetchJson(port, pathSuffix, opts = {}) {
|
|
144
|
+
// Use the global fetch (Node 18+ ships it); we only require Node 22
|
|
145
|
+
// at runtime per the app's package.json engines block, so this is
|
|
146
|
+
// safe to assume.
|
|
147
|
+
const url = `http://127.0.0.1:${port}${pathSuffix}`;
|
|
148
|
+
const res = await fetch(url, opts);
|
|
149
|
+
const text = await res.text();
|
|
150
|
+
let parsed;
|
|
151
|
+
try {
|
|
152
|
+
parsed = text.length === 0 ? null : JSON.parse(text);
|
|
153
|
+
} catch {
|
|
154
|
+
parsed = { raw: text };
|
|
155
|
+
}
|
|
156
|
+
return { status: res.status, body: parsed };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function probeHealth(port, timeoutMs) {
|
|
160
|
+
// Single short probe with a small AbortController; used as a
|
|
161
|
+
// liveness check before auto-launching, then in a poll loop after
|
|
162
|
+
// launch to wait for readiness.
|
|
163
|
+
const ctrl = new AbortController();
|
|
164
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/health`, { signal: ctrl.signal });
|
|
167
|
+
clearTimeout(t);
|
|
168
|
+
return res.ok;
|
|
169
|
+
} catch {
|
|
170
|
+
clearTimeout(t);
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function findInstalledApp(appOverride) {
|
|
176
|
+
if (appOverride) return appOverride;
|
|
177
|
+
if (process.env.PANDASTUDIO_BIN) return process.env.PANDASTUDIO_BIN;
|
|
178
|
+
|
|
179
|
+
if (process.platform === "darwin") {
|
|
180
|
+
const macCandidates = [
|
|
181
|
+
"/Applications/PandaStudio.app/Contents/MacOS/PandaStudio",
|
|
182
|
+
path.join(
|
|
183
|
+
os.homedir(),
|
|
184
|
+
"Applications",
|
|
185
|
+
"PandaStudio.app",
|
|
186
|
+
"Contents",
|
|
187
|
+
"MacOS",
|
|
188
|
+
"PandaStudio",
|
|
189
|
+
),
|
|
190
|
+
];
|
|
191
|
+
return macCandidates[0]; // best-effort; spawn() will surface ENOENT clearly
|
|
192
|
+
}
|
|
193
|
+
if (process.platform === "win32") {
|
|
194
|
+
const programFiles = process.env["ProgramFiles"] ?? "C:\\Program Files";
|
|
195
|
+
return path.join(programFiles, "PandaStudio", "PandaStudio.exe");
|
|
196
|
+
}
|
|
197
|
+
// Linux is unsupported by the installer today, but scaffolding it
|
|
198
|
+
// here means the day we publish a .AppImage or .deb the auto-launch
|
|
199
|
+
// path is one PR away.
|
|
200
|
+
return "/opt/PandaStudio/pandastudio";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function autoLaunchAndWait(opts, log) {
|
|
204
|
+
const bin = findInstalledApp(opts.appOverride);
|
|
205
|
+
log(`auto-launching ${bin}`);
|
|
206
|
+
try {
|
|
207
|
+
const child = spawn(bin, [], {
|
|
208
|
+
detached: true,
|
|
209
|
+
stdio: "ignore",
|
|
210
|
+
env: process.env,
|
|
211
|
+
});
|
|
212
|
+
child.unref();
|
|
213
|
+
} catch (err) {
|
|
214
|
+
throw new Error(`failed to launch PandaStudio (${bin}): ${err?.message ?? err}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const deadline = Date.now() + opts.timeoutSeconds * 1000;
|
|
218
|
+
let credsKnown = null;
|
|
219
|
+
while (Date.now() < deadline) {
|
|
220
|
+
// Re-read credentials each iteration — the app writes them only
|
|
221
|
+
// after binding, so the file may not exist yet.
|
|
222
|
+
try {
|
|
223
|
+
credsKnown = await readCredentials(opts);
|
|
224
|
+
if (await probeHealth(credsKnown.port, 2000)) {
|
|
225
|
+
log(
|
|
226
|
+
`ready after ${Math.round((Date.now() - (deadline - opts.timeoutSeconds * 1000)) / 1000)}s`,
|
|
227
|
+
);
|
|
228
|
+
return credsKnown;
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
/* token/port not written yet */
|
|
232
|
+
}
|
|
233
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
234
|
+
}
|
|
235
|
+
throw new Error(
|
|
236
|
+
`PandaStudio did not become ready within ${opts.timeoutSeconds}s. Open the app and enable "Allow local automation" in Settings.`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function emit(parsed, opts) {
|
|
241
|
+
if (opts.json) {
|
|
242
|
+
process.stdout.write(`${JSON.stringify(parsed.body, null, 2)}\n`);
|
|
243
|
+
return parsed.body?.ok === false ? 1 : 0;
|
|
244
|
+
}
|
|
245
|
+
if (parsed.status >= 400) {
|
|
246
|
+
process.stderr.write(`HTTP ${parsed.status}: ${parsed.body?.error ?? "unknown error"}\n`);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
if (parsed.body?.ok === false) {
|
|
250
|
+
process.stderr.write(`error: ${parsed.body.error}\n`);
|
|
251
|
+
if (parsed.body.details) {
|
|
252
|
+
process.stderr.write(`${JSON.stringify(parsed.body.details, null, 2)}\n`);
|
|
253
|
+
}
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
prettyPrint(parsed.body?.data);
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function prettyPrint(data) {
|
|
261
|
+
// Heuristic table-ish output for common shapes. Anything we don't
|
|
262
|
+
// recognise falls back to JSON, which is still readable without
|
|
263
|
+
// blowing the user's terminal up.
|
|
264
|
+
if (data == null) {
|
|
265
|
+
process.stdout.write("ok\n");
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (Array.isArray(data?.commands)) {
|
|
269
|
+
for (const c of data.commands) {
|
|
270
|
+
process.stdout.write(` ${c.command.padEnd(28)} ${c.summary}\n`);
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (Array.isArray(data?.templates)) {
|
|
275
|
+
for (const t of data.templates) {
|
|
276
|
+
process.stdout.write(` ${t.id.padEnd(28)} ${t.name}\n`);
|
|
277
|
+
}
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (Array.isArray(data?.themes)) {
|
|
281
|
+
for (const t of data.themes) {
|
|
282
|
+
process.stdout.write(` ${t.id.padEnd(20)} ${t.name}\n`);
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (Array.isArray(data?.projects)) {
|
|
287
|
+
for (const p of data.projects) {
|
|
288
|
+
process.stdout.write(` ${p.modifiedAt} ${p.name}\n ${p.path}\n`);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (Array.isArray(data?.exports)) {
|
|
293
|
+
for (const e of data.exports) {
|
|
294
|
+
process.stdout.write(` ${new Date(e.exportedAt).toISOString()} ${e.fileName}\n`);
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (data?.job) {
|
|
299
|
+
const j = data.job;
|
|
300
|
+
process.stdout.write(` job ${j.id}\n status: ${j.status}\n`);
|
|
301
|
+
if (j.progress?.message) process.stdout.write(` progress: ${j.progress.message}\n`);
|
|
302
|
+
if (j.error) process.stdout.write(` error: ${j.error}\n`);
|
|
303
|
+
if (j.result) process.stdout.write(` result: ${JSON.stringify(j.result)}\n`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function main() {
|
|
310
|
+
const argv = process.argv.slice(2);
|
|
311
|
+
const opts = parseArgv(argv);
|
|
312
|
+
|
|
313
|
+
if (opts.version) {
|
|
314
|
+
process.stdout.write(`${VERSION}\n`);
|
|
315
|
+
return 0;
|
|
316
|
+
}
|
|
317
|
+
if (opts.help || (opts._.length === 0 && Object.keys(opts.flags).length === 0)) {
|
|
318
|
+
printHelp();
|
|
319
|
+
return 0;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const log = (msg) => {
|
|
323
|
+
if (!opts.json) process.stderr.write(`[pandastudio] ${msg}\n`);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
let command = opts._[0];
|
|
327
|
+
if (!command) {
|
|
328
|
+
process.stderr.write("error: missing command. Try `pandastudio --help`.\n");
|
|
329
|
+
return 2;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Friendly aliases — `pandastudio commands` → `system.list`.
|
|
333
|
+
if (command === "commands") command = "system.list";
|
|
334
|
+
if (command === "status") command = "system.status";
|
|
335
|
+
if (command === "ping") command = "system.ping";
|
|
336
|
+
|
|
337
|
+
if (!command.includes(".")) {
|
|
338
|
+
process.stderr.write(`error: command must be of the form 'verb.noun' (got '${command}')\n`);
|
|
339
|
+
return 2;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let creds;
|
|
343
|
+
try {
|
|
344
|
+
creds = await readCredentials(opts);
|
|
345
|
+
} catch {
|
|
346
|
+
creds = null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let alive = creds ? await probeHealth(creds.port, 1500) : false;
|
|
350
|
+
if (!alive) {
|
|
351
|
+
if (opts.noLaunch) {
|
|
352
|
+
process.stderr.write(
|
|
353
|
+
"error: PandaStudio is not reachable and --no-launch was set.\n Open the app and enable 'Allow local automation' in Settings.\n",
|
|
354
|
+
);
|
|
355
|
+
return 3;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
creds = await autoLaunchAndWait(opts, log);
|
|
359
|
+
alive = true;
|
|
360
|
+
} catch (err) {
|
|
361
|
+
process.stderr.write(`${err?.message ?? err}\n`);
|
|
362
|
+
return 4;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Build the call envelope. Everything after the command is mapped
|
|
367
|
+
// into the `args` object — the value `--slots='{"a":1}'` becomes
|
|
368
|
+
// `args.slots = {a:1}` thanks to parseScalar.
|
|
369
|
+
const callBody = { command, args: { ...opts.flags } };
|
|
370
|
+
const result = await fetchJson(creds.port, "/v1/call", {
|
|
371
|
+
method: "POST",
|
|
372
|
+
headers: {
|
|
373
|
+
"Content-Type": "application/json",
|
|
374
|
+
Authorization: `Bearer ${creds.token}`,
|
|
375
|
+
},
|
|
376
|
+
body: JSON.stringify(callBody),
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
return emit(result, opts);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// IMPORTANT: do NOT call process.exit() here. process.exit forces an
|
|
383
|
+
// immediate teardown that doesn't wait for stdout/stderr to drain — on
|
|
384
|
+
// macOS the OS pipe buffer caps at 64 KiB, so any response larger than
|
|
385
|
+
// that gets silently truncated mid-write (we hit this with export.list
|
|
386
|
+
// returning 144 KB of JSON — only the first 64 KiB reached the user).
|
|
387
|
+
//
|
|
388
|
+
// Setting process.exitCode and letting Node return naturally lets the
|
|
389
|
+
// streams flush properly. The "no top-level work pending" exit happens
|
|
390
|
+
// after stdout's drain queue is empty.
|
|
391
|
+
main()
|
|
392
|
+
.then((code) => {
|
|
393
|
+
process.exitCode = code ?? 0;
|
|
394
|
+
})
|
|
395
|
+
.catch((err) => {
|
|
396
|
+
process.stderr.write(`fatal: ${err?.stack ?? err}\n`);
|
|
397
|
+
process.exitCode = 99;
|
|
398
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@writepanda/cli",
|
|
3
|
+
"version": "1.9.3",
|
|
4
|
+
"description": "Drive PandaStudio (a desktop video editor for YouTube creators) from the command line. Talks to a localhost HTTP API the desktop app exposes.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pandastudio",
|
|
7
|
+
"video-editor",
|
|
8
|
+
"cli",
|
|
9
|
+
"automation",
|
|
10
|
+
"agent",
|
|
11
|
+
"claude-code"
|
|
12
|
+
],
|
|
13
|
+
"author": "Kamala Kannan Sankarraj",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"homepage": "https://www.writepanda.ai/cli",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/kamskans/openscreen.git",
|
|
19
|
+
"directory": "packages/cli"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/kamskans/openscreen/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"pandastudio": "./bin/pandastudio.mjs"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"prepublishOnly": "node ../../scripts/sync-cli-to-package.mjs"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|