@postplus/cli 0.1.30 → 0.1.32

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/README.md CHANGED
@@ -33,17 +33,40 @@ Requires Node.js and npm.
33
33
  ```bash
34
34
  npm install -g @postplus/cli@latest
35
35
  postplus auth login
36
- npx -y skills add PostPlusAI/postplus-skills --global --full-depth --skill '*' --agent claude-code codex cursor github-copilot windsurf trae trae-cn --yes
36
+ npx -y skills add PostPlusAI/postplus-skills --global --full-depth --skill '*' --agent claude-code codex cursor github-copilot windsurf trae trae-cn openclaw hermes-agent --yes
37
37
  postplus skills verify
38
38
  ```
39
39
 
40
+ If you explicitly do not want global skills, run the install from the target
41
+ project directory and omit `--global`:
42
+
43
+ ```bash
44
+ npx -y skills add PostPlusAI/postplus-skills --full-depth --skill '*' --agent claude-code codex cursor github-copilot windsurf trae trae-cn openclaw hermes-agent --yes
45
+ ```
46
+
40
47
  Useful checks:
41
48
 
42
49
  ```bash
43
50
  postplus status
44
- npx -y skills add PostPlusAI/postplus-skills --list --full-depth
51
+ npx -y skills add PostPlusAI/postplus-skills --global --list
52
+ ```
53
+
54
+ ## Local Studio
55
+
56
+ For heavier skills that benefit from a visual workspace, use the CLI-managed
57
+ local Studio:
58
+
59
+ ```bash
60
+ postplus studio init
61
+ postplus studio open
62
+ postplus studio status
45
63
  ```
46
64
 
65
+ Studio creates a visible `PostPlus Studio/` folder in the current working
66
+ directory and opens the bundled local dashboard from the public CLI package.
67
+ Assets, workflow files, activity, and provenance live inside that folder; hidden
68
+ runtime cache and logs stay under `PostPlus Studio/.postplus/`.
69
+
47
70
  ## The Vision
48
71
 
49
72
  PostPlus is built for a world where one marketer, founder, operator, or agency strategist can work with an AI agent as if they had a larger marketing team around them.
package/build/index.js CHANGED
@@ -8,10 +8,11 @@ import { readCurrentCliVersion } from './client-compatibility.js';
8
8
  import { formatDoctorReport, generateDoctorReport } from './doctor.js';
9
9
  import { assertConfigFilePermissions } from './local-state.js';
10
10
  import { readLargeCreditQuoteConfirmationChallenge, resolveLargeCreditQuoteConfirmation, } from './quote-confirmation.js';
11
- import { POSTPLUS_SKILLS_INSTALL_COMMAND, loadPublicSkillCatalog, } from './skill-catalog.js';
11
+ import { POSTPLUS_SKILLS_CURRENT_DIRECTORY_INSTALL_COMMAND, POSTPLUS_SKILLS_INSTALL_COMMAND, loadPublicSkillCatalog, formatPostPlusSkillsInstallCommand, } from './skill-catalog.js';
12
12
  import { formatSkillBaselineVerifyReport, runPostPlusSkillUninstall, runPostPlusSkillUpdate, runPostPlusSkillVerify, } from './skill-management.js';
13
13
  import { formatStatusReport, generateStatusReport } from './status.js';
14
14
  import { refreshUpdateCheckCache, runCliSelfUpdateIfOutdated, } from './update-check.js';
15
+ import { runStudioCommand } from './studio.js';
15
16
  function printAuthHelp() {
16
17
  process.stdout.write(`PostPlus CLI — auth commands
17
18
 
@@ -42,15 +43,19 @@ Usage:
42
43
  postplus doctor [--skill <skill-id>] [--json]
43
44
  postplus quote confirm --json --challenge-file <path>
44
45
  postplus skills verify [--json]
45
- postplus update
46
- postplus uninstall
46
+ postplus studio init|open|status Open bundled Local Studio
47
+ postplus update [--current-directory]
48
+ postplus uninstall [--current-directory]
47
49
  postplus list [--json]
48
50
  postplus status [--skill <skill-id>] [--json]
49
51
  postplus version
50
52
  postplus help
51
53
 
52
54
  Skills:
53
- ${POSTPLUS_SKILLS_INSTALL_COMMAND}
55
+ Global:
56
+ ${POSTPLUS_SKILLS_INSTALL_COMMAND}
57
+ Current directory:
58
+ ${POSTPLUS_SKILLS_CURRENT_DIRECTORY_INSTALL_COMMAND}
54
59
 
55
60
  After first install, run:
56
61
  postplus skills verify
@@ -96,7 +101,8 @@ async function runList(json) {
96
101
  'PostPlus skills',
97
102
  '',
98
103
  `Source: ${catalog.source}`,
99
- `Install: ${catalog.installCommand}`,
104
+ `Install (global): ${catalog.installCommand}`,
105
+ `Install (current directory): ${formatPostPlusSkillsInstallCommand(catalog.source, 'current-directory')}`,
100
106
  '',
101
107
  ];
102
108
  for (const entry of catalog.skills) {
@@ -109,19 +115,25 @@ async function runVersion() {
109
115
  process.stdout.write(`${await readCurrentCliVersion()}\n`);
110
116
  return 0;
111
117
  }
112
- async function runSkillUpdateCommand() {
118
+ async function runSkillUpdateCommand(rest) {
119
+ const options = parseSkillMutationOptions(rest, 'update');
113
120
  const cliSelfUpdate = await runCliSelfUpdateIfOutdated();
114
121
  if (cliSelfUpdate.updateAvailable) {
115
122
  return cliSelfUpdate.exitCode ?? 1;
116
123
  }
117
- const exitCode = await runPostPlusSkillUpdate();
124
+ const exitCode = await runPostPlusSkillUpdate(undefined, {
125
+ scope: options.scope,
126
+ });
118
127
  if (exitCode === 0) {
119
128
  await refreshUpdateCheckCache().catch(() => { });
120
129
  }
121
130
  return exitCode;
122
131
  }
123
- async function runSkillUninstallCommand() {
124
- return runPostPlusSkillUninstall();
132
+ async function runSkillUninstallCommand(rest) {
133
+ const options = parseSkillMutationOptions(rest, 'uninstall');
134
+ return runPostPlusSkillUninstall(undefined, {
135
+ scope: options.scope,
136
+ });
125
137
  }
126
138
  async function runSkillsCommand(rest) {
127
139
  const [subcommand] = rest;
@@ -153,6 +165,12 @@ Usage:
153
165
 
154
166
  Options:
155
167
  --json Output results as JSON
168
+
169
+ Install scope:
170
+ postplus update Update global PostPlus skills
171
+ postplus update --current-directory Update PostPlus skills in the current directory
172
+ postplus uninstall Remove global PostPlus skills
173
+ postplus uninstall --current-directory Remove PostPlus skills from the current directory
156
174
  `);
157
175
  return 0;
158
176
  default:
@@ -233,6 +251,17 @@ function parseDiagnosticOptions(args) {
233
251
  }
234
252
  return options;
235
253
  }
254
+ function parseSkillMutationOptions(args, commandName) {
255
+ let scope = 'global';
256
+ for (const arg of args) {
257
+ if (arg === '--current-directory') {
258
+ scope = 'current-directory';
259
+ continue;
260
+ }
261
+ throw new Error(`Unknown option for ${commandName}: ${arg}`);
262
+ }
263
+ return { scope };
264
+ }
236
265
  async function runAuthLogout(json) {
237
266
  const report = await clearAuthState();
238
267
  if (json) {
@@ -309,6 +338,9 @@ async function main() {
309
338
  else if (helpTopic === 'skills') {
310
339
  await runSkillsCommand(['help']);
311
340
  }
341
+ else if (helpTopic === 'studio') {
342
+ await runStudioCommand(['help']);
343
+ }
312
344
  else {
313
345
  printHelp();
314
346
  }
@@ -324,15 +356,18 @@ async function main() {
324
356
  case 'skills':
325
357
  process.exitCode = await runSkillsCommand(rest);
326
358
  return;
359
+ case 'studio':
360
+ process.exitCode = await runStudioCommand(rest);
361
+ return;
327
362
  case 'install':
328
363
  process.stderr.write(`PostPlus CLI does not install skills directly. Run \`${POSTPLUS_SKILLS_INSTALL_COMMAND}\`.\n`);
329
364
  process.exitCode = 1;
330
365
  return;
331
366
  case 'update':
332
- process.exitCode = await runSkillUpdateCommand();
367
+ process.exitCode = await runSkillUpdateCommand(rest);
333
368
  return;
334
369
  case 'uninstall':
335
- process.exitCode = await runSkillUninstallCommand();
370
+ process.exitCode = await runSkillUninstallCommand(rest);
336
371
  return;
337
372
  case 'list':
338
373
  process.exitCode = await runList(json);
@@ -9,9 +9,12 @@ export const POSTPLUS_SKILLS_AGENT_TARGETS = [
9
9
  'windsurf',
10
10
  'trae',
11
11
  'trae-cn',
12
+ 'openclaw',
13
+ 'hermes-agent',
12
14
  ];
13
15
  const POSTPLUS_SKILLS_AGENT_ARGS = POSTPLUS_SKILLS_AGENT_TARGETS.join(' ');
14
16
  export const POSTPLUS_SKILLS_INSTALL_COMMAND = formatPostPlusSkillsInstallCommand();
17
+ export const POSTPLUS_SKILLS_CURRENT_DIRECTORY_INSTALL_COMMAND = formatPostPlusSkillsInstallCommand(POSTPLUS_SKILLS_REPO, 'current-directory');
15
18
  export const POSTPLUS_SKILLS_LIST_COMMAND = formatPostPlusSkillsListCommand();
16
19
  const POSTPLUS_SKILLS_INDEX_URL = 'https://raw.githubusercontent.com/PostPlusAI/postplus-skills/main/skills/INDEX.md';
17
20
  const POSTPLUS_SKILLS_CATALOG_URL = 'https://raw.githubusercontent.com/PostPlusAI/postplus-skills/main/skills/catalog.json';
@@ -54,8 +57,9 @@ export function resolvePostPlusSkillsSource(env = process.env) {
54
57
  export function resolvePostPlusSkillsCatalogUrl(env = process.env) {
55
58
  return env[POSTPLUS_SKILLS_CATALOG_URL_ENV]?.trim() || POSTPLUS_SKILLS_CATALOG_URL;
56
59
  }
57
- export function formatPostPlusSkillsInstallCommand(source = POSTPLUS_SKILLS_REPO) {
58
- return `npx -y skills add ${source} --global --full-depth --skill '*' --agent ${POSTPLUS_SKILLS_AGENT_ARGS} --yes`;
60
+ export function formatPostPlusSkillsInstallCommand(source = POSTPLUS_SKILLS_REPO, scope = 'global') {
61
+ const scopeArgs = scope === 'global' ? ' --global' : '';
62
+ return `npx -y skills add ${source}${scopeArgs} --full-depth --skill '*' --agent ${POSTPLUS_SKILLS_AGENT_ARGS} --yes`;
59
63
  }
60
64
  export function formatPostPlusSkillsListCommand(source = POSTPLUS_SKILLS_REPO) {
61
65
  return `npx -y skills add ${source} --list --full-depth`;
@@ -3,9 +3,12 @@ import { runCommand, runInteractiveCommand } from './command-runner.js';
3
3
  import { clearManagedSkillBaseline, readManagedSkillBaseline, writeManagedSkillBaseline, } from './local-state.js';
4
4
  import { POSTPLUS_SKILLS_AGENT_TARGETS, formatPostPlusSkillsInstallCommand, resolvePostPlusSkillsSource, loadPublicSkillCatalog, } from './skill-catalog.js';
5
5
  const NPX_SKILLS = ['-y', 'skills'];
6
+ const DEFAULT_SKILL_MUTATION_OPTIONS = {
7
+ scope: 'global',
8
+ };
6
9
  export async function runPostPlusSkillUpdate(dependencies = {
7
10
  runInteractiveCommand,
8
- }) {
11
+ }, options = DEFAULT_SKILL_MUTATION_OPTIONS) {
9
12
  const catalog = await loadPublicSkillCatalog();
10
13
  const skillNames = catalog.skills.map((skill) => skill.skillId);
11
14
  const baseline = await readManagedSkillBaseline();
@@ -13,12 +16,12 @@ export async function runPostPlusSkillUpdate(dependencies = {
13
16
  if (skillNames.length === 0) {
14
17
  throw new Error('PostPlus public skill catalog has no released skills.');
15
18
  }
16
- const updateExitCode = await dependencies.runInteractiveCommand('npx', buildPostPlusSkillUpdateArgs(skillNames));
19
+ const updateExitCode = await dependencies.runInteractiveCommand('npx', buildPostPlusSkillUpdateArgs(skillNames, options.scope));
17
20
  if (updateExitCode !== 0) {
18
21
  return updateExitCode;
19
22
  }
20
23
  if (retiredSkillNames.length > 0) {
21
- const removeExitCode = await dependencies.runInteractiveCommand('npx', buildPostPlusSkillUninstallArgs(retiredSkillNames));
24
+ const removeExitCode = await dependencies.runInteractiveCommand('npx', buildPostPlusSkillUninstallArgs(retiredSkillNames, options.scope));
22
25
  if (removeExitCode !== 0) {
23
26
  return removeExitCode;
24
27
  }
@@ -32,7 +35,7 @@ export async function runPostPlusSkillUpdate(dependencies = {
32
35
  }
33
36
  export async function runPostPlusSkillUninstall(dependencies = {
34
37
  runInteractiveCommand,
35
- }) {
38
+ }, options = DEFAULT_SKILL_MUTATION_OPTIONS) {
36
39
  const catalog = await loadPublicSkillCatalog();
37
40
  const skillNames = catalog.skills.map((skill) => skill.skillId);
38
41
  const baseline = await readManagedSkillBaseline();
@@ -40,7 +43,7 @@ export async function runPostPlusSkillUninstall(dependencies = {
40
43
  if (allKnownSkillNames.length === 0) {
41
44
  throw new Error('PostPlus public skill catalog has no released skills.');
42
45
  }
43
- const exitCode = await dependencies.runInteractiveCommand('npx', buildPostPlusSkillUninstallArgs(allKnownSkillNames));
46
+ const exitCode = await dependencies.runInteractiveCommand('npx', buildPostPlusSkillUninstallArgs(allKnownSkillNames, options.scope));
44
47
  if (exitCode === 0) {
45
48
  await clearManagedSkillBaseline();
46
49
  }
@@ -150,13 +153,13 @@ export function formatSkillInstallStatusReport(report) {
150
153
  lines.push(` Managed baseline: ${report.managedSkillsReleaseId ?? 'none'}`);
151
154
  lines.push(` Scope: ${report.scopes.length > 0 ? report.scopes.join(', ') : 'none detected'}`);
152
155
  if (report.retiredManagedSkills.length > 0) {
153
- lines.push(` Retired managed skills: ${formatSkillList(report.retiredManagedSkills, 8)}`, ` Cleanup: ${report.updateCommand}`);
156
+ lines.push(` Retired managed skills: ${formatSkillList(report.retiredManagedSkills, 8)}`, ` Cleanup (global): ${report.updateCommand}`, ` Cleanup (current directory): ${formatPostPlusSkillUpdateCommand('current-directory')}`);
154
157
  }
155
158
  if (report.missingSkills.length > 0) {
156
- lines.push(` Missing: ${formatSkillList(report.missingSkills, 8)}`, ` Fix: ${report.installCommand}`);
159
+ lines.push(` Missing: ${formatSkillList(report.missingSkills, 8)}`, ` Fix (global): ${report.installCommand}`, ` Fix (current directory): ${formatPostPlusSkillsInstallCommand(report.source, 'current-directory')}`);
157
160
  }
158
161
  else {
159
- lines.push(` Update: ${report.updateCommand}`);
162
+ lines.push(` Update (global): ${report.updateCommand}`, ` Update (current directory): ${formatPostPlusSkillUpdateCommand('current-directory')}`);
160
163
  }
161
164
  return lines.join('\n');
162
165
  }
@@ -181,11 +184,11 @@ export function formatSkillBaselineVerifyReport(report) {
181
184
  lines.push(' Verified baseline: unchanged');
182
185
  }
183
186
  if (report.missingSkills.length > 0) {
184
- lines.push(` Missing: ${formatSkillList(report.missingSkills, 8)}`, ` Fix: ${report.installCommand}`);
187
+ lines.push(` Missing: ${formatSkillList(report.missingSkills, 8)}`, ` Fix (global): ${report.installCommand}`, ` Fix (current directory): ${formatPostPlusSkillsInstallCommand(report.source, 'current-directory')}`);
185
188
  }
186
189
  return lines.join('\n');
187
190
  }
188
- export function buildPostPlusSkillUpdateArgs(skillNames) {
191
+ export function buildPostPlusSkillUpdateArgs(skillNames, scope = 'global') {
189
192
  if (skillNames.length === 0) {
190
193
  throw new Error('PostPlus public skill catalog has no released skills.');
191
194
  }
@@ -194,7 +197,7 @@ export function buildPostPlusSkillUpdateArgs(skillNames) {
194
197
  ...NPX_SKILLS,
195
198
  'add',
196
199
  skillsSource,
197
- '--global',
200
+ ...buildSkillScopeArgs(scope),
198
201
  '--full-depth',
199
202
  '--skill',
200
203
  '*',
@@ -203,22 +206,29 @@ export function buildPostPlusSkillUpdateArgs(skillNames) {
203
206
  '--yes',
204
207
  ];
205
208
  }
206
- export function buildPostPlusSkillUninstallArgs(skillNames) {
209
+ export function buildPostPlusSkillUninstallArgs(skillNames, scope = 'global') {
207
210
  return [
208
211
  ...NPX_SKILLS,
209
212
  'remove',
210
213
  ...skillNames,
211
- '--global',
214
+ ...buildSkillScopeArgs(scope),
212
215
  '--agent',
213
216
  ...POSTPLUS_SKILLS_AGENT_TARGETS,
214
217
  '--yes',
215
218
  ];
216
219
  }
217
- export function formatPostPlusSkillUpdateCommand() {
218
- return 'postplus update';
220
+ export function formatPostPlusSkillUpdateCommand(scope = 'global') {
221
+ return scope === 'global'
222
+ ? 'postplus update'
223
+ : 'postplus update --current-directory';
224
+ }
225
+ export function formatPostPlusSkillUninstallCommand(scope = 'global') {
226
+ return scope === 'global'
227
+ ? 'postplus uninstall'
228
+ : 'postplus uninstall --current-directory';
219
229
  }
220
- export function formatPostPlusSkillUninstallCommand() {
221
- return 'postplus uninstall';
230
+ function buildSkillScopeArgs(scope) {
231
+ return scope === 'global' ? ['--global'] : [];
222
232
  }
223
233
  function mergeSkillNames(left, right) {
224
234
  return [...new Set([...left, ...right])].sort((a, b) => a.localeCompare(b));
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ import http from 'node:http';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { basename, join, resolve } from 'node:path';
6
+ export async function startStudioServer(argv = process.argv.slice(2)) {
7
+ const options = parseOptions(argv);
8
+ const server = http.createServer(async (request, response) => {
9
+ try {
10
+ await handleRequest(request, response, options);
11
+ }
12
+ catch (error) {
13
+ sendJson(response, 500, {
14
+ error: error instanceof Error ? error.message : String(error),
15
+ ok: false,
16
+ });
17
+ }
18
+ });
19
+ await new Promise((resolveListen, rejectListen) => {
20
+ server.once('error', rejectListen);
21
+ server.listen(options.port, options.host, () => {
22
+ server.off('error', rejectListen);
23
+ resolveListen();
24
+ });
25
+ });
26
+ process.on('SIGTERM', () => {
27
+ server.close(() => process.exit(0));
28
+ });
29
+ process.on('SIGINT', () => {
30
+ server.close(() => process.exit(0));
31
+ });
32
+ return server;
33
+ }
34
+ function parseOptions(argv) {
35
+ const options = {};
36
+ for (let index = 0; index < argv.length; index += 1) {
37
+ const arg = argv[index];
38
+ if (arg === '--studio-root') {
39
+ const value = readOptionValue(argv, index, arg);
40
+ options.studioRoot = resolve(value);
41
+ index += 1;
42
+ continue;
43
+ }
44
+ if (arg === '--host') {
45
+ options.host = readOptionValue(argv, index, arg);
46
+ index += 1;
47
+ continue;
48
+ }
49
+ if (arg === '--port') {
50
+ const value = Number(readOptionValue(argv, index, arg));
51
+ if (!Number.isInteger(value) || value <= 0) {
52
+ throw new Error('--port must be a positive integer.');
53
+ }
54
+ options.port = value;
55
+ index += 1;
56
+ continue;
57
+ }
58
+ if (arg === '--help' || arg === '-h') {
59
+ process.stdout.write(`Usage:
60
+ node build/studio-server.js --studio-root <dir> --host 127.0.0.1 --port 3978
61
+ `);
62
+ process.exit(0);
63
+ }
64
+ throw new Error(`Unknown Studio server option: ${arg}`);
65
+ }
66
+ if (!options.studioRoot) {
67
+ throw new Error('Studio server requires --studio-root.');
68
+ }
69
+ return {
70
+ host: options.host ?? '127.0.0.1',
71
+ port: options.port ?? 3978,
72
+ studioRoot: options.studioRoot,
73
+ };
74
+ }
75
+ function readOptionValue(argv, index, name) {
76
+ const value = argv[index + 1];
77
+ if (!value || value.startsWith('--')) {
78
+ throw new Error(`Missing value for ${name}.`);
79
+ }
80
+ return value;
81
+ }
82
+ async function handleRequest(request, response, options) {
83
+ const url = new URL(request.url ?? '/', `http://${options.host}`);
84
+ if (request.method !== 'GET') {
85
+ sendJson(response, 405, { error: 'Method not allowed.', ok: false });
86
+ return;
87
+ }
88
+ if (url.pathname === '/api/health') {
89
+ sendJson(response, 200, { ok: true });
90
+ return;
91
+ }
92
+ if (url.pathname === '/api/project') {
93
+ sendJson(response, 200, await readStudioSnapshot(options.studioRoot));
94
+ return;
95
+ }
96
+ if (url.pathname === '/' ||
97
+ url.pathname === '/dashboard' ||
98
+ url.pathname === '/dashboard/') {
99
+ sendHtml(response, renderDashboardHtml());
100
+ return;
101
+ }
102
+ sendJson(response, 404, { error: 'Not found.', ok: false });
103
+ }
104
+ async function readStudioSnapshot(studioRoot) {
105
+ return {
106
+ activity: await readJsonLines(join(studioRoot, 'activity.jsonl')),
107
+ manifest: await readJsonFile(join(studioRoot, 'manifest.json')),
108
+ pipeline: await readJsonFile(join(studioRoot, 'pipeline.json')),
109
+ project: await readJsonFile(join(studioRoot, 'project.json')),
110
+ provenance: await readJsonLines(join(studioRoot, 'provenance.jsonl')),
111
+ studio: await readJsonFile(join(studioRoot, 'studio.json')),
112
+ studioRoot,
113
+ };
114
+ }
115
+ async function readJsonFile(path) {
116
+ if (!existsSync(path)) {
117
+ return null;
118
+ }
119
+ return JSON.parse(await readFile(path, 'utf8'));
120
+ }
121
+ async function readJsonLines(path) {
122
+ if (!existsSync(path)) {
123
+ return [];
124
+ }
125
+ const lines = (await readFile(path, 'utf8'))
126
+ .split('\n')
127
+ .map((line) => line.trim())
128
+ .filter(Boolean);
129
+ return lines.slice(-50).map((line) => JSON.parse(line));
130
+ }
131
+ function sendJson(response, statusCode, payload) {
132
+ response.writeHead(statusCode, {
133
+ 'cache-control': 'no-store',
134
+ 'content-type': 'application/json; charset=utf-8',
135
+ });
136
+ response.end(`${JSON.stringify(payload, null, 2)}\n`);
137
+ }
138
+ function sendHtml(response, html) {
139
+ response.writeHead(200, {
140
+ 'cache-control': 'no-store',
141
+ 'content-type': 'text/html; charset=utf-8',
142
+ });
143
+ response.end(html);
144
+ }
145
+ function renderDashboardHtml() {
146
+ return `<!doctype html>
147
+ <html lang="en">
148
+ <head>
149
+ <meta charset="utf-8" />
150
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
151
+ <title>PostPlus Studio</title>
152
+ <style>
153
+ :root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
154
+ body { margin: 0; background: #f7f7f4; color: #1f2933; }
155
+ header { border-bottom: 1px solid #d6d8dc; background: #ffffff; padding: 20px 28px; }
156
+ main { display: grid; gap: 16px; grid-template-columns: 280px minmax(0, 1fr); padding: 20px 28px 28px; }
157
+ h1 { font-size: 22px; line-height: 1.2; margin: 0 0 6px; }
158
+ h2 { font-size: 13px; letter-spacing: 0; line-height: 1.25; margin: 0 0 10px; text-transform: uppercase; color: #5b6472; }
159
+ p { margin: 0; }
160
+ .subtle { color: #627083; font-size: 13px; }
161
+ .panel { background: #ffffff; border: 1px solid #d8dce2; border-radius: 8px; padding: 14px; min-width: 0; }
162
+ .stack { display: grid; gap: 12px; }
163
+ .grid { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
164
+ .row { align-items: center; display: flex; justify-content: space-between; gap: 12px; border-top: 1px solid #edf0f2; padding-top: 10px; margin-top: 10px; }
165
+ .label { color: #5b6472; font-size: 12px; }
166
+ .value { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
167
+ .step { border: 1px solid #dfe3e8; border-radius: 6px; padding: 10px; background: #fbfbfa; }
168
+ .step-title { font-size: 13px; font-weight: 700; }
169
+ .status { color: #0f766e; font-size: 12px; margin-top: 4px; }
170
+ pre { background: #111827; border-radius: 8px; color: #e5e7eb; font-size: 12px; line-height: 1.45; margin: 0; max-height: 420px; overflow: auto; padding: 12px; white-space: pre-wrap; }
171
+ @media (max-width: 840px) { main { grid-template-columns: 1fr; padding: 16px; } header { padding: 18px 16px; } .grid { grid-template-columns: 1fr; } }
172
+ </style>
173
+ </head>
174
+ <body>
175
+ <header>
176
+ <h1>PostPlus Studio</h1>
177
+ <p class="subtle" id="studio-root">Loading workspace...</p>
178
+ </header>
179
+ <main>
180
+ <section class="stack">
181
+ <div class="panel">
182
+ <h2>Project</h2>
183
+ <div class="row"><span class="label">Name</span><span class="value" id="project-name">-</span></div>
184
+ <div class="row"><span class="label">Status</span><span class="value" id="project-status">-</span></div>
185
+ </div>
186
+ <div class="panel">
187
+ <h2>Pipeline</h2>
188
+ <div id="pipeline-steps" class="stack"></div>
189
+ </div>
190
+ </section>
191
+ <section class="stack">
192
+ <div class="grid">
193
+ <div class="panel"><h2>Assets</h2><p class="value" id="asset-count">-</p></div>
194
+ <div class="panel"><h2>Activity</h2><p class="value" id="activity-count">-</p></div>
195
+ <div class="panel"><h2>Provenance</h2><p class="value" id="provenance-count">-</p></div>
196
+ </div>
197
+ <div class="panel">
198
+ <h2>Workspace JSON</h2>
199
+ <pre id="snapshot">{}</pre>
200
+ </div>
201
+ </section>
202
+ </main>
203
+ <script>
204
+ const text = (id, value) => { document.getElementById(id).textContent = value ?? '-'; };
205
+ const render = async () => {
206
+ const response = await fetch('/api/project');
207
+ const data = await response.json();
208
+ const project = data.project || {};
209
+ const pipeline = data.pipeline || {};
210
+ const manifest = data.manifest || {};
211
+ text('studio-root', data.studioRoot || '');
212
+ text('project-name', project.name || project.project_id || 'PostPlus Studio');
213
+ text('project-status', project.status || 'active');
214
+ text('asset-count', Array.isArray(manifest.assets) ? String(manifest.assets.length) : '0');
215
+ text('activity-count', Array.isArray(data.activity) ? String(data.activity.length) : '0');
216
+ text('provenance-count', Array.isArray(data.provenance) ? String(data.provenance.length) : '0');
217
+ const steps = Array.isArray(pipeline.steps) ? pipeline.steps : [];
218
+ document.getElementById('pipeline-steps').innerHTML = steps.map((step) =>
219
+ '<div class="step"><div class="step-title">' + escapeHtml(step.name || step.id || 'Step') + '</div><div class="status">' + escapeHtml(step.status || 'pending') + '</div></div>'
220
+ ).join('') || '<p class="subtle">No pipeline steps yet.</p>';
221
+ document.getElementById('snapshot').textContent = JSON.stringify(data, null, 2);
222
+ };
223
+ const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
224
+ render().catch((error) => {
225
+ document.getElementById('snapshot').textContent = error.stack || error.message || String(error);
226
+ });
227
+ </script>
228
+ </body>
229
+ </html>`;
230
+ }
231
+ if (process.argv[1] && basename(process.argv[1])?.startsWith('studio-server')) {
232
+ startStudioServer().catch((error) => {
233
+ process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`);
234
+ process.exit(1);
235
+ });
236
+ }
@@ -0,0 +1,407 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { access, mkdir, readFile, writeFile, } from 'node:fs/promises';
3
+ import { closeSync, constants as fsConstants, openSync } from 'node:fs';
4
+ import net from 'node:net';
5
+ import { platform } from 'node:os';
6
+ import { basename, dirname, join, resolve, } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ export const POSTPLUS_STUDIO_DIRECTORY_NAME = 'PostPlus Studio';
9
+ const DEFAULT_STUDIO_ID = 'postplus-studio';
10
+ export async function runStudioCommand(args) {
11
+ const [subcommand, ...rest] = args;
12
+ if (!subcommand || ['help', '--help', '-h'].includes(subcommand)) {
13
+ printStudioHelp();
14
+ return 0;
15
+ }
16
+ if (rest.some((arg) => ['help', '--help', '-h'].includes(arg))) {
17
+ printStudioHelp();
18
+ return 0;
19
+ }
20
+ const options = parseStudioOptions(rest);
21
+ switch (subcommand) {
22
+ case 'init': {
23
+ const result = await initializeStudio(options.workdir);
24
+ writeOutput(options.json, result, formatStudioInitReport(result));
25
+ return 0;
26
+ }
27
+ case 'status': {
28
+ const report = await getStudioStatus(options.workdir);
29
+ writeOutput(options.json, report, formatStudioStatusReport(report));
30
+ return report.ok ? 0 : 1;
31
+ }
32
+ case 'open': {
33
+ const result = await openStudio(options);
34
+ writeOutput(options.json, result, formatStudioOpenReport(result));
35
+ return 0;
36
+ }
37
+ default:
38
+ process.stderr.write(`Unknown studio command: ${subcommand}\n\n`);
39
+ printStudioHelp();
40
+ return 1;
41
+ }
42
+ }
43
+ function printStudioHelp() {
44
+ process.stdout.write(`PostPlus CLI — studio commands
45
+
46
+ Usage:
47
+ postplus studio init [--workdir <dir>] [--json]
48
+ postplus studio open [--workdir <dir>] [--port 3978] [--no-browser] [--json]
49
+ postplus studio status [--workdir <dir>] [--json]
50
+
51
+ Local Studio is a public local workspace included in the PostPlus CLI package.
52
+ Studio creates a visible "PostPlus Studio" folder inside the selected working directory and opens the bundled local dashboard.
53
+ `);
54
+ }
55
+ function parseStudioOptions(args) {
56
+ const options = {
57
+ browser: true,
58
+ json: false,
59
+ port: 3978,
60
+ workdir: process.cwd(),
61
+ };
62
+ for (let index = 0; index < args.length; index += 1) {
63
+ const arg = args[index];
64
+ if (arg === '--json') {
65
+ options.json = true;
66
+ continue;
67
+ }
68
+ if (arg === '--no-browser') {
69
+ options.browser = false;
70
+ continue;
71
+ }
72
+ if (arg === '--workdir') {
73
+ const value = args[index + 1];
74
+ if (!value || value.startsWith('--')) {
75
+ throw new Error('Missing value for --workdir.');
76
+ }
77
+ options.workdir = resolve(value);
78
+ index += 1;
79
+ continue;
80
+ }
81
+ if (arg === '--port') {
82
+ const value = args[index + 1];
83
+ if (!value || value.startsWith('--')) {
84
+ throw new Error('Missing value for --port.');
85
+ }
86
+ options.port = Number(value);
87
+ if (!Number.isInteger(options.port) || options.port <= 0) {
88
+ throw new Error('--port must be a positive integer.');
89
+ }
90
+ index += 1;
91
+ continue;
92
+ }
93
+ throw new Error(`Unknown option for studio command: ${arg}`);
94
+ }
95
+ return options;
96
+ }
97
+ export function resolveStudioRoot(workdir) {
98
+ const root = resolve(workdir);
99
+ return basename(root) === POSTPLUS_STUDIO_DIRECTORY_NAME
100
+ ? root
101
+ : join(root, POSTPLUS_STUDIO_DIRECTORY_NAME);
102
+ }
103
+ async function initializeStudio(workdir) {
104
+ const studioRoot = resolveStudioRoot(workdir);
105
+ const createdAt = new Date().toISOString();
106
+ await mkdir(studioRoot, { recursive: true });
107
+ for (const dir of [
108
+ 'workflows',
109
+ 'assets/texts',
110
+ 'assets/images',
111
+ 'assets/audio',
112
+ 'assets/videos',
113
+ 'assets/html',
114
+ 'assets/references',
115
+ 'data',
116
+ 'exports',
117
+ '.postplus/locks',
118
+ '.postplus/cache',
119
+ '.postplus/temp',
120
+ '.postplus/runs',
121
+ '.postplus/provider-responses',
122
+ '.postplus/quote-confirmations',
123
+ '.postplus/logs',
124
+ ]) {
125
+ await mkdir(join(studioRoot, dir), { recursive: true });
126
+ }
127
+ await writeJsonIfMissing(join(studioRoot, 'studio.json'), {
128
+ schemaVersion: 1,
129
+ studio_id: DEFAULT_STUDIO_ID,
130
+ name: 'PostPlus Studio',
131
+ root_name: POSTPLUS_STUDIO_DIRECTORY_NAME,
132
+ created_at: createdAt,
133
+ updated_at: createdAt,
134
+ });
135
+ await writeJsonIfMissing(join(studioRoot, 'project.json'), {
136
+ project_id: DEFAULT_STUDIO_ID,
137
+ name: 'PostPlus Studio',
138
+ goal: 'Run PostPlus workflows in a local visual Studio workspace.',
139
+ status: 'active',
140
+ created_at: createdAt,
141
+ updated_at: createdAt,
142
+ });
143
+ await writeJsonIfMissing(join(studioRoot, 'manifest.json'), { assets: [] });
144
+ await writeJsonIfMissing(join(studioRoot, 'pipeline.json'), {
145
+ pipeline_id: 'ad-video-pipeline',
146
+ steps: [
147
+ {
148
+ id: 'brief',
149
+ name: 'Brief',
150
+ status: 'pending',
151
+ updated_at: createdAt,
152
+ },
153
+ {
154
+ id: 'script',
155
+ name: 'Script',
156
+ status: 'pending',
157
+ updated_at: createdAt,
158
+ },
159
+ {
160
+ id: 'storyboard',
161
+ name: 'Storyboard',
162
+ status: 'pending',
163
+ updated_at: createdAt,
164
+ },
165
+ ],
166
+ });
167
+ await writeJsonIfMissing(join(studioRoot, 'context.json'), {
168
+ active_project: DEFAULT_STUDIO_ID,
169
+ active_pipeline: 'ad-video-pipeline',
170
+ active_step: 'brief',
171
+ selected_asset_id: null,
172
+ selected_block_id: null,
173
+ selected_version: null,
174
+ visible_panel: 'dashboard',
175
+ updated_at: createdAt,
176
+ });
177
+ await writeTextIfMissing(join(studioRoot, 'provenance.jsonl'), '');
178
+ await writeTextIfMissing(join(studioRoot, 'activity.jsonl'), '');
179
+ return {
180
+ ok: true,
181
+ studioRoot,
182
+ };
183
+ }
184
+ async function getStudioStatus(workdir) {
185
+ const studioRoot = resolveStudioRoot(workdir);
186
+ const exists = await pathExists(studioRoot);
187
+ const files = {
188
+ manifest: await pathExists(join(studioRoot, 'manifest.json')),
189
+ pipeline: await pathExists(join(studioRoot, 'pipeline.json')),
190
+ studio: await pathExists(join(studioRoot, 'studio.json')),
191
+ };
192
+ return {
193
+ exists,
194
+ files,
195
+ ok: exists && files.studio && files.manifest && files.pipeline,
196
+ studioRoot,
197
+ };
198
+ }
199
+ async function openStudio(options) {
200
+ const { studioRoot } = await initializeStudio(options.workdir);
201
+ const parsed = await launchBundledStudioServer(studioRoot, options.port);
202
+ if (options.browser) {
203
+ openSystemBrowser(parsed.url);
204
+ }
205
+ return {
206
+ ok: true,
207
+ studioRoot,
208
+ ...parsed,
209
+ };
210
+ }
211
+ async function launchBundledStudioServer(studioRoot, startPort) {
212
+ const existing = await readLiveStudioServerState(studioRoot);
213
+ if (existing) {
214
+ return {
215
+ logPath: existing.logPath,
216
+ pid: existing.pid,
217
+ reused: true,
218
+ url: existing.dashboardUrl,
219
+ };
220
+ }
221
+ const host = '127.0.0.1';
222
+ const port = await findAvailablePort(startPort, host);
223
+ const baseUrl = `http://${host}:${port}`;
224
+ const dashboardUrl = `${baseUrl}/dashboard/`;
225
+ const logDir = join(studioRoot, '.postplus', 'logs');
226
+ await mkdir(logDir, { recursive: true });
227
+ const logPath = join(logDir, 'studio-server.log');
228
+ const logFd = openSync(logPath, 'a');
229
+ const serverEntrypoint = resolveBundledStudioServerEntrypoint();
230
+ const packageRoot = resolveCliPackageRoot();
231
+ const child = spawn(process.execPath, [
232
+ ...buildNodeLoaderArgs(serverEntrypoint),
233
+ serverEntrypoint,
234
+ '--studio-root',
235
+ studioRoot,
236
+ '--host',
237
+ host,
238
+ '--port',
239
+ String(port),
240
+ ], {
241
+ cwd: packageRoot,
242
+ detached: true,
243
+ stdio: ['ignore', logFd, logFd],
244
+ });
245
+ child.unref();
246
+ closeSync(logFd);
247
+ try {
248
+ await waitForStudioServer(baseUrl, logPath);
249
+ }
250
+ catch (error) {
251
+ if (child.pid) {
252
+ try {
253
+ process.kill(child.pid);
254
+ }
255
+ catch {
256
+ // The process already exited; the readiness error below carries the failure.
257
+ }
258
+ }
259
+ throw error;
260
+ }
261
+ const state = {
262
+ baseUrl,
263
+ dashboardUrl,
264
+ logPath,
265
+ pid: child.pid,
266
+ startedAt: new Date().toISOString(),
267
+ studioRoot,
268
+ };
269
+ await writeJson(getStudioServerStatePath(studioRoot), state);
270
+ return {
271
+ logPath,
272
+ pid: child.pid,
273
+ reused: false,
274
+ url: dashboardUrl,
275
+ };
276
+ }
277
+ function resolveBundledStudioServerEntrypoint() {
278
+ const currentModulePath = fileURLToPath(import.meta.url);
279
+ const extension = currentModulePath.endsWith('.ts') ? '.ts' : '.js';
280
+ return join(dirname(currentModulePath), `studio-server${extension}`);
281
+ }
282
+ function resolveCliPackageRoot() {
283
+ return dirname(dirname(fileURLToPath(import.meta.url)));
284
+ }
285
+ function buildNodeLoaderArgs(entrypoint) {
286
+ return entrypoint.endsWith('.ts') ? ['--import', 'tsx'] : [];
287
+ }
288
+ async function readLiveStudioServerState(studioRoot) {
289
+ const state = await readJsonIfExists(getStudioServerStatePath(studioRoot));
290
+ if (!state?.baseUrl || !state.dashboardUrl) {
291
+ return null;
292
+ }
293
+ if (await canFetchStudioServer(state.baseUrl)) {
294
+ return state;
295
+ }
296
+ return null;
297
+ }
298
+ function getStudioServerStatePath(studioRoot) {
299
+ return join(studioRoot, '.postplus', 'studio-server.json');
300
+ }
301
+ async function readJsonIfExists(path) {
302
+ if (!(await pathExists(path))) {
303
+ return null;
304
+ }
305
+ try {
306
+ return JSON.parse(await readFile(path, 'utf8'));
307
+ }
308
+ catch {
309
+ return null;
310
+ }
311
+ }
312
+ async function waitForStudioServer(baseUrl, logPath) {
313
+ const startedAt = Date.now();
314
+ while (Date.now() - startedAt < 5000) {
315
+ if (await canFetchStudioServer(baseUrl)) {
316
+ return;
317
+ }
318
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 150));
319
+ }
320
+ throw new Error(`Studio server did not become ready at ${baseUrl}. See log: ${logPath}`);
321
+ }
322
+ async function canFetchStudioServer(baseUrl) {
323
+ try {
324
+ const response = await fetch(`${baseUrl.replace(/\/$/u, '')}/api/health`, {
325
+ signal: AbortSignal.timeout(1200),
326
+ });
327
+ return response.ok;
328
+ }
329
+ catch {
330
+ return false;
331
+ }
332
+ }
333
+ async function findAvailablePort(startPort, host) {
334
+ for (let port = startPort; port < startPort + 50; port += 1) {
335
+ if (await isPortAvailable(port, host)) {
336
+ return port;
337
+ }
338
+ }
339
+ throw new Error(`No available Studio port found from ${startPort} to ${startPort + 49}.`);
340
+ }
341
+ function isPortAvailable(port, host) {
342
+ return new Promise((resolveAvailable) => {
343
+ const server = net.createServer();
344
+ server.once('error', () => resolveAvailable(false));
345
+ server.once('listening', () => {
346
+ server.close(() => resolveAvailable(true));
347
+ });
348
+ server.listen(port, host);
349
+ });
350
+ }
351
+ async function pathExists(path) {
352
+ try {
353
+ await access(path, fsConstants.F_OK);
354
+ return true;
355
+ }
356
+ catch {
357
+ return false;
358
+ }
359
+ }
360
+ async function writeJsonIfMissing(path, value) {
361
+ if (await pathExists(path)) {
362
+ return;
363
+ }
364
+ await writeJson(path, value);
365
+ }
366
+ async function writeTextIfMissing(path, value) {
367
+ if (await pathExists(path)) {
368
+ return;
369
+ }
370
+ await mkdir(dirname(path), { recursive: true });
371
+ await writeFile(path, value, 'utf8');
372
+ }
373
+ async function writeJson(path, value) {
374
+ await mkdir(dirname(path), { recursive: true });
375
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
376
+ }
377
+ function openSystemBrowser(url) {
378
+ const command = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'cmd' : 'xdg-open';
379
+ const args = platform() === 'win32' ? ['/c', 'start', '', url] : [url];
380
+ const child = spawn(command, args, {
381
+ detached: true,
382
+ stdio: 'ignore',
383
+ });
384
+ child.unref();
385
+ }
386
+ function writeOutput(json, value, text) {
387
+ process.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : text);
388
+ }
389
+ function formatStudioInitReport(result) {
390
+ return `PostPlus Studio initialized\n\nStudio root: ${result.studioRoot}\n`;
391
+ }
392
+ function formatStudioOpenReport(result) {
393
+ return `PostPlus Studio is running\n\nStudio root: ${result.studioRoot}\nURL: ${result.url}\n`;
394
+ }
395
+ function formatStudioStatusReport(report) {
396
+ return [
397
+ 'PostPlus Studio status',
398
+ '',
399
+ `Studio root: ${report.studioRoot}`,
400
+ `Exists: ${report.exists ? 'yes' : 'no'}`,
401
+ `studio.json: ${report.files.studio ? 'yes' : 'no'}`,
402
+ `manifest.json: ${report.files.manifest ? 'yes' : 'no'}`,
403
+ `pipeline.json: ${report.files.pipeline ? 'yes' : 'no'}`,
404
+ `Status: ${report.ok ? 'ready' : 'not ready'}`,
405
+ '',
406
+ ].join('\n');
407
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postplus/cli",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
5
5
  "type": "module",
6
6
  "description": "PostPlus CLI for PostPlus Cloud auth, status, and diagnostics.",
@@ -22,6 +22,8 @@
22
22
  "build/skill-catalog.js",
23
23
  "build/skill-management.js",
24
24
  "build/status.js",
25
+ "build/studio.js",
26
+ "build/studio-server.js",
25
27
  "build/subscription-status.js",
26
28
  "build/update-check.js",
27
29
  "LICENSE",