@invago/mixin 1.0.8 → 1.0.9
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/README.md +67 -1
- package/README.zh-CN.md +67 -1
- package/package.json +1 -1
- package/src/channel.ts +101 -35
- package/src/config-schema.ts +20 -1
- package/src/config.ts +98 -10
- package/src/inbound-handler.ts +108 -47
- package/src/outbound-plan.ts +197 -0
- package/src/reply-format.ts +37 -23
- package/src/send-service.ts +11 -0
- package/src/status.ts +100 -0
- package/tools/mixin-plugin-onboard/README.md +98 -0
- package/tools/mixin-plugin-onboard/bin/mixin-plugin-onboard.mjs +3 -0
- package/tools/mixin-plugin-onboard/src/commands/doctor.ts +28 -0
- package/tools/mixin-plugin-onboard/src/commands/info.ts +23 -0
- package/tools/mixin-plugin-onboard/src/commands/install.ts +5 -0
- package/tools/mixin-plugin-onboard/src/commands/update.ts +5 -0
- package/tools/mixin-plugin-onboard/src/index.ts +49 -0
- package/tools/mixin-plugin-onboard/src/utils.ts +189 -0
package/src/reply-format.ts
CHANGED
|
@@ -13,6 +13,10 @@ export type MixinReplyPlan =
|
|
|
13
13
|
| { kind: "buttons"; intro?: string; buttons: MixinButton[] }
|
|
14
14
|
| { kind: "card"; card: MixinCard };
|
|
15
15
|
|
|
16
|
+
export type MixinReplyPlanResolution =
|
|
17
|
+
| { matchedTemplate: false; plan: MixinReplyPlan | null }
|
|
18
|
+
| { matchedTemplate: true; plan: MixinReplyPlan | null; error?: string };
|
|
19
|
+
|
|
16
20
|
const MAX_BUTTONS = 6;
|
|
17
21
|
const MAX_BUTTON_LABEL = 36;
|
|
18
22
|
const MAX_CARD_TITLE = 36;
|
|
@@ -166,40 +170,40 @@ function parseAudioTemplate(body: string): MixinReplyPlan | null {
|
|
|
166
170
|
};
|
|
167
171
|
}
|
|
168
172
|
|
|
169
|
-
function parseExplicitTemplate(text: string):
|
|
173
|
+
function parseExplicitTemplate(text: string): MixinReplyPlanResolution {
|
|
170
174
|
const match = text.match(TEMPLATE_REGEX);
|
|
171
175
|
if (!match) {
|
|
172
|
-
return null;
|
|
176
|
+
return { matchedTemplate: false, plan: null };
|
|
173
177
|
}
|
|
174
178
|
|
|
175
179
|
const templateType = (match[1] ?? "").toLowerCase();
|
|
176
180
|
const body = match[2] ?? "";
|
|
177
181
|
|
|
178
182
|
if (templateType === "text") {
|
|
179
|
-
return parseTextTemplate(body);
|
|
183
|
+
return { matchedTemplate: true, plan: parseTextTemplate(body), error: "Invalid mixin-text template body" };
|
|
180
184
|
}
|
|
181
185
|
|
|
182
186
|
if (templateType === "post") {
|
|
183
|
-
return parsePostTemplate(body);
|
|
187
|
+
return { matchedTemplate: true, plan: parsePostTemplate(body), error: "Invalid mixin-post template body" };
|
|
184
188
|
}
|
|
185
189
|
|
|
186
190
|
if (templateType === "buttons") {
|
|
187
|
-
return parseButtonsTemplate(body);
|
|
191
|
+
return { matchedTemplate: true, plan: parseButtonsTemplate(body), error: "Invalid mixin-buttons template JSON" };
|
|
188
192
|
}
|
|
189
193
|
|
|
190
194
|
if (templateType === "card") {
|
|
191
|
-
return parseCardTemplate(body);
|
|
195
|
+
return { matchedTemplate: true, plan: parseCardTemplate(body), error: "Invalid mixin-card template JSON" };
|
|
192
196
|
}
|
|
193
197
|
|
|
194
198
|
if (templateType === "file") {
|
|
195
|
-
return parseFileTemplate(body);
|
|
199
|
+
return { matchedTemplate: true, plan: parseFileTemplate(body), error: "Invalid mixin-file template JSON" };
|
|
196
200
|
}
|
|
197
201
|
|
|
198
202
|
if (templateType === "audio") {
|
|
199
|
-
return parseAudioTemplate(body);
|
|
203
|
+
return { matchedTemplate: true, plan: parseAudioTemplate(body), error: "Invalid mixin-audio template JSON" };
|
|
200
204
|
}
|
|
201
205
|
|
|
202
|
-
return null;
|
|
206
|
+
return { matchedTemplate: true, plan: null, error: "Unknown Mixin template type" };
|
|
203
207
|
}
|
|
204
208
|
|
|
205
209
|
function toPlainText(text: string): string {
|
|
@@ -288,21 +292,21 @@ function isLongStructuredText(text: string): boolean {
|
|
|
288
292
|
);
|
|
289
293
|
}
|
|
290
294
|
|
|
291
|
-
export function
|
|
295
|
+
export function resolveMixinReplyPlan(text: string): MixinReplyPlanResolution {
|
|
292
296
|
const normalized = normalizeWhitespace(text);
|
|
293
297
|
if (!normalized) {
|
|
294
|
-
return null;
|
|
298
|
+
return { matchedTemplate: false, plan: null };
|
|
295
299
|
}
|
|
296
300
|
|
|
297
301
|
const explicit = parseExplicitTemplate(normalized);
|
|
298
|
-
if (explicit) {
|
|
302
|
+
if (explicit.matchedTemplate) {
|
|
299
303
|
return explicit;
|
|
300
304
|
}
|
|
301
305
|
|
|
302
306
|
const links = extractLinks(normalized);
|
|
303
307
|
|
|
304
308
|
if (isLongStructuredText(normalized)) {
|
|
305
|
-
return { kind: "post", text: normalized };
|
|
309
|
+
return { matchedTemplate: false, plan: { kind: "post", text: normalized } };
|
|
306
310
|
}
|
|
307
311
|
|
|
308
312
|
if (links.length >= 2 && links.length <= MAX_BUTTONS) {
|
|
@@ -310,9 +314,12 @@ export function buildMixinReplyPlan(text: string): MixinReplyPlan | null {
|
|
|
310
314
|
normalized.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "").replace(/https?:\/\/[^\s)]+/g, ""),
|
|
311
315
|
);
|
|
312
316
|
return {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
317
|
+
matchedTemplate: false,
|
|
318
|
+
plan: {
|
|
319
|
+
kind: "buttons",
|
|
320
|
+
intro: intro || undefined,
|
|
321
|
+
buttons: buildButtons(links),
|
|
322
|
+
},
|
|
316
323
|
};
|
|
317
324
|
}
|
|
318
325
|
|
|
@@ -320,15 +327,22 @@ export function buildMixinReplyPlan(text: string): MixinReplyPlan | null {
|
|
|
320
327
|
const title = detectTitle(normalized, links[0].label);
|
|
321
328
|
const description = detectCardDescription(normalized, title) || truncate(links[0].url, MAX_CARD_DESCRIPTION);
|
|
322
329
|
return {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
330
|
+
matchedTemplate: false,
|
|
331
|
+
plan: {
|
|
332
|
+
kind: "card",
|
|
333
|
+
card: {
|
|
334
|
+
title,
|
|
335
|
+
description,
|
|
336
|
+
action: links[0].url,
|
|
337
|
+
shareable: true,
|
|
338
|
+
},
|
|
329
339
|
},
|
|
330
340
|
};
|
|
331
341
|
}
|
|
332
342
|
|
|
333
|
-
return { kind: "text", text: toPlainText(normalized) };
|
|
343
|
+
return { matchedTemplate: false, plan: { kind: "text", text: toPlainText(normalized) } };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function buildMixinReplyPlan(text: string): MixinReplyPlan | null {
|
|
347
|
+
return resolveMixinReplyPlan(text).plan;
|
|
334
348
|
}
|
package/src/send-service.ts
CHANGED
|
@@ -230,6 +230,17 @@ function resolveOutboxPaths(): {
|
|
|
230
230
|
};
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
export function getOutboxPathsSnapshot(): {
|
|
234
|
+
outboxDir: string;
|
|
235
|
+
outboxFile: string;
|
|
236
|
+
} {
|
|
237
|
+
const { outboxDir, outboxFile } = resolveOutboxPaths();
|
|
238
|
+
return {
|
|
239
|
+
outboxDir,
|
|
240
|
+
outboxFile,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
233
244
|
function normalizeErrorMessage(message: string): string {
|
|
234
245
|
if (message.length <= MAX_ERROR_LENGTH) {
|
|
235
246
|
return message;
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
3
|
+
import { getAccountConfig, resolveDefaultAccountId } from "./config.js";
|
|
4
|
+
import { getOutboxPathsSnapshot, type OutboxStatus } from "./send-service.js";
|
|
5
|
+
|
|
6
|
+
type RuntimeLifecycleSnapshot = {
|
|
7
|
+
running?: boolean | null;
|
|
8
|
+
lastStartAt?: number | null;
|
|
9
|
+
lastStopAt?: number | null;
|
|
10
|
+
lastError?: string | null;
|
|
11
|
+
lastInboundAt?: number | null;
|
|
12
|
+
lastOutboundAt?: number | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type MixinChannelStatusSnapshot = {
|
|
16
|
+
configured?: boolean | null;
|
|
17
|
+
running?: boolean | null;
|
|
18
|
+
lastStartAt?: number | null;
|
|
19
|
+
lastStopAt?: number | null;
|
|
20
|
+
lastError?: string | null;
|
|
21
|
+
defaultAccountId?: string | null;
|
|
22
|
+
outboxDir?: string | null;
|
|
23
|
+
outboxFile?: string | null;
|
|
24
|
+
outboxPending?: number | null;
|
|
25
|
+
mediaMaxMb?: number | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type MixinStatusAccount = {
|
|
29
|
+
accountId: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
enabled?: boolean;
|
|
32
|
+
configured?: boolean;
|
|
33
|
+
config: {
|
|
34
|
+
requireMentionInGroup?: boolean;
|
|
35
|
+
mediaBypassMentionInGroup?: boolean;
|
|
36
|
+
mediaMaxMb?: number;
|
|
37
|
+
audioAutoDetectDuration?: boolean;
|
|
38
|
+
audioSendAsVoiceByDefault?: boolean;
|
|
39
|
+
audioRequireFfprobe?: boolean;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function resolveMixinStatusSnapshot(
|
|
44
|
+
cfg: OpenClawConfig,
|
|
45
|
+
accountId?: string,
|
|
46
|
+
outboxStatus?: OutboxStatus | null,
|
|
47
|
+
): {
|
|
48
|
+
defaultAccountId: string;
|
|
49
|
+
outboxDir: string;
|
|
50
|
+
outboxFile: string;
|
|
51
|
+
outboxPending: number;
|
|
52
|
+
mediaMaxMb: number | null;
|
|
53
|
+
} {
|
|
54
|
+
const defaultAccountId = resolveDefaultAccountId(cfg);
|
|
55
|
+
const resolvedAccountId = accountId ?? defaultAccountId;
|
|
56
|
+
const accountConfig = getAccountConfig(cfg, resolvedAccountId);
|
|
57
|
+
const { outboxDir, outboxFile } = getOutboxPathsSnapshot();
|
|
58
|
+
return {
|
|
59
|
+
defaultAccountId,
|
|
60
|
+
outboxDir,
|
|
61
|
+
outboxFile,
|
|
62
|
+
outboxPending: outboxStatus?.totalPending ?? 0,
|
|
63
|
+
mediaMaxMb: accountConfig.mediaMaxMb ?? null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildMixinChannelSummary(params: {
|
|
68
|
+
snapshot: MixinChannelStatusSnapshot;
|
|
69
|
+
}) {
|
|
70
|
+
const { snapshot } = params;
|
|
71
|
+
return {
|
|
72
|
+
...buildBaseChannelStatusSummary(snapshot),
|
|
73
|
+
defaultAccountId: snapshot.defaultAccountId ?? null,
|
|
74
|
+
outboxDir: snapshot.outboxDir ?? null,
|
|
75
|
+
outboxFile: snapshot.outboxFile ?? null,
|
|
76
|
+
outboxPending: snapshot.outboxPending ?? 0,
|
|
77
|
+
mediaMaxMb: snapshot.mediaMaxMb ?? null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildMixinAccountSnapshot(params: {
|
|
82
|
+
account: MixinStatusAccount;
|
|
83
|
+
runtime?: RuntimeLifecycleSnapshot | null;
|
|
84
|
+
probe?: unknown;
|
|
85
|
+
defaultAccountId?: string | null;
|
|
86
|
+
outboxPending?: number | null;
|
|
87
|
+
}) {
|
|
88
|
+
const { account, runtime, probe, defaultAccountId, outboxPending } = params;
|
|
89
|
+
return {
|
|
90
|
+
...buildBaseAccountStatusSnapshot({ account, runtime, probe }),
|
|
91
|
+
defaultAccountId: defaultAccountId ?? null,
|
|
92
|
+
outboxPending: outboxPending ?? 0,
|
|
93
|
+
requireMentionInGroup: account.config.requireMentionInGroup ?? true,
|
|
94
|
+
mediaBypassMentionInGroup: account.config.mediaBypassMentionInGroup ?? true,
|
|
95
|
+
mediaMaxMb: account.config.mediaMaxMb ?? null,
|
|
96
|
+
audioAutoDetectDuration: account.config.audioAutoDetectDuration ?? true,
|
|
97
|
+
audioSendAsVoiceByDefault: account.config.audioSendAsVoiceByDefault ?? true,
|
|
98
|
+
audioRequireFfprobe: account.config.audioRequireFfprobe ?? false,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Mixin Plugin Onboarding CLI
|
|
2
|
+
|
|
3
|
+
This CLI is bundled inside [`@invago/mixin`](https://www.npmjs.com/package/@invago/mixin). It helps inspect local OpenClaw installation state, verify key paths, and automate plugin install or update commands.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
### `info`
|
|
8
|
+
|
|
9
|
+
Prints the current local OpenClaw and Mixin plugin context:
|
|
10
|
+
|
|
11
|
+
- OpenClaw home, state, and extensions directories
|
|
12
|
+
- detected `openclaw.json` path
|
|
13
|
+
- detected Mixin plugin directories
|
|
14
|
+
- whether the plugin looks enabled in config
|
|
15
|
+
- current outbox path
|
|
16
|
+
- whether `ffprobe` is available
|
|
17
|
+
|
|
18
|
+
Run:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx -y @invago/mixin info
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### `doctor`
|
|
25
|
+
|
|
26
|
+
Runs a basic local diagnosis and returns a non-zero exit code when required checks fail.
|
|
27
|
+
|
|
28
|
+
Current checks:
|
|
29
|
+
|
|
30
|
+
- config file found
|
|
31
|
+
- `channels.mixin` present
|
|
32
|
+
- plugin enabled in config
|
|
33
|
+
- plugin installed in extensions
|
|
34
|
+
- outbox directory writable
|
|
35
|
+
- `ffprobe` available
|
|
36
|
+
|
|
37
|
+
It also reports leftover `.openclaw-install-stage-*` directories if any are detected.
|
|
38
|
+
|
|
39
|
+
Run:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx -y @invago/mixin doctor
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### `install`
|
|
46
|
+
|
|
47
|
+
Runs:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
openclaw plugins install @invago/mixin
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
You can also pass a custom npm spec:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx -y @invago/mixin install @invago/mixin@latest
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `update`
|
|
60
|
+
|
|
61
|
+
Runs:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
openclaw plugins install @invago/mixin@latest
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Run:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npx -y @invago/mixin update
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Local Development
|
|
74
|
+
|
|
75
|
+
From this repository:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
node --import jiti/register.js tools/mixin-plugin-onboard/src/index.ts info
|
|
79
|
+
node --import jiti/register.js tools/mixin-plugin-onboard/src/index.ts doctor
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or from the tool directory:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
cd tools/mixin-plugin-onboard
|
|
86
|
+
npm run info
|
|
87
|
+
npm run doctor
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Publish
|
|
91
|
+
|
|
92
|
+
This CLI is published together with the main `@invago/mixin` package from the repository root.
|
|
93
|
+
|
|
94
|
+
## Notes
|
|
95
|
+
|
|
96
|
+
- This CLI is intentionally read-mostly right now.
|
|
97
|
+
- `install` and `update` delegate to the local `openclaw` command.
|
|
98
|
+
- `doctor` currently treats missing `ffprobe` as a failed check because native outbound audio-as-voice depends on it.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { buildContext, checkWritableDir, findMixinPluginDirs, isPluginEnabled, readMixinConfig, runFfprobeCheck } from "../utils.ts";
|
|
2
|
+
|
|
3
|
+
export async function runDoctor(): Promise<number> {
|
|
4
|
+
const ctx = await buildContext();
|
|
5
|
+
const pluginDirs = await findMixinPluginDirs(ctx.extensionsDir);
|
|
6
|
+
const mixinConfig = readMixinConfig(ctx.config);
|
|
7
|
+
const checks = [
|
|
8
|
+
{ label: "config_found", ok: Boolean(ctx.configPath) },
|
|
9
|
+
{ label: "mixin_config_present", ok: Boolean(mixinConfig) },
|
|
10
|
+
{ label: "plugin_enabled", ok: isPluginEnabled(ctx.config) },
|
|
11
|
+
{ label: "plugin_installed", ok: pluginDirs.length > 0 },
|
|
12
|
+
{ label: "outbox_writable", ok: await checkWritableDir(ctx.outboxDir) },
|
|
13
|
+
{ label: "ffprobe_available", ok: runFfprobeCheck() },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const stageDirs = pluginDirs.filter((dir) => dir.includes(".openclaw-install-stage-"));
|
|
17
|
+
console.log(JSON.stringify({
|
|
18
|
+
ok: checks.every((item) => item.ok),
|
|
19
|
+
checks,
|
|
20
|
+
stageDirs,
|
|
21
|
+
pluginDirs,
|
|
22
|
+
configPath: ctx.configPath,
|
|
23
|
+
outboxDir: ctx.outboxDir,
|
|
24
|
+
outboxFile: ctx.outboxFile,
|
|
25
|
+
}, null, 2));
|
|
26
|
+
|
|
27
|
+
return checks.every((item) => item.ok) ? 0 : 1;
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { buildContext, findMixinPluginDirs, isPluginEnabled, readMixinConfig, runFfprobeCheck } from "../utils.ts";
|
|
2
|
+
|
|
3
|
+
export async function runInfo(): Promise<number> {
|
|
4
|
+
const ctx = await buildContext();
|
|
5
|
+
const pluginDirs = await findMixinPluginDirs(ctx.extensionsDir);
|
|
6
|
+
const mixinConfig = readMixinConfig(ctx.config);
|
|
7
|
+
|
|
8
|
+
console.log(JSON.stringify({
|
|
9
|
+
homeDir: ctx.homeDir,
|
|
10
|
+
stateDir: ctx.stateDir,
|
|
11
|
+
extensionsDir: ctx.extensionsDir,
|
|
12
|
+
configPath: ctx.configPath,
|
|
13
|
+
pluginDirs,
|
|
14
|
+
pluginEnabled: isPluginEnabled(ctx.config),
|
|
15
|
+
mixinConfigured: Boolean(mixinConfig),
|
|
16
|
+
defaultAccount: typeof mixinConfig?.defaultAccount === "string" ? mixinConfig.defaultAccount : "default",
|
|
17
|
+
outboxDir: ctx.outboxDir,
|
|
18
|
+
outboxFile: ctx.outboxFile,
|
|
19
|
+
ffprobeAvailable: runFfprobeCheck(),
|
|
20
|
+
}, null, 2));
|
|
21
|
+
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { runDoctor } from "./commands/doctor.ts";
|
|
2
|
+
import { runInfo } from "./commands/info.ts";
|
|
3
|
+
import { runInstall } from "./commands/install.ts";
|
|
4
|
+
import { runUpdate } from "./commands/update.ts";
|
|
5
|
+
|
|
6
|
+
function printUsage(): void {
|
|
7
|
+
console.log(`mixin-plugin-onboard <command>
|
|
8
|
+
|
|
9
|
+
Commands:
|
|
10
|
+
info
|
|
11
|
+
doctor
|
|
12
|
+
install [npm-spec]
|
|
13
|
+
update [npm-spec]
|
|
14
|
+
`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function main(): Promise<void> {
|
|
18
|
+
const [, , command, arg] = process.argv;
|
|
19
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
20
|
+
printUsage();
|
|
21
|
+
process.exitCode = 0;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (command === "info") {
|
|
26
|
+
process.exitCode = await runInfo();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (command === "doctor") {
|
|
31
|
+
process.exitCode = await runDoctor();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (command === "install") {
|
|
36
|
+
process.exitCode = await runInstall(arg);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (command === "update") {
|
|
41
|
+
process.exitCode = await runUpdate(arg);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
printUsage();
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await main();
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
export type OpenClawContext = {
|
|
7
|
+
homeDir: string;
|
|
8
|
+
stateDir: string;
|
|
9
|
+
extensionsDir: string;
|
|
10
|
+
configPath: string | null;
|
|
11
|
+
config: Record<string, unknown> | null;
|
|
12
|
+
outboxDir: string;
|
|
13
|
+
outboxFile: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function resolveHomeDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
17
|
+
const configured = env.OPENCLAW_HOME?.trim();
|
|
18
|
+
if (configured) {
|
|
19
|
+
return configured;
|
|
20
|
+
}
|
|
21
|
+
return path.join(os.homedir(), ".openclaw");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveStateDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
25
|
+
const configured = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
|
|
26
|
+
if (configured) {
|
|
27
|
+
return configured;
|
|
28
|
+
}
|
|
29
|
+
return path.join(resolveHomeDir(env), "state");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveExtensionsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
33
|
+
return path.join(resolveHomeDir(env), "extensions");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveOutboxPaths(env: NodeJS.ProcessEnv = process.env): {
|
|
37
|
+
outboxDir: string;
|
|
38
|
+
outboxFile: string;
|
|
39
|
+
} {
|
|
40
|
+
const outboxDir = path.join(resolveStateDir(env), "mixin");
|
|
41
|
+
return {
|
|
42
|
+
outboxDir,
|
|
43
|
+
outboxFile: path.join(outboxDir, "mixin-outbox.json"),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function readConfig(env: NodeJS.ProcessEnv = process.env): Promise<{
|
|
48
|
+
path: string | null;
|
|
49
|
+
config: Record<string, unknown> | null;
|
|
50
|
+
}> {
|
|
51
|
+
const explicit = env.OPENCLAW_CONFIG?.trim();
|
|
52
|
+
const candidates = [
|
|
53
|
+
explicit,
|
|
54
|
+
path.join(resolveHomeDir(env), "openclaw.json"),
|
|
55
|
+
path.join(process.cwd(), "openclaw.json"),
|
|
56
|
+
].filter((value): value is string => Boolean(value));
|
|
57
|
+
|
|
58
|
+
for (const candidate of candidates) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = await fs.readFile(candidate, "utf8");
|
|
61
|
+
return {
|
|
62
|
+
path: candidate,
|
|
63
|
+
config: parseLooseConfig(raw),
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
path: null,
|
|
72
|
+
config: null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseLooseConfig(raw: string): Record<string, unknown> {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
79
|
+
} catch {
|
|
80
|
+
const relaxed = raw
|
|
81
|
+
.replace(/^\uFEFF/, "")
|
|
82
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
83
|
+
.replace(/^\s*\/\/.*$/gm, "")
|
|
84
|
+
.replace(/,\s*([}\]])/g, "$1");
|
|
85
|
+
return JSON.parse(relaxed) as Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function buildContext(env: NodeJS.ProcessEnv = process.env): Promise<OpenClawContext> {
|
|
90
|
+
const config = await readConfig(env);
|
|
91
|
+
const outbox = resolveOutboxPaths(env);
|
|
92
|
+
return {
|
|
93
|
+
homeDir: resolveHomeDir(env),
|
|
94
|
+
stateDir: resolveStateDir(env),
|
|
95
|
+
extensionsDir: resolveExtensionsDir(env),
|
|
96
|
+
configPath: config.path,
|
|
97
|
+
config: config.config,
|
|
98
|
+
outboxDir: outbox.outboxDir,
|
|
99
|
+
outboxFile: outbox.outboxFile,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function findMixinPluginDirs(extensionsDir: string): Promise<string[]> {
|
|
104
|
+
try {
|
|
105
|
+
const entries = await fs.readdir(extensionsDir, { withFileTypes: true });
|
|
106
|
+
const matched: string[] = [];
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (!entry.isDirectory()) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const dirPath = path.join(extensionsDir, entry.name);
|
|
112
|
+
const openclawPluginPath = path.join(dirPath, "openclaw.plugin.json");
|
|
113
|
+
const packageJsonPath = path.join(dirPath, "package.json");
|
|
114
|
+
try {
|
|
115
|
+
const pluginRaw = await fs.readFile(openclawPluginPath, "utf8");
|
|
116
|
+
if (pluginRaw.includes("\"mixin\"")) {
|
|
117
|
+
matched.push(dirPath);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const packageRaw = await fs.readFile(packageJsonPath, "utf8");
|
|
124
|
+
if (packageRaw.includes("\"@invago/mixin\"") || packageRaw.includes("\"id\":\"mixin\"")) {
|
|
125
|
+
matched.push(dirPath);
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return matched;
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function checkWritableDir(dirPath: string): Promise<boolean> {
|
|
137
|
+
try {
|
|
138
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
139
|
+
const testPath = path.join(dirPath, `.write-test-${Date.now()}`);
|
|
140
|
+
await fs.writeFile(testPath, "ok", "utf8");
|
|
141
|
+
await fs.rm(testPath, { force: true });
|
|
142
|
+
return true;
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function runOpenClawInstall(spec: string): number {
|
|
149
|
+
const result = spawnSync("openclaw", ["plugins", "install", spec], {
|
|
150
|
+
stdio: "inherit",
|
|
151
|
+
shell: process.platform === "win32",
|
|
152
|
+
});
|
|
153
|
+
return result.status ?? 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function runFfprobeCheck(): boolean {
|
|
157
|
+
const result = spawnSync(process.platform === "win32" ? "ffprobe.exe" : "ffprobe", ["-version"], {
|
|
158
|
+
stdio: "ignore",
|
|
159
|
+
shell: false,
|
|
160
|
+
});
|
|
161
|
+
return result.status === 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function readMixinConfig(config: Record<string, unknown> | null): Record<string, unknown> | null {
|
|
165
|
+
const channels = config?.channels;
|
|
166
|
+
if (!channels || typeof channels !== "object") {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const mixin = (channels as Record<string, unknown>).mixin;
|
|
170
|
+
return mixin && typeof mixin === "object" ? (mixin as Record<string, unknown>) : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function isPluginEnabled(config: Record<string, unknown> | null): boolean {
|
|
174
|
+
const plugins = config?.plugins;
|
|
175
|
+
if (!plugins || typeof plugins !== "object") {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
const allow = Array.isArray((plugins as Record<string, unknown>).allow)
|
|
179
|
+
? ((plugins as Record<string, unknown>).allow as unknown[]).map(String)
|
|
180
|
+
: [];
|
|
181
|
+
const entries = (plugins as Record<string, unknown>).entries;
|
|
182
|
+
const mixinEntry = entries && typeof entries === "object"
|
|
183
|
+
? (entries as Record<string, unknown>).mixin
|
|
184
|
+
: null;
|
|
185
|
+
const enabled = mixinEntry && typeof mixinEntry === "object"
|
|
186
|
+
? (mixinEntry as Record<string, unknown>).enabled !== false
|
|
187
|
+
: false;
|
|
188
|
+
return allow.includes("mixin") && enabled;
|
|
189
|
+
}
|