social-autoposter 1.6.68 → 1.6.69

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/mcp/dist/index.js CHANGED
@@ -34,7 +34,8 @@ const PANEL_URI = "ui://social-autoposter/panel.html";
34
34
  const TWITTER_AUTOPILOT_LABEL = "com.m13v.social-twitter-cycle";
35
35
  const TWITTER_AUTOPILOT_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${TWITTER_AUTOPILOT_LABEL}.plist`);
36
36
  // Daily self-updater. Enabled alongside autopilot so a hands-free (headless)
37
- // install keeps itself current — the interactive `version` tool only helps when
37
+ // install keeps itself current — the interactive `runtime` tool (action:'update')
38
+ // only helps when
38
39
  // a human-facing agent session is open, which an autopilot box never has.
39
40
  const UPDATER_LABEL = "com.m13v.social-autoposter-update";
40
41
  const UPDATER_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", `${UPDATER_LABEL}.plist`);
@@ -171,13 +172,15 @@ const server = new McpServer({
171
172
  "about it. A feature nobody hears about is wasted distribution. Use judgment, suggest it for " +
172
173
  "genuinely shareable moments, not routine refactors, chores, or internal cleanup.\n\n" +
173
174
  "STAY CURRENT. At the start of a session, and again right after you help the user ship/merge/" +
174
- "release, call the `version` tool (action:'status'). If `update_available` is true, tell the " +
175
- "user and offer to run `version` with action:'update'. The `setup` tool's status also surfaces " +
176
- "`update_available` and an `update_hint`.\n\n" +
177
- "TYPICAL FLOW: `setup` (configure the project once) -> `draft_cycle` (scan + review a batch; the " +
178
- "user approves / edits / skips every draft in a single form) -> `autopilot` (enable to also turn " +
179
- "on hands-free background posting AND daily auto-updates) -> `get_stats` (see performance). Run " +
180
- "`setup` first; the other tools refuse until a project is fully configured.\n\n" +
175
+ "release, call the `runtime` tool (action:'version'). If `update_available` is true, tell the " +
176
+ "user and offer to run `runtime` with action:'update'. The `project_config` tool's status also " +
177
+ "surfaces `update_available` and an `update_hint`.\n\n" +
178
+ "TYPICAL FLOW: `project_config` (configure OR edit the project, and connect X) -> `draft_cycle` " +
179
+ "(scan + review a batch; the user approves / edits / skips every draft in a single form) -> " +
180
+ "`autopilot` (enable to also turn on hands-free background posting AND daily auto-updates) -> " +
181
+ "`get_stats` (see performance). Run `project_config` first; the other tools refuse until a " +
182
+ "project is fully configured. To change anything about a project later, call `project_config` " +
183
+ "again with the project's name and just the changed fields — there is no separate config editor.\n\n" +
181
184
  "RENDER THE DASHBOARD AFTER ACTIONS. After any state-changing or results-producing tool call " +
182
185
  "(`draft_cycle`, `post_drafts`, `autopilot` enable/disable, `get_stats`), end your turn by " +
183
186
  "calling the `dashboard` tool so the user sees the updated state visually. Do NOT call " +
@@ -238,9 +241,10 @@ function blockedReasonMessage(reason) {
238
241
  "Wait for the limit to reset, then run draft_cycle again.");
239
242
  case "no_search_topics":
240
243
  return ("This project has no search topics yet, so there was nothing to scan. Topics live in the " +
241
- "DB (project_search_topics) and are seeded from your project's `search_topics` during setup. " +
242
- "Re-run the `setup` tool for this project with a `search_topics` list (comma-separated keywords/" +
243
- "phrases your buyers tweet about); setup seeds them automatically, then run draft_cycle again.");
244
+ "DB (project_search_topics) and are seeded from your project's `search_topics` when you " +
245
+ "configure it. Re-run the `project_config` tool for this project with a `search_topics` list " +
246
+ "(comma-separated keywords/phrases your buyers tweet about); it seeds them automatically, then " +
247
+ "run draft_cycle again.");
244
248
  case "topics_api_unreachable":
245
249
  return ("Couldn't reach the search-topics service to load this project's topics, so the cycle stopped " +
246
250
  "before scanning. This is usually a transient backend/network issue. Try draft_cycle again in a " +
@@ -509,10 +513,10 @@ async function postApproved(batchId, plan) {
509
513
  // This is NOT a tool — the model never auto-calls it. It surfaces in clients
510
514
  // that render prompts as slash-commands / starters (e.g. Claude Desktop's "/"
511
515
  // menu). When the user picks it, it injects the message below into the chat,
512
- // which nudges the agent to start the real onboarding via the `setup` tool.
516
+ // which nudges the agent to start the real onboarding via the `project_config` tool.
513
517
  // Deliberately a DUMB POINTER: it names no fields and no steps, so it can never
514
- // drift from REQUIRED_FIELDS / the setup tool's flow. All real logic stays in
515
- // `setup`; this is just a convenience handle to begin.
518
+ // drift from REQUIRED_FIELDS / the project_config tool's flow. All real logic stays
519
+ // in `project_config`; this is just a convenience handle to begin.
516
520
  server.registerPrompt("getting-started", {
517
521
  title: "Set up social-autoposter",
518
522
  description: "Start here. Walks you through configuring a product and connecting your X/Twitter " +
@@ -561,29 +565,36 @@ const WEBSITE_RESEARCH_INSTRUCTIONS = "PRODUCT RESEARCH (do this before saving t
561
565
  "register, vibe) while keeping every product CLAIM factual to the site. Don't invent " +
562
566
  "features, metrics, or guarantees the site doesn't state.\n" +
563
567
  "5. Save the best conservative factual draft without adding a confirmation round-trip. Call " +
564
- "setup with name + the product fields (plus voice/search_topics from the profile scan). If the " +
568
+ "project_config with name + the product fields (plus voice/search_topics from the profile scan). If the " +
565
569
  "site is thin or unreachable, use only supported facts and leave optional detail conservative; " +
566
570
  "ask the user only if a required field is genuinely unknowable.";
567
- // ---- setup: per-project config (the "brain": project, website, voice) -----
571
+ // ---- project_config: per-project config (the "brain": project, website, voice) -----
568
572
  // Run this FIRST. The action tools refuse until at least one project is ready.
569
573
  // You can set up MULTIPLE products and fill each project's fields INCREMENTALLY
570
574
  // across several calls — readiness is derived from config.json, never a stored
571
575
  // flag. Call with status:true (or just no name) to list every project this
572
576
  // install manages and what each still needs.
573
- tool("setup", {
574
- title: "Set up a project",
575
- description: "Run this FIRST, before any drafting or autopilot. A user's request to set up social-autoposter " +
576
- "is a request to finish the workflow end to end, not to interview them step by step. Resume " +
577
- "from current status, infer discoverable fields, and keep taking safe actions until runtime, " +
578
- "project, X connection, topic seeding, and draft-only verification are complete.\n" +
577
+ tool("project_config", {
578
+ title: "Configure or edit a project",
579
+ description: "The ONE tool for a project's whole lifecycle: create it, EDIT it later, and connect its X " +
580
+ "account. There is no separate raw-config editor every project change goes through here so " +
581
+ "it validates, merges, and re-seeds the search-topic universe the cycle reads. To CHANGE an " +
582
+ "existing project (its website, voice, icp, differentiator, search_topics, guardrails, CTA " +
583
+ "link), call this with that project's `name` and ONLY the fields you want to change; it merges " +
584
+ "onto what's already saved and never clobbers untouched fields. Run it FIRST before any " +
585
+ "drafting or autopilot. A user's request to set up social-autoposter is a request to finish " +
586
+ "the workflow end to end, not to interview them step by step: resume from current status, " +
587
+ "infer discoverable fields, and keep taking safe actions until runtime, project, X connection, " +
588
+ "topic seeding, and draft-only verification are complete.\n" +
579
589
  "Two jobs:\n" +
580
- "1) Configure a project this install posts for: its website, what it does (description), who " +
581
- "to target (icp), and brand voice. To fill the PRODUCT fields, discover the product URL from " +
582
- "config, conversation context, the connected X profile, or public research, then visit it " +
583
- "with your own browser/fetch tools — read 5+ pages (home, pricing, features, about, docs/" +
584
- "blog, FAQ) to learn it deeply, rather than guessing from the name. Set up MULTIPLE products " +
585
- "(call once per product, identified by name); fill a project's fields INCREMENTALLY across " +
586
- "several calls — pass whatever you have, it merges and tells you what's still missing.\n" +
590
+ "1) Configure (or edit) a project this install posts for: its website, what it does " +
591
+ "(description), who to target (icp), and brand voice. To fill the PRODUCT fields, discover the " +
592
+ "product URL from config, conversation context, the connected X profile, or public research, " +
593
+ "then visit it with your own browser/fetch tools — read 5+ pages (home, pricing, features, " +
594
+ "about, docs/blog, FAQ) to learn it deeply, rather than guessing from the name. Set up MULTIPLE " +
595
+ "products (call once per product, identified by name); fill or edit a project's fields " +
596
+ "INCREMENTALLY across several calls — pass whatever you have, it merges and tells you what's " +
597
+ "still missing.\n" +
587
598
  "2) Connect X/Twitter (action:'connect_x'): the autoposter posts through its OWN managed Chrome, " +
588
599
  "which needs your logged-in x.com session. This imports x.com/twitter.com cookies from your " +
589
600
  "everyday browser (Chrome/Arc/Brave/Edge, auto-detected) into that browser — nothing else is " +
@@ -655,6 +666,17 @@ tool("setup", {
655
666
  .string()
656
667
  .optional()
657
668
  .describe("Anything the posts must avoid saying / claiming"),
669
+ fields: z
670
+ .record(z.string(), z.any())
671
+ .optional()
672
+ .describe("Escape hatch to edit ANY other project field the named props above don't cover — e.g. " +
673
+ "weight, platform, voice_relationship, booking_link, qualification, subreddit_bans, " +
674
+ "short_links_host, short_links_live, content_angle, messaging, landing_pages, posthog. " +
675
+ "Pass {name:'<project>', fields:{<key>:<value>, ...}}; each key SHALLOW-merges onto the " +
676
+ "project, REPLACING that key's whole value (read the current value via status:true first if " +
677
+ "you only want to tweak part of a nested object, then pass the full new value). A value of " +
678
+ "null DELETES the key. 'name' is ignored here (can't rename through this path). This is how " +
679
+ "you edit advanced config without any raw whole-file overwrite."),
658
680
  },
659
681
  }, async (args) => {
660
682
  // ---- List import sources (for the panel dropdown) ---------------------
@@ -707,7 +729,7 @@ tool("setup", {
707
729
  "Allow (or Always Allow). If you use more than one browser you may see it a couple of times, " +
708
730
  "once per browser.",
709
731
  how_to_proceed: "If the user explicitly requested setup or connection, relay the say_to_user line as a brief " +
710
- "heads-up and immediately call setup again with action:'connect_x', confirm:true; do not wait " +
732
+ "heads-up and immediately call project_config again with action:'connect_x', confirm:true; do not wait " +
711
733
  "for another yes/no reply. Optionally pass the recommended x_source. If the user only asked " +
712
734
  "what connection would do, stop after this preview.",
713
735
  });
@@ -744,12 +766,12 @@ tool("setup", {
744
766
  : undefined,
745
767
  onboarding: onboardingSnapshot(),
746
768
  next_step: r.connected
747
- ? "X is connected. Next, run setup action:'profile_scan' to read this account's bio + recent " +
769
+ ? "X is connected. Next, run project_config action:'profile_scan' to read this account's bio + recent " +
748
770
  "posts + replies and draft the project's voice/icp/search_topics in the user's own register " +
749
771
  "before saving. Then run draft_cycle once the project is fully set up."
750
772
  : r.state === "needs_login"
751
773
  ? "The user must finish signing in to x.com in the Chrome window that just opened. Tell " +
752
- "them that single required action, then call setup action:'connect_x', confirm:true again."
774
+ "them that single required action, then call project_config action:'connect_x', confirm:true again."
753
775
  : "X is not connected yet. " + summarizeXAuth(r),
754
776
  });
755
777
  }
@@ -758,14 +780,14 @@ tool("setup", {
758
780
  // successful connect_x) to read the user's bio + recent posts + recent
759
781
  // replies. Returns the raw corpus plus grounding_instructions; synthesis of
760
782
  // voice/icp/topics happens IN THIS CONVERSATION (no nested model), then the
761
- // agent confirms with the user and calls setup to persist. Read-only.
783
+ // agent confirms with the user and calls project_config to persist. Read-only.
762
784
  if (args.action === "profile_scan") {
763
785
  // Handle is auto-detected from the live logged-in session by the scanner.
764
786
  recordOnboardingAttempt("profile_scanned");
765
787
  const scan = await xScanProfile();
766
788
  if (!scan.ok) {
767
789
  const hint = scan.state === "browser_not_running" || scan.state === "no_handle"
768
- ? " Run setup action:'connect_x' (confirm:true) first so the account is connected, then retry profile_scan."
790
+ ? " Run project_config action:'connect_x' (confirm:true) first so the account is connected, then retry profile_scan."
769
791
  : "";
770
792
  blockOnboardingMilestone("profile_scanned", `profile_${scan.state || "failed"}`, scan.error || "profile scan failed", { state: scan.state || "failed" });
771
793
  return jsonContent({
@@ -838,7 +860,7 @@ tool("setup", {
838
860
  update_available: ver.update_available,
839
861
  update_hint: ver.update_available
840
862
  ? `A newer version (${ver.latest}) is available — you're on ${ver.installed}. ` +
841
- `Tell the user and offer to run the \`version\` tool with action:'update' ` +
863
+ `Tell the user and offer to run the \`runtime\` tool with action:'update' ` +
842
864
  `(or \`npx social-autoposter@latest update\`).`
843
865
  : undefined,
844
866
  required_fields: REQUIRED_FIELDS,
@@ -847,16 +869,16 @@ tool("setup", {
847
869
  ready_for_verification: rtReady && configured && x.connected,
848
870
  onboarding: onboardingSnapshot(),
849
871
  next_step: !rtReady
850
- ? "Runtime is not ready. Call install_runtime, poll install_status to completion, then continue setup automatically."
872
+ ? "Runtime is not ready. Call runtime action:'install', poll runtime action:'status' to completion, then continue setup automatically."
851
873
  : projects.length === 0
852
- ? "No projects yet. Discover the product from conversation context and the connected X profile; research its website, infer a conservative complete project, and call setup. Ask only if no product can be identified." +
874
+ ? "No projects yet. Discover the product from conversation context and the connected X profile; research its website, infer a conservative complete project, and call project_config. Ask only if no product can be identified." +
853
875
  (x.connected ? "" : " X is not connected yet either — detect_x_sources, warn about keychain prompts, then run connect_x with confirm:true without a separate permission turn.")
854
876
  : projects.every((p) => p.ready)
855
877
  ? (x.connected
856
878
  ? "All configured projects are ready and X is connected. Run draft_cycle now to verify end to end without posting. After it verifies, call the `dashboard` tool so the user sees the finished setup."
857
879
  : "All configured projects are ready, but X is NOT connected — posting needs a logged-in " +
858
- "x.com session. Detect sources and run setup action:'connect_x', confirm:true; do not ask whether to proceed.")
859
- : "Some projects are missing required fields (see each project's missing_required). Derive them from config, context, profile_scan, and website research, then call setup again. Ask only if a required field is genuinely unknowable." +
880
+ "x.com session. Detect sources and run project_config action:'connect_x', confirm:true; do not ask whether to proceed.")
881
+ : "Some projects are missing required fields (see each project's missing_required). Derive them from config, context, profile_scan, and website research, then call project_config again. Ask only if a required field is genuinely unknowable." +
860
882
  (x.connected ? "" : " X is also not connected yet; detect sources and run connect_x with confirm:true."),
861
883
  });
862
884
  }
@@ -938,6 +960,17 @@ tool("setup", {
938
960
  }
939
961
  }
940
962
  }
963
+ // Surface any advanced (escape-hatch) field edits in the note so the
964
+ // agent can confirm exactly what changed to the user.
965
+ let advancedNote = "";
966
+ if (result.fields_set.length || result.fields_removed.length) {
967
+ const parts = [];
968
+ if (result.fields_set.length)
969
+ parts.push(`set ${result.fields_set.join(", ")}`);
970
+ if (result.fields_removed.length)
971
+ parts.push(`removed ${result.fields_removed.join(", ")}`);
972
+ advancedNote = ` Advanced fields updated: ${parts.join("; ")}.`;
973
+ }
941
974
  return jsonContent({
942
975
  ok: true,
943
976
  project: result.project,
@@ -947,16 +980,19 @@ tool("setup", {
947
980
  topics_seeded: topicsSeeded,
948
981
  topic_count: topicCount,
949
982
  search_queries: searchQueries,
983
+ fields_set: result.fields_set,
984
+ fields_removed: result.fields_removed,
950
985
  config_path: configPath(),
951
986
  onboarding: onboardingSnapshot(),
952
- note: result.ready
987
+ note: (result.ready
953
988
  ? `Project '${result.project}' is fully configured.${seedNote} Next: if X is not connected, ` +
954
- `detect sources, warn about keychain prompts, and call setup with ` +
989
+ `detect sources, warn about keychain prompts, and call project_config with ` +
955
990
  `action:'connect_x', confirm:true immediately. Once X is connected, run draft_cycle to ` +
956
991
  `verify without posting. Do not enable autopilot unless explicitly requested.`
957
992
  : `Saved what you provided for '${result.project}'. Still need: ${result.missing_required.join(", ")}. ` +
958
993
  `First derive those fields from existing context, profile_scan, and website research, then ` +
959
- `call setup again with name='${result.project}'. Ask only if a required field is genuinely unknowable.`,
994
+ `call project_config again with name='${result.project}'. Ask only if a required field is genuinely unknowable.`) +
995
+ advancedNote,
960
996
  });
961
997
  }
962
998
  catch (e) {
@@ -1175,7 +1211,7 @@ tool("autopilot", {
1175
1211
  },
1176
1212
  }, async ({ action }) => {
1177
1213
  if (action !== "status" && !hasReadyProject()) {
1178
- return textContent("No project is fully set up yet, so autopilot has nothing to post. Run the `setup` tool " +
1214
+ return textContent("No project is fully set up yet, so autopilot has nothing to post. Run the `project_config` tool " +
1179
1215
  "first. Note: autopilot runs the background cycle across all configured projects; it is " +
1180
1216
  "not scoped to one project.");
1181
1217
  }
@@ -1282,29 +1318,90 @@ tool("get_stats", {
1282
1318
  }
1283
1319
  });
1284
1320
  // ---- version: report installed version + deliver updates on demand ---------
1285
- tool("version", {
1286
- title: "Version & updates",
1287
- description: "Report the installed social-autoposter version and check npm for a newer release. " +
1288
- "action:'status' (default) shows installed vs latest published and whether an update is " +
1289
- "available. action:'update' pulls and installs the latest release (runs " +
1290
- "`npx social-autoposter@latest update`); the new MCP code takes effect after the client " +
1291
- "reconnects / restarts (this running process keeps the old code until then). Use this when " +
1292
- "the user asks what version they're on, or to push the latest update to their machine.",
1321
+ // ---- runtime: install + version/update + diagnostics ----------------------
1322
+ // ONE plumbing tool for the whole local-runtime lifecycle, action-based like
1323
+ // project_config and autopilot. The pipeline runs Python locally; rather than
1324
+ // depend on the user's system Python (the #1 source of install failures), the
1325
+ // first run provisions a fully OWNED uv runtime: standalone CPython + owned venv
1326
+ // + deps + Chromium. It also reports/installs new releases and runs the Doctor.
1327
+ // Plain (non-UI) so EVERY host can drive it the panel's Install card and
1328
+ // Update button are just skins that call action:'install' then poll
1329
+ // action:'status'. See runtime.ts for the provisioning + progress contract.
1330
+ //
1331
+ // Actions:
1332
+ // status (default) — is the owned runtime installed? + in-progress step detail
1333
+ // install — start provisioning in the background; poll status to follow
1334
+ // version — installed vs latest published, whether an update is available
1335
+ // update — pull + install the latest release (npx social-autoposter@latest update)
1336
+ // doctor — run structured environment diagnostics (phase: pre_connect|full)
1337
+ // doctor_status — last persisted Doctor result without re-running checks
1338
+ tool("runtime", {
1339
+ title: "Runtime: install, update & diagnostics",
1340
+ description: "The ONE plumbing tool for the autoposter's local runtime lifecycle. action:'status' (default) " +
1341
+ "reports whether the self-contained Python/Chromium runtime is installed and, mid-install, the " +
1342
+ "per-step progress (uv, Python, venv, dependencies, Chromium) — poll it after action:'install'. " +
1343
+ "action:'install' provisions that runtime (a private Python via uv, NOT your system Python, plus " +
1344
+ "deps and Chromium); it runs in the background and returns immediately, is safe to call " +
1345
+ "repeatedly, and is a no-op once installed. action:'version' shows installed vs latest published " +
1346
+ "and whether an update is available; action:'update' pulls and installs the latest release (runs " +
1347
+ "`npx social-autoposter@latest update`, taking effect after the client reconnects/restarts). " +
1348
+ "action:'doctor' runs structured environment diagnostics (phase:'pre_connect' is safe at " +
1349
+ "onboarding start and treats the missing X session/cookies as expected; phase:'full' verifies the " +
1350
+ "completed environment after X is connected); action:'doctor_status' returns the last persisted " +
1351
+ "Doctor result without re-running. Use this the first time the user sets up, when another tool " +
1352
+ "reports the runtime isn't ready, when the user asks what version they're on or to update, or to " +
1353
+ "diagnose a broken environment.",
1293
1354
  inputSchema: {
1294
- action: z.enum(["status", "update"]).optional(),
1355
+ action: z
1356
+ .enum(["status", "install", "version", "update", "doctor", "doctor_status"])
1357
+ .optional(),
1358
+ phase: z
1359
+ .enum(["pre_connect", "full"])
1360
+ .optional()
1361
+ .describe("Only for action:'doctor' — which diagnostic phase to run (default pre_connect)."),
1295
1362
  },
1296
- }, async ({ action }) => {
1363
+ }, async ({ action, phase }) => {
1364
+ // ---- install: start provisioning the owned runtime --------------------
1365
+ if (action === "install") {
1366
+ if (runtimeReady()) {
1367
+ completeOnboardingMilestone("runtime_ready");
1368
+ return jsonContent({ already_installed: true, ...runtimeSnapshot() });
1369
+ }
1370
+ recordOnboardingAttempt("runtime_ready");
1371
+ const progress = startProvisioning();
1372
+ return jsonContent({
1373
+ started: true,
1374
+ runtime_ready: false,
1375
+ note: "Runtime install started. Poll runtime action:'status' every ~1.5s for progress.",
1376
+ progress,
1377
+ });
1378
+ }
1379
+ // ---- version: installed vs latest published ---------------------------
1380
+ if (action === "version") {
1381
+ const v = await versionStatus();
1382
+ return jsonContent({
1383
+ installed: v.installed,
1384
+ latest_published: v.latest,
1385
+ update_available: v.update_available,
1386
+ update_command: "npx social-autoposter@latest update",
1387
+ note: v.latest == null
1388
+ ? "Could not reach npm to check for a newer version (offline or registry error)."
1389
+ : v.update_available
1390
+ ? `A newer version (${v.latest}) is available. Run this tool with action:'update' ` +
1391
+ "to install it, or run `npx social-autoposter@latest update` in a terminal."
1392
+ : "You are on the latest published version.",
1393
+ });
1394
+ }
1395
+ // ---- update: pull + install the latest release ------------------------
1297
1396
  if (action === "update") {
1298
- // Pull + install the latest published release. This overwrites mcp/dist/
1299
- // (including this running file safe; the loaded process keeps old code)
1300
- // and re-runs install.mjs to re-register the client config. npx is run
1301
- // non-interactively so it can't stall on a confirm prompt.
1397
+ // Overwrites mcp/dist/ (including this running file safe; the loaded
1398
+ // process keeps old code) and re-runs install.mjs to re-register the
1399
+ // client config. npx is non-interactive so it can't stall on a confirm.
1302
1400
  const before = VERSION;
1303
1401
  const res = await run("npx", ["-y", "social-autoposter@latest", "update"], {
1304
1402
  timeoutMs: 600_000,
1305
1403
  });
1306
- // Bust the latest-version cache so the post-update number is fresh.
1307
- const latest = await latestPublishedVersion();
1404
+ const latest = await latestPublishedVersion(); // bust the cache
1308
1405
  return jsonContent({
1309
1406
  action: "update",
1310
1407
  ran: "npx social-autoposter@latest update",
@@ -1317,27 +1414,29 @@ tool("version", {
1317
1414
  output_tail: (res.stdout + "\n" + res.stderr).trim().split("\n").slice(-20).join("\n"),
1318
1415
  });
1319
1416
  }
1320
- const v = await versionStatus();
1321
- return jsonContent({
1322
- installed: v.installed,
1323
- latest_published: v.latest,
1324
- update_available: v.update_available,
1325
- update_command: "npx social-autoposter@latest update",
1326
- note: v.latest == null
1327
- ? "Could not reach npm to check for a newer version (offline or registry error)."
1328
- : v.update_available
1329
- ? `A newer version (${v.latest}) is available. Run this tool with action:'update' ` +
1330
- "to install it, or run `npx social-autoposter@latest update` in a terminal."
1331
- : "You are on the latest published version.",
1332
- });
1417
+ // ---- doctor: run structured diagnostics -------------------------------
1418
+ if (action === "doctor") {
1419
+ const selected = phase || "pre_connect";
1420
+ const report = await runDoctorPhase(selected);
1421
+ return jsonContent({ doctor: report, onboarding: onboardingSnapshot() });
1422
+ }
1423
+ // ---- doctor_status: last persisted Doctor result ----------------------
1424
+ if (action === "doctor_status") {
1425
+ return jsonContent({
1426
+ doctor: onboardingLedger()?.doctor?.latest ?? null,
1427
+ onboarding: onboardingSnapshot(),
1428
+ });
1429
+ }
1430
+ // ---- status (default): runtime install snapshot -----------------------
1431
+ const snapshot = runtimeSnapshot();
1432
+ if (snapshot.runtime_ready) {
1433
+ completeOnboardingMilestone("runtime_ready");
1434
+ }
1435
+ else if (snapshot.progress?.done && !snapshot.progress.ok) {
1436
+ blockOnboardingMilestone("runtime_ready", "runtime_install_failed", snapshot.progress.error || "Runtime installation failed", { outcome: "failed" });
1437
+ }
1438
+ return jsonContent({ ...snapshot, onboarding: onboardingSnapshot() });
1333
1439
  });
1334
- // ---- runtime installer ----------------------------------------------------
1335
- // The pipeline runs Python locally. Rather than depend on the user's system
1336
- // Python (the #1 source of install failures), the first run provisions a fully
1337
- // OWNED uv runtime: standalone CPython + owned venv + deps + Chromium. These two
1338
- // tools drive it. They are plain (non-UI) tools so EVERY host can install — the
1339
- // panel's Install card is just a skin that calls install_runtime then polls
1340
- // install_status. See runtime.ts for the provisioning + progress contract.
1341
1440
  function runtimeSnapshot() {
1342
1441
  const rt = readRuntime();
1343
1442
  const progress = readProgress();
@@ -1350,138 +1449,10 @@ function runtimeSnapshot() {
1350
1449
  onboarding: onboardingSnapshot(),
1351
1450
  };
1352
1451
  }
1353
- tool("install_runtime", {
1354
- title: "Install the Python runtime",
1355
- description: "One-time setup that provisions the self-contained runtime the autoposter needs: a private " +
1356
- "Python (via uv, not your system Python), its dependencies, and the Chromium browser. Runs in " +
1357
- "the background and returns immediately; poll `install_status` for progress. Safe to call " +
1358
- "repeatedly; it resumes/repairs and is a no-op once everything is installed. Use this the " +
1359
- "first time the user sets up, or if other tools report the runtime isn't ready.",
1360
- inputSchema: {},
1361
- }, async () => {
1362
- if (runtimeReady()) {
1363
- completeOnboardingMilestone("runtime_ready");
1364
- return jsonContent({ already_installed: true, ...runtimeSnapshot() });
1365
- }
1366
- recordOnboardingAttempt("runtime_ready");
1367
- const progress = startProvisioning();
1368
- return jsonContent({
1369
- started: true,
1370
- runtime_ready: false,
1371
- note: "Runtime install started. Poll install_status every ~1.5s for progress.",
1372
- progress,
1373
- });
1374
- });
1375
- tool("install_status", {
1376
- title: "Runtime install status",
1377
- description: "Report whether the self-contained Python/Chromium runtime is installed and, if an install is " +
1378
- "in progress, the per-step progress (uv, Python, venv, dependencies, Chromium). Poll this after " +
1379
- "install_runtime to follow the install to completion.",
1380
- inputSchema: {},
1381
- }, async () => {
1382
- const snapshot = runtimeSnapshot();
1383
- if (snapshot.runtime_ready) {
1384
- completeOnboardingMilestone("runtime_ready");
1385
- }
1386
- else if (snapshot.progress?.done && !snapshot.progress.ok) {
1387
- blockOnboardingMilestone("runtime_ready", "runtime_install_failed", snapshot.progress.error || "Runtime installation failed", { outcome: "failed" });
1388
- }
1389
- return jsonContent({ ...snapshot, onboarding: onboardingSnapshot() });
1390
- });
1391
- // ---- doctor: structured environment diagnostics --------------------------
1392
- // Uses the same shared engine as `npx social-autoposter doctor`. Pre-connect
1393
- // avoids invasive keychain access and classifies not-yet-created X artifacts as
1394
- // expected; full verifies the completed browser/session/cookie environment.
1395
- tool("doctor", {
1396
- title: "Diagnose the onboarding environment",
1397
- description: "Run the structured social-autoposter Doctor and persist the result in the onboarding " +
1398
- "ledger. phase:'pre_connect' is safe at onboarding start and treats the missing X session/" +
1399
- "cookie artifacts as expected. phase:'full' verifies the completed environment after X is " +
1400
- "connected. action:'status' returns the most recent persisted result without re-running checks.",
1401
- inputSchema: {
1402
- action: z.enum(["run", "status"]).optional(),
1403
- phase: z.enum(["pre_connect", "full"]).optional(),
1404
- },
1405
- }, async ({ action, phase, }) => {
1406
- if (action === "status") {
1407
- return jsonContent({
1408
- doctor: onboardingLedger()?.doctor?.latest ?? null,
1409
- onboarding: onboardingSnapshot(),
1410
- });
1411
- }
1412
- const selected = phase || "pre_connect";
1413
- const report = await runDoctorPhase(selected);
1414
- return jsonContent({
1415
- doctor: report,
1416
- onboarding: onboardingSnapshot(),
1417
- });
1418
- });
1419
- // ---- config: read / edit the raw config.json ------------------------------
1420
- // The panel renders the full config and lets the user edit it. Writing is
1421
- // guarded: the new content must parse as JSON, and we always drop a timestamped
1422
- // backup next to config.json before overwriting, so a bad paste is recoverable.
1423
- tool("config", {
1424
- title: "View or edit config.json",
1425
- description: "Read or update the autoposter's config.json (the source of truth for every project, the X/" +
1426
- "Reddit/LinkedIn account handles, topics, and exclusions). action:'get' (default) returns the " +
1427
- "full raw JSON; action:'save' validates the supplied `content` as JSON, writes a timestamped " +
1428
- "backup, then overwrites config.json. Use when the user asks to see, edit, or fix their config.",
1429
- inputSchema: {
1430
- action: z.enum(["get", "save"]).optional(),
1431
- content: z.string().optional(),
1432
- },
1433
- }, async (args) => {
1434
- const action = args.action || "get";
1435
- const cfgPath = configPath();
1436
- if (action === "get") {
1437
- try {
1438
- const content = fs.readFileSync(cfgPath, "utf-8");
1439
- return jsonContent({ ok: true, path: cfgPath, bytes: content.length, content });
1440
- }
1441
- catch (e) {
1442
- return jsonContent({ ok: false, path: cfgPath, error: String(e?.message || e) });
1443
- }
1444
- }
1445
- // save
1446
- const content = args.content;
1447
- if (typeof content !== "string" || content.trim() === "") {
1448
- return jsonContent({ ok: false, error: "Nothing to save: `content` was empty." });
1449
- }
1450
- let parsed;
1451
- try {
1452
- parsed = JSON.parse(content);
1453
- }
1454
- catch (e) {
1455
- // Don't write a config that won't parse — every pipeline reads this file.
1456
- return jsonContent({ ok: false, error: "Invalid JSON, not saved: " + String(e?.message || e) });
1457
- }
1458
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1459
- return jsonContent({ ok: false, error: "Top level of config.json must be a JSON object." });
1460
- }
1461
- try {
1462
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1463
- const backup = `${cfgPath}.bak-panel-${stamp}`;
1464
- try {
1465
- fs.copyFileSync(cfgPath, backup);
1466
- }
1467
- catch {
1468
- /* first-write / missing original is non-fatal */
1469
- }
1470
- // Re-serialize the parsed object so what lands on disk is canonical,
1471
- // 2-space-indented JSON with a trailing newline (matches the Python
1472
- // writers), regardless of how the user formatted their paste.
1473
- const out = JSON.stringify(parsed, null, 2) + "\n";
1474
- fs.writeFileSync(cfgPath, out, "utf-8");
1475
- return jsonContent({ ok: true, path: cfgPath, bytes: out.length, backup });
1476
- }
1477
- catch (e) {
1478
- return jsonContent({ ok: false, error: "Write failed: " + String(e?.message || e) });
1479
- }
1480
- });
1481
1452
  // ---- panel: MCP Apps control surface --------------------------------------
1482
1453
  // A self-contained HTML view rendered by hosts that support MCP Apps (Claude
1483
1454
  // desktop/web, etc.). It duplicates NO pipeline logic: each button calls one of
1484
- // the tools above (draft_cycle / autopilot / setup / get_stats) through the host
1455
+ // the tools above (draft_cycle / autopilot / project_config / get_stats) through the host
1485
1456
  // and re-reads status. The tool itself returns the first-paint snapshot so the
1486
1457
  // view has data the instant it loads.
1487
1458
  // Is either launchd job (cycle / daily updater) currently loaded?
@@ -1641,19 +1612,39 @@ function startLocalPanel() {
1641
1612
  }
1642
1613
  });
1643
1614
  srv.on("error", reject);
1644
- srv.listen(0, "127.0.0.1", () => {
1615
+ // Optional fixed port (SAPS_PANEL_PORT) for deterministic addressing; default
1616
+ // is an OS-assigned ephemeral port.
1617
+ const wantPort = Number(process.env.SAPS_PANEL_PORT) || 0;
1618
+ srv.listen(wantPort, "127.0.0.1", () => {
1645
1619
  const addr = srv.address();
1646
1620
  const port = typeof addr === "object" && addr ? addr.port : 0;
1647
1621
  localPanel = { url: `http://127.0.0.1:${port}/`, server: srv };
1622
+ writePanelUrl(localPanel.url);
1648
1623
  resolve(localPanel.url);
1649
1624
  });
1650
1625
  });
1651
1626
  }
1652
- // Open a URL in the user's default browser, cross-platform. Honors
1653
- // SAPS_PANEL_NO_OPEN (set on headless autopilot boxes or in tests) to skip the
1654
- // actual open while still returning the URL to the caller.
1627
+ // Publish the loopback URL to a stable file so an out-of-process reader (the
1628
+ // Claude Code side-panel reverse proxy) can find the ephemeral port without
1629
+ // scraping `lsof`. Best-effort: a write failure never blocks the panel.
1630
+ function writePanelUrl(url) {
1631
+ try {
1632
+ const dir = path.join(process.env.HOME || os.homedir(), ".social-autoposter-mcp");
1633
+ fs.mkdirSync(dir, { recursive: true });
1634
+ fs.writeFileSync(path.join(dir, "panel-url"), url, "utf-8");
1635
+ }
1636
+ catch (e) {
1637
+ console.error("[social-autoposter-mcp] writePanelUrl failed:", e?.message || e);
1638
+ }
1639
+ }
1640
+ // Open a URL in the user's default browser, cross-platform. Opening is OPT-IN:
1641
+ // by default we do NOT pop a browser tab. The dashboard already surfaces in-host
1642
+ // (MCP Apps inline) or via the Claude Code side panel / returned loopback URL, so
1643
+ // auto-opening the OS browser on every dashboard call is unwanted noise. Set
1644
+ // SAPS_PANEL_OPEN_BROWSER=1 to restore the old auto-open behavior. (The URL is
1645
+ // always returned to the caller regardless, so nothing is lost when we don't open.)
1655
1646
  async function openInBrowser(url) {
1656
- if (process.env.SAPS_PANEL_NO_OPEN)
1647
+ if (!process.env.SAPS_PANEL_OPEN_BROWSER)
1657
1648
  return;
1658
1649
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
1659
1650
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
@@ -1699,8 +1690,9 @@ appTool("dashboard", {
1699
1690
  return { content: [], structuredContent: { snapshot: JSON.stringify(snap) } };
1700
1691
  }
1701
1692
  // Host CAN'T render inline (Claude Code / Cowork today): serve the identical
1702
- // panel.html from a loopback HTTP server and open it in the browser, so the
1703
- // user still gets the visual surface instead of a wall of text.
1693
+ // panel.html from a loopback HTTP server. We do NOT auto-open a browser tab
1694
+ // (see openInBrowser opt-in only); the dashboard is shown in the Claude Code
1695
+ // side panel, and the loopback URL is returned for anyone who wants to open it.
1704
1696
  try {
1705
1697
  const url = await startLocalPanel();
1706
1698
  await openInBrowser(url);
@@ -1708,7 +1700,7 @@ appTool("dashboard", {
1708
1700
  content: [{
1709
1701
  type: "text",
1710
1702
  text: human +
1711
- `\n\nThis host can't render the dashboard inline, so I opened it in your browser: ${url}`,
1703
+ `\n\nThis host can't render the dashboard inline. It's available in the side panel; loopback URL: ${url}`,
1712
1704
  }],
1713
1705
  structuredContent: { snapshot: JSON.stringify(snap), fallback_url: url },
1714
1706
  };
@@ -1804,6 +1796,12 @@ async function main() {
1804
1796
  const transport = new StdioServerTransport();
1805
1797
  await server.connect(transport);
1806
1798
  console.error(`[social-autoposter-mcp] connected. v=${VERSION} repo=${repoDir()}`);
1799
+ // Eagerly start the loopback panel server so the Claude Code side panel (and any
1800
+ // reverse proxy in front of it) always has a backend to hit, without waiting for
1801
+ // a first `dashboard` call. Best-effort: a bind failure must never block boot.
1802
+ void startLocalPanel()
1803
+ .then((url) => console.error(`[social-autoposter-mcp] panel loopback ready at ${url}`))
1804
+ .catch((e) => console.error("[social-autoposter-mcp] panel loopback start failed:", e?.message || e));
1807
1805
  // Phone home so this .mcpb install is visible in the install-lane digest
1808
1806
  // (parity with the npx launchd heartbeat). Once on startup, then every 15m
1809
1807
  // while the desktop app keeps the server alive. unref() so it never holds the