@supportpages.io/wtfm 0.0.0-stage → 0.3.0

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.
Files changed (275) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +23 -0
  3. package/README.md +392 -2
  4. package/dist/actions.d.ts +98 -0
  5. package/dist/actions.js +86 -0
  6. package/dist/actions.js.map +1 -0
  7. package/dist/agent-settings.d.ts +22 -0
  8. package/dist/agent-settings.js +35 -0
  9. package/dist/agent-settings.js.map +1 -0
  10. package/dist/api.d.ts +14 -0
  11. package/dist/api.js +159 -0
  12. package/dist/api.js.map +1 -0
  13. package/dist/article-link.d.ts +16 -0
  14. package/dist/article-link.js +25 -0
  15. package/dist/article-link.js.map +1 -0
  16. package/dist/artifacts.d.ts +45 -0
  17. package/dist/artifacts.js +144 -0
  18. package/dist/artifacts.js.map +1 -0
  19. package/dist/brand.d.ts +4 -0
  20. package/dist/brand.js +9 -0
  21. package/dist/brand.js.map +1 -0
  22. package/dist/bridge.d.ts +2964 -0
  23. package/dist/bridge.js +1036 -0
  24. package/dist/bridge.js.map +1 -0
  25. package/dist/capacity.d.ts +5 -0
  26. package/dist/capacity.js +11 -0
  27. package/dist/capacity.js.map +1 -0
  28. package/dist/credentials.d.ts +13 -0
  29. package/dist/credentials.js +88 -0
  30. package/dist/credentials.js.map +1 -0
  31. package/dist/development-tls.d.ts +8 -0
  32. package/dist/development-tls.js +38 -0
  33. package/dist/development-tls.js.map +1 -0
  34. package/dist/errors.d.ts +15 -0
  35. package/dist/errors.js +18 -0
  36. package/dist/errors.js.map +1 -0
  37. package/dist/export.d.ts +11 -0
  38. package/dist/export.js +91 -0
  39. package/dist/export.js.map +1 -0
  40. package/dist/hosted-operations.d.ts +108 -0
  41. package/dist/hosted-operations.js +120 -0
  42. package/dist/hosted-operations.js.map +1 -0
  43. package/dist/hosting-benefits.d.ts +66 -0
  44. package/dist/hosting-benefits.js +67 -0
  45. package/dist/hosting-benefits.js.map +1 -0
  46. package/dist/index.d.ts +2 -0
  47. package/dist/index.js +49 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/local-inventory.d.ts +22 -0
  50. package/dist/local-inventory.js +59 -0
  51. package/dist/local-inventory.js.map +1 -0
  52. package/dist/local-setup.d.ts +208 -0
  53. package/dist/local-setup.js +140 -0
  54. package/dist/local-setup.js.map +1 -0
  55. package/dist/pairing.d.ts +71 -0
  56. package/dist/pairing.js +235 -0
  57. package/dist/pairing.js.map +1 -0
  58. package/dist/preferences.d.ts +18 -0
  59. package/dist/preferences.js +44 -0
  60. package/dist/preferences.js.map +1 -0
  61. package/dist/progress.d.ts +205 -0
  62. package/dist/progress.js +224 -0
  63. package/dist/progress.js.map +1 -0
  64. package/dist/reminders.d.ts +31 -0
  65. package/dist/reminders.js +73 -0
  66. package/dist/reminders.js.map +1 -0
  67. package/dist/replace-connection.d.ts +6 -0
  68. package/dist/replace-connection.js +77 -0
  69. package/dist/replace-connection.js.map +1 -0
  70. package/dist/repository-actions.d.ts +18 -0
  71. package/dist/repository-actions.js +9 -0
  72. package/dist/repository-actions.js.map +1 -0
  73. package/dist/repository-benefits.d.ts +96 -0
  74. package/dist/repository-benefits.js +64 -0
  75. package/dist/repository-benefits.js.map +1 -0
  76. package/dist/run-update.d.ts +17 -0
  77. package/dist/run-update.js +27 -0
  78. package/dist/run-update.js.map +1 -0
  79. package/dist/runs.d.ts +541 -0
  80. package/dist/runs.js +146 -0
  81. package/dist/runs.js.map +1 -0
  82. package/dist/runtime.d.ts +12 -0
  83. package/dist/runtime.js +58 -0
  84. package/dist/runtime.js.map +1 -0
  85. package/dist/schema.d.ts +277 -0
  86. package/dist/schema.js +66 -0
  87. package/dist/schema.js.map +1 -0
  88. package/dist/server.d.ts +4 -0
  89. package/dist/server.js +298 -0
  90. package/dist/server.js.map +1 -0
  91. package/dist/session.d.ts +1714 -0
  92. package/dist/session.js +619 -0
  93. package/dist/session.js.map +1 -0
  94. package/dist/settings.d.ts +9 -0
  95. package/dist/settings.js +21 -0
  96. package/dist/settings.js.map +1 -0
  97. package/dist/sync.d.ts +495 -0
  98. package/dist/sync.js +191 -0
  99. package/dist/sync.js.map +1 -0
  100. package/dist/telemetry-scrub.d.ts +13 -0
  101. package/dist/telemetry-scrub.js +51 -0
  102. package/dist/telemetry-scrub.js.map +1 -0
  103. package/dist/telemetry.d.ts +73 -0
  104. package/dist/telemetry.js +173 -0
  105. package/dist/telemetry.js.map +1 -0
  106. package/dist/walkthroughs.d.ts +224 -0
  107. package/dist/walkthroughs.js +109 -0
  108. package/dist/walkthroughs.js.map +1 -0
  109. package/dist/workspace.d.ts +14 -0
  110. package/dist/workspace.js +127 -0
  111. package/dist/workspace.js.map +1 -0
  112. package/dist/writer-agent.d.ts +21 -0
  113. package/dist/writer-agent.js +27 -0
  114. package/dist/writer-agent.js.map +1 -0
  115. package/dist/writer-entry.d.ts +171 -0
  116. package/dist/writer-entry.js +233 -0
  117. package/dist/writer-entry.js.map +1 -0
  118. package/dist/writing-style.d.ts +7 -0
  119. package/dist/writing-style.js +52 -0
  120. package/dist/writing-style.js.map +1 -0
  121. package/engine/SYNC.json +4 -0
  122. package/engine/VERSION +1 -0
  123. package/engine/detect-project/README.md +141 -0
  124. package/engine/detect-project/SKILL.md +1421 -0
  125. package/engine/detect-project/assets/desktop/desktop-frame.css +428 -0
  126. package/engine/detect-project/assets/game/game-frame.css +132 -0
  127. package/engine/detect-project/assets/macosui/LICENSE-puppertino.txt +21 -0
  128. package/engine/detect-project/assets/macosui/VERSIONS.txt +1 -0
  129. package/engine/detect-project/assets/macosui/fonts.css +15 -0
  130. package/engine/detect-project/assets/macosui/macos-frame.css +481 -0
  131. package/engine/detect-project/assets/macosui/puppertino.css +2153 -0
  132. package/engine/detect-project/assets/mobileui/LICENSE-fonts.txt +13 -0
  133. package/engine/detect-project/assets/mobileui/LICENSE-framework7.txt +52 -0
  134. package/engine/detect-project/assets/mobileui/VERSIONS.txt +6 -0
  135. package/engine/detect-project/assets/mobileui/device-frame.css +316 -0
  136. package/engine/detect-project/assets/mobileui/f7-color-theme.mjs +1345 -0
  137. package/engine/detect-project/assets/mobileui/f7-icons-names.json +1254 -0
  138. package/engine/detect-project/assets/mobileui/fonts.css +16 -0
  139. package/engine/detect-project/assets/mobileui/framework7-components.css +39 -0
  140. package/engine/detect-project/assets/mobileui/framework7-core.css +5245 -0
  141. package/engine/detect-project/assets/mobileui/icons.css +31 -0
  142. package/engine/detect-project/assets/mobileui/md3-defaults.css +89 -0
  143. package/engine/detect-project/assets/mobileui/platforms.json +46 -0
  144. package/engine/detect-project/assets/tailwind-fallback.css +1729 -0
  145. package/engine/detect-project/assets/webtui/LICENSE-webtui.txt +28 -0
  146. package/engine/detect-project/assets/webtui/VERSIONS.txt +7 -0
  147. package/engine/detect-project/assets/webtui/terminal-frame.css +195 -0
  148. package/engine/detect-project/assets/webtui/theme-catppuccin.css +1 -0
  149. package/engine/detect-project/assets/webtui/theme-everforest.css +1 -0
  150. package/engine/detect-project/assets/webtui/theme-gruvbox.css +1 -0
  151. package/engine/detect-project/assets/webtui/theme-nord.css +1 -0
  152. package/engine/detect-project/assets/webtui/theme-vitesse.css +1 -0
  153. package/engine/detect-project/assets/webtui/themes.json +37 -0
  154. package/engine/detect-project/assets/webtui/webtui-core.css +1 -0
  155. package/engine/detect-project/assets/win32ui/7css.css +2 -0
  156. package/engine/detect-project/assets/win32ui/LICENSE-7css.txt +21 -0
  157. package/engine/detect-project/assets/win32ui/VERSIONS.txt +1 -0
  158. package/engine/detect-project/assets/win32ui/win32-frame.css +278 -0
  159. package/engine/detect-project/package-lock.json +12 -0
  160. package/engine/detect-project/package.json +10 -0
  161. package/engine/detect-project/scripts/apply_runtime_profiles.js +313 -0
  162. package/engine/detect-project/scripts/check_css_health.js +412 -0
  163. package/engine/detect-project/scripts/check_project_map.js +150 -0
  164. package/engine/detect-project/scripts/check_runtime_coverage.js +311 -0
  165. package/engine/detect-project/scripts/check_runtime_recipe_quality.js +184 -0
  166. package/engine/detect-project/scripts/classify_app_type.sh +246 -0
  167. package/engine/detect-project/scripts/classify_surface.sh +95 -0
  168. package/engine/detect-project/scripts/classify_workspace.js +39 -0
  169. package/engine/detect-project/scripts/compile_css.sh +447 -0
  170. package/engine/detect-project/scripts/detect_static.js +645 -0
  171. package/engine/detect-project/scripts/detect_structure.js +187 -0
  172. package/engine/detect-project/scripts/include_census.js +451 -0
  173. package/engine/detect-project/scripts/json_get.js +142 -0
  174. package/engine/detect-project/scripts/merge_json.js +52 -0
  175. package/engine/detect-project/scripts/recommend_model_tier.js +252 -0
  176. package/engine/detect-project/scripts/resolve_route_chains.js +135 -0
  177. package/engine/detect-project/scripts/run_css_build.sh +40 -0
  178. package/engine/detect-project/scripts/sanitize_css.js +83 -0
  179. package/engine/detect-project/scripts/test_classify_surface.js +108 -0
  180. package/engine/detect-project/scripts/test_node_helpers.js +142 -0
  181. package/engine/detect-project/scripts/test_recommend_model_tier.js +119 -0
  182. package/engine/detect-project/scripts/test_resolve_route_chains.js +182 -0
  183. package/engine/detect-project/scripts/test_runtime_coverage.js +323 -0
  184. package/engine/detect-project/scripts/test_runtime_recipe_quality.js +187 -0
  185. package/engine/detect-project/scripts/theme_overrides.js +169 -0
  186. package/engine/detect-project/scripts/write_branding.js +129 -0
  187. package/engine/generate-illustrated-article/SKILL.md +461 -0
  188. package/engine/generate-illustrated-article/contracts/desktop.md +90 -0
  189. package/engine/generate-illustrated-article/contracts/game.md +38 -0
  190. package/engine/generate-illustrated-article/contracts/label-evidence.md +38 -0
  191. package/engine/generate-illustrated-article/contracts/macos.md +79 -0
  192. package/engine/generate-illustrated-article/contracts/mobile.md +40 -0
  193. package/engine/generate-illustrated-article/contracts/terminal.md +29 -0
  194. package/engine/generate-illustrated-article/contracts/win32.md +43 -0
  195. package/engine/generate-illustrated-article/package-lock.json +366 -0
  196. package/engine/generate-illustrated-article/package.json +15 -0
  197. package/engine/generate-illustrated-article/scripts/article_blocks.js +34 -0
  198. package/engine/generate-illustrated-article/scripts/check_article_json.js +105 -0
  199. package/engine/generate-illustrated-article/scripts/emit_walkthrough_signals.js +100 -0
  200. package/engine/generate-illustrated-article/scripts/extract_images.js +187 -0
  201. package/engine/generate-illustrated-article/scripts/generate_content_images.js +376 -0
  202. package/engine/generate-illustrated-article/scripts/include_census.js +451 -0
  203. package/engine/generate-illustrated-article/scripts/inject_assets.js +1691 -0
  204. package/engine/generate-illustrated-article/scripts/jit_mockup_css.js +191 -0
  205. package/engine/generate-illustrated-article/scripts/label_evidence.js +87 -0
  206. package/engine/generate-illustrated-article/scripts/lint_article_copy.js +252 -0
  207. package/engine/generate-illustrated-article/scripts/lint_mockup_fidelity.js +2403 -0
  208. package/engine/generate-illustrated-article/scripts/polish_tickets.js +1148 -0
  209. package/engine/generate-illustrated-article/scripts/related_repos.sh +52 -0
  210. package/engine/generate-illustrated-article/scripts/render_all.js +177 -0
  211. package/engine/generate-illustrated-article/scripts/render_mockup.js +665 -0
  212. package/engine/generate-illustrated-article/scripts/render_ready.js +164 -0
  213. package/engine/generate-illustrated-article/scripts/resolve_workspace.sh +86 -0
  214. package/engine/generate-illustrated-article/scripts/runtime_region_geometry.js +55 -0
  215. package/engine/generate-illustrated-article/scripts/source_paths.js +49 -0
  216. package/engine/generate-illustrated-article/scripts/test_control_visibility.js +30 -0
  217. package/engine/generate-illustrated-article/scripts/test_emit_walkthrough_signals.js +157 -0
  218. package/engine/generate-illustrated-article/scripts/test_generate_content_images.js +168 -0
  219. package/engine/generate-illustrated-article/scripts/test_include_census.js +126 -0
  220. package/engine/generate-illustrated-article/scripts/test_label_evidence.js +49 -0
  221. package/engine/generate-illustrated-article/scripts/test_lint_article_copy.js +138 -0
  222. package/engine/generate-illustrated-article/scripts/test_lint_mockup_fidelity.js +653 -0
  223. package/engine/generate-illustrated-article/scripts/test_node_ports.js +152 -0
  224. package/engine/generate-illustrated-article/scripts/test_polish_tickets.js +481 -0
  225. package/engine/generate-illustrated-article/scripts/test_related_repos.js +64 -0
  226. package/engine/generate-illustrated-article/scripts/test_render_ready.js +85 -0
  227. package/engine/generate-illustrated-article/scripts/test_source_paths.js +68 -0
  228. package/engine/generate-illustrated-article/scripts/trace_hook.js +79 -0
  229. package/engine/generate-illustrated-article/scripts/trace_hook.sh +4 -0
  230. package/engine/generate-illustrated-article/scripts/validate_html.js +114 -0
  231. package/engine/generate-illustrated-article/scripts/watermark.js +69 -0
  232. package/install.sh +14 -0
  233. package/package.json +57 -4
  234. package/scripts/auth.mjs +33 -0
  235. package/scripts/auto-update.mjs +7 -0
  236. package/scripts/build-plugin.mjs +58 -0
  237. package/scripts/build-release.mjs +94 -0
  238. package/scripts/check-release.mjs +40 -0
  239. package/scripts/check-runtime.mjs +10 -0
  240. package/scripts/cli.mjs +8 -0
  241. package/scripts/install.mjs +64 -0
  242. package/scripts/lib/agent-runner.mjs +181 -0
  243. package/scripts/lib/agent-settings.mjs +215 -0
  244. package/scripts/lib/article-skills.mjs +82 -0
  245. package/scripts/lib/auto-update.mjs +50 -0
  246. package/scripts/lib/brand.mjs +9 -0
  247. package/scripts/lib/browser.mjs +12 -0
  248. package/scripts/lib/claude-connection.mjs +33 -0
  249. package/scripts/lib/claude-permissions.mjs +47 -0
  250. package/scripts/lib/claude-plugin.mjs +15 -0
  251. package/scripts/lib/claude-writer.mjs +10 -0
  252. package/scripts/lib/cli-main.mjs +85 -0
  253. package/scripts/lib/cli.mjs +888 -0
  254. package/scripts/lib/codex-config.mjs +51 -0
  255. package/scripts/lib/codex-integration.mjs +61 -0
  256. package/scripts/lib/codex-skill.mjs +34 -0
  257. package/scripts/lib/harness-models.mjs +109 -0
  258. package/scripts/lib/install.mjs +269 -0
  259. package/scripts/lib/managed-writer.mjs +38 -0
  260. package/scripts/lib/planning.mjs +263 -0
  261. package/scripts/lib/prepare-update.mjs +85 -0
  262. package/scripts/lib/refresh-writers.mjs +12 -0
  263. package/scripts/lib/remove.mjs +167 -0
  264. package/scripts/lib/renderer.mjs +34 -0
  265. package/scripts/lib/terminal.mjs +252 -0
  266. package/scripts/lib/uninit.mjs +72 -0
  267. package/scripts/lib/update.mjs +55 -0
  268. package/scripts/lib/writer-recovery.mjs +35 -0
  269. package/scripts/lib/yolo.mjs +362 -0
  270. package/scripts/plugin-session.mjs +65 -0
  271. package/scripts/prepare-update.mjs +12 -0
  272. package/scripts/publish-release.mjs +92 -0
  273. package/scripts/skills/supportpages/SKILL.md +120 -0
  274. package/scripts/smoke-release.mjs +101 -0
  275. package/server.json +27 -0
@@ -0,0 +1,51 @@
1
+ import { spawn } from 'node:child_process';
2
+ import readline from 'node:readline';
3
+ import { CLI_NAME } from './brand.mjs';
4
+
5
+ /** Use Codex's TOML editor so comments, inline tables and concurrent edits survive. */
6
+ export async function withCodexConfig({ env, cwd }, action) {
7
+ const childEnv = { ...env };
8
+ delete childEnv.SUPPORTPAGES_API_TOKEN;
9
+ delete childEnv.SUPPORTPAGES_API_TOKEN_FILE;
10
+ const child = spawn('codex', ['app-server', '--stdio'], { cwd, env: childEnv, stdio: ['pipe', 'pipe', 'ignore'] });
11
+ let id = 0, closed = false;
12
+ const pending = new Map();
13
+ const failure = () => Error(`Codex configuration could not be updated. Update Codex and rerun ${CLI_NAME} init.`);
14
+ const stop = () => {
15
+ closed = true;
16
+ for (const item of pending.values()) { clearTimeout(item.timer); item.reject(failure()); }
17
+ pending.clear();
18
+ };
19
+ child.on('error', stop); child.on('exit', stop); child.stdin.on('error', stop);
20
+ const lines = readline.createInterface({ input: child.stdout });
21
+ lines.on('line', line => {
22
+ let value;
23
+ try { value = JSON.parse(line); } catch { stop(); return; }
24
+ const item = pending.get(value.id);
25
+ if (!item) return;
26
+ clearTimeout(item.timer); pending.delete(value.id);
27
+ // Never echo configuration or raw errors: they can contain credentials.
28
+ if (value.error) item.reject(failure()); else item.resolve(value.result);
29
+ });
30
+ const request = (method, params) => new Promise((resolve, reject) => {
31
+ if (closed) { reject(failure()); return; }
32
+ const requestId = ++id;
33
+ const timer = setTimeout(() => { pending.delete(requestId); reject(failure()); }, 10000);
34
+ pending.set(requestId, { resolve, reject, timer });
35
+ child.stdin.write(JSON.stringify({ id: requestId, method, params }) + '\n');
36
+ });
37
+ try {
38
+ await request('initialize', { clientInfo: { name: 'supportpages', version: '0.1.0' }, capabilities: {} });
39
+ child.stdin.write(JSON.stringify({ method: 'initialized', params: {} }) + '\n');
40
+ return await action(request);
41
+ } finally {
42
+ stop(); lines.close(); child.stdin.end();
43
+ child.kill('SIGTERM');
44
+ if (child.exitCode === null && child.signalCode === null) {
45
+ await new Promise(resolve => {
46
+ const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(); }, 1000);
47
+ child.once('exit', () => { clearTimeout(timer); resolve(); });
48
+ });
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,61 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { lstat, realpath } from 'node:fs/promises';
4
+ import { installManagedWriter } from './managed-writer.mjs';
5
+ import { withCodexConfig } from './codex-config.mjs';
6
+ import { installCodexSkill } from './codex-skill.mjs';
7
+ import { askFirstTools } from './claude-permissions.mjs';
8
+ import { CLI_NAME } from './brand.mjs';
9
+
10
+ export async function installCodexIntegration({ home = os.homedir(), env = process.env, name, configDir, backup, ui, codexConfig = withCodexConfig, registration, announcePermission = true }) {
11
+ if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9_-]{0,79}$/.test(name)) throw Error('Invalid SupportPages.io connection name.');
12
+ const config = path.resolve(env.CODEX_HOME || path.join(home, '.codex'));
13
+ const { codexWriterFile, writerAgentType } = await import('../../dist/writer-agent.js');
14
+ const agent = await installManagedWriter({ config, name: writerAgentType, extension: 'toml', contents: codexWriterFile, marker: '# Managed by SupportPages: article writer v1' });
15
+ if (agent.changed) ui?.line('Installed the SupportPages Writer agent for Codex. Restart existing Codex sessions to load it.');
16
+ const skill = await installCodexSkill({ home, env });
17
+ if (skill.changed) ui?.line('Installed the SupportPages.io article skill for Codex. Restart existing Codex sessions to load it.');
18
+ const filename = path.join(config, 'config.toml');
19
+ const info = await lstat(filename);
20
+ if (info.isSymbolicLink() || !info.isFile()) throw Error(`Cannot update Codex permissions at an unsafe path: ${filename}`);
21
+ const canonical = await realpath(filename);
22
+ const permission = await codexConfig({ env: { ...env, CODEX_HOME: config }, cwd: config }, async request => {
23
+ const state = await request('config/read', { includeLayers: true });
24
+ const user = state.layers?.find(layer => layer.name?.type === 'user' && !layer.name.profile && path.resolve(layer.name.file) === canonical);
25
+ if (!user?.version) throw Error(`Codex did not return a version for its user settings. Update Codex and rerun ${CLI_NAME} init.`);
26
+ const server = state.config?.mcp_servers?.[name];
27
+ if (!server) throw Error('Register the SupportPages.io Codex connection before installing its publishing permission.');
28
+ const edits = [];
29
+ if (registration) {
30
+ if (!server.command || server.url) throw Error('The existing Codex connection is not the expected local SupportPages.io server. Keep it and choose another connection name.');
31
+ edits.push({ keyPath: `mcp_servers.${name}.command`, value: registration.command, mergeStrategy: 'replace' },
32
+ { keyPath: `mcp_servers.${name}.args`, value: registration.args, mergeStrategy: 'replace' });
33
+ }
34
+ const mode = server.tools?.supportpages_publish_article?.approval_mode;
35
+ let keptExistingPolicy = false;
36
+ if (mode !== 'approve' && (mode || server.enabled === false || server.disabled_tools?.includes('supportpages_publish_article') ||
37
+ server.enabled_tools && !server.enabled_tools.includes('supportpages_publish_article') ||
38
+ ['prompt', 'writes'].includes(server.default_tools_approval_mode))) {
39
+ ui?.line('Kept your existing Codex publishing restrictions. Manage them in Codex permission settings.');
40
+ keptExistingPolicy = true;
41
+ }
42
+ // Pre-approve the server's tools (publish explicitly) unless the user restricted
43
+ // them; removing content always asks unless the user already chose otherwise.
44
+ const tool = (id, value) => ({ keyPath: `mcp_servers.${name}.tools.${id}.approval_mode`, value, mergeStrategy: 'upsert' });
45
+ const policies = keptExistingPolicy ? [] : [
46
+ ...(mode !== 'approve' ? [tool('supportpages_publish_article', 'approve')] : []),
47
+ ...(server.default_tools_approval_mode === undefined
48
+ ? [{ keyPath: `mcp_servers.${name}.default_tools_approval_mode`, value: 'approve', mergeStrategy: 'upsert' }] : []),
49
+ ];
50
+ for (const id of askFirstTools) if (server.tools?.[id]?.approval_mode === undefined) policies.push(tool(id, 'prompt'));
51
+ const changed = policies.length > 0;
52
+ edits.push(...policies);
53
+ if (!edits.length) return { changed, ...(keptExistingPolicy ? { keptExistingPolicy } : {}) };
54
+ await backup(filename, path.join(configDir, 'backups'));
55
+ const result = await request('config/batchWrite', { filePath: canonical, expectedVersion: user.version, edits });
56
+ if (result.status !== 'ok') throw Error('Codex could not save the SupportPages.io tool permissions.');
57
+ if (changed && announcePermission) ui?.line("Allowed SupportPages Writer's tools in Codex; deleting or unpublishing an article still asks first.");
58
+ return { changed, ...(keptExistingPolicy ? { keptExistingPolicy } : {}) };
59
+ });
60
+ return { agent, skill, permission };
61
+ }
@@ -0,0 +1,34 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import { lstat, readFile } from 'node:fs/promises';
4
+ import { installManagedFile } from './managed-writer.mjs';
5
+
6
+ const marker = '<!-- Managed by SupportPages: article coordination v1 -->';
7
+ const configPath = (home, env) => path.resolve(env.CODEX_HOME || path.join(home, '.codex'));
8
+
9
+ export async function installCodexSkill({ home = os.homedir(), env = process.env } = {}) {
10
+ return installManagedFile({ config: configPath(home, env), parts: ['skills', 'supportpages', 'SKILL.md'],
11
+ contents: await readFile(new URL('../skills/supportpages/SKILL.md', import.meta.url), 'utf8'), marker, kind: 'skill' });
12
+ }
13
+
14
+ /** Upgrades only add the skill to a previously managed Codex integration. */
15
+ export async function refreshCodexSkill({ home = os.homedir(), env = process.env } = {}) {
16
+ const config = configPath(home, env);
17
+ for (const [parts, ownership] of [
18
+ [['agents', 'supportpages-io.toml'], '# Managed by SupportPages: article writer v1'],
19
+ [['skills', 'supportpages', 'SKILL.md'], marker],
20
+ ]) {
21
+ let entry = config;
22
+ try {
23
+ for (const part of ['', ...parts]) {
24
+ entry = path.join(entry, part);
25
+ const info = await lstat(entry);
26
+ if (info.isSymbolicLink() || (entry === path.join(config, ...parts) ? !info.isFile() : !info.isDirectory())) {
27
+ throw Error(`Cannot refresh the SupportPages.io skill from an unsafe path: ${entry}`);
28
+ }
29
+ }
30
+ if ((await readFile(entry, 'utf8')).includes(ownership)) return installCodexSkill({ home, env });
31
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
32
+ }
33
+ return { changed: false };
34
+ }
@@ -0,0 +1,109 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { modelValid, efforts } from '../../dist/agent-settings.js';
3
+ import { Cancelled } from './terminal.mjs';
4
+
5
+ const clean = value => typeof value === 'string' ? value.replace(/[\p{Cc}\p{Cf}]/gu, '').slice(0, 300) : '';
6
+
7
+ /** Query the installed harness's catalogue without sending a prompt or starting a turn.
8
+ * Codex: app-server model/list. Claude: Agent SDK initialize control response.
9
+ */
10
+ export async function discoverModels(agent, { cwd, env = process.env, spawnProcess = spawn, timeoutMs = 15000 } = {}) {
11
+ if (!['claude', 'codex'].includes(agent)) throw Error('Unsupported coding agent.');
12
+ const childEnv = { ...env };
13
+ delete childEnv.SUPPORTPAGES_API_TOKEN;
14
+ delete childEnv.SUPPORTPAGES_API_TOKEN_FILE;
15
+ const args = agent === 'codex' ? ['app-server'] : [
16
+ '--print', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
17
+ '--no-session-persistence', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}',
18
+ '--tools', '', '--settings', '{"disableAllHooks":true}',
19
+ ];
20
+ const child = spawnProcess(agent, args, { cwd, env: childEnv, stdio: ['pipe', 'pipe', 'ignore'] });
21
+ let buffer = '', bytes = 0, requestId = 1, done = false;
22
+ const entries = [], cursors = new Set();
23
+ const failure = () => Error('The coding agent could not list its models.');
24
+ let timer, interrupt;
25
+ try {
26
+ return await new Promise((resolve, reject) => {
27
+ const finish = (error, value) => {
28
+ if (done) return;
29
+ done = true;
30
+ error ? reject(error) : resolve(value);
31
+ };
32
+ const send = value => child.stdin.write(JSON.stringify(value) + '\n');
33
+ const list = cursor => send({ id: ++requestId, method: 'model/list', params: { limit: 100, includeHidden: false, ...(cursor ? { cursor } : {}) } });
34
+ const complete = () => {
35
+ const models = new Map();
36
+ for (const entry of entries) {
37
+ if (!entry || entry.hidden === true) continue;
38
+ const value = agent === 'codex' ? entry.model : entry.value;
39
+ if (!modelValid(value) || ['inherit', 'custom'].includes(value) || models.has(value)) continue;
40
+ const supported = agent === 'codex' ? entry.supportedReasoningEfforts?.map?.(item => item?.reasoningEffort)
41
+ : entry.supportedEffortLevels ?? (entry.supportsEffort === false ? [] : undefined);
42
+ models.set(value, { value, label: clean(entry.displayName) || value, hint: clean(entry.description),
43
+ isDefault: entry.isDefault === true || (agent === 'claude' && value === 'default'),
44
+ ...(Array.isArray(supported) ? { efforts: [...new Set(supported.filter(effort => efforts[agent].includes(effort)))] } : {}),
45
+ defaultEffort: entry.defaultReasoningEffort });
46
+ }
47
+ if (!models.size) finish(failure());
48
+ else finish(null, [...models.values()]);
49
+ };
50
+ const receive = value => {
51
+ if (!value || typeof value !== 'object') { finish(failure()); return; }
52
+ if (agent === 'claude') {
53
+ if (value.type !== 'control_response' || value.response?.request_id !== 'models') return;
54
+ if (value.response.subtype !== 'success' || !Array.isArray(value.response.response?.models)) { finish(failure()); return; }
55
+ entries.push(...value.response.response.models);
56
+ complete();
57
+ } else {
58
+ if (value.id !== requestId) return;
59
+ if (value.error) { finish(failure()); return; }
60
+ if (requestId === 1) {
61
+ send({ method: 'initialized', params: {} });
62
+ list();
63
+ } else {
64
+ if (!Array.isArray(value.result?.data)) { finish(failure()); return; }
65
+ entries.push(...value.result.data);
66
+ const cursor = value.result.nextCursor;
67
+ if (cursor == null) complete();
68
+ else if (typeof cursor !== 'string' || !cursor || cursors.has(cursor) || cursors.size >= 10) finish(failure());
69
+ else { cursors.add(cursor); list(cursor); }
70
+ }
71
+ }
72
+ };
73
+ interrupt = () => finish(new Cancelled());
74
+ process.once('SIGINT', interrupt);
75
+ process.once('SIGTERM', interrupt);
76
+ timer = setTimeout(() => finish(failure()), timeoutMs);
77
+ child.on('error', () => finish(failure()));
78
+ child.on('close', () => finish(failure()));
79
+ child.stdin.on('error', () => finish(failure()));
80
+ child.stdout.setEncoding('utf8');
81
+ child.stdout.on('data', chunk => {
82
+ if (done) return;
83
+ bytes += Buffer.byteLength(chunk);
84
+ if (bytes > 2 * 1024 * 1024) { finish(failure()); return; }
85
+ buffer += chunk;
86
+ let newline;
87
+ while (!done && (newline = buffer.indexOf('\n')) !== -1) {
88
+ const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
89
+ if (!line.trim()) continue;
90
+ try { receive(JSON.parse(line)); } catch { finish(failure()); }
91
+ }
92
+ });
93
+ send(agent === 'codex'
94
+ ? { id: 1, method: 'initialize', params: { clientInfo: { name: 'supportpages', version: '0.1.0' }, capabilities: {} } }
95
+ : { type: 'control_request', request_id: 'models', request: { subtype: 'initialize' } });
96
+ });
97
+ } finally {
98
+ clearTimeout(timer);
99
+ if (interrupt) { process.off('SIGINT', interrupt); process.off('SIGTERM', interrupt); }
100
+ child.stdin.end();
101
+ child.kill('SIGTERM');
102
+ if (child.exitCode === null && child.signalCode === null) {
103
+ await new Promise(resolve => {
104
+ const killTimer = setTimeout(() => { child.kill('SIGKILL'); resolve(); }, 1000);
105
+ child.once('exit', () => { clearTimeout(killTimer); resolve(); });
106
+ });
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,269 @@
1
+ import { chmod, copyFile, lstat, mkdir, open, readFile, realpath, rename, rm } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { spawn } from 'node:child_process';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { ARTICLE_SKILLS, installArticleSkills, retirePublicSkillLinks } from './article-skills.mjs';
8
+ import { installClaudeWriter } from './claude-writer.mjs';
9
+ import { installClaudePermissions } from './claude-permissions.mjs';
10
+ import { installCodexIntegration } from './codex-integration.mjs';
11
+ import { ensureRenderer } from './renderer.mjs';
12
+ import { claudePluginInstalled } from './claude-plugin.mjs';
13
+ import { pathToFileURL } from 'node:url';
14
+ import { CLI_NAME } from './brand.mjs';
15
+
16
+ export const SKILLS = ARTICLE_SKILLS;
17
+ const digest = text => createHash('sha256').update(text).digest('hex').slice(0, 12);
18
+ export const exists = async file => { try { await lstat(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; } };
19
+ export const expand = (value, home = os.homedir()) => path.resolve(value === '~' ? home : value.startsWith('~/') ? path.join(home, value.slice(2)) : value);
20
+ export const serverName = (workspace, origin) => `supportpages-${path.basename(workspace).toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 28) || 'project'}-${digest(workspace + '\n' + origin).slice(0, 8)}`;
21
+
22
+ /** No shell interpolation; credentials are not inherited by install/build/client subprocesses. */
23
+ export function command(command, args = [], { cwd, capture = false, env = process.env } = {}) {
24
+ const safeEnv = { ...env };
25
+ delete safeEnv.SUPPORTPAGES_API_TOKEN;
26
+ delete safeEnv.SUPPORTPAGES_API_TOKEN_FILE;
27
+ return new Promise((resolve, reject) => {
28
+ const child = spawn(command, args, { cwd, env: safeEnv, stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit' });
29
+ let stdout = '', stderr = '';
30
+ child.stdout?.on('data', chunk => { if (stdout.length < 128_000) stdout += chunk; });
31
+ child.stderr?.on('data', chunk => { if (stderr.length < 128_000) stderr += chunk; });
32
+ child.once('error', error => error.code === 'ENOENT' ? resolve({ code: 127, stdout: '', stderr: '' }) : reject(error));
33
+ child.once('exit', code => resolve({ code: code ?? 1, stdout, stderr }));
34
+ });
35
+ }
36
+
37
+ async function noSymlinks(file) {
38
+ const absolute = path.resolve(file);
39
+ // Resolve system-level aliases (/tmp on macOS) once, then reject application
40
+ // symlinks below the existing parent when securing credential files.
41
+ if (await exists(absolute) && (await lstat(absolute)).isSymbolicLink()) throw new Error(`Refusing to replace a symlink: ${absolute}`);
42
+ }
43
+ export async function privateDirectory(dir) {
44
+ await noSymlinks(dir);
45
+ if (await exists(dir)) {
46
+ const info = await lstat(dir);
47
+ if (!info.isDirectory() || (process.getuid && info.uid !== process.getuid())) throw new Error(`Not an owned directory: ${dir}`);
48
+ }
49
+ await mkdir(dir, { recursive: true, mode: 0o700 });
50
+ await chmod(dir, 0o700);
51
+ }
52
+ export async function privateJson(file, value) {
53
+ await privateDirectory(path.dirname(file));
54
+ await noSymlinks(file);
55
+ const temporary = `${file}.${randomUUID()}.tmp`;
56
+ const fd = await open(temporary, 'wx', 0o600);
57
+ try { await fd.writeFile(JSON.stringify(value, null, 2) + '\n'); await fd.sync(); }
58
+ finally { await fd.close(); }
59
+ try { await rename(temporary, file); } finally { await rm(temporary, { force: true }); }
60
+ }
61
+ export async function backup(file, dir) {
62
+ if (!await exists(file)) return null;
63
+ await noSymlinks(file);
64
+ if (!(await lstat(file)).isFile()) throw new Error(`Cannot back up a non-file: ${file}`);
65
+ await privateDirectory(dir);
66
+ const target = path.join(dir, `${path.basename(file)}.${Date.now()}.${randomUUID()}.bak`);
67
+ await copyFile(file, target, constants.COPYFILE_EXCL);
68
+ await chmod(target, 0o600);
69
+ return target;
70
+ }
71
+
72
+ /** The article engine ships inside the package, beside dist/ and scripts/. */
73
+ export const bundledEngine = installRoot => path.join(installRoot, 'engine');
74
+
75
+ /** Map a registered engine path from another managed release (mcp/skills in older
76
+ * releases, mcp/engine now) to this release's engine. Other paths are kept. */
77
+ export async function installationSkills(installRoot, selected) {
78
+ let directory;
79
+ try { directory = await realpath(selected); } catch (error) { if (error.code === 'ENOENT') return selected; throw error; }
80
+ const version = path.dirname(installRoot);
81
+ if (path.basename(installRoot) === 'mcp' && path.basename(path.dirname(version)) === 'versions') {
82
+ const root = path.dirname(path.dirname(version));
83
+ const relative = path.relative(root, directory).split(path.sep);
84
+ // Earlier setups also kept skills copies in the shared data directory (<data>/skills/<name>).
85
+ const shared = path.relative(path.join(path.dirname(root), 'skills'), directory).split(path.sep);
86
+ const managed = (relative.length === 4 && relative[0] === 'versions' && relative[2] === 'mcp' && ['skills', 'engine'].includes(relative[3])) ||
87
+ (shared.length === 1 && shared[0] !== '' && shared[0] !== '..');
88
+ if (managed && await exists(path.join(bundledEngine(installRoot), 'generate-illustrated-article', 'SKILL.md'))) directory = await realpath(bundledEngine(installRoot));
89
+ }
90
+ return directory;
91
+ }
92
+
93
+ export async function findSkills(explicit, { home, installRoot }) {
94
+ if (explicit) return { directory: await installationSkills(installRoot, await realpath(expand(explicit, home))) };
95
+ const engine = bundledEngine(installRoot);
96
+ if (!await exists(engine)) throw new Error(`The article engine is missing from this installation: ${engine}. Reinstall SupportPages Writer.`);
97
+ return { directory: await realpath(engine) };
98
+ }
99
+
100
+ export async function checkSkills(directory) {
101
+ for (const name of SKILLS) {
102
+ if (!await exists(path.join(directory, name, 'SKILL.md'))) throw new Error(`The article engine is missing ${name}/SKILL.md: ${directory}`);
103
+ }
104
+ }
105
+
106
+ /** Prefer the managed `current` link over an immutable versions/<release> path, so
107
+ * commands written into client settings keep working after an update. */
108
+ export async function stablePath(file, installRoot) {
109
+ const version = path.dirname(installRoot);
110
+ if (path.basename(installRoot) !== 'mcp' || path.basename(path.dirname(version)) !== 'versions') return file;
111
+ const current = path.join(path.dirname(path.dirname(version)), 'current');
112
+ try {
113
+ if (await realpath(current) !== await realpath(version)) return file;
114
+ } catch (error) { if (error.code === 'ENOENT') return file; throw error; }
115
+ const relative = path.relative(version, file);
116
+ return relative.startsWith('..') || path.isAbsolute(relative) ? file : path.join(current, relative);
117
+ }
118
+
119
+ export function registration(client, name, serverArgs, node = process.execPath) {
120
+ if (client === 'codex') return { command: 'codex', args: ['mcp', 'add', name, '--', node, ...serverArgs] };
121
+ if (client === 'claude') return { command: 'claude', args: ['mcp', 'add', '--transport', 'stdio', '--scope', 'user', name, '--', node, ...serverArgs] };
122
+ throw new Error('Unsupported coding client.');
123
+ }
124
+
125
+ export async function runInstaller(options, dependencies) {
126
+ const { ui: suppliedUi, installRoot, home = os.homedir(), run = command, env = process.env } = dependencies;
127
+ // Setup writes this copy's path into client configuration; npx's cache is temporary.
128
+ if (installRoot.split(path.sep).includes('_npx')) {
129
+ throw new Error(`Run setup from an installed copy: npm install -g @supportpages.io/wtfm, then ${CLI_NAME} setup. npx runs from a temporary cache that npm may delete.`);
130
+ }
131
+ // Setup and init run this inside their own wizard frame: no step headers, and no
132
+ // standalone-installer indentation on the lines that remain.
133
+ const ui = options.embedded ? { ...suppliedUi, step: () => {}, ok: () => {}, line: text => suppliedUi.line(String(text).replace(/^\s+/, '')) } : suppliedUi;
134
+ const configDir = expand(options['config-dir'] ?? path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'supportpages'), home);
135
+ const dataDir = expand(options['data-dir'] ?? path.join(env.XDG_DATA_HOME || path.join(home, '.local/share'), 'supportpages'), home);
136
+ const yes = options.yes === true;
137
+ const confirm = async (label, fallback = true) => yes ? fallback : ui.confirm(label, fallback);
138
+ const checked = async (cmd, args, settings = {}) => {
139
+ const result = await run(cmd, args, settings);
140
+ if (result.code !== 0) throw new Error(`${path.basename(cmd)} failed (exit ${result.code}). Fix the reported issue and rerun ./install.sh; completed steps are kept.`);
141
+ return result;
142
+ };
143
+ if (!options.embedded) ui.line('\n SupportPages.io\n Local articles. Your coding agent. One place to publish.\n');
144
+ ui.step(1, 'Choose your coding client');
145
+ if (yes && !options.client) throw new Error('--yes requires --client.');
146
+ const available = [];
147
+ for (const client of ['codex', 'claude']) {
148
+ if ((await run(client, ['--version'], { capture: true })).code === 0) available.push(client);
149
+ }
150
+ let client = options.client;
151
+ if (!client) {
152
+ const choices = [...available.map(value => ({ value, label: value === 'codex' ? 'Codex' : 'Claude Code' })), ...(available.length === 2 ? [{ value: 'both', label: 'Both' }] : []), { value: 'manual', label: 'Another MCP client — show configuration' }];
153
+ client = await ui.choose('Where will you use SupportPages.io?', choices, available.length === 2 ? 2 : 0);
154
+ }
155
+ if (!['codex', 'claude', 'both', 'manual'].includes(client)) throw new Error('--client must be codex, claude, both, or manual.');
156
+ let clients = client === 'both' ? ['codex', 'claude'] : client === 'manual' ? [] : [client];
157
+ if (clients.includes('claude') && await claudePluginInstalled({ home, env })) {
158
+ ui.line(' Claude Code already has the SupportPages Writer plugin, which provides the server and writer; skipping a second Claude registration.');
159
+ clients = clients.filter(name => name !== 'claude');
160
+ }
161
+ for (const name of clients) if (!available.includes(name)) throw new Error(`${name} is not installed or is not on PATH. Install it first, or choose --client manual.`);
162
+ ui.ok(client === 'manual' ? 'manual configuration' : client);
163
+
164
+ ui.step(2, 'Check prerequisites and build the server');
165
+ for (const executable of ['npm', 'git']) {
166
+ const result = await run(executable, ['--version'], { capture: true });
167
+ if (result.code !== 0) {
168
+ if (executable === 'npm' && await exists(path.resolve(installRoot, '../runtime/bin/npm'))) throw new Error('Missing bundled npm. Reinstall SupportPages Writer, then rerun setup.');
169
+ throw new Error(`Missing ${executable}. Install it and rerun setup. Published SupportPages Writer releases include Node and npm; Git must be installed separately.`);
170
+ }
171
+ }
172
+ if (!options['skip-dependencies']) {
173
+ if (await exists(path.join(installRoot, 'src/index.ts'))) {
174
+ await checked('npm', ['ci', '--no-audit', '--no-fund'], { cwd: installRoot });
175
+ await checked('npm', ['run', 'build'], { cwd: installRoot });
176
+ } else if (!await exists(path.join(installRoot, 'node_modules/@modelcontextprotocol/sdk'))) {
177
+ await checked('npm', ['install', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: installRoot });
178
+ }
179
+ }
180
+ if (!await exists(path.join(installRoot, 'dist/index.js'))) throw new Error('The server is not built. Run npm ci && npm run build, or rerun without --skip-dependencies.');
181
+ const { apiOrigin, defaultOrigin, developmentMode } = await import(pathToFileURL(path.join(installRoot, 'dist/api.js')).href);
182
+ const dev = options.dev ?? developmentMode(env.SUPPORTPAGES_DEV);
183
+ const origin = apiOrigin(options['api-url'] ?? env.SUPPORTPAGES_API_URL ?? defaultOrigin(dev), dev);
184
+ await privateDirectory(configDir);
185
+ const name = options.name ?? (dev ? 'supportpages-dev' : 'supportpages');
186
+ if (!/^[a-z0-9][a-z0-9_-]{0,79}$/.test(name)) throw new Error('--name must contain 1–80 lowercase letters, digits, underscores or hyphens.');
187
+ const receiptPath = path.join(configDir, 'installations', `${name}.json`);
188
+ const prior = await exists(receiptPath) ? JSON.parse(await readFile(receiptPath, 'utf8')) : null;
189
+ if (prior && prior.api_origin !== origin) throw new Error('This connection name is already assigned to another API origin. Choose a different --name.');
190
+ const receipt = { version: 2, api_origin: origin, name, clients: prior?.name === name ? [...(prior.clients ?? [])] : [] };
191
+ ui.ok('Prerequisites ready; server built');
192
+
193
+ ui.step(3, 'Prepare the article engine');
194
+ const skills = await findSkills(options['skills-dir'], { home, installRoot });
195
+ await checkSkills(skills.directory);
196
+ let previousDirectory = prior?.skills_dir;
197
+ const previousConfig = path.join(configDir, 'installations', `${name}.mcp.json`);
198
+ if (!previousDirectory && prior && await exists(previousConfig)) {
199
+ const args = JSON.parse(await readFile(previousConfig, 'utf8')).mcpServers?.[name]?.args ?? [];
200
+ const index = args.indexOf('--skills-dir');
201
+ if (index >= 0 && typeof args[index + 1] === 'string') previousDirectory = args[index + 1];
202
+ }
203
+ if (!options['skip-skills']) {
204
+ if (client === 'claude' || client === 'both') await backup(path.join(env.CLAUDE_CONFIG_DIR || path.join(home, '.claude'), 'settings.json'), path.join(configDir, 'backups'));
205
+ if (!options.embedded) ui.line(` Using ${skills.directory}`);
206
+ const node = dependencies.node ?? process.execPath;
207
+ const hook = await stablePath(path.join(skills.directory, 'generate-illustrated-article', 'scripts', 'trace_hook.js'), installRoot);
208
+ await installArticleSkills({ home, client, directory: skills.directory, previousDirectory, writeJson: privateJson, env, dataDir, hookCommand: [node, hook] });
209
+ if (!await ensureRenderer(skills.directory, run, { node, ui })) {
210
+ throw new Error('Chromium for article screenshots could not be downloaded. Check your network connection, then rerun setup; completed steps are kept.');
211
+ }
212
+ } else {
213
+ await retirePublicSkillLinks({ home, clients: client === 'both' ? ['claude', 'codex'] : [client === 'manual' ? 'codex' : client], directory: skills.directory, previousDirectory, env });
214
+ }
215
+ receipt.skills_dir = skills.directory;
216
+ ui.ok(options['skip-skills'] ? 'Using the existing article engine (preparation skipped)' : 'Article engine and renderer ready');
217
+
218
+ ui.step(4, 'Connect your coding client');
219
+ if (clients.includes('claude')) {
220
+ const agent = await installClaudeWriter({ home, env });
221
+ ui.line(options.embedded ? 'Added SupportPages Writer to Claude Code.' : ` SupportPages Writer agent available to Claude Code: ${agent.filename}`);
222
+ }
223
+ const serverArgs = [path.join(installRoot, 'dist/index.js'), ...(dev ? ['--dev'] : []), '--api-url', origin, '--skills-dir', skills.directory, '--config-dir', configDir];
224
+ const manual = { mcpServers: { [name]: { command: dependencies.node ?? process.execPath, args: serverArgs } } };
225
+ const manualPath = path.join(configDir, 'installations', `${name}.mcp.json`);
226
+ await privateJson(manualPath, manual);
227
+ for (const selected of clients) {
228
+ const existing = await run(selected, ['mcp', 'get', name, ...(selected === 'codex' ? ['--json'] : [])], { capture: true });
229
+ if (existing.code === 127) throw new Error(`${selected} is no longer available on PATH.`);
230
+ if (existing.code === 0) {
231
+ const managed = prior?.name === name && prior?.clients?.includes(selected);
232
+ if (yes && !managed) throw new Error(`An existing ${selected} MCP entry named ${name} was not created by this installer. Use --name to choose a different name.`);
233
+ if (!yes && !(options.embedded && managed) && !await confirm(`Update the existing ${selected} entry ${name}?`, Boolean(managed))) {
234
+ if (options.embedded) throw new Error(`Could not register ${selected}: the existing entry was kept. Choose a different connection with the advanced installer.`);
235
+ ui.line(` Kept the existing ${selected} entry. Manual configuration: ${manualPath}`);
236
+ continue;
237
+ }
238
+ }
239
+ const configFile = selected === 'codex' ? path.join(env.CODEX_HOME || path.join(home, '.codex'), 'config.toml') : (env.CLAUDE_CONFIG_DIR ? path.join(env.CLAUDE_CONFIG_DIR, '.claude.json') : path.join(home, '.claude.json'));
240
+ const backupPath = await backup(configFile, path.join(configDir, 'backups'));
241
+ if (existing.code === 0 && selected === 'claude') {
242
+ await checked('claude', ['mcp', 'remove', '--scope', 'user', name], { capture: true });
243
+ }
244
+ const registrationCommand = registration(selected, name, serverArgs, dependencies.node ?? process.execPath);
245
+ const updatingCodex = selected === 'codex' && existing.code === 0;
246
+ if (updatingCodex) await (dependencies.installCodex ?? installCodexIntegration)({ home, env, name, configDir, backup, ui, announcePermission: !options.embedded,
247
+ registration: { command: dependencies.node ?? process.execPath, args: serverArgs } });
248
+ const result = updatingCodex ? { code: 0 } : await run(registrationCommand.command, registrationCommand.args, { capture: true });
249
+ if (result.code !== 0) throw new Error(`Could not register ${selected}. Manual configuration: ${manualPath}.${backupPath ? ` Your original configuration is backed up at ${backupPath}.` : ''}`);
250
+ if (!receipt.clients.includes(selected)) receipt.clients.push(selected);
251
+ await privateJson(receiptPath, receipt);
252
+ if (selected === 'claude') {
253
+ const permission = await installClaudePermissions({ home, env, name, configDir, backup, writeJson: privateJson });
254
+ if (permission.changed && !options.embedded) ui.line(" Allowed SupportPages Writer's tools in Claude Code; deleting or unpublishing an article still asks first.");
255
+ }
256
+ if (selected === 'codex' && !updatingCodex) await (dependencies.installCodex ?? installCodexIntegration)({ home, env, name, configDir, backup, ui, announcePermission: !options.embedded });
257
+ ui.ok(`${selected === 'codex' ? 'Codex' : 'Claude Code'} connected as ${name}`);
258
+ }
259
+ if (!clients.length) ui.line(` Add the server entry from ${manualPath} to your MCP client.\n${JSON.stringify(manual, null, 2)}`);
260
+ if (!clients.length) await privateJson(receiptPath, receipt);
261
+ await checked(process.execPath, [...serverArgs, '--help'], { capture: true });
262
+ ui.ok('Server startup checked');
263
+ if (options.embedded) return { name, clients: receipt.clients, manualPath, skillsDir: skills.directory };
264
+ ui.line('\n Setup complete.');
265
+ ui.line(' Restart your coding client and open any product repository.');
266
+ ui.line(' Try: “Initialize SupportPages.io for this project.”');
267
+ ui.line(` Run ${CLI_NAME} setup once on this computer, then ${CLI_NAME} init inside each project folder.`);
268
+ return { name, clients: receipt.clients, manualPath, skillsDir: skills.directory };
269
+ }
@@ -0,0 +1,38 @@
1
+ import { lstat, mkdir, open, readFile, rename, rm, copyFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ export async function installManagedWriter({ config, name, extension, contents, marker, existingOnly = false }) {
6
+ return installManagedFile({ config, parts: ['agents', `${name}.${extension}`], contents, marker, kind: 'agent', existingOnly });
7
+ }
8
+
9
+ /** Shared ownership, backup and atomic-write rules for installed agent instructions. */
10
+ export async function installManagedFile({ config, parts, contents, marker, kind, existingOnly = false }) {
11
+ const entries = [config];
12
+ for (const part of parts) entries.push(path.join(entries.at(-1), part));
13
+ const filename = entries.at(-1), directory = path.dirname(filename);
14
+ for (const entry of entries) {
15
+ try {
16
+ const info = await lstat(entry);
17
+ if (info.isSymbolicLink() || (entry === filename ? !info.isFile() : !info.isDirectory())) throw Error(`Cannot install the SupportPages.io ${kind} over an unsafe path: ${entry}`);
18
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
19
+ }
20
+ let previous;
21
+ try { previous = await readFile(filename, 'utf8'); }
22
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
23
+ if (existingOnly && (previous === undefined || !previous.includes(marker))) return { filename, changed: false };
24
+ if (previous === contents) return { filename, changed: false };
25
+ if (previous !== undefined && !previous.includes(marker)) {
26
+ throw Error(`A custom ${kind} already exists at ${filename}. Keep or rename it before installing SupportPages.io instructions.`);
27
+ }
28
+ await mkdir(directory, { recursive: true, mode: 0o700 });
29
+ if (previous !== undefined) await copyFile(filename, `${filename}.backup-${randomUUID()}`);
30
+ const temporary = `${filename}.${randomUUID()}.tmp`;
31
+ try {
32
+ const file = await open(temporary, 'wx', 0o600);
33
+ try { await file.writeFile(contents); await file.sync(); }
34
+ finally { await file.close(); }
35
+ await rename(temporary, filename);
36
+ } finally { await rm(temporary, { force: true }); }
37
+ return { filename, changed: true };
38
+ }