@showly/mcp-server 0.4.2 → 0.4.3

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/dist/cli.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  export type Target = "claude-code" | "codex" | "stdout";
3
+ export type LoginTarget = Target | "openclaw" | "hermes";
3
4
  /**
4
5
  * The host that will RECEIVE a token printed to stdout.
5
6
  *
@@ -88,7 +89,7 @@ export declare const LOGIN_CLIENT_ID = "showly-mcp-cli";
88
89
  * names; lookalikes stay neutral.
89
90
  */
90
91
  export declare const LOGIN_AGENT_CLIENT_IDS: Record<LoginAgent, string>;
91
- export declare function loginClientId(target: Target, agent?: LoginAgent): string;
92
+ export declare function loginClientId(target: LoginTarget, agent?: LoginAgent): string;
92
93
  /** The env var name emitted into config snippets that must not hold a secret. */
93
94
  export declare const TOKEN_ENV_VAR = "SHOWLY_TOKEN";
94
95
  export type DeviceStart = {
@@ -138,6 +139,29 @@ export declare function buildCodexAuthSnippet(opts: {
138
139
  url: string;
139
140
  tokenEnvVar?: string;
140
141
  }): string;
142
+ /** Secrets used by the two remote-agent targets the Hero prompt exercises. */
143
+ export declare const OPENCLAW_TOKEN_ENV_VAR = "SHOWLY_MCP_TOKEN";
144
+ export declare const HERMES_TOKEN_ENV_VAR = "MCP_SHOWLY_API_KEY";
145
+ /**
146
+ * Merge Showly into OpenClaw's user config without disturbing other Gateway
147
+ * settings. The token lives in the Gateway-owned env.vars store; the MCP entry
148
+ * contains only an environment placeholder. `auth: oauth` is removed because
149
+ * header auth and native OAuth are alternative modes in OpenClaw.
150
+ */
151
+ export declare function buildOpenClawAuthConfig(opts: {
152
+ existing: Record<string, unknown>;
153
+ url: string;
154
+ token: string;
155
+ }): Record<string, unknown>;
156
+ /** Replace one dotenv assignment, remove duplicates, and preserve other lines. */
157
+ export declare function mergeDotEnvCredential(existing: string, name: string, token: string): string;
158
+ /**
159
+ * Update Hermes YAML through a real YAML document rather than `hermes config
160
+ * set`, whose secret masking corrupts the literal ${MCP_SHOWLY_API_KEY}
161
+ * placeholder. Parse errors are terminal: never replace a user's unreadable
162
+ * config with a guessed one.
163
+ */
164
+ export declare function buildHermesAuthConfig(existing: string, url: string): string;
141
165
  export type LoginDeps = {
142
166
  fetchImpl?: typeof fetch;
143
167
  /**
@@ -240,11 +264,12 @@ export declare function pollForDeviceToken(input: {
240
264
  clientId?: string;
241
265
  }, deps?: LoginDeps): Promise<DeviceToken>;
242
266
  export type LoginResult = {
243
- target: Target;
267
+ target: LoginTarget;
244
268
  token: string;
245
269
  scope: string;
246
270
  expiresAt: Date | null;
247
271
  path: string | null;
272
+ credentialPath?: string;
248
273
  wrote: boolean;
249
274
  snippet: string;
250
275
  };
@@ -259,7 +284,7 @@ export type LoginResult = {
259
284
  * and out of every `ps` listing on the machine.
260
285
  */
261
286
  export declare function performLogin(opts: {
262
- target: Target;
287
+ target: LoginTarget;
263
288
  agent?: LoginAgent;
264
289
  env?: NodeJS.ProcessEnv;
265
290
  }, deps?: LoginDeps): Promise<LoginResult>;
package/dist/cli.js CHANGED
@@ -34,6 +34,7 @@ import { readFileSync, readdirSync, mkdirSync, rmSync, writeFileSync, existsSync
34
34
  import { dirname, join } from "node:path";
35
35
  import { homedir } from "node:os";
36
36
  import { pathToFileURL } from "node:url";
37
+ import { parseDocument } from "yaml";
37
38
  import { loadManifest } from "./index.js";
38
39
  import { SHOWLY_HOSTING_SKILL_DIRECTORY, SHOWLY_HOSTING_SKILL_NAME, SHOWLY_LEGACY_SKILL_NAME, } from "./showly-hosting-skill.js";
39
40
  /**
@@ -83,13 +84,15 @@ function usage() {
83
84
  "",
84
85
  "Usage:",
85
86
  " showly-mcp install --to <claude-code|codex|stdout> [--with-skill]",
86
- " showly-mcp login [--to <claude-code|codex|stdout>] [--agent <host>] [--print-token]",
87
+ " showly-mcp login [--to <claude-code|codex|openclaw|hermes|stdout>] [--agent <host>] [--print-token]",
87
88
  " showly-mcp manifest",
88
89
  " showly-mcp --version",
89
90
  "",
90
91
  "login authorizes this machine without a browser on it: it prints a short",
91
92
  "code, you approve it on any device, and the credential lands in your host",
92
- "config. --to codex writes a config that reads the token from SHOWLY_TOKEN,",
93
+ "config. --to openclaw and --to hermes safely merge the credential into",
94
+ "those hosts' user config; --to codex prints the SHOWLY_TOKEN export its",
95
+ "config reads,",
93
96
  "so login also prints the export line that sets it. --print-token writes",
94
97
  "ONLY the token to stdout (everything else goes to stderr) so CI can",
95
98
  "capture it without it touching a file. When that token is for another",
@@ -309,6 +312,24 @@ function safeReadJson(path) {
309
312
  return {};
310
313
  }
311
314
  }
315
+ /**
316
+ * Read a host's primary config without the install helper's empty fallback.
317
+ * Login is about to add a live credential, so treating malformed JSON as `{}`
318
+ * would overwrite the user's whole Gateway after they had already approved.
319
+ */
320
+ function readHostJson(path, label) {
321
+ let parsed;
322
+ try {
323
+ parsed = JSON.parse(readFileSync(path, "utf8"));
324
+ }
325
+ catch (error) {
326
+ throw new Error(`Could not update ${label}: ${error instanceof Error ? error.message : String(error)}`);
327
+ }
328
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
329
+ throw new Error(`Could not update ${label}: the root must be a JSON object.`);
330
+ }
331
+ return parsed;
332
+ }
312
333
  /**
313
334
  * Write a file that holds (or will hold) a credential, 0600 on creation.
314
335
  *
@@ -466,6 +487,89 @@ export function buildCodexAuthSnippet(opts) {
466
487
  "",
467
488
  ].join("\n");
468
489
  }
490
+ /** Secrets used by the two remote-agent targets the Hero prompt exercises. */
491
+ export const OPENCLAW_TOKEN_ENV_VAR = "SHOWLY_MCP_TOKEN";
492
+ export const HERMES_TOKEN_ENV_VAR = "MCP_SHOWLY_API_KEY";
493
+ function asRecord(value) {
494
+ return typeof value === "object" && value !== null && !Array.isArray(value)
495
+ ? value
496
+ : {};
497
+ }
498
+ /**
499
+ * Merge Showly into OpenClaw's user config without disturbing other Gateway
500
+ * settings. The token lives in the Gateway-owned env.vars store; the MCP entry
501
+ * contains only an environment placeholder. `auth: oauth` is removed because
502
+ * header auth and native OAuth are alternative modes in OpenClaw.
503
+ */
504
+ export function buildOpenClawAuthConfig(opts) {
505
+ const env = asRecord(opts.existing.env);
506
+ const vars = asRecord(env.vars);
507
+ const mcp = asRecord(opts.existing.mcp);
508
+ const servers = asRecord(mcp.servers);
509
+ const currentShowly = asRecord(servers.showly);
510
+ const currentHeaders = asRecord(currentShowly.headers);
511
+ const { auth: _nativeOauth, ...showlyWithoutOauth } = currentShowly;
512
+ return {
513
+ ...opts.existing,
514
+ env: {
515
+ ...env,
516
+ vars: { ...vars, [OPENCLAW_TOKEN_ENV_VAR]: opts.token },
517
+ },
518
+ mcp: {
519
+ ...mcp,
520
+ servers: {
521
+ ...servers,
522
+ showly: {
523
+ ...showlyWithoutOauth,
524
+ url: opts.url,
525
+ transport: "streamable-http",
526
+ headers: {
527
+ ...currentHeaders,
528
+ Authorization: `Bearer \${${OPENCLAW_TOKEN_ENV_VAR}}`,
529
+ },
530
+ },
531
+ },
532
+ },
533
+ };
534
+ }
535
+ /** Replace one dotenv assignment, remove duplicates, and preserve other lines. */
536
+ export function mergeDotEnvCredential(existing, name, token) {
537
+ const assignment = `${name}=${token}`;
538
+ const matcher = new RegExp(`^(?:export\\s+)?${name}=`);
539
+ const lines = existing.split(/\r?\n/);
540
+ if (lines.at(-1) === "")
541
+ lines.pop();
542
+ const merged = [];
543
+ let wrote = false;
544
+ for (const line of lines) {
545
+ if (!matcher.test(line)) {
546
+ merged.push(line);
547
+ continue;
548
+ }
549
+ if (!wrote)
550
+ merged.push(assignment);
551
+ wrote = true;
552
+ }
553
+ if (!wrote)
554
+ merged.push(assignment);
555
+ return `${merged.join("\n")}\n`;
556
+ }
557
+ /**
558
+ * Update Hermes YAML through a real YAML document rather than `hermes config
559
+ * set`, whose secret masking corrupts the literal ${MCP_SHOWLY_API_KEY}
560
+ * placeholder. Parse errors are terminal: never replace a user's unreadable
561
+ * config with a guessed one.
562
+ */
563
+ export function buildHermesAuthConfig(existing, url) {
564
+ const doc = parseDocument(existing.trim().length > 0 ? existing : "{}\n");
565
+ if (doc.errors.length > 0) {
566
+ throw new Error(`Could not update ~/.hermes/config.yaml: ${doc.errors[0].message}`);
567
+ }
568
+ doc.setIn(["mcp_servers", "showly", "url"], url);
569
+ doc.deleteIn(["mcp_servers", "showly", "auth"]);
570
+ doc.setIn(["mcp_servers", "showly", "headers", "Authorization"], `Bearer \${${HERMES_TOKEN_ENV_VAR}}`);
571
+ return doc.toString({ lineWidth: 0 });
572
+ }
469
573
  /**
470
574
  * How long ONE HTTP request may take before it is abandoned.
471
575
  *
@@ -763,6 +867,22 @@ export async function performLogin(opts, deps = {}) {
763
867
  const env = opts.env ?? process.env;
764
868
  const { url, apiUrl } = resolveUrls(env);
765
869
  const log = deps.log ?? ((line) => console.error(line));
870
+ // Validate destination files before asking a human to approve anything.
871
+ // Re-read them after approval before merging, so a change made during the
872
+ // device wait is preserved too.
873
+ const openClawPath = opts.target === "openclaw"
874
+ ? join(homedir(), ".openclaw", "openclaw.json")
875
+ : null;
876
+ if (openClawPath && existsSync(openClawPath)) {
877
+ readHostJson(openClawPath, "~/.openclaw/openclaw.json");
878
+ }
879
+ const hermesDirectory = opts.target === "hermes" ? join(homedir(), ".hermes") : null;
880
+ const hermesConfigPath = hermesDirectory
881
+ ? join(hermesDirectory, "config.yaml")
882
+ : null;
883
+ if (hermesConfigPath && existsSync(hermesConfigPath)) {
884
+ buildHermesAuthConfig(readFileSync(hermesConfigPath, "utf8"), url);
885
+ }
766
886
  const clientId = loginClientId(opts.target, opts.agent);
767
887
  const started = await startDeviceFlow(apiUrl, deps, clientId);
768
888
  const expiresAt = new Date(Date.now() + started.expires_in * 1000);
@@ -854,6 +974,54 @@ export async function performLogin(opts, deps = {}) {
854
974
  snippet,
855
975
  };
856
976
  }
977
+ if (opts.target === "openclaw") {
978
+ const path = openClawPath;
979
+ const existing = existsSync(path)
980
+ ? readHostJson(path, "~/.openclaw/openclaw.json")
981
+ : {};
982
+ const merged = buildOpenClawAuthConfig({
983
+ existing,
984
+ url,
985
+ token: token.access_token,
986
+ });
987
+ mkdirSync(dirname(path), { recursive: true });
988
+ writeCredentialFile(path, JSON.stringify(merged, null, 2) + "\n");
989
+ return {
990
+ target: opts.target,
991
+ token: token.access_token,
992
+ scope: token.scope,
993
+ expiresAt: tokenExpiresAt,
994
+ path,
995
+ wrote: true,
996
+ snippet: JSON.stringify(merged, null, 2),
997
+ };
998
+ }
999
+ if (opts.target === "hermes") {
1000
+ const directory = hermesDirectory;
1001
+ const path = hermesConfigPath;
1002
+ const credentialPath = join(directory, ".env");
1003
+ const existingConfig = existsSync(path) ? readFileSync(path, "utf8") : "";
1004
+ const existingEnv = existsSync(credentialPath)
1005
+ ? readFileSync(credentialPath, "utf8")
1006
+ : "";
1007
+ // Build both outputs before touching either file. In particular, malformed
1008
+ // YAML must not leave a fresh secret behind beside an unchanged config.
1009
+ const config = buildHermesAuthConfig(existingConfig, url);
1010
+ const dotenv = mergeDotEnvCredential(existingEnv, HERMES_TOKEN_ENV_VAR, token.access_token);
1011
+ mkdirSync(directory, { recursive: true });
1012
+ writeCredentialFile(credentialPath, dotenv);
1013
+ writeCredentialFile(path, config);
1014
+ return {
1015
+ target: opts.target,
1016
+ token: token.access_token,
1017
+ scope: token.scope,
1018
+ expiresAt: tokenExpiresAt,
1019
+ path,
1020
+ credentialPath,
1021
+ wrote: true,
1022
+ snippet: config,
1023
+ };
1024
+ }
857
1025
  return {
858
1026
  target: opts.target,
859
1027
  token: token.access_token,
@@ -916,7 +1084,10 @@ export function buildLoginOutput(result, opts = {}) {
916
1084
  return [{ stream: "out", line: result.token }];
917
1085
  const lines = [];
918
1086
  if (result.wrote) {
919
- lines.push({ stream: "err", line: `Connected. Wrote ${result.path}` });
1087
+ const written = [result.path, result.credentialPath]
1088
+ .filter((path) => Boolean(path))
1089
+ .join(" and ");
1090
+ lines.push({ stream: "err", line: `Connected. Wrote ${written}` });
920
1091
  }
921
1092
  else if (result.path) {
922
1093
  lines.push({
@@ -1029,12 +1200,13 @@ export function parseCommandArgs(command, rest) {
1029
1200
  }
1030
1201
  return { ok: true, help, flags, values };
1031
1202
  }
1032
- function parseTarget(parsed, fallback) {
1203
+ function parseLoginTarget(parsed, fallback) {
1033
1204
  const value = parsed.values.get("--to");
1034
1205
  if (value === undefined)
1035
1206
  return fallback;
1036
- if (!["claude-code", "codex", "stdout"].includes(value))
1207
+ if (!["claude-code", "codex", "openclaw", "hermes", "stdout"].includes(value)) {
1037
1208
  return null;
1209
+ }
1038
1210
  return value;
1039
1211
  }
1040
1212
  function parseLoginAgent(parsed) {
@@ -1098,9 +1270,9 @@ export async function runCli(argv, io = consoleIo, env = process.env) {
1098
1270
  if (command === "login") {
1099
1271
  // --to defaults to stdout: printing a snippet can never corrupt a config
1100
1272
  // file the user did not ask us to touch.
1101
- const target = parseTarget(parsed, "stdout");
1273
+ const target = parseLoginTarget(parsed, "stdout");
1102
1274
  if (!target) {
1103
- io.err("login: --to must be claude-code, codex or stdout");
1275
+ io.err("login: --to must be claude-code, codex, openclaw, hermes or stdout");
1104
1276
  return 2;
1105
1277
  }
1106
1278
  const agent = parseLoginAgent(parsed);
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://showly.ai/schemas/skill-manifest-v1.json",
3
3
  "name": "showly",
4
4
  "displayName": "Showly",
5
- "version": "0.4.2",
5
+ "version": "0.4.3",
6
6
  "description": "Deploy and manage Showly sites from inside Claude Code / Codex.",
7
7
  "homepage": "https://showly.ai/docs/mcp/overview",
8
8
  "publisher": "Showly",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@showly/mcp-server",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Connect Claude Code / Codex to the Showly MCP server — preview and deploy sites from your agent.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "claude-code-skill": {
54
54
  "name": "showly",
55
- "version": "0.4.2",
55
+ "version": "0.4.3",
56
56
  "description": "Deploy and manage Showly sites from inside Claude Code.",
57
57
  "mcp-server": {
58
58
  "url-env": "SHOWLY_MCP_URL",
@@ -65,10 +65,13 @@
65
65
  },
66
66
  "codex-plugin": {
67
67
  "name": "showly",
68
- "version": "0.4.2",
68
+ "version": "0.4.3",
69
69
  "type": "mcp-server",
70
70
  "manifest": "manifest.json"
71
71
  },
72
+ "dependencies": {
73
+ "yaml": "^2.9.0"
74
+ },
72
75
  "devDependencies": {
73
76
  "@showly/eslint-config": "workspace:^",
74
77
  "@types/node": "^26.2.0",