opencode-gitlab-duo-agentic 0.1.0 → 0.1.2

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
@@ -26,13 +26,21 @@ export GITLAB_INSTANCE_URL=https://gitlab.com
26
26
 
27
27
  ## Optional model configuration
28
28
 
29
- If you generate `models.json` (using `src/scripts/fetch_models.ts`), the plugin resolves it by:
29
+ The plugin discovers models in this order:
30
30
 
31
- 1. `options.modelsPath` in provider config
32
- 2. `GITLAB_DUO_MODELS_PATH`
33
- 3. `models.json` found by walking upward from `process.cwd()`
31
+ 1. Live GitLab API discovery with cache (TTL default: `86400` seconds)
32
+ 2. `models.json` file resolution:
33
+ - `options.modelsPath` in provider config
34
+ - `GITLAB_DUO_MODELS_PATH`
35
+ - `models.json` found by walking upward from `process.cwd()`
36
+ 3. Fallback default model `duo-agentic`
34
37
 
35
- If no file is found, it falls back to a default `duo-agentic` model.
38
+ Cache settings:
39
+
40
+ - Location: `~/.cache/opencode/gitlab-duo-models-*.json`
41
+ - TTL override: `GITLAB_DUO_MODELS_CACHE_TTL` (seconds)
42
+
43
+ You can still generate a local `models.json` manually using `src/scripts/fetch_models.ts`.
36
44
 
37
45
  ## Development
38
46
 
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ type GitLabDuoAgenticProviderOptions = {
18
18
  systemRules?: string;
19
19
  };
20
20
 
21
- declare function createGitLabDuoAgentic(options: GitLabDuoAgenticProviderOptions): ProviderV2;
21
+ type GitLabDuoAgenticProviderInput = Partial<GitLabDuoAgenticProviderOptions>;
22
+ declare function createGitLabDuoAgentic(options?: GitLabDuoAgenticProviderInput): ProviderV2;
22
23
 
23
24
  export { GitLabDuoAgenticPlugin, createGitLabDuoAgentic, GitLabDuoAgenticPlugin as default };
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // src/plugin/config.ts
2
- import path2 from "path";
3
- import fs2 from "fs";
2
+ import path4 from "path";
3
+ import fs4 from "fs";
4
4
 
5
5
  // src/plugin/models.ts
6
- import path from "path";
7
- import fs from "fs";
6
+ import path3 from "path";
7
+ import fs3 from "fs";
8
8
 
9
9
  // src/shared/model_entry.ts
10
10
  function buildModelEntry(name) {
@@ -28,12 +28,435 @@ var GITLAB_DUO_DEFAULT_MODEL_NAME = "Duo Agentic";
28
28
  var GITLAB_DUO_PLUGIN_PACKAGE_NAME = "opencode-gitlab-duo-agentic";
29
29
  var GITLAB_DUO_PROVIDER_NPM_ENTRY = GITLAB_DUO_PLUGIN_PACKAGE_NAME;
30
30
 
31
+ // src/plugin/fetch_models.ts
32
+ import crypto from "crypto";
33
+ import os from "os";
34
+ import path2 from "path";
35
+ import fs2 from "fs/promises";
36
+
37
+ // src/provider/adapters/gitlab_utils.ts
38
+ import fs from "fs/promises";
39
+ import path from "path";
40
+ async function detectProjectPath(cwd, instanceUrl) {
41
+ let current = cwd;
42
+ const instance = new URL(instanceUrl);
43
+ const instanceHost = instance.host;
44
+ const instanceBasePath = instance.pathname.replace(/\/$/, "");
45
+ while (true) {
46
+ try {
47
+ const config = await readGitConfig(current);
48
+ const url = extractGitRemoteUrl(config) || "";
49
+ const remote = parseRemote(url);
50
+ if (!remote) {
51
+ return void 0;
52
+ }
53
+ if (remote.host !== instanceHost) {
54
+ throw new Error(
55
+ `GitLab remote host mismatch. Expected ${instanceHost}, got ${remote.host}.`
56
+ );
57
+ }
58
+ return normalizeProjectPath(remote.path, instanceBasePath);
59
+ } catch {
60
+ const parent = path.dirname(current);
61
+ if (parent === current) return void 0;
62
+ current = parent;
63
+ }
64
+ }
65
+ }
66
+ function extractGitRemoteUrl(config) {
67
+ const lines = config.split("\n");
68
+ let inOrigin = false;
69
+ let originUrl;
70
+ let firstUrl;
71
+ for (const line of lines) {
72
+ const trimmed = line.trim();
73
+ const sectionMatch = /^\[remote\s+"([^"]+)"\]$/.exec(trimmed);
74
+ if (sectionMatch) {
75
+ inOrigin = sectionMatch[1] === "origin";
76
+ continue;
77
+ }
78
+ const urlMatch = /^url\s*=\s*(.+)$/.exec(trimmed);
79
+ if (urlMatch) {
80
+ const value = urlMatch[1].trim();
81
+ if (!firstUrl) firstUrl = value;
82
+ if (inOrigin) originUrl = value;
83
+ }
84
+ }
85
+ return originUrl ?? firstUrl;
86
+ }
87
+ function parseRemote(remoteUrl) {
88
+ if (!remoteUrl) return void 0;
89
+ if (remoteUrl.startsWith("http")) {
90
+ try {
91
+ const url = new URL(remoteUrl);
92
+ return { host: url.host, path: url.pathname.replace(/^\//, "") };
93
+ } catch {
94
+ return void 0;
95
+ }
96
+ }
97
+ if (remoteUrl.startsWith("git@")) {
98
+ const match = /^git@([^:]+):(.+)$/.exec(remoteUrl);
99
+ if (!match) return void 0;
100
+ return { host: match[1], path: match[2] };
101
+ }
102
+ if (remoteUrl.startsWith("ssh://")) {
103
+ try {
104
+ const url = new URL(remoteUrl);
105
+ return { host: url.host, path: url.pathname.replace(/^\//, "") };
106
+ } catch {
107
+ return void 0;
108
+ }
109
+ }
110
+ return void 0;
111
+ }
112
+ function normalizeProjectPath(remotePath, instanceBasePath) {
113
+ let pathValue = remotePath;
114
+ if (instanceBasePath && instanceBasePath !== "/") {
115
+ const base = instanceBasePath.replace(/^\//, "") + "/";
116
+ if (pathValue.startsWith(base)) {
117
+ pathValue = pathValue.slice(base.length);
118
+ }
119
+ }
120
+ const cleaned = stripGitSuffix(pathValue);
121
+ return cleaned.length > 0 ? cleaned : void 0;
122
+ }
123
+ function stripGitSuffix(pathname) {
124
+ return pathname.endsWith(".git") ? pathname.slice(0, -4) : pathname;
125
+ }
126
+ function buildApiUrl(instanceUrl, apiPath) {
127
+ const base = instanceUrl.endsWith("/") ? instanceUrl : `${instanceUrl}/`;
128
+ return new URL(apiPath.replace(/^\//, ""), base).toString();
129
+ }
130
+ function buildAuthHeaders(apiKey) {
131
+ return { authorization: `Bearer ${apiKey}` };
132
+ }
133
+ async function fetchProjectDetails(instanceUrl, apiKey, projectPath) {
134
+ const url = buildApiUrl(instanceUrl, `api/v4/projects/${encodeURIComponent(projectPath)}`);
135
+ const response = await fetch(url, {
136
+ headers: buildAuthHeaders(apiKey)
137
+ });
138
+ if (!response.ok) {
139
+ throw new Error(`Failed to fetch project details: ${response.status}`);
140
+ }
141
+ const data = await response.json();
142
+ return {
143
+ projectId: data.id ? String(data.id) : void 0,
144
+ namespaceId: data.namespace?.id ? String(data.namespace.id) : void 0
145
+ };
146
+ }
147
+ async function fetchProjectDetailsWithFallback(instanceUrl, apiKey, projectPath) {
148
+ const candidates = getProjectPathCandidates(projectPath);
149
+ for (const candidate of candidates) {
150
+ try {
151
+ return await fetchProjectDetails(instanceUrl, apiKey, candidate);
152
+ } catch {
153
+ continue;
154
+ }
155
+ }
156
+ try {
157
+ const name = projectPath.split("/").pop() || projectPath;
158
+ const searchUrl = new URL(buildApiUrl(instanceUrl, "api/v4/projects"));
159
+ searchUrl.searchParams.set("search", name);
160
+ searchUrl.searchParams.set("simple", "true");
161
+ searchUrl.searchParams.set("per_page", "100");
162
+ searchUrl.searchParams.set("membership", "true");
163
+ const response = await fetch(searchUrl.toString(), {
164
+ headers: buildAuthHeaders(apiKey)
165
+ });
166
+ if (!response.ok) {
167
+ throw new Error(`Failed to search projects: ${response.status}`);
168
+ }
169
+ const data = await response.json();
170
+ const match = data.find((project) => project.path_with_namespace === projectPath);
171
+ if (!match) {
172
+ throw new Error("Project not found via search");
173
+ }
174
+ return {
175
+ projectId: match.id ? String(match.id) : void 0,
176
+ namespaceId: match.namespace?.id ? String(match.namespace.id) : void 0
177
+ };
178
+ } catch {
179
+ throw new Error("Project not found via API");
180
+ }
181
+ }
182
+ function getProjectPathCandidates(projectPath) {
183
+ const candidates = /* @__PURE__ */ new Set();
184
+ candidates.add(projectPath);
185
+ const parts = projectPath.split("/");
186
+ if (parts.length > 2) {
187
+ const withoutFirst = parts.slice(1).join("/");
188
+ candidates.add(withoutFirst);
189
+ }
190
+ return Array.from(candidates);
191
+ }
192
+ async function readGitConfig(cwd) {
193
+ const gitPath = path.join(cwd, ".git");
194
+ const stat = await fs.stat(gitPath);
195
+ if (stat.isDirectory()) {
196
+ return fs.readFile(path.join(gitPath, "config"), "utf8");
197
+ }
198
+ const file = await fs.readFile(gitPath, "utf8");
199
+ const match = /^gitdir:\s*(.+)$/m.exec(file);
200
+ if (!match) throw new Error("Invalid .git file");
201
+ const gitdir = match[1].trim();
202
+ const resolved = path.isAbsolute(gitdir) ? gitdir : path.join(cwd, gitdir);
203
+ return fs.readFile(path.join(resolved, "config"), "utf8");
204
+ }
205
+ async function resolveRootNamespaceId(instanceUrl, apiKey, namespaceId) {
206
+ let currentId = namespaceId;
207
+ for (let depth = 0; depth < 20; depth++) {
208
+ const url = buildApiUrl(instanceUrl, `api/v4/namespaces/${currentId}`);
209
+ const response = await fetch(url, {
210
+ headers: buildAuthHeaders(apiKey)
211
+ });
212
+ if (!response.ok) {
213
+ break;
214
+ }
215
+ const data = await response.json();
216
+ if (!data.parent_id) {
217
+ currentId = String(data.id ?? currentId);
218
+ break;
219
+ }
220
+ currentId = String(data.parent_id);
221
+ }
222
+ return `gid://gitlab/Group/${currentId}`;
223
+ }
224
+
225
+ // src/plugin/fetch_models.ts
226
+ var DEFAULT_MODELS_CACHE_TTL_SECONDS = 60 * 60 * 24;
227
+ var AVAILABLE_MODELS_QUERY = `query lsp_aiChatAvailableModels($rootNamespaceId: GroupID!) {
228
+ metadata {
229
+ featureFlags(names: ["ai_user_model_switching"]) {
230
+ enabled
231
+ name
232
+ }
233
+ version
234
+ }
235
+ aiChatAvailableModels(rootNamespaceId: $rootNamespaceId) {
236
+ defaultModel { name ref }
237
+ selectableModels { name ref }
238
+ pinnedModel { name ref }
239
+ }
240
+ }`;
241
+ function resolveModelsCacheTtlSeconds() {
242
+ const raw = process.env.GITLAB_DUO_MODELS_CACHE_TTL;
243
+ if (!raw) return DEFAULT_MODELS_CACHE_TTL_SECONDS;
244
+ const parsed = Number.parseInt(raw, 10);
245
+ if (!Number.isFinite(parsed) || parsed < 0) {
246
+ return DEFAULT_MODELS_CACHE_TTL_SECONDS;
247
+ }
248
+ return parsed;
249
+ }
250
+ async function loadModelsFromCache(options) {
251
+ const instanceUrl = normalizeInstanceUrl(options.instanceUrl);
252
+ const cwd = options.cwd ?? process.cwd();
253
+ const ttlSeconds = options.ttlSeconds ?? resolveModelsCacheTtlSeconds();
254
+ const allowStale = options.allowStale ?? false;
255
+ const { cachePath, projectPath } = await resolveCacheLocation(instanceUrl, cwd);
256
+ let raw;
257
+ try {
258
+ raw = await fs2.readFile(cachePath, "utf8");
259
+ } catch (error) {
260
+ const fsError = error;
261
+ if (fsError?.code === "ENOENT") return null;
262
+ console.warn(
263
+ `[gitlab-duo] Failed to read models cache at ${cachePath}: ${fsError?.message ?? String(error)}`
264
+ );
265
+ return null;
266
+ }
267
+ let parsed;
268
+ try {
269
+ parsed = JSON.parse(raw);
270
+ } catch (error) {
271
+ console.warn(
272
+ `[gitlab-duo] Failed to parse models cache at ${cachePath}: ${error instanceof Error ? error.message : String(error)}`
273
+ );
274
+ return null;
275
+ }
276
+ const models = parsed.payload?.models;
277
+ if (!models || Object.keys(models).length === 0) return null;
278
+ const cachedAtMs = Date.parse(parsed.cachedAt);
279
+ const ageSeconds = Number.isFinite(cachedAtMs) ? Math.max(0, (Date.now() - cachedAtMs) / 1e3) : Infinity;
280
+ const stale = ageSeconds > ttlSeconds;
281
+ if (stale && !allowStale) return null;
282
+ return {
283
+ payload: parsed.payload,
284
+ stale,
285
+ cachePath,
286
+ projectPath
287
+ };
288
+ }
289
+ async function fetchAndCacheModels(options) {
290
+ const instanceUrl = normalizeInstanceUrl(options.instanceUrl);
291
+ const apiKey = options.apiKey.trim();
292
+ const cwd = options.cwd ?? process.cwd();
293
+ if (!apiKey) {
294
+ throw new Error("GITLAB_TOKEN is required for live model discovery");
295
+ }
296
+ const projectPath = await resolveProjectPath(cwd, instanceUrl);
297
+ if (!projectPath) {
298
+ throw new Error("Could not detect GitLab project from git remote");
299
+ }
300
+ const payload = await fetchModels(instanceUrl, apiKey, projectPath);
301
+ const { cachePath } = await resolveCacheLocation(instanceUrl, cwd, projectPath);
302
+ await writeCache(cachePath, payload);
303
+ return {
304
+ payload,
305
+ stale: false,
306
+ cachePath,
307
+ projectPath
308
+ };
309
+ }
310
+ async function fetchModels(instanceUrl, apiKey, projectPath) {
311
+ const details = await fetchProjectDetailsWithFallback(instanceUrl, apiKey, projectPath);
312
+ const namespaceId = details.namespaceId;
313
+ if (!namespaceId) {
314
+ throw new Error("Could not determine namespace ID from project");
315
+ }
316
+ const rootNamespaceId = await resolveRootNamespaceId(instanceUrl, apiKey, namespaceId);
317
+ const graphqlUrl = `${instanceUrl}/api/graphql`;
318
+ const response = await fetch(graphqlUrl, {
319
+ method: "POST",
320
+ headers: {
321
+ "content-type": "application/json",
322
+ authorization: `Bearer ${apiKey}`
323
+ },
324
+ body: JSON.stringify({
325
+ query: AVAILABLE_MODELS_QUERY,
326
+ variables: { rootNamespaceId }
327
+ })
328
+ });
329
+ if (!response.ok) {
330
+ const text = await response.text();
331
+ throw new Error(`GraphQL request failed (${response.status}): ${text}`);
332
+ }
333
+ const result = await response.json();
334
+ if (result.errors?.length) {
335
+ throw new Error(result.errors.map((error) => error.message).join("; "));
336
+ }
337
+ const metadata = result.data?.metadata;
338
+ const available = result.data?.aiChatAvailableModels;
339
+ const defaultModel = available?.defaultModel ?? null;
340
+ const pinnedModel = available?.pinnedModel ?? null;
341
+ const selectableModels = available?.selectableModels ?? [];
342
+ const featureFlags = {};
343
+ for (const flag of metadata?.featureFlags ?? []) {
344
+ featureFlags[flag.name] = flag.enabled;
345
+ }
346
+ const models = {};
347
+ if (defaultModel?.ref) {
348
+ models[defaultModel.ref] = buildModelEntry(defaultModel.name || defaultModel.ref);
349
+ }
350
+ if (pinnedModel?.ref) {
351
+ models[pinnedModel.ref] = buildModelEntry(pinnedModel.name || pinnedModel.ref);
352
+ }
353
+ for (const model of selectableModels) {
354
+ if (model.ref && !models[model.ref]) {
355
+ models[model.ref] = buildModelEntry(model.name || model.ref);
356
+ }
357
+ }
358
+ if (Object.keys(models).length === 0) {
359
+ models[GITLAB_DUO_DEFAULT_MODEL_ID] = buildModelEntry(GITLAB_DUO_DEFAULT_MODEL_NAME);
360
+ }
361
+ return {
362
+ metadata: {
363
+ instanceUrl,
364
+ rootNamespaceId,
365
+ gitlabVersion: metadata?.version ?? null,
366
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
367
+ featureFlags,
368
+ defaultModel: defaultModel?.ref ?? null,
369
+ pinnedModel: pinnedModel?.ref ?? null
370
+ },
371
+ models,
372
+ available: {
373
+ defaultModel,
374
+ pinnedModel,
375
+ selectableModels
376
+ }
377
+ };
378
+ }
379
+ async function resolveCacheLocation(instanceUrl, cwd, projectPathHint) {
380
+ const projectPath = projectPathHint ?? await resolveProjectPath(cwd, instanceUrl);
381
+ const cacheDir = resolveCacheDirectory();
382
+ const cacheKey = `${instanceUrl}::${projectPath ?? "no-project"}`;
383
+ const hash = crypto.createHash("sha256").update(cacheKey).digest("hex").slice(0, 12);
384
+ return {
385
+ cachePath: path2.join(cacheDir, `gitlab-duo-models-${hash}.json`),
386
+ projectPath
387
+ };
388
+ }
389
+ function resolveCacheDirectory() {
390
+ const xdg = process.env.XDG_CACHE_HOME?.trim();
391
+ if (xdg) return path2.join(xdg, "opencode");
392
+ return path2.join(os.homedir(), ".cache", "opencode");
393
+ }
394
+ async function resolveProjectPath(cwd, instanceUrl) {
395
+ try {
396
+ return await detectProjectPath(cwd, instanceUrl);
397
+ } catch {
398
+ return void 0;
399
+ }
400
+ }
401
+ async function writeCache(cachePath, payload) {
402
+ const data = {
403
+ cachedAt: (/* @__PURE__ */ new Date()).toISOString(),
404
+ payload
405
+ };
406
+ await fs2.mkdir(path2.dirname(cachePath), { recursive: true });
407
+ await fs2.writeFile(cachePath, JSON.stringify(data, null, 2) + "\n", "utf8");
408
+ }
409
+ function normalizeInstanceUrl(value) {
410
+ return value.trim().replace(/\/$/, "");
411
+ }
412
+
31
413
  // src/plugin/models.ts
32
414
  async function loadGitLabModels(options = {}) {
415
+ const instanceUrl = resolveInstanceUrl(options.instanceUrl);
416
+ const apiKey = resolveApiKey(options.apiKey);
417
+ const cacheTtlSeconds = resolveModelsCacheTtlSeconds();
418
+ const cache = await loadModelsFromCache({
419
+ instanceUrl,
420
+ ttlSeconds: cacheTtlSeconds,
421
+ allowStale: true
422
+ });
423
+ if (cache && !cache.stale) {
424
+ console.log(
425
+ `[gitlab-duo] Loaded ${Object.keys(cache.payload.models).length} model(s) from cache ${cache.cachePath}`
426
+ );
427
+ return cache.payload.models;
428
+ }
429
+ if (apiKey) {
430
+ try {
431
+ const fetched = await fetchAndCacheModels({
432
+ instanceUrl,
433
+ apiKey
434
+ });
435
+ console.log(
436
+ `[gitlab-duo] Loaded ${Object.keys(fetched.payload.models).length} model(s) from GitLab API`
437
+ );
438
+ return fetched.payload.models;
439
+ } catch (error) {
440
+ console.warn(
441
+ `[gitlab-duo] Failed to fetch models from GitLab API: ${error instanceof Error ? error.message : String(error)}`
442
+ );
443
+ }
444
+ }
445
+ if (cache?.stale) {
446
+ console.warn(`[gitlab-duo] Using stale models cache from ${cache.cachePath}`);
447
+ return cache.payload.models;
448
+ }
33
449
  const modelsJsonPath = resolveModelsJsonPath(options.modelsPath);
450
+ const modelsFromFile = await loadModelsFromFile(modelsJsonPath);
451
+ if (modelsFromFile) return modelsFromFile;
452
+ return {
453
+ [GITLAB_DUO_DEFAULT_MODEL_ID]: buildModelEntry(GITLAB_DUO_DEFAULT_MODEL_NAME)
454
+ };
455
+ }
456
+ async function loadModelsFromFile(modelsJsonPath) {
34
457
  if (modelsJsonPath) {
35
458
  try {
36
- const raw = await fs.promises.readFile(modelsJsonPath, "utf8");
459
+ const raw = await fs3.promises.readFile(modelsJsonPath, "utf8");
37
460
  const data = JSON.parse(raw);
38
461
  if (data.models && Object.keys(data.models).length > 0) {
39
462
  console.log(
@@ -48,28 +471,36 @@ async function loadGitLabModels(options = {}) {
48
471
  );
49
472
  }
50
473
  }
51
- return {
52
- [GITLAB_DUO_DEFAULT_MODEL_ID]: buildModelEntry(GITLAB_DUO_DEFAULT_MODEL_NAME)
53
- };
474
+ return null;
54
475
  }
55
476
  function resolveModelsJsonPath(overridePath) {
56
477
  const override = typeof overridePath === "string" && overridePath.trim() ? overridePath.trim() : process.env.GITLAB_DUO_MODELS_PATH;
57
478
  if (override) {
58
- const resolved = path.isAbsolute(override) ? override : path.resolve(process.cwd(), override);
59
- if (fs.existsSync(resolved)) return resolved;
479
+ const resolved = path3.isAbsolute(override) ? override : path3.resolve(process.cwd(), override);
480
+ if (fs3.existsSync(resolved)) return resolved;
60
481
  console.warn(`[gitlab-duo] models.json not found at override path ${resolved}`);
61
482
  return null;
62
483
  }
63
484
  let current = process.cwd();
64
485
  while (true) {
65
- const candidate = path.join(current, "models.json");
66
- if (fs.existsSync(candidate)) return candidate;
67
- const parent = path.dirname(current);
486
+ const candidate = path3.join(current, "models.json");
487
+ if (fs3.existsSync(candidate)) return candidate;
488
+ const parent = path3.dirname(current);
68
489
  if (parent === current) break;
69
490
  current = parent;
70
491
  }
71
492
  return null;
72
493
  }
494
+ function resolveInstanceUrl(override) {
495
+ if (typeof override === "string" && override.trim()) return override.trim();
496
+ const fromEnv = process.env.GITLAB_INSTANCE_URL?.trim();
497
+ if (fromEnv) return fromEnv.replace(/\/$/, "");
498
+ return "https://gitlab.com";
499
+ }
500
+ function resolveApiKey(override) {
501
+ if (typeof override === "string" && override.trim()) return override.trim();
502
+ return process.env.GITLAB_TOKEN || "";
503
+ }
73
504
 
74
505
  // src/plugin/config.ts
75
506
  async function configHook(input) {
@@ -77,8 +508,8 @@ async function configHook(input) {
77
508
  const existing = input.provider[GITLAB_DUO_PROVIDER_ID];
78
509
  const existingOptions = existing?.options ?? {};
79
510
  const providerNpm = typeof existing?.npm === "string" && existing.npm.trim() ? existing.npm : GITLAB_DUO_PROVIDER_NPM_ENTRY;
80
- const apiKey = typeof existingOptions.apiKey === "string" ? existingOptions.apiKey : process.env.GITLAB_TOKEN || "";
81
- const instanceUrl = typeof existingOptions.instanceUrl === "string" ? existingOptions.instanceUrl : process.env.GITLAB_INSTANCE_URL || "https://gitlab.com";
511
+ const apiKey = typeof existingOptions.apiKey === "string" && existingOptions.apiKey.trim() ? existingOptions.apiKey.trim() : process.env.GITLAB_TOKEN || "";
512
+ const instanceUrl = typeof existingOptions.instanceUrl === "string" && existingOptions.instanceUrl.trim() ? existingOptions.instanceUrl.trim() : process.env.GITLAB_INSTANCE_URL?.trim() || "https://gitlab.com";
82
513
  const systemRules = typeof existingOptions.systemRules === "string" ? existingOptions.systemRules : "";
83
514
  const systemRulesPath = typeof existingOptions.systemRulesPath === "string" ? existingOptions.systemRulesPath : "";
84
515
  const modelsPath = typeof existingOptions.modelsPath === "string" ? existingOptions.modelsPath : void 0;
@@ -101,15 +532,15 @@ async function configHook(input) {
101
532
  enableMcp,
102
533
  systemRules: mergedSystemRules || void 0
103
534
  },
104
- models: await loadGitLabModels({ modelsPath })
535
+ models: await loadGitLabModels({ modelsPath, instanceUrl, apiKey })
105
536
  };
106
537
  }
107
538
  async function mergeSystemRules(rules, rulesPath) {
108
539
  const baseRules = rules.trim();
109
540
  if (!rulesPath) return baseRules;
110
- const resolvedPath = path2.isAbsolute(rulesPath) ? rulesPath : path2.resolve(process.cwd(), rulesPath);
541
+ const resolvedPath = path4.isAbsolute(rulesPath) ? rulesPath : path4.resolve(process.cwd(), rulesPath);
111
542
  try {
112
- const fileRules = (await fs2.promises.readFile(resolvedPath, "utf8")).trim();
543
+ const fileRules = (await fs4.promises.readFile(resolvedPath, "utf8")).trim();
113
544
  if (!fileRules) return baseRules;
114
545
  return baseRules ? `${baseRules}
115
546
 
@@ -122,8 +553,8 @@ ${fileRules}` : fileRules;
122
553
 
123
554
  // src/plugin/tools.ts
124
555
  import { tool } from "@opencode-ai/plugin";
125
- import path3 from "path";
126
- import fs3 from "fs";
556
+ import path5 from "path";
557
+ import fs5 from "fs";
127
558
  function createReadTools() {
128
559
  return {
129
560
  read_file: tool({
@@ -140,7 +571,7 @@ function createReadTools() {
140
571
  metadata: {}
141
572
  });
142
573
  try {
143
- return await fs3.promises.readFile(resolvedPath, "utf8");
574
+ return await fs5.promises.readFile(resolvedPath, "utf8");
144
575
  } catch (error) {
145
576
  throw new Error(formatReadError(displayPath, error));
146
577
  }
@@ -165,7 +596,7 @@ function createReadTools() {
165
596
  const results = await Promise.all(
166
597
  targets.map(async (target) => {
167
598
  try {
168
- const content = await fs3.promises.readFile(target.resolvedPath, "utf8");
599
+ const content = await fs5.promises.readFile(target.resolvedPath, "utf8");
169
600
  return [target.inputPath, { content }];
170
601
  } catch (error) {
171
602
  return [target.inputPath, { error: formatReadError(target.displayPath, error) }];
@@ -183,9 +614,9 @@ function createReadTools() {
183
614
  }
184
615
  function resolveReadPath(filePath, ctx) {
185
616
  const displayPath = filePath;
186
- const resolvedPath = path3.isAbsolute(filePath) ? filePath : path3.resolve(ctx.worktree, filePath);
187
- const worktreePath = path3.resolve(ctx.worktree);
188
- if (resolvedPath !== worktreePath && !resolvedPath.startsWith(worktreePath + path3.sep)) {
617
+ const resolvedPath = path5.isAbsolute(filePath) ? filePath : path5.resolve(ctx.worktree, filePath);
618
+ const worktreePath = path5.resolve(ctx.worktree);
619
+ if (resolvedPath !== worktreePath && !resolvedPath.startsWith(worktreePath + path5.sep)) {
189
620
  throw new Error(`File is outside the repository: "${displayPath}"`);
190
621
  }
191
622
  return { resolvedPath, displayPath };
@@ -1060,7 +1491,7 @@ function classifyModeReminder2(reminder) {
1060
1491
  }
1061
1492
 
1062
1493
  // src/provider/application/workflow_event_mapper.ts
1063
- import crypto from "crypto";
1494
+ import crypto2 from "crypto";
1064
1495
 
1065
1496
  // src/provider/core/ui_chat_log.ts
1066
1497
  import { z } from "zod";
@@ -1201,7 +1632,7 @@ var WorkflowEventMapper = class {
1201
1632
  break;
1202
1633
  }
1203
1634
  case "request": {
1204
- const requestId = latestMessage.correlation_id || crypto.randomUUID();
1635
+ const requestId = latestMessage.correlation_id || crypto2.randomUUID();
1205
1636
  events.push({
1206
1637
  type: "TOOL_REQUEST",
1207
1638
  requestId,
@@ -1706,175 +2137,6 @@ function parseHttpToolOutput(output) {
1706
2137
  // src/provider/adapters/default_runtime_dependencies.ts
1707
2138
  import { ProxyAgent } from "proxy-agent";
1708
2139
 
1709
- // src/provider/adapters/gitlab_utils.ts
1710
- import fs4 from "fs/promises";
1711
- import path4 from "path";
1712
- async function detectProjectPath(cwd, instanceUrl) {
1713
- let current = cwd;
1714
- const instance = new URL(instanceUrl);
1715
- const instanceHost = instance.host;
1716
- const instanceBasePath = instance.pathname.replace(/\/$/, "");
1717
- while (true) {
1718
- try {
1719
- const config = await readGitConfig(current);
1720
- const url = extractGitRemoteUrl(config) || "";
1721
- const remote = parseRemote(url);
1722
- if (!remote) {
1723
- return void 0;
1724
- }
1725
- if (remote.host !== instanceHost) {
1726
- throw new Error(
1727
- `GitLab remote host mismatch. Expected ${instanceHost}, got ${remote.host}.`
1728
- );
1729
- }
1730
- return normalizeProjectPath(remote.path, instanceBasePath);
1731
- } catch {
1732
- const parent = path4.dirname(current);
1733
- if (parent === current) return void 0;
1734
- current = parent;
1735
- }
1736
- }
1737
- }
1738
- function extractGitRemoteUrl(config) {
1739
- const lines = config.split("\n");
1740
- let inOrigin = false;
1741
- let originUrl;
1742
- let firstUrl;
1743
- for (const line of lines) {
1744
- const trimmed = line.trim();
1745
- const sectionMatch = /^\[remote\s+"([^"]+)"\]$/.exec(trimmed);
1746
- if (sectionMatch) {
1747
- inOrigin = sectionMatch[1] === "origin";
1748
- continue;
1749
- }
1750
- const urlMatch = /^url\s*=\s*(.+)$/.exec(trimmed);
1751
- if (urlMatch) {
1752
- const value = urlMatch[1].trim();
1753
- if (!firstUrl) firstUrl = value;
1754
- if (inOrigin) originUrl = value;
1755
- }
1756
- }
1757
- return originUrl ?? firstUrl;
1758
- }
1759
- function parseRemote(remoteUrl) {
1760
- if (!remoteUrl) return void 0;
1761
- if (remoteUrl.startsWith("http")) {
1762
- try {
1763
- const url = new URL(remoteUrl);
1764
- return { host: url.host, path: url.pathname.replace(/^\//, "") };
1765
- } catch {
1766
- return void 0;
1767
- }
1768
- }
1769
- if (remoteUrl.startsWith("git@")) {
1770
- const match = /^git@([^:]+):(.+)$/.exec(remoteUrl);
1771
- if (!match) return void 0;
1772
- return { host: match[1], path: match[2] };
1773
- }
1774
- if (remoteUrl.startsWith("ssh://")) {
1775
- try {
1776
- const url = new URL(remoteUrl);
1777
- return { host: url.host, path: url.pathname.replace(/^\//, "") };
1778
- } catch {
1779
- return void 0;
1780
- }
1781
- }
1782
- return void 0;
1783
- }
1784
- function normalizeProjectPath(remotePath, instanceBasePath) {
1785
- let pathValue = remotePath;
1786
- if (instanceBasePath && instanceBasePath !== "/") {
1787
- const base = instanceBasePath.replace(/^\//, "") + "/";
1788
- if (pathValue.startsWith(base)) {
1789
- pathValue = pathValue.slice(base.length);
1790
- }
1791
- }
1792
- const cleaned = stripGitSuffix(pathValue);
1793
- return cleaned.length > 0 ? cleaned : void 0;
1794
- }
1795
- function stripGitSuffix(pathname) {
1796
- return pathname.endsWith(".git") ? pathname.slice(0, -4) : pathname;
1797
- }
1798
- function buildApiUrl(instanceUrl, apiPath) {
1799
- const base = instanceUrl.endsWith("/") ? instanceUrl : `${instanceUrl}/`;
1800
- return new URL(apiPath.replace(/^\//, ""), base).toString();
1801
- }
1802
- function buildAuthHeaders(apiKey) {
1803
- return { authorization: `Bearer ${apiKey}` };
1804
- }
1805
- async function fetchProjectDetails(instanceUrl, apiKey, projectPath) {
1806
- const url = buildApiUrl(instanceUrl, `api/v4/projects/${encodeURIComponent(projectPath)}`);
1807
- const response = await fetch(url, {
1808
- headers: buildAuthHeaders(apiKey)
1809
- });
1810
- if (!response.ok) {
1811
- throw new Error(`Failed to fetch project details: ${response.status}`);
1812
- }
1813
- const data = await response.json();
1814
- return {
1815
- projectId: data.id ? String(data.id) : void 0,
1816
- namespaceId: data.namespace?.id ? String(data.namespace.id) : void 0
1817
- };
1818
- }
1819
- async function fetchProjectDetailsWithFallback(instanceUrl, apiKey, projectPath) {
1820
- const candidates = getProjectPathCandidates(projectPath);
1821
- for (const candidate of candidates) {
1822
- try {
1823
- return await fetchProjectDetails(instanceUrl, apiKey, candidate);
1824
- } catch {
1825
- continue;
1826
- }
1827
- }
1828
- try {
1829
- const name = projectPath.split("/").pop() || projectPath;
1830
- const searchUrl = new URL(buildApiUrl(instanceUrl, "api/v4/projects"));
1831
- searchUrl.searchParams.set("search", name);
1832
- searchUrl.searchParams.set("simple", "true");
1833
- searchUrl.searchParams.set("per_page", "100");
1834
- searchUrl.searchParams.set("membership", "true");
1835
- const response = await fetch(searchUrl.toString(), {
1836
- headers: buildAuthHeaders(apiKey)
1837
- });
1838
- if (!response.ok) {
1839
- throw new Error(`Failed to search projects: ${response.status}`);
1840
- }
1841
- const data = await response.json();
1842
- const match = data.find((project) => project.path_with_namespace === projectPath);
1843
- if (!match) {
1844
- throw new Error("Project not found via search");
1845
- }
1846
- return {
1847
- projectId: match.id ? String(match.id) : void 0,
1848
- namespaceId: match.namespace?.id ? String(match.namespace.id) : void 0
1849
- };
1850
- } catch {
1851
- throw new Error("Project not found via API");
1852
- }
1853
- }
1854
- function getProjectPathCandidates(projectPath) {
1855
- const candidates = /* @__PURE__ */ new Set();
1856
- candidates.add(projectPath);
1857
- const parts = projectPath.split("/");
1858
- if (parts.length > 2) {
1859
- const withoutFirst = parts.slice(1).join("/");
1860
- candidates.add(withoutFirst);
1861
- }
1862
- return Array.from(candidates);
1863
- }
1864
- async function readGitConfig(cwd) {
1865
- const gitPath = path4.join(cwd, ".git");
1866
- const stat = await fs4.stat(gitPath);
1867
- if (stat.isDirectory()) {
1868
- return fs4.readFile(path4.join(gitPath, "config"), "utf8");
1869
- }
1870
- const file = await fs4.readFile(gitPath, "utf8");
1871
- const match = /^gitdir:\s*(.+)$/m.exec(file);
1872
- if (!match) throw new Error("Invalid .git file");
1873
- const gitdir = match[1].trim();
1874
- const resolved = path4.isAbsolute(gitdir) ? gitdir : path4.join(cwd, gitdir);
1875
- return fs4.readFile(path4.join(resolved, "config"), "utf8");
1876
- }
1877
-
1878
2140
  // src/provider/adapters/workflow_service.ts
1879
2141
  var WorkflowCreateError = class extends Error {
1880
2142
  status;
@@ -2097,10 +2359,10 @@ function buildUserAgent() {
2097
2359
  }
2098
2360
 
2099
2361
  // src/provider/adapters/system_context.ts
2100
- import os from "os";
2362
+ import os2 from "os";
2101
2363
  function getSystemContextItems(systemRules) {
2102
- const platform = os.platform();
2103
- const arch = os.arch();
2364
+ const platform = os2.platform();
2365
+ const arch = os2.arch();
2104
2366
  const items = [
2105
2367
  {
2106
2368
  category: "os_information",
@@ -2334,24 +2596,54 @@ function assertDependencies() {
2334
2596
  );
2335
2597
  }
2336
2598
  }
2599
+ function resolveInstanceUrl2(value) {
2600
+ if (typeof value === "string" && value.trim()) {
2601
+ const normalized = value.trim();
2602
+ assertInstanceUrl(normalized);
2603
+ return normalized;
2604
+ }
2605
+ const fromEnv = process.env.GITLAB_INSTANCE_URL?.trim();
2606
+ if (fromEnv) {
2607
+ assertInstanceUrl(fromEnv);
2608
+ return fromEnv;
2609
+ }
2610
+ return "https://gitlab.com";
2611
+ }
2337
2612
  function assertInstanceUrl(value) {
2338
2613
  try {
2339
2614
  new URL(value);
2340
2615
  } catch {
2341
- throw new Error(`Invalid instanceUrl: "${value}"`);
2616
+ throw new Error(
2617
+ `Invalid instanceUrl: "${value}". Expected an absolute URL (for example https://gitlab.com).`
2618
+ );
2342
2619
  }
2343
2620
  }
2344
- function createGitLabDuoAgentic(options) {
2621
+ function resolveApiKey2(value) {
2622
+ if (typeof value === "string" && value.trim()) return value.trim();
2623
+ return process.env.GITLAB_TOKEN || "";
2624
+ }
2625
+ function createGitLabDuoAgentic(options = {}) {
2345
2626
  assertDependencies();
2346
- assertInstanceUrl(options.instanceUrl);
2627
+ const resolvedOptions = {
2628
+ instanceUrl: resolveInstanceUrl2(options.instanceUrl),
2629
+ apiKey: resolveApiKey2(options.apiKey),
2630
+ sendSystemContext: typeof options.sendSystemContext === "boolean" ? options.sendSystemContext : true,
2631
+ enableMcp: typeof options.enableMcp === "boolean" ? options.enableMcp : true,
2632
+ systemRules: typeof options.systemRules === "string" ? options.systemRules : void 0
2633
+ };
2634
+ if (!resolvedOptions.apiKey) {
2635
+ console.warn(
2636
+ "[gitlab-duo] GITLAB_TOKEN is empty for the OpenCode process. Ensure it is exported in the same shell."
2637
+ );
2638
+ }
2347
2639
  const container = createRuntimeContainer();
2348
2640
  const dependencies = container.resolve(
2349
2641
  RUNTIME_TOKENS.runtimeDependencies
2350
2642
  );
2351
- const sharedRuntime = new GitLabAgenticRuntime(options, dependencies);
2643
+ const sharedRuntime = new GitLabAgenticRuntime(resolvedOptions, dependencies);
2352
2644
  return {
2353
2645
  languageModel(modelId) {
2354
- return new GitLabDuoAgenticLanguageModel(modelId, options, sharedRuntime);
2646
+ return new GitLabDuoAgenticLanguageModel(modelId, resolvedOptions, sharedRuntime);
2355
2647
  },
2356
2648
  textEmbeddingModel() {
2357
2649
  throw new Error("GitLab Duo Agentic does not support text embedding models");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-gitlab-duo-agentic",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "OpenCode plugin and provider for GitLab Duo Agentic workflows",
5
5
  "license": "MIT",
6
6
  "type": "module",