duoops 0.1.7 → 0.2.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.
Files changed (64) hide show
  1. package/README.md +151 -63
  2. package/data/aws_machine_power_profiles.json +54 -0
  3. package/data/cpu_physical_specs.json +105 -0
  4. package/data/cpu_power_profiles.json +275 -0
  5. package/data/gcp_machine_power_profiles.json +1802 -0
  6. package/data/runtime-pue-mappings.json +183 -0
  7. package/dist/commands/ask.d.ts +3 -0
  8. package/dist/commands/ask.js +9 -3
  9. package/dist/commands/autofix-ci.d.ts +13 -0
  10. package/dist/commands/autofix-ci.js +114 -0
  11. package/dist/commands/autofix.d.ts +5 -0
  12. package/dist/commands/autofix.js +11 -0
  13. package/dist/commands/config.d.ts +10 -0
  14. package/dist/commands/config.js +47 -0
  15. package/dist/commands/init.js +50 -27
  16. package/dist/commands/mcp/deploy.d.ts +13 -0
  17. package/dist/commands/mcp/deploy.js +139 -0
  18. package/dist/commands/measure/calculate.js +2 -2
  19. package/dist/commands/portal.js +428 -11
  20. package/dist/lib/ai/agent.d.ts +6 -2
  21. package/dist/lib/ai/agent.js +51 -57
  22. package/dist/lib/ai/tools/editing.js +28 -13
  23. package/dist/lib/ai/tools/gitlab.d.ts +4 -0
  24. package/dist/lib/ai/tools/gitlab.js +166 -11
  25. package/dist/lib/ai/tools/measure.js +7 -3
  26. package/dist/lib/ai/tools/types.d.ts +3 -0
  27. package/dist/lib/ai/tools/types.js +1 -0
  28. package/dist/lib/config.d.ts +10 -0
  29. package/dist/lib/gcloud.d.ts +7 -0
  30. package/dist/lib/gcloud.js +105 -0
  31. package/dist/lib/gitlab/pipelines-service.d.ts +23 -0
  32. package/dist/lib/gitlab/pipelines-service.js +146 -0
  33. package/dist/lib/gitlab/runner-service.d.ts +11 -0
  34. package/dist/lib/gitlab/runner-service.js +15 -0
  35. package/dist/lib/portal/settings.d.ts +3 -0
  36. package/dist/lib/portal/settings.js +48 -0
  37. package/dist/lib/scaffold.d.ts +5 -0
  38. package/dist/lib/scaffold.js +32 -0
  39. package/dist/portal/assets/HomeDashboard-DlkwSyKx.js +1 -0
  40. package/dist/portal/assets/JobDetailsDrawer-7kXXMSH8.js +1 -0
  41. package/dist/portal/assets/JobsDashboard-D4pNc9TM.js +1 -0
  42. package/dist/portal/assets/MetricsDashboard-BcgzvzBz.js +1 -0
  43. package/dist/portal/assets/PipelinesDashboard-BNrSM9GB.js +1 -0
  44. package/dist/portal/assets/allPaths-CXDKahbk.js +1 -0
  45. package/dist/portal/assets/allPathsLoader-BF5PAx2c.js +2 -0
  46. package/dist/portal/assets/cache-YerT0Slh.js +6 -0
  47. package/dist/portal/assets/core-Cz8f3oSB.js +19 -0
  48. package/dist/portal/assets/{index-B6bzT1Vv.js → index-B9sNUqEC.js} +1 -1
  49. package/dist/portal/assets/index-BWa_E8Y7.css +1 -0
  50. package/dist/portal/assets/index-Bp4RqK05.js +1 -0
  51. package/dist/portal/assets/index-DW6Qp0d6.js +64 -0
  52. package/dist/portal/assets/index-Uc4Xhv31.js +1 -0
  53. package/dist/portal/assets/progressBar-C4SmnGeZ.js +1 -0
  54. package/dist/portal/assets/splitPathsBySizeLoader-C-T9_API.js +1 -0
  55. package/dist/portal/index.html +2 -2
  56. package/oclif.manifest.json +282 -93
  57. package/package.json +2 -1
  58. package/templates/.gitlab/duo/flows/duoops.yaml +114 -0
  59. package/templates/agents/agent.yml +45 -0
  60. package/templates/duoops-autofix-component.yml +52 -0
  61. package/templates/flows/flow.yml +283 -0
  62. package/dist/portal/assets/MetricsDashboard-DIsoz4Sl.js +0 -71
  63. package/dist/portal/assets/index-BP8FwWqA.css +0 -1
  64. package/dist/portal/assets/index-DkVG3jel.js +0 -70
@@ -6,6 +6,49 @@ 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
+ Before changing CI configs or runner scripts, inspect the DuoOps CLI source (e.g. src/commands/measure/*) and confirm whether the tool already bundles required data under the package's data/ directory; avoid inventing placeholder directories or files when the CLI provides them.
22
+ Use the available tools to answer questions about pipelines, jobs, and sustainability.`;
23
+ }
24
+ function createTools(projectId) {
25
+ return Object.fromEntries(Object.entries(baseTools).map(([name, t]) => [
26
+ name,
27
+ {
28
+ ...t,
29
+ async execute(args, options) {
30
+ const toolOptions = {
31
+ ...(typeof options === 'object' && options !== null ? options : {}),
32
+ projectId,
33
+ };
34
+ const logArgs = typeof args === 'object' && args !== null
35
+ ? Object.fromEntries(Object.entries(args).filter(([key]) => key !== 'projectId' && key !== 'project_id'))
36
+ : args;
37
+ logger.info({ args: logArgs, tool: name }, 'tool call');
38
+ try {
39
+ const toolInstance = t;
40
+ const result = await toolInstance.execute(args, toolOptions);
41
+ logger.info({ result, tool: name }, 'tool result');
42
+ return result;
43
+ }
44
+ catch (error) {
45
+ logger.warn({ error: String(error), tool: name }, 'tool error');
46
+ throw error;
47
+ }
48
+ },
49
+ },
50
+ ]));
51
+ }
9
52
  /** Ensure no message has empty text content (Anthropic rejects "text content blocks must be non-empty"). */
10
53
  function sanitizeMessages(messages) {
11
54
  return messages.map((m) => {
@@ -38,37 +81,12 @@ function sanitizeMessages(messages) {
38
81
  return m;
39
82
  });
40
83
  }
41
- export const runAgent = async (input) => {
84
+ export const runAgent = async (input, context) => {
42
85
  const model = createModel();
43
86
  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
- ]));
87
+ const projectId = context?.projectId || config.defaultProjectId;
88
+ const system = createSystemPrompt(Boolean(projectId));
89
+ const tools = createTools(projectId);
72
90
  const rawMessages = Array.isArray(input)
73
91
  ? input
74
92
  : [{ content: input.trim() || ' ', role: 'user' }];
@@ -91,36 +109,12 @@ Use the available tools to answer questions about pipelines, jobs, and sustainab
91
109
  return result.text ?? '';
92
110
  };
93
111
  /** Same as runAgent but returns a streamText result for UI streaming (tool steps, etc.). */
94
- export function streamAgent(messages) {
112
+ export function streamAgent(messages, context) {
95
113
  const model = createModel();
96
114
  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
- ]));
115
+ const projectId = context?.projectId || config.defaultProjectId;
116
+ const system = createSystemPrompt(Boolean(projectId));
117
+ const tools = createTools(projectId);
124
118
  const sanitized = sanitizeMessages(messages);
125
119
  if (sanitized.length === 0) {
126
120
  throw new Error('Please send a non-empty message.');
@@ -8,34 +8,49 @@ import { z } from 'zod';
8
8
  import { ensureDuoOpsDir, PATCHES_DIR } from '../../state.js';
9
9
  const execAsync = promisify(exec);
10
10
  /* eslint-disable camelcase */
11
- const patchFileSchema = z.object({
12
- filePath: z.string().describe('Relative path to the file to edit'),
13
- newString: z.string().describe('The new content to replace with'),
14
- oldString: z.string().describe('The exact existing content to search for and replace'),
11
+ const patchFileSchema = z
12
+ .object({
13
+ filePath: z.string().describe('Relative path to the file to edit').optional(),
14
+ newString: z.string().describe('The new content to replace with').optional(),
15
+ oldString: z.string().describe('The exact existing content to search for and replace').optional(),
16
+ original_content: z.string().describe('Alias for oldString used by some agents').optional(),
17
+ patched_content: z.string().describe('Alias for newString used by some agents').optional(),
18
+ path: z.string().describe('Alias for filePath used by some agents').optional(),
19
+ })
20
+ .refine((args) => Boolean((args.filePath ?? args.path) &&
21
+ (args.oldString ?? args.original_content) &&
22
+ (args.newString ?? args.patched_content)), {
23
+ message: 'filePath/path, oldString/original_content, and newString/patched_content are required',
15
24
  });
16
25
  export const editingTools = {
17
26
  patch_file: tool({
18
27
  description: 'Safely edit a file by creating and applying a reversible patch',
19
28
  async execute(args) {
20
29
  try {
21
- const fullPath = path.resolve(process.cwd(), args.filePath);
30
+ const filePath = args.filePath ?? args.path;
31
+ const oldString = args.oldString ?? args.original_content;
32
+ const newString = args.newString ?? args.patched_content;
33
+ if (!filePath || !oldString || !newString) {
34
+ return { error: 'filePath/path, oldString/original_content, and newString/patched_content are required' };
35
+ }
36
+ const fullPath = path.resolve(process.cwd(), filePath);
22
37
  if (!fs.existsSync(fullPath)) {
23
- return { error: `File not found: ${args.filePath}` };
38
+ return { error: `File not found: ${filePath}` };
24
39
  }
25
40
  const content = fs.readFileSync(fullPath, 'utf8');
26
41
  // Safety check: Ensure oldString exists exactly once to avoid ambiguity
27
42
  // Or at least exists.
28
- if (!content.includes(args.oldString)) {
29
- return { error: `Could not find exact match for oldString in ${args.filePath}. Please read the file again to ensure accuracy.` };
43
+ if (!content.includes(oldString)) {
44
+ return { error: `Could not find exact match for oldString in ${filePath}. Please read the file again to ensure accuracy.` };
30
45
  }
31
- const newContent = content.replaceAll(args.oldString, args.newString);
46
+ const newContent = content.replaceAll(oldString, newString);
32
47
  // Generate Unified Diff
33
48
  // createPatch(fileName, oldStr, newStr, oldHeader, newHeader)
34
- const patchContent = createPatch(args.filePath, content, newContent);
49
+ const patchContent = createPatch(filePath, content, newContent);
35
50
  // Save patch
36
51
  ensureDuoOpsDir();
37
52
  const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
38
- const patchName = `${path.basename(args.filePath)}-${timestamp}.patch`;
53
+ const patchName = `${path.basename(filePath)}-${timestamp}.patch`;
39
54
  const patchPath = path.join(PATCHES_DIR, patchName);
40
55
  fs.writeFileSync(patchPath, patchContent);
41
56
  // Apply patch using git apply
@@ -43,7 +58,7 @@ export const editingTools = {
43
58
  // --ignore-space-change might be useful, but let's be strict first
44
59
  await execAsync(`git apply "${patchPath}"`);
45
60
  return {
46
- message: `Successfully applied patch to ${args.filePath}`,
61
+ message: `Successfully applied patch to ${filePath}`,
47
62
  patchPath,
48
63
  status: 'success',
49
64
  };
@@ -58,4 +73,4 @@ export const editingTools = {
58
73
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
74
  }),
60
75
  };
61
- /* eslint-enable camelcase */
76
+ /* eslint-enable camelcase */
@@ -1,4 +1,8 @@
1
1
  export declare const gitlabTools: {
2
2
  get_job_logs: import("ai").Tool<unknown, unknown>;
3
+ get_pipeline_details: import("ai").Tool<unknown, unknown>;
4
+ get_project: import("ai").Tool<unknown, unknown>;
5
+ list_jobs: import("ai").Tool<unknown, unknown>;
3
6
  list_pipelines: import("ai").Tool<unknown, unknown>;
7
+ search_projects: import("ai").Tool<unknown, unknown>;
4
8
  };
@@ -1,25 +1,44 @@
1
1
  import { generateText, tool } from 'ai';
2
2
  import { z } from 'zod';
3
+ import { configManager } from '../../config.js';
3
4
  import { createGitlabClient } from '../../gitlab/client.js';
4
5
  import { createModel } from '../model.js';
5
6
  /* eslint-disable camelcase */
7
+ /* eslint-disable @typescript-eslint/no-explicit-any */
6
8
  const getJobLogsSchema = z.object({
7
9
  jobId: z.number().describe('GitLab Job ID'),
8
- projectId: z.string().describe('GitLab Project ID'),
9
10
  });
10
11
  const listPipelinesSchema = z.object({
11
12
  limit: z.number().default(5).optional(),
12
- projectId: z.string().describe('GitLab Project ID'),
13
+ status: z.string().optional().describe('Filter by status (e.g. success, failed, running)'),
14
+ });
15
+ const getPipelineDetailsSchema = z.object({
16
+ pipelineId: z.number().describe('GitLab Pipeline ID'),
17
+ });
18
+ const listJobsSchema = z.object({
19
+ limit: z.number().default(20).optional(),
20
+ pipelineId: z.number().optional().describe('Filter by Pipeline ID'),
21
+ scope: z.enum(['created', 'pending', 'running', 'failed', 'success', 'canceled', 'skipped', 'manual']).optional(),
22
+ });
23
+ const searchProjectsSchema = z.object({
24
+ query: z.string().describe('Search query (project name or path)'),
25
+ });
26
+ const getProjectSchema = z.object({
27
+ projectPathOrId: z.string().optional().describe('GitLab Project Path (group/project) or ID'),
13
28
  });
14
29
  export const gitlabTools = {
15
30
  get_job_logs: tool({
16
31
  description: 'Get the logs of a specific job to debug failures. If logs are long, it uses a subagent to summarize them.',
17
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
- async execute(args) {
32
+ async execute(args, options) {
19
33
  try {
34
+ const projectId = options?.projectId || configManager.get().defaultProjectId;
35
+ const jobId = args.jobId || args.job_id;
36
+ if (!projectId)
37
+ throw new Error('Project ID is required (argument or default config)');
38
+ if (!jobId)
39
+ throw new Error('Job ID is required');
20
40
  const client = createGitlabClient();
21
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
- const trace = await client.Jobs.showTrace(args.projectId, args.jobId);
41
+ const trace = await client.Jobs.showLog(projectId, jobId);
23
42
  const logText = typeof trace === 'string' ? trace : JSON.stringify(trace);
24
43
  // If logs are short, return directly
25
44
  if (logText.length < 5000) {
@@ -51,15 +70,117 @@ ${content}`
51
70
  }
52
71
  },
53
72
  parameters: getJobLogsSchema,
54
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
+ }),
74
+ get_pipeline_details: tool({
75
+ description: 'Get detailed information about a specific pipeline, including its jobs and status.',
76
+ async execute(args, options) {
77
+ try {
78
+ const projectId = options?.projectId || configManager.get().defaultProjectId;
79
+ const pipelineId = args.pipelineId || args.pipeline_id;
80
+ if (!projectId)
81
+ throw new Error('Project ID is required (argument or default config)');
82
+ if (!pipelineId)
83
+ throw new Error('Pipeline ID is required');
84
+ const client = createGitlabClient();
85
+ const pipeline = await client.Pipelines.show(projectId, pipelineId);
86
+ const jobs = await client.Jobs.all(projectId, { pipelineId });
87
+ return {
88
+ jobs: jobs.map((j) => ({
89
+ id: j.id,
90
+ name: j.name,
91
+ stage: j.stage,
92
+ status: j.status,
93
+ })),
94
+ pipeline: {
95
+ created_at: pipeline.created_at,
96
+ duration: pipeline.duration,
97
+ id: pipeline.id,
98
+ ref: pipeline.ref,
99
+ status: pipeline.status,
100
+ web_url: pipeline.web_url,
101
+ },
102
+ };
103
+ }
104
+ catch (error) {
105
+ return { error: String(error) };
106
+ }
107
+ },
108
+ parameters: getPipelineDetailsSchema,
109
+ }),
110
+ get_project: tool({
111
+ description: 'Get details of a specific project by its path (e.g. "group/project") or ID to find its numeric ID.',
112
+ async execute(args, options) {
113
+ try {
114
+ const projectPathOrId = options?.projectId || args.projectPathOrId || configManager.get().defaultProjectId;
115
+ if (!projectPathOrId)
116
+ throw new Error('Project Path or ID is required (argument or default config)');
117
+ const client = createGitlabClient();
118
+ const project = await client.Projects.show(projectPathOrId);
119
+ return {
120
+ project: {
121
+ description: project.description,
122
+ id: project.id,
123
+ name: project.name,
124
+ path_with_namespace: project.path_with_namespace,
125
+ web_url: project.web_url,
126
+ }
127
+ };
128
+ }
129
+ catch (error) {
130
+ return { error: `Failed to find project "${args.projectPathOrId || options?.projectId || 'unknown'}": ${String(error)}` };
131
+ }
132
+ },
133
+ parameters: getProjectSchema,
134
+ }),
135
+ list_jobs: tool({
136
+ description: 'List jobs for a project, optionally filtered by pipeline or status scope.',
137
+ async execute(args, options) {
138
+ try {
139
+ const projectId = options?.projectId || configManager.get().defaultProjectId;
140
+ const pipelineId = args.pipelineId || args.pipeline_id;
141
+ if (!projectId)
142
+ throw new Error('Project ID is required (argument or default config)');
143
+ const client = createGitlabClient();
144
+ const queryOptions = {};
145
+ if (typeof args.limit === 'number') {
146
+ queryOptions.perPage = args.limit;
147
+ }
148
+ if (args.scope) {
149
+ queryOptions.scope = args.scope;
150
+ }
151
+ const jobs = pipelineId
152
+ ? await client.Jobs.all(projectId, { ...queryOptions, pipelineId })
153
+ : await client.Jobs.all(projectId, queryOptions);
154
+ return {
155
+ jobs: jobs.map((j) => ({
156
+ created_at: j.created_at,
157
+ id: j.id,
158
+ name: j.name,
159
+ pipeline: { id: j.pipeline.id },
160
+ stage: j.stage,
161
+ status: j.status,
162
+ web_url: j.web_url,
163
+ })),
164
+ };
165
+ }
166
+ catch (error) {
167
+ return { error: String(error) };
168
+ }
169
+ },
170
+ parameters: listJobsSchema,
55
171
  }),
56
172
  list_pipelines: tool({
57
173
  description: 'List recent pipelines for a project to check status',
58
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
- async execute(args) {
174
+ async execute(args, options) {
60
175
  try {
176
+ const projectId = options?.projectId || configManager.get().defaultProjectId;
177
+ if (!projectId)
178
+ throw new Error('Project ID is required (argument or default config)');
61
179
  const client = createGitlabClient();
62
- const pipelines = await client.Pipelines.all(args.projectId, { perPage: args.limit });
180
+ const queryOptions = { perPage: args.limit };
181
+ if (args.status)
182
+ queryOptions.status = args.status;
183
+ const pipelines = await client.Pipelines.all(projectId, queryOptions);
63
184
  return {
64
185
  pipelines: pipelines.map((p) => ({
65
186
  created_at: p.created_at,
@@ -75,7 +196,41 @@ ${content}`
75
196
  }
76
197
  },
77
198
  parameters: listPipelinesSchema,
78
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
199
+ }),
200
+ search_projects: tool({
201
+ description: 'Search for a GitLab project by name or path to find its ID.',
202
+ async execute(args) {
203
+ try {
204
+ const client = createGitlabClient();
205
+ // search() is good for name but less precise than path.
206
+ // It's often useful for discovering projects.
207
+ // In some GitBeaker versions, this might be Projects.search(query) or Projects.all({search: query})
208
+ // We will try Projects.search(query) first, fallback to all({search}) if fails
209
+ let projects;
210
+ try {
211
+ projects = await client.Projects.search(args.query);
212
+ }
213
+ catch {
214
+ projects = await client.Projects.all({ search: args.query });
215
+ }
216
+ if (!Array.isArray(projects)) {
217
+ projects = [projects];
218
+ }
219
+ return {
220
+ projects: projects.map((p) => ({
221
+ description: p.description,
222
+ id: p.id,
223
+ name: p.name,
224
+ path_with_namespace: p.path_with_namespace,
225
+ web_url: p.web_url,
226
+ })),
227
+ };
228
+ }
229
+ catch (error) {
230
+ return { error: String(error) };
231
+ }
232
+ },
233
+ parameters: searchProjectsSchema,
79
234
  }),
80
235
  };
81
236
  /* eslint-enable camelcase */
@@ -1,18 +1,22 @@
1
1
  import { tool } from 'ai';
2
2
  import { z } from 'zod';
3
+ import { configManager } from '../../config.js';
3
4
  import { fetchCarbonMetrics } from '../../measure/bigquery-service.js';
4
5
  /* eslint-disable camelcase */
5
6
  const getCarbonMetricsSchema = z.object({
6
7
  limit: z.number().default(10).optional(),
7
- projectId: z.string().describe('GitLab Project ID (numeric)'),
8
8
  });
9
9
  export const measureTools = {
10
10
  get_carbon_metrics: tool({
11
11
  description: 'Query carbon emissions for a project from BigQuery',
12
12
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
- async execute(args) {
13
+ async execute(args, options) {
14
14
  try {
15
- const metrics = await fetchCarbonMetrics(args.projectId, args.limit);
15
+ const projectId = options?.projectId || configManager.get().defaultProjectId;
16
+ if (!projectId) {
17
+ return { error: 'Project ID is required. Please select a project in the portal or configure a default project.' };
18
+ }
19
+ const metrics = await fetchCarbonMetrics(projectId, args.limit);
16
20
  return { metrics };
17
21
  }
18
22
  catch (error) {
@@ -0,0 +1,3 @@
1
+ export interface ToolRuntimeContext {
2
+ projectId?: string;
3
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -8,6 +8,9 @@ export interface DuoOpsConfig {
8
8
  bigqueryTable?: string;
9
9
  googleProjectId?: string;
10
10
  };
11
+ portal?: {
12
+ budgets?: PortalBudgetConfig[];
13
+ };
11
14
  runner?: {
12
15
  gcpInstanceId?: string;
13
16
  gcpProjectId?: string;
@@ -16,6 +19,13 @@ export interface DuoOpsConfig {
16
19
  machineType?: string;
17
20
  };
18
21
  }
22
+ export interface PortalBudgetConfig {
23
+ id: string;
24
+ limit: number;
25
+ name: string;
26
+ period: string;
27
+ unit: string;
28
+ }
19
29
  export declare class ConfigManager {
20
30
  private configPath;
21
31
  constructor();
@@ -0,0 +1,7 @@
1
+ export declare function requireGcloud(): void;
2
+ export declare function getActiveAccount(): string;
3
+ export declare function detectGcpProject(): string | undefined;
4
+ export declare function validateProjectAccess(project: string): void;
5
+ export declare function enableApis(project: string, apis: string[], log: (msg: string) => void): void;
6
+ export declare function getProjectNumber(project: string): string | undefined;
7
+ export declare function ensureCloudBuildServiceAccount(project: string, log: (msg: string) => void): void;
@@ -0,0 +1,105 @@
1
+ import { execSync } from 'node:child_process';
2
+ export function requireGcloud() {
3
+ try {
4
+ execSync('gcloud --version', { stdio: 'ignore' });
5
+ }
6
+ catch {
7
+ throw new Error('gcloud CLI is not installed or not found in PATH. Install it from https://cloud.google.com/sdk');
8
+ }
9
+ }
10
+ export function getActiveAccount() {
11
+ try {
12
+ return execSync('gcloud config get-value account', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
13
+ }
14
+ catch {
15
+ return 'unknown';
16
+ }
17
+ }
18
+ export function detectGcpProject() {
19
+ try {
20
+ const value = execSync('gcloud config get-value project', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
21
+ return value && value !== '(unset)' ? value : undefined;
22
+ }
23
+ catch { }
24
+ }
25
+ export function validateProjectAccess(project) {
26
+ const account = getActiveAccount();
27
+ try {
28
+ execSync(`gcloud projects describe ${project} --format="value(projectId)"`, {
29
+ encoding: 'utf8',
30
+ stdio: ['pipe', 'pipe', 'pipe'],
31
+ });
32
+ }
33
+ catch (error) {
34
+ const stderr = error.stderr || '';
35
+ if (stderr.includes('does not have permission') || stderr.includes('PERMISSION_DENIED')) {
36
+ throw new Error(`Account "${account}" does not have access to project "${project}".\n\n` +
37
+ `Either:\n` +
38
+ ` 1. Switch account: gcloud config set account <account-with-access>\n` +
39
+ ` 2. Grant access: Grant Owner/Editor role to ${account} on project ${project}\n` +
40
+ ` 3. Use another project: --gcp-project <project-id>`);
41
+ }
42
+ if (stderr.includes('not exist')) {
43
+ throw new Error(`GCP project "${project}" does not exist. Check the project ID.`);
44
+ }
45
+ throw new Error(`Cannot access GCP project "${project}": ${stderr.trim()}`);
46
+ }
47
+ }
48
+ export function enableApis(project, apis, log) {
49
+ for (const api of apis) {
50
+ try {
51
+ execSync(`gcloud services enable ${api} --project=${project} --quiet`, {
52
+ encoding: 'utf8',
53
+ stdio: ['pipe', 'pipe', 'pipe'],
54
+ });
55
+ log(` ✓ ${api}`);
56
+ }
57
+ catch (error) {
58
+ const stderr = error.stderr || '';
59
+ if (stderr.includes('PERMISSION_DENIED') || stderr.includes('AUTH_PERMISSION_DENIED')) {
60
+ log(` ? ${api} — no permission to enable, will attempt to proceed`);
61
+ }
62
+ else {
63
+ throw new Error(`Failed to enable ${api}. Enable it manually:\n` +
64
+ ` https://console.developers.google.com/apis/api/${api}/overview?project=${project}\n\n` +
65
+ `Then retry.`);
66
+ }
67
+ }
68
+ }
69
+ }
70
+ export function getProjectNumber(project) {
71
+ try {
72
+ return execSync(`gcloud projects describe ${project} --format="value(projectNumber)"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim() || undefined;
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ }
78
+ export function ensureCloudBuildServiceAccount(project, log) {
79
+ const projectNumber = getProjectNumber(project);
80
+ if (!projectNumber) {
81
+ log(' ? Could not determine project number, skipping service account setup');
82
+ return;
83
+ }
84
+ const computeSa = `${projectNumber}-compute@developer.gserviceaccount.com`;
85
+ const roles = [
86
+ 'roles/cloudbuild.builds.builder',
87
+ 'roles/storage.admin',
88
+ ];
89
+ log(` Granting Cloud Build permissions to ${computeSa}...`);
90
+ for (const role of roles) {
91
+ try {
92
+ execSync(`gcloud projects add-iam-policy-binding ${project} --member="serviceAccount:${computeSa}" --role="${role}" --quiet`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
93
+ log(` ✓ ${role}`);
94
+ }
95
+ catch (error) {
96
+ const stderr = error.stderr || '';
97
+ if (stderr.includes('PERMISSION_DENIED')) {
98
+ log(` ? ${role} — no permission to grant, will attempt deploy anyway`);
99
+ }
100
+ else {
101
+ log(` ? ${role} — failed: ${stderr.trim().split('\n')[0]}`);
102
+ }
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,23 @@
1
+ export interface PortalPipeline {
2
+ branch: string;
3
+ commit_message?: string;
4
+ created_at: string;
5
+ duration_seconds?: number;
6
+ id: number;
7
+ sha: string;
8
+ status: string;
9
+ triggered_by?: string;
10
+ updated_at?: string;
11
+ web_url?: string;
12
+ }
13
+ export interface PortalPipelinePage {
14
+ hasNextPage: boolean;
15
+ hasPrevPage: boolean;
16
+ page: number;
17
+ perPage: number;
18
+ pipelines: PortalPipeline[];
19
+ }
20
+ export declare function fetchPortalPipelines(projectId: string, options?: {
21
+ limit?: number;
22
+ page?: number;
23
+ }): Promise<PortalPipelinePage>;