@imdeadpool/guardex 7.0.39 → 7.0.43
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 +85 -16
- package/package.json +2 -1
- package/skills/gitguardex/SKILL.md +13 -0
- package/skills/guardex-merge-skills-to-dev/SKILL.md +59 -0
- package/src/agents/cleanup-sessions.js +126 -0
- package/src/agents/detect.js +160 -0
- package/src/agents/finish.js +172 -0
- package/src/agents/inspect.js +189 -0
- package/src/agents/launch.js +240 -0
- package/src/agents/registry.js +133 -0
- package/src/agents/selection-panel.js +571 -0
- package/src/agents/sessions.js +151 -0
- package/src/agents/start.js +591 -0
- package/src/agents/status.js +143 -0
- package/src/agents/terminal.js +152 -0
- package/src/budget/index.js +343 -0
- package/src/ci-init/index.js +265 -0
- package/src/cli/args.js +305 -1
- package/src/cli/main.js +262 -132
- package/src/cockpit/action-runner.js +3 -0
- package/src/cockpit/actions.js +80 -0
- package/src/cockpit/control.js +1121 -0
- package/src/cockpit/index.js +426 -0
- package/src/cockpit/keybindings.js +224 -0
- package/src/cockpit/kitty-layout.js +549 -0
- package/src/cockpit/kitty-tree.js +144 -0
- package/src/cockpit/layout.js +224 -0
- package/src/cockpit/logs-reader.js +182 -0
- package/src/cockpit/menu.js +204 -0
- package/src/cockpit/pane-actions.js +597 -0
- package/src/cockpit/pane-menu.js +387 -0
- package/src/cockpit/projects-finder.js +178 -0
- package/src/cockpit/render.js +215 -0
- package/src/cockpit/settings-render.js +128 -0
- package/src/cockpit/settings.js +124 -0
- package/src/cockpit/shortcuts.js +24 -0
- package/src/cockpit/sidebar.js +311 -0
- package/src/cockpit/state.js +72 -0
- package/src/cockpit/theme.js +128 -0
- package/src/cockpit/welcome.js +266 -0
- package/src/context.js +78 -35
- package/src/doctor/index.js +4 -3
- package/src/finish/index.js +39 -2
- package/src/git/index.js +65 -0
- package/src/kitty/command.js +101 -0
- package/src/kitty/runtime.js +250 -0
- package/src/output/index.js +1 -1
- package/src/pr-review.js +241 -0
- package/src/scaffold/index.js +19 -0
- package/src/submodule/index.js +288 -0
- package/src/terminal/index.js +120 -0
- package/src/terminal/kitty.js +622 -0
- package/src/terminal/tmux.js +126 -0
- package/src/tmux/command.js +27 -0
- package/src/tmux/session.js +89 -0
- package/templates/AGENTS.multiagent-safety.md +421 -37
- package/templates/codex/skills/gitguardex/SKILL.md +2 -0
- package/templates/githooks/pre-commit +22 -1
- package/templates/github/workflows/README.md +87 -0
- package/templates/github/workflows/ci-full.yml +55 -0
- package/templates/github/workflows/ci.yml +56 -0
- package/templates/github/workflows/cr.yml +20 -1
- package/templates/scripts/agent-branch-finish.sh +545 -27
- package/templates/scripts/agent-branch-start.sh +193 -21
- package/templates/scripts/agent-preflight.sh +89 -0
- package/templates/scripts/agent-worktree-prune.sh +96 -5
- package/templates/scripts/codex-agent.sh +41 -6
- package/templates/scripts/openspec/init-plan-workspace.sh +43 -0
- package/templates/scripts/review-bot-watch.sh +31 -2
- package/templates/scripts/agent-session-state.js +0 -171
- package/templates/scripts/install-vscode-active-agents-extension.js +0 -135
- package/templates/vscode/guardex-active-agents/README.md +0 -34
- package/templates/vscode/guardex-active-agents/extension.js +0 -3782
- package/templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json +0 -54
- package/templates/vscode/guardex-active-agents/fileicons/icons/agent.svg +0 -5
- package/templates/vscode/guardex-active-agents/fileicons/icons/branch.svg +0 -7
- package/templates/vscode/guardex-active-agents/fileicons/icons/config.svg +0 -4
- package/templates/vscode/guardex-active-agents/fileicons/icons/hook.svg +0 -4
- package/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg +0 -5
- package/templates/vscode/guardex-active-agents/fileicons/icons/plan.svg +0 -4
- package/templates/vscode/guardex-active-agents/fileicons/icons/spec.svg +0 -5
- package/templates/vscode/guardex-active-agents/icon.png +0 -0
- package/templates/vscode/guardex-active-agents/media/active-agents-hivemind.svg +0 -14
- package/templates/vscode/guardex-active-agents/package.json +0 -169
- package/templates/vscode/guardex-active-agents/session-schema.js +0 -1348
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const { fs, path, LOCK_FILE_RELATIVE, TOOL_NAME } = require('../context');
|
|
2
|
+
const { changedFiles } = require('./inspect');
|
|
3
|
+
const { listAgentSessions } = require('./sessions');
|
|
4
|
+
|
|
5
|
+
function uniqueSorted(values) {
|
|
6
|
+
return Array.from(new Set((values || []).filter(Boolean))).sort((left, right) => left.localeCompare(right));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readLockDetails(repoRoot) {
|
|
10
|
+
const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE);
|
|
11
|
+
let parsed = null;
|
|
12
|
+
try {
|
|
13
|
+
parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
14
|
+
} catch (_error) {
|
|
15
|
+
parsed = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const locks = parsed?.locks && typeof parsed.locks === 'object' && !Array.isArray(parsed.locks)
|
|
19
|
+
? parsed.locks
|
|
20
|
+
: {};
|
|
21
|
+
const counts = new Map();
|
|
22
|
+
const files = new Map();
|
|
23
|
+
for (const entry of Object.values(locks)) {
|
|
24
|
+
const branch = typeof entry?.branch === 'string' ? entry.branch : '';
|
|
25
|
+
if (!branch) continue;
|
|
26
|
+
counts.set(branch, (counts.get(branch) || 0) + 1);
|
|
27
|
+
}
|
|
28
|
+
for (const [filePath, entry] of Object.entries(locks)) {
|
|
29
|
+
const branch = typeof entry?.branch === 'string' ? entry.branch : '';
|
|
30
|
+
if (!branch) continue;
|
|
31
|
+
const branchFiles = files.get(branch) || [];
|
|
32
|
+
branchFiles.push(filePath);
|
|
33
|
+
files.set(branch, branchFiles);
|
|
34
|
+
}
|
|
35
|
+
return { counts, files };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readChangedFiles(repoRoot, branch) {
|
|
39
|
+
if (!branch) return [];
|
|
40
|
+
try {
|
|
41
|
+
return changedFiles({ target: repoRoot, branch }).files || [];
|
|
42
|
+
} catch (_error) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizePr(session) {
|
|
48
|
+
const pr = session.pr && typeof session.pr === 'object' ? session.pr : {};
|
|
49
|
+
const evidence = session.finishEvidence && typeof session.finishEvidence === 'object' ? session.finishEvidence : {};
|
|
50
|
+
return {
|
|
51
|
+
url: pr.url || evidence.prUrl || '',
|
|
52
|
+
state: pr.state || evidence.mergeState || '',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeSessionForStatus(session, lockDetails, repoRoot) {
|
|
57
|
+
const branch = session.branch || '';
|
|
58
|
+
const worktreePath = session.worktreePath || '';
|
|
59
|
+
const claimedFiles = uniqueSorted([
|
|
60
|
+
...((lockDetails.files.get(branch) || [])),
|
|
61
|
+
...(Array.isArray(session.claims) ? session.claims : []),
|
|
62
|
+
]);
|
|
63
|
+
const pr = normalizePr(session);
|
|
64
|
+
return {
|
|
65
|
+
id: session.id || '',
|
|
66
|
+
agent: session.agent || '',
|
|
67
|
+
task: session.task || '',
|
|
68
|
+
branch,
|
|
69
|
+
base: session.base || '',
|
|
70
|
+
status: session.status || '',
|
|
71
|
+
activity: session.activity || session.status || '',
|
|
72
|
+
worktreePath,
|
|
73
|
+
worktreeExists: worktreePath ? fs.existsSync(worktreePath) : false,
|
|
74
|
+
lockCount: lockDetails.counts.get(branch) || 0,
|
|
75
|
+
claimedFiles,
|
|
76
|
+
changedFiles: readChangedFiles(repoRoot, branch),
|
|
77
|
+
metadata: session.metadata && typeof session.metadata === 'object' ? session.metadata : {},
|
|
78
|
+
launchCommand: session.launchCommand || '',
|
|
79
|
+
tmux: session.tmux && typeof session.tmux === 'object' ? session.tmux : null,
|
|
80
|
+
prUrl: pr.url,
|
|
81
|
+
prState: pr.state,
|
|
82
|
+
pr,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildAgentsStatusPayload(repoRoot) {
|
|
87
|
+
const lockDetails = readLockDetails(repoRoot);
|
|
88
|
+
return {
|
|
89
|
+
schemaVersion: 1,
|
|
90
|
+
repoRoot,
|
|
91
|
+
sessions: listAgentSessions(repoRoot).map((session) => normalizeSessionForStatus(session, lockDetails, repoRoot)),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const buildAgentsStatus = buildAgentsStatusPayload;
|
|
96
|
+
|
|
97
|
+
function formatValue(value) {
|
|
98
|
+
const text = String(value || '');
|
|
99
|
+
return text || '-';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function metadataSummary(metadata) {
|
|
103
|
+
if (!metadata || typeof metadata !== 'object') return '';
|
|
104
|
+
return Object.entries(metadata)
|
|
105
|
+
.filter(([key, value]) => key.startsWith('colony.') && value !== null && value !== undefined && String(value) !== '')
|
|
106
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
107
|
+
.join(' ');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderAgentsStatus(payload, options = {}) {
|
|
111
|
+
if (options.json) return `${JSON.stringify(payload, null, 2)}\n`;
|
|
112
|
+
|
|
113
|
+
if (payload.sessions.length === 0) {
|
|
114
|
+
return `[${TOOL_NAME}] Agent sessions: none (${payload.repoRoot})\n`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const lines = [`[${TOOL_NAME}] Agent sessions: ${payload.sessions.length} (${payload.repoRoot})`];
|
|
118
|
+
for (const session of payload.sessions) {
|
|
119
|
+
const meta = metadataSummary(session.metadata);
|
|
120
|
+
const pr = session.prUrl || session.prState ? ` pr=${formatValue(session.prState)} ${formatValue(session.prUrl)}` : '';
|
|
121
|
+
lines.push(
|
|
122
|
+
`- ${formatValue(session.id)} ${formatValue(session.agent)} ${formatValue(session.status)} ` +
|
|
123
|
+
`branch=${formatValue(session.branch)} base=${formatValue(session.base)} ` +
|
|
124
|
+
`worktreeExists=${session.worktreeExists ? 'yes' : 'no'} locks=${session.lockCount} ` +
|
|
125
|
+
`changed=${Array.isArray(session.changedFiles) ? session.changedFiles.length : 0}${pr} ` +
|
|
126
|
+
`task=${formatValue(session.task)} worktree=${formatValue(session.worktreePath)}` +
|
|
127
|
+
`${meta ? ` meta=${meta}` : ''}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return `${lines.join('\n')}\n`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function runStatusCommand(repoRoot, options = {}) {
|
|
134
|
+
return renderAgentsStatus(buildAgentsStatusPayload(repoRoot), options);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
buildAgentsStatusPayload,
|
|
139
|
+
buildAgentsStatus,
|
|
140
|
+
readLockDetails,
|
|
141
|
+
renderAgentsStatus,
|
|
142
|
+
runStatusCommand,
|
|
143
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const { TOOL_NAME } = require('../context');
|
|
7
|
+
const { run } = require('../core/runtime');
|
|
8
|
+
const { shellQuote } = require('./launch');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_AGENT_TERMINAL = 'kitty';
|
|
11
|
+
const SUPPORTED_AGENT_TERMINALS = new Set(['kitty', 'none']);
|
|
12
|
+
|
|
13
|
+
function normalizeAgentTerminal(value) {
|
|
14
|
+
const terminal = String(value || DEFAULT_AGENT_TERMINAL).trim().toLowerCase();
|
|
15
|
+
return terminal || DEFAULT_AGENT_TERMINAL;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sanitizeFileSegment(value) {
|
|
19
|
+
return String(value || 'agents')
|
|
20
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '__')
|
|
21
|
+
.replace(/^_+|_+$/g, '')
|
|
22
|
+
.slice(0, 120) || 'agents';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function terminalSessionDir(repoRoot) {
|
|
26
|
+
return path.join(repoRoot, '.guardex', 'agents', 'terminals');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function terminalSessionFilePath(repoRoot, sessions, terminal = DEFAULT_AGENT_TERMINAL) {
|
|
30
|
+
const firstSession = sessions[0] || {};
|
|
31
|
+
const sessionId = sanitizeFileSegment(firstSession.id || firstSession.branch || 'agents');
|
|
32
|
+
return path.join(terminalSessionDir(repoRoot), `${sessionId}-${sessions.length}.${terminal}-session`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sessionTitle(session, index) {
|
|
36
|
+
const branch = String(session.branch || session.id || `agent-${index + 1}`);
|
|
37
|
+
const leaf = branch.split('/').filter(Boolean).pop() || branch;
|
|
38
|
+
return `${index + 1}: ${session.agent || 'agent'} ${leaf}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildKittySession(sessions, options = {}) {
|
|
42
|
+
const lines = ['# Generated by gx agents start.'];
|
|
43
|
+
const welcomeTitle = options.welcomeTitle || 'gx welcome';
|
|
44
|
+
const welcomeCommand = options.welcomeCommand || 'gx';
|
|
45
|
+
|
|
46
|
+
lines.push(
|
|
47
|
+
'',
|
|
48
|
+
`new_tab ${shellQuote(welcomeTitle)}`,
|
|
49
|
+
);
|
|
50
|
+
if (options.repoRoot) {
|
|
51
|
+
lines.push(`cd ${shellQuote(options.repoRoot)}`);
|
|
52
|
+
}
|
|
53
|
+
lines.push(`launch --title ${shellQuote(welcomeTitle)} sh -lc ${shellQuote(welcomeCommand)}`);
|
|
54
|
+
|
|
55
|
+
sessions.forEach((session, index) => {
|
|
56
|
+
const title = sessionTitle(session, index);
|
|
57
|
+
lines.push(
|
|
58
|
+
'',
|
|
59
|
+
`new_tab ${shellQuote(title)}`,
|
|
60
|
+
`cd ${shellQuote(session.worktreePath)}`,
|
|
61
|
+
`launch --title ${shellQuote(title)} sh -lc ${shellQuote(session.launchCommand)}`,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
return `${lines.join('\n')}\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeKittySessionFile(repoRoot, sessions) {
|
|
68
|
+
const filePath = terminalSessionFilePath(repoRoot, sessions, 'kitty');
|
|
69
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
70
|
+
fs.writeFileSync(filePath, buildKittySession(sessions, { repoRoot }), { encoding: 'utf8', mode: 0o600 });
|
|
71
|
+
return filePath;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function recoveryLines(sessionFilePath, reason) {
|
|
75
|
+
const detail = reason ? `: ${reason}` : '.';
|
|
76
|
+
return [
|
|
77
|
+
`[${TOOL_NAME}] Kitty terminal not launched${detail}`,
|
|
78
|
+
`[${TOOL_NAME}] Kitty session file: ${sessionFilePath}`,
|
|
79
|
+
`[${TOOL_NAME}] Recovery: kitty --detach --session ${shellQuote(sessionFilePath)}`,
|
|
80
|
+
`[${TOOL_NAME}] Agent lanes are intact; run the recovery command when Kitty is available.`,
|
|
81
|
+
'',
|
|
82
|
+
].join('\n');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resultReason(result, fallback) {
|
|
86
|
+
if (result?.error?.message) return result.error.message;
|
|
87
|
+
if (typeof result?.status === 'number') return `${fallback} exited ${result.status}`;
|
|
88
|
+
return fallback;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function launchAgentTerminal(repoRoot, sessions, options = {}) {
|
|
92
|
+
const terminal = normalizeAgentTerminal(options.terminal);
|
|
93
|
+
if (terminal === 'none' || !Array.isArray(sessions) || sessions.length === 0) {
|
|
94
|
+
return { status: 'skipped', stdout: '', stderr: '', sessionFilePath: '' };
|
|
95
|
+
}
|
|
96
|
+
if (!SUPPORTED_AGENT_TERMINALS.has(terminal)) {
|
|
97
|
+
return {
|
|
98
|
+
status: 'unsupported',
|
|
99
|
+
stdout: '',
|
|
100
|
+
stderr: `[${TOOL_NAME}] Unsupported agent terminal '${terminal}'. Supported terminals: kitty, none.\n`,
|
|
101
|
+
sessionFilePath: '',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sessionFilePath = writeKittySessionFile(repoRoot, sessions);
|
|
106
|
+
const runner = options.runner || run;
|
|
107
|
+
if (typeof runner !== 'function') {
|
|
108
|
+
return {
|
|
109
|
+
status: 'missing',
|
|
110
|
+
stdout: '',
|
|
111
|
+
stderr: recoveryLines(sessionFilePath, 'terminal runner unavailable'),
|
|
112
|
+
sessionFilePath,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
const kittyBin = options.kittyBin || process.env.GUARDEX_KITTY_BIN || 'kitty';
|
|
116
|
+
const probe = runner(kittyBin, ['--version'], { cwd: repoRoot, stdio: 'pipe' });
|
|
117
|
+
if (probe?.error || probe?.status !== 0) {
|
|
118
|
+
return {
|
|
119
|
+
status: 'missing',
|
|
120
|
+
stdout: '',
|
|
121
|
+
stderr: recoveryLines(sessionFilePath, resultReason(probe, `${kittyBin} --version`)),
|
|
122
|
+
sessionFilePath,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const launch = runner(kittyBin, ['--detach', '--session', sessionFilePath], { cwd: repoRoot, stdio: 'ignore' });
|
|
127
|
+
if (launch?.error || launch?.status !== 0) {
|
|
128
|
+
return {
|
|
129
|
+
status: 'failed',
|
|
130
|
+
stdout: '',
|
|
131
|
+
stderr: recoveryLines(sessionFilePath, resultReason(launch, `${kittyBin} --detach`)),
|
|
132
|
+
sessionFilePath,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
status: 'launched',
|
|
138
|
+
stdout: `[${TOOL_NAME}] Kitty agent terminal: ${sessionFilePath}\n`,
|
|
139
|
+
stderr: '',
|
|
140
|
+
sessionFilePath,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
DEFAULT_AGENT_TERMINAL,
|
|
146
|
+
buildKittySession,
|
|
147
|
+
launchAgentTerminal,
|
|
148
|
+
normalizeAgentTerminal,
|
|
149
|
+
recoveryLines,
|
|
150
|
+
terminalSessionFilePath,
|
|
151
|
+
writeKittySessionFile,
|
|
152
|
+
};
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const cp = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
const TOOL_NAME = 'gx';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_WARN_NET_USD = 1; // any paid spend at all
|
|
8
|
+
const DEFAULT_CRITICAL_NET_USD = 10; // paid spend that has caused merge blocks before
|
|
9
|
+
|
|
10
|
+
function runGh(args) {
|
|
11
|
+
const result = cp.spawnSync('gh', args, { encoding: 'utf8' });
|
|
12
|
+
if (result.error) {
|
|
13
|
+
const err = new Error(`gh binary not found: ${result.error.message}`);
|
|
14
|
+
err.code = 'GH_BIN_MISSING';
|
|
15
|
+
throw err;
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ghApi(endpoint) {
|
|
21
|
+
const result = runGh(['api', endpoint]);
|
|
22
|
+
if (result.status !== 0) {
|
|
23
|
+
const message = (result.stderr || result.stdout || '').trim();
|
|
24
|
+
if (/404/.test(message)) {
|
|
25
|
+
const err = new Error(`GitHub API 404: ${endpoint}`);
|
|
26
|
+
err.code = 'GH_API_NOT_FOUND';
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
if (/403/.test(message)) {
|
|
30
|
+
const err = new Error(
|
|
31
|
+
`GitHub API 403: ${endpoint}. The current token lacks the billing scope (org owners need admin:org; user accounts need user scope).`,
|
|
32
|
+
);
|
|
33
|
+
err.code = 'GH_API_FORBIDDEN';
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
if (/410/.test(message)) {
|
|
37
|
+
const err = new Error(
|
|
38
|
+
`GitHub API 410: ${endpoint}. This endpoint was retired in early 2026; the new enhanced billing endpoint is /{scope}/{name}/settings/billing/usage.`,
|
|
39
|
+
);
|
|
40
|
+
err.code = 'GH_API_GONE';
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`gh api ${endpoint} failed: ${message}`);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(result.stdout);
|
|
47
|
+
} catch (parseErr) {
|
|
48
|
+
throw new Error(`gh api ${endpoint} returned non-JSON output: ${parseErr.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function detectCurrentLogin() {
|
|
53
|
+
const result = runGh(['api', 'user', '--jq', '.login']);
|
|
54
|
+
if (result.status !== 0) return null;
|
|
55
|
+
return result.stdout.trim() || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fetchUsage({ org, user } = {}) {
|
|
59
|
+
if (org) {
|
|
60
|
+
const usage = ghApi(`/orgs/${org}/settings/billing/usage`);
|
|
61
|
+
return { scope: 'org', name: org, usage };
|
|
62
|
+
}
|
|
63
|
+
if (user) {
|
|
64
|
+
const usage = ghApi(`/users/${user}/settings/billing/usage`);
|
|
65
|
+
return { scope: 'user', name: user, usage };
|
|
66
|
+
}
|
|
67
|
+
const login = detectCurrentLogin();
|
|
68
|
+
if (!login) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Could not detect the authenticated login. Pass --org <name> or --user <name> explicitly.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const usage = ghApi(`/users/${login}/settings/billing/usage`);
|
|
75
|
+
return { scope: 'user', name: login, usage };
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err.code === 'GH_API_NOT_FOUND') {
|
|
78
|
+
const usage = ghApi(`/orgs/${login}/settings/billing/usage`);
|
|
79
|
+
return { scope: 'org', name: login, usage };
|
|
80
|
+
}
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function currentMonthKey(now = new Date()) {
|
|
86
|
+
const year = now.getUTCFullYear();
|
|
87
|
+
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
88
|
+
return `${year}-${month}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function itemMonthKey(item) {
|
|
92
|
+
// Dates land as 'YYYY-MM-01T00:00:00Z' for the start of a billed month.
|
|
93
|
+
return typeof item.date === 'string' ? item.date.slice(0, 7) : '';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function thresholdSeverity(netUsd, warnUsd, criticalUsd) {
|
|
97
|
+
if (netUsd >= criticalUsd) return 'critical';
|
|
98
|
+
if (netUsd >= warnUsd) return 'warn';
|
|
99
|
+
return 'ok';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function shapeBudgetReport({ scope, name, usage, monthKey, warnUsd, criticalUsd }) {
|
|
103
|
+
const items = Array.isArray(usage?.usageItems) ? usage.usageItems : [];
|
|
104
|
+
const targetMonth = monthKey ?? currentMonthKey();
|
|
105
|
+
|
|
106
|
+
const actionsThisMonth = items.filter(
|
|
107
|
+
(item) =>
|
|
108
|
+
item.product === 'actions' &&
|
|
109
|
+
item.unitType === 'Minutes' &&
|
|
110
|
+
itemMonthKey(item) === targetMonth,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const totalMinutes = actionsThisMonth.reduce((sum, item) => sum + (Number(item.quantity) || 0), 0);
|
|
114
|
+
const totalGross = actionsThisMonth.reduce(
|
|
115
|
+
(sum, item) => sum + (Number(item.grossAmount) || 0),
|
|
116
|
+
0,
|
|
117
|
+
);
|
|
118
|
+
const totalDiscount = actionsThisMonth.reduce(
|
|
119
|
+
(sum, item) => sum + (Number(item.discountAmount) || 0),
|
|
120
|
+
0,
|
|
121
|
+
);
|
|
122
|
+
const totalNet = actionsThisMonth.reduce((sum, item) => sum + (Number(item.netAmount) || 0), 0);
|
|
123
|
+
|
|
124
|
+
const byRepo = new Map();
|
|
125
|
+
const bySku = new Map();
|
|
126
|
+
for (const item of actionsThisMonth) {
|
|
127
|
+
const minutes = Number(item.quantity) || 0;
|
|
128
|
+
const repo = item.repositoryName || '(unknown)';
|
|
129
|
+
byRepo.set(repo, (byRepo.get(repo) || 0) + minutes);
|
|
130
|
+
const sku = item.sku || '(unknown)';
|
|
131
|
+
bySku.set(sku, (bySku.get(sku) || 0) + minutes);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const topRepos = [...byRepo.entries()]
|
|
135
|
+
.map(([repo, minutes]) => ({ repository: repo, minutes_used: round(minutes, 1) }))
|
|
136
|
+
.sort((a, b) => b.minutes_used - a.minutes_used)
|
|
137
|
+
.slice(0, 5);
|
|
138
|
+
const skuBreakdown = [...bySku.entries()]
|
|
139
|
+
.map(([sku, minutes]) => ({ sku, minutes_used: round(minutes, 1) }))
|
|
140
|
+
.sort((a, b) => b.minutes_used - a.minutes_used);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
scope,
|
|
144
|
+
name,
|
|
145
|
+
month: targetMonth,
|
|
146
|
+
actions_minutes_used: round(totalMinutes, 1),
|
|
147
|
+
gross_usd: round(totalGross, 2),
|
|
148
|
+
discount_usd: round(totalDiscount, 2),
|
|
149
|
+
net_usd: round(totalNet, 2),
|
|
150
|
+
severity: thresholdSeverity(totalNet, warnUsd, criticalUsd),
|
|
151
|
+
warn_threshold_usd: warnUsd,
|
|
152
|
+
critical_threshold_usd: criticalUsd,
|
|
153
|
+
top_repos: topRepos,
|
|
154
|
+
sku_breakdown: skuBreakdown,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function round(value, decimals) {
|
|
159
|
+
const factor = 10 ** decimals;
|
|
160
|
+
return Math.round(value * factor) / factor;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function formatBudgetReportText(report) {
|
|
164
|
+
const lines = [];
|
|
165
|
+
lines.push(
|
|
166
|
+
`${TOOL_NAME} budget — GitHub Actions usage for ${report.scope}:${report.name} (${report.month})`,
|
|
167
|
+
);
|
|
168
|
+
lines.push(` actions minutes used: ${report.actions_minutes_used}`);
|
|
169
|
+
lines.push(
|
|
170
|
+
` gross: $${report.gross_usd} discount: $${report.discount_usd} net (paid): $${report.net_usd}`,
|
|
171
|
+
);
|
|
172
|
+
if (report.sku_breakdown.length > 0) {
|
|
173
|
+
lines.push(` by runner sku:`);
|
|
174
|
+
for (const entry of report.sku_breakdown) {
|
|
175
|
+
lines.push(` ${entry.sku}: ${entry.minutes_used} min`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (report.top_repos.length > 0) {
|
|
179
|
+
lines.push(` top repos:`);
|
|
180
|
+
for (const entry of report.top_repos) {
|
|
181
|
+
lines.push(` ${entry.repository}: ${entry.minutes_used} min`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const verdict =
|
|
185
|
+
report.severity === 'critical'
|
|
186
|
+
? `CRITICAL — paid spend $${report.net_usd} this month is at/above $${report.critical_threshold_usd}. Raise the spending limit before the next push to avoid blocked merges.`
|
|
187
|
+
: report.severity === 'warn'
|
|
188
|
+
? `WARN — paid spend $${report.net_usd} this month exceeds the warn threshold ($${report.warn_threshold_usd}). Review CI triggers or accept the spend.`
|
|
189
|
+
: `OK — no paid spend yet this month (all usage covered by free tier).`;
|
|
190
|
+
lines.push(` status: ${verdict}`);
|
|
191
|
+
return lines.join('\n');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseBudgetArgs(rawArgs) {
|
|
195
|
+
const options = {
|
|
196
|
+
org: null,
|
|
197
|
+
user: null,
|
|
198
|
+
json: false,
|
|
199
|
+
help: false,
|
|
200
|
+
month: null,
|
|
201
|
+
warnUsd: DEFAULT_WARN_NET_USD,
|
|
202
|
+
criticalUsd: DEFAULT_CRITICAL_NET_USD,
|
|
203
|
+
};
|
|
204
|
+
const args = Array.isArray(rawArgs) ? [...rawArgs] : [];
|
|
205
|
+
while (args.length > 0) {
|
|
206
|
+
const arg = args.shift();
|
|
207
|
+
if (arg === '--help' || arg === '-h' || arg === 'help') {
|
|
208
|
+
options.help = true;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (arg === '--json') {
|
|
212
|
+
options.json = true;
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (arg === '--org') {
|
|
216
|
+
options.org = args.shift();
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (arg === '--user') {
|
|
220
|
+
options.user = args.shift();
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (arg === '--month') {
|
|
224
|
+
options.month = args.shift();
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (arg === '--warn-usd') {
|
|
228
|
+
options.warnUsd = Number(args.shift());
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (arg === '--critical-usd') {
|
|
232
|
+
options.criticalUsd = Number(args.shift());
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (arg.startsWith('--org=')) {
|
|
236
|
+
options.org = arg.slice('--org='.length);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (arg.startsWith('--user=')) {
|
|
240
|
+
options.user = arg.slice('--user='.length);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (arg.startsWith('--month=')) {
|
|
244
|
+
options.month = arg.slice('--month='.length);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (arg.startsWith('--warn-usd=')) {
|
|
248
|
+
options.warnUsd = Number(arg.slice('--warn-usd='.length));
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (arg.startsWith('--critical-usd=')) {
|
|
252
|
+
options.criticalUsd = Number(arg.slice('--critical-usd='.length));
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const err = new Error(`Unknown budget argument: ${arg}`);
|
|
256
|
+
err.code = 'BUDGET_BAD_ARG';
|
|
257
|
+
throw err;
|
|
258
|
+
}
|
|
259
|
+
if (!Number.isFinite(options.warnUsd) || options.warnUsd < 0) {
|
|
260
|
+
throw new Error(`--warn-usd must be a non-negative number; got ${options.warnUsd}`);
|
|
261
|
+
}
|
|
262
|
+
if (!Number.isFinite(options.criticalUsd) || options.criticalUsd < 0) {
|
|
263
|
+
throw new Error(`--critical-usd must be a non-negative number; got ${options.criticalUsd}`);
|
|
264
|
+
}
|
|
265
|
+
return options;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function renderBudgetHelp() {
|
|
269
|
+
return [
|
|
270
|
+
`${TOOL_NAME} budget — GitHub Actions spend for the current month.`,
|
|
271
|
+
'',
|
|
272
|
+
'Usage:',
|
|
273
|
+
` ${TOOL_NAME} budget [--org <name>] [--user <name>] [--month YYYY-MM] [--warn-usd <n>] [--critical-usd <n>] [--json]`,
|
|
274
|
+
'',
|
|
275
|
+
'Options:',
|
|
276
|
+
` --org <name> Query an org's billing (requires admin:org on the gh token).`,
|
|
277
|
+
` --user <name> Query a user's billing (requires user scope on the gh token).`,
|
|
278
|
+
` --month YYYY-MM Report a specific month (default: current UTC month).`,
|
|
279
|
+
` --warn-usd <n> Net-paid threshold to flag WARN (default ${DEFAULT_WARN_NET_USD}).`,
|
|
280
|
+
` --critical-usd <n> Net-paid threshold to flag CRITICAL (default ${DEFAULT_CRITICAL_NET_USD}).`,
|
|
281
|
+
` --json Emit structured JSON instead of the text summary.`,
|
|
282
|
+
'',
|
|
283
|
+
'Without --org or --user, the command auto-detects the authenticated login from',
|
|
284
|
+
'`gh api user` and probes the user usage endpoint first, then the org endpoint.',
|
|
285
|
+
'',
|
|
286
|
+
'Exit codes: 0 ok, 1 error fetching, 2 CRITICAL severity (so CI scripts can fail closed).',
|
|
287
|
+
].join('\n');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function runBudgetCommand(rawArgs) {
|
|
291
|
+
let options;
|
|
292
|
+
try {
|
|
293
|
+
options = parseBudgetArgs(rawArgs);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
console.error(`[${TOOL_NAME}] ${err.message}`);
|
|
296
|
+
console.error(renderBudgetHelp());
|
|
297
|
+
process.exitCode = 1;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (options.help) {
|
|
302
|
+
console.log(renderBudgetHelp());
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let response;
|
|
307
|
+
try {
|
|
308
|
+
response = fetchUsage({ org: options.org, user: options.user });
|
|
309
|
+
} catch (err) {
|
|
310
|
+
console.error(`[${TOOL_NAME}] ${err.message}`);
|
|
311
|
+
process.exitCode = 1;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const report = shapeBudgetReport({
|
|
316
|
+
scope: response.scope,
|
|
317
|
+
name: response.name,
|
|
318
|
+
usage: response.usage,
|
|
319
|
+
monthKey: options.month,
|
|
320
|
+
warnUsd: options.warnUsd,
|
|
321
|
+
criticalUsd: options.criticalUsd,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
if (options.json) {
|
|
325
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
326
|
+
process.exitCode = report.severity === 'critical' ? 2 : 0;
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
console.log(formatBudgetReportText(report));
|
|
331
|
+
process.exitCode = report.severity === 'critical' ? 2 : 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
module.exports = {
|
|
335
|
+
runBudgetCommand,
|
|
336
|
+
parseBudgetArgs,
|
|
337
|
+
shapeBudgetReport,
|
|
338
|
+
formatBudgetReportText,
|
|
339
|
+
renderBudgetHelp,
|
|
340
|
+
currentMonthKey,
|
|
341
|
+
DEFAULT_WARN_NET_USD,
|
|
342
|
+
DEFAULT_CRITICAL_NET_USD,
|
|
343
|
+
};
|