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 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
- Agents that support `SKILL.md` folders can register that directory using their normal skill installation mechanism. 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.
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.7.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": {
@@ -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
- return [
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);
@@ -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 action = item.configured ? "Update" : "Install";
284
- const button = item.installable ? '<button class="button secondary" data-install="' + escapeHtml(item.id) + '"><img src="/icons/plug-zap.svg" alt="">' + action + '</button>' : '';
285
- 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>' + button + '</div></div><div class="row-path">' + escapeHtml(item.configPath) + '</div></div>';
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
- if (!install) return;
379
- const item = state.clients.find(function (entry) { return entry.id === install.dataset.install; });
380
- 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(); } });
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);