@relaymessenger/cli 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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/claude-plugin/marketplace/.claude-plugin/marketplace.json +14 -0
  4. package/claude-plugin/marketplace/plugins/relay/.claude-plugin/plugin.json +18 -0
  5. package/claude-plugin/marketplace/plugins/relay/LICENSE +21 -0
  6. package/claude-plugin/marketplace/plugins/relay/README.md +173 -0
  7. package/claude-plugin/marketplace/plugins/relay/commands/configure.md +71 -0
  8. package/claude-plugin/marketplace/plugins/relay/runtime/server.mjs +26107 -0
  9. package/dist/api.d.ts +74 -0
  10. package/dist/api.d.ts.map +1 -0
  11. package/dist/api.js +129 -0
  12. package/dist/api.js.map +1 -0
  13. package/dist/claude-settings.d.ts +14 -0
  14. package/dist/claude-settings.d.ts.map +1 -0
  15. package/dist/claude-settings.js +47 -0
  16. package/dist/claude-settings.js.map +1 -0
  17. package/dist/cli.d.ts +3 -0
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/cli.js +162 -0
  20. package/dist/cli.js.map +1 -0
  21. package/dist/codex.d.ts +25 -0
  22. package/dist/codex.d.ts.map +1 -0
  23. package/dist/codex.js +227 -0
  24. package/dist/codex.js.map +1 -0
  25. package/dist/doctor.d.ts +2 -0
  26. package/dist/doctor.d.ts.map +1 -0
  27. package/dist/doctor.js +122 -0
  28. package/dist/doctor.js.map +1 -0
  29. package/dist/engine/acp.d.ts +69 -0
  30. package/dist/engine/acp.d.ts.map +1 -0
  31. package/dist/engine/acp.js +402 -0
  32. package/dist/engine/acp.js.map +1 -0
  33. package/dist/engine/catalog.d.ts +26 -0
  34. package/dist/engine/catalog.d.ts.map +1 -0
  35. package/dist/engine/catalog.js +41 -0
  36. package/dist/engine/catalog.js.map +1 -0
  37. package/dist/engine/process.d.ts +12 -0
  38. package/dist/engine/process.d.ts.map +1 -0
  39. package/dist/engine/process.js +54 -0
  40. package/dist/engine/process.js.map +1 -0
  41. package/dist/engine/types.d.ts +57 -0
  42. package/dist/engine/types.d.ts.map +1 -0
  43. package/dist/engine/types.js +8 -0
  44. package/dist/engine/types.js.map +1 -0
  45. package/dist/flags.d.ts +9 -0
  46. package/dist/flags.d.ts.map +1 -0
  47. package/dist/flags.js +34 -0
  48. package/dist/flags.js.map +1 -0
  49. package/dist/install.d.ts +69 -0
  50. package/dist/install.d.ts.map +1 -0
  51. package/dist/install.js +510 -0
  52. package/dist/install.js.map +1 -0
  53. package/dist/mcp.d.ts +16 -0
  54. package/dist/mcp.d.ts.map +1 -0
  55. package/dist/mcp.js +127 -0
  56. package/dist/mcp.js.map +1 -0
  57. package/dist/pair.d.ts +20 -0
  58. package/dist/pair.d.ts.map +1 -0
  59. package/dist/pair.js +135 -0
  60. package/dist/pair.js.map +1 -0
  61. package/dist/permissions.d.ts +87 -0
  62. package/dist/permissions.d.ts.map +1 -0
  63. package/dist/permissions.js +342 -0
  64. package/dist/permissions.js.map +1 -0
  65. package/dist/receive.d.ts +54 -0
  66. package/dist/receive.d.ts.map +1 -0
  67. package/dist/receive.js +353 -0
  68. package/dist/receive.js.map +1 -0
  69. package/dist/store.d.ts +260 -0
  70. package/dist/store.d.ts.map +1 -0
  71. package/dist/store.js +550 -0
  72. package/dist/store.js.map +1 -0
  73. package/openclaw-plugin/relaymessenger-openclaw-plugin-0.1.0.tgz +0 -0
  74. package/package.json +51 -0
@@ -0,0 +1,9 @@
1
+ import { type EngineName } from "./engine/catalog.js";
2
+ export interface CliFlags {
3
+ engine: EngineName;
4
+ dir?: string;
5
+ name?: string;
6
+ rest: string[];
7
+ }
8
+ export declare function parseFlags(argv: string[]): CliFlags;
9
+ //# sourceMappingURL=flags.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flags.d.ts","sourceRoot":"","sources":["../src/flags.ts"],"names":[],"mappings":"AACA,OAAO,EAA6B,KAAK,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAEjF,MAAM,WAAW,QAAQ;IACvB,MAAM,EAAE,UAAU,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,QAAQ,CAuBnD"}
package/dist/flags.js ADDED
@@ -0,0 +1,34 @@
1
+ import { resolve } from "node:path";
2
+ import { ENGINE_HELP, isEngineName } from "./engine/catalog.js";
3
+ export function parseFlags(argv) {
4
+ const flags = { engine: "claude", rest: [] };
5
+ for (let i = 0; i < argv.length; i += 1) {
6
+ const arg = argv[i];
7
+ if (arg === "--staging") {
8
+ throw new Error("--staging is unavailable because Relay has no live staging API origin.");
9
+ }
10
+ else if (arg === "--engine") {
11
+ const value = argv[++i];
12
+ if (!isEngineName(value)) {
13
+ throw new Error(`--engine must be one of ${ENGINE_HELP}, got ${value ?? "(nothing)"}`);
14
+ }
15
+ flags.engine = value;
16
+ }
17
+ else if (arg === "--dir") {
18
+ const value = argv[++i];
19
+ if (!value)
20
+ throw new Error("--dir needs a path");
21
+ flags.dir = resolve(value);
22
+ }
23
+ else if (arg === "--name") {
24
+ const value = argv[++i];
25
+ if (!value)
26
+ throw new Error("--name needs a value");
27
+ flags.name = value;
28
+ }
29
+ else
30
+ flags.rest.push(arg);
31
+ }
32
+ return flags;
33
+ }
34
+ //# sourceMappingURL=flags.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flags.js","sourceRoot":"","sources":["../src/flags.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,WAAW,EAAE,YAAY,EAAmB,MAAM,qBAAqB,CAAC;AASjF,MAAM,UAAU,UAAU,CAAC,IAAc;IACvC,MAAM,KAAK,GAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;IACvD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACxC,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,CAAE,CAAC;QACrB,IAAI,GAAG,KAAK,WAAW,EAAE,CAAC;YACxB,MAAM,IAAI,KAAK,CAAC,wEAAwE,CAAC,CAAC;QAC5F,CAAC;aAAM,IAAI,GAAG,KAAK,UAAU,EAAE,CAAC;YAC9B,MAAM,KAAK,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;YACxB,IAAI,CAAC,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;gBACzB,MAAM,IAAI,KAAK,CAAC,2BAA2B,WAAW,SAAS,KAAK,IAAI,WAAW,EAAE,CAAC,CAAC;YACzF,CAAC;YACD,KAAK,CAAC,MAAM,GAAG,KAAK,CAAC;QACvB,CAAC;aAAM,IAAI,GAAG,KAAK,OAAO,EAAE,CAAC;YAC3B,MAAM,KAAK,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;YACxB,IAAI,CAAC,KAAK;gBAAE,MAAM,IAAI,KAAK,CAAC,oBAAoB,CAAC,CAAC;YAClD,KAAK,CAAC,GAAG,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;QAC7B,CAAC;aAAM,IAAI,GAAG,KAAK,QAAQ,EAAE,CAAC;YAC5B,MAAM,KAAK,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;YACxB,IAAI,CAAC,KAAK;gBAAE,MAAM,IAAI,KAAK,CAAC,sBAAsB,CAAC,CAAC;YACpD,KAAK,CAAC,IAAI,GAAG,KAAK,CAAC;QACrB,CAAC;;YAAM,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC9B,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
@@ -0,0 +1,69 @@
1
+ import { CodexNotifyPolicyStore, ConfigStore } from "./store.js";
2
+ export interface MergeReport {
3
+ changed: boolean;
4
+ notes: string[];
5
+ }
6
+ export interface InstallCodexOptions {
7
+ projectRoot?: string;
8
+ policy?: CodexNotifyPolicyStore;
9
+ }
10
+ /** Secret-bearing Codex config writes are private and crash-atomic. */
11
+ export declare function writePrivateText(path: string, value: string): void;
12
+ /**
13
+ * Pure merge for tests: returns the new TOML text plus a report.
14
+ *
15
+ * The existing file is parsed only to DETECT what is present; the returned
16
+ * text is the user's original byte-for-byte with the missing pieces added
17
+ * textually (top-level `notify` prepended before the first table so it stays
18
+ * top-level; `[mcp_servers.relay]` appended at the end). Comments, ordering,
19
+ * and formatting the user wrote are never round-tripped away.
20
+ */
21
+ export declare function mergeCodexConfigToml(existing: string): {
22
+ toml: string;
23
+ report: MergeReport;
24
+ };
25
+ /** Pure merge for tests: hooks.json PermissionRequest handler, append-only. */
26
+ export declare function mergeHooksJson(existing: string): {
27
+ json: string;
28
+ report: MergeReport;
29
+ };
30
+ export declare function installCodex(codexHome?: string, out?: (line: string) => void, options?: InstallCodexOptions): void;
31
+ export interface InstallClaudeOptions {
32
+ config?: ConfigStore;
33
+ channelDir?: string;
34
+ /** Generated marketplace root; injectable only for tests. */
35
+ bundleDir?: string;
36
+ /** Stable account-scoped install root; injectable only for tests. */
37
+ installRoot?: string;
38
+ claudeCommand?: string;
39
+ runClaude?: (command: string, args: string[]) => {
40
+ status: number | null;
41
+ stdout?: string;
42
+ stderr?: string;
43
+ error?: Error;
44
+ };
45
+ }
46
+ export interface InstallOpenClawOptions {
47
+ config?: ConfigStore;
48
+ openclawHome?: string;
49
+ bundleDir?: string;
50
+ installRoot?: string;
51
+ openclawCommand?: string;
52
+ runOpenClaw?: InstallClaudeOptions["runClaude"];
53
+ }
54
+ export declare function mergeClaudeChannelEnv(existing: string, desired: {
55
+ token: string;
56
+ origin: string;
57
+ ownerUserId: string;
58
+ }): string;
59
+ export declare function platformCliCommand(command: string, platform?: NodeJS.Platform): string;
60
+ export declare function mergeOpenClawConfig(existing: string, desired: {
61
+ token: string;
62
+ tokenFile: string;
63
+ baseUrl: string;
64
+ }): string;
65
+ /** Install the bundled marketplace plus owner-only channel credentials. */
66
+ export declare function installClaude(out?: (line: string) => void, options?: InstallClaudeOptions): void;
67
+ /** Install and configure the OpenClaw plugin from relaymessenger's bundled archive. */
68
+ export declare function installOpenClaw(out?: (line: string) => void, options?: InstallOpenClawOptions): void;
69
+ //# sourceMappingURL=install.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../src/install.ts"],"names":[],"mappings":"AAqCA,OAAO,EACL,sBAAsB,EACtB,WAAW,EAGZ,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,sBAAsB,CAAC;CACjC;AAYD,uEAAuE;AACvE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CA6BlE;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,WAAW,CAAA;CAAE,CAwC5F;AAED,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,WAAW,CAAA;CAAE,CAuBtF;AAYD,wBAAgB,YAAY,CAC1B,SAAS,SAA4B,EACrC,GAAG,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAkB,EACzC,OAAO,GAAE,mBAAwB,GAChC,IAAI,CAuDN;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qEAAqE;IACrE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK;QAC/C,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;QACtB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,KAAK,CAAC,EAAE,KAAK,CAAC;KACf,CAAC;CACH;AAED,MAAM,WAAW,sBAAsB;IACrC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,oBAAoB,CAAC,WAAW,CAAC,CAAC;CACjD;AAkBD,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAC9D,MAAM,CAoBR;AAgBD,wBAAgB,kBAAkB,CAChC,OAAO,EAAE,MAAM,EACf,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAC3C,MAAM,CAGR;AA0FD,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC7D,MAAM,CAmCR;AAED,2EAA2E;AAC3E,wBAAgB,aAAa,CAC3B,GAAG,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAkB,EACzC,OAAO,GAAE,oBAAyB,GACjC,IAAI,CAiGN;AAED,uFAAuF;AACvF,wBAAgB,eAAe,CAC7B,GAAG,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAkB,EACzC,OAAO,GAAE,sBAA2B,GACnC,IAAI,CAsEN"}
@@ -0,0 +1,510 @@
1
+ /**
2
+ * `relaymessenger install-codex` / `relaymessenger install-claude`.
3
+ *
4
+ * install-codex merges — never clobbers — three things into the user's Codex
5
+ * setup:
6
+ * ~/.codex/config.toml [mcp_servers.relay] → `relaymessenger mcp` stdio server
7
+ * ~/.codex/config.toml notify → `relaymessenger notify` turn-complete ping
8
+ * ~/.codex/hooks.json PermissionRequest → `relaymessenger hook permission-request`
9
+ * (phone-tap approvals; Codex hooks
10
+ * answer with decision JSON on stdout)
11
+ *
12
+ * Existing user values are preserved: a different notify command or an
13
+ * existing hook list is left alone (we append/skip, and report what we did).
14
+ */
15
+ import { chmodSync, closeSync, cpSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeSync, } from "node:fs";
16
+ import { createHash } from "node:crypto";
17
+ import { spawnSync } from "node:child_process";
18
+ import crossSpawn from "cross-spawn";
19
+ import { homedir } from "node:os";
20
+ import { dirname, isAbsolute, join, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+ import { parse as parseToml } from "smol-toml";
23
+ import { CodexNotifyPolicyStore, ConfigStore, resolveOwnerUserId, runtimeHomeForConfig, } from "./store.js";
24
+ const RELAY_NOTIFY = ["relaymessenger", "notify"];
25
+ const RELAY_PERMISSION_HOOK_COMMAND = "relaymessenger hook permission-request";
26
+ // Codex's hooks engine is Claude-style: matcher groups wrapping command handlers.
27
+ const RELAY_PERMISSION_HOOK_ENTRY = {
28
+ matcher: "*",
29
+ hooks: [{ type: "command", command: RELAY_PERMISSION_HOOK_COMMAND }],
30
+ };
31
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
32
+ /** Secret-bearing Codex config writes are private and crash-atomic. */
33
+ export function writePrivateText(path, value) {
34
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
35
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
36
+ let fd = openSync(tmp, "wx", 0o600);
37
+ let installed = false;
38
+ try {
39
+ writeSync(fd, value);
40
+ fsyncSync(fd);
41
+ closeSync(fd);
42
+ fd = -1;
43
+ renameSync(tmp, path);
44
+ chmodSync(path, 0o600);
45
+ installed = true;
46
+ }
47
+ finally {
48
+ if (fd !== -1) {
49
+ try {
50
+ closeSync(fd);
51
+ }
52
+ catch {
53
+ // Preserve the original write failure.
54
+ }
55
+ }
56
+ if (!installed) {
57
+ try {
58
+ unlinkSync(tmp);
59
+ }
60
+ catch {
61
+ // Best effort cleanup; preserve the original failure.
62
+ }
63
+ }
64
+ }
65
+ }
66
+ /**
67
+ * Pure merge for tests: returns the new TOML text plus a report.
68
+ *
69
+ * The existing file is parsed only to DETECT what is present; the returned
70
+ * text is the user's original byte-for-byte with the missing pieces added
71
+ * textually (top-level `notify` prepended before the first table so it stays
72
+ * top-level; `[mcp_servers.relay]` appended at the end). Comments, ordering,
73
+ * and formatting the user wrote are never round-tripped away.
74
+ */
75
+ export function mergeCodexConfigToml(existing) {
76
+ const notes = [];
77
+ let changed = false;
78
+ let doc;
79
+ try {
80
+ doc = (existing.trim().length > 0 ? parseToml(existing) : {});
81
+ }
82
+ catch {
83
+ throw new Error("~/.codex/config.toml exists but is not valid TOML; not touching it");
84
+ }
85
+ let toml = existing;
86
+ const servers = doc.mcp_servers;
87
+ if (servers?.relay === undefined) {
88
+ const suffix = `[mcp_servers.relay]\ncommand = "relaymessenger"\nargs = ["mcp"]\n`;
89
+ toml = toml.length === 0 || toml.endsWith("\n\n") ? `${toml}${suffix}`
90
+ : toml.endsWith("\n") ? `${toml}\n${suffix}`
91
+ : `${toml}\n\n${suffix}`;
92
+ notes.push("added [mcp_servers.relay]");
93
+ changed = true;
94
+ }
95
+ else {
96
+ notes.push("kept existing [mcp_servers.relay]");
97
+ }
98
+ if (doc.notify === undefined) {
99
+ // Top-level keys must precede any [table]; prepending is always valid.
100
+ toml = `notify = ["relaymessenger", "notify"]\n${toml.length > 0 && !toml.startsWith("\n") ? "\n" : ""}${toml}`;
101
+ notes.push('set notify = ["relaymessenger", "notify"]');
102
+ changed = true;
103
+ }
104
+ else if (JSON.stringify(doc.notify) === JSON.stringify(RELAY_NOTIFY)) {
105
+ notes.push("notify already points at relaymessenger");
106
+ }
107
+ else {
108
+ notes.push(`left existing notify unchanged (${JSON.stringify(doc.notify)}); ` +
109
+ "chain relaymessenger notify from your own script if you want both");
110
+ }
111
+ // Sanity: never write a file Codex cannot parse.
112
+ parseToml(toml);
113
+ return { toml, report: { changed, notes } };
114
+ }
115
+ /** Pure merge for tests: hooks.json PermissionRequest handler, append-only. */
116
+ export function mergeHooksJson(existing) {
117
+ const notes = [];
118
+ let changed = false;
119
+ let doc;
120
+ try {
121
+ doc = existing.trim().length > 0 ? JSON.parse(existing) : {};
122
+ }
123
+ catch {
124
+ throw new Error("~/.codex/hooks.json exists but is not valid JSON; not touching it");
125
+ }
126
+ const hooks = (doc.hooks ??= {});
127
+ const list = (hooks.PermissionRequest ??= []);
128
+ if (!Array.isArray(list)) {
129
+ throw new Error("hooks.PermissionRequest is not an array; not touching it");
130
+ }
131
+ const already = JSON.stringify(list).includes(RELAY_PERMISSION_HOOK_COMMAND);
132
+ if (already) {
133
+ notes.push("PermissionRequest hook already installed");
134
+ }
135
+ else {
136
+ list.push(RELAY_PERMISSION_HOOK_ENTRY);
137
+ notes.push("appended relaymessenger PermissionRequest hook");
138
+ changed = true;
139
+ }
140
+ return { json: `${JSON.stringify(doc, null, 2)}\n`, report: { changed, notes } };
141
+ }
142
+ function currentProjectRoot(cwd = process.cwd()) {
143
+ const git = spawnSync("git", ["rev-parse", "--show-toplevel"], {
144
+ cwd,
145
+ encoding: "utf8",
146
+ timeout: 5_000,
147
+ windowsHide: true,
148
+ });
149
+ return git.status === 0 && git.stdout.trim().length > 0 ? git.stdout.trim() : cwd;
150
+ }
151
+ export function installCodex(codexHome = join(homedir(), ".codex"), out = console.log, options = {}) {
152
+ mkdirSync(codexHome, { recursive: true });
153
+ const allowedRoot = (options.policy ?? new CodexNotifyPolicyStore()).allowProject(options.projectRoot ?? currentProjectRoot());
154
+ const configPath = join(codexHome, "config.toml");
155
+ const existingToml = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
156
+ const merged = mergeCodexConfigToml(existingToml);
157
+ if (merged.report.changed) {
158
+ // Keep a copy of the pristine original before our first modification.
159
+ if (existingToml.length > 0 && !existsSync(`${configPath}.bak`)) {
160
+ writePrivateText(`${configPath}.bak`, existingToml);
161
+ merged.report.notes.push(`original saved to ${configPath}.bak`);
162
+ }
163
+ writePrivateText(configPath, merged.toml);
164
+ }
165
+ out(`${configPath}:`);
166
+ for (const note of merged.report.notes)
167
+ out(` - ${note}`);
168
+ const hooksPath = join(codexHome, "hooks.json");
169
+ const existingHooks = existsSync(hooksPath) ? readFileSync(hooksPath, "utf8") : "";
170
+ const hooks = mergeHooksJson(existingHooks);
171
+ if (hooks.report.changed) {
172
+ if (existingHooks.length > 0 && !existsSync(`${hooksPath}.bak`)) {
173
+ writePrivateText(`${hooksPath}.bak`, existingHooks);
174
+ hooks.report.notes.push(`original saved to ${hooksPath}.bak`);
175
+ }
176
+ writePrivateText(hooksPath, hooks.json);
177
+ }
178
+ out(`${hooksPath}:`);
179
+ for (const note of hooks.report.notes)
180
+ out(` - ${note}`);
181
+ const finalConfig = parseToml(merged.toml);
182
+ const relayMcp = finalConfig.mcp_servers?.relay;
183
+ const mcpActive = relayMcp?.command === "relaymessenger" &&
184
+ JSON.stringify(relayMcp?.args) === JSON.stringify(["mcp"]);
185
+ const notifyActive = JSON.stringify(finalConfig.notify) === JSON.stringify(RELAY_NOTIFY);
186
+ const finalHooks = JSON.parse(hooks.json);
187
+ const permissionActive = JSON.stringify(finalHooks).includes(RELAY_PERMISSION_HOOK_COMMAND);
188
+ out("");
189
+ out(`Relay enabled only for this project root: ${allowedRoot}`);
190
+ if (mcpActive && notifyActive && permissionActive) {
191
+ out("Codex will send the final assistant message when a turn completes and route tool");
192
+ out("approvals to your phone only from that project.");
193
+ }
194
+ else {
195
+ out("Relay Codex setup is partial because existing non-Relay values were preserved:");
196
+ out(` - MCP relay_send_message: ${mcpActive ? "active" : "inactive (existing mcp_servers.relay kept)"}`);
197
+ out(` - final-message notify: ${notifyActive ? "active" : "inactive (existing notify kept)"}`);
198
+ out(` - phone permission hook: ${permissionActive ? "active" : "inactive"}`);
199
+ }
200
+ out("Codex gates untrusted hook handlers: the first run may ask you to trust the relaymessenger handler.");
201
+ out("Approvals require `relaymessenger pair` to have run on this machine.");
202
+ out("Run install-codex separately inside each additional project you explicitly opt in.");
203
+ }
204
+ function parseEnvValues(contents) {
205
+ const values = {};
206
+ for (const raw of contents.split(/\r?\n/)) {
207
+ const line = raw.trim();
208
+ if (!line || line.startsWith("#"))
209
+ continue;
210
+ const match = /^(?:export\s+)?([A-Z0-9_]+)=(.*)$/u.exec(line);
211
+ if (!match)
212
+ continue;
213
+ let value = match[2].trim();
214
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
215
+ value = value.slice(1, -1);
216
+ }
217
+ values[match[1]] = value;
218
+ }
219
+ return values;
220
+ }
221
+ export function mergeClaudeChannelEnv(existing, desired) {
222
+ const values = parseEnvValues(existing);
223
+ const required = {
224
+ RELAY_AGENT_TOKEN: desired.token,
225
+ RELAY_BASE_URL: desired.origin,
226
+ RELAY_OWNER_USER_ID: desired.ownerUserId,
227
+ };
228
+ for (const [key, value] of Object.entries(required)) {
229
+ if (/[\r\n]/u.test(value))
230
+ throw new Error(`Refusing newline in ${key}`);
231
+ if (values[key] && values[key] !== value) {
232
+ throw new Error(`Claude Relay channel already has a different ${key}. ` +
233
+ "Its .env was not changed; move it aside deliberately before installing this paired agent.");
234
+ }
235
+ }
236
+ const missing = Object.entries(required).filter(([key]) => values[key] !== required[key]);
237
+ if (missing.length === 0)
238
+ return existing;
239
+ const prefix = existing.length === 0 || existing.endsWith("\n") ? existing : `${existing}\n`;
240
+ return `${prefix}${missing.map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
241
+ }
242
+ function runExternalCommand(product, command, args, stage, runner) {
243
+ const result = runner(command, args);
244
+ if (result.status === 0)
245
+ return result;
246
+ const detail = `${result.stderr ?? ""}\n${result.stdout ?? ""}`.trim();
247
+ const suffix = detail.length > 0 ? `: ${detail}` : result.error ? `: ${result.error.message}` : "";
248
+ throw new Error(`${product} ${stage} failed${suffix}`);
249
+ }
250
+ export function platformCliCommand(command, platform = process.platform) {
251
+ if (platform !== "win32" || /\.(?:bat|cmd|com|exe)$/iu.test(command))
252
+ return command;
253
+ return `${command}.cmd`;
254
+ }
255
+ function resolveOpenClawConfigPath(command, runner) {
256
+ const result = runExternalCommand("OpenClaw", command, ["config", "file"], "config path lookup", runner);
257
+ const lines = (result.stdout ?? "").split(/\r?\n/u).map((line) => line.trim()).filter(Boolean);
258
+ const reported = lines.at(-1) ?? "";
259
+ if (!reported || /[\0\r\n]/u.test(reported)) {
260
+ throw new Error("OpenClaw config path lookup returned no usable path");
261
+ }
262
+ if (/^~[\\/]/u.test(reported))
263
+ return join(homedir(), reported.slice(2));
264
+ return isAbsolute(reported) ? reported : resolve(reported);
265
+ }
266
+ function makeTreePrivate(path) {
267
+ const stat = statSync(path);
268
+ if (!stat.isDirectory()) {
269
+ chmodSync(path, 0o600);
270
+ return;
271
+ }
272
+ chmodSync(path, 0o700);
273
+ for (const name of readdirSync(path))
274
+ makeTreePrivate(join(path, name));
275
+ }
276
+ function persistClaudeMarketplace(source, installRoot) {
277
+ const digest = createHash("sha256");
278
+ const visit = (path, relative = "") => {
279
+ for (const name of readdirSync(path).sort()) {
280
+ const absolute = join(path, name);
281
+ const child = relative ? `${relative}/${name}` : name;
282
+ if (statSync(absolute).isDirectory())
283
+ visit(absolute, child);
284
+ else {
285
+ digest.update(child).update("\0").update(readFileSync(absolute)).update("\0");
286
+ }
287
+ }
288
+ };
289
+ visit(source);
290
+ const destination = join(installRoot, digest.digest("hex").slice(0, 24), "marketplace");
291
+ if (existsSync(destination))
292
+ return destination;
293
+ mkdirSync(dirname(dirname(destination)), { recursive: true, mode: 0o700 });
294
+ const temporary = `${dirname(destination)}.tmp-${process.pid}-${Date.now()}`;
295
+ try {
296
+ cpSync(source, join(temporary, "marketplace"), { recursive: true, errorOnExist: true });
297
+ makeTreePrivate(temporary);
298
+ renameSync(temporary, dirname(destination));
299
+ }
300
+ catch (error) {
301
+ rmSync(temporary, { recursive: true, force: true });
302
+ if (error?.code !== "EEXIST" && error?.code !== "ENOTEMPTY")
303
+ throw error;
304
+ }
305
+ if (!existsSync(destination))
306
+ throw new Error("Could not persist the bundled Claude marketplace");
307
+ return destination;
308
+ }
309
+ function persistOpenClawArchive(source, installRoot) {
310
+ const digest = createHash("sha256").update(readFileSync(source)).digest("hex").slice(0, 24);
311
+ const destination = join(installRoot, digest, "relay-openclaw-plugin.tgz");
312
+ if (existsSync(destination))
313
+ return destination;
314
+ mkdirSync(dirname(destination), { recursive: true, mode: 0o700 });
315
+ const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`;
316
+ try {
317
+ cpSync(source, temporary, { errorOnExist: true });
318
+ chmodSync(temporary, 0o600);
319
+ renameSync(temporary, destination);
320
+ }
321
+ finally {
322
+ rmSync(temporary, { force: true });
323
+ }
324
+ return destination;
325
+ }
326
+ function objectAt(parent, key) {
327
+ const value = parent[key];
328
+ if (value === undefined) {
329
+ const created = {};
330
+ parent[key] = created;
331
+ return created;
332
+ }
333
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
334
+ throw new Error(`OpenClaw config ${key} must be an object; not touching it`);
335
+ }
336
+ return value;
337
+ }
338
+ export function mergeOpenClawConfig(existing, desired) {
339
+ let doc;
340
+ try {
341
+ doc = existing.trim() ? JSON.parse(existing) : {};
342
+ }
343
+ catch {
344
+ throw new Error("~/.openclaw/openclaw.json exists but is not valid JSON; not touching it");
345
+ }
346
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
347
+ throw new Error("~/.openclaw/openclaw.json root must be an object; not touching it");
348
+ }
349
+ const plugins = objectAt(doc, "plugins");
350
+ const allow = plugins.allow ?? [];
351
+ if (!Array.isArray(allow) || !allow.every((entry) => typeof entry === "string")) {
352
+ throw new Error("OpenClaw config plugins.allow must be a string array; not touching it");
353
+ }
354
+ if (!allow.includes("relay"))
355
+ allow.push("relay");
356
+ plugins.allow = allow;
357
+ const entries = objectAt(plugins, "entries");
358
+ objectAt(entries, "relay").enabled = true;
359
+ const channels = objectAt(doc, "channels");
360
+ const relay = objectAt(channels, "relay");
361
+ if (typeof relay.token === "string" && relay.token !== desired.token) {
362
+ throw new Error("OpenClaw Relay channel already has a different token; not touching it");
363
+ }
364
+ if (typeof relay.tokenFile === "string" && relay.tokenFile !== desired.tokenFile) {
365
+ throw new Error("OpenClaw Relay channel already has a different tokenFile; not touching it");
366
+ }
367
+ if (typeof relay.baseUrl === "string" && new URL(relay.baseUrl).origin !== new URL(desired.baseUrl).origin) {
368
+ throw new Error("OpenClaw Relay channel already has a different baseUrl; not touching it");
369
+ }
370
+ relay.enabled = true;
371
+ relay.tokenFile = desired.tokenFile;
372
+ relay.baseUrl = desired.baseUrl;
373
+ return `${JSON.stringify(doc, null, 2)}\n`;
374
+ }
375
+ /** Install the bundled marketplace plus owner-only channel credentials. */
376
+ export function installClaude(out = console.log, options = {}) {
377
+ const config = options.config ?? new ConfigStore();
378
+ const paired = config.load();
379
+ if (!paired?.agent_token)
380
+ throw new Error("Not paired. Run `relaymessenger pair` first.");
381
+ const ownerUserId = resolveOwnerUserId(paired);
382
+ const channelDir = options.channelDir ?? join(homedir(), ".claude", "channels", "relay");
383
+ const envPath = join(channelDir, ".env");
384
+ const existingEnv = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
385
+ const mergedEnv = mergeClaudeChannelEnv(existingEnv, {
386
+ token: paired.agent_token,
387
+ origin: paired.api_origin,
388
+ ownerUserId,
389
+ });
390
+ const bundledMarketplace = options.bundleDir ?? join(MODULE_DIR, "..", "claude-plugin", "marketplace");
391
+ const pluginDir = join(bundledMarketplace, "plugins", "relay");
392
+ for (const relative of [
393
+ ".claude-plugin/plugin.json",
394
+ "commands/configure.md",
395
+ "runtime/server.mjs",
396
+ "LICENSE",
397
+ "README.md",
398
+ ]) {
399
+ if (!existsSync(join(pluginDir, relative))) {
400
+ throw new Error(`Installed relaymessenger package is missing bundled Claude plugin file: ${relative}`);
401
+ }
402
+ }
403
+ if (!existsSync(join(bundledMarketplace, ".claude-plugin", "marketplace.json"))) {
404
+ throw new Error("Installed relaymessenger package is missing its bundled Claude marketplace manifest");
405
+ }
406
+ const command = options.claudeCommand ?? process.env.RELAYMESSENGER_CLAUDE_BIN?.trim() ?? "claude";
407
+ const runner = options.runClaude ?? ((binary, args) => {
408
+ const result = crossSpawn.sync(platformCliCommand(binary), args, {
409
+ encoding: "utf8",
410
+ stdio: "pipe",
411
+ windowsHide: true,
412
+ });
413
+ return {
414
+ status: result.status,
415
+ stdout: result.stdout ?? "",
416
+ stderr: result.stderr ?? "",
417
+ error: result.error,
418
+ };
419
+ });
420
+ // Validate what will actually be installed, then add the local marketplace.
421
+ // `marketplace add` and `plugin install` are idempotent in Claude Code and
422
+ // update a moved npm package path without needing GitHub access.
423
+ runExternalCommand("Claude Code", command, ["plugin", "validate", pluginDir, "--strict"], "plugin validation", runner);
424
+ runExternalCommand("Claude Code", command, ["plugin", "validate", bundledMarketplace, "--strict"], "marketplace validation", runner);
425
+ const marketplaceDir = persistClaudeMarketplace(bundledMarketplace, options.installRoot ?? join(runtimeHomeForConfig(paired, dirname(config.path)), "installed-plugins", "claude"));
426
+ runExternalCommand("Claude Code", command, ["plugin", "validate", join(marketplaceDir, "plugins", "relay"), "--strict"], "persisted plugin validation", runner);
427
+ runExternalCommand("Claude Code", command, ["plugin", "marketplace", "add", marketplaceDir], "local marketplace installation", runner);
428
+ runExternalCommand("Claude Code", command, ["plugin", "install", "relay@relaymessenger-bundled", "--scope", "user"], "plugin installation", runner);
429
+ if (mergedEnv !== existingEnv) {
430
+ if (existingEnv.length > 0 && !existsSync(`${envPath}.bak`)) {
431
+ writePrivateText(`${envPath}.bak`, existingEnv);
432
+ }
433
+ writePrivateText(envPath, mergedEnv);
434
+ }
435
+ else if (existsSync(envPath)) {
436
+ chmodSync(envPath, 0o600);
437
+ }
438
+ out(`Configured Claude Relay channel at ${envPath} (mode 600).`);
439
+ out(`API origin: ${paired.api_origin}; owner pin: ${ownerUserId}. Agent Token was not printed.`);
440
+ out(`Installed bundled Claude plugin relay@relaymessenger-bundled from ${marketplaceDir}.`);
441
+ out("");
442
+ out("Run:");
443
+ out(" claude --dangerously-load-development-channels plugin:relay@relaymessenger-bundled");
444
+ out("Setup guide: https://docs.relayapp.im/guides/coding-agents");
445
+ }
446
+ /** Install and configure the OpenClaw plugin from relaymessenger's bundled archive. */
447
+ export function installOpenClaw(out = console.log, options = {}) {
448
+ const config = options.config ?? new ConfigStore();
449
+ const paired = config.load();
450
+ if (!paired?.agent_token)
451
+ throw new Error("Not paired. Run `relaymessenger pair` first.");
452
+ if (/[\r\n]/u.test(paired.agent_token))
453
+ throw new Error("Refusing newline in Agent Token");
454
+ const openclawHome = options.openclawHome ?? join(homedir(), ".openclaw");
455
+ const command = options.openclawCommand ?? process.env.RELAYMESSENGER_OPENCLAW_BIN?.trim() ?? "openclaw";
456
+ const runner = options.runOpenClaw ?? ((binary, args) => {
457
+ const result = crossSpawn.sync(platformCliCommand(binary), args, {
458
+ encoding: "utf8",
459
+ stdio: "pipe",
460
+ windowsHide: true,
461
+ });
462
+ return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "", error: result.error };
463
+ });
464
+ // OpenClaw can relocate its config via environment/state settings. Ask its
465
+ // own CLI for the authoritative path instead of assuming ~/.openclaw.
466
+ const configPath = resolveOpenClawConfigPath(command, runner);
467
+ const tokenPath = join(openclawHome, "secrets", "relay-agent-token");
468
+ const existingToken = existsSync(tokenPath) ? readFileSync(tokenPath, "utf8").trim() : "";
469
+ if (existingToken && existingToken !== paired.agent_token) {
470
+ throw new Error("OpenClaw Relay token file contains a different paired identity; not touching it");
471
+ }
472
+ const existingConfig = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
473
+ const desired = {
474
+ token: paired.agent_token,
475
+ tokenFile: tokenPath,
476
+ baseUrl: paired.api_origin,
477
+ };
478
+ // Identity conflicts must fail before the official installer changes any
479
+ // external state. This first merge is validation-only.
480
+ mergeOpenClawConfig(existingConfig, desired);
481
+ const bundleDir = options.bundleDir ?? join(MODULE_DIR, "..", "openclaw-plugin");
482
+ const archives = readdirSync(bundleDir).filter((name) => name.endsWith(".tgz"));
483
+ if (archives.length !== 1) {
484
+ throw new Error(`Installed relaymessenger package must contain exactly one OpenClaw archive; found ${archives.length}`);
485
+ }
486
+ const archive = persistOpenClawArchive(join(bundleDir, archives[0]), options.installRoot ?? join(runtimeHomeForConfig(paired, dirname(config.path)), "installed-plugins", "openclaw"));
487
+ runExternalCommand("OpenClaw", command, ["plugins", "install", archive, "--force"], "plugin installation", runner);
488
+ // The official installer stamps root metadata and records install
489
+ // provenance/update/uninstall state. Re-read after it returns; a merge
490
+ // computed before that command would overwrite the newly authoritative
491
+ // state. Apply only Relay's fields through OpenClaw's config writer so the
492
+ // resulting write is validated and freshly stamped by OpenClaw itself.
493
+ const installedConfig = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
494
+ const mergedConfig = JSON.parse(mergeOpenClawConfig(installedConfig, desired));
495
+ const configUpdates = [
496
+ { path: "plugins.allow", value: mergedConfig.plugins.allow },
497
+ { path: "plugins.entries.relay.enabled", value: true },
498
+ { path: "channels.relay.enabled", value: true },
499
+ { path: "channels.relay.tokenFile", value: tokenPath },
500
+ { path: "channels.relay.baseUrl", value: paired.api_origin },
501
+ ];
502
+ writePrivateText(tokenPath, `${paired.agent_token}\n`);
503
+ runExternalCommand("OpenClaw", command, ["config", "set", "--batch-json", JSON.stringify(configUpdates)], "Relay config merge", runner);
504
+ if (existsSync(configPath))
505
+ chmodSync(configPath, 0o600);
506
+ out(`Installed bundled Relay plugin into OpenClaw from ${archive}.`);
507
+ out(`Configured ${configPath} and owner-private token file ${tokenPath}. Agent Token was not printed.`);
508
+ out("Restart the OpenClaw gateway to activate Relay.");
509
+ }
510
+ //# sourceMappingURL=install.js.map