@rex_koh/subagent-budget-guard 0.1.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.
@@ -0,0 +1,373 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import {
7
+ CONFIG_KEYS,
8
+ buildReport,
9
+ handlePostToolUseAgent,
10
+ handlePreToolUseAgent,
11
+ handleUserPromptSubmit,
12
+ installStatusLineBridge,
13
+ pathExists,
14
+ updateRateLimitFromStatusLine
15
+ } from './guard.js';
16
+
17
+ async function readJson(filePath) {
18
+ const { readFile } = await import('node:fs/promises');
19
+ const text = await readFile(filePath, 'utf8');
20
+ return JSON.parse(text.replace(/^\uFEFF/, ''));
21
+ }
22
+
23
+ function pluginRoot(repoRoot) {
24
+ return path.join(repoRoot, 'plugins', 'subagent-budget-guard');
25
+ }
26
+
27
+ async function withCheck(result, name, fn) {
28
+ try {
29
+ const detail = await fn();
30
+ result.checks.push({ name, ok: true, detail: detail || 'ok' });
31
+ } catch (error) {
32
+ result.checks.push({ name, ok: false, detail: error.message });
33
+ result.failures.push(`${name}: ${error.message}`);
34
+ }
35
+ }
36
+
37
+ function assert(condition, message) {
38
+ if (!condition) throw new Error(message);
39
+ }
40
+
41
+ export async function runOfflineVerification({
42
+ repoRoot = process.cwd(),
43
+ env = process.env
44
+ } = {}) {
45
+ const result = {
46
+ mode: 'offline',
47
+ ok: false,
48
+ checks: [],
49
+ failures: []
50
+ };
51
+ const root = pluginRoot(repoRoot);
52
+
53
+ await withCheck(result, 'marketplace-manifest', async () => {
54
+ const marketplacePath = path.join(repoRoot, '.claude-plugin', 'marketplace.json');
55
+ const marketplace = await readJson(marketplacePath);
56
+ assert(marketplace.name === 'subagent-budget-tools', 'marketplace name mismatch');
57
+ assert(Array.isArray(marketplace.plugins), 'marketplace.plugins must be an array');
58
+ const entry = marketplace.plugins.find((plugin) => plugin.name === 'subagent-budget-guard');
59
+ assert(entry, 'subagent-budget-guard entry missing');
60
+ assert(entry.source?.source === 'npm', 'marketplace source must use npm');
61
+ assert(
62
+ entry.source?.package === '@rex_koh/subagent-budget-guard',
63
+ 'marketplace npm package mismatch'
64
+ );
65
+ assert(entry.source?.version === '0.1.0', 'marketplace npm version mismatch');
66
+ return marketplacePath;
67
+ });
68
+
69
+ await withCheck(result, 'plugin-manifest-user-config', async () => {
70
+ const manifestPath = path.join(root, '.claude-plugin', 'plugin.json');
71
+ const manifest = await readJson(manifestPath);
72
+ assert(manifest.name === 'subagent-budget-guard', 'plugin name mismatch');
73
+ assert(
74
+ manifest.hooks === undefined,
75
+ 'manifest.hooks must be omitted for default hooks/hooks.json to avoid duplicate loading'
76
+ );
77
+ assert(
78
+ manifest.skills === undefined,
79
+ 'manifest.skills must be omitted for default skills/ scanning to avoid duplicate loading'
80
+ );
81
+ for (const key of CONFIG_KEYS) {
82
+ assert(manifest.userConfig?.[key], `missing userConfig.${key}`);
83
+ }
84
+ return manifestPath;
85
+ });
86
+
87
+ await withCheck(result, 'hooks-config', async () => {
88
+ const hooksPath = path.join(root, 'hooks', 'hooks.json');
89
+ const hooks = await readJson(hooksPath);
90
+ const requiredEvents = [
91
+ 'PreToolUse',
92
+ 'PostToolUse',
93
+ 'SubagentStart',
94
+ 'SubagentStop',
95
+ 'TaskCreated',
96
+ 'TaskCompleted',
97
+ 'UserPromptSubmit'
98
+ ];
99
+ for (const event of requiredEvents) {
100
+ assert(Array.isArray(hooks.hooks?.[event]), `missing hooks.${event}`);
101
+ }
102
+ assert(hooks.hooks.PreToolUse[0].matcher === 'Agent', 'PreToolUse must match Agent');
103
+ assert(hooks.hooks.PostToolUse[0].matcher === 'Agent', 'PostToolUse must match Agent');
104
+ return hooksPath;
105
+ });
106
+
107
+ await withCheck(result, 'script-paths', async () => {
108
+ const scripts = [
109
+ 'bin/hook.js',
110
+ 'bin/statusline.js',
111
+ 'bin/setup.js',
112
+ 'bin/report.js',
113
+ 'bin/verify.js',
114
+ 'lib/guard.js',
115
+ 'lib/verifier.js',
116
+ 'skills/setup/SKILL.md',
117
+ 'skills/report/SKILL.md',
118
+ 'skills/verify/SKILL.md'
119
+ ];
120
+ for (const script of scripts) {
121
+ assert(await pathExists(path.join(root, script)), `missing ${script}`);
122
+ }
123
+ return `${scripts.length} files present`;
124
+ });
125
+
126
+ await withCheck(result, 'pretool-agent-denies-default', async () => {
127
+ const dataDir = await mkdtemp(path.join(os.tmpdir(), 'sbg-verify-'));
128
+ try {
129
+ const checkEnv = {
130
+ ...env,
131
+ CLAUDE_PLUGIN_DATA: dataDir,
132
+ CLAUDE_PLUGIN_ROOT: root
133
+ };
134
+ const output = await handlePreToolUseAgent(
135
+ {
136
+ session_id: 'offline-pretool',
137
+ hook_event_name: 'PreToolUse',
138
+ tool_name: 'Agent',
139
+ tool_input: { description: 'verify', subagent_type: 'Explore' }
140
+ },
141
+ checkEnv
142
+ );
143
+ assert(
144
+ output.stdout?.hookSpecificOutput?.permissionDecision === 'deny',
145
+ 'Agent launch was not denied by default'
146
+ );
147
+ return output.stdout.hookSpecificOutput.permissionDecisionReason;
148
+ } finally {
149
+ await rm(dataDir, { recursive: true, force: true });
150
+ }
151
+ });
152
+
153
+ await withCheck(result, 'posttool-agent-records-verified-tokens', async () => {
154
+ const dataDir = await mkdtemp(path.join(os.tmpdir(), 'sbg-verify-'));
155
+ try {
156
+ const checkEnv = {
157
+ ...env,
158
+ CLAUDE_PLUGIN_DATA: dataDir,
159
+ CLAUDE_PLUGIN_ROOT: root
160
+ };
161
+ await handlePostToolUseAgent(
162
+ {
163
+ session_id: 'offline-posttool',
164
+ hook_event_name: 'PostToolUse',
165
+ tool_name: 'Agent',
166
+ tool_input: { description: 'verify', subagent_type: 'Explore' },
167
+ tool_response: {
168
+ status: 'completed',
169
+ agentId: 'agent-verify',
170
+ totalTokens: 101,
171
+ totalToolUseCount: 2,
172
+ totalDurationMs: 300
173
+ }
174
+ },
175
+ checkEnv
176
+ );
177
+ const report = await buildReport('offline-posttool', checkEnv);
178
+ assert(report.state.subagents.verifiedTokens === 101, 'verified token count mismatch');
179
+ return report.summary.verifiedTokenLabel;
180
+ } finally {
181
+ await rm(dataDir, { recursive: true, force: true });
182
+ }
183
+ });
184
+
185
+ await withCheck(result, 'statusline-budget-blocks', async () => {
186
+ const dataDir = await mkdtemp(path.join(os.tmpdir(), 'sbg-verify-'));
187
+ try {
188
+ const checkEnv = {
189
+ ...env,
190
+ CLAUDE_PLUGIN_DATA: dataDir,
191
+ CLAUDE_PLUGIN_ROOT: root,
192
+ CLAUDE_PLUGIN_OPTION_session_five_hour_budget_percent: '3'
193
+ };
194
+ await updateRateLimitFromStatusLine(
195
+ {
196
+ session_id: 'offline-budget',
197
+ rate_limits: { five_hour: { used_percentage: 10, resets_at: 1 } }
198
+ },
199
+ checkEnv
200
+ );
201
+ await updateRateLimitFromStatusLine(
202
+ {
203
+ session_id: 'offline-budget',
204
+ rate_limits: { five_hour: { used_percentage: 13.5, resets_at: 1 } }
205
+ },
206
+ checkEnv
207
+ );
208
+ const output = await handleUserPromptSubmit(
209
+ {
210
+ session_id: 'offline-budget',
211
+ hook_event_name: 'UserPromptSubmit',
212
+ prompt: 'continue'
213
+ },
214
+ checkEnv
215
+ );
216
+ assert(output.stdout?.decision === 'block', 'prompt was not blocked');
217
+ return output.stdout.reason;
218
+ } finally {
219
+ await rm(dataDir, { recursive: true, force: true });
220
+ }
221
+ });
222
+
223
+ await withCheck(result, 'statusline-setup-wraps-existing-command', async () => {
224
+ const dataDir = await mkdtemp(path.join(os.tmpdir(), 'sbg-verify-data-'));
225
+ const homeDir = await mkdtemp(path.join(os.tmpdir(), 'sbg-verify-home-'));
226
+ try {
227
+ const { mkdir, writeFile, readFile } = await import('node:fs/promises');
228
+ const claudeDir = path.join(homeDir, '.claude');
229
+ await mkdir(claudeDir, { recursive: true });
230
+ await writeFile(
231
+ path.join(claudeDir, 'settings.json'),
232
+ JSON.stringify({
233
+ statusLine: {
234
+ type: 'command',
235
+ command: 'node old-statusline.js'
236
+ }
237
+ })
238
+ );
239
+ const setup = await installStatusLineBridge({
240
+ homeDir,
241
+ pluginRoot: root,
242
+ pluginData: dataDir
243
+ });
244
+ const settings = JSON.parse(await readFile(path.join(claudeDir, 'settings.json'), 'utf8'));
245
+ assert(settings.statusLine.command.includes('statusline.js'), 'bridge command missing');
246
+ assert(settings.statusLine.command.includes('--data'), 'bridge data arg missing');
247
+ return setup.bridgePath;
248
+ } finally {
249
+ await rm(dataDir, { recursive: true, force: true });
250
+ await rm(homeDir, { recursive: true, force: true });
251
+ }
252
+ });
253
+
254
+ result.ok = result.failures.length === 0;
255
+ return result;
256
+ }
257
+
258
+ function commandExists(command) {
259
+ return new Promise((resolve) => {
260
+ const child = spawn(process.platform === 'win32' ? 'where.exe' : 'which', [command], {
261
+ stdio: 'ignore',
262
+ windowsHide: true
263
+ });
264
+ child.on('exit', (code) => resolve(code === 0));
265
+ child.on('error', () => resolve(false));
266
+ });
267
+ }
268
+
269
+ function runCommand(command, args, options = {}) {
270
+ return new Promise((resolve) => {
271
+ const child = spawn(command, args, {
272
+ cwd: options.cwd,
273
+ shell: false,
274
+ windowsHide: true
275
+ });
276
+ let stdout = '';
277
+ let stderr = '';
278
+ child.stdout?.on('data', (chunk) => {
279
+ stdout += chunk;
280
+ });
281
+ child.stderr?.on('data', (chunk) => {
282
+ stderr += chunk;
283
+ });
284
+ child.on('exit', (code) => resolve({ code, stdout, stderr }));
285
+ child.on('error', (error) =>
286
+ resolve({ code: 1, stdout, stderr: `${stderr}\n${error.message}` })
287
+ );
288
+ });
289
+ }
290
+
291
+ export async function runLiveVerification({
292
+ repoRoot = process.cwd(),
293
+ env = process.env
294
+ } = {}) {
295
+ const result = {
296
+ mode: 'live',
297
+ ok: false,
298
+ checks: [],
299
+ failures: [],
300
+ warnings: []
301
+ };
302
+ const offline = await runOfflineVerification({ repoRoot, env });
303
+ result.checks.push(...offline.checks);
304
+ result.failures.push(...offline.failures);
305
+
306
+ const root = pluginRoot(repoRoot);
307
+ const hasClaude = await commandExists('claude');
308
+ if (!hasClaude) {
309
+ result.warnings.push('claude executable was not found on PATH; skipped claude plugin validate and install-state checks.');
310
+ } else {
311
+ await withCheck(result, 'claude-plugin-validate', async () => {
312
+ const validate = await runCommand('claude', ['plugin', 'validate', root], {
313
+ cwd: repoRoot
314
+ });
315
+ assert(validate.code === 0, validate.stderr || validate.stdout || 'claude plugin validate failed');
316
+ return validate.stdout.trim() || 'claude plugin validate passed';
317
+ });
318
+
319
+ await withCheck(result, 'claude-plugin-list', async () => {
320
+ const list = await runCommand('claude', ['plugin', 'list'], { cwd: repoRoot });
321
+ assert(list.code === 0, list.stderr || list.stdout || 'claude plugin list failed');
322
+ assert(
323
+ list.stdout.includes('subagent-budget-guard'),
324
+ 'subagent-budget-guard is not installed'
325
+ );
326
+ assert(
327
+ !/subagent-budget-guard@subagent-budget-tools[\s\S]*failed to load/i.test(list.stdout),
328
+ 'subagent-budget-guard is installed but failed to load'
329
+ );
330
+ return 'claude plugin list returned output';
331
+ });
332
+ }
333
+
334
+ await withCheck(result, 'statusline-bridge-configured', async () => {
335
+ const home = env.USERPROFILE || env.HOME || os.homedir();
336
+ const settingsPath = path.join(home, '.claude', 'settings.json');
337
+ const settings = await readJson(settingsPath);
338
+ assert(
339
+ typeof settings.statusLine?.command === 'string' &&
340
+ settings.statusLine.command.includes('statusline.js') &&
341
+ settings.statusLine.command.includes('--data'),
342
+ 'statusLine bridge is not installed; run /subagent-budget-guard:setup'
343
+ );
344
+ return settings.statusLine.command;
345
+ });
346
+
347
+ result.ok = result.failures.length === 0;
348
+ return result;
349
+ }
350
+
351
+ export function formatVerificationResult(result) {
352
+ const lines = [
353
+ `Subagent Budget Guard ${result.mode} verification`,
354
+ result.ok ? 'PASS' : 'FAIL'
355
+ ];
356
+
357
+ for (const check of result.checks) {
358
+ lines.push(`${check.ok ? 'PASS' : 'FAIL'} ${check.name}: ${check.detail}`);
359
+ }
360
+
361
+ for (const warning of result.warnings || []) {
362
+ lines.push(`WARN ${warning}`);
363
+ }
364
+
365
+ if (result.failures.length > 0) {
366
+ lines.push('Failures:');
367
+ for (const failure of result.failures) {
368
+ lines.push(`- ${failure}`);
369
+ }
370
+ }
371
+
372
+ return lines.join('\n');
373
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@rex_koh/subagent-budget-guard",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code plugin that blocks subagents by default, records verified subagent usage, and enforces 5-hour usage budgets.",
5
+ "license": "MIT",
6
+ "author": "ClaudeSubAgentSuppressor",
7
+ "homepage": "https://github.com/rexkoh425/ClaudeSubAgentSuppressor#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+ssh://git@github.com/rexkoh425/ClaudeSubAgentSuppressor.git",
11
+ "directory": "plugins/subagent-budget-guard"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/rexkoh425/ClaudeSubAgentSuppressor/issues"
15
+ },
16
+ "keywords": [
17
+ "claude-code",
18
+ "claude-plugin",
19
+ "subagents",
20
+ "budget",
21
+ "tokens",
22
+ "rate-limit"
23
+ ],
24
+ "type": "module",
25
+ "files": [
26
+ ".claude-plugin/",
27
+ "hooks/",
28
+ "skills/",
29
+ "bin/",
30
+ "lib/",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "bin": {
35
+ "subagent-budget-guard-report": "bin/report.js",
36
+ "subagent-budget-guard-setup": "bin/setup.js",
37
+ "subagent-budget-guard-verify": "bin/verify.js"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "scripts": {
43
+ "test": "node --test test/*.test.js",
44
+ "verify:offline": "node bin/verify.js --offline",
45
+ "verify:live": "node bin/verify.js --live",
46
+ "prepack": "node --check bin/hook.js && node --check bin/statusline.js && node --check bin/setup.js && node --check bin/report.js && node --check bin/verify.js && node --check lib/guard.js && node --check lib/verifier.js"
47
+ },
48
+ "engines": {
49
+ "node": ">=20"
50
+ }
51
+ }
@@ -0,0 +1,18 @@
1
+ ---
2
+ description: Show the current Subagent Budget Guard session report with subagent counts, verified token totals, and 5-hour budget state.
3
+ disable-model-invocation: true
4
+ ---
5
+
6
+ # Report Subagent Budget Guard Usage
7
+
8
+ Run this command:
9
+
10
+ ```bash
11
+ node "${CLAUDE_PLUGIN_ROOT}/bin/report.js"
12
+ ```
13
+
14
+ If the user asks for machine-readable output, run:
15
+
16
+ ```bash
17
+ node "${CLAUDE_PLUGIN_ROOT}/bin/report.js" --json
18
+ ```
@@ -0,0 +1,20 @@
1
+ ---
2
+ description: Install or refresh the Subagent Budget Guard statusLine bridge so 5-hour rate-limit percentages can be captured for enforcement.
3
+ disable-model-invocation: true
4
+ ---
5
+
6
+ # Setup Subagent Budget Guard
7
+
8
+ Run this command:
9
+
10
+ ```bash
11
+ node "${CLAUDE_PLUGIN_ROOT}/bin/setup.js"
12
+ ```
13
+
14
+ Then tell the user to interact with Claude Code once so the statusLine bridge receives fresh session JSON. After that, run:
15
+
16
+ ```bash
17
+ node "${CLAUDE_PLUGIN_ROOT}/bin/verify.js" --live
18
+ ```
19
+
20
+ The live verifier does not submit Claude prompts. It checks local plugin shape, Claude plugin validation when `claude` is on `PATH`, and whether the statusLine bridge is configured.
@@ -0,0 +1,20 @@
1
+ ---
2
+ description: Verify the Subagent Budget Guard plugin without spending Claude quota, or run live local installation checks.
3
+ disable-model-invocation: true
4
+ ---
5
+
6
+ # Verify Subagent Budget Guard
7
+
8
+ For default offline verification, run:
9
+
10
+ ```bash
11
+ node "${CLAUDE_PLUGIN_ROOT}/bin/verify.js" --offline
12
+ ```
13
+
14
+ For live local installation checks, run:
15
+
16
+ ```bash
17
+ node "${CLAUDE_PLUGIN_ROOT}/bin/verify.js" --live
18
+ ```
19
+
20
+ The live verifier does not submit Claude prompts. It checks local plugin shape, `claude plugin validate` when available, plugin listing shape, and statusLine bridge setup.