helmpilot 0.4.2

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.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Shared module: openclaw plugin-sdk symlink creation logic.
3
+ *
4
+ * Used by both preload.cjs and postinstall-link-sdk.js to avoid duplication.
5
+ * Must be CJS format because preload.cjs requires synchronous require().
6
+ */
7
+ "use strict";
8
+
9
+ const path = require("node:path");
10
+ const fs = require("node:fs");
11
+ const { execSync } = require("node:child_process");
12
+
13
+ const CLI_NAMES = ["openclaw", "clawdbot", "moltbot"];
14
+
15
+ /**
16
+ * Find the global openclaw installation root.
17
+ * Tries: npm root -g, which/where <cli>, extensions directory inference.
18
+ */
19
+ function findOpenclawRoot(pluginRoot) {
20
+ // Strategy 1: npm root -g
21
+ try {
22
+ const globalRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 5000 }).trim();
23
+ for (const name of CLI_NAMES) {
24
+ const candidate = path.join(globalRoot, name);
25
+ if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
26
+ }
27
+ } catch {}
28
+
29
+ // Strategy 2: which/where <cli>
30
+ const whichCmd = process.platform === "win32" ? "where" : "which";
31
+ for (const name of CLI_NAMES) {
32
+ try {
33
+ const bin = execSync(`${whichCmd} ${name}`, {
34
+ encoding: "utf-8",
35
+ timeout: 5000,
36
+ stdio: ["pipe", "pipe", "pipe"],
37
+ }).trim().split("\n")[0];
38
+ if (!bin) continue;
39
+ const realBin = fs.realpathSync(bin);
40
+ const c1 = path.resolve(path.dirname(realBin), "..", "lib", "node_modules", name);
41
+ if (fs.existsSync(path.join(c1, "package.json"))) return c1;
42
+ const c2 = path.resolve(path.dirname(realBin), "..");
43
+ if (fs.existsSync(path.join(c2, "package.json")) && fs.existsSync(path.join(c2, "plugin-sdk"))) return c2;
44
+ } catch {}
45
+ }
46
+
47
+ // Strategy 3: infer from extensions directory structure
48
+ const extensionsDir = path.dirname(pluginRoot);
49
+ const dataDir = path.dirname(extensionsDir);
50
+ const dataDirName = path.basename(dataDir);
51
+ const cliName = dataDirName.replace(/^\./, "");
52
+ if (cliName) {
53
+ try {
54
+ const globalRoot = execSync("npm root -g", { encoding: "utf-8", timeout: 5000 }).trim();
55
+ const candidate = path.join(globalRoot, cliName);
56
+ if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
57
+ } catch {}
58
+ }
59
+
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Validate existing node_modules/openclaw is complete and usable.
65
+ */
66
+ function isLinkValid(linkTarget) {
67
+ try {
68
+ const stat = fs.lstatSync(linkTarget);
69
+ if (stat.isSymbolicLink()) {
70
+ return fs.existsSync(path.join(linkTarget, "dist", "plugin-sdk"))
71
+ || fs.existsSync(path.join(linkTarget, "plugin-sdk"));
72
+ }
73
+ // Real directory — check for core.js
74
+ return fs.existsSync(path.join(linkTarget, "dist", "plugin-sdk", "core.js"));
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Ensure the openclaw plugin-sdk symlink exists.
82
+ *
83
+ * @param {string} pluginRoot - Plugin root directory path
84
+ * @param {string} [tag="[link-sdk]"] - Log prefix
85
+ * @returns {boolean} true if symlink already exists or was successfully created
86
+ */
87
+ function ensurePluginSdkSymlink(pluginRoot, tag) {
88
+ tag = tag || "[link-sdk]";
89
+ try {
90
+ if (!pluginRoot.includes("extensions")) return true;
91
+
92
+ const linkTarget = path.join(pluginRoot, "node_modules", "openclaw");
93
+
94
+ if (fs.existsSync(linkTarget)) {
95
+ if (isLinkValid(linkTarget)) return true;
96
+ // Invalid/incomplete — remove and recreate
97
+ try {
98
+ fs.rmSync(linkTarget, { recursive: true, force: true });
99
+ console.log(`${tag} removed incomplete node_modules/openclaw`);
100
+ } catch {}
101
+ }
102
+
103
+ const openclawRoot = findOpenclawRoot(pluginRoot);
104
+ if (!openclawRoot) {
105
+ console.error(`${tag} WARNING: could not find openclaw global installation, symlink not created`);
106
+ return false;
107
+ }
108
+
109
+ fs.mkdirSync(path.join(pluginRoot, "node_modules"), { recursive: true });
110
+ fs.symlinkSync(openclawRoot, linkTarget, "junction");
111
+ console.log(`${tag} symlink created: node_modules/openclaw -> ${openclawRoot}`);
112
+ return true;
113
+ } catch (e) {
114
+ console.error(`${tag} WARNING: symlink check failed: ${e.message || e}`);
115
+ return false;
116
+ }
117
+ }
118
+
119
+ module.exports = {
120
+ CLI_NAMES,
121
+ findOpenclawRoot,
122
+ isLinkValid,
123
+ ensurePluginSdkSymlink,
124
+ };
package/setup-entry.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Helmpilot Channel — Setup-only plugin entry.
3
+ *
4
+ * Loaded by the CLI for `openclaw channels add --channel helmpilot`.
5
+ * This is a lightweight entry that only exposes meta + setup adapter.
6
+ * ALL code is inlined to avoid import chain issues in extensions directory.
7
+ */
8
+
9
+ import { defineSetupPluginEntry } from 'openclaw/plugin-sdk/core';
10
+ import type { ChannelPlugin } from 'openclaw/plugin-sdk/core';
11
+
12
+ const HELMPILOT_CHANNEL_ID = 'helmpilot';
13
+ const HELMPILOT_CONFIG_SECTION = 'channels.helmpilot';
14
+ const DEFAULT_ACCOUNT_ID = 'default';
15
+
16
+ interface HelmpilotAccountConfig {
17
+ enabled?: boolean;
18
+ relayUrl?: string;
19
+ channelId?: string;
20
+ channelKey?: string;
21
+ }
22
+
23
+ interface ResolvedHelmpilotAccount {
24
+ accountId: string;
25
+ config: HelmpilotAccountConfig;
26
+ }
27
+
28
+ interface RawHelmpilotConfig {
29
+ enabled?: boolean;
30
+ relayUrl?: string;
31
+ channelId?: string;
32
+ channelKey?: string | { source: string; id: string; provider?: string };
33
+ accounts?: Record<string, {
34
+ enabled?: boolean;
35
+ relayUrl?: string;
36
+ channelId?: string;
37
+ channelKey?: string | { source: string; id: string; provider?: string };
38
+ }>;
39
+ }
40
+
41
+ function extractHelmpilotConfig(cfg: unknown): RawHelmpilotConfig {
42
+ const section = (cfg as Record<string, unknown>)?.channels as Record<string, unknown> | undefined;
43
+ return (section?.helmpilot as RawHelmpilotConfig) ?? {};
44
+ }
45
+
46
+ // ── Inlined Setup Adapter ──
47
+
48
+ function createInlinedSetupAdapter() {
49
+ return {
50
+ applyAccountConfig(params: {
51
+ cfg: Record<string, unknown>;
52
+ accountId: string;
53
+ input: { url?: string; token?: string; code?: string };
54
+ }): Record<string, unknown> {
55
+ const cfg = structuredClone(params.cfg);
56
+ const channels = (cfg.channels ?? {}) as Record<string, unknown>;
57
+ const hp = (channels.helmpilot ?? {}) as Record<string, unknown>;
58
+
59
+ if (params.input.url) {
60
+ hp.relayUrl = params.input.url;
61
+ }
62
+ if (params.input.code) {
63
+ hp.channelId = params.input.code;
64
+ }
65
+ if (params.input.token) {
66
+ hp.channelKey = params.input.token;
67
+ }
68
+ hp.enabled = true;
69
+
70
+ channels.helmpilot = hp;
71
+ cfg.channels = channels;
72
+ return cfg;
73
+ },
74
+
75
+ validateInput(params: {
76
+ cfg: Record<string, unknown>;
77
+ accountId: string;
78
+ input: { url?: string; token?: string; code?: string };
79
+ }): string | null {
80
+ const { url, token, code } = params.input;
81
+
82
+ if (url) {
83
+ try {
84
+ const parsed = new URL(url);
85
+ if (!['ws:', 'wss:', 'http:', 'https:'].includes(parsed.protocol)) {
86
+ return 'Relay URL must use ws://, wss://, http://, or https:// protocol';
87
+ }
88
+ } catch {
89
+ return 'Invalid Relay URL format';
90
+ }
91
+ }
92
+
93
+ if (code && !code.startsWith('ch_')) {
94
+ return 'Channel ID must start with "ch_"';
95
+ }
96
+
97
+ if (token && !token.startsWith('ck_')) {
98
+ return 'Channel key must start with "ck_"';
99
+ }
100
+
101
+ // Check for required fields (existing config values fill gaps)
102
+ const channels = (params.cfg?.channels ?? {}) as Record<string, unknown>;
103
+ const existing = (channels?.helmpilot ?? {}) as Record<string, unknown>;
104
+ const hasUrl = url || existing.relayUrl;
105
+ const hasCode = code || existing.channelId;
106
+ const hasToken = token || typeof existing.channelKey === 'string';
107
+
108
+ if (!hasUrl && !hasCode && !hasToken) {
109
+ return 'Helmpilot requires --url (Relay URL), --code (channel ID), and --token (channel key).\n'
110
+ + 'Example: openclaw channels add --channel helmpilot --url ws://localhost:4800 --code ch_xxx --token ck_xxx';
111
+ }
112
+
113
+ return null;
114
+ },
115
+ };
116
+ }
117
+
118
+ export const helmpilotSetupPlugin: ChannelPlugin<ResolvedHelmpilotAccount> = {
119
+ id: HELMPILOT_CHANNEL_ID,
120
+
121
+ meta: {
122
+ id: HELMPILOT_CHANNEL_ID,
123
+ label: 'Helmpilot Desktop',
124
+ selectionLabel: 'Helmpilot Desktop Client',
125
+ docsPath: '/docs/channels/helmpilot',
126
+ blurb: 'Connect to the Helmpilot desktop AI assistant client',
127
+ order: 100,
128
+ },
129
+
130
+ setup: createInlinedSetupAdapter(),
131
+
132
+ reload: { configPrefixes: [HELMPILOT_CONFIG_SECTION] },
133
+
134
+ config: {
135
+ listAccountIds(cfg) {
136
+ const hp = extractHelmpilotConfig(cfg);
137
+ if (hp.accounts && Object.keys(hp.accounts).length > 0) {
138
+ const ids = Object.keys(hp.accounts);
139
+ if (hp.relayUrl && hp.channelKey && !ids.includes(DEFAULT_ACCOUNT_ID)) {
140
+ ids.unshift(DEFAULT_ACCOUNT_ID);
141
+ }
142
+ return ids;
143
+ }
144
+ return [DEFAULT_ACCOUNT_ID];
145
+ },
146
+
147
+ resolveAccount(cfg, accountId) {
148
+ const id = accountId ?? DEFAULT_ACCOUNT_ID;
149
+ const hp = extractHelmpilotConfig(cfg);
150
+
151
+ const acct = hp.accounts?.[id];
152
+ if (acct) {
153
+ return {
154
+ accountId: id,
155
+ config: {
156
+ enabled: acct.enabled !== false && hp.enabled !== false,
157
+ relayUrl: typeof acct.relayUrl === 'string' ? acct.relayUrl : undefined,
158
+ channelId: typeof acct.channelId === 'string' ? acct.channelId : undefined,
159
+ channelKey: typeof acct.channelKey === 'string' ? acct.channelKey : undefined,
160
+ },
161
+ };
162
+ }
163
+
164
+ return {
165
+ accountId: id,
166
+ config: {
167
+ enabled: hp.enabled !== false,
168
+ relayUrl: typeof hp.relayUrl === 'string' ? hp.relayUrl : undefined,
169
+ channelId: typeof hp.channelId === 'string' ? hp.channelId : undefined,
170
+ channelKey: typeof hp.channelKey === 'string' ? hp.channelKey : undefined,
171
+ },
172
+ };
173
+ },
174
+
175
+ defaultAccountId() {
176
+ return DEFAULT_ACCOUNT_ID;
177
+ },
178
+
179
+ isEnabled(account) {
180
+ return account.config.enabled !== false;
181
+ },
182
+
183
+ isConfigured(account) {
184
+ const { relayUrl, channelId, channelKey } = account.config;
185
+ if (relayUrl || channelId || channelKey) {
186
+ return Boolean(relayUrl && channelId && channelKey);
187
+ }
188
+ return true;
189
+ },
190
+ },
191
+ };
192
+
193
+ export default defineSetupPluginEntry(helmpilotSetupPlugin);
package/tools.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Helmpilot Channel — Tool definitions
3
+ *
4
+ * Defines hp_send_file, hp_send_msg, hp_ask_user tools and
5
+ * hp_read_file, hp_write_file, hp_list_dir client-side file tools,
6
+ * plus the process-level pending request map for blocking tools ↔ helmpilot.respond.
7
+ *
8
+ * Schemas are plain JSON Schema objects (no TypeBox dependency) to avoid
9
+ * pnpm symlink issues when the plugin is installed to ~/.openclaw/extensions/.
10
+ */
11
+
12
+ // ── Tool names (hp_ prefix per channel convention) ──
13
+
14
+ export const HP_SEND_FILE = 'hp_send_file';
15
+ export const HP_SEND_MSG = 'hp_send_msg';
16
+ export const HP_ASK_USER = 'hp_ask_user';
17
+ export const HP_READ_FILE = 'hp_read_file';
18
+ export const HP_WRITE_FILE = 'hp_write_file';
19
+ export const HP_LIST_DIR = 'hp_list_dir';
20
+
21
+ // ── Schemas (plain JSON Schema) ──
22
+
23
+ export const SendFileParamsSchema = {
24
+ type: 'object' as const,
25
+ required: ['filename', 'content'],
26
+ properties: {
27
+ filename: { type: 'string' as const, description: 'Filename (e.g. AGENTS.md)' },
28
+ content: { type: 'string' as const, description: 'File content' },
29
+ },
30
+ };
31
+
32
+ const QuestionOptionSchema = {
33
+ type: 'object' as const,
34
+ required: ['label'],
35
+ properties: {
36
+ label: { type: 'string' as const },
37
+ description: { type: 'string' as const },
38
+ recommended: { type: 'boolean' as const },
39
+ },
40
+ };
41
+
42
+ const QuestionSchema = {
43
+ type: 'object' as const,
44
+ required: ['header', 'question'],
45
+ properties: {
46
+ header: { type: 'string' as const, maxLength: 50 },
47
+ question: { type: 'string' as const, maxLength: 500 },
48
+ options: { type: 'array' as const, items: QuestionOptionSchema },
49
+ multiSelect: { type: 'boolean' as const },
50
+ allowFreeformInput: { type: 'boolean' as const },
51
+ },
52
+ };
53
+
54
+ export const AskUserParamsSchema = {
55
+ type: 'object' as const,
56
+ required: ['questions'],
57
+ properties: {
58
+ questions: { type: 'array' as const, items: QuestionSchema, minItems: 1, maxItems: 10 },
59
+ },
60
+ };
61
+
62
+ export const SendMsgParamsSchema = {
63
+ type: 'object' as const,
64
+ required: ['message'],
65
+ properties: {
66
+ message: { type: 'string' as const, description: 'Message content (supports Markdown)' },
67
+ type: {
68
+ type: 'string' as const,
69
+ enum: ['info', 'success', 'warning', 'error'],
70
+ description: 'Message type that determines display style. Defaults to "info".',
71
+ },
72
+ title: { type: 'string' as const, description: 'Optional notification title' },
73
+ },
74
+ };
75
+
76
+ // ── File tool schemas ──
77
+
78
+ export const ReadFileParamsSchema = {
79
+ type: 'object' as const,
80
+ required: ['path'],
81
+ properties: {
82
+ path: { type: 'string' as const, description: 'Relative path to a file within the user workspace (e.g. "src/main.ts")' },
83
+ },
84
+ };
85
+
86
+ export const WriteFileParamsSchema = {
87
+ type: 'object' as const,
88
+ required: ['path', 'content'],
89
+ properties: {
90
+ path: { type: 'string' as const, description: 'Relative path within the user workspace where the file will be written' },
91
+ content: { type: 'string' as const, description: 'File content to write' },
92
+ },
93
+ };
94
+
95
+ export const ListDirParamsSchema = {
96
+ type: 'object' as const,
97
+ required: ['path'],
98
+ properties: {
99
+ path: { type: 'string' as const, description: 'Relative path to a directory within the user workspace (e.g. "src" or "")' },
100
+ },
101
+ };
102
+
103
+ // ── Process-level shared state (toolCallId → pending request) ──
104
+ // Uses Symbol.for() key for namespace isolation, following OpenClaw's own
105
+ // globalThis[Symbol.for(...)] pattern for cross-module-graph state sharing.
106
+
107
+ export interface PendingRequest {
108
+ resolve: (answer: string) => void;
109
+ questions: unknown;
110
+ toolCallId: string;
111
+ }
112
+
113
+ const PENDING_MAP_KEY = Symbol.for('helmpilot.pendingMap');
114
+
115
+ export function getPendingMap(): Map<string, PendingRequest> {
116
+ const g = globalThis as Record<symbol, unknown>;
117
+ if (!g[PENDING_MAP_KEY]) {
118
+ g[PENDING_MAP_KEY] = new Map<string, PendingRequest>();
119
+ }
120
+ return g[PENDING_MAP_KEY] as Map<string, PendingRequest>;
121
+ }
package/types.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * helmpilot-channel — Shared types
3
+ *
4
+ * Defines the question/answer schema used across:
5
+ * - Plugin tool registration (plain JSON Schema)
6
+ * - Gateway RPC callback (helmpilot.respond)
7
+ * - Client-side rendering (QuestionCard)
8
+ *
9
+ * No TypeBox dependency — plain JSON Schema + manual TS types to avoid
10
+ * pnpm symlink resolution issues in ~/.openclaw/extensions/.
11
+ */
12
+
13
+ // ── Question Schema (plain JSON Schema) ──
14
+
15
+ export const QuestionOptionSchema = {
16
+ type: 'object' as const,
17
+ required: ['label'],
18
+ properties: {
19
+ label: { type: 'string' as const, description: 'Display label for this option' },
20
+ description: { type: 'string' as const, description: 'Optional secondary text shown with the option' },
21
+ recommended: { type: 'boolean' as const, description: 'Mark this option as the recommended default' },
22
+ },
23
+ };
24
+
25
+ export const QuestionSchema = {
26
+ type: 'object' as const,
27
+ required: ['header', 'question'],
28
+ properties: {
29
+ header: { type: 'string' as const, description: 'Short unique identifier for the question (used as answer key)', maxLength: 50 },
30
+ question: { type: 'string' as const, description: 'The question text to display to the user', maxLength: 500 },
31
+ options: { type: 'array' as const, items: QuestionOptionSchema, description: 'Predefined selectable answers. Omit for free-text only.' },
32
+ multiSelect: { type: 'boolean' as const, description: 'Allow selecting multiple options (default: false)' },
33
+ allowFreeformInput: { type: 'boolean' as const, description: 'Allow freeform text in addition to option selection (default: true)' },
34
+ },
35
+ };
36
+
37
+ export const AskUserParamsSchema = {
38
+ type: 'object' as const,
39
+ required: ['questions'],
40
+ properties: {
41
+ questions: { type: 'array' as const, items: QuestionSchema, minItems: 1, maxItems: 10, description: 'One or more questions to present to the user' },
42
+ },
43
+ };
44
+
45
+ // ── Derived TypeScript types ──
46
+
47
+ export interface QuestionOption {
48
+ label: string;
49
+ description?: string;
50
+ recommended?: boolean;
51
+ }
52
+
53
+ export interface Question {
54
+ header: string;
55
+ question: string;
56
+ options?: QuestionOption[];
57
+ multiSelect?: boolean;
58
+ allowFreeformInput?: boolean;
59
+ }
60
+
61
+ export interface AskUserParams {
62
+ questions: Question[];
63
+ }
64
+
65
+ // ── HTTP callback types ──
66
+
67
+ export interface AskUserRespondPayload {
68
+ /** Formatted answer string (human-readable) */
69
+ answers: string;
70
+ }
71
+
72
+ export interface AskUserRespondResult {
73
+ status: 'ok' | 'not_found' | 'expired';
74
+ }
75
+
76
+ // ── Tool result format ──
77
+
78
+ // ── Tool names & constants ──
79
+
80
+ export const ASK_USER_TOOL_NAME = 'hp_ask_user';
81
+ export const SEND_MSG_TOOL_NAME = 'hp_send_msg';
82
+ export const SEND_FILE_TOOL_NAME = 'hp_send_file';
83
+ export const ASK_USER_RPC_METHOD = 'helmpilot.respond';
84
+
85
+ // ── hp_send_msg types ──
86
+
87
+ export type SendMsgType = 'info' | 'success' | 'warning' | 'error';
88
+
89
+ export interface SendMsgParams {
90
+ message: string;
91
+ type?: SendMsgType;
92
+ title?: string;
93
+ }