opencode-gitlab-dap 1.14.2 → 1.15.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.
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,33 @@ 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 isAgentsGitignored(dir) {
4196
+ try {
4197
+ (0, import_child_process.execSync)("git check-ignore -q .agents", { cwd: dir, stdio: "pipe" });
4198
+ return true;
4199
+ } catch {
4200
+ return false;
4201
+ }
4202
+ }
4203
+ function ensureGitignore(dir) {
4204
+ if (isAgentsGitignored(dir)) return;
4205
+ const gitignorePath = (0, import_path2.join)(dir, ".gitignore");
4206
+ if ((0, import_fs2.existsSync)(gitignorePath)) {
4207
+ const content = (0, import_fs2.readFileSync)(gitignorePath, "utf-8");
4208
+ (0, import_fs2.writeFileSync)(
4209
+ gitignorePath,
4210
+ content.trimEnd() + "\n\n# Agent skills cache (managed by opencode-gitlab-dap)\n.agents/\n"
4211
+ );
4212
+ } else {
4213
+ (0, import_fs2.writeFileSync)(
4214
+ gitignorePath,
4215
+ "# Agent skills cache (managed by opencode-gitlab-dap)\n.agents/\n"
4216
+ );
4217
+ }
4218
+ }
4120
4219
  function searchSkillsSh(query) {
4121
4220
  try {
4122
4221
  const raw = (0, import_child_process.execSync)(`npx skills find ${JSON.stringify(query)}`, {
@@ -4276,8 +4375,23 @@ ${page.content}` : page.content;
4276
4375
 
4277
4376
  ---
4278
4377
  Available references: ${refs.join(", ")}`;
4378
+ }
4379
+ const indexSlug = isDraft ? DRAFTS_INDEX : SKILLS_INDEX;
4380
+ const indexEntries = await readIndex(
4381
+ auth.instanceUrl,
4382
+ auth.token,
4383
+ scope,
4384
+ id,
4385
+ indexSlug
4386
+ );
4387
+ const entry = indexEntries.find((e) => e.name === args.name);
4388
+ if (entry?.snippetId) {
4389
+ result += `
4390
+
4391
+ ---
4392
+ This skill has executable scripts in snippet #${entry.snippetId}.`;
4279
4393
  result += `
4280
- Load with: gitlab_skill_load_reference(name="${args.name}", reference="<name>")`;
4394
+ Run \`gitlab_skill_setup(name="${args.name}")\` to extract them to .agents/skills/${args.name}/ for execution.`;
4281
4395
  }
4282
4396
  return result;
4283
4397
  } catch {
@@ -4435,29 +4549,42 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
4435
4549
  if (!downloaded) {
4436
4550
  return `Failed to download skill "${args.name}" from skills.sh. Check that the identifier is correct (e.g., "owner/repo@skill-name").`;
4437
4551
  }
4438
- const skillSlug = `${targetPrefix}/${downloaded.name}/SKILL`;
4439
4552
  try {
4440
4553
  await upsertPage(
4441
4554
  auth.instanceUrl,
4442
4555
  auth.token,
4443
4556
  projectScope.scope,
4444
4557
  projectScope.id,
4445
- skillSlug,
4558
+ `${targetPrefix}/${downloaded.name}/SKILL`,
4446
4559
  downloaded.content
4447
4560
  );
4448
- let fileCount = 1;
4449
- for (const file of downloaded.files) {
4450
- const ext = file.path.replace(/\.[^.]+$/, "");
4451
- const refSlug = `${targetPrefix}/${downloaded.name}/${ext}`;
4561
+ let wikiCount = 1;
4562
+ const mdFiles = downloaded.files.filter((f) => isMarkdownFile(f.path));
4563
+ const scriptFiles = downloaded.files.filter((f) => !isMarkdownFile(f.path));
4564
+ for (const file of mdFiles) {
4565
+ const slug = `${targetPrefix}/${downloaded.name}/references/${file.path.replace(/\.[^.]+$/, "")}`;
4452
4566
  await upsertPage(
4453
4567
  auth.instanceUrl,
4454
4568
  auth.token,
4455
4569
  projectScope.scope,
4456
4570
  projectScope.id,
4457
- refSlug,
4571
+ slug,
4458
4572
  file.content
4459
4573
  );
4460
- fileCount++;
4574
+ wikiCount++;
4575
+ }
4576
+ let snippetId;
4577
+ if (scriptFiles.length > 0) {
4578
+ const snippet = await createProjectSnippet(
4579
+ auth.instanceUrl,
4580
+ auth.token,
4581
+ args.project_id,
4582
+ `skill:${downloaded.name}`,
4583
+ `Scripts for skill "${downloaded.name}" (installed from skills.sh:${args.name})`,
4584
+ scriptFiles.map((f) => ({ file_path: f.path, content: f.content })),
4585
+ "private"
4586
+ );
4587
+ snippetId = snippet.id;
4461
4588
  }
4462
4589
  await upsertIndexEntry(
4463
4590
  auth.instanceUrl,
@@ -4469,10 +4596,13 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
4469
4596
  name: downloaded.name,
4470
4597
  description: downloaded.description,
4471
4598
  source: `skills.sh:${args.name}`,
4599
+ snippetId,
4472
4600
  draft: !!args.draft
4473
4601
  }
4474
4602
  );
4475
- return `Installed skill "${downloaded.name}" from skills.sh. ${fileCount} page(s) written.`;
4603
+ const parts = [`${wikiCount} wiki page(s)`];
4604
+ if (snippetId) parts.push(`snippet #${snippetId} with ${scriptFiles.length} script(s)`);
4605
+ return `Installed skill "${downloaded.name}" from skills.sh. ${parts.join(", ")}. Use gitlab_skill_setup to extract scripts to disk.`;
4476
4606
  } catch (err) {
4477
4607
  return `Error installing skill from skills.sh: ${err.message}`;
4478
4608
  }
@@ -4534,8 +4664,106 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
4534
4664
  }
4535
4665
  }
4536
4666
  }),
4667
+ gitlab_skill_setup: (0, import_plugin6.tool)({
4668
+ 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.",
4669
+ args: {
4670
+ project_id: z6.string().describe(PROJECT_ID_DESC2),
4671
+ name: z6.string().describe("Skill name to set up locally"),
4672
+ scope: z6.enum(["projects", "groups"]).optional().describe("Scope (default: projects)"),
4673
+ group_id: z6.string().optional().describe("Group path (required when scope is groups)")
4674
+ },
4675
+ execute: async (args) => {
4676
+ const auth = authAndValidate(args.project_id);
4677
+ const { scope, id } = resolveScope2(args);
4678
+ const targetDir = (0, import_path2.join)(process.cwd(), ".agents", "skills", args.name);
4679
+ try {
4680
+ let skillContent = null;
4681
+ for (const prefix of [SKILLS_PREFIX, DRAFTS_PREFIX]) {
4682
+ try {
4683
+ const page = await getWikiPage(
4684
+ auth.instanceUrl,
4685
+ auth.token,
4686
+ scope,
4687
+ id,
4688
+ `${prefix}/${args.name}/SKILL`
4689
+ );
4690
+ skillContent = page.content;
4691
+ break;
4692
+ } catch {
4693
+ }
4694
+ }
4695
+ if (!skillContent) {
4696
+ return `Skill "${args.name}" not found in wiki. Use gitlab_skill_list to see available skills.`;
4697
+ }
4698
+ (0, import_fs2.mkdirSync)(targetDir, { recursive: true });
4699
+ (0, import_fs2.writeFileSync)((0, import_path2.join)(targetDir, "SKILL.md"), skillContent);
4700
+ const pages = await listWikiPages(auth.instanceUrl, auth.token, scope, id, true);
4701
+ let refCount = 0;
4702
+ for (const prefix of [SKILLS_PREFIX, DRAFTS_PREFIX]) {
4703
+ const refPrefix = `${prefix}/${args.name}/references/`;
4704
+ const refPages = pages.filter(
4705
+ (p) => p.slug.startsWith(refPrefix) && p.content
4706
+ );
4707
+ for (const ref of refPages) {
4708
+ const refName = ref.slug.slice(refPrefix.length);
4709
+ const refDir = (0, import_path2.join)(targetDir, "references");
4710
+ (0, import_fs2.mkdirSync)(refDir, { recursive: true });
4711
+ (0, import_fs2.writeFileSync)((0, import_path2.join)(refDir, `${refName}.md`), ref.content);
4712
+ refCount++;
4713
+ }
4714
+ }
4715
+ let scriptCount = 0;
4716
+ const indexEntries = await readIndex(
4717
+ auth.instanceUrl,
4718
+ auth.token,
4719
+ scope,
4720
+ id,
4721
+ SKILLS_INDEX
4722
+ );
4723
+ const draftsEntries = await readIndex(
4724
+ auth.instanceUrl,
4725
+ auth.token,
4726
+ scope,
4727
+ id,
4728
+ DRAFTS_INDEX
4729
+ );
4730
+ const entry = [...indexEntries, ...draftsEntries].find((e) => e.name === args.name);
4731
+ if (entry?.snippetId) {
4732
+ const files = await listSnippetFiles(
4733
+ auth.instanceUrl,
4734
+ auth.token,
4735
+ args.project_id,
4736
+ entry.snippetId
4737
+ );
4738
+ for (const file of files) {
4739
+ const filePath = (0, import_path2.join)(targetDir, file.path);
4740
+ (0, import_fs2.mkdirSync)((0, import_path2.dirname)(filePath), { recursive: true });
4741
+ const content = await getSnippetFileRaw(
4742
+ auth.instanceUrl,
4743
+ auth.token,
4744
+ args.project_id,
4745
+ entry.snippetId,
4746
+ file.path
4747
+ );
4748
+ (0, import_fs2.writeFileSync)(filePath, content);
4749
+ if (file.path.endsWith(".sh")) {
4750
+ (0, import_fs2.chmodSync)(filePath, 493);
4751
+ }
4752
+ scriptCount++;
4753
+ }
4754
+ }
4755
+ ensureGitignore(process.cwd());
4756
+ const parts = ["SKILL.md"];
4757
+ if (refCount > 0) parts.push(`${refCount} reference(s)`);
4758
+ if (scriptCount > 0) parts.push(`${scriptCount} script(s)`);
4759
+ return `Skill "${args.name}" extracted to ${targetDir} (${parts.join(", ")}). OpenCode will auto-discover it on next session.`;
4760
+ } catch (err) {
4761
+ return `Error setting up skill: ${err.message}`;
4762
+ }
4763
+ }
4764
+ }),
4537
4765
  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.",
4766
+ 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
4767
  args: {
4540
4768
  project_id: z6.string().describe(PROJECT_ID_DESC2),
4541
4769
  name: z6.string().describe("Skill name to delete"),
@@ -4558,8 +4786,25 @@ Install: \`gitlab_skill_install(name="${r.identifier}", source="skills.sh")\``
4558
4786
  for (const page of skillPages) {
4559
4787
  await deleteWikiPage(auth.instanceUrl, auth.token, scope, id, page.slug);
4560
4788
  }
4789
+ const indexEntries = await readIndex(auth.instanceUrl, auth.token, scope, id, indexSlug);
4790
+ const entry = indexEntries.find((e) => e.name === args.name);
4791
+ let snippetDeleted = false;
4792
+ if (entry?.snippetId) {
4793
+ try {
4794
+ await deleteProjectSnippet(
4795
+ auth.instanceUrl,
4796
+ auth.token,
4797
+ args.project_id,
4798
+ entry.snippetId
4799
+ );
4800
+ snippetDeleted = true;
4801
+ } catch {
4802
+ }
4803
+ }
4561
4804
  await removeIndexEntry(auth.instanceUrl, auth.token, scope, id, indexSlug, args.name);
4562
- return `Deleted skill "${args.name}" (${skillPages.length} page(s) removed).`;
4805
+ const parts = [`${skillPages.length} wiki page(s)`];
4806
+ if (snippetDeleted) parts.push("snippet");
4807
+ return `Deleted skill "${args.name}" (${parts.join(" + ")} removed).`;
4563
4808
  } catch (err) {
4564
4809
  return `Error deleting skill: ${err.message}`;
4565
4810
  }