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 +0 -55
- package/dist/commands/ask.d.ts +4 -1
- package/dist/commands/ask.js +35 -4
- package/dist/commands/config.d.ts +10 -0
- package/dist/commands/config.js +47 -0
- package/dist/commands/portal.js +7 -5
- package/dist/lib/ai/agent.d.ts +6 -2
- package/dist/lib/ai/agent.js +50 -57
- package/dist/lib/ai/prompt-library.d.ts +13 -0
- package/dist/lib/ai/prompt-library.js +335 -0
- package/dist/lib/ai/tools/gitlab.d.ts +4 -0
- package/dist/lib/ai/tools/gitlab.js +162 -11
- package/dist/lib/ai/tools/measure.js +7 -3
- package/dist/lib/ai/tools/types.d.ts +3 -0
- package/dist/lib/ai/tools/types.js +1 -0
- package/dist/portal/assets/MetricsDashboard-Bnj-jtu6.js +27 -0
- package/dist/portal/assets/index-B1SGDQNX.css +1 -0
- package/dist/portal/assets/index-Bk8OVV7a.js +106 -0
- package/dist/portal/assets/{index-B6bzT1Vv.js → index-C54ZhVUo.js} +1 -1
- package/dist/portal/index.html +2 -2
- package/oclif.manifest.json +47 -3
- package/package.json +1 -1
- package/templates/duoops-measure-component.yml +7 -4
- package/dist/portal/assets/MetricsDashboard-DIsoz4Sl.js +0 -71
- package/dist/portal/assets/index-BP8FwWqA.css +0 -1
- package/dist/portal/assets/index-DkVG3jel.js +0 -70
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
|
package/dist/commands/ask.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/commands/ask.js
CHANGED
|
@@ -1,18 +1,49 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
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(
|
|
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
|
+
}
|
package/dist/commands/portal.js
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
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(
|
|
89
|
+
const metrics = await fetchCarbonMetrics(targetProjectId, 50);
|
|
88
90
|
res.json(metrics);
|
|
89
91
|
}
|
|
90
92
|
catch (error) {
|
package/dist/lib/ai/agent.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { ModelMessage } from 'ai';
|
|
2
|
-
export declare const runAgent: (input: ModelMessage[] | 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
|
};
|
package/dist/lib/ai/agent.js
CHANGED
|
@@ -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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
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[];
|