@hienlh/ppm 0.9.62 → 0.9.64

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.63] - 2026-04-08
4
+
5
+ ### Fixed
6
+ - **Cloud auto-link**: `ppm cloud login` now auto-links device (no separate `ppm cloud link` needed). `ppm cloud logout` auto-unlinks.
7
+ - **Supervisor cloud monitor**: Supervisor periodically checks cloud-device.json and auto-connects/disconnects/reconnects WS as needed. Fixes "stuck offline" after upgrade or file loss.
8
+ - **Stale tests**: Aligned usage-cache and chat-routes tests with current API response shapes.
9
+
3
10
  ## [0.9.62] - 2026-04-08
4
11
 
5
12
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.9.62",
3
+ "version": "0.9.64",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  "build": "bun run build:web && bun build src/index.ts --compile --outfile dist/ppm",
19
19
  "start": "bun run src/index.ts start",
20
20
  "typecheck": "bunx tsc --noEmit",
21
+ "generate:bot": "bun scripts/generate-bot-coordinator.ts --update",
21
22
  "prepublishOnly": "bun run build:web"
22
23
  },
23
24
  "devDependencies": {
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Auto-generate PPMBot coordinator identity from Commander.js CLI source.
4
+ *
5
+ * Parses src/index.ts + src/cli/commands/*.ts to extract all commands,
6
+ * descriptions, options, and arguments. Produces coordinator.md content
7
+ * with full CLI reference, decision framework, and safety rules.
8
+ *
9
+ * Usage:
10
+ * bun scripts/generate-bot-coordinator.ts # print to stdout
11
+ * bun scripts/generate-bot-coordinator.ts --update # write coordinator.md + update source
12
+ * bun scripts/generate-bot-coordinator.ts --write-md # only write ~/.ppm/bot/coordinator.md
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
16
+ import { join, resolve } from "node:path";
17
+ import { homedir } from "node:os";
18
+
19
+ const ROOT = resolve(import.meta.dir, "..");
20
+ const INDEX_PATH = join(ROOT, "src", "index.ts");
21
+ const CMD_DIR = join(ROOT, "src", "cli", "commands");
22
+
23
+ // ── Types ──────────────────────────────────────────────────────────────
24
+
25
+ interface CliOption {
26
+ flags: string;
27
+ description: string;
28
+ defaultValue?: string;
29
+ required?: boolean;
30
+ }
31
+
32
+ interface CliCommand {
33
+ /** Full display name including parent path, e.g. "branch create" */
34
+ displayName: string;
35
+ description: string;
36
+ args: string[];
37
+ options: CliOption[];
38
+ }
39
+
40
+ interface CommandGroup {
41
+ name: string;
42
+ description: string;
43
+ commands: CliCommand[];
44
+ }
45
+
46
+ // ── Parsing ────────────────────────────────────────────────────────────
47
+
48
+ /** Extract first quoted string from a regex match, handling mixed quote types */
49
+ function extractQuoted(line: string, after: string): string | null {
50
+ const idx = line.indexOf(after);
51
+ if (idx === -1) return null;
52
+ const rest = line.slice(idx + after.length);
53
+
54
+ // Match opening quote, then capture until same closing quote
55
+ const m = rest.match(/["'`]((?:[^"'`\\]|\\.)*)["'`]/);
56
+ return m ? m[1]! : null;
57
+ }
58
+
59
+ /** Extract string argument from .description("...") — handles embedded quotes */
60
+ function extractDescription(line: string): string | null {
61
+ // Try specific patterns: .description("..."), .description('...')
62
+ const dblMatch = line.match(/\.description\(\s*"([^"]*)"\s*\)/);
63
+ if (dblMatch) return dblMatch[1]!;
64
+
65
+ const sglMatch = line.match(/\.description\(\s*'([^']*)'\s*\)/);
66
+ if (sglMatch) return sglMatch[1]!;
67
+
68
+ const btMatch = line.match(/\.description\(\s*`([^`]*)`\s*\)/);
69
+ if (btMatch) return btMatch[1]!;
70
+
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Parse a Commander.js source file into commands.
76
+ * Tracks variable assignments to resolve nested groups:
77
+ * const branch = git.command("branch") → branch.command("create") is "branch create"
78
+ *
79
+ * Handles multi-line chaining where receiver is on previous line:
80
+ * branch
81
+ * .command("create <name>")
82
+ */
83
+ function parseFile(filePath: string): CliCommand[] {
84
+ const src = readFileSync(filePath, "utf-8");
85
+ const lines = src.split("\n");
86
+ const commands: CliCommand[] = [];
87
+
88
+ // Track variable → parent path mapping
89
+ // e.g. "git" → "", "branch" → "branch", "mem" → "memory"
90
+ const varParent = new Map<string, string>();
91
+
92
+ // Track function parameters as potential receivers: function xxx(param: Command)
93
+ for (const line of lines) {
94
+ const funcParamMatch = line.match(
95
+ /function\s+\w+\(\s*(\w+)\s*:\s*Command\s*\)/,
96
+ );
97
+ if (funcParamMatch) {
98
+ varParent.set(funcParamMatch[1]!, "");
99
+ }
100
+ }
101
+
102
+ /** Last standalone variable seen (for multi-line chaining: `branch\n .command(...)`) */
103
+ let lastStandaloneVar = "";
104
+
105
+ let currentCmd: {
106
+ displayName: string;
107
+ description: string;
108
+ args: string[];
109
+ options: CliOption[];
110
+ } | null = null;
111
+
112
+ for (let i = 0; i < lines.length; i++) {
113
+ const line = lines[i]!;
114
+
115
+ // Track standalone variable references (for multi-line chaining)
116
+ const standaloneMatch = line.match(/^\s+(\w+)\s*$/);
117
+ if (standaloneMatch) {
118
+ lastStandaloneVar = standaloneMatch[1]!;
119
+ }
120
+
121
+ // Detect variable assignment: const/let varName = something.command("name")
122
+ const assignMatch = line.match(
123
+ /(?:const|let)\s+(\w+)\s*=\s*(\w+)\.command\(\s*["'`](\w+)["'`]\s*\)/,
124
+ );
125
+ if (assignMatch) {
126
+ const varName = assignMatch[1]!;
127
+ const receiver = assignMatch[2]!;
128
+ const cmdName = assignMatch[3]!;
129
+
130
+ // If this creates a known CLI group (git, bot, etc.), its path stays ""
131
+ // because formatCommand already prepends "ppm <group>"
132
+ if (cmdName in GROUP_MAP) {
133
+ varParent.set(varName, "");
134
+ } else {
135
+ const parentPath = varParent.get(receiver);
136
+ if (parentPath !== undefined) {
137
+ varParent.set(varName, parentPath ? `${parentPath} ${cmdName}` : cmdName);
138
+ } else {
139
+ varParent.set(varName, "");
140
+ }
141
+ }
142
+ continue;
143
+ }
144
+
145
+ // Detect chained .command("name") — not an assignment
146
+ const cmdMatch = line.match(/\.command\(\s*["'`]([^"'`]+)["'`]\s*\)/);
147
+ if (cmdMatch) {
148
+ // Save previous command
149
+ if (currentCmd) {
150
+ commands.push({ ...currentCmd });
151
+ }
152
+
153
+ const fullArg = cmdMatch[1]!;
154
+ const parts = fullArg.split(/\s+/);
155
+ const name = parts[0]!;
156
+ const args = parts.slice(1);
157
+
158
+ // Determine receiver: same line or previous line (multi-line chaining)
159
+ const sameLineReceiver = line.match(/(\w+)\.command\(/);
160
+ let receiver = sameLineReceiver ? sameLineReceiver[1]! : "";
161
+
162
+ // If receiver looks like a keyword (not a variable), try lastStandaloneVar
163
+ if (!receiver || receiver === "command") {
164
+ // .command() at start of line → look at previous non-empty line
165
+ receiver = lastStandaloneVar;
166
+ }
167
+
168
+ const parentPath = varParent.get(receiver) ?? "";
169
+ const displayName = parentPath ? `${parentPath} ${name}` : name;
170
+
171
+ currentCmd = { displayName, description: "", args, options: [] };
172
+ lastStandaloneVar = "";
173
+ continue;
174
+ }
175
+
176
+ if (!currentCmd) continue;
177
+
178
+ // Description
179
+ const desc = extractDescription(line);
180
+ if (desc !== null && line.includes(".description(")) {
181
+ currentCmd.description = desc;
182
+ }
183
+
184
+ // Option: .option("flags", "desc", "default?")
185
+ const optMatch = line.match(
186
+ /\.option\(\s*["'`]([^"'`]+)["'`]\s*,\s*["'`]([^"'`]+)["'`](?:\s*,\s*["'`]([^"'`]+)["'`])?\s*\)/,
187
+ );
188
+ if (optMatch) {
189
+ currentCmd.options.push({
190
+ flags: optMatch[1]!,
191
+ description: optMatch[2]!,
192
+ defaultValue: optMatch[3],
193
+ });
194
+ }
195
+
196
+ // Required option
197
+ const reqMatch = line.match(
198
+ /\.requiredOption\(\s*["'`]([^"'`]+)["'`]\s*,\s*["'`]([^"'`]+)["'`](?:\s*,\s*["'`]([^"'`]+)["'`])?\s*\)/,
199
+ );
200
+ if (reqMatch) {
201
+ currentCmd.options.push({
202
+ flags: reqMatch[1]!,
203
+ description: reqMatch[2]!,
204
+ defaultValue: reqMatch[3],
205
+ required: true,
206
+ });
207
+ }
208
+
209
+ // Argument: .argument("name")
210
+ const argMatch = line.match(/\.argument\(\s*["'`]([^"'`]+)["'`]/);
211
+ if (argMatch) {
212
+ currentCmd.args.push(argMatch[1]!);
213
+ }
214
+ }
215
+
216
+ // Push last command
217
+ if (currentCmd) {
218
+ commands.push({ ...currentCmd });
219
+ }
220
+
221
+ return commands;
222
+ }
223
+
224
+ /** Known command groups with their source files */
225
+ const GROUP_MAP: Record<string, { description: string; file: string }> = {
226
+ projects: { description: "Manage registered projects", file: "projects.ts" },
227
+ config: { description: "Configuration management", file: "config-cmd.ts" },
228
+ git: { description: "Git operations for a project", file: "git-cmd.ts" },
229
+ chat: { description: "AI chat sessions", file: "chat-cmd.ts" },
230
+ db: { description: "Database connections & queries", file: "db-cmd.ts" },
231
+ autostart: { description: "Auto-start on boot", file: "autostart.ts" },
232
+ cloud: { description: "PPM Cloud — device registry + tunnel", file: "cloud.ts" },
233
+ ext: { description: "Manage PPM extensions", file: "ext-cmd.ts" },
234
+ bot: { description: "PPMBot coordinator utilities", file: "bot-cmd.ts" },
235
+ };
236
+
237
+ function buildGroups(): CommandGroup[] {
238
+ const groups: CommandGroup[] = [];
239
+
240
+ // Top-level commands from index.ts
241
+ const indexCmds = parseFile(INDEX_PATH);
242
+ const groupNames = new Set(Object.keys(GROUP_MAP));
243
+ const topLevel = indexCmds.filter((c) => !groupNames.has(c.displayName));
244
+
245
+ if (topLevel.length > 0) {
246
+ groups.push({
247
+ name: "core",
248
+ description: "Server & system management",
249
+ commands: topLevel,
250
+ });
251
+ }
252
+
253
+ // Sub-command groups from individual files
254
+ for (const [groupName, info] of Object.entries(GROUP_MAP)) {
255
+ const filePath = join(CMD_DIR, info.file);
256
+ if (!existsSync(filePath)) continue;
257
+
258
+ const cmds = parseFile(filePath);
259
+
260
+ // Filter out the group parent command and pure sub-group declarations
261
+ // (e.g. "branch" with no description = just a group container)
262
+ const subCmds = cmds.filter((c) => {
263
+ if (c.displayName === groupName) return false;
264
+ // Skip pure group containers (no description, no args)
265
+ if (!c.description && c.args.length === 0 && c.options.length === 0) return false;
266
+ return true;
267
+ });
268
+
269
+ groups.push({
270
+ name: groupName,
271
+ description: info.description,
272
+ commands: subCmds,
273
+ });
274
+ }
275
+
276
+ return groups;
277
+ }
278
+
279
+ // ── Output Generation ──────────────────────────────────────────────────
280
+
281
+ function formatOption(opt: CliOption): string {
282
+ const req = opt.required ? " (required)" : "";
283
+ const def = opt.defaultValue ? ` [default: ${opt.defaultValue}]` : "";
284
+ return ` ${opt.flags} — ${opt.description}${req}${def}`;
285
+ }
286
+
287
+ function formatCommand(group: string, cmd: CliCommand): string {
288
+ const args = cmd.args.length > 0 ? " " + cmd.args.join(" ") : "";
289
+ const prefix = group === "core" ? "ppm" : `ppm ${group}`;
290
+ let line = `${prefix} ${cmd.displayName}${args}`;
291
+ if (cmd.description) line += `\n ${cmd.description}`;
292
+ if (cmd.options.length > 0) {
293
+ line += "\n" + cmd.options.map(formatOption).join("\n");
294
+ }
295
+ return line;
296
+ }
297
+
298
+ /** Generate CLI-only reference (for cli-reference.md) */
299
+ function generateCliReference(groups: CommandGroup[]): string {
300
+ const sections: string[] = [];
301
+ sections.push(`# PPM CLI Reference`);
302
+
303
+ for (const group of groups) {
304
+ const heading = group.name === "core"
305
+ ? `## Core Commands (${group.description})`
306
+ : `## ppm ${group.name} — ${group.description}`;
307
+ sections.push(heading);
308
+
309
+ const cmdLines = group.commands.map((c) => formatCommand(group.name, c));
310
+ sections.push("```");
311
+ sections.push(cmdLines.join("\n\n"));
312
+ sections.push("```");
313
+ }
314
+
315
+ sections.push(`
316
+ ## Quick Reference — Task Delegation
317
+ \`\`\`
318
+ ppm bot delegate --chat <chatId> --project <name> --prompt "<enriched task>"
319
+ ppm bot task-status <id>
320
+ ppm bot task-result <id>
321
+ ppm bot tasks
322
+ \`\`\`
323
+
324
+ ## Quick Reference — Memory
325
+ \`\`\`
326
+ ppm bot memory save "<content>" -c <category>
327
+ ppm bot memory list
328
+ ppm bot memory forget "<topic>"
329
+ \`\`\`
330
+
331
+ ## Tips
332
+ - Use \`--json\` flag when parsing command output programmatically
333
+ - For git/chat/db operations: always specify \`--project <name>\` or connection name`);
334
+
335
+ return sections.join("\n");
336
+ }
337
+
338
+ // ── Source Code Update ────────────────────────────────────────────────
339
+
340
+ const CLI_DEFAULT_PATH = join(ROOT, "src", "services", "ppmbot", "cli-reference-default.ts");
341
+
342
+ function updateBundledDefault(cliRef: string): void {
343
+ const escaped = cliRef
344
+ .replace(/\\/g, "\\\\")
345
+ .replace(/`/g, "\\`")
346
+ .replace(/\$\{/g, "\\${");
347
+
348
+ const src = readFileSync(CLI_DEFAULT_PATH, "utf-8");
349
+ const startMarker = "export const DEFAULT_CLI_REFERENCE = `";
350
+ const startIdx = src.indexOf(startMarker);
351
+ if (startIdx === -1) {
352
+ console.error("Could not find DEFAULT_CLI_REFERENCE in cli-reference-default.ts");
353
+ process.exit(1);
354
+ }
355
+
356
+ const afterStart = startIdx + startMarker.length;
357
+ const endIdx = src.indexOf("\n`;\n", afterStart);
358
+ if (endIdx === -1) {
359
+ console.error("Could not find closing backtick for DEFAULT_CLI_REFERENCE");
360
+ process.exit(1);
361
+ }
362
+
363
+ const updated = src.slice(0, afterStart) + escaped + src.slice(endIdx);
364
+ writeFileSync(CLI_DEFAULT_PATH, updated);
365
+ console.log(`Updated: ${CLI_DEFAULT_PATH}`);
366
+ }
367
+
368
+ function writeCliReferenceMd(cliRef: string): void {
369
+ const dir = join(homedir(), ".ppm", "bot");
370
+ mkdirSync(dir, { recursive: true });
371
+ const cliRefPath = join(dir, "cli-reference.md");
372
+ // Read version from package.json
373
+ const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf-8"));
374
+ writeFileSync(cliRefPath, `<!-- ppm-version: ${pkg.version} -->\n${cliRef}`);
375
+ console.log(`Written: ${cliRefPath}`);
376
+ }
377
+
378
+ // ── Main ───────────────────────────────────────────────────────────────
379
+
380
+ const cliArgs = process.argv.slice(2);
381
+ const groups = buildGroups();
382
+ const cliRef = generateCliReference(groups);
383
+
384
+ if (cliArgs.includes("--cli-only")) {
385
+ // Runtime mode: just output CLI reference to stdout (used by ensureCliReference)
386
+ console.log(cliRef);
387
+ } else if (cliArgs.includes("--update")) {
388
+ // Dev mode: write cli-reference.md + update bundled default
389
+ writeCliReferenceMd(cliRef);
390
+ updateBundledDefault(cliRef);
391
+ console.log("\nDone. Review the changes and test with PPMBot.");
392
+ } else if (cliArgs.includes("--write-md")) {
393
+ writeCliReferenceMd(cliRef);
394
+ } else {
395
+ // Default: stdout
396
+ console.log(cliRef);
397
+ }
@@ -28,7 +28,21 @@ export function registerCloudCommands(program: Command): void {
28
28
  const existing = getCloudAuth();
29
29
  if (existing) {
30
30
  console.log(` Already logged in as ${existing.email}`);
31
- console.log(` Run 'ppm cloud logout' to switch accounts.\n`);
31
+
32
+ // Auto-link if not yet linked
33
+ const { getCloudDevice, linkDevice } = await import("../../services/cloud.service.ts");
34
+ if (!getCloudDevice()) {
35
+ try {
36
+ const device = await linkDevice();
37
+ console.log(` ✓ Machine linked: ${device.name}\n`);
38
+ } catch (linkErr: unknown) {
39
+ const linkMsg = linkErr instanceof Error ? linkErr.message : String(linkErr);
40
+ console.warn(` ⚠ Auto-link failed: ${linkMsg}`);
41
+ }
42
+ } else {
43
+ console.log(` Run 'ppm cloud logout' to switch accounts.`);
44
+ }
45
+ console.log();
32
46
  return;
33
47
  }
34
48
 
@@ -51,8 +65,19 @@ export function registerCloudCommands(program: Command): void {
51
65
  }
52
66
  }
53
67
 
54
- console.log(`\n ✓ Logged in as ${auth.email}\n`);
55
- console.log(` Next: run 'ppm cloud link' to register this machine.\n`);
68
+ console.log(`\n ✓ Logged in as ${auth.email}`);
69
+
70
+ // Auto-link device after login
71
+ try {
72
+ const { linkDevice } = await import("../../services/cloud.service.ts");
73
+ const device = await linkDevice();
74
+ console.log(` ✓ Machine linked: ${device.name}`);
75
+ } catch (linkErr: unknown) {
76
+ const linkMsg = linkErr instanceof Error ? linkErr.message : String(linkErr);
77
+ console.warn(` ⚠ Auto-link failed: ${linkMsg}`);
78
+ console.log(` Run 'ppm cloud link' manually to register this machine.`);
79
+ }
80
+ console.log();
56
81
  } catch (err: unknown) {
57
82
  const msg = err instanceof Error ? err.message : String(err);
58
83
  console.error(` ✗ Login failed: ${msg}\n`);
@@ -64,17 +89,28 @@ export function registerCloudCommands(program: Command): void {
64
89
  .command("logout")
65
90
  .description("Sign out from PPM Cloud")
66
91
  .action(async () => {
67
- const { removeCloudAuth, getCloudAuth } = await import(
92
+ const { removeCloudAuth, getCloudAuth, unlinkDevice, getCloudDevice } = await import(
68
93
  "../../services/cloud.service.ts"
69
94
  );
70
95
 
71
96
  const auth = getCloudAuth();
72
- removeCloudAuth();
73
- if (auth) {
74
- console.log(` ✓ Logged out (was: ${auth.email})\n`);
75
- } else {
97
+ if (!auth) {
76
98
  console.log(` Not logged in.\n`);
99
+ return;
100
+ }
101
+
102
+ // Auto-unlink device before removing auth
103
+ if (getCloudDevice()) {
104
+ try {
105
+ await unlinkDevice();
106
+ console.log(` ✓ Machine unlinked`);
107
+ } catch {
108
+ // Non-blocking — still logout even if unlink fails
109
+ }
77
110
  }
111
+
112
+ removeCloudAuth();
113
+ console.log(` ✓ Logged out (was: ${auth.email})\n`);
78
114
  });
79
115
 
80
116
  cmd
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Bundled CLI reference fallback for compiled binary (when generator script is unavailable).
3
+ * Auto-generated by: bun scripts/generate-bot-coordinator.ts --update-default
4
+ *
5
+ * To regenerate: bun generate:bot
6
+ */
7
+
8
+ // prettier-ignore
9
+ export const DEFAULT_CLI_REFERENCE = `# PPM CLI Reference
10
+ ## Core Commands (Server & system management)
11
+ \`\`\`
12
+ ppm start
13
+ Start the PPM server (background by default)
14
+ -p, --port <port> — Port to listen on
15
+ -s, --share — (deprecated) Tunnel is now always enabled
16
+ -c, --config <path> — Path to config file (YAML import into DB)
17
+
18
+ ppm stop
19
+ Stop the PPM server (supervisor stays alive)
20
+ -a, --all — Kill all PPM and cloudflared processes (including untracked)
21
+ --kill — Full shutdown (kills supervisor too)
22
+
23
+ ppm down
24
+ Fully shut down PPM (supervisor + server + tunnel)
25
+
26
+ ppm restart
27
+ Restart the server (keeps tunnel alive)
28
+ -c, --config <path> — Path to config file
29
+ --force — Force resume from paused state
30
+
31
+ ppm status
32
+ Show PPM daemon status
33
+ -a, --all — Show all PPM and cloudflared processes (including untracked)
34
+ --json — Output as JSON
35
+
36
+ ppm open
37
+ Open PPM in browser
38
+ -c, --config <path> — Path to config file
39
+
40
+ ppm logs
41
+ View PPM daemon logs
42
+ -n, --tail <lines> — Number of lines to show [default: 50]
43
+ -f, --follow — Follow log output
44
+ --clear — Clear log file
45
+
46
+ ppm report
47
+ Report a bug on GitHub (pre-fills env info + logs)
48
+
49
+ ppm init
50
+ Initialize PPM configuration (interactive or via flags)
51
+ -p, --port <port> — Port to listen on
52
+ --scan <path> — Directory to scan for git repos
53
+ --auth — Enable authentication
54
+ --no-auth — Disable authentication
55
+ --password <pw> — Set access password
56
+ --share — Pre-install cloudflared for sharing
57
+ -y, --yes — Non-interactive mode (use defaults + flags)
58
+
59
+ ppm upgrade
60
+ Check for and install PPM updates
61
+ \`\`\`
62
+ ## ppm projects — Manage registered projects
63
+ \`\`\`
64
+ ppm projects list
65
+ List all registered projects
66
+
67
+ ppm projects add <path>
68
+ Add a project to the registry
69
+ -n, --name <name> — Project name (defaults to folder name)
70
+
71
+ ppm projects remove <name>
72
+ Remove a project from the registry
73
+ \`\`\`
74
+ ## ppm config — Configuration management
75
+ \`\`\`
76
+ ppm config get <key>
77
+ Get a config value (e.g. port, auth.enabled)
78
+
79
+ ppm config set <key> <value>
80
+ Set a config value (e.g. port 9090)
81
+ \`\`\`
82
+ ## ppm git — Git operations for a project
83
+ \`\`\`
84
+ ppm git status
85
+ Show working tree status
86
+ -p, --project <name> — Project name or path
87
+
88
+ ppm git log
89
+ Show recent commits
90
+ -p, --project <name> — Project name or path
91
+ -n, --count <n> — Number of commits to show [default: 20]
92
+
93
+ ppm git diff [ref1] [ref2]
94
+ Show diff between refs or working tree
95
+ -p, --project <name> — Project name or path
96
+
97
+ ppm git stage <files...>
98
+ Stage files (use "." to stage all)
99
+ -p, --project <name> — Project name or path
100
+
101
+ ppm git unstage <files...>
102
+ Unstage files
103
+ -p, --project <name> — Project name or path
104
+
105
+ ppm git commit
106
+ Commit staged changes
107
+ -p, --project <name> — Project name or path
108
+ -m, --message <msg> — Commit message (required)
109
+
110
+ ppm git push
111
+ Push to remote
112
+ -p, --project <name> — Project name or path
113
+ --remote <remote> — Remote name [default: origin]
114
+ --branch <branch> — Branch name
115
+
116
+ ppm git pull
117
+ Pull from remote
118
+ -p, --project <name> — Project name or path
119
+ --remote <remote> — Remote name
120
+ --branch <branch> — Branch name
121
+
122
+ ppm git branch create <name>
123
+ Create and checkout a new branch
124
+ -p, --project <name> — Project name or path
125
+ --from <ref> — Base ref (commit/branch/tag)
126
+
127
+ ppm git branch checkout <name>
128
+ Switch to a branch
129
+ -p, --project <name> — Project name or path
130
+
131
+ ppm git branch delete <name>
132
+ Delete a branch
133
+ -p, --project <name> — Project name or path
134
+ -f, --force — Force delete
135
+
136
+ ppm git branch merge <source>
137
+ Merge a branch into current branch
138
+ -p, --project <name> — Project name or path
139
+ \`\`\`
140
+ ## ppm chat — AI chat sessions
141
+ \`\`\`
142
+ ppm chat list
143
+ List all chat sessions
144
+ -p, --project <name> — Filter by project name
145
+
146
+ ppm chat create
147
+ Create a new chat session
148
+ -p, --project <name> — Project name or path
149
+ --provider <provider> — AI provider (default: claude)
150
+
151
+ ppm chat send <session-id> <message>
152
+ Send a message and stream response to stdout
153
+ -p, --project <name> — Project name or path
154
+
155
+ ppm chat resume <session-id>
156
+ Resume an interactive chat session
157
+ -p, --project <name> — Project name or path
158
+
159
+ ppm chat delete <session-id>
160
+ Delete a chat session
161
+ \`\`\`
162
+ ## ppm db — Database connections & queries
163
+ \`\`\`
164
+ ppm db list
165
+ List all saved database connections
166
+
167
+ ppm db add
168
+ Add a new database connection
169
+ -n, --name <name> — Connection name (unique) (required)
170
+ -t, --type <type> — Database type: sqlite | postgres (required)
171
+ -c, --connection-string <url> — PostgreSQL connection string
172
+ -f, --file <path> — SQLite file path (absolute)
173
+ -g, --group <group> — Group name
174
+ --color <color> — Tab color (hex, e.g. #3b82f6)
175
+
176
+ ppm db remove <name>
177
+ Remove a saved connection (by name or ID)
178
+
179
+ ppm db test <name>
180
+ Test a saved connection
181
+
182
+ ppm db tables <name>
183
+ List tables in a database connection
184
+
185
+ ppm db schema <name> <table>
186
+ Show table schema (columns, types, constraints)
187
+ -s, --schema <schema> — PostgreSQL schema name [default: public]
188
+
189
+ ppm db data <name> <table>
190
+ View table data (paginated)
191
+ -p, --page <page> — Page number [default: 1]
192
+ -l, --limit <limit> — Rows per page [default: 50]
193
+ --order <column> — Order by column
194
+ --desc — Descending order
195
+ -s, --schema <schema> — PostgreSQL schema name [default: public]
196
+
197
+ ppm db query <name> <sql>
198
+ Execute a SQL query against a saved connection
199
+ \`\`\`
200
+ ## ppm autostart — Auto-start on boot
201
+ \`\`\`
202
+ ppm autostart enable
203
+ Register PPM to start automatically on boot
204
+ -p, --port <port> — Override port
205
+ -s, --share — (deprecated) Tunnel is now always enabled
206
+ -c, --config <path> — Config file path
207
+ --profile <name> — DB profile name
208
+
209
+ ppm autostart disable
210
+ Remove PPM auto-start registration
211
+
212
+ ppm autostart status
213
+ Show auto-start status
214
+ --json — Output as JSON
215
+ \`\`\`
216
+ ## ppm cloud — PPM Cloud — device registry + tunnel
217
+ \`\`\`
218
+ ppm cloud login
219
+ Sign in with Google
220
+ --url <url> — Cloud URL override
221
+ --device-code — Force device code flow (for remote terminals)
222
+
223
+ ppm cloud logout
224
+ Sign out from PPM Cloud
225
+
226
+ ppm cloud link
227
+ Register this machine with PPM Cloud
228
+ -n, --name <name> — Machine display name
229
+
230
+ ppm cloud unlink
231
+ Remove this machine from PPM Cloud
232
+
233
+ ppm cloud status
234
+ Show PPM Cloud connection status
235
+ --json — Output as JSON
236
+
237
+ ppm cloud devices
238
+ List all registered devices from cloud
239
+ --json — Output as JSON
240
+ \`\`\`
241
+ ## ppm ext — Manage PPM extensions
242
+ \`\`\`
243
+ ppm ext install <name>
244
+ Install an extension from npm
245
+
246
+ ppm ext remove <name>
247
+ Remove an installed extension
248
+
249
+ ppm ext list
250
+ List installed extensions
251
+
252
+ ppm ext enable <name>
253
+ Enable an extension
254
+
255
+ ppm ext disable <name>
256
+ Disable an extension
257
+
258
+ ppm ext dev <path>
259
+ Symlink a local extension for development
260
+ \`\`\`
261
+ ## ppm bot — PPMBot coordinator utilities
262
+ \`\`\`
263
+ ppm bot delegate
264
+ Delegate a task to a project subagent
265
+ --chat <id> — Telegram chat ID (required)
266
+ --project <name> — Project name (required)
267
+ --prompt <text> — Enriched task prompt (required)
268
+ --timeout <ms> — Timeout in milliseconds [default: 900000]
269
+
270
+ ppm bot task-status <id>
271
+ Get status of a delegated task
272
+
273
+ ppm bot task-result <id>
274
+ Get full result of a completed task
275
+
276
+ ppm bot tasks
277
+ List recent delegated tasks
278
+ --chat <id> — Telegram chat ID (auto-detected if single)
279
+
280
+ ppm bot memory save <content>
281
+ Save a cross-project memory
282
+ -c, --category <cat> — Category: fact|preference|decision|architecture|issue [default: fact]
283
+ -s, --session <id> — Session ID (optional)
284
+
285
+ ppm bot memory list
286
+ List active cross-project memories
287
+ -l, --limit <n> — Max results [default: 30]
288
+ --json — Output as JSON
289
+
290
+ ppm bot memory forget <topic>
291
+ Delete memories matching a topic (FTS5 search)
292
+
293
+ ppm bot project list
294
+ List available projects
295
+ --json — Output as JSON
296
+
297
+ ppm bot status
298
+ Show current status and running tasks
299
+ --chat <id> — Telegram chat ID (auto-detected if single)
300
+ --json — Output as JSON
301
+
302
+ ppm bot version
303
+ Show PPM version
304
+
305
+ ppm bot restart
306
+ Restart the PPM server
307
+
308
+ ppm bot help
309
+ Show all bot CLI commands
310
+ \`\`\`
311
+
312
+ ## Quick Reference — Task Delegation
313
+ \`\`\`
314
+ ppm bot delegate --chat <chatId> --project <name> --prompt "<enriched task>"
315
+ ppm bot task-status <id>
316
+ ppm bot task-result <id>
317
+ ppm bot tasks
318
+ \`\`\`
319
+
320
+ ## Quick Reference — Memory
321
+ \`\`\`
322
+ ppm bot memory save "<content>" -c <category>
323
+ ppm bot memory list
324
+ ppm bot memory forget "<topic>"
325
+ \`\`\`
326
+
327
+ ## Tips
328
+ - Use \`--json\` flag when parsing command output programmatically
329
+ - For git/chat/db operations: always specify \`--project <name>\` or connection name
330
+ `;
@@ -14,7 +14,7 @@ import {
14
14
  markBotTaskReported,
15
15
  } from "../db.service.ts";
16
16
  import { PPMBotTelegram } from "./ppmbot-telegram.ts";
17
- import { PPMBotSessionManager, ensureCoordinatorWorkspace, DEFAULT_COORDINATOR_IDENTITY } from "./ppmbot-session.ts";
17
+ import { PPMBotSessionManager, ensureCoordinatorWorkspace, readCliReference, DEFAULT_COORDINATOR_IDENTITY } from "./ppmbot-session.ts";
18
18
  import { PPMBotMemory } from "./ppmbot-memory.ts";
19
19
  import { streamToTelegram } from "./ppmbot-streamer.ts";
20
20
  import { escapeHtml } from "./ppmbot-formatter.ts";
@@ -286,6 +286,13 @@ I'll answer directly or delegate to your project's AI.`;
286
286
  parts.push(identity);
287
287
  }
288
288
 
289
+ // CLI reference (from cli-reference.md)
290
+ const cliRef = readCliReference();
291
+ if (cliRef) {
292
+ parts.push(`\n## CLI Reference`);
293
+ parts.push(cliRef);
294
+ }
295
+
289
296
  // Session info
290
297
  parts.push(`\n## Session Info`);
291
298
  parts.push(`Chat ID: ${chatId}`);
@@ -1,8 +1,9 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync as fsRead, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { chatService } from "../chat.service.ts";
5
5
  import { configService } from "../config.service.ts";
6
+ import { VERSION } from "../../version.ts";
6
7
  import {
7
8
  getActivePPMBotSession,
8
9
  createPPMBotSession,
@@ -10,57 +11,66 @@ import {
10
11
  touchPPMBotSession,
11
12
  getDistinctPPMBotProjectNames,
12
13
  } from "../db.service.ts";
14
+ import { DEFAULT_CLI_REFERENCE } from "./cli-reference-default.ts";
13
15
  import type { PPMBotActiveSession, PPMBotSessionRow } from "../../types/ppmbot.ts";
14
16
  import type { PPMBotConfig, ProjectConfig } from "../../types/config.ts";
15
17
 
16
18
  export const DEFAULT_COORDINATOR_IDENTITY = `# PPMBot — AI Project Coordinator
17
19
 
18
- You are PPMBot, a personal AI project coordinator and team leader. You communicate with users via Telegram.
20
+ You are PPMBot, a personal AI project coordinator and team leader. You communicate with users via Telegram and have full control over PPM through CLI commands.
19
21
 
20
22
  ## Role
21
23
  - Answer direct questions immediately (coding, general knowledge, quick advice)
22
24
  - Delegate project-specific tasks to subagents using \`ppm bot delegate\`
23
25
  - Track delegated task status and report results proactively
26
+ - Manage PPM server, projects, config, git, cloud, extensions, and databases
24
27
  - Remember user preferences across conversations
25
- - Act as a team leader coordinating work across multiple projects
26
28
 
27
29
  ## Decision Framework
28
30
  1. Can I answer this directly without project context? → Answer now
29
- 2. Does this reference a specific project or need file access? → Delegate
30
- 3. Is this about PPM config or bot management? Handle directly
31
- 4. Ambiguous project? → Ask user to clarify
32
-
33
- ## Coordination Tools (via Bash)
34
-
35
- ### Delegate a task to a project
36
- ppm bot delegate --chat <chatId> --project <name> --prompt "<enriched task description>"
37
- Returns task ID. Tell user you're working on it.
38
-
39
- ### Check task status
40
- ppm bot task-status <task-id>
41
-
42
- ### Get task result
43
- ppm bot task-result <task-id>
44
-
45
- ### List recent tasks
46
- ppm bot tasks
31
+ 2. Does this reference a specific project or need file access? → Delegate with \`ppm bot delegate\`
32
+ 3. Is this about PPM management (server/config/projects/git/db/cloud/ext)? Use CLI commands directly
33
+ 4. Is this a destructive operation? → Confirm with user first
34
+ 5. Ambiguous project? → Ask user to clarify
35
+
36
+ ## Safety Rules (CRITICAL)
37
+ Before executing destructive commands, ALWAYS confirm with the user:
38
+ - \`ppm stop\` / \`ppm down\` / \`ppm restart\` "Are you sure you want to stop/restart PPM?"
39
+ - \`ppm db query\` with writes warn about data modification risk
40
+ - \`ppm projects remove\` → confirm project name
41
+ - \`ppm config set\` → show current value with \`ppm config get\` BEFORE changing
42
+ - \`ppm cloud logout\` / \`ppm cloud unlink\` → confirm
43
+ - \`ppm git branch delete\` → warn about potential data loss
44
+ - \`ppm ext remove\` → confirm extension name
45
+
46
+ ## Operational Patterns
47
+ - Before restart: check \`ppm status\` first
48
+ - Before config change: read current with \`ppm config get <key>\`
49
+ - Before git push: check \`ppm git status --project <name>\`
50
+ - For DB operations: always specify connection name
51
+ - For git operations: always use \`--project <name>\` flag
52
+
53
+ ## CLI Commands
54
+ Full CLI reference is in \`cli-reference.md\` (auto-injected into context).
47
55
 
48
56
  ## Response Style
49
- - Keep responses concise (Telegram context — mobile-friendly)
50
- - Use short paragraphs, no walls of text
57
+ - Keep responses concise (Telegram — mobile-friendly)
58
+ - Short paragraphs, no walls of text
51
59
  - When delegating: acknowledge immediately, notify on completion
52
60
  - Support Vietnamese and English naturally
53
61
 
54
62
  ## Important
55
- - When delegating, write an enriched prompt with full context — not just the raw user message
56
- - Include relevant details: what the user wants, which files/features, acceptance criteria
63
+ - When delegating, write enriched prompts with full context — not just raw user message
64
+ - Include: what user wants, which files/features, acceptance criteria
57
65
  - Each delegation creates a fresh AI session in the target project workspace
66
+ - Use \`--json\` flag when parsing command output programmatically
58
67
  `;
59
68
 
60
- /** Ensure ~/.ppm/bot/ workspace exists with coordinator.md */
69
+ /** Ensure ~/.ppm/bot/ workspace exists with coordinator.md + cli-reference.md */
61
70
  export function ensureCoordinatorWorkspace(): void {
62
71
  const botDir = join(homedir(), ".ppm", "bot");
63
72
  const coordinatorMd = join(botDir, "coordinator.md");
73
+ const cliRefPath = join(botDir, "cli-reference.md");
64
74
  const settingsDir = join(botDir, ".claude");
65
75
  const settingsFile = join(settingsDir, "settings.local.json");
66
76
 
@@ -75,6 +85,63 @@ export function ensureCoordinatorWorkspace(): void {
75
85
  permissions: { allow: ["Bash", "Read", "Write", "Edit", "Glob", "Grep"] },
76
86
  }, null, 2));
77
87
  }
88
+
89
+ // Auto-generate cli-reference.md if missing or version mismatch
90
+ ensureCliReference(cliRefPath);
91
+ }
92
+
93
+ /** Read CLI reference from disk (for context injection) */
94
+ export function readCliReference(): string {
95
+ const cliRefPath = join(homedir(), ".ppm", "bot", "cli-reference.md");
96
+ try {
97
+ return fsRead(cliRefPath, "utf-8");
98
+ } catch {
99
+ return "";
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Generate cli-reference.md if missing or version differs.
105
+ * Embeds version header: `<!-- ppm-version: x.x.x -->`
106
+ */
107
+ function ensureCliReference(cliRefPath: string): void {
108
+ // Check existing version
109
+ if (existsSync(cliRefPath)) {
110
+ try {
111
+ const existing = fsRead(cliRefPath, "utf-8");
112
+ const versionMatch = existing.match(/<!-- ppm-version: (.+?) -->/);
113
+ if (versionMatch && versionMatch[1] === VERSION) return; // up to date
114
+ } catch { /* regenerate on read error */ }
115
+ }
116
+
117
+ try {
118
+ const content = generateCliReference();
119
+ writeFileSync(cliRefPath, content);
120
+ console.log(`[ppmbot] Generated cli-reference.md (v${VERSION})`);
121
+ } catch (err) {
122
+ console.warn(`[ppmbot] Failed to generate cli-reference.md:`, (err as Error).message);
123
+ }
124
+ }
125
+
126
+ /** Generate CLI reference by running the generator script */
127
+ function generateCliReference(): string {
128
+ const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
129
+ const scriptPath = join(import.meta.dir, "../../../scripts/generate-bot-coordinator.ts");
130
+
131
+ // If generator script exists (dev/source install), run it
132
+ if (existsSync(scriptPath)) {
133
+ const result = spawnSync("bun", [scriptPath, "--cli-only"], {
134
+ cwd: join(import.meta.dir, "../../.."),
135
+ timeout: 10_000,
136
+ encoding: "utf-8",
137
+ });
138
+ if (result.status === 0 && result.stdout) {
139
+ return `<!-- ppm-version: ${VERSION} -->\n${result.stdout}`;
140
+ }
141
+ }
142
+
143
+ // Fallback: generate from bundled constant
144
+ return `<!-- ppm-version: ${VERSION} -->\n${DEFAULT_CLI_REFERENCE}`;
78
145
  }
79
146
 
80
147
  export class PPMBotSessionManager {
@@ -65,6 +65,8 @@ let tunnelProbeTimer: ReturnType<typeof setInterval> | null = null;
65
65
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
66
66
  let upgradeCheckTimer: ReturnType<typeof setInterval> | null = null;
67
67
  let upgradeDelayTimer: ReturnType<typeof setTimeout> | null = null;
68
+ let cloudMonitorTimer: ReturnType<typeof setInterval> | null = null;
69
+ let cloudConnected = false; // tracks whether we've initiated a cloud WS connection
68
70
 
69
71
  // Saved at startup for self-replace
70
72
  let originalArgv: string[] = [];
@@ -488,11 +490,11 @@ async function notifyStateChange(from: string, to: string, reason: string) {
488
490
  }
489
491
 
490
492
  /** Connect supervisor to Cloud via WebSocket (if device is linked) */
491
- async function connectCloud(opts: { port: number }, serverArgs: string[], logFd: number) {
493
+ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd: number): Promise<boolean> {
492
494
  try {
493
495
  const { getCloudDevice } = await import("./cloud.service.ts");
494
496
  const device = getCloudDevice();
495
- if (!device) return; // not linked to cloud
497
+ if (!device) return false; // not linked to cloud
496
498
 
497
499
  const { connect, onCommand } = await import("./cloud-ws.service.ts");
498
500
  const { VERSION } = await import("../version.ts");
@@ -609,11 +611,48 @@ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd:
609
611
  sendResult(false, `Unknown action: ${cmd.action}`);
610
612
  }
611
613
  });
614
+ cloudConnected = true;
615
+ return true;
612
616
  } catch (e) {
613
617
  log("WARN", `Cloud WS setup failed: ${e}`);
618
+ return false;
614
619
  }
615
620
  }
616
621
 
622
+ /** Periodically check if cloud-device.json appeared/disappeared and connect/disconnect */
623
+ function startCloudMonitor(opts: { port: number }, serverArgs: string[], logFd: number) {
624
+ const CLOUD_MONITOR_INTERVAL_MS = 60_000; // check every 60s
625
+ cloudMonitorTimer = setInterval(async () => {
626
+ if (shuttingDown) return;
627
+ try {
628
+ const { getCloudDevice } = await import("./cloud.service.ts");
629
+ const device = getCloudDevice();
630
+ const { isConnected } = await import("./cloud-ws.service.ts");
631
+
632
+ if (device && !cloudConnected) {
633
+ // Device linked but WS not connected — connect now
634
+ log("INFO", "Cloud monitor: device linked detected, connecting to cloud");
635
+ await connectCloud(opts, serverArgs, logFd);
636
+ } else if (device && cloudConnected && !isConnected()) {
637
+ // Device linked, we attempted connection but WS is dead — reconnect
638
+ log("WARN", "Cloud monitor: WS disconnected, reconnecting");
639
+ const { disconnect } = await import("./cloud-ws.service.ts");
640
+ disconnect();
641
+ cloudConnected = false;
642
+ await connectCloud(opts, serverArgs, logFd);
643
+ } else if (!device && cloudConnected) {
644
+ // Device unlinked — disconnect
645
+ log("INFO", "Cloud monitor: device unlinked, disconnecting from cloud");
646
+ const { disconnect } = await import("./cloud-ws.service.ts");
647
+ disconnect();
648
+ cloudConnected = false;
649
+ }
650
+ } catch (e) {
651
+ log("WARN", `Cloud monitor error: ${e}`);
652
+ }
653
+ }, CLOUD_MONITOR_INTERVAL_MS);
654
+ }
655
+
617
656
  // ─── Soft stop (server only, supervisor stays alive) ──────────────────
618
657
  let _softStopRunning = false;
619
658
  export async function softStop() {
@@ -674,6 +713,7 @@ export function shutdown() {
674
713
  if (heartbeatTimer) clearInterval(heartbeatTimer);
675
714
  if (upgradeCheckTimer) clearInterval(upgradeCheckTimer);
676
715
  if (upgradeDelayTimer) clearTimeout(upgradeDelayTimer);
716
+ if (cloudMonitorTimer) clearInterval(cloudMonitorTimer);
677
717
 
678
718
  if (serverChild) { try { serverChild.kill(); } catch {} }
679
719
  if (tunnelChild) { try { tunnelChild.kill(); } catch {} }
@@ -801,8 +841,9 @@ export async function runSupervisor(opts: {
801
841
  }, 1000);
802
842
  }
803
843
 
804
- // Connect to Cloud via WebSocket (if device is linked)
844
+ // Connect to Cloud via WebSocket (if device is linked) + start monitoring
805
845
  connectCloud(opts, serverArgs, logFd);
846
+ startCloudMonitor(opts, serverArgs, logFd);
806
847
 
807
848
  // Spawn server + tunnel in parallel
808
849
  const promises: Promise<void>[] = [spawnServer(serverArgs, logFd)];