unraidclaw 0.1.13 → 0.1.15

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 CHANGED
@@ -1,29 +1,37 @@
1
1
  # unraidclaw
2
2
 
3
- > OpenClaw plugin to manage your Unraid server through AI agents — Docker, VMs, array, shares, system, notifications, and more, with permission control.
3
+ > OpenClaw plugin to manage your Unraid server through AI agents: Docker, VMs, array, shares, system, notifications, and more, with permission control.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/unraidclaw)](https://www.npmjs.com/package/unraidclaw)
6
6
 
7
- This is the [OpenClaw](https://github.com/openclaw/openclaw) plugin for **[UnraidClaw](https://github.com/emaspa/unraidclaw)**. It exposes **44 tools** to any AI agent running on OpenClaw, letting it monitor and manage your Unraid server. The plugin talks to the UnraidClaw gateway (a permission-enforcing REST API) running on your Unraid box.
7
+ This is the [OpenClaw](https://github.com/openclaw/openclaw) plugin for **[UnraidClaw](https://github.com/emaspa/unraidclaw)**. It exposes **55 tools** to any AI agent running on OpenClaw, letting it monitor and manage your Unraid server. The plugin talks to the UnraidClaw gateway (a permission-enforcing REST API) running on your Unraid box.
8
8
 
9
9
  ## Prerequisites
10
10
 
11
- 1. **The UnraidClaw plugin installed on your Unraid server** — install it from the Unraid Community Apps store, or see the [main repo](https://github.com/emaspa/unraidclaw). It runs the gateway on port `9876` (HTTPS).
12
- 2. **An UnraidClaw API key** — generate one on the **Settings → UnraidClaw** page in the Unraid WebGUI.
11
+ 1. **The UnraidClaw plugin installed on your Unraid server.** Install it from the Unraid Community Apps store, or see the [main repo](https://github.com/emaspa/unraidclaw). It runs the gateway on port `9876` (HTTPS) by default.
12
+ 2. **An UnraidClaw API key.** Generate one on the **Settings > UnraidClaw** page in the Unraid WebGUI.
13
13
  3. **OpenClaw** installed (`openclaw --version`).
14
14
 
15
15
  ## Install
16
16
 
17
17
  ```bash
18
- npm pack unraidclaw && openclaw plugins install unraidclaw-*.tgz && rm unraidclaw-*.tgz
18
+ openclaw plugins install clawhub:unraidclaw --accept-capabilities
19
+ ```
20
+
21
+ The same package is on npm. OpenClaw asks you to confirm installs from outside ClawHub, so installing from npm needs `--force`:
22
+
23
+ ```bash
24
+ openclaw plugins install unraidclaw --force --accept-capabilities
19
25
  ```
20
26
 
21
27
  To update to the latest version:
22
28
 
23
29
  ```bash
24
- rm -rf ~/.openclaw/extensions/unraidclaw && npm pack unraidclaw && openclaw plugins install unraidclaw-*.tgz && rm unraidclaw-*.tgz
30
+ openclaw plugins update unraidclaw --accept-capabilities
25
31
  ```
26
32
 
33
+ Then restart the gateway with `openclaw gateway restart` so it loads the new version.
34
+
27
35
  ## Configure
28
36
 
29
37
  Edit `~/.openclaw/openclaw.json`.
@@ -57,8 +65,8 @@ Edit `~/.openclaw/openclaw.json`.
57
65
  "unraidclaw": {
58
66
  "config": {
59
67
  "servers": [
60
- { "name": "home", "serverUrl": "https://192.168.1.100:9876", "apiKey": "...", "tlsSkipVerify": true, "default": true },
61
- { "name": "work", "serverUrl": "https://10.0.0.50:9876", "apiKey": "..." }
68
+ { "name": "home", "serverUrl": "https://<home-server>:9876", "apiKey": "<api-key>", "tlsSkipVerify": true, "default": true },
69
+ { "name": "work", "serverUrl": "https://<work-server>:9876", "apiKey": "<api-key>" }
62
70
  ]
63
71
  }
64
72
  }
@@ -67,21 +75,21 @@ Edit `~/.openclaw/openclaw.json`.
67
75
  }
68
76
  ```
69
77
 
70
- With multi-server config, every tool accepts an optional `server` parameter (e.g. `unraid_docker_list(server: "work")`); the default server is used when it's omitted.
78
+ With multi-server config, every tool accepts an optional `server` parameter (e.g. `unraid_docker_list(server: "work")`); the first server marked `default` is used when it's omitted, or the first configured server if none is marked.
71
79
 
72
- Set `tlsSkipVerify: true` when using UnraidClaw's auto-generated self-signed certificate.
80
+ Set `tlsSkipVerify: true` to accept the gateway's self-signed certificate. The plugin does not verify the certificate in that mode, so regenerating the certificate on the server does not affect it. The repository README's [TLS certificate](https://github.com/emaspa/unraidclaw#tls-certificate) section explains what the certificate contains and how strict clients can trust it.
73
81
 
74
82
  ### Keeping the API key out of the config file
75
83
 
76
84
  You don't have to hard-code the key in `openclaw.json`. Two options:
77
85
 
78
- **Environment variable** — OpenClaw expands `${VAR}` references at config-load time:
86
+ **Environment variable.** OpenClaw expands `${VAR}` references at config-load time:
79
87
 
80
88
  ```json
81
89
  "apiKey": "${UNRAID_API_KEY}"
82
90
  ```
83
91
 
84
- **Provider-backed secret (`SecretRef`)** — point `apiKey` at one of your configured secret providers; OpenClaw resolves it before the plugin loads, so the plugin only ever sees the resolved string:
92
+ **Provider-backed secret (`SecretRef`).** Point `apiKey` at one of your configured secret providers; OpenClaw resolves it before the plugin loads, so the plugin only ever sees the resolved string:
85
93
 
86
94
  ```json
87
95
  "apiKey": { "source": "file", "provider": "default", "id": "/unraidclaw_key" }
@@ -91,24 +99,30 @@ You don't have to hard-code the key in `openclaw.json`. Two options:
91
99
 
92
100
  ## Usage
93
101
 
94
- Once installed and configured, just ask your agent:
102
+ Once installed and configured, ask your agent:
95
103
 
96
104
  - "List all running Docker containers"
97
105
  - "Stop the plex container"
98
106
  - "What's the array status?"
99
107
  - "Show me disk temperatures"
100
108
  - "Create a new nginx container with port 8080"
109
+ - "Find me a Community Applications backup tool"
110
+ - "Install Jellyfin from Community Applications, media on /mnt/user/media"
111
+ - "Update the jellyfin container to the latest image"
112
+ - "Which of my Unraid plugins have updates?"
101
113
  - "Check parity status"
102
114
  - "Reboot the server"
103
115
 
104
116
  ## Tools
105
117
 
106
- 44 tools across 11 categories:
118
+ 55 tools across 13 categories:
107
119
 
108
120
  | Category | Tools |
109
121
  |----------|-------|
110
122
  | Health | `unraid_health_check` |
111
123
  | Docker | `unraid_docker_list`, `unraid_docker_inspect`, `unraid_docker_logs`, `unraid_docker_create`, `unraid_docker_start`, `unraid_docker_stop`, `unraid_docker_restart`, `unraid_docker_pause`, `unraid_docker_unpause`, `unraid_docker_remove` |
124
+ | Community Apps | `unraid_ca_search`, `unraid_ca_app`, `unraid_ca_install`, `unraid_ca_update`, `unraid_ca_remove` |
125
+ | Plugins | `unraid_plugins_list`, `unraid_plugin_info`, `unraid_plugin_install`, `unraid_plugin_check_updates`, `unraid_plugin_update`, `unraid_plugin_remove` |
112
126
  | VMs | `unraid_vm_list`, `unraid_vm_inspect`, `unraid_vm_start`, `unraid_vm_stop`, `unraid_vm_pause`, `unraid_vm_resume`, `unraid_vm_force_stop`, `unraid_vm_reboot` |
113
127
  | Array | `unraid_array_status`, `unraid_array_start`, `unraid_array_stop`, `unraid_parity_status`, `unraid_parity_start`, `unraid_parity_pause`, `unraid_parity_resume`, `unraid_parity_cancel` |
114
128
  | Disks | `unraid_disk_list`, `unraid_disk_details` |
@@ -119,7 +133,11 @@ Once installed and configured, just ask your agent:
119
133
  | Users | `unraid_user_me` |
120
134
  | Logs | `unraid_syslog` |
121
135
 
122
- Every tool is gated by a 22-key `resource:action` permission matrix configured from the Unraid WebGUI, so you control exactly what agents can do.
136
+ `unraid_ca_update` and `unraid_ca_remove` act on an installed app, so their `name` is the container's name from the Docker tab, not the app's name in the catalog. Update keeps the configuration saved on the server and restores the running or stopped state; remove deletes the container and leaves appdata, volumes, the image and the template alone. Both take `dryRun`.
137
+
138
+ The six plugin tools manage Unraid `.plg` plugins through Unraid's own plugin manager. Installing one runs vendor code as root, checking for an update downloads a plugin file and stages it, and removing one runs the plugin's removal script, which may take its data with it. All four mutating tools take `dryRun`.
139
+
140
+ Tools use the gateway's 30-key `resource:action` permission matrix configured from the Unraid WebGUI. Health requires no permission.
123
141
 
124
142
  ## Links
125
143
 
@@ -127,6 +145,10 @@ Every tool is gated by a 22-key `resource:action` permission matrix configured f
127
145
  - [Issues](https://github.com/emaspa/unraidclaw/issues)
128
146
  - [Unraid Community Apps](https://unraid.net/community/apps)
129
147
 
148
+ ## Gateway MCP mode
149
+
150
+ The gateway has an optional MCP endpoint at `/mcp` that serves the same 55 tools to MCP clients. It is off by default and is switched on with **Enable MCP** in the gateway's Settings tab. This plugin does not use it: OpenClaw keeps calling `/api/*` whether MCP is on or off. The tool definitions in this package are shared with the gateway through the `unraidclaw/tools` export, so OpenClaw, MCP and the standalone CLI use the same tools and the `READ_ONLY` set exported by `src/registry.ts`. The CLI, command `unraidclaw`, is attached to each [GitHub release](https://github.com/emaspa/unraidclaw/releases/latest) as `unraidclaw-cli-<version>.tar.gz`; see the [CLI guide](https://github.com/emaspa/unraidclaw/blob/main/packages/cli/README.md). See the [repository README](https://github.com/emaspa/unraidclaw#mcp) for MCP client setup.
151
+
130
152
  ## License
131
153
 
132
154
  MIT
package/dist/index.js CHANGED
@@ -109,9 +109,26 @@ var UnraidClient = class {
109
109
  function textResult(data) {
110
110
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
111
111
  }
112
+ var failures = /* @__PURE__ */ new WeakSet();
112
113
  function errorResult(err) {
113
114
  const message = err instanceof Error ? err.message : String(err);
114
- return { content: [{ type: "text", text: `Error: ${message}` }] };
115
+ const result = { content: [{ type: "text", text: `Error: ${message}` }] };
116
+ failures.add(result);
117
+ return result;
118
+ }
119
+ function checkParams(params, allowed) {
120
+ for (const key of Object.keys(params)) {
121
+ if (allowed.includes(key)) continue;
122
+ const near = allowed.find((a) => a.toLowerCase() === key.toLowerCase());
123
+ throw new Error(
124
+ near ? `Unknown parameter "${key}". Did you mean "${near}"? Nothing was sent to the server.` : `Unknown parameter "${key}". Allowed parameters are: ${allowed.join(", ")}. Nothing was sent to the server.`
125
+ );
126
+ }
127
+ if (params.dryRun !== void 0 && typeof params.dryRun !== "boolean") {
128
+ throw new Error(
129
+ `"dryRun" must be true or false, not ${JSON.stringify(params.dryRun)}. Nothing was sent to the server.`
130
+ );
131
+ }
115
132
  }
116
133
 
117
134
  // src/tools/health.ts
@@ -180,7 +197,7 @@ function registerDockerTools(api, getClient) {
180
197
  type: "object",
181
198
  properties: {
182
199
  id: { type: "string", description: "Container ID or name" },
183
- tail: { type: "number", description: "Number of lines from the end (default: 100)" },
200
+ tail: { type: "integer", minimum: 1, maximum: 1e4, description: "Number of lines from the end (default: 100)" },
184
201
  since: { type: "string", description: "Show logs since timestamp (e.g., 2024-01-01T00:00:00Z)" },
185
202
  server: { type: "string", description: "Target server name (optional, uses default server)" }
186
203
  },
@@ -289,6 +306,359 @@ function registerDockerTools(api, getClient) {
289
306
  });
290
307
  }
291
308
 
309
+ // src/tools/ca.ts
310
+ function registerCaTools(api, getClient) {
311
+ api.registerTool({
312
+ name: "unraid_ca_search",
313
+ description: "Search the Unraid Community Applications catalog by name, description, Docker image or maintainer. Returns matching apps with their icon, categories and whether UnraidClaw can install them. Use this to find an app before calling unraid_ca_app or unraid_ca_install.",
314
+ parameters: {
315
+ type: "object",
316
+ properties: {
317
+ q: { type: "string", description: "Search text, e.g. 'plex' or 'backup tool'. All words must match." },
318
+ limit: { type: "number", description: "Maximum results to return (1-100, default: 25)" },
319
+ includeDeprecated: { type: "boolean", description: "Include templates the maintainer deprecated (default: false)" },
320
+ includePlugins: { type: "boolean", description: "Include Unraid plugin (.plg) entries, which are not containers (default: false)" },
321
+ server: { type: "string", description: "Target server name (optional, uses default server)" }
322
+ },
323
+ required: ["q"]
324
+ },
325
+ execute: async (_id, params) => {
326
+ try {
327
+ const query = { q: String(params.q ?? "") };
328
+ if (params.limit) query.limit = String(params.limit);
329
+ if (params.includeDeprecated) query.includeDeprecated = "true";
330
+ if (params.includePlugins) query.includePlugins = "true";
331
+ return textResult(await getClient(params.server).get("/api/ca/search", query));
332
+ } catch (err) {
333
+ return errorResult(err);
334
+ }
335
+ }
336
+ });
337
+ api.registerTool({
338
+ name: "unraid_ca_app",
339
+ description: "Get the full Community Applications template for one app: its Docker image, icon, WebUI, network mode, and every configurable port, volume and environment variable with its default. Also reports which required fields have no default and any reason the app cannot be installed. Several apps share a name; pass repo to disambiguate when the call reports an ambiguity.",
340
+ parameters: {
341
+ type: "object",
342
+ properties: {
343
+ name: { type: "string", description: "App name exactly as it appears in the catalog, e.g. 'Jellyfin'" },
344
+ repo: { type: "string", description: `Owning repository, e.g. "linuxserver's Repository". Required when several templates share the name.` },
345
+ server: { type: "string", description: "Target server name (optional, uses default server)" }
346
+ },
347
+ required: ["name"]
348
+ },
349
+ execute: async (_id, params) => {
350
+ try {
351
+ const query = {};
352
+ if (params.repo) query.repo = String(params.repo);
353
+ return textResult(
354
+ await getClient(params.server).get(
355
+ `/api/ca/app/${encodeURIComponent(String(params.name))}`,
356
+ query
357
+ )
358
+ );
359
+ } catch (err) {
360
+ return errorResult(err);
361
+ }
362
+ }
363
+ });
364
+ api.registerTool({
365
+ name: "unraid_ca_install",
366
+ description: "Install a Community Applications app on the Unraid server using its template defaults. Writes an Unraid docker-manager template and has Unraid create and start the container, so it appears on the Docker tab like any other app. Required fields with no default must be supplied in overrides; call unraid_ca_app first to see them. Pass dryRun=true to get the resolved template and a preview of the docker command without changing anything. Refuses apps whose templates need unsupported or unsafe options rather than installing something different from the template.",
367
+ parameters: {
368
+ type: "object",
369
+ properties: {
370
+ name: { type: "string", description: "App name exactly as it appears in the catalog, e.g. 'Jellyfin'" },
371
+ repo: { type: "string", description: "Owning repository. Required when several templates share the name." },
372
+ containerName: {
373
+ type: "string",
374
+ description: "Name for the new container. Defaults to the app name. Letters, digits, dot, dash and underscore only."
375
+ },
376
+ overrides: {
377
+ type: "object",
378
+ additionalProperties: { type: "string" },
379
+ description: "Values for template fields, keyed by the field's name or its container-side target, e.g. {'/data/tvshows': '/mnt/user/media/tv', 'PUID': '99'}. An unknown key is an error."
380
+ },
381
+ dryRun: {
382
+ type: "boolean",
383
+ description: "Resolve and validate everything and return the plan without installing (default: false)"
384
+ },
385
+ server: { type: "string", description: "Target server name (optional, uses default server)" }
386
+ },
387
+ required: ["name"],
388
+ additionalProperties: false
389
+ },
390
+ execute: async (_id, params) => {
391
+ try {
392
+ checkParams(params, ["name", "repo", "containerName", "overrides", "dryRun", "server"]);
393
+ const body = {};
394
+ if (params.repo) body.repo = params.repo;
395
+ if (params.containerName) body.name = params.containerName;
396
+ if (params.overrides) body.overrides = params.overrides;
397
+ if (params.dryRun !== void 0) body.dryRun = params.dryRun;
398
+ return textResult(
399
+ await getClient(params.server).post(
400
+ `/api/ca/app/${encodeURIComponent(String(params.name))}/install`,
401
+ body
402
+ )
403
+ );
404
+ } catch (err) {
405
+ return errorResult(err);
406
+ }
407
+ }
408
+ }, { optional: true });
409
+ api.registerTool({
410
+ name: "unraid_ca_update",
411
+ description: "Update an app that is already installed on the Unraid server: pulls the newest image for the tag it runs and recreates the container from the template saved on the server, keeping its ports, paths, variables and its running or stopped state. The name is the INSTALLED CONTAINER NAME as shown on the Docker tab, not the app's name in the Community Applications catalog; call unraid_docker_list to find it and never guess it from a catalog name. Nothing is refreshed from the catalog, so values the user changed are kept. A failed pull leaves the running app untouched, the previous image is never deleted, and no appdata is removed. Pass dryRun=true to see the resolved configuration and the exact docker command without changing anything.",
412
+ parameters: {
413
+ type: "object",
414
+ properties: {
415
+ name: { type: "string", description: "Installed container name, e.g. 'jellyfin'. Not the catalog app name." },
416
+ dryRun: {
417
+ type: "boolean",
418
+ description: "Check everything and report what would happen without pulling or recreating anything (default: false)"
419
+ },
420
+ server: { type: "string", description: "Target server name (optional, uses default server)" }
421
+ },
422
+ required: ["name"],
423
+ additionalProperties: false
424
+ },
425
+ execute: async (_id, params) => {
426
+ try {
427
+ checkParams(params, ["name", "dryRun", "server"]);
428
+ const body = {};
429
+ if (params.dryRun !== void 0) body.dryRun = params.dryRun;
430
+ return textResult(
431
+ await getClient(params.server).post(
432
+ `/api/ca/app/${encodeURIComponent(String(params.name))}/update`,
433
+ body
434
+ )
435
+ );
436
+ } catch (err) {
437
+ return errorResult(err);
438
+ }
439
+ }
440
+ }, { optional: true });
441
+ api.registerTool({
442
+ name: "unraid_ca_remove",
443
+ description: "Remove an installed app's Docker container from the Unraid server. The name is the INSTALLED CONTAINER NAME as shown on the Docker tab, not the app's name in the Community Applications catalog; call unraid_docker_list to find it and never guess it from a catalog name. Only the container is removed: its appdata, its Docker volumes, its image and the saved template all stay, so the app can be recreated with the same configuration. Pass dryRun=true to see exactly what would be removed and what would be kept.",
444
+ parameters: {
445
+ type: "object",
446
+ properties: {
447
+ name: { type: "string", description: "Installed container name, e.g. 'jellyfin'. Not the catalog app name." },
448
+ dryRun: {
449
+ type: "boolean",
450
+ description: "Report what would be removed and what would be kept, without removing anything (default: false)"
451
+ },
452
+ server: { type: "string", description: "Target server name (optional, uses default server)" }
453
+ },
454
+ required: ["name"],
455
+ additionalProperties: false
456
+ },
457
+ execute: async (_id, params) => {
458
+ try {
459
+ checkParams(params, ["name", "dryRun", "server"]);
460
+ const body = {};
461
+ if (params.dryRun !== void 0) body.dryRun = params.dryRun;
462
+ return textResult(
463
+ await getClient(params.server).post(
464
+ `/api/ca/app/${encodeURIComponent(String(params.name))}/remove`,
465
+ body
466
+ )
467
+ );
468
+ } catch (err) {
469
+ return errorResult(err);
470
+ }
471
+ }
472
+ }, { optional: true });
473
+ }
474
+
475
+ // src/tools/plugins.ts
476
+ function registerPluginTools(api, getClient) {
477
+ api.registerTool({
478
+ name: "unraid_plugins_list",
479
+ description: "List the Unraid plugins (.plg) installed on the server, with author, version, update URL and whether an update is already staged. Unraid plugins are not Docker containers and not Community Applications: they install files and run scripts on the server itself. Plugins marked builtin belong to Unraid OS and cannot be changed through UnraidClaw.",
480
+ parameters: {
481
+ type: "object",
482
+ properties: {
483
+ server: { type: "string", description: "Target server name (optional, uses default server)" }
484
+ }
485
+ },
486
+ execute: async (_id, params) => {
487
+ try {
488
+ return textResult(await getClient(params.server).get("/api/plugins"));
489
+ } catch (err) {
490
+ return errorResult(err);
491
+ }
492
+ }
493
+ });
494
+ api.registerTool({
495
+ name: "unraid_plugin_info",
496
+ description: "Show one installed Unraid plugin in full: its metadata, support link, Unraid version requirements, changelog text and the structure of the files it installs. Inline scripts inside the plugin are described but never returned or executed.",
497
+ parameters: {
498
+ type: "object",
499
+ properties: {
500
+ plugin: {
501
+ type: "string",
502
+ description: "Plugin file name, e.g. 'unassigned.devices.plg'. The .plg suffix is optional."
503
+ },
504
+ server: { type: "string", description: "Target server name (optional, uses default server)" }
505
+ },
506
+ required: ["plugin"]
507
+ },
508
+ execute: async (_id, params) => {
509
+ try {
510
+ return textResult(
511
+ await getClient(params.server).get(
512
+ `/api/plugins/${encodeURIComponent(String(params.plugin))}`
513
+ )
514
+ );
515
+ } catch (err) {
516
+ return errorResult(err);
517
+ }
518
+ }
519
+ });
520
+ api.registerTool(
521
+ {
522
+ name: "unraid_plugin_install",
523
+ description: "Install an Unraid plugin from an explicit https URL to a .plg file. The plugin file is an installer that Unraid runs as root, so this executes code from whoever controls that URL: only use a URL the user gave you or one from a source they trust. The URL must be https, public, credential-free and end in .plg. Use dryRun first to see exactly what would happen. This is not the way to install Community Applications apps; those are Docker containers (unraid_ca_install).",
524
+ parameters: {
525
+ type: "object",
526
+ properties: {
527
+ url: {
528
+ type: "string",
529
+ description: "Direct https URL of the .plg file, e.g. 'https://example.com/myplugin.plg'"
530
+ },
531
+ dryRun: {
532
+ type: "boolean",
533
+ description: "Validate the URL and return the plan without downloading, writing or installing anything (default: false)"
534
+ },
535
+ server: { type: "string", description: "Target server name (optional, uses default server)" }
536
+ },
537
+ required: ["url"],
538
+ additionalProperties: false
539
+ },
540
+ execute: async (_id, params) => {
541
+ try {
542
+ checkParams(params, ["url", "dryRun", "server"]);
543
+ const body = { url: String(params.url ?? "") };
544
+ if (params.dryRun !== void 0) body.dryRun = params.dryRun;
545
+ return textResult(
546
+ await getClient(params.server).post("/api/plugins/install", body)
547
+ );
548
+ } catch (err) {
549
+ return errorResult(err);
550
+ }
551
+ }
552
+ },
553
+ { optional: true }
554
+ );
555
+ api.registerTool(
556
+ {
557
+ name: "unraid_plugin_check_updates",
558
+ description: "Check whether a newer version of an installed Unraid plugin has been published, and stage it for installation. This is not a read-only lookup: it downloads the plugin file from the plugin's own update URL and writes it to the plugin manager's staging directory, where this tool's update call and the Unraid web UI will then offer to install it. Nothing is installed or executed by the check. Run this before unraid_plugin_update.",
559
+ parameters: {
560
+ type: "object",
561
+ properties: {
562
+ plugin: { type: "string", description: "Plugin file name, e.g. 'unassigned.devices.plg'" },
563
+ dryRun: {
564
+ type: "boolean",
565
+ description: "Return the plan without downloading or staging anything (default: false)"
566
+ },
567
+ server: { type: "string", description: "Target server name (optional, uses default server)" }
568
+ },
569
+ required: ["plugin"],
570
+ additionalProperties: false
571
+ },
572
+ execute: async (_id, params) => {
573
+ try {
574
+ checkParams(params, ["plugin", "dryRun", "server"]);
575
+ const body = {};
576
+ if (params.dryRun !== void 0) body.dryRun = params.dryRun;
577
+ return textResult(
578
+ await getClient(params.server).post(
579
+ `/api/plugins/${encodeURIComponent(String(params.plugin))}/check`,
580
+ body
581
+ )
582
+ );
583
+ } catch (err) {
584
+ return errorResult(err);
585
+ }
586
+ }
587
+ },
588
+ { optional: true }
589
+ );
590
+ api.registerTool(
591
+ {
592
+ name: "unraid_plugin_update",
593
+ description: "Install the plugin version staged by unraid_plugin_check_updates. Unraid's plugin manager runs the new plugin's install scripts as root and there is no rollback. The result says whether the installed version actually changed; a plugin manager exit code alone is not treated as success.",
594
+ parameters: {
595
+ type: "object",
596
+ properties: {
597
+ plugin: { type: "string", description: "Plugin file name, e.g. 'unassigned.devices.plg'" },
598
+ dryRun: {
599
+ type: "boolean",
600
+ description: "Return the plan, including which version would replace which, without installing (default: false)"
601
+ },
602
+ server: { type: "string", description: "Target server name (optional, uses default server)" }
603
+ },
604
+ required: ["plugin"],
605
+ additionalProperties: false
606
+ },
607
+ execute: async (_id, params) => {
608
+ try {
609
+ checkParams(params, ["plugin", "dryRun", "server"]);
610
+ const body = {};
611
+ if (params.dryRun !== void 0) body.dryRun = params.dryRun;
612
+ return textResult(
613
+ await getClient(params.server).post(
614
+ `/api/plugins/${encodeURIComponent(String(params.plugin))}/update`,
615
+ body
616
+ )
617
+ );
618
+ } catch (err) {
619
+ return errorResult(err);
620
+ }
621
+ }
622
+ },
623
+ { optional: true }
624
+ );
625
+ api.registerTool(
626
+ {
627
+ name: "unraid_plugin_remove",
628
+ description: "Remove an installed Unraid plugin through the plugin manager. The plugin's own removal scripts run as root and decide what they delete: some keep their configuration and data, others delete it, so removal cannot be promised to be data-preserving. Anything depending on the plugin stops working. Use dryRun first and confirm with the user before removing.",
629
+ parameters: {
630
+ type: "object",
631
+ properties: {
632
+ plugin: { type: "string", description: "Plugin file name, e.g. 'unassigned.devices.plg'" },
633
+ dryRun: {
634
+ type: "boolean",
635
+ description: "Return the plan and its warnings without removing anything (default: false)"
636
+ },
637
+ server: { type: "string", description: "Target server name (optional, uses default server)" }
638
+ },
639
+ required: ["plugin"],
640
+ additionalProperties: false
641
+ },
642
+ execute: async (_id, params) => {
643
+ try {
644
+ checkParams(params, ["plugin", "dryRun", "server"]);
645
+ const body = {};
646
+ if (params.dryRun !== void 0) body.dryRun = params.dryRun;
647
+ return textResult(
648
+ await getClient(params.server).post(
649
+ `/api/plugins/${encodeURIComponent(String(params.plugin))}/remove`,
650
+ body
651
+ )
652
+ );
653
+ } catch (err) {
654
+ return errorResult(err);
655
+ }
656
+ }
657
+ },
658
+ { optional: true }
659
+ );
660
+ }
661
+
292
662
  // src/tools/vms.ts
293
663
  function registerVMTools(api, getClient) {
294
664
  api.registerTool({
@@ -711,7 +1081,7 @@ function registerNotificationTools(api, getClient) {
711
1081
  title: { type: "string", description: "Notification title" },
712
1082
  subject: { type: "string", description: "Notification subject" },
713
1083
  description: { type: "string", description: "Notification body text" },
714
- importance: { type: "string", description: "Importance level: alert, warning, or normal" },
1084
+ importance: { type: "string", enum: ["normal", "warning", "alert"], description: "Importance level: alert, warning, or normal" },
715
1085
  server: { type: "string", description: "Target server name (optional, uses default server)" }
716
1086
  },
717
1087
  required: ["title", "subject", "description"]
@@ -839,6 +1209,23 @@ function registerLogTools(api, getClient) {
839
1209
  });
840
1210
  }
841
1211
 
1212
+ // src/registry.ts
1213
+ function registerTools(api, getClient) {
1214
+ registerHealthTools(api, getClient);
1215
+ registerDockerTools(api, getClient);
1216
+ registerCaTools(api, getClient);
1217
+ registerPluginTools(api, getClient);
1218
+ registerVMTools(api, getClient);
1219
+ registerArrayTools(api, getClient);
1220
+ registerDiskTools(api, getClient);
1221
+ registerShareTools(api, getClient);
1222
+ registerSystemTools(api, getClient);
1223
+ registerNotificationTools(api, getClient);
1224
+ registerNetworkTools(api, getClient);
1225
+ registerUserTools(api, getClient);
1226
+ registerLogTools(api, getClient);
1227
+ }
1228
+
842
1229
  // src/index.ts
843
1230
  function resolveServers(api) {
844
1231
  const raw = api.config?.servers ?? api.pluginConfig?.servers ?? api.config?.plugins?.entries?.unraidclaw?.config?.servers;
@@ -868,17 +1255,7 @@ function register(api) {
868
1255
  }
869
1256
  return client;
870
1257
  }
871
- registerHealthTools(api, getClient);
872
- registerDockerTools(api, getClient);
873
- registerVMTools(api, getClient);
874
- registerArrayTools(api, getClient);
875
- registerDiskTools(api, getClient);
876
- registerShareTools(api, getClient);
877
- registerSystemTools(api, getClient);
878
- registerNotificationTools(api, getClient);
879
- registerNetworkTools(api, getClient);
880
- registerUserTools(api, getClient);
881
- registerLogTools(api, getClient);
1258
+ registerTools(api, getClient);
882
1259
  const servers = resolveServers(api);
883
1260
  if (servers.length > 1) {
884
1261
  log.info(`UnraidClaw: registered tools for ${servers.length} servers: ${servers.map((s) => s.name).join(", ")}`);
@@ -0,0 +1,38 @@
1
+ interface ToolDefinition {
2
+ name: string;
3
+ description: string;
4
+ parameters: JsonSchema;
5
+ execute: (id: string, params: Record<string, unknown>) => Promise<ToolResult>;
6
+ }
7
+ interface ToolOptions {
8
+ optional?: boolean;
9
+ }
10
+ interface JsonSchema {
11
+ type: string;
12
+ properties?: Record<string, unknown>;
13
+ required?: string[];
14
+ additionalProperties?: boolean;
15
+ }
16
+ interface ToolResult {
17
+ content: Array<{
18
+ type: "text";
19
+ text: string;
20
+ }>;
21
+ }
22
+
23
+ declare function isErrorResult(result: ToolResult): boolean;
24
+
25
+ /** The tools depend only on this transport, not on an HTTP client or host. */
26
+ interface ToolClient {
27
+ get<T>(path: string, query?: Record<string, string>): Promise<T>;
28
+ post<T>(path: string, body?: unknown): Promise<T>;
29
+ patch<T>(path: string, body?: unknown): Promise<T>;
30
+ delete<T>(path: string): Promise<T>;
31
+ }
32
+ type ClientResolver = (serverName?: string) => ToolClient;
33
+ declare function registerTools(api: {
34
+ registerTool(tool: ToolDefinition, options?: ToolOptions): void;
35
+ }, getClient: ClientResolver): void;
36
+ declare const READ_ONLY: Set<string>;
37
+
38
+ export { type ClientResolver, READ_ONLY, type ToolClient, type ToolDefinition, type ToolOptions, type ToolResult, isErrorResult, registerTools };