duoops 0.1.6 → 0.1.9

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
@@ -27,18 +27,6 @@ DuoOps is a developer-focused CLI and Web Portal designed to make GitLab CI/CD p
27
27
 
28
28
  ### Installation
29
29
 
30
- #### Install from npm (recommended)
31
-
32
- ```bash
33
- # Install the published CLI globally
34
- npm install -g duoops
35
-
36
- # Verify the binary is available
37
- duoops --help
38
- ```
39
-
40
- #### Install from source (development)
41
-
42
30
  ```bash
43
31
  # Clone the repository
44
32
  git clone https://github.com/youneslaaroussi/duoops.git
@@ -146,35 +134,6 @@ duoops portal
146
134
 
147
135
  Open your browser to `http://localhost:3000`.
148
136
 
149
- ### 6. Runner Utilities
150
-
151
- Once you've provisioned a runner with `duoops init`, you can inspect and manage it directly from the CLI:
152
-
153
- ```bash
154
- # Tail the runner's systemd logs (defaults to gitlab-runner service)
155
- duoops runner:logs --lines 300
156
-
157
- # Reinstall the DuoOps CLI on the VM (uses the current CLI version by default)
158
- duoops runner:reinstall-duoops --version 0.1.0
159
- ```
160
-
161
- ### 7. Use the GitLab CI Component
162
-
163
- Instead of copying `.duoops/measure-component.yml` into every project, you can reference the published component directly:
164
-
165
- ```yaml
166
- include:
167
- - component: gitlab.com/youneslaaroussi/duoops/templates/duoops-measure-component@v0.1.0
168
- inputs:
169
- gcp_project_id: "my-project"
170
- gcp_instance_id: "1234567890123456789"
171
- gcp_zone: "us-central1-a"
172
- machine_type: "e2-standard-4"
173
- tags: ["gcp"]
174
- ```
175
-
176
- Make sure your runner already has DuoOps installed (the provisioning flow handles this) and that `GCP_SA_KEY_BASE64` plus any optional BigQuery variables are set in the project’s CI/CD settings. Pin to a version tag (`@v0.1.0`) and update when you’re ready to adopt new behavior.
177
-
178
137
  ## 🛠️ Development
179
138
 
180
139
  ### Project Structure
@@ -198,20 +157,6 @@ pnpm build
198
157
 
199
158
  This compiles the TypeScript CLI and builds the React frontend, copying assets to the `dist/` directory.
200
159
 
201
- ## 📦 Publishing to npm
202
-
203
- 1. Update `package.json` with the release version you want to publish.
204
- 2. Run the checks: `pnpm test` (this runs lint afterwards) to ensure the CLI and portal build cleanly.
205
- 3. Inspect the publish payload locally with `pnpm pack` (this runs the `prepack` script, which now builds the CLI, portal, and Oclif manifest automatically). Untar the generated `.tgz` if you want to double-check the contents.
206
- 4. When you're satisfied, publish the package: `pnpm publish --access public`.
207
-
208
- ## 📦 Publishing the CI Component
209
-
210
- 1. Update `templates/duoops-measure-component.yml` and commit the changes.
211
- 2. Let the `.gitlab-ci.yml` validation job pass (runs automatically on MRs, default branch, and tags).
212
- 3. Tag the repository (e.g., `git tag v0.1.0 && git push origin v0.1.0`). Consumers reference the component with the tag via the snippet above.
213
- 4. Update release notes/README with the new component version so teams know what changed.
214
-
215
160
  ## 📄 License
216
161
 
217
162
  MIT
@@ -1,8 +1,11 @@
1
1
  import { Command } from '@oclif/core';
2
2
  export default class Ask extends Command {
3
3
  static args: {
4
- question: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
4
+ question: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
5
  };
6
6
  static description: string;
7
+ static flags: {
8
+ project: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ };
7
10
  run(): Promise<void>;
8
11
  }
@@ -1,18 +1,49 @@
1
- import { Args, Command } from '@oclif/core';
1
+ import { search } from '@inquirer/prompts';
2
+ import { Args, Command, Flags } from '@oclif/core';
2
3
  import { runAgent } from '../lib/ai/agent.js';
4
+ import { PROMPT_LIBRARY } from '../lib/ai/prompt-library.js';
3
5
  export default class Ask extends Command {
4
6
  static args = {
5
7
  question: Args.string({
6
8
  description: 'The question to ask the agent',
7
- required: true,
9
+ required: false,
8
10
  }),
9
11
  };
10
12
  static description = 'Ask questions about your CI/CD pipelines, logs, and sustainability';
13
+ static flags = {
14
+ project: Flags.string({
15
+ char: 'P',
16
+ description: 'GitLab Project ID to contextually use',
17
+ }),
18
+ };
11
19
  async run() {
12
- const { args } = await this.parse(Ask);
20
+ const { args, flags } = await this.parse(Ask);
21
+ let { question } = args;
22
+ if (!question) {
23
+ question = await search({
24
+ message: 'Select a prompt from the library:',
25
+ async source(term) {
26
+ if (!term) {
27
+ return PROMPT_LIBRARY.map((p) => ({
28
+ description: p.description,
29
+ name: p.title,
30
+ value: p.description, // Passing description as the prompt to the agent
31
+ }));
32
+ }
33
+ return PROMPT_LIBRARY
34
+ .filter((p) => p.title.toLowerCase().includes(term.toLowerCase()) ||
35
+ p.description.toLowerCase().includes(term.toLowerCase()))
36
+ .map((p) => ({
37
+ description: p.description,
38
+ name: p.title,
39
+ value: p.description,
40
+ }));
41
+ },
42
+ });
43
+ }
13
44
  this.log('Thinking...');
14
45
  try {
15
- const response = await runAgent(args.question);
46
+ const response = await runAgent(question, { projectId: flags.project });
16
47
  this.log('\n' + response);
17
48
  }
18
49
  catch (error) {
@@ -0,0 +1,10 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Config extends Command {
3
+ static args: {
4
+ key: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ value: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static examples: string[];
9
+ run(): Promise<void>;
10
+ }
@@ -0,0 +1,47 @@
1
+ import { Args, Command } from '@oclif/core';
2
+ import { bold, green } from 'kleur/colors';
3
+ import { configManager } from '../lib/config.js';
4
+ export default class Config extends Command {
5
+ static args = {
6
+ key: Args.string({
7
+ description: 'Config key (e.g., gitlabUrl, defaultProjectId)',
8
+ required: true,
9
+ }),
10
+ value: Args.string({
11
+ description: 'Config value',
12
+ required: true,
13
+ }),
14
+ };
15
+ static description = 'Set a configuration value directly';
16
+ static examples = [
17
+ '<%= config.bin %> <%= command.id %> gitlabUrl https://gitlab.com',
18
+ '<%= config.bin %> <%= command.id %> defaultProjectId 123456',
19
+ ];
20
+ async run() {
21
+ const { args } = await this.parse(Config);
22
+ const { key, value } = args;
23
+ const currentConfig = configManager.get();
24
+ // Basic nested update support for measure.*
25
+ if (key.startsWith('measure.')) {
26
+ const subKey = key.split('.')[1];
27
+ const measure = currentConfig.measure || {
28
+ bigqueryDataset: '',
29
+ bigqueryTable: '',
30
+ googleProjectId: ''
31
+ };
32
+ // @ts-expect-error - dynamic assignment
33
+ measure[subKey] = value;
34
+ configManager.set({
35
+ ...currentConfig,
36
+ measure
37
+ });
38
+ }
39
+ else {
40
+ configManager.set({
41
+ ...currentConfig,
42
+ [key]: value,
43
+ });
44
+ }
45
+ this.log(green(`Updated ${bold(key)} to ${bold(value)}`));
46
+ }
47
+ }
@@ -28,13 +28,13 @@ export default class Portal extends Command {
28
28
  /* eslint-disable max-depth -- stream consumer adds necessary nesting */
29
29
  app.post('/api/chat', async (req, res) => {
30
30
  try {
31
- const { messages } = req.body;
31
+ const { messages, projectId } = req.body;
32
32
  if (!messages || !Array.isArray(messages)) {
33
33
  res.status(400).json({ error: 'Messages array is required' });
34
34
  return;
35
35
  }
36
36
  const modelMessages = await convertToModelMessages(messages);
37
- const result = streamAgent(modelMessages);
37
+ const result = streamAgent(modelMessages, { projectId });
38
38
  const response = result.toUIMessageStreamResponse();
39
39
  res.status(response.status);
40
40
  for (const [k, v] of response.headers.entries())
@@ -79,12 +79,14 @@ export default class Portal extends Command {
79
79
  });
80
80
  app.get('/api/metrics', async (req, res) => {
81
81
  try {
82
+ const config = configManager.get();
82
83
  const { projectId } = req.query;
83
- if (!projectId) {
84
- res.status(400).json({ error: 'Project ID is required' });
84
+ const targetProjectId = projectId ? String(projectId) : config.defaultProjectId;
85
+ if (!targetProjectId) {
86
+ res.status(400).json({ error: 'Project ID is required and no default is configured' });
85
87
  return;
86
88
  }
87
- const metrics = await fetchCarbonMetrics(String(projectId), 50);
89
+ const metrics = await fetchCarbonMetrics(targetProjectId, 50);
88
90
  res.json(metrics);
89
91
  }
90
92
  catch (error) {
@@ -1,6 +1,10 @@
1
1
  import type { ModelMessage } from 'ai';
2
- export declare const runAgent: (input: ModelMessage[] | string) => Promise<string>;
2
+ export declare const runAgent: (input: ModelMessage[] | string, context?: {
3
+ projectId?: string;
4
+ }) => Promise<string>;
3
5
  /** Same as runAgent but returns a streamText result for UI streaming (tool steps, etc.). */
4
- export declare function streamAgent(messages: ModelMessage[]): {
6
+ export declare function streamAgent(messages: ModelMessage[], context?: {
7
+ projectId?: string;
8
+ }): {
5
9
  toUIMessageStreamResponse(): Response;
6
10
  };
@@ -6,6 +6,48 @@ import { editingTools } from './tools/editing.js';
6
6
  import { fileTools } from './tools/filesystem.js';
7
7
  import { gitlabTools } from './tools/gitlab.js';
8
8
  import { measureTools } from './tools/measure.js';
9
+ const baseTools = {
10
+ ...editingTools,
11
+ ...fileTools,
12
+ ...gitlabTools,
13
+ ...measureTools,
14
+ };
15
+ function createSystemPrompt(hasProjectId) {
16
+ const projectLine = hasProjectId
17
+ ? 'The portal already selected the GitLab project for you. All GitLab tools automatically target that project. Never attempt to guess, request, or change the project ID.'
18
+ : 'If a GitLab project has not been configured yet, ask the user to select one before running GitLab tools.';
19
+ return `You are a DevOps assistant.
20
+ ${projectLine}
21
+ Use the available tools to answer questions about pipelines, jobs, and sustainability.`;
22
+ }
23
+ function createTools(projectId) {
24
+ return Object.fromEntries(Object.entries(baseTools).map(([name, t]) => [
25
+ name,
26
+ {
27
+ ...t,
28
+ async execute(args, options) {
29
+ const toolOptions = {
30
+ ...(typeof options === 'object' && options !== null ? options : {}),
31
+ projectId,
32
+ };
33
+ const logArgs = typeof args === 'object' && args !== null
34
+ ? Object.fromEntries(Object.entries(args).filter(([key]) => key !== 'projectId' && key !== 'project_id'))
35
+ : args;
36
+ logger.info({ args: logArgs, tool: name }, 'tool call');
37
+ try {
38
+ const toolInstance = t;
39
+ const result = await toolInstance.execute(args, toolOptions);
40
+ logger.info({ result, tool: name }, 'tool result');
41
+ return result;
42
+ }
43
+ catch (error) {
44
+ logger.warn({ error: String(error), tool: name }, 'tool error');
45
+ throw error;
46
+ }
47
+ },
48
+ },
49
+ ]));
50
+ }
9
51
  /** Ensure no message has empty text content (Anthropic rejects "text content blocks must be non-empty"). */
10
52
  function sanitizeMessages(messages) {
11
53
  return messages.map((m) => {
@@ -38,37 +80,12 @@ function sanitizeMessages(messages) {
38
80
  return m;
39
81
  });
40
82
  }
41
- export const runAgent = async (input) => {
83
+ export const runAgent = async (input, context) => {
42
84
  const model = createModel();
43
85
  const config = configManager.get();
44
- const system = `You are a DevOps assistant.
45
- ${config.defaultProjectId ? `The default GitLab Project ID is: ${config.defaultProjectId}. If the user doesn't specify a project, use this one.` : ''}
46
- Use the available tools to answer questions about pipelines, jobs, and sustainability.`;
47
- const rawTools = {
48
- ...editingTools,
49
- ...fileTools,
50
- ...gitlabTools,
51
- ...measureTools,
52
- };
53
- /** Wrap each tool's execute to log invocations and results (for debugging tool round-trips). */
54
- const tools = Object.fromEntries(Object.entries(rawTools).map(([name, t]) => [
55
- name,
56
- {
57
- ...t,
58
- async execute(args, options) {
59
- logger.info({ args, tool: name }, 'tool call');
60
- try {
61
- const result = await t.execute(args, options);
62
- logger.info({ result, tool: name }, 'tool result');
63
- return result;
64
- }
65
- catch (error) {
66
- logger.warn({ error: String(error), tool: name }, 'tool error');
67
- throw error;
68
- }
69
- },
70
- },
71
- ]));
86
+ const projectId = context?.projectId || config.defaultProjectId;
87
+ const system = createSystemPrompt(Boolean(projectId));
88
+ const tools = createTools(projectId);
72
89
  const rawMessages = Array.isArray(input)
73
90
  ? input
74
91
  : [{ content: input.trim() || ' ', role: 'user' }];
@@ -91,36 +108,12 @@ Use the available tools to answer questions about pipelines, jobs, and sustainab
91
108
  return result.text ?? '';
92
109
  };
93
110
  /** Same as runAgent but returns a streamText result for UI streaming (tool steps, etc.). */
94
- export function streamAgent(messages) {
111
+ export function streamAgent(messages, context) {
95
112
  const model = createModel();
96
113
  const config = configManager.get();
97
- const system = `You are a DevOps assistant.
98
- ${config.defaultProjectId ? `The default GitLab Project ID is: ${config.defaultProjectId}. If the user doesn't specify a project, use this one.` : ''}
99
- Use the available tools to answer questions about pipelines, jobs, and sustainability.`;
100
- const rawTools = {
101
- ...editingTools,
102
- ...fileTools,
103
- ...gitlabTools,
104
- ...measureTools,
105
- };
106
- const tools = Object.fromEntries(Object.entries(rawTools).map(([name, t]) => [
107
- name,
108
- {
109
- ...t,
110
- async execute(args, options) {
111
- logger.info({ args, tool: name }, 'tool call');
112
- try {
113
- const result = await t.execute(args, options);
114
- logger.info({ result, tool: name }, 'tool result');
115
- return result;
116
- }
117
- catch (error) {
118
- logger.warn({ error: String(error), tool: name }, 'tool error');
119
- throw error;
120
- }
121
- },
122
- },
123
- ]));
114
+ const projectId = context?.projectId || config.defaultProjectId;
115
+ const system = createSystemPrompt(Boolean(projectId));
116
+ const tools = createTools(projectId);
124
117
  const sanitized = sanitizeMessages(messages);
125
118
  if (sanitized.length === 0) {
126
119
  throw new Error('Please send a non-empty message.');
@@ -0,0 +1,13 @@
1
+ /**
2
+ * GitLab Duo Prompt Library
3
+ * Source: https://about.gitlab.com/gitlab-duo/prompt-library/
4
+ */
5
+ export interface PromptTemplate {
6
+ category: string;
7
+ complexity?: 'Advanced' | 'Beginner' | 'Intermediate';
8
+ description: string;
9
+ tags?: string[];
10
+ title: string;
11
+ tool?: string;
12
+ }
13
+ export declare const PROMPT_LIBRARY: PromptTemplate[];