opencode-gitlab-dap 1.14.2 → 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
  }
@@ -4117,6 +4189,19 @@ async function upsertPage(instanceUrl, token, scope, id, slug, content) {
4117
4189
  await createWikiPage(instanceUrl, token, scope, id, slug, content);
4118
4190
  }
4119
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
+ }
4120
4205
  function searchSkillsSh(query) {
4121
4206
  try {
4122
4207
  const raw = (0, import_child_process.execSync)(`npx skills find ${JSON.stringify(query)}`, {
@@ -4276,8 +4361,23 @@ ${page.content}` : page.content;
4276
4361
 
4277
4362
  ---
4278
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) {
4279
4375
  result += `
4280
- 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.`;
4281
4381
  }
4282
4382
  return result;
4283
4383
  } catch {
@@ -4435,29 +4535,42 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
4435
4535
  if (!downloaded) {
4436
4536
  return `Failed to download skill "${args.name}" from skills.sh. Check that the identifier is correct (e.g., "owner/repo@skill-name").`;
4437
4537
  }
4438
- const skillSlug = `${targetPrefix}/${downloaded.name}/SKILL`;
4439
4538
  try {
4440
4539
  await upsertPage(
4441
4540
  auth.instanceUrl,
4442
4541
  auth.token,
4443
4542
  projectScope.scope,
4444
4543
  projectScope.id,
4445
- skillSlug,
4544
+ `${targetPrefix}/${downloaded.name}/SKILL`,
4446
4545
  downloaded.content
4447
4546
  );
4448
- let fileCount = 1;
4449
- for (const file of downloaded.files) {
4450
- const ext = file.path.replace(/\.[^.]+$/, "");
4451
- 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(/\.[^.]+$/, "")}`;
4452
4552
  await upsertPage(
4453
4553
  auth.instanceUrl,
4454
4554
  auth.token,
4455
4555
  projectScope.scope,
4456
4556
  projectScope.id,
4457
- refSlug,
4557
+ slug,
4458
4558
  file.content
4459
4559
  );
4460
- 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;
4461
4574
  }
4462
4575
  await upsertIndexEntry(
4463
4576
  auth.instanceUrl,
@@ -4469,10 +4582,13 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
4469
4582
  name: downloaded.name,
4470
4583
  description: downloaded.description,
4471
4584
  source: `skills.sh:${args.name}`,
4585
+ snippetId,
4472
4586
  draft: !!args.draft
4473
4587
  }
4474
4588
  );
4475
- 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.`;
4476
4592
  } catch (err) {
4477
4593
  return `Error installing skill from skills.sh: ${err.message}`;
4478
4594
  }
@@ -4534,8 +4650,106 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
4534
4650
  }
4535
4651
  }
4536
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
+ }),
4537
4751
  gitlab_skill_delete: (0, import_plugin6.tool)({
4538
- 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.",
4539
4753
  args: {
4540
4754
  project_id: z6.string().describe(PROJECT_ID_DESC2),
4541
4755
  name: z6.string().describe("Skill name to delete"),
@@ -4558,8 +4772,25 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
4558
4772
  for (const page of skillPages) {
4559
4773
  await deleteWikiPage(auth.instanceUrl, auth.token, scope, id, page.slug);
4560
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
+ }
4561
4790
  await removeIndexEntry(auth.instanceUrl, auth.token, scope, id, indexSlug, args.name);
4562
- 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).`;
4563
4794
  } catch (err) {
4564
4795
  return `Error deleting skill: ${err.message}`;
4565
4796
  }