@rubytech/taskmaster 1.20.2 → 1.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ import { PRELOADED_SKILL_AGENTS } from "./types.js";
2
+ const DEFAULT_AGENTS = ["admin", "public"];
3
+ /**
4
+ * Resolve which agents can access a skill.
5
+ * Preloaded skills use the hardcoded map (not overridable).
6
+ * User/licensed skills use config, defaulting to all agents.
7
+ */
8
+ export function resolveSkillAgents(skillName, isPreloaded, config) {
9
+ if (isPreloaded) {
10
+ return PRELOADED_SKILL_AGENTS[skillName] ?? DEFAULT_AGENTS;
11
+ }
12
+ const entry = config?.skills?.entries?.[skillName];
13
+ return entry?.agents ?? DEFAULT_AGENTS;
14
+ }
@@ -1,5 +1,5 @@
1
- import fsp from "node:fs/promises";
2
1
  import path from "node:path";
2
+ import fsp from "node:fs/promises";
3
3
  import { Type } from "@sinclair/typebox";
4
4
  import { jsonResult, readStringParam } from "./common.js";
5
5
  import { verifySkillPack, hasMarker, readMarker, writeMarker, } from "../../license/skill-pack.js";
@@ -16,6 +16,7 @@ export function createSkillPackInstallTool(opts) {
16
16
  name: "skill_pack_install",
17
17
  description: "Install a signed skill pack (.skillpack.json) onto this device. " +
18
18
  "Verifies the cryptographic signature and device binding before installing. " +
19
+ "Supports single skills and bundles (multiple skills in one pack). " +
19
20
  "Use this when a customer forwards a skill pack file to you.",
20
21
  parameters: SkillPackInstallSchema,
21
22
  execute: async (_toolCallId, args) => {
@@ -43,48 +44,65 @@ export function createSkillPackInstallTool(opts) {
43
44
  throw new Error("This skill pack is licensed for a different device. " +
44
45
  "It cannot be installed on this device.");
45
46
  }
46
- // Check for existing marketplace skill
47
- const skillDir = path.join(opts.workspaceDir, "skills", payload.pack.id);
48
- if (hasMarker(skillDir)) {
49
- const existing = await readMarker(skillDir);
50
- if (existing && existing.version === payload.pack.version) {
51
- return jsonResult({
52
- ok: true,
53
- alreadyInstalled: true,
54
- packId: payload.pack.id,
55
- version: payload.pack.version,
56
- message: `${payload.pack.name} v${payload.pack.version} is already installed.`,
57
- });
58
- }
47
+ // Check if all skills in this pack version are already installed
48
+ const allAlreadyInstalled = await allSkillsInstalled(opts.workspaceDir, payload.content.skills.map((s) => s.id), payload.pack.id, payload.pack.version);
49
+ if (allAlreadyInstalled) {
50
+ return jsonResult({
51
+ ok: true,
52
+ alreadyInstalled: true,
53
+ packId: payload.pack.id,
54
+ version: payload.pack.version,
55
+ message: `${payload.pack.name} v${payload.pack.version} is already installed.`,
56
+ });
57
+ }
58
+ // Install each skill via skills.create gateway RPC
59
+ const installed = [];
60
+ for (const entry of payload.content.skills) {
61
+ const references = entry.references.map((r) => ({
62
+ name: r.name,
63
+ content: r.content,
64
+ }));
65
+ await callGatewayTool("skills.create", {}, {
66
+ name: entry.id,
67
+ skillContent: entry.skill,
68
+ ...(references.length > 0 ? { references } : {}),
69
+ });
70
+ // Write marker file for each skill
71
+ const skillDir = path.join(opts.workspaceDir, "skills", entry.id);
72
+ const marker = {
73
+ packId: payload.pack.id,
74
+ version: payload.pack.version,
75
+ author: payload.pack.author,
76
+ installedAt: new Date().toISOString(),
77
+ deviceId: payload.device.did,
78
+ customerId: payload.device.cid,
79
+ };
80
+ await writeMarker(skillDir, marker);
81
+ installed.push(entry.id);
59
82
  }
60
- // Install via skills.create gateway RPC
61
- const references = payload.content.references.map((r) => ({
62
- name: r.name,
63
- content: r.content,
64
- }));
65
- await callGatewayTool("skills.create", {}, {
66
- name: payload.pack.id,
67
- skillContent: payload.content.skill,
68
- ...(references.length > 0 ? { references } : {}),
69
- });
70
- // Write marker file
71
- const marker = {
72
- packId: payload.pack.id,
73
- version: payload.pack.version,
74
- author: payload.pack.author,
75
- installedAt: new Date().toISOString(),
76
- deviceId: payload.device.did,
77
- customerId: payload.device.cid,
78
- };
79
- await writeMarker(skillDir, marker);
80
83
  return jsonResult({
81
84
  ok: true,
82
85
  packId: payload.pack.id,
83
86
  version: payload.pack.version,
84
87
  name: payload.pack.name,
85
- message: `Successfully installed ${payload.pack.name} v${payload.pack.version}. ` +
86
- `This skill is now available for use.`,
88
+ skillCount: installed.length,
89
+ skills: installed,
90
+ message: `Successfully installed ${payload.pack.name} v${payload.pack.version} ` +
91
+ `with ${installed.length} skill(s): ${installed.join(", ")}. ` +
92
+ `${installed.length === 1 ? "This skill is" : "These skills are"} now available for use.`,
87
93
  });
88
94
  },
89
95
  };
90
96
  }
97
+ /** Check whether all skills from a pack are already installed at the given version. */
98
+ async function allSkillsInstalled(workspaceDir, skillIds, packId, version) {
99
+ for (const id of skillIds) {
100
+ const skillDir = path.join(workspaceDir, "skills", id);
101
+ if (!hasMarker(skillDir))
102
+ return false;
103
+ const existing = await readMarker(skillDir);
104
+ if (!existing || existing.packId !== packId || existing.version !== version)
105
+ return false;
106
+ }
107
+ return true;
108
+ }
@@ -2,79 +2,178 @@ import fsp from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { Type } from "@sinclair/typebox";
4
4
  import { jsonResult, readStringParam } from "./common.js";
5
- import { signSkillPack } from "../../license/skill-pack.js";
5
+ import { signSkillPack, } from "../../license/skill-pack.js";
6
6
  import { parseFrontmatter } from "../skills/frontmatter.js";
7
7
  const SkillPackSignSchema = Type.Object({
8
- packId: Type.String({
9
- description: "Skill pack ID — must match a directory name in the skill-packs/ folder of this workspace.",
10
- }),
11
- deviceId: Type.String({
8
+ packId: Type.Optional(Type.String({
9
+ description: "Skill pack ID — must match a directory name in the skill-packs/ folder. " +
10
+ "Omit to list available packs.",
11
+ })),
12
+ deviceId: Type.Optional(Type.String({
12
13
  description: "Target device ID (tm_dev_...) from the customer's contact record.",
13
- }),
14
- customerId: Type.String({
14
+ })),
15
+ customerId: Type.Optional(Type.String({
15
16
  description: "Customer identifier from the contact record.",
16
- }),
17
+ })),
17
18
  version: Type.Optional(Type.String({ description: 'Skill pack version (e.g. "1.0.0"). Defaults to "1.0.0".' })),
18
19
  });
20
+ /** Read a single skill entry from a directory containing SKILL.md and optional references/. */
21
+ async function readSkillEntry(dir, id) {
22
+ const skillPath = path.join(dir, "SKILL.md");
23
+ const skillContent = await fsp.readFile(skillPath, "utf-8");
24
+ const references = [];
25
+ try {
26
+ const refsDir = path.join(dir, "references");
27
+ const refFiles = await fsp.readdir(refsDir);
28
+ for (const file of refFiles.sort()) {
29
+ if (file.startsWith("."))
30
+ continue;
31
+ const content = await fsp.readFile(path.join(refsDir, file), "utf-8");
32
+ references.push({ name: file, content });
33
+ }
34
+ }
35
+ catch {
36
+ // No references directory — that's fine
37
+ }
38
+ return { id, skill: skillContent, references };
39
+ }
40
+ /** Check whether a directory is a bundle (subdirectories with SKILL.md) or a single skill. */
41
+ async function detectPackLayout(packDir) {
42
+ try {
43
+ await fsp.access(path.join(packDir, "SKILL.md"));
44
+ return "single";
45
+ }
46
+ catch {
47
+ // No SKILL.md at top level — check for subdirectories
48
+ }
49
+ const entries = await fsp.readdir(packDir, { withFileTypes: true });
50
+ for (const entry of entries) {
51
+ if (!entry.isDirectory() || entry.name.startsWith("."))
52
+ continue;
53
+ try {
54
+ await fsp.access(path.join(packDir, entry.name, "SKILL.md"));
55
+ return "bundle";
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ }
61
+ throw new Error(`Pack "${path.basename(packDir)}" has no SKILL.md at root and no subdirectories with SKILL.md.`);
62
+ }
19
63
  export function createSkillPackSignTool(opts) {
20
64
  return {
21
65
  label: "Skill Pack Sign",
22
66
  name: "skill_pack_sign",
23
- description: "Sign a skill pack for a specific customer device. Reads the skill template from " +
24
- "skill-packs/<packId>/, signs it with the customer's device ID embedded, and writes " +
25
- "the signed .skillpack.json file. Send the resulting file to the customer.",
67
+ description: "Sign a skill pack for a specific customer device. Supports single skills " +
68
+ "(skill-packs/<id>/SKILL.md) and bundles (skill-packs/<id>/<skill>/SKILL.md). " +
69
+ "Call without packId to list available packs.",
26
70
  parameters: SkillPackSignSchema,
27
71
  execute: async (_toolCallId, args) => {
28
72
  const params = args;
29
- const packId = readStringParam(params, "packId", { required: true });
30
- const deviceId = readStringParam(params, "deviceId", { required: true });
31
- const customerId = readStringParam(params, "customerId", { required: true });
73
+ const packId = readStringParam(params, "packId");
74
+ const deviceId = readStringParam(params, "deviceId");
75
+ const customerId = readStringParam(params, "customerId");
32
76
  const version = readStringParam(params, "version") ?? "1.0.0";
77
+ // ── List mode: no packId → return available packs ──
78
+ const packsDir = path.join(opts.workspaceDir, "skill-packs");
79
+ if (!packId) {
80
+ const packs = [];
81
+ try {
82
+ const entries = await fsp.readdir(packsDir, { withFileTypes: true });
83
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
84
+ if (!entry.isDirectory() || entry.name === "signed" || entry.name.startsWith("."))
85
+ continue;
86
+ const dir = path.join(packsDir, entry.name);
87
+ try {
88
+ const layout = await detectPackLayout(dir);
89
+ if (layout === "bundle") {
90
+ const subs = await fsp.readdir(dir, { withFileTypes: true });
91
+ const skillIds = subs
92
+ .filter((s) => s.isDirectory() && !s.name.startsWith("."))
93
+ .map((s) => s.name)
94
+ .sort();
95
+ packs.push({ id: entry.name, type: "bundle", skills: skillIds });
96
+ }
97
+ else {
98
+ packs.push({ id: entry.name, type: "single" });
99
+ }
100
+ }
101
+ catch {
102
+ // Skip invalid pack directories
103
+ }
104
+ }
105
+ }
106
+ catch {
107
+ // directory doesn't exist
108
+ }
109
+ return jsonResult({
110
+ ok: true,
111
+ packsDir,
112
+ packs,
113
+ message: packs.length
114
+ ? `Found ${packs.length} pack(s)`
115
+ : `No skill packs found. Create pack templates in ${packsDir}/<packId>/SKILL.md`,
116
+ });
117
+ }
118
+ if (!deviceId || !customerId) {
119
+ throw new Error("deviceId and customerId are required when signing a pack.");
120
+ }
33
121
  if (!deviceId.startsWith("tm_dev_")) {
34
122
  throw new Error(`Invalid device ID format: "${deviceId}". Device IDs start with "tm_dev_".`);
35
123
  }
36
- // Read skill template
37
- const packDir = path.join(opts.workspaceDir, "skill-packs", packId);
38
- const skillPath = path.join(packDir, "SKILL.md");
39
- let skillContent;
124
+ const packDir = path.join(packsDir, packId);
125
+ let layout;
40
126
  try {
41
- skillContent = await fsp.readFile(skillPath, "utf-8");
127
+ layout = await detectPackLayout(packDir);
42
128
  }
43
129
  catch {
44
- throw new Error(`Skill pack template not found: ${packId}. Expected SKILL.md at ${skillPath}`);
130
+ throw new Error(`Skill pack not found: ${packId}. Expected at ${packDir}`);
45
131
  }
46
- // Read references
47
- const refsDir = path.join(packDir, "references");
48
- const references = [];
49
- try {
50
- const refFiles = await fsp.readdir(refsDir);
51
- for (const file of refFiles.sort()) {
52
- const content = await fsp.readFile(path.join(refsDir, file), "utf-8");
53
- references.push({ name: file, content });
132
+ // ── Read skill entries ──
133
+ const skillEntries = [];
134
+ if (layout === "single") {
135
+ skillEntries.push(await readSkillEntry(packDir, packId));
136
+ }
137
+ else {
138
+ const subs = await fsp.readdir(packDir, { withFileTypes: true });
139
+ for (const sub of subs.sort((a, b) => a.name.localeCompare(b.name))) {
140
+ if (!sub.isDirectory() || sub.name.startsWith("."))
141
+ continue;
142
+ const subDir = path.join(packDir, sub.name);
143
+ try {
144
+ await fsp.access(path.join(subDir, "SKILL.md"));
145
+ skillEntries.push(await readSkillEntry(subDir, sub.name));
146
+ }
147
+ catch {
148
+ // subdirectory without SKILL.md — skip
149
+ }
54
150
  }
55
151
  }
56
- catch {
57
- // No references directory that's fine
152
+ if (skillEntries.length === 0) {
153
+ throw new Error(`No skills found in pack "${packId}".`);
58
154
  }
59
- // Parse frontmatter for metadata
60
- const frontmatter = parseFrontmatter(skillContent);
61
- const name = frontmatter?.name ?? packId;
62
- const description = frontmatter?.description ?? "";
155
+ // Parse frontmatter from first skill for pack-level metadata
156
+ const firstFm = parseFrontmatter(skillEntries[0].skill);
157
+ const name = layout === "single" ? (firstFm?.name ?? packId) : packId;
158
+ const description = layout === "single"
159
+ ? (firstFm?.description ?? "")
160
+ : `Bundle of ${skillEntries.length} skills`;
63
161
  // Build and sign payload
64
162
  const payload = {
65
163
  format: "skillpack-v1",
66
164
  pack: { id: packId, version, name, description, author: "Rubytech LLC" },
67
165
  device: { did: deviceId, cid: customerId },
68
- content: { skill: skillContent, references },
166
+ content: { skills: skillEntries },
69
167
  signedAt: new Date().toISOString(),
70
168
  };
71
169
  const signed = signSkillPack(payload);
72
170
  // Write signed file
73
171
  const shortDid = deviceId.slice(0, 20);
74
- const signedDir = path.join(opts.workspaceDir, "skill-packs", "signed");
172
+ const signedDir = path.join(packsDir, "signed");
75
173
  await fsp.mkdir(signedDir, { recursive: true });
76
174
  const outputPath = path.join(signedDir, `${packId}-${shortDid}.skillpack.json`);
77
175
  await fsp.writeFile(outputPath, JSON.stringify(signed, null, 2), "utf-8");
176
+ const skillNames = skillEntries.map((s) => s.id).join(", ");
78
177
  return jsonResult({
79
178
  ok: true,
80
179
  filePath: outputPath,
@@ -82,7 +181,10 @@ export function createSkillPackSignTool(opts) {
82
181
  version,
83
182
  deviceId,
84
183
  customerId,
85
- message: `Signed skill pack "${name}" v${version} for device ${deviceId}. ` +
184
+ skillCount: skillEntries.length,
185
+ skills: skillEntries.map((s) => s.id),
186
+ message: `Signed skill pack "${name}" v${version} with ${skillEntries.length} skill(s) ` +
187
+ `(${skillNames}) for device ${deviceId}. ` +
86
188
  `File saved to ${outputPath}. Send this file to the customer with instructions ` +
87
189
  `to forward it to their admin agent and ask it to install.`,
88
190
  });
@@ -32,7 +32,7 @@ export function createSystemStatusTool() {
32
32
  description: "Check the status of the Taskmaster system. " +
33
33
  '"overview" returns a combined snapshot (health, auth, license, channels, version, update availability). ' +
34
34
  "Use specific actions for detail: " +
35
- '"health" (gateway health), "auth" (Claude API auth), "license" (license info), ' +
35
+ '"health" (gateway health), "auth" (Claude API auth), "license" (license info + device ID), ' +
36
36
  '"channels" (WhatsApp/iMessage connections), "models" (available LLM models), ' +
37
37
  '"update" (software version and update availability), ' +
38
38
  '"tailscale" (Tailscale connection + public URL), "network" (hostname + port).',
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.20.2",
3
- "commit": "d4b6c29dd73647523ebc7a8e8b7ab3c1e790dd19",
4
- "builtAt": "2026-03-06T19:20:00.353Z"
2
+ "version": "1.21.1",
3
+ "commit": "a9d05c02ef2f2cf9eb7cfb55ee76266fea595687",
4
+ "builtAt": "2026-03-06T20:52:13.028Z"
5
5
  }
@@ -294,3 +294,69 @@ export function reconcileSkillReadTool(params) {
294
294
  }
295
295
  return { config, changes };
296
296
  }
297
+ /**
298
+ * Individual skill tool names that should be replaced by `group:skills`.
299
+ * Matches the members of TOOL_GROUPS["group:skills"] in tool-policy.ts.
300
+ */
301
+ const INDIVIDUAL_SKILL_TOOLS = ["skill_read", "skill_draft_save", "skill_pack_install"];
302
+ /**
303
+ * Replace individual skill tool entries with `group:skills` on admin agents.
304
+ *
305
+ * When `group:skills` gains new tools (e.g. `skill_pack_install`), agents with
306
+ * explicit allow lists don't see them. This reconciliation upgrades individual
307
+ * entries to the group reference so future additions are automatic.
308
+ *
309
+ * Runs unconditionally on gateway startup. Idempotent — skips agents that
310
+ * already have `group:skills`.
311
+ */
312
+ export function reconcileSkillsGroup(params) {
313
+ const config = structuredClone(params.config);
314
+ const changes = [];
315
+ const agents = config.agents?.list;
316
+ if (!Array.isArray(agents))
317
+ return { config, changes };
318
+ for (const agent of agents) {
319
+ if (!agent || !isAdminAgent(agent))
320
+ continue;
321
+ const allow = agent.tools?.allow;
322
+ if (!Array.isArray(allow))
323
+ continue;
324
+ // Already using the group — just clean up any redundant individual entries
325
+ if (allow.includes("group:skills")) {
326
+ const removed = [];
327
+ for (const tool of INDIVIDUAL_SKILL_TOOLS) {
328
+ const idx = allow.indexOf(tool);
329
+ if (idx !== -1) {
330
+ allow.splice(idx, 1);
331
+ removed.push(tool);
332
+ }
333
+ }
334
+ if (removed.length > 0) {
335
+ changes.push(`Removed redundant ${removed.join(", ")} from agent "${agent.id}" tools.allow (group:skills covers them).`);
336
+ }
337
+ continue;
338
+ }
339
+ // Check if any individual skill tools are present
340
+ const hasAny = INDIVIDUAL_SKILL_TOOLS.some((t) => allow.includes(t));
341
+ if (!hasAny) {
342
+ // No skill tools at all — only add group if this looks like a full admin agent
343
+ if (!allow.includes("message"))
344
+ continue;
345
+ allow.push("group:skills");
346
+ changes.push(`Added group:skills to agent "${agent.id ?? "<unnamed>"}" tools.allow.`);
347
+ continue;
348
+ }
349
+ // Replace individual entries with group
350
+ const removed = [];
351
+ for (const tool of INDIVIDUAL_SKILL_TOOLS) {
352
+ const idx = allow.indexOf(tool);
353
+ if (idx !== -1) {
354
+ allow.splice(idx, 1);
355
+ removed.push(tool);
356
+ }
357
+ }
358
+ allow.push("group:skills");
359
+ changes.push(`Replaced ${removed.join(", ")} with group:skills in agent "${agent.id}" tools.allow.`);
360
+ }
361
+ return { config, changes };
362
+ }