tdoms-mcp 0.7.0 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/package.json +4 -1
- package/src/client-manager.js +347 -2
- package/src/index.js +38 -0
- package/src/storage-utils.js +4 -2
- package/src/ui-page.js +53 -8
- package/src/web-app.js +38 -0
package/README.md
CHANGED
|
@@ -70,7 +70,34 @@ Restart or reload the agent's MCP servers after changing the configuration.
|
|
|
70
70
|
|
|
71
71
|
The package includes a portable developer skill at `skills/tdoms-mcp-routing`. Its `SKILL.md` teaches agents how to select core, full-profile, or Octo tools; discover site-specific values; perform guarded task, source, compile, checkout, promote, transfer, branch, and approval workflows; and verify final TD/OMS state.
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
The CLI and local UI can install the complete skill folder for detected IBM Bob, Codex, Claude Code, VS Code Copilot, Kiro, and Gemini CLI installations. The skill contains no credentials, connection profiles, organization-specific values, or personal information. Runtime tool schemas and values returned by the connected TD/OMS server remain authoritative.
|
|
74
|
+
|
|
75
|
+
List the native skill targets and their installation status:
|
|
76
|
+
|
|
77
|
+
```shell
|
|
78
|
+
tdoms-mcp skills
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Install or update the skill for one agent. User scope makes it available across that agent's projects; workspace scope installs it only in the current project:
|
|
82
|
+
|
|
83
|
+
```shell
|
|
84
|
+
tdoms-mcp install-skill ibm-bob --scope user --confirm
|
|
85
|
+
tdoms-mcp install-skill codex --scope user --confirm
|
|
86
|
+
tdoms-mcp install-skill claude-code --scope workspace --confirm
|
|
87
|
+
tdoms-mcp install-skill vscode --scope workspace --confirm
|
|
88
|
+
tdoms-mcp install-skill kiro --scope user --confirm
|
|
89
|
+
tdoms-mcp install-skill gemini-cli --scope user --confirm
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Install or update the skill for every detected compatible agent:
|
|
93
|
+
|
|
94
|
+
```shell
|
|
95
|
+
tdoms-mcp install-detected-skills --scope user --confirm
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The Agent Setup page in `tdoms-mcp ui` provides the same actions with User skill and Workspace skill scope controls. Existing skill folders are timestamped and backed up before replacement, concurrent installs are locked, and a failed replacement restores the prior folder. Bob users must enable Advanced mode; existing agent sessions may need to be restarted or reloaded after installation. Claude Desktop chat requires a ZIP upload to **Customize > Skills**; its row provides a correctly packaged download instead of reporting a local folder copy as installed.
|
|
99
|
+
|
|
100
|
+
The **Codex** row covers the Codex desktop app, CLI, and VS Code extension through `~/.agents/skills`. Restart Codex or reload the VS Code window and start a new task if a newly installed skill is not visible. The **Visual Studio Code** skill action installs for every compatible extension it detects: Codex through `~/.agents/skills`, Claude Code through `~/.claude/skills`, and GitHub Copilot through `~/.copilot/skills`. Use `/skills` in agents that expose that menu, or invoke the skill by name in Codex and Claude Code.
|
|
74
101
|
|
|
75
102
|
Detect supported local clients from the installed command:
|
|
76
103
|
|
|
@@ -101,6 +128,8 @@ Detection and installation are environment-local. Running the installer on Windo
|
|
|
101
128
|
|
|
102
129
|
Client installation never copies TD/OMS passwords or tokens into agent configuration. It registers only the local stdio command and the `core` tool profile. Existing files are backed up, writes are atomic, invalid JSON is rejected, and failed client updates are restored.
|
|
103
130
|
|
|
131
|
+
MCP and skill installation are separate on purpose: the MCP provides live TD/OMS tools, while the skill teaches an agent how to route requests and perform guarded change-management workflows. Install both for the complete experience.
|
|
132
|
+
|
|
104
133
|
## Core Workflow
|
|
105
134
|
|
|
106
135
|
The default profile exposes seven tools:
|
|
@@ -138,8 +167,13 @@ All tools that can change TD/OMS require explicit confirmation. `tdoms_call_api`
|
|
|
138
167
|
```text
|
|
139
168
|
tdoms-mcp ui Open the local connection and agent setup UI
|
|
140
169
|
tdoms-mcp clients List detected MCP-capable agents
|
|
170
|
+
tdoms-mcp skills List native skill targets and status
|
|
141
171
|
tdoms-mcp install-client <id> --confirm Configure one detected agent
|
|
142
172
|
tdoms-mcp install-detected --confirm Configure all detected agents
|
|
173
|
+
tdoms-mcp install-skill <id> --scope user --confirm
|
|
174
|
+
Install a skill for one detected agent
|
|
175
|
+
tdoms-mcp install-detected-skills --scope user --confirm
|
|
176
|
+
Install skills for all compatible agents
|
|
143
177
|
tdoms-mcp mcp Start the core stdio MCP server
|
|
144
178
|
tdoms-mcp mcp --profile full Start the complete tool profile
|
|
145
179
|
tdoms-mcp smoke Check local configuration without connecting
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tdoms-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "Agent-neutral MCP server for TD/OMS change-management workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
"secure-storage": "node src/index.js secure-storage",
|
|
38
38
|
"clients": "node src/index.js clients",
|
|
39
39
|
"clients:install": "node src/index.js install-detected --confirm",
|
|
40
|
+
"skills": "node src/index.js skills",
|
|
41
|
+
"skills:install": "node src/index.js install-detected-skills --scope user --confirm",
|
|
40
42
|
"test": "node --test"
|
|
41
43
|
},
|
|
42
44
|
"dependencies": {
|
|
@@ -44,6 +46,7 @@
|
|
|
44
46
|
"express": "^4.19.2",
|
|
45
47
|
"jsonc-parser": "^3.3.1",
|
|
46
48
|
"lucide-static": "^1.24.0",
|
|
49
|
+
"yazl": "^3.3.1",
|
|
47
50
|
"zod": "^4.1.12"
|
|
48
51
|
},
|
|
49
52
|
"engines": {
|
package/src/client-manager.js
CHANGED
|
@@ -1,30 +1,38 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { promisify } from "node:util";
|
|
7
8
|
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
9
|
+
import { ZipFile } from "yazl";
|
|
8
10
|
import { atomicWriteFile, withFileLock } from "./storage-utils.js";
|
|
9
11
|
|
|
10
12
|
const execFileAsync = promisify(execFile);
|
|
11
13
|
const SERVER_NAME = "tdoms";
|
|
14
|
+
const SKILL_NAME = "tdoms-mcp-routing";
|
|
12
15
|
const DEFAULT_SERVER_ENTRY = fileURLToPath(new URL("./index.js", import.meta.url));
|
|
16
|
+
const DEFAULT_SKILL_ROOT = fileURLToPath(new URL(`../skills/${SKILL_NAME}`, import.meta.url));
|
|
13
17
|
|
|
14
18
|
export class ClientManager {
|
|
15
19
|
constructor({
|
|
16
20
|
env = process.env,
|
|
17
21
|
platform = process.platform,
|
|
18
22
|
homedir = os.homedir(),
|
|
23
|
+
workspace = process.cwd(),
|
|
19
24
|
nodeCommand = process.execPath,
|
|
20
25
|
serverEntry = DEFAULT_SERVER_ENTRY,
|
|
26
|
+
skillRoot = DEFAULT_SKILL_ROOT,
|
|
21
27
|
runner = runExecutable
|
|
22
28
|
} = {}) {
|
|
23
29
|
this.env = env;
|
|
24
30
|
this.platform = platform;
|
|
25
31
|
this.homedir = homedir;
|
|
32
|
+
this.workspace = path.resolve(workspace);
|
|
26
33
|
this.nodeCommand = nodeCommand;
|
|
27
34
|
this.serverEntry = serverEntry;
|
|
35
|
+
this.skillRoot = skillRoot;
|
|
28
36
|
this.runner = runner;
|
|
29
37
|
this.runtime = describeRuntime(platform, env);
|
|
30
38
|
}
|
|
@@ -36,6 +44,7 @@ export class ClientManager {
|
|
|
36
44
|
const detectedByConfig = await anyPathExists(definition.configPaths);
|
|
37
45
|
const configured = await isConfigured(definition);
|
|
38
46
|
const detected = Boolean(executable || detectedByConfig || await anyPathExists(definition.appCandidates));
|
|
47
|
+
const skill = await inspectSkill(definition.skill, this.skillRoot);
|
|
39
48
|
return {
|
|
40
49
|
id: definition.id,
|
|
41
50
|
name: definition.name,
|
|
@@ -48,11 +57,91 @@ export class ClientManager {
|
|
|
48
57
|
strategy: definition.strategy,
|
|
49
58
|
environment: this.runtime,
|
|
50
59
|
restartRequired: definition.restartRequired,
|
|
51
|
-
status: configured ? "configured" : detected ? "detected" : "not-detected"
|
|
60
|
+
status: configured ? "configured" : detected ? "detected" : "not-detected",
|
|
61
|
+
skill
|
|
52
62
|
};
|
|
53
63
|
}));
|
|
54
64
|
}
|
|
55
65
|
|
|
66
|
+
async installSkill(id, { confirm = false, scope = "user" } = {}) {
|
|
67
|
+
if (confirm !== true) {
|
|
68
|
+
throw new Error("Refusing to install an agent skill without confirm: true.");
|
|
69
|
+
}
|
|
70
|
+
if (!["user", "workspace"].includes(scope)) {
|
|
71
|
+
throw new Error("Skill scope must be either user or workspace.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const definition = (await this.#definitions()).find((item) => item.id === id);
|
|
75
|
+
if (!definition) throw new Error(`Unsupported MCP client: ${id}`);
|
|
76
|
+
if (!definition.skill.supported) {
|
|
77
|
+
throw new Error(`${definition.name} does not have a verified filesystem skill installer. ${definition.skill.guidance}`.trim());
|
|
78
|
+
}
|
|
79
|
+
const detected = Boolean(
|
|
80
|
+
await firstExistingFile(definition.executableCandidates)
|
|
81
|
+
|| await anyPathExists(definition.configPaths)
|
|
82
|
+
|| await anyPathExists(definition.appCandidates)
|
|
83
|
+
);
|
|
84
|
+
if (!detected) throw new Error(`${definition.name} is not installed in ${this.runtime.label}.`);
|
|
85
|
+
|
|
86
|
+
const targets = skillTargets(definition.skill, scope);
|
|
87
|
+
const results = [];
|
|
88
|
+
for (const target of targets) {
|
|
89
|
+
results.push({
|
|
90
|
+
...target,
|
|
91
|
+
...await installSkillDirectory(this.skillRoot, target.path, this.platform)
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
clientId: definition.id,
|
|
97
|
+
clientName: definition.name,
|
|
98
|
+
scope,
|
|
99
|
+
skillName: SKILL_NAME,
|
|
100
|
+
skillPath: results[0].path,
|
|
101
|
+
skillPaths: results.map((result) => result.path),
|
|
102
|
+
backupPath: results[0].backupPath,
|
|
103
|
+
backupPaths: results.map((result) => result.backupPath).filter(Boolean),
|
|
104
|
+
targets: results,
|
|
105
|
+
installed: true,
|
|
106
|
+
current: true,
|
|
107
|
+
restartRequired: definition.skill.restartRequired
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async createSkillArchive() {
|
|
112
|
+
if (!await pathExists(path.join(this.skillRoot, "SKILL.md"))) {
|
|
113
|
+
throw new Error(`Bundled skill is incomplete: ${this.skillRoot}`);
|
|
114
|
+
}
|
|
115
|
+
return createZipArchive(this.skillRoot);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async installDetectedSkills({ confirm = false, scope = "user" } = {}) {
|
|
119
|
+
if (confirm !== true) {
|
|
120
|
+
throw new Error("Refusing to install agent skills without confirm: true.");
|
|
121
|
+
}
|
|
122
|
+
if (!["user", "workspace"].includes(scope)) {
|
|
123
|
+
throw new Error("Skill scope must be either user or workspace.");
|
|
124
|
+
}
|
|
125
|
+
const clients = await this.listClients();
|
|
126
|
+
const targets = clients.filter((client) => client.detected && client.skill.supported && !client.skill[scope].current);
|
|
127
|
+
const results = [];
|
|
128
|
+
for (const client of targets) {
|
|
129
|
+
try {
|
|
130
|
+
results.push(await this.installSkill(client.id, { confirm: true, scope }));
|
|
131
|
+
} catch (error) {
|
|
132
|
+
results.push({ ok: false, clientId: client.id, clientName: client.name, error: error.message });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
ok: results.every((result) => result.ok),
|
|
137
|
+
environment: this.runtime,
|
|
138
|
+
scope,
|
|
139
|
+
installed: results.filter((result) => result.ok).length,
|
|
140
|
+
failed: results.filter((result) => !result.ok).length,
|
|
141
|
+
results
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
56
145
|
async installDetected({ confirm = false } = {}) {
|
|
57
146
|
if (confirm !== true) {
|
|
58
147
|
throw new Error("Refusing to modify MCP client configurations without confirm: true.");
|
|
@@ -157,6 +246,7 @@ export class ClientManager {
|
|
|
157
246
|
const claudeDesktopCodeVersions = await extensionDirectories(paths.claudeDesktopCodeRoot);
|
|
158
247
|
const codexExtension = newestMatching(vscodeExtensions, "openai.chatgpt-");
|
|
159
248
|
const claudeExtension = newestMatching(vscodeExtensions, "anthropic.claude-code-");
|
|
249
|
+
const copilotExtension = newestMatching(vscodeExtensions, "github.copilot-");
|
|
160
250
|
const claudeStorePackage = newestMatching(windowsPackages, "Claude_");
|
|
161
251
|
const claudeDesktopCodeVersion = newestMatching(claudeDesktopCodeVersions, "");
|
|
162
252
|
const claudeDesktopConfig = claudeStorePackage
|
|
@@ -164,7 +254,7 @@ export class ClientManager {
|
|
|
164
254
|
: paths.claudeDesktopConfig;
|
|
165
255
|
const commandCandidates = async (name, fixed = []) => compact([...fixed, await findOnPath(name, this.platform, this.env)]);
|
|
166
256
|
|
|
167
|
-
|
|
257
|
+
const definitions = [
|
|
168
258
|
{
|
|
169
259
|
id: "ibm-bob",
|
|
170
260
|
name: "IBM Bob",
|
|
@@ -313,6 +403,261 @@ export class ClientManager {
|
|
|
313
403
|
restartRequired: true
|
|
314
404
|
}
|
|
315
405
|
];
|
|
406
|
+
return definitions.map((definition) => ({
|
|
407
|
+
...definition,
|
|
408
|
+
skill: skillDefinition(definition.id, this.homedir, this.workspace, {
|
|
409
|
+
hasVsCodeCodex: Boolean(codexExtension),
|
|
410
|
+
hasVsCodeClaude: Boolean(claudeExtension),
|
|
411
|
+
hasVsCodeCopilot: Boolean(copilotExtension)
|
|
412
|
+
})
|
|
413
|
+
}));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function skillDefinition(clientId, home, workspace, {
|
|
418
|
+
hasVsCodeCodex = false,
|
|
419
|
+
hasVsCodeClaude = false,
|
|
420
|
+
hasVsCodeCopilot = false
|
|
421
|
+
} = {}) {
|
|
422
|
+
const definitions = {
|
|
423
|
+
"ibm-bob": {
|
|
424
|
+
userPath: path.join(home, ".bob", "skills", SKILL_NAME),
|
|
425
|
+
workspacePath: path.join(workspace, ".bob", "skills", SKILL_NAME),
|
|
426
|
+
restartRequired: false,
|
|
427
|
+
guidance: "IBM Bob must be in Advanced mode before the skill appears."
|
|
428
|
+
},
|
|
429
|
+
codex: {
|
|
430
|
+
userPath: path.join(home, ".agents", "skills", SKILL_NAME),
|
|
431
|
+
workspacePath: path.join(workspace, ".agents", "skills", SKILL_NAME),
|
|
432
|
+
restartRequired: true,
|
|
433
|
+
guidance: "Start a new Codex session after installation."
|
|
434
|
+
},
|
|
435
|
+
"claude-code": {
|
|
436
|
+
userPath: path.join(home, ".claude", "skills", SKILL_NAME),
|
|
437
|
+
workspacePath: path.join(workspace, ".claude", "skills", SKILL_NAME),
|
|
438
|
+
restartRequired: true,
|
|
439
|
+
guidance: "Start a new Claude Code session after installation."
|
|
440
|
+
},
|
|
441
|
+
vscode: {
|
|
442
|
+
userPath: path.join(home, ".copilot", "skills", SKILL_NAME),
|
|
443
|
+
workspacePath: path.join(workspace, ".github", "skills", SKILL_NAME),
|
|
444
|
+
restartRequired: true,
|
|
445
|
+
guidance: "Reload the VS Code window or start a new chat after installation."
|
|
446
|
+
},
|
|
447
|
+
"gemini-cli": {
|
|
448
|
+
userPath: path.join(home, ".gemini", "skills", SKILL_NAME),
|
|
449
|
+
workspacePath: path.join(workspace, ".gemini", "skills", SKILL_NAME),
|
|
450
|
+
restartRequired: false,
|
|
451
|
+
guidance: "Run /skills reload in an existing Gemini CLI session."
|
|
452
|
+
},
|
|
453
|
+
kiro: {
|
|
454
|
+
userPath: path.join(home, ".kiro", "skills", SKILL_NAME),
|
|
455
|
+
workspacePath: path.join(workspace, ".kiro", "skills", SKILL_NAME),
|
|
456
|
+
restartRequired: false,
|
|
457
|
+
guidance: "Kiro detects skills in its Agent Steering & Skills panel and as slash commands."
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
if (clientId === "vscode") {
|
|
461
|
+
const userTargets = compact([
|
|
462
|
+
hasVsCodeCodex && { label: "Codex", path: path.join(home, ".agents", "skills", SKILL_NAME) },
|
|
463
|
+
hasVsCodeClaude && { label: "Claude Code", path: path.join(home, ".claude", "skills", SKILL_NAME) },
|
|
464
|
+
hasVsCodeCopilot && { label: "GitHub Copilot", path: path.join(home, ".copilot", "skills", SKILL_NAME) }
|
|
465
|
+
]);
|
|
466
|
+
const workspaceTargets = compact([
|
|
467
|
+
hasVsCodeCodex && { label: "Codex", path: path.join(workspace, ".agents", "skills", SKILL_NAME) },
|
|
468
|
+
hasVsCodeClaude && { label: "Claude Code", path: path.join(workspace, ".claude", "skills", SKILL_NAME) },
|
|
469
|
+
hasVsCodeCopilot && { label: "GitHub Copilot", path: path.join(workspace, ".github", "skills", SKILL_NAME) }
|
|
470
|
+
]);
|
|
471
|
+
if (!userTargets.length) {
|
|
472
|
+
return {
|
|
473
|
+
supported: false,
|
|
474
|
+
guidance: "No compatible Codex, Claude Code, or GitHub Copilot extension was detected in VS Code."
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
supported: true,
|
|
479
|
+
userTargets,
|
|
480
|
+
workspaceTargets,
|
|
481
|
+
restartRequired: true,
|
|
482
|
+
guidance: `Installs for detected VS Code agents: ${userTargets.map((target) => target.label).join(", ")}.`
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
if (definitions[clientId]) return { supported: true, ...definitions[clientId] };
|
|
486
|
+
if (clientId === "claude-desktop") {
|
|
487
|
+
return {
|
|
488
|
+
supported: false,
|
|
489
|
+
downloadable: true,
|
|
490
|
+
guidance: "Download the ZIP, then use Customize > Skills > Create skill > Upload a skill in Claude Desktop."
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
return { supported: false, guidance: "No verified native Agent Skills directory is available for this client." };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function inspectSkill(definition, sourceRoot) {
|
|
497
|
+
if (!definition.supported) {
|
|
498
|
+
return { supported: false, downloadable: definition.downloadable === true, guidance: definition.guidance };
|
|
499
|
+
}
|
|
500
|
+
const inspect = async (target) => {
|
|
501
|
+
const installed = await pathExists(path.join(target.path, "SKILL.md"));
|
|
502
|
+
return {
|
|
503
|
+
...target,
|
|
504
|
+
installed,
|
|
505
|
+
current: installed && await skillFilesMatch(sourceRoot, target.path)
|
|
506
|
+
};
|
|
507
|
+
};
|
|
508
|
+
const inspectScope = async (scope) => {
|
|
509
|
+
const targets = await Promise.all(skillTargets(definition, scope).map(inspect));
|
|
510
|
+
return {
|
|
511
|
+
path: targets[0]?.path,
|
|
512
|
+
paths: targets.map((target) => target.path),
|
|
513
|
+
installed: targets.length > 0 && targets.every((target) => target.installed),
|
|
514
|
+
installedAny: targets.some((target) => target.installed),
|
|
515
|
+
current: targets.length > 0 && targets.every((target) => target.current),
|
|
516
|
+
targets
|
|
517
|
+
};
|
|
518
|
+
};
|
|
519
|
+
return {
|
|
520
|
+
supported: true,
|
|
521
|
+
guidance: definition.guidance,
|
|
522
|
+
restartRequired: definition.restartRequired,
|
|
523
|
+
user: await inspectScope("user"),
|
|
524
|
+
workspace: await inspectScope("workspace")
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function skillTargets(definition, scope) {
|
|
529
|
+
const configured = definition[`${scope}Targets`];
|
|
530
|
+
if (configured?.length) {
|
|
531
|
+
const seen = new Set();
|
|
532
|
+
return configured.filter((target) => {
|
|
533
|
+
const normalized = path.resolve(target.path).toLowerCase();
|
|
534
|
+
if (seen.has(normalized)) return false;
|
|
535
|
+
seen.add(normalized);
|
|
536
|
+
return true;
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const skillPath = definition[`${scope}Path`];
|
|
540
|
+
return skillPath ? [{ label: definition.label || "Agent", path: skillPath }] : [];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function installSkillDirectory(source, destination, platform) {
|
|
544
|
+
if (!await pathExists(path.join(source, "SKILL.md"))) {
|
|
545
|
+
throw new Error(`Bundled skill is incomplete: ${source}`);
|
|
546
|
+
}
|
|
547
|
+
if (path.basename(destination) !== SKILL_NAME) {
|
|
548
|
+
throw new Error(`Refusing to write an unexpected skill destination: ${destination}`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const parent = path.dirname(destination);
|
|
552
|
+
await fs.mkdir(parent, { recursive: true });
|
|
553
|
+
return withFileLock(path.join(parent, `.${SKILL_NAME}.install`), async () => {
|
|
554
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
555
|
+
const staged = path.join(parent, `.${SKILL_NAME}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
556
|
+
const rollback = path.join(parent, `.${SKILL_NAME}.${process.pid}.${crypto.randomUUID()}.rollback`);
|
|
557
|
+
const existed = await pathExists(destination);
|
|
558
|
+
const backupPath = existed ? `${destination}.tdoms-backup-${stamp}` : undefined;
|
|
559
|
+
if (platform === "win32") {
|
|
560
|
+
if (backupPath) await fs.cp(destination, backupPath, { recursive: true, errorOnExist: true });
|
|
561
|
+
try {
|
|
562
|
+
await fs.cp(source, destination, { recursive: true, force: true });
|
|
563
|
+
if (!await skillFilesMatch(source, destination)) {
|
|
564
|
+
throw new Error("The copied skill did not pass verification.");
|
|
565
|
+
}
|
|
566
|
+
return { backupPath };
|
|
567
|
+
} catch (error) {
|
|
568
|
+
if (backupPath) {
|
|
569
|
+
try {
|
|
570
|
+
await fs.cp(backupPath, destination, { recursive: true, force: true });
|
|
571
|
+
} catch (restoreError) {
|
|
572
|
+
throw new Error(`${error.message} The backup remains at ${backupPath} because automatic restoration failed: ${restoreError.message}`);
|
|
573
|
+
}
|
|
574
|
+
} else {
|
|
575
|
+
await fs.rm(destination, { recursive: true, force: true }).catch(() => {});
|
|
576
|
+
}
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
try {
|
|
582
|
+
await fs.cp(source, staged, { recursive: true, errorOnExist: true });
|
|
583
|
+
if (backupPath) await fs.cp(destination, backupPath, { recursive: true, errorOnExist: true });
|
|
584
|
+
if (existed) await renameDirectory(destination, rollback);
|
|
585
|
+
await renameDirectory(staged, destination);
|
|
586
|
+
await fs.rm(rollback, { recursive: true, force: true });
|
|
587
|
+
return { backupPath };
|
|
588
|
+
} catch (error) {
|
|
589
|
+
await fs.rm(staged, { recursive: true, force: true }).catch(() => {});
|
|
590
|
+
if (await pathExists(rollback) && !await pathExists(destination)) {
|
|
591
|
+
try {
|
|
592
|
+
await renameDirectory(rollback, destination);
|
|
593
|
+
} catch (restoreError) {
|
|
594
|
+
throw new Error(`${error.message} The previous skill remains at ${rollback} because automatic restoration failed: ${restoreError.message}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
throw error;
|
|
598
|
+
}
|
|
599
|
+
}, { privateDirectory: false });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async function skillFilesMatch(source, destination) {
|
|
603
|
+
async function compare(sourceDirectory, destinationDirectory) {
|
|
604
|
+
const entries = await fs.readdir(sourceDirectory, { withFileTypes: true });
|
|
605
|
+
for (const entry of entries) {
|
|
606
|
+
const sourcePath = path.join(sourceDirectory, entry.name);
|
|
607
|
+
const destinationPath = path.join(destinationDirectory, entry.name);
|
|
608
|
+
if (entry.isDirectory()) {
|
|
609
|
+
if (!await pathExists(destinationPath) || !await compare(sourcePath, destinationPath)) return false;
|
|
610
|
+
} else if (entry.isFile()) {
|
|
611
|
+
try {
|
|
612
|
+
const [sourceContent, destinationContent] = await Promise.all([
|
|
613
|
+
fs.readFile(sourcePath),
|
|
614
|
+
fs.readFile(destinationPath)
|
|
615
|
+
]);
|
|
616
|
+
if (!sourceContent.equals(destinationContent)) return false;
|
|
617
|
+
} catch {
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
} else {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
return compare(source, destination).catch(() => false);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function createZipArchive(source) {
|
|
630
|
+
const zip = new ZipFile();
|
|
631
|
+
async function addDirectory(directory, relative = "") {
|
|
632
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
633
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
634
|
+
for (const entry of entries) {
|
|
635
|
+
const relativePath = path.posix.join(relative, entry.name);
|
|
636
|
+
const absolutePath = path.join(directory, entry.name);
|
|
637
|
+
if (entry.isDirectory()) await addDirectory(absolutePath, relativePath);
|
|
638
|
+
else if (entry.isFile()) zip.addFile(absolutePath, path.posix.join(SKILL_NAME, relativePath));
|
|
639
|
+
else throw new Error(`Unsupported entry in skill directory: ${absolutePath}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
await addDirectory(source);
|
|
643
|
+
return new Promise((resolve, reject) => {
|
|
644
|
+
const chunks = [];
|
|
645
|
+
zip.outputStream.on("data", (chunk) => chunks.push(chunk));
|
|
646
|
+
zip.outputStream.on("error", reject);
|
|
647
|
+
zip.outputStream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
648
|
+
zip.end();
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function renameDirectory(source, destination) {
|
|
653
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
654
|
+
try {
|
|
655
|
+
await fs.rename(source, destination);
|
|
656
|
+
return;
|
|
657
|
+
} catch (error) {
|
|
658
|
+
if (!["EACCES", "EBUSY", "EPERM"].includes(error.code) || attempt === 19) throw error;
|
|
659
|
+
await new Promise((resolve) => setTimeout(resolve, 10 + attempt * 5));
|
|
660
|
+
}
|
|
316
661
|
}
|
|
317
662
|
}
|
|
318
663
|
|
package/src/index.js
CHANGED
|
@@ -53,6 +53,15 @@ async function main() {
|
|
|
53
53
|
return;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
if (command === "skills") {
|
|
57
|
+
const clients = await clientManager.listClients();
|
|
58
|
+
console.log(JSON.stringify({
|
|
59
|
+
environment: clients[0]?.environment,
|
|
60
|
+
skills: clients.map(({ id, name, detected, skill }) => ({ id, name, detected, skill }))
|
|
61
|
+
}, null, 2));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
if (command === "install-client") {
|
|
57
66
|
const clientId = process.argv[3];
|
|
58
67
|
if (!clientId) throw new Error("Client id is required. Run `tdoms-mcp clients` to list detected clients.");
|
|
@@ -68,6 +77,27 @@ async function main() {
|
|
|
68
77
|
return;
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
if (command === "install-skill") {
|
|
81
|
+
const clientId = process.argv[3];
|
|
82
|
+
if (!clientId) throw new Error("Client id is required. Run `tdoms-mcp skills` to list skill targets.");
|
|
83
|
+
const result = await clientManager.installSkill(clientId, {
|
|
84
|
+
confirm: process.argv.includes("--confirm"),
|
|
85
|
+
scope: flagValue("--scope") || "user"
|
|
86
|
+
});
|
|
87
|
+
console.log(JSON.stringify(result, null, 2));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (command === "install-detected-skills") {
|
|
92
|
+
const result = await clientManager.installDetectedSkills({
|
|
93
|
+
confirm: process.argv.includes("--confirm"),
|
|
94
|
+
scope: flagValue("--scope") || "user"
|
|
95
|
+
});
|
|
96
|
+
console.log(JSON.stringify(result, null, 2));
|
|
97
|
+
if (!result.ok) process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
71
101
|
if (command === "mcp") {
|
|
72
102
|
await startMcpServer({ store, profile });
|
|
73
103
|
return;
|
|
@@ -84,14 +114,22 @@ function printHelp(write = console.log) {
|
|
|
84
114
|
Usage:
|
|
85
115
|
tdoms-mcp ui
|
|
86
116
|
tdoms-mcp clients
|
|
117
|
+
tdoms-mcp skills
|
|
87
118
|
tdoms-mcp install-client <id> --confirm
|
|
88
119
|
tdoms-mcp install-detected --confirm
|
|
120
|
+
tdoms-mcp install-skill <id> [--scope user|workspace] --confirm
|
|
121
|
+
tdoms-mcp install-detected-skills [--scope user|workspace] --confirm
|
|
89
122
|
tdoms-mcp mcp [--profile core|full]
|
|
90
123
|
tdoms-mcp smoke
|
|
91
124
|
tdoms-mcp secure-storage
|
|
92
125
|
tdoms-mcp --help`);
|
|
93
126
|
}
|
|
94
127
|
|
|
128
|
+
function flagValue(name) {
|
|
129
|
+
const index = process.argv.indexOf(name);
|
|
130
|
+
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
95
133
|
main().catch((error) => {
|
|
96
134
|
console.error(error);
|
|
97
135
|
process.exit(1);
|
package/src/storage-utils.js
CHANGED
|
@@ -39,12 +39,14 @@ export async function atomicWriteFile(filePath, data, { mode = 0o600 } = {}) {
|
|
|
39
39
|
export async function withFileLock(filePath, operation, {
|
|
40
40
|
timeoutMs = 10000,
|
|
41
41
|
staleMs = 30000,
|
|
42
|
-
retryMs = 20
|
|
42
|
+
retryMs = 20,
|
|
43
|
+
privateDirectory = true
|
|
43
44
|
} = {}) {
|
|
44
45
|
const lockPath = `${filePath}.lock`;
|
|
45
46
|
const token = `${process.pid}:${crypto.randomUUID()}`;
|
|
46
47
|
const startedAt = Date.now();
|
|
47
|
-
await ensurePrivateDirectory(path.dirname(filePath));
|
|
48
|
+
if (privateDirectory) await ensurePrivateDirectory(path.dirname(filePath));
|
|
49
|
+
else await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
48
50
|
|
|
49
51
|
while (true) {
|
|
50
52
|
try {
|
package/src/ui-page.js
CHANGED
|
@@ -68,7 +68,7 @@ export function renderPage() {
|
|
|
68
68
|
.details-fields { display: grid; gap: 12px; margin-top: 12px; }
|
|
69
69
|
details:not([open]) .details-fields { display: none; }
|
|
70
70
|
.form-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; }
|
|
71
|
-
.button { min-height: 36px; border: 1px solid #1f6d59; border-radius: 6px; padding: 7px 12px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; background: #2f7d67; color: #fff; font-size: 12px; font-weight: 700; white-space: nowrap; }
|
|
71
|
+
.button { min-height: 36px; border: 1px solid #1f6d59; border-radius: 6px; padding: 7px 12px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; background: #2f7d67; color: #fff; font-size: 12px; font-weight: 700; white-space: nowrap; text-decoration: none; }
|
|
72
72
|
.button:hover { background: #276b58; }
|
|
73
73
|
.button.secondary { background: #fff; border-color: #bdc8d0; color: #283642; }
|
|
74
74
|
.button.secondary:hover { background: #f5f7f8; }
|
|
@@ -94,6 +94,12 @@ export function renderPage() {
|
|
|
94
94
|
.status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
95
95
|
.clients-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 14px; margin-bottom: 14px; }
|
|
96
96
|
.clients-toolbar-actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; flex-wrap: wrap; }
|
|
97
|
+
.segmented { display: inline-flex; border: 1px solid #bdc8d0; border-radius: 6px; overflow: hidden; background: #fff; }
|
|
98
|
+
.segmented button { min-height: 34px; border: 0; border-right: 1px solid #d5dde3; padding: 6px 10px; background: #fff; color: #53616d; font-size: 11px; font-weight: 700; }
|
|
99
|
+
.segmented button:last-child { border-right: 0; }
|
|
100
|
+
.segmented button.active { background: #eaf3f0; color: #215f50; }
|
|
101
|
+
.path-stack { display: grid; gap: 3px; }
|
|
102
|
+
.path-label { display: inline-block; width: 38px; color: #52616d; font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-weight: 700; }
|
|
97
103
|
.section-heading { font-size: 15px; font-weight: 750; }
|
|
98
104
|
.environment-label { margin-top: 3px; color: #687582; font-size: 11px; }
|
|
99
105
|
.filter { display: flex; align-items: center; gap: 7px; color: #66737f; font-size: 12px; }
|
|
@@ -198,6 +204,11 @@ export function renderPage() {
|
|
|
198
204
|
<div><div class="section-heading">MCP clients</div><div class="environment-label" id="environment-label">Detecting environment</div></div>
|
|
199
205
|
<div class="clients-toolbar-actions">
|
|
200
206
|
<label class="filter"><input type="checkbox" id="show-all-clients">Show products not detected</label>
|
|
207
|
+
<div class="segmented" aria-label="Skill installation scope">
|
|
208
|
+
<button type="button" class="active" data-skill-scope="user">User skill</button>
|
|
209
|
+
<button type="button" data-skill-scope="workspace">Workspace skill</button>
|
|
210
|
+
</div>
|
|
211
|
+
<button class="button secondary" id="install-detected-skills"><img src="/icons/book-open.svg" alt="">Install skills</button>
|
|
201
212
|
<button class="button" id="install-detected"><img src="/icons/plug-zap.svg" alt="">Configure detected</button>
|
|
202
213
|
</div>
|
|
203
214
|
</div>
|
|
@@ -217,7 +228,7 @@ export function renderPage() {
|
|
|
217
228
|
</dialog>
|
|
218
229
|
|
|
219
230
|
<script>
|
|
220
|
-
const state = { connections: [], clients: [], pendingAction: null };
|
|
231
|
+
const state = { connections: [], clients: [], pendingAction: null, skillScope: "user" };
|
|
221
232
|
const form = document.querySelector("#connection-form");
|
|
222
233
|
const connectionList = document.querySelector("#connections-list");
|
|
223
234
|
const clientList = document.querySelector("#clients-list");
|
|
@@ -271,18 +282,29 @@ export function renderPage() {
|
|
|
271
282
|
const clients = state.clients.filter(function (item) { return showAll || item.detected; });
|
|
272
283
|
const detected = state.clients.filter(function (item) { return item.detected; }).length;
|
|
273
284
|
const pending = state.clients.filter(function (item) { return item.detected && item.installable && !item.configured; }).length;
|
|
285
|
+
const pendingSkills = state.clients.filter(function (item) { return item.detected && item.skill && item.skill.supported && !item.skill[state.skillScope].current; }).length;
|
|
274
286
|
const environment = state.clients[0] && state.clients[0].environment;
|
|
275
287
|
document.querySelector("#environment-label").textContent = environment ? environment.label + " | " + environment.note : "Current environment";
|
|
276
288
|
document.querySelector("#install-detected").disabled = pending === 0;
|
|
289
|
+
document.querySelector("#install-detected-skills").disabled = pendingSkills === 0;
|
|
277
290
|
document.querySelector("#client-count").textContent = detected + " detected";
|
|
278
291
|
if (!clients.length) {
|
|
279
292
|
clientList.innerHTML = '<div class="empty">No supported MCP clients detected</div>';
|
|
280
293
|
return;
|
|
281
294
|
}
|
|
282
295
|
clientList.innerHTML = clients.map(function (item) {
|
|
283
|
-
const
|
|
284
|
-
const
|
|
285
|
-
|
|
296
|
+
const mcpAction = item.configured ? "Update MCP" : "Install MCP";
|
|
297
|
+
const mcpButton = item.installable ? '<button class="button secondary" data-install="' + escapeHtml(item.id) + '"><img src="/icons/plug-zap.svg" alt="">' + mcpAction + '</button>' : '';
|
|
298
|
+
const skill = item.skill || { supported: false };
|
|
299
|
+
const scopedSkill = skill.supported ? skill[state.skillScope] : null;
|
|
300
|
+
const skillAction = scopedSkill && scopedSkill.installed ? "Update skill" : "Install skill";
|
|
301
|
+
const skillButton = skill.supported && item.detected ? '<button class="button secondary" data-install-skill="' + escapeHtml(item.id) + '" title="' + escapeHtml(skill.guidance) + '"><img src="/icons/book-open.svg" alt="">' + skillAction + '</button>' : '';
|
|
302
|
+
const skillDownload = skill.downloadable && item.detected ? '<button class="button secondary" data-install-upload-skill="' + escapeHtml(item.id) + '" title="' + escapeHtml(skill.guidance) + '"><img src="/icons/download.svg" alt="">Install skill</button>' : '';
|
|
303
|
+
const unavailableStatus = !skill.supported && !skill.downloadable && item.detected ? '<span class="status not-detected" title="' + escapeHtml(skill.guidance) + '">skill unavailable</span>' : '';
|
|
304
|
+
const uploadStatus = skill.downloadable ? '<span class="status detected">upload required</span>' : '';
|
|
305
|
+
const skillStatus = scopedSkill ? '<span class="status ' + (scopedSkill.current ? 'configured' : 'detected') + '">' + (scopedSkill.current ? 'skill current' : scopedSkill.installedAny ? 'skill update' : 'skill available') + '</span>' : uploadStatus || unavailableStatus;
|
|
306
|
+
const skillPath = scopedSkill ? scopedSkill.targets.map(function (target) { return '<div><span class="path-label">Skill</span>' + escapeHtml(target.label) + ': ' + escapeHtml(target.path) + '</div>'; }).join('') : '';
|
|
307
|
+
return '<div class="client-row"><div class="row-main"><div class="row-identity"><div class="row-icon"><img src="/icons/terminal.svg" alt=""></div><div><div class="row-name">' + escapeHtml(item.name) + '</div><div class="row-description">' + escapeHtml(item.description) + '</div></div></div><div class="row-actions"><span class="status ' + escapeHtml(item.status) + '">' + escapeHtml(item.status.replace("-", " ")) + '</span>' + skillStatus + skillButton + skillDownload + mcpButton + '</div></div><div class="row-path path-stack"><div><span class="path-label">MCP</span>' + escapeHtml(item.configPath) + '</div>' + skillPath + '</div></div>';
|
|
286
308
|
}).join("");
|
|
287
309
|
}
|
|
288
310
|
|
|
@@ -375,9 +397,21 @@ export function renderPage() {
|
|
|
375
397
|
|
|
376
398
|
clientList.addEventListener("click", function (event) {
|
|
377
399
|
const install = event.target.closest("[data-install]");
|
|
378
|
-
|
|
379
|
-
const
|
|
380
|
-
|
|
400
|
+
const installSkill = event.target.closest("[data-install-skill]");
|
|
401
|
+
const installUploadSkill = event.target.closest("[data-install-upload-skill]");
|
|
402
|
+
if (install) {
|
|
403
|
+
const item = state.clients.find(function (entry) { return entry.id === install.dataset.install; });
|
|
404
|
+
openConfirmation({ title: item.configured ? "Update MCP client" : "Install MCP client", message: "A backup is created before the client configuration changes.", target: item.name, action: async function () { notify("Configuring " + item.name); const result = await request("/api/clients/" + encodeURIComponent(item.id) + "/install", { method: "POST", body: JSON.stringify({ confirm: true }) }); notify(result.restartRequired ? item.name + " configured. Restart the client." : item.name + " configured."); await refresh(); } });
|
|
405
|
+
}
|
|
406
|
+
if (installSkill) {
|
|
407
|
+
const item = state.clients.find(function (entry) { return entry.id === installSkill.dataset.installSkill; });
|
|
408
|
+
const scopedSkill = item.skill[state.skillScope];
|
|
409
|
+
openConfirmation({ title: scopedSkill.installed ? "Update agent skill" : "Install agent skill", message: "The complete TD/OMS workflow skill is installed for every compatible detected agent in this product. Existing skill files are backed up first.", target: item.name + " | " + state.skillScope + " | " + scopedSkill.targets.map(function (target) { return target.label + ": " + target.path; }).join(" | "), action: async function () { notify("Installing skill for " + item.name); const result = await request("/api/clients/" + encodeURIComponent(item.id) + "/install-skill", { method: "POST", body: JSON.stringify({ confirm: true, scope: state.skillScope }) }); notify(result.restartRequired ? item.name + " skill installed. Restart the agent session." : item.name + " skill installed."); await refresh(); } });
|
|
410
|
+
}
|
|
411
|
+
if (installUploadSkill) {
|
|
412
|
+
const item = state.clients.find(function (entry) { return entry.id === installUploadSkill.dataset.installUploadSkill; });
|
|
413
|
+
openConfirmation({ title: "Install skill in Claude Desktop", message: "Claude Desktop chat requires an account upload. Confirm to download the correctly packaged ZIP, then open Customize > Skills > Create skill > Upload a skill and select it.", target: "tdoms-mcp-routing.zip", action: async function () { const link = document.createElement("a"); link.href = "/api/skill/archive"; link.download = "tdoms-mcp-routing.zip"; document.body.appendChild(link); link.click(); link.remove(); notify("Skill ZIP downloaded. Upload it in Claude Desktop Customize > Skills."); } });
|
|
414
|
+
}
|
|
381
415
|
});
|
|
382
416
|
|
|
383
417
|
document.querySelectorAll(".tab").forEach(function (tab) {
|
|
@@ -389,10 +423,21 @@ export function renderPage() {
|
|
|
389
423
|
document.querySelector("#refresh").addEventListener("click", refresh);
|
|
390
424
|
document.querySelector("#clear-form").addEventListener("click", resetForm);
|
|
391
425
|
document.querySelector("#show-all-clients").addEventListener("change", renderClients);
|
|
426
|
+
document.querySelectorAll("[data-skill-scope]").forEach(function (button) {
|
|
427
|
+
button.addEventListener("click", function () {
|
|
428
|
+
state.skillScope = button.dataset.skillScope;
|
|
429
|
+
document.querySelectorAll("[data-skill-scope]").forEach(function (item) { item.classList.toggle("active", item === button); });
|
|
430
|
+
renderClients();
|
|
431
|
+
});
|
|
432
|
+
});
|
|
392
433
|
document.querySelector("#install-detected").addEventListener("click", function () {
|
|
393
434
|
const pending = state.clients.filter(function (item) { return item.detected && item.installable && !item.configured; });
|
|
394
435
|
openConfirmation({ title: "Configure detected agents", message: "This adds TD/OMS only to detected agents that are not configured yet. Every existing file is backed up first.", target: pending.map(function (item) { return item.name; }).join(", "), action: async function () { notify("Configuring detected agents"); const result = await request("/api/clients/install-detected", { method: "POST", body: JSON.stringify({ confirm: true }) }); notify(result.configured + " agents configured" + (result.failed ? ", " + result.failed + " failed" : ""), Boolean(result.failed)); await refresh(); } });
|
|
395
436
|
});
|
|
437
|
+
document.querySelector("#install-detected-skills").addEventListener("click", function () {
|
|
438
|
+
const pending = state.clients.filter(function (item) { return item.detected && item.skill && item.skill.supported && !item.skill[state.skillScope].current; });
|
|
439
|
+
openConfirmation({ title: "Install detected agent skills", message: "This installs or updates the bundled TD/OMS workflow skill for the selected scope. Existing skill folders are backed up first.", target: state.skillScope + " | " + pending.map(function (item) { return item.name; }).join(", "), action: async function () { notify("Installing detected agent skills"); const result = await request("/api/clients/install-detected-skills", { method: "POST", body: JSON.stringify({ confirm: true, scope: state.skillScope }) }); notify(result.installed + " skills installed" + (result.failed ? ", " + result.failed + " failed" : ""), Boolean(result.failed)); await refresh(); } });
|
|
440
|
+
});
|
|
396
441
|
document.querySelector("#dialog-close").addEventListener("click", closeDialog);
|
|
397
442
|
document.querySelector("#dialog-cancel").addEventListener("click", closeDialog);
|
|
398
443
|
document.querySelector("#dialog-confirm").addEventListener("click", async function () {
|
package/src/web-app.js
CHANGED
|
@@ -40,6 +40,20 @@ export function createWebApp({ store = new ConnectionStore(), clientManager = ne
|
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
app.get("/api/skill/archive", async (_req, res, next) => {
|
|
44
|
+
try {
|
|
45
|
+
const archive = await clientManager.createSkillArchive();
|
|
46
|
+
res.set({
|
|
47
|
+
"content-type": "application/zip",
|
|
48
|
+
"content-disposition": "attachment; filename=tdoms-mcp-routing.zip",
|
|
49
|
+
"content-length": archive.length
|
|
50
|
+
});
|
|
51
|
+
res.send(archive);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
next(error);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
43
57
|
app.post("/api/clients/:id/install", async (req, res, next) => {
|
|
44
58
|
try {
|
|
45
59
|
const result = await clientManager.installClient(req.params.id, { confirm: req.body?.confirm === true });
|
|
@@ -58,6 +72,30 @@ export function createWebApp({ store = new ConnectionStore(), clientManager = ne
|
|
|
58
72
|
}
|
|
59
73
|
});
|
|
60
74
|
|
|
75
|
+
app.post("/api/clients/:id/install-skill", async (req, res, next) => {
|
|
76
|
+
try {
|
|
77
|
+
const result = await clientManager.installSkill(req.params.id, {
|
|
78
|
+
confirm: req.body?.confirm === true,
|
|
79
|
+
scope: req.body?.scope || "user"
|
|
80
|
+
});
|
|
81
|
+
res.json(result);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
next(error);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
app.post("/api/clients/install-detected-skills", async (req, res, next) => {
|
|
88
|
+
try {
|
|
89
|
+
const result = await clientManager.installDetectedSkills({
|
|
90
|
+
confirm: req.body?.confirm === true,
|
|
91
|
+
scope: req.body?.scope || "user"
|
|
92
|
+
});
|
|
93
|
+
res.status(result.ok ? 200 : 207).json(result);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
next(error);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
61
99
|
app.post("/api/connections", async (req, res, next) => {
|
|
62
100
|
try {
|
|
63
101
|
const connection = await store.upsertConnection(req.body);
|