social-autoposter 1.6.67 → 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 " +
@@ -530,7 +534,10 @@ server.registerPrompt("getting-started", {
530
534
  "verification. Keep going without asking me to approve each safe setup step. A brief " +
531
535
  "heads-up before macOS keychain prompts is enough; proceed immediately. Ask only if an " +
532
536
  "interactive login is unavoidable or no product can be identified from config, context, " +
533
- "my X profile, or public research. Do not post or enable autopilot unless I explicitly ask.",
537
+ "my X profile, or public research. Do not post or enable autopilot unless I explicitly ask. " +
538
+ "Keep every reply to me extremely concise: a few short sentences at most, no step-by-step " +
539
+ "narration or long status walls. If you must ask me something (e.g. the product URL), make " +
540
+ "it one short question.",
534
541
  },
535
542
  },
536
543
  ],
@@ -558,29 +565,36 @@ const WEBSITE_RESEARCH_INSTRUCTIONS = "PRODUCT RESEARCH (do this before saving t
558
565
  "register, vibe) while keeping every product CLAIM factual to the site. Don't invent " +
559
566
  "features, metrics, or guarantees the site doesn't state.\n" +
560
567
  "5. Save the best conservative factual draft without adding a confirmation round-trip. Call " +
561
- "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 " +
562
569
  "site is thin or unreachable, use only supported facts and leave optional detail conservative; " +
563
570
  "ask the user only if a required field is genuinely unknowable.";
564
- // ---- setup: per-project config (the "brain": project, website, voice) -----
571
+ // ---- project_config: per-project config (the "brain": project, website, voice) -----
565
572
  // Run this FIRST. The action tools refuse until at least one project is ready.
566
573
  // You can set up MULTIPLE products and fill each project's fields INCREMENTALLY
567
574
  // across several calls — readiness is derived from config.json, never a stored
568
575
  // flag. Call with status:true (or just no name) to list every project this
569
576
  // install manages and what each still needs.
570
- tool("setup", {
571
- title: "Set up a project",
572
- description: "Run this FIRST, before any drafting or autopilot. A user's request to set up social-autoposter " +
573
- "is a request to finish the workflow end to end, not to interview them step by step. Resume " +
574
- "from current status, infer discoverable fields, and keep taking safe actions until runtime, " +
575
- "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" +
576
589
  "Two jobs:\n" +
577
- "1) Configure a project this install posts for: its website, what it does (description), who " +
578
- "to target (icp), and brand voice. To fill the PRODUCT fields, discover the product URL from " +
579
- "config, conversation context, the connected X profile, or public research, then visit it " +
580
- "with your own browser/fetch tools — read 5+ pages (home, pricing, features, about, docs/" +
581
- "blog, FAQ) to learn it deeply, rather than guessing from the name. Set up MULTIPLE products " +
582
- "(call once per product, identified by name); fill a project's fields INCREMENTALLY across " +
583
- "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" +
584
598
  "2) Connect X/Twitter (action:'connect_x'): the autoposter posts through its OWN managed Chrome, " +
585
599
  "which needs your logged-in x.com session. This imports x.com/twitter.com cookies from your " +
586
600
  "everyday browser (Chrome/Arc/Brave/Edge, auto-detected) into that browser — nothing else is " +
@@ -652,6 +666,17 @@ tool("setup", {
652
666
  .string()
653
667
  .optional()
654
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."),
655
680
  },
656
681
  }, async (args) => {
657
682
  // ---- List import sources (for the panel dropdown) ---------------------
@@ -704,7 +729,7 @@ tool("setup", {
704
729
  "Allow (or Always Allow). If you use more than one browser you may see it a couple of times, " +
705
730
  "once per browser.",
706
731
  how_to_proceed: "If the user explicitly requested setup or connection, relay the say_to_user line as a brief " +
707
- "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 " +
708
733
  "for another yes/no reply. Optionally pass the recommended x_source. If the user only asked " +
709
734
  "what connection would do, stop after this preview.",
710
735
  });
@@ -741,12 +766,12 @@ tool("setup", {
741
766
  : undefined,
742
767
  onboarding: onboardingSnapshot(),
743
768
  next_step: r.connected
744
- ? "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 " +
745
770
  "posts + replies and draft the project's voice/icp/search_topics in the user's own register " +
746
771
  "before saving. Then run draft_cycle once the project is fully set up."
747
772
  : r.state === "needs_login"
748
773
  ? "The user must finish signing in to x.com in the Chrome window that just opened. Tell " +
749
- "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."
750
775
  : "X is not connected yet. " + summarizeXAuth(r),
751
776
  });
752
777
  }
@@ -755,14 +780,14 @@ tool("setup", {
755
780
  // successful connect_x) to read the user's bio + recent posts + recent
756
781
  // replies. Returns the raw corpus plus grounding_instructions; synthesis of
757
782
  // voice/icp/topics happens IN THIS CONVERSATION (no nested model), then the
758
- // 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.
759
784
  if (args.action === "profile_scan") {
760
785
  // Handle is auto-detected from the live logged-in session by the scanner.
761
786
  recordOnboardingAttempt("profile_scanned");
762
787
  const scan = await xScanProfile();
763
788
  if (!scan.ok) {
764
789
  const hint = scan.state === "browser_not_running" || scan.state === "no_handle"
765
- ? " 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."
766
791
  : "";
767
792
  blockOnboardingMilestone("profile_scanned", `profile_${scan.state || "failed"}`, scan.error || "profile scan failed", { state: scan.state || "failed" });
768
793
  return jsonContent({
@@ -835,7 +860,7 @@ tool("setup", {
835
860
  update_available: ver.update_available,
836
861
  update_hint: ver.update_available
837
862
  ? `A newer version (${ver.latest}) is available — you're on ${ver.installed}. ` +
838
- `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' ` +
839
864
  `(or \`npx social-autoposter@latest update\`).`
840
865
  : undefined,
841
866
  required_fields: REQUIRED_FIELDS,
@@ -844,16 +869,16 @@ tool("setup", {
844
869
  ready_for_verification: rtReady && configured && x.connected,
845
870
  onboarding: onboardingSnapshot(),
846
871
  next_step: !rtReady
847
- ? "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."
848
873
  : projects.length === 0
849
- ? "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." +
850
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.")
851
876
  : projects.every((p) => p.ready)
852
877
  ? (x.connected
853
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."
854
879
  : "All configured projects are ready, but X is NOT connected — posting needs a logged-in " +
855
- "x.com session. Detect sources and run setup action:'connect_x', confirm:true; do not ask whether to proceed.")
856
- : "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." +
857
882
  (x.connected ? "" : " X is also not connected yet; detect sources and run connect_x with confirm:true."),
858
883
  });
859
884
  }
@@ -935,6 +960,17 @@ tool("setup", {
935
960
  }
936
961
  }
937
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
+ }
938
974
  return jsonContent({
939
975
  ok: true,
940
976
  project: result.project,
@@ -944,16 +980,19 @@ tool("setup", {
944
980
  topics_seeded: topicsSeeded,
945
981
  topic_count: topicCount,
946
982
  search_queries: searchQueries,
983
+ fields_set: result.fields_set,
984
+ fields_removed: result.fields_removed,
947
985
  config_path: configPath(),
948
986
  onboarding: onboardingSnapshot(),
949
- note: result.ready
987
+ note: (result.ready
950
988
  ? `Project '${result.project}' is fully configured.${seedNote} Next: if X is not connected, ` +
951
- `detect sources, warn about keychain prompts, and call setup with ` +
989
+ `detect sources, warn about keychain prompts, and call project_config with ` +
952
990
  `action:'connect_x', confirm:true immediately. Once X is connected, run draft_cycle to ` +
953
991
  `verify without posting. Do not enable autopilot unless explicitly requested.`
954
992
  : `Saved what you provided for '${result.project}'. Still need: ${result.missing_required.join(", ")}. ` +
955
993
  `First derive those fields from existing context, profile_scan, and website research, then ` +
956
- `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,
957
996
  });
958
997
  }
959
998
  catch (e) {
@@ -1172,7 +1211,7 @@ tool("autopilot", {
1172
1211
  },
1173
1212
  }, async ({ action }) => {
1174
1213
  if (action !== "status" && !hasReadyProject()) {
1175
- 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 " +
1176
1215
  "first. Note: autopilot runs the background cycle across all configured projects; it is " +
1177
1216
  "not scoped to one project.");
1178
1217
  }
@@ -1279,29 +1318,90 @@ tool("get_stats", {
1279
1318
  }
1280
1319
  });
1281
1320
  // ---- version: report installed version + deliver updates on demand ---------
1282
- tool("version", {
1283
- title: "Version & updates",
1284
- description: "Report the installed social-autoposter version and check npm for a newer release. " +
1285
- "action:'status' (default) shows installed vs latest published and whether an update is " +
1286
- "available. action:'update' pulls and installs the latest release (runs " +
1287
- "`npx social-autoposter@latest update`); the new MCP code takes effect after the client " +
1288
- "reconnects / restarts (this running process keeps the old code until then). Use this when " +
1289
- "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.",
1290
1354
  inputSchema: {
1291
- 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)."),
1292
1362
  },
1293
- }, 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 ------------------------
1294
1396
  if (action === "update") {
1295
- // Pull + install the latest published release. This overwrites mcp/dist/
1296
- // (including this running file safe; the loaded process keeps old code)
1297
- // and re-runs install.mjs to re-register the client config. npx is run
1298
- // 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.
1299
1400
  const before = VERSION;
1300
1401
  const res = await run("npx", ["-y", "social-autoposter@latest", "update"], {
1301
1402
  timeoutMs: 600_000,
1302
1403
  });
1303
- // Bust the latest-version cache so the post-update number is fresh.
1304
- const latest = await latestPublishedVersion();
1404
+ const latest = await latestPublishedVersion(); // bust the cache
1305
1405
  return jsonContent({
1306
1406
  action: "update",
1307
1407
  ran: "npx social-autoposter@latest update",
@@ -1314,27 +1414,29 @@ tool("version", {
1314
1414
  output_tail: (res.stdout + "\n" + res.stderr).trim().split("\n").slice(-20).join("\n"),
1315
1415
  });
1316
1416
  }
1317
- const v = await versionStatus();
1318
- return jsonContent({
1319
- installed: v.installed,
1320
- latest_published: v.latest,
1321
- update_available: v.update_available,
1322
- update_command: "npx social-autoposter@latest update",
1323
- note: v.latest == null
1324
- ? "Could not reach npm to check for a newer version (offline or registry error)."
1325
- : v.update_available
1326
- ? `A newer version (${v.latest}) is available. Run this tool with action:'update' ` +
1327
- "to install it, or run `npx social-autoposter@latest update` in a terminal."
1328
- : "You are on the latest published version.",
1329
- });
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() });
1330
1439
  });
1331
- // ---- runtime installer ----------------------------------------------------
1332
- // The pipeline runs Python locally. Rather than depend on the user's system
1333
- // Python (the #1 source of install failures), the first run provisions a fully
1334
- // OWNED uv runtime: standalone CPython + owned venv + deps + Chromium. These two
1335
- // tools drive it. They are plain (non-UI) tools so EVERY host can install — the
1336
- // panel's Install card is just a skin that calls install_runtime then polls
1337
- // install_status. See runtime.ts for the provisioning + progress contract.
1338
1440
  function runtimeSnapshot() {
1339
1441
  const rt = readRuntime();
1340
1442
  const progress = readProgress();
@@ -1347,138 +1449,10 @@ function runtimeSnapshot() {
1347
1449
  onboarding: onboardingSnapshot(),
1348
1450
  };
1349
1451
  }
1350
- tool("install_runtime", {
1351
- title: "Install the Python runtime",
1352
- description: "One-time setup that provisions the self-contained runtime the autoposter needs: a private " +
1353
- "Python (via uv, not your system Python), its dependencies, and the Chromium browser. Runs in " +
1354
- "the background and returns immediately; poll `install_status` for progress. Safe to call " +
1355
- "repeatedly; it resumes/repairs and is a no-op once everything is installed. Use this the " +
1356
- "first time the user sets up, or if other tools report the runtime isn't ready.",
1357
- inputSchema: {},
1358
- }, async () => {
1359
- if (runtimeReady()) {
1360
- completeOnboardingMilestone("runtime_ready");
1361
- return jsonContent({ already_installed: true, ...runtimeSnapshot() });
1362
- }
1363
- recordOnboardingAttempt("runtime_ready");
1364
- const progress = startProvisioning();
1365
- return jsonContent({
1366
- started: true,
1367
- runtime_ready: false,
1368
- note: "Runtime install started. Poll install_status every ~1.5s for progress.",
1369
- progress,
1370
- });
1371
- });
1372
- tool("install_status", {
1373
- title: "Runtime install status",
1374
- description: "Report whether the self-contained Python/Chromium runtime is installed and, if an install is " +
1375
- "in progress, the per-step progress (uv, Python, venv, dependencies, Chromium). Poll this after " +
1376
- "install_runtime to follow the install to completion.",
1377
- inputSchema: {},
1378
- }, async () => {
1379
- const snapshot = runtimeSnapshot();
1380
- if (snapshot.runtime_ready) {
1381
- completeOnboardingMilestone("runtime_ready");
1382
- }
1383
- else if (snapshot.progress?.done && !snapshot.progress.ok) {
1384
- blockOnboardingMilestone("runtime_ready", "runtime_install_failed", snapshot.progress.error || "Runtime installation failed", { outcome: "failed" });
1385
- }
1386
- return jsonContent({ ...snapshot, onboarding: onboardingSnapshot() });
1387
- });
1388
- // ---- doctor: structured environment diagnostics --------------------------
1389
- // Uses the same shared engine as `npx social-autoposter doctor`. Pre-connect
1390
- // avoids invasive keychain access and classifies not-yet-created X artifacts as
1391
- // expected; full verifies the completed browser/session/cookie environment.
1392
- tool("doctor", {
1393
- title: "Diagnose the onboarding environment",
1394
- description: "Run the structured social-autoposter Doctor and persist the result in the onboarding " +
1395
- "ledger. phase:'pre_connect' is safe at onboarding start and treats the missing X session/" +
1396
- "cookie artifacts as expected. phase:'full' verifies the completed environment after X is " +
1397
- "connected. action:'status' returns the most recent persisted result without re-running checks.",
1398
- inputSchema: {
1399
- action: z.enum(["run", "status"]).optional(),
1400
- phase: z.enum(["pre_connect", "full"]).optional(),
1401
- },
1402
- }, async ({ action, phase, }) => {
1403
- if (action === "status") {
1404
- return jsonContent({
1405
- doctor: onboardingLedger()?.doctor?.latest ?? null,
1406
- onboarding: onboardingSnapshot(),
1407
- });
1408
- }
1409
- const selected = phase || "pre_connect";
1410
- const report = await runDoctorPhase(selected);
1411
- return jsonContent({
1412
- doctor: report,
1413
- onboarding: onboardingSnapshot(),
1414
- });
1415
- });
1416
- // ---- config: read / edit the raw config.json ------------------------------
1417
- // The panel renders the full config and lets the user edit it. Writing is
1418
- // guarded: the new content must parse as JSON, and we always drop a timestamped
1419
- // backup next to config.json before overwriting, so a bad paste is recoverable.
1420
- tool("config", {
1421
- title: "View or edit config.json",
1422
- description: "Read or update the autoposter's config.json (the source of truth for every project, the X/" +
1423
- "Reddit/LinkedIn account handles, topics, and exclusions). action:'get' (default) returns the " +
1424
- "full raw JSON; action:'save' validates the supplied `content` as JSON, writes a timestamped " +
1425
- "backup, then overwrites config.json. Use when the user asks to see, edit, or fix their config.",
1426
- inputSchema: {
1427
- action: z.enum(["get", "save"]).optional(),
1428
- content: z.string().optional(),
1429
- },
1430
- }, async (args) => {
1431
- const action = args.action || "get";
1432
- const cfgPath = configPath();
1433
- if (action === "get") {
1434
- try {
1435
- const content = fs.readFileSync(cfgPath, "utf-8");
1436
- return jsonContent({ ok: true, path: cfgPath, bytes: content.length, content });
1437
- }
1438
- catch (e) {
1439
- return jsonContent({ ok: false, path: cfgPath, error: String(e?.message || e) });
1440
- }
1441
- }
1442
- // save
1443
- const content = args.content;
1444
- if (typeof content !== "string" || content.trim() === "") {
1445
- return jsonContent({ ok: false, error: "Nothing to save: `content` was empty." });
1446
- }
1447
- let parsed;
1448
- try {
1449
- parsed = JSON.parse(content);
1450
- }
1451
- catch (e) {
1452
- // Don't write a config that won't parse — every pipeline reads this file.
1453
- return jsonContent({ ok: false, error: "Invalid JSON, not saved: " + String(e?.message || e) });
1454
- }
1455
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1456
- return jsonContent({ ok: false, error: "Top level of config.json must be a JSON object." });
1457
- }
1458
- try {
1459
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1460
- const backup = `${cfgPath}.bak-panel-${stamp}`;
1461
- try {
1462
- fs.copyFileSync(cfgPath, backup);
1463
- }
1464
- catch {
1465
- /* first-write / missing original is non-fatal */
1466
- }
1467
- // Re-serialize the parsed object so what lands on disk is canonical,
1468
- // 2-space-indented JSON with a trailing newline (matches the Python
1469
- // writers), regardless of how the user formatted their paste.
1470
- const out = JSON.stringify(parsed, null, 2) + "\n";
1471
- fs.writeFileSync(cfgPath, out, "utf-8");
1472
- return jsonContent({ ok: true, path: cfgPath, bytes: out.length, backup });
1473
- }
1474
- catch (e) {
1475
- return jsonContent({ ok: false, error: "Write failed: " + String(e?.message || e) });
1476
- }
1477
- });
1478
1452
  // ---- panel: MCP Apps control surface --------------------------------------
1479
1453
  // A self-contained HTML view rendered by hosts that support MCP Apps (Claude
1480
1454
  // desktop/web, etc.). It duplicates NO pipeline logic: each button calls one of
1481
- // 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
1482
1456
  // and re-reads status. The tool itself returns the first-paint snapshot so the
1483
1457
  // view has data the instant it loads.
1484
1458
  // Is either launchd job (cycle / daily updater) currently loaded?
@@ -1638,19 +1612,39 @@ function startLocalPanel() {
1638
1612
  }
1639
1613
  });
1640
1614
  srv.on("error", reject);
1641
- 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", () => {
1642
1619
  const addr = srv.address();
1643
1620
  const port = typeof addr === "object" && addr ? addr.port : 0;
1644
1621
  localPanel = { url: `http://127.0.0.1:${port}/`, server: srv };
1622
+ writePanelUrl(localPanel.url);
1645
1623
  resolve(localPanel.url);
1646
1624
  });
1647
1625
  });
1648
1626
  }
1649
- // Open a URL in the user's default browser, cross-platform. Honors
1650
- // SAPS_PANEL_NO_OPEN (set on headless autopilot boxes or in tests) to skip the
1651
- // 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.)
1652
1646
  async function openInBrowser(url) {
1653
- if (process.env.SAPS_PANEL_NO_OPEN)
1647
+ if (!process.env.SAPS_PANEL_OPEN_BROWSER)
1654
1648
  return;
1655
1649
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
1656
1650
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
@@ -1696,8 +1690,9 @@ appTool("dashboard", {
1696
1690
  return { content: [], structuredContent: { snapshot: JSON.stringify(snap) } };
1697
1691
  }
1698
1692
  // Host CAN'T render inline (Claude Code / Cowork today): serve the identical
1699
- // panel.html from a loopback HTTP server and open it in the browser, so the
1700
- // 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.
1701
1696
  try {
1702
1697
  const url = await startLocalPanel();
1703
1698
  await openInBrowser(url);
@@ -1705,7 +1700,7 @@ appTool("dashboard", {
1705
1700
  content: [{
1706
1701
  type: "text",
1707
1702
  text: human +
1708
- `\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}`,
1709
1704
  }],
1710
1705
  structuredContent: { snapshot: JSON.stringify(snap), fallback_url: url },
1711
1706
  };
@@ -1801,6 +1796,12 @@ async function main() {
1801
1796
  const transport = new StdioServerTransport();
1802
1797
  await server.connect(transport);
1803
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));
1804
1805
  // Phone home so this .mcpb install is visible in the install-lane digest
1805
1806
  // (parity with the npx launchd heartbeat). Once on startup, then every 15m
1806
1807
  // while the desktop app keeps the server alive. unref() so it never holds the