opencode-gitlab-dap 1.14.1 → 1.15.0
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 +15 -12
- package/dist/index.cjs +269 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +281 -16
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3778,9 +3778,86 @@ ${e.content}`).join("\n\n---\n\n");
|
|
|
3778
3778
|
|
|
3779
3779
|
// src/tools/skill-tools.ts
|
|
3780
3780
|
import { tool as tool6 } from "@opencode-ai/plugin";
|
|
3781
|
+
|
|
3782
|
+
// src/snippets.ts
|
|
3783
|
+
function snippetApi(instanceUrl, token, projectId) {
|
|
3784
|
+
const base = instanceUrl.replace(/\/$/, "");
|
|
3785
|
+
const encoded = typeof projectId === "number" ? projectId : encodeURIComponent(projectId);
|
|
3786
|
+
return {
|
|
3787
|
+
url: `${base}/api/v4/projects/${encoded}/snippets`,
|
|
3788
|
+
headers: {
|
|
3789
|
+
"Content-Type": "application/json",
|
|
3790
|
+
Authorization: `Bearer ${token}`
|
|
3791
|
+
}
|
|
3792
|
+
};
|
|
3793
|
+
}
|
|
3794
|
+
async function handleResponse2(res) {
|
|
3795
|
+
if (!res.ok) {
|
|
3796
|
+
const text = await res.text();
|
|
3797
|
+
throw new Error(`Snippet API error (${res.status}): ${text}`);
|
|
3798
|
+
}
|
|
3799
|
+
return res.json();
|
|
3800
|
+
}
|
|
3801
|
+
async function createProjectSnippet(instanceUrl, token, projectId, title, description, files, visibility = "private") {
|
|
3802
|
+
const { url, headers } = snippetApi(instanceUrl, token, projectId);
|
|
3803
|
+
const res = await fetch(url, {
|
|
3804
|
+
method: "POST",
|
|
3805
|
+
headers,
|
|
3806
|
+
body: JSON.stringify({ title, description, visibility, files })
|
|
3807
|
+
});
|
|
3808
|
+
return handleResponse2(res);
|
|
3809
|
+
}
|
|
3810
|
+
async function deleteProjectSnippet(instanceUrl, token, projectId, snippetId) {
|
|
3811
|
+
const { url, headers } = snippetApi(instanceUrl, token, projectId);
|
|
3812
|
+
const res = await fetch(`${url}/${snippetId}`, { method: "DELETE", headers });
|
|
3813
|
+
if (!res.ok) {
|
|
3814
|
+
const text = await res.text();
|
|
3815
|
+
throw new Error(`Snippet API error (${res.status}): ${text}`);
|
|
3816
|
+
}
|
|
3817
|
+
}
|
|
3818
|
+
async function getSnippetFileRaw(instanceUrl, token, projectId, snippetId, filePath, ref = "main") {
|
|
3819
|
+
const base = instanceUrl.replace(/\/$/, "");
|
|
3820
|
+
const encoded = typeof projectId === "number" ? projectId : encodeURIComponent(projectId);
|
|
3821
|
+
const encodedPath = encodeURIComponent(filePath);
|
|
3822
|
+
const url = `${base}/api/v4/projects/${encoded}/snippets/${snippetId}/files/${ref}/${encodedPath}/raw`;
|
|
3823
|
+
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
3824
|
+
if (!res.ok) {
|
|
3825
|
+
const text = await res.text();
|
|
3826
|
+
throw new Error(`Snippet file API error (${res.status}): ${text}`);
|
|
3827
|
+
}
|
|
3828
|
+
return res.text();
|
|
3829
|
+
}
|
|
3830
|
+
async function listSnippetFiles(instanceUrl, token, projectId, snippetId) {
|
|
3831
|
+
const base = instanceUrl.replace(/\/$/, "");
|
|
3832
|
+
const encoded = typeof projectId === "number" ? projectId : encodeURIComponent(projectId);
|
|
3833
|
+
const url = `${base}/api/v4/projects/${encoded}/snippets/${snippetId}`;
|
|
3834
|
+
const res = await fetch(url, {
|
|
3835
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
|
|
3836
|
+
});
|
|
3837
|
+
const snippet = await handleResponse2(res);
|
|
3838
|
+
if (snippet.files) {
|
|
3839
|
+
return snippet.files.map((f) => ({ path: f.path, raw_url: f.raw_url }));
|
|
3840
|
+
}
|
|
3841
|
+
if (snippet.file_name) {
|
|
3842
|
+
return [{ path: snippet.file_name, raw_url: snippet.raw_url }];
|
|
3843
|
+
}
|
|
3844
|
+
return [];
|
|
3845
|
+
}
|
|
3846
|
+
|
|
3847
|
+
// src/tools/skill-tools.ts
|
|
3781
3848
|
import { execSync } from "child_process";
|
|
3782
|
-
import {
|
|
3783
|
-
|
|
3849
|
+
import {
|
|
3850
|
+
mkdtempSync,
|
|
3851
|
+
readFileSync as readFileSync2,
|
|
3852
|
+
rmSync,
|
|
3853
|
+
readdirSync,
|
|
3854
|
+
statSync,
|
|
3855
|
+
writeFileSync,
|
|
3856
|
+
mkdirSync,
|
|
3857
|
+
existsSync,
|
|
3858
|
+
chmodSync
|
|
3859
|
+
} from "fs";
|
|
3860
|
+
import { join as join2, dirname } from "path";
|
|
3784
3861
|
import { tmpdir } from "os";
|
|
3785
3862
|
var z6 = tool6.schema;
|
|
3786
3863
|
var PREFIX2 = "agents";
|
|
@@ -3811,14 +3888,17 @@ function parseIndex(content) {
|
|
|
3811
3888
|
const rest = lines.slice(1).join("\n").trim();
|
|
3812
3889
|
const descLines = [];
|
|
3813
3890
|
let source;
|
|
3891
|
+
let snippetId;
|
|
3814
3892
|
for (const line of rest.split("\n")) {
|
|
3815
3893
|
if (line.startsWith("Source:")) {
|
|
3816
3894
|
source = line.slice(7).trim();
|
|
3895
|
+
} else if (line.startsWith("Snippet:")) {
|
|
3896
|
+
snippetId = parseInt(line.slice(8).trim(), 10) || void 0;
|
|
3817
3897
|
} else if (line.trim()) {
|
|
3818
3898
|
descLines.push(line);
|
|
3819
3899
|
}
|
|
3820
3900
|
}
|
|
3821
|
-
entries.push({ name, description: descLines.join("\n"), source, draft: false });
|
|
3901
|
+
entries.push({ name, description: descLines.join("\n"), source, snippetId, draft: false });
|
|
3822
3902
|
}
|
|
3823
3903
|
return entries;
|
|
3824
3904
|
}
|
|
@@ -3828,6 +3908,8 @@ function formatIndex(entries) {
|
|
|
3828
3908
|
${e.description}`;
|
|
3829
3909
|
if (e.source) block += `
|
|
3830
3910
|
Source: ${e.source}`;
|
|
3911
|
+
if (e.snippetId) block += `
|
|
3912
|
+
Snippet: ${e.snippetId}`;
|
|
3831
3913
|
return block;
|
|
3832
3914
|
}).join("\n\n");
|
|
3833
3915
|
}
|
|
@@ -3893,8 +3975,32 @@ async function rebuildIndex(instanceUrl, token, scope, id, prefix, indexSlug) {
|
|
|
3893
3975
|
dirty = true;
|
|
3894
3976
|
}
|
|
3895
3977
|
}
|
|
3978
|
+
const staleDescriptions = currentEntries.filter(
|
|
3979
|
+
(e) => actual.has(e.name) && (e.description.startsWith("(auto-indexed") || e.description.startsWith("(no description"))
|
|
3980
|
+
);
|
|
3981
|
+
if (staleDescriptions.length > 0) dirty = true;
|
|
3896
3982
|
if (!dirty && added.length === 0) return currentEntries;
|
|
3897
|
-
const kept =
|
|
3983
|
+
const kept = [];
|
|
3984
|
+
for (const e of currentEntries) {
|
|
3985
|
+
if (!actual.has(e.name)) continue;
|
|
3986
|
+
if (e.description.startsWith("(auto-indexed") || e.description.startsWith("(no description")) {
|
|
3987
|
+
let description = "";
|
|
3988
|
+
try {
|
|
3989
|
+
const page = await getWikiPage(instanceUrl, token, scope, id, `${prefix}/${e.name}/SKILL`);
|
|
3990
|
+
const content = page.content ?? "";
|
|
3991
|
+
const fmMatch = content.match(/^---\s*\n[\s\S]*?description:\s*(.+)\n[\s\S]*?---/);
|
|
3992
|
+
if (fmMatch) {
|
|
3993
|
+
description = fmMatch[1].trim();
|
|
3994
|
+
} else {
|
|
3995
|
+
description = content.replace(/^---[\s\S]*?---\s*\n/, "").replace(/^#[^\n]*\n+/, "").split("\n\n")[0].replace(/\n/g, " ").trim().slice(0, 200);
|
|
3996
|
+
}
|
|
3997
|
+
} catch {
|
|
3998
|
+
}
|
|
3999
|
+
kept.push({ ...e, description: description || e.description });
|
|
4000
|
+
} else {
|
|
4001
|
+
kept.push(e);
|
|
4002
|
+
}
|
|
4003
|
+
}
|
|
3898
4004
|
for (const name of added) {
|
|
3899
4005
|
let description = "";
|
|
3900
4006
|
try {
|
|
@@ -3924,6 +4030,19 @@ async function upsertPage(instanceUrl, token, scope, id, slug, content) {
|
|
|
3924
4030
|
await createWikiPage(instanceUrl, token, scope, id, slug, content);
|
|
3925
4031
|
}
|
|
3926
4032
|
}
|
|
4033
|
+
function isMarkdownFile(path) {
|
|
4034
|
+
return path === "SKILL.md" || path.endsWith(".md") || path.endsWith(".markdown");
|
|
4035
|
+
}
|
|
4036
|
+
function ensureGitignore(dir) {
|
|
4037
|
+
const gitignorePath = join2(dir, ".gitignore");
|
|
4038
|
+
if (existsSync(gitignorePath)) {
|
|
4039
|
+
const content = readFileSync2(gitignorePath, "utf-8");
|
|
4040
|
+
if (content.includes(".agents/") || content.includes(".agents")) return;
|
|
4041
|
+
writeFileSync(gitignorePath, content.trimEnd() + "\n.agents/\n");
|
|
4042
|
+
} else {
|
|
4043
|
+
writeFileSync(gitignorePath, ".agents/\n");
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
3927
4046
|
function searchSkillsSh(query) {
|
|
3928
4047
|
try {
|
|
3929
4048
|
const raw = execSync(`npx skills find ${JSON.stringify(query)}`, {
|
|
@@ -4083,8 +4202,23 @@ ${page.content}` : page.content;
|
|
|
4083
4202
|
|
|
4084
4203
|
---
|
|
4085
4204
|
Available references: ${refs.join(", ")}`;
|
|
4205
|
+
}
|
|
4206
|
+
const indexSlug = isDraft ? DRAFTS_INDEX : SKILLS_INDEX;
|
|
4207
|
+
const indexEntries = await readIndex(
|
|
4208
|
+
auth.instanceUrl,
|
|
4209
|
+
auth.token,
|
|
4210
|
+
scope,
|
|
4211
|
+
id,
|
|
4212
|
+
indexSlug
|
|
4213
|
+
);
|
|
4214
|
+
const entry = indexEntries.find((e) => e.name === args.name);
|
|
4215
|
+
if (entry?.snippetId) {
|
|
4216
|
+
result += `
|
|
4217
|
+
|
|
4218
|
+
---
|
|
4219
|
+
This skill has executable scripts in snippet #${entry.snippetId}.`;
|
|
4086
4220
|
result += `
|
|
4087
|
-
|
|
4221
|
+
Run \`gitlab_skill_setup(name="${args.name}")\` to extract them to .agents/skills/${args.name}/ for execution.`;
|
|
4088
4222
|
}
|
|
4089
4223
|
return result;
|
|
4090
4224
|
} catch {
|
|
@@ -4242,29 +4376,42 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
|
|
|
4242
4376
|
if (!downloaded) {
|
|
4243
4377
|
return `Failed to download skill "${args.name}" from skills.sh. Check that the identifier is correct (e.g., "owner/repo@skill-name").`;
|
|
4244
4378
|
}
|
|
4245
|
-
const skillSlug = `${targetPrefix}/${downloaded.name}/SKILL`;
|
|
4246
4379
|
try {
|
|
4247
4380
|
await upsertPage(
|
|
4248
4381
|
auth.instanceUrl,
|
|
4249
4382
|
auth.token,
|
|
4250
4383
|
projectScope.scope,
|
|
4251
4384
|
projectScope.id,
|
|
4252
|
-
|
|
4385
|
+
`${targetPrefix}/${downloaded.name}/SKILL`,
|
|
4253
4386
|
downloaded.content
|
|
4254
4387
|
);
|
|
4255
|
-
let
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4388
|
+
let wikiCount = 1;
|
|
4389
|
+
const mdFiles = downloaded.files.filter((f) => isMarkdownFile(f.path));
|
|
4390
|
+
const scriptFiles = downloaded.files.filter((f) => !isMarkdownFile(f.path));
|
|
4391
|
+
for (const file of mdFiles) {
|
|
4392
|
+
const slug = `${targetPrefix}/${downloaded.name}/references/${file.path.replace(/\.[^.]+$/, "")}`;
|
|
4259
4393
|
await upsertPage(
|
|
4260
4394
|
auth.instanceUrl,
|
|
4261
4395
|
auth.token,
|
|
4262
4396
|
projectScope.scope,
|
|
4263
4397
|
projectScope.id,
|
|
4264
|
-
|
|
4398
|
+
slug,
|
|
4265
4399
|
file.content
|
|
4266
4400
|
);
|
|
4267
|
-
|
|
4401
|
+
wikiCount++;
|
|
4402
|
+
}
|
|
4403
|
+
let snippetId;
|
|
4404
|
+
if (scriptFiles.length > 0) {
|
|
4405
|
+
const snippet = await createProjectSnippet(
|
|
4406
|
+
auth.instanceUrl,
|
|
4407
|
+
auth.token,
|
|
4408
|
+
args.project_id,
|
|
4409
|
+
`skill:${downloaded.name}`,
|
|
4410
|
+
`Scripts for skill "${downloaded.name}" (installed from skills.sh:${args.name})`,
|
|
4411
|
+
scriptFiles.map((f) => ({ file_path: f.path, content: f.content })),
|
|
4412
|
+
"private"
|
|
4413
|
+
);
|
|
4414
|
+
snippetId = snippet.id;
|
|
4268
4415
|
}
|
|
4269
4416
|
await upsertIndexEntry(
|
|
4270
4417
|
auth.instanceUrl,
|
|
@@ -4276,10 +4423,13 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
|
|
|
4276
4423
|
name: downloaded.name,
|
|
4277
4424
|
description: downloaded.description,
|
|
4278
4425
|
source: `skills.sh:${args.name}`,
|
|
4426
|
+
snippetId,
|
|
4279
4427
|
draft: !!args.draft
|
|
4280
4428
|
}
|
|
4281
4429
|
);
|
|
4282
|
-
|
|
4430
|
+
const parts = [`${wikiCount} wiki page(s)`];
|
|
4431
|
+
if (snippetId) parts.push(`snippet #${snippetId} with ${scriptFiles.length} script(s)`);
|
|
4432
|
+
return `Installed skill "${downloaded.name}" from skills.sh. ${parts.join(", ")}. Use gitlab_skill_setup to extract scripts to disk.`;
|
|
4283
4433
|
} catch (err) {
|
|
4284
4434
|
return `Error installing skill from skills.sh: ${err.message}`;
|
|
4285
4435
|
}
|
|
@@ -4341,8 +4491,106 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
|
|
|
4341
4491
|
}
|
|
4342
4492
|
}
|
|
4343
4493
|
}),
|
|
4494
|
+
gitlab_skill_setup: tool6({
|
|
4495
|
+
description: "Extract a skill to the local .agents/skills/ directory for execution.\nDownloads SKILL.md from wiki and script files from the associated snippet.\nWrites to .agents/skills/<name>/ and ensures .agents/ is in .gitignore.\nAfter setup, OpenCode will auto-discover the skill from the local directory.",
|
|
4496
|
+
args: {
|
|
4497
|
+
project_id: z6.string().describe(PROJECT_ID_DESC2),
|
|
4498
|
+
name: z6.string().describe("Skill name to set up locally"),
|
|
4499
|
+
scope: z6.enum(["projects", "groups"]).optional().describe("Scope (default: projects)"),
|
|
4500
|
+
group_id: z6.string().optional().describe("Group path (required when scope is groups)")
|
|
4501
|
+
},
|
|
4502
|
+
execute: async (args) => {
|
|
4503
|
+
const auth = authAndValidate(args.project_id);
|
|
4504
|
+
const { scope, id } = resolveScope2(args);
|
|
4505
|
+
const targetDir = join2(process.cwd(), ".agents", "skills", args.name);
|
|
4506
|
+
try {
|
|
4507
|
+
let skillContent = null;
|
|
4508
|
+
for (const prefix of [SKILLS_PREFIX, DRAFTS_PREFIX]) {
|
|
4509
|
+
try {
|
|
4510
|
+
const page = await getWikiPage(
|
|
4511
|
+
auth.instanceUrl,
|
|
4512
|
+
auth.token,
|
|
4513
|
+
scope,
|
|
4514
|
+
id,
|
|
4515
|
+
`${prefix}/${args.name}/SKILL`
|
|
4516
|
+
);
|
|
4517
|
+
skillContent = page.content;
|
|
4518
|
+
break;
|
|
4519
|
+
} catch {
|
|
4520
|
+
}
|
|
4521
|
+
}
|
|
4522
|
+
if (!skillContent) {
|
|
4523
|
+
return `Skill "${args.name}" not found in wiki. Use gitlab_skill_list to see available skills.`;
|
|
4524
|
+
}
|
|
4525
|
+
mkdirSync(targetDir, { recursive: true });
|
|
4526
|
+
writeFileSync(join2(targetDir, "SKILL.md"), skillContent);
|
|
4527
|
+
const pages = await listWikiPages(auth.instanceUrl, auth.token, scope, id, true);
|
|
4528
|
+
let refCount = 0;
|
|
4529
|
+
for (const prefix of [SKILLS_PREFIX, DRAFTS_PREFIX]) {
|
|
4530
|
+
const refPrefix = `${prefix}/${args.name}/references/`;
|
|
4531
|
+
const refPages = pages.filter(
|
|
4532
|
+
(p) => p.slug.startsWith(refPrefix) && p.content
|
|
4533
|
+
);
|
|
4534
|
+
for (const ref of refPages) {
|
|
4535
|
+
const refName = ref.slug.slice(refPrefix.length);
|
|
4536
|
+
const refDir = join2(targetDir, "references");
|
|
4537
|
+
mkdirSync(refDir, { recursive: true });
|
|
4538
|
+
writeFileSync(join2(refDir, `${refName}.md`), ref.content);
|
|
4539
|
+
refCount++;
|
|
4540
|
+
}
|
|
4541
|
+
}
|
|
4542
|
+
let scriptCount = 0;
|
|
4543
|
+
const indexEntries = await readIndex(
|
|
4544
|
+
auth.instanceUrl,
|
|
4545
|
+
auth.token,
|
|
4546
|
+
scope,
|
|
4547
|
+
id,
|
|
4548
|
+
SKILLS_INDEX
|
|
4549
|
+
);
|
|
4550
|
+
const draftsEntries = await readIndex(
|
|
4551
|
+
auth.instanceUrl,
|
|
4552
|
+
auth.token,
|
|
4553
|
+
scope,
|
|
4554
|
+
id,
|
|
4555
|
+
DRAFTS_INDEX
|
|
4556
|
+
);
|
|
4557
|
+
const entry = [...indexEntries, ...draftsEntries].find((e) => e.name === args.name);
|
|
4558
|
+
if (entry?.snippetId) {
|
|
4559
|
+
const files = await listSnippetFiles(
|
|
4560
|
+
auth.instanceUrl,
|
|
4561
|
+
auth.token,
|
|
4562
|
+
args.project_id,
|
|
4563
|
+
entry.snippetId
|
|
4564
|
+
);
|
|
4565
|
+
for (const file of files) {
|
|
4566
|
+
const filePath = join2(targetDir, file.path);
|
|
4567
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
4568
|
+
const content = await getSnippetFileRaw(
|
|
4569
|
+
auth.instanceUrl,
|
|
4570
|
+
auth.token,
|
|
4571
|
+
args.project_id,
|
|
4572
|
+
entry.snippetId,
|
|
4573
|
+
file.path
|
|
4574
|
+
);
|
|
4575
|
+
writeFileSync(filePath, content);
|
|
4576
|
+
if (file.path.endsWith(".sh")) {
|
|
4577
|
+
chmodSync(filePath, 493);
|
|
4578
|
+
}
|
|
4579
|
+
scriptCount++;
|
|
4580
|
+
}
|
|
4581
|
+
}
|
|
4582
|
+
ensureGitignore(process.cwd());
|
|
4583
|
+
const parts = ["SKILL.md"];
|
|
4584
|
+
if (refCount > 0) parts.push(`${refCount} reference(s)`);
|
|
4585
|
+
if (scriptCount > 0) parts.push(`${scriptCount} script(s)`);
|
|
4586
|
+
return `Skill "${args.name}" extracted to ${targetDir} (${parts.join(", ")}). OpenCode will auto-discover it on next session.`;
|
|
4587
|
+
} catch (err) {
|
|
4588
|
+
return `Error setting up skill: ${err.message}`;
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
}),
|
|
4344
4592
|
gitlab_skill_delete: tool6({
|
|
4345
|
-
description: "Delete a skill and remove it from the index.\nRemoves all pages under the skill directory
|
|
4593
|
+
description: "Delete a skill and remove it from the index.\nRemoves all wiki pages under the skill directory and any associated snippet.\nWiki and snippet git history preserves deleted content.",
|
|
4346
4594
|
args: {
|
|
4347
4595
|
project_id: z6.string().describe(PROJECT_ID_DESC2),
|
|
4348
4596
|
name: z6.string().describe("Skill name to delete"),
|
|
@@ -4365,8 +4613,25 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
|
|
|
4365
4613
|
for (const page of skillPages) {
|
|
4366
4614
|
await deleteWikiPage(auth.instanceUrl, auth.token, scope, id, page.slug);
|
|
4367
4615
|
}
|
|
4616
|
+
const indexEntries = await readIndex(auth.instanceUrl, auth.token, scope, id, indexSlug);
|
|
4617
|
+
const entry = indexEntries.find((e) => e.name === args.name);
|
|
4618
|
+
let snippetDeleted = false;
|
|
4619
|
+
if (entry?.snippetId) {
|
|
4620
|
+
try {
|
|
4621
|
+
await deleteProjectSnippet(
|
|
4622
|
+
auth.instanceUrl,
|
|
4623
|
+
auth.token,
|
|
4624
|
+
args.project_id,
|
|
4625
|
+
entry.snippetId
|
|
4626
|
+
);
|
|
4627
|
+
snippetDeleted = true;
|
|
4628
|
+
} catch {
|
|
4629
|
+
}
|
|
4630
|
+
}
|
|
4368
4631
|
await removeIndexEntry(auth.instanceUrl, auth.token, scope, id, indexSlug, args.name);
|
|
4369
|
-
|
|
4632
|
+
const parts = [`${skillPages.length} wiki page(s)`];
|
|
4633
|
+
if (snippetDeleted) parts.push("snippet");
|
|
4634
|
+
return `Deleted skill "${args.name}" (${parts.join(" + ")} removed).`;
|
|
4370
4635
|
} catch (err) {
|
|
4371
4636
|
return `Error deleting skill: ${err.message}`;
|
|
4372
4637
|
}
|