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 CHANGED
@@ -110,7 +110,7 @@ The plugin provides a multi-round design workflow:
110
110
 
111
111
  The vendored `flow_v2.json` schema from GitLab Rails powers client-side validation using `ajv`, catching errors before hitting the API.
112
112
 
113
- ### 34 Tools
113
+ ### 35 Tools
114
114
 
115
115
  #### DAP Tools (20)
116
116
 
@@ -157,17 +157,20 @@ Say **"bootstrap project memory"** to automatically inspect a project and build
157
157
 
158
158
  ##### Skill Tools
159
159
 
160
- | Tool | Description |
161
- | ----------------------- | --------------------------------------------------------- |
162
- | `gitlab_skill_list` | List skills with auto-rebuilding index |
163
- | `gitlab_skill_load` | Load a skill (SKILL page + available references) |
164
- | `gitlab_skill_save` | Create/update a skill with required description for index |
165
- | `gitlab_skill_promote` | Promote a draft skill to published (moves all pages) |
166
- | `gitlab_skill_discover` | Search group wiki for team-shared skills |
167
- | `gitlab_skill_install` | Copy a skill from group wiki to project wiki |
168
- | `gitlab_skill_delete` | Delete a skill and remove from index |
169
-
170
- Each skill is a directory: `agents/skills/<name>/SKILL` with optional `references/` subpages.
160
+ | Tool | Description |
161
+ | ----------------------- | ------------------------------------------------------------- |
162
+ | `gitlab_skill_list` | List skills with auto-rebuilding index |
163
+ | `gitlab_skill_load` | Load a skill (SKILL page + references + snippet info) |
164
+ | `gitlab_skill_save` | Create/update a skill with required description for index |
165
+ | `gitlab_skill_promote` | Promote a draft skill to published (moves all pages) |
166
+ | `gitlab_skill_discover` | Search group wiki and skills.sh for available skills |
167
+ | `gitlab_skill_install` | Install from group wiki or skills.sh (wiki + snippet) |
168
+ | `gitlab_skill_setup` | Extract skill to `.agents/skills/<name>/` for local execution |
169
+ | `gitlab_skill_delete` | Delete skill (wiki pages + snippet + index entry) |
170
+
171
+ **Hybrid storage:** SKILL.md and markdown references are stored in the wiki. Executable files
172
+ (`.py`, `.js`, `.sh`) are stored as a multi-file project snippet with extensions preserved.
173
+ Use `gitlab_skill_setup` to extract everything to `.agents/skills/<name>/` for local execution.
171
174
  Skills are indexed in `agents/skills/index` for fast discovery.
172
175
  All tools support project scope (default) and group scope (`scope="groups"`).
173
176
 
package/dist/index.cjs CHANGED
@@ -3947,6 +3947,73 @@ ${e.content}`).join("\n\n---\n\n");
3947
3947
 
3948
3948
  // src/tools/skill-tools.ts
3949
3949
  var import_plugin6 = require("@opencode-ai/plugin");
3950
+
3951
+ // src/snippets.ts
3952
+ function snippetApi(instanceUrl, token, projectId) {
3953
+ const base = instanceUrl.replace(/\/$/, "");
3954
+ const encoded = typeof projectId === "number" ? projectId : encodeURIComponent(projectId);
3955
+ return {
3956
+ url: `${base}/api/v4/projects/${encoded}/snippets`,
3957
+ headers: {
3958
+ "Content-Type": "application/json",
3959
+ Authorization: `Bearer ${token}`
3960
+ }
3961
+ };
3962
+ }
3963
+ async function handleResponse2(res) {
3964
+ if (!res.ok) {
3965
+ const text = await res.text();
3966
+ throw new Error(`Snippet API error (${res.status}): ${text}`);
3967
+ }
3968
+ return res.json();
3969
+ }
3970
+ async function createProjectSnippet(instanceUrl, token, projectId, title, description, files, visibility = "private") {
3971
+ const { url, headers } = snippetApi(instanceUrl, token, projectId);
3972
+ const res = await fetch(url, {
3973
+ method: "POST",
3974
+ headers,
3975
+ body: JSON.stringify({ title, description, visibility, files })
3976
+ });
3977
+ return handleResponse2(res);
3978
+ }
3979
+ async function deleteProjectSnippet(instanceUrl, token, projectId, snippetId) {
3980
+ const { url, headers } = snippetApi(instanceUrl, token, projectId);
3981
+ const res = await fetch(`${url}/${snippetId}`, { method: "DELETE", headers });
3982
+ if (!res.ok) {
3983
+ const text = await res.text();
3984
+ throw new Error(`Snippet API error (${res.status}): ${text}`);
3985
+ }
3986
+ }
3987
+ async function getSnippetFileRaw(instanceUrl, token, projectId, snippetId, filePath, ref = "main") {
3988
+ const base = instanceUrl.replace(/\/$/, "");
3989
+ const encoded = typeof projectId === "number" ? projectId : encodeURIComponent(projectId);
3990
+ const encodedPath = encodeURIComponent(filePath);
3991
+ const url = `${base}/api/v4/projects/${encoded}/snippets/${snippetId}/files/${ref}/${encodedPath}/raw`;
3992
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
3993
+ if (!res.ok) {
3994
+ const text = await res.text();
3995
+ throw new Error(`Snippet file API error (${res.status}): ${text}`);
3996
+ }
3997
+ return res.text();
3998
+ }
3999
+ async function listSnippetFiles(instanceUrl, token, projectId, snippetId) {
4000
+ const base = instanceUrl.replace(/\/$/, "");
4001
+ const encoded = typeof projectId === "number" ? projectId : encodeURIComponent(projectId);
4002
+ const url = `${base}/api/v4/projects/${encoded}/snippets/${snippetId}`;
4003
+ const res = await fetch(url, {
4004
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }
4005
+ });
4006
+ const snippet = await handleResponse2(res);
4007
+ if (snippet.files) {
4008
+ return snippet.files.map((f) => ({ path: f.path, raw_url: f.raw_url }));
4009
+ }
4010
+ if (snippet.file_name) {
4011
+ return [{ path: snippet.file_name, raw_url: snippet.raw_url }];
4012
+ }
4013
+ return [];
4014
+ }
4015
+
4016
+ // src/tools/skill-tools.ts
3950
4017
  var import_child_process = require("child_process");
3951
4018
  var import_fs2 = require("fs");
3952
4019
  var import_path2 = require("path");
@@ -3980,14 +4047,17 @@ function parseIndex(content) {
3980
4047
  const rest = lines.slice(1).join("\n").trim();
3981
4048
  const descLines = [];
3982
4049
  let source;
4050
+ let snippetId;
3983
4051
  for (const line of rest.split("\n")) {
3984
4052
  if (line.startsWith("Source:")) {
3985
4053
  source = line.slice(7).trim();
4054
+ } else if (line.startsWith("Snippet:")) {
4055
+ snippetId = parseInt(line.slice(8).trim(), 10) || void 0;
3986
4056
  } else if (line.trim()) {
3987
4057
  descLines.push(line);
3988
4058
  }
3989
4059
  }
3990
- entries.push({ name, description: descLines.join("\n"), source, draft: false });
4060
+ entries.push({ name, description: descLines.join("\n"), source, snippetId, draft: false });
3991
4061
  }
3992
4062
  return entries;
3993
4063
  }
@@ -3997,6 +4067,8 @@ function formatIndex(entries) {
3997
4067
  ${e.description}`;
3998
4068
  if (e.source) block += `
3999
4069
  Source: ${e.source}`;
4070
+ if (e.snippetId) block += `
4071
+ Snippet: ${e.snippetId}`;
4000
4072
  return block;
4001
4073
  }).join("\n\n");
4002
4074
  }
@@ -4062,8 +4134,32 @@ async function rebuildIndex(instanceUrl, token, scope, id, prefix, indexSlug) {
4062
4134
  dirty = true;
4063
4135
  }
4064
4136
  }
4137
+ const staleDescriptions = currentEntries.filter(
4138
+ (e) => actual.has(e.name) && (e.description.startsWith("(auto-indexed") || e.description.startsWith("(no description"))
4139
+ );
4140
+ if (staleDescriptions.length > 0) dirty = true;
4065
4141
  if (!dirty && added.length === 0) return currentEntries;
4066
- const kept = currentEntries.filter((e) => actual.has(e.name));
4142
+ const kept = [];
4143
+ for (const e of currentEntries) {
4144
+ if (!actual.has(e.name)) continue;
4145
+ if (e.description.startsWith("(auto-indexed") || e.description.startsWith("(no description")) {
4146
+ let description = "";
4147
+ try {
4148
+ const page = await getWikiPage(instanceUrl, token, scope, id, `${prefix}/${e.name}/SKILL`);
4149
+ const content = page.content ?? "";
4150
+ const fmMatch = content.match(/^---\s*\n[\s\S]*?description:\s*(.+)\n[\s\S]*?---/);
4151
+ if (fmMatch) {
4152
+ description = fmMatch[1].trim();
4153
+ } else {
4154
+ description = content.replace(/^---[\s\S]*?---\s*\n/, "").replace(/^#[^\n]*\n+/, "").split("\n\n")[0].replace(/\n/g, " ").trim().slice(0, 200);
4155
+ }
4156
+ } catch {
4157
+ }
4158
+ kept.push({ ...e, description: description || e.description });
4159
+ } else {
4160
+ kept.push(e);
4161
+ }
4162
+ }
4067
4163
  for (const name of added) {
4068
4164
  let description = "";
4069
4165
  try {
@@ -4093,6 +4189,19 @@ async function upsertPage(instanceUrl, token, scope, id, slug, content) {
4093
4189
  await createWikiPage(instanceUrl, token, scope, id, slug, content);
4094
4190
  }
4095
4191
  }
4192
+ function isMarkdownFile(path) {
4193
+ return path === "SKILL.md" || path.endsWith(".md") || path.endsWith(".markdown");
4194
+ }
4195
+ function ensureGitignore(dir) {
4196
+ const gitignorePath = (0, import_path2.join)(dir, ".gitignore");
4197
+ if ((0, import_fs2.existsSync)(gitignorePath)) {
4198
+ const content = (0, import_fs2.readFileSync)(gitignorePath, "utf-8");
4199
+ if (content.includes(".agents/") || content.includes(".agents")) return;
4200
+ (0, import_fs2.writeFileSync)(gitignorePath, content.trimEnd() + "\n.agents/\n");
4201
+ } else {
4202
+ (0, import_fs2.writeFileSync)(gitignorePath, ".agents/\n");
4203
+ }
4204
+ }
4096
4205
  function searchSkillsSh(query) {
4097
4206
  try {
4098
4207
  const raw = (0, import_child_process.execSync)(`npx skills find ${JSON.stringify(query)}`, {
@@ -4252,8 +4361,23 @@ ${page.content}` : page.content;
4252
4361
 
4253
4362
  ---
4254
4363
  Available references: ${refs.join(", ")}`;
4364
+ }
4365
+ const indexSlug = isDraft ? DRAFTS_INDEX : SKILLS_INDEX;
4366
+ const indexEntries = await readIndex(
4367
+ auth.instanceUrl,
4368
+ auth.token,
4369
+ scope,
4370
+ id,
4371
+ indexSlug
4372
+ );
4373
+ const entry = indexEntries.find((e) => e.name === args.name);
4374
+ if (entry?.snippetId) {
4255
4375
  result += `
4256
- Load with: gitlab_skill_load_reference(name="${args.name}", reference="<name>")`;
4376
+
4377
+ ---
4378
+ This skill has executable scripts in snippet #${entry.snippetId}.`;
4379
+ result += `
4380
+ Run \`gitlab_skill_setup(name="${args.name}")\` to extract them to .agents/skills/${args.name}/ for execution.`;
4257
4381
  }
4258
4382
  return result;
4259
4383
  } catch {
@@ -4411,29 +4535,42 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
4411
4535
  if (!downloaded) {
4412
4536
  return `Failed to download skill "${args.name}" from skills.sh. Check that the identifier is correct (e.g., "owner/repo@skill-name").`;
4413
4537
  }
4414
- const skillSlug = `${targetPrefix}/${downloaded.name}/SKILL`;
4415
4538
  try {
4416
4539
  await upsertPage(
4417
4540
  auth.instanceUrl,
4418
4541
  auth.token,
4419
4542
  projectScope.scope,
4420
4543
  projectScope.id,
4421
- skillSlug,
4544
+ `${targetPrefix}/${downloaded.name}/SKILL`,
4422
4545
  downloaded.content
4423
4546
  );
4424
- let fileCount = 1;
4425
- for (const file of downloaded.files) {
4426
- const ext = file.path.replace(/\.[^.]+$/, "");
4427
- const refSlug = `${targetPrefix}/${downloaded.name}/${ext}`;
4547
+ let wikiCount = 1;
4548
+ const mdFiles = downloaded.files.filter((f) => isMarkdownFile(f.path));
4549
+ const scriptFiles = downloaded.files.filter((f) => !isMarkdownFile(f.path));
4550
+ for (const file of mdFiles) {
4551
+ const slug = `${targetPrefix}/${downloaded.name}/references/${file.path.replace(/\.[^.]+$/, "")}`;
4428
4552
  await upsertPage(
4429
4553
  auth.instanceUrl,
4430
4554
  auth.token,
4431
4555
  projectScope.scope,
4432
4556
  projectScope.id,
4433
- refSlug,
4557
+ slug,
4434
4558
  file.content
4435
4559
  );
4436
- fileCount++;
4560
+ wikiCount++;
4561
+ }
4562
+ let snippetId;
4563
+ if (scriptFiles.length > 0) {
4564
+ const snippet = await createProjectSnippet(
4565
+ auth.instanceUrl,
4566
+ auth.token,
4567
+ args.project_id,
4568
+ `skill:${downloaded.name}`,
4569
+ `Scripts for skill "${downloaded.name}" (installed from skills.sh:${args.name})`,
4570
+ scriptFiles.map((f) => ({ file_path: f.path, content: f.content })),
4571
+ "private"
4572
+ );
4573
+ snippetId = snippet.id;
4437
4574
  }
4438
4575
  await upsertIndexEntry(
4439
4576
  auth.instanceUrl,
@@ -4445,10 +4582,13 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
4445
4582
  name: downloaded.name,
4446
4583
  description: downloaded.description,
4447
4584
  source: `skills.sh:${args.name}`,
4585
+ snippetId,
4448
4586
  draft: !!args.draft
4449
4587
  }
4450
4588
  );
4451
- return `Installed skill "${downloaded.name}" from skills.sh. ${fileCount} page(s) written.`;
4589
+ const parts = [`${wikiCount} wiki page(s)`];
4590
+ if (snippetId) parts.push(`snippet #${snippetId} with ${scriptFiles.length} script(s)`);
4591
+ return `Installed skill "${downloaded.name}" from skills.sh. ${parts.join(", ")}. Use gitlab_skill_setup to extract scripts to disk.`;
4452
4592
  } catch (err) {
4453
4593
  return `Error installing skill from skills.sh: ${err.message}`;
4454
4594
  }
@@ -4510,8 +4650,106 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
4510
4650
  }
4511
4651
  }
4512
4652
  }),
4653
+ gitlab_skill_setup: (0, import_plugin6.tool)({
4654
+ 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.",
4655
+ args: {
4656
+ project_id: z6.string().describe(PROJECT_ID_DESC2),
4657
+ name: z6.string().describe("Skill name to set up locally"),
4658
+ scope: z6.enum(["projects", "groups"]).optional().describe("Scope (default: projects)"),
4659
+ group_id: z6.string().optional().describe("Group path (required when scope is groups)")
4660
+ },
4661
+ execute: async (args) => {
4662
+ const auth = authAndValidate(args.project_id);
4663
+ const { scope, id } = resolveScope2(args);
4664
+ const targetDir = (0, import_path2.join)(process.cwd(), ".agents", "skills", args.name);
4665
+ try {
4666
+ let skillContent = null;
4667
+ for (const prefix of [SKILLS_PREFIX, DRAFTS_PREFIX]) {
4668
+ try {
4669
+ const page = await getWikiPage(
4670
+ auth.instanceUrl,
4671
+ auth.token,
4672
+ scope,
4673
+ id,
4674
+ `${prefix}/${args.name}/SKILL`
4675
+ );
4676
+ skillContent = page.content;
4677
+ break;
4678
+ } catch {
4679
+ }
4680
+ }
4681
+ if (!skillContent) {
4682
+ return `Skill "${args.name}" not found in wiki. Use gitlab_skill_list to see available skills.`;
4683
+ }
4684
+ (0, import_fs2.mkdirSync)(targetDir, { recursive: true });
4685
+ (0, import_fs2.writeFileSync)((0, import_path2.join)(targetDir, "SKILL.md"), skillContent);
4686
+ const pages = await listWikiPages(auth.instanceUrl, auth.token, scope, id, true);
4687
+ let refCount = 0;
4688
+ for (const prefix of [SKILLS_PREFIX, DRAFTS_PREFIX]) {
4689
+ const refPrefix = `${prefix}/${args.name}/references/`;
4690
+ const refPages = pages.filter(
4691
+ (p) => p.slug.startsWith(refPrefix) && p.content
4692
+ );
4693
+ for (const ref of refPages) {
4694
+ const refName = ref.slug.slice(refPrefix.length);
4695
+ const refDir = (0, import_path2.join)(targetDir, "references");
4696
+ (0, import_fs2.mkdirSync)(refDir, { recursive: true });
4697
+ (0, import_fs2.writeFileSync)((0, import_path2.join)(refDir, `${refName}.md`), ref.content);
4698
+ refCount++;
4699
+ }
4700
+ }
4701
+ let scriptCount = 0;
4702
+ const indexEntries = await readIndex(
4703
+ auth.instanceUrl,
4704
+ auth.token,
4705
+ scope,
4706
+ id,
4707
+ SKILLS_INDEX
4708
+ );
4709
+ const draftsEntries = await readIndex(
4710
+ auth.instanceUrl,
4711
+ auth.token,
4712
+ scope,
4713
+ id,
4714
+ DRAFTS_INDEX
4715
+ );
4716
+ const entry = [...indexEntries, ...draftsEntries].find((e) => e.name === args.name);
4717
+ if (entry?.snippetId) {
4718
+ const files = await listSnippetFiles(
4719
+ auth.instanceUrl,
4720
+ auth.token,
4721
+ args.project_id,
4722
+ entry.snippetId
4723
+ );
4724
+ for (const file of files) {
4725
+ const filePath = (0, import_path2.join)(targetDir, file.path);
4726
+ (0, import_fs2.mkdirSync)((0, import_path2.dirname)(filePath), { recursive: true });
4727
+ const content = await getSnippetFileRaw(
4728
+ auth.instanceUrl,
4729
+ auth.token,
4730
+ args.project_id,
4731
+ entry.snippetId,
4732
+ file.path
4733
+ );
4734
+ (0, import_fs2.writeFileSync)(filePath, content);
4735
+ if (file.path.endsWith(".sh")) {
4736
+ (0, import_fs2.chmodSync)(filePath, 493);
4737
+ }
4738
+ scriptCount++;
4739
+ }
4740
+ }
4741
+ ensureGitignore(process.cwd());
4742
+ const parts = ["SKILL.md"];
4743
+ if (refCount > 0) parts.push(`${refCount} reference(s)`);
4744
+ if (scriptCount > 0) parts.push(`${scriptCount} script(s)`);
4745
+ return `Skill "${args.name}" extracted to ${targetDir} (${parts.join(", ")}). OpenCode will auto-discover it on next session.`;
4746
+ } catch (err) {
4747
+ return `Error setting up skill: ${err.message}`;
4748
+ }
4749
+ }
4750
+ }),
4513
4751
  gitlab_skill_delete: (0, import_plugin6.tool)({
4514
- description: "Delete a skill and remove it from the index.\nRemoves all pages under the skill directory (SKILL + references).\nWiki git history preserves deleted content.",
4752
+ 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.",
4515
4753
  args: {
4516
4754
  project_id: z6.string().describe(PROJECT_ID_DESC2),
4517
4755
  name: z6.string().describe("Skill name to delete"),
@@ -4534,8 +4772,25 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
4534
4772
  for (const page of skillPages) {
4535
4773
  await deleteWikiPage(auth.instanceUrl, auth.token, scope, id, page.slug);
4536
4774
  }
4775
+ const indexEntries = await readIndex(auth.instanceUrl, auth.token, scope, id, indexSlug);
4776
+ const entry = indexEntries.find((e) => e.name === args.name);
4777
+ let snippetDeleted = false;
4778
+ if (entry?.snippetId) {
4779
+ try {
4780
+ await deleteProjectSnippet(
4781
+ auth.instanceUrl,
4782
+ auth.token,
4783
+ args.project_id,
4784
+ entry.snippetId
4785
+ );
4786
+ snippetDeleted = true;
4787
+ } catch {
4788
+ }
4789
+ }
4537
4790
  await removeIndexEntry(auth.instanceUrl, auth.token, scope, id, indexSlug, args.name);
4538
- return `Deleted skill "${args.name}" (${skillPages.length} page(s) removed).`;
4791
+ const parts = [`${skillPages.length} wiki page(s)`];
4792
+ if (snippetDeleted) parts.push("snippet");
4793
+ return `Deleted skill "${args.name}" (${parts.join(" + ")} removed).`;
4539
4794
  } catch (err) {
4540
4795
  return `Error deleting skill: ${err.message}`;
4541
4796
  }