@kairyou/agent-tools 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.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/README.zh-CN.md +149 -0
- package/config.default.jsonc +23 -0
- package/hooks/claude/.gitkeep +1 -0
- package/hooks/codex/.gitkeep +1 -0
- package/hooks/codex/usage-hook.mjs +157 -0
- package/hooks/common/.gitkeep +1 -0
- package/hooks/opencode/.gitkeep +1 -0
- package/lib/usage.mjs +1200 -0
- package/package.json +31 -0
- package/plugins/opencode/usage-plugin.mjs +95 -0
- package/plugins/opencode/usage-tui.mjs +49 -0
- package/scripts/install.mjs +551 -0
- package/skills/workflow/at-commit/SKILL.md +83 -0
- package/skills/workflow/at-review/SKILL.md +89 -0
- package/skills/workflow/at-simplify/SKILL.md +67 -0
- package/statusline/.gitkeep +1 -0
- package/statusline/claude/statusline.mjs +399 -0
- package/statusline/codex/.gitkeep +1 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agent-tools installer: wires statusline / usage into each agent's config.
|
|
3
|
+
// Skills are NOT handled here — install those with `npx skills add`.
|
|
4
|
+
//
|
|
5
|
+
// Capabilities (all global for now — they target the user-level config):
|
|
6
|
+
// statusline Claude Code statusLine script (claude only).
|
|
7
|
+
// usage Active API provider quota/balance (codex + opencode).
|
|
8
|
+
//
|
|
9
|
+
// Targets:
|
|
10
|
+
// claude -> ~/.claude/settings.json (statusLine key)
|
|
11
|
+
// codex -> ~/.codex/hooks.json (standalone hooks file)
|
|
12
|
+
// opencode -> ~/.config/opencode/ (server + TUI plugins)
|
|
13
|
+
// Runtime scripts are copied into ~/.agent-tools so this installer can be
|
|
14
|
+
// run via npx from GitHub without requiring a persistent local clone.
|
|
15
|
+
//
|
|
16
|
+
// NOTE: Codex will not run a freshly-installed hook until you trust it — run
|
|
17
|
+
// `/hooks` inside Codex and approve the agent-tools usage hooks.
|
|
18
|
+
//
|
|
19
|
+
// Usage:
|
|
20
|
+
// agent-tools <capabilities> [options]
|
|
21
|
+
//
|
|
22
|
+
// Options:
|
|
23
|
+
// -a, --agent <names> Target agents: claude | codex | opencode.
|
|
24
|
+
// Default: claude.
|
|
25
|
+
// --settings <path> Override the Claude settings.json (for testing).
|
|
26
|
+
// --codex-hooks <path> Override the Codex hooks.json (for testing).
|
|
27
|
+
// --opencode-config-dir <p> Override the opencode config dir (for testing).
|
|
28
|
+
// --uninstall Remove what this installer added, restoring backups.
|
|
29
|
+
// --dry-run Print planned changes without writing anything.
|
|
30
|
+
// -h, --help Show this help.
|
|
31
|
+
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import os from "node:os";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
36
|
+
import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser";
|
|
37
|
+
|
|
38
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
39
|
+
const INSTALL_ROOT =
|
|
40
|
+
process.env.AGENT_TOOLS_HOME || path.join(os.homedir(), ".agent-tools");
|
|
41
|
+
const META_KEY = "_agentTools";
|
|
42
|
+
const META_VERSION = 1;
|
|
43
|
+
const SOURCE = {
|
|
44
|
+
codexUsageHook: path.join(REPO_ROOT, "hooks", "codex", "usage-hook.mjs"),
|
|
45
|
+
usageScript: path.join(REPO_ROOT, "lib", "usage.mjs"),
|
|
46
|
+
config: path.join(REPO_ROOT, "config.default.jsonc"),
|
|
47
|
+
claudeStatusline: path.join(REPO_ROOT, "statusline", "claude", "statusline.mjs"),
|
|
48
|
+
opencodeUsagePlugin: path.join(REPO_ROOT, "plugins", "opencode", "usage-plugin.mjs"),
|
|
49
|
+
opencodeUsageTui: path.join(REPO_ROOT, "plugins", "opencode", "usage-tui.mjs"),
|
|
50
|
+
};
|
|
51
|
+
const RUNTIME = {
|
|
52
|
+
codexUsageHook: path.join(INSTALL_ROOT, "hooks", "codex", "usage-hook.mjs"),
|
|
53
|
+
usageScript: path.join(INSTALL_ROOT, "lib", "usage.mjs"),
|
|
54
|
+
config: path.join(INSTALL_ROOT, "config.jsonc"),
|
|
55
|
+
claudeStatusline: path.join(INSTALL_ROOT, "statusline", "claude", "statusline.mjs"),
|
|
56
|
+
opencodeUsagePlugin: path.join(INSTALL_ROOT, "plugins", "opencode", "usage-plugin.mjs"),
|
|
57
|
+
opencodeUsageTui: path.join(INSTALL_ROOT, "plugins", "opencode", "usage-tui.mjs"),
|
|
58
|
+
};
|
|
59
|
+
const ALL_CAPS = ["statusline", "usage"];
|
|
60
|
+
const ALL_AGENTS = ["claude", "codex", "opencode"];
|
|
61
|
+
const AGENT_CAPS = {
|
|
62
|
+
claude: ["statusline"],
|
|
63
|
+
codex: ["usage"],
|
|
64
|
+
opencode: ["usage"],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function fwd(p) {
|
|
68
|
+
return p.replace(/\\/g, "/");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function nodeCmd(absScript) {
|
|
72
|
+
return `node "${fwd(absScript)}"`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stripJsonComments(input) {
|
|
76
|
+
let out = "";
|
|
77
|
+
let inString = false;
|
|
78
|
+
let escaped = false;
|
|
79
|
+
for (let i = 0; i < input.length; i++) {
|
|
80
|
+
const ch = input[i];
|
|
81
|
+
const next = input[i + 1];
|
|
82
|
+
if (inString) {
|
|
83
|
+
out += ch;
|
|
84
|
+
escaped = ch === "\\" ? !escaped : false;
|
|
85
|
+
if (ch === "\"" && !escaped) inString = false;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (ch === "\"") {
|
|
89
|
+
inString = true;
|
|
90
|
+
out += ch;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ch === "/" && next === "/") {
|
|
94
|
+
while (i < input.length && input[i] !== "\n") i++;
|
|
95
|
+
out += "\n";
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (ch === "/" && next === "*") {
|
|
99
|
+
i += 2;
|
|
100
|
+
while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++;
|
|
101
|
+
i++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
out += ch;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readJsonc(file) {
|
|
110
|
+
if (!fs.existsSync(file)) return {};
|
|
111
|
+
const raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
|
|
112
|
+
return raw.trim() ? JSON.parse(stripJsonComments(raw)) : {};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function headerComments(text) {
|
|
116
|
+
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
117
|
+
const header = [];
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
if (/^\s*(?:\/\/.*)?$/.test(line)) {
|
|
120
|
+
header.push(line);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
return header.length ? header.join("\n").replace(/\s+$/, "") + "\n" : "";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function mergeDefaults(target, defaults) {
|
|
129
|
+
if (Array.isArray(defaults)) return target === undefined ? defaults : target;
|
|
130
|
+
if (!defaults || typeof defaults !== "object") {
|
|
131
|
+
return target === undefined ? defaults : target;
|
|
132
|
+
}
|
|
133
|
+
const out = target && typeof target === "object" && !Array.isArray(target) ? { ...target } : {};
|
|
134
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
135
|
+
out[key] = mergeDefaults(out[key], value);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function writeText(file, text, dryRun) {
|
|
141
|
+
if (dryRun) {
|
|
142
|
+
console.log(` [dry-run] would write ${file}:`);
|
|
143
|
+
console.log(text.split("\n").map((l) => " " + l).join("\n"));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
147
|
+
fs.writeFileSync(file, text);
|
|
148
|
+
console.log(` wrote ${file}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isOpenCodeUsageTuiEntry(entry) {
|
|
152
|
+
const spec = Array.isArray(entry) ? entry[0] : entry;
|
|
153
|
+
return (
|
|
154
|
+
typeof spec === "string" &&
|
|
155
|
+
/\/plugins\/opencode\/usage-tui\.mjs$/i.test(spec.replace(/\\/g, "/"))
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function updateOpenCodeTuiConfig(file, { remove, dryRun }) {
|
|
160
|
+
const exists = fs.existsSync(file);
|
|
161
|
+
const currentText = exists ? fs.readFileSync(file, "utf8") : "{}\n";
|
|
162
|
+
const errors = [];
|
|
163
|
+
const current = parseJsonc(currentText, errors, { allowTrailingComma: true }) || {};
|
|
164
|
+
if (errors.length > 0 || typeof current !== "object" || Array.isArray(current)) {
|
|
165
|
+
throw new Error(`Cannot parse ${file} as JSONC`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const plugins = Array.isArray(current.plugin) ? current.plugin : [];
|
|
169
|
+
const next = plugins.filter((entry) => !isOpenCodeUsageTuiEntry(entry));
|
|
170
|
+
if (!remove) next.push(pathToFileURL(RUNTIME.opencodeUsageTui).href);
|
|
171
|
+
if (JSON.stringify(plugins) === JSON.stringify(next)) {
|
|
172
|
+
console.log(` kept existing ${file}`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const eol = currentText.includes("\r\n") ? "\r\n" : "\n";
|
|
177
|
+
const edits = modify(currentText, ["plugin"], next.length > 0 ? next : undefined, {
|
|
178
|
+
formattingOptions: { insertSpaces: true, tabSize: 2, eol },
|
|
179
|
+
});
|
|
180
|
+
const updated = applyEdits(currentText, edits).replace(/\s*$/, "") + eol;
|
|
181
|
+
writeText(file, updated, dryRun);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function mergeJsoncFile(src, dest, dryRun) {
|
|
185
|
+
const defaults = readJsonc(src);
|
|
186
|
+
const defaultHeader = headerComments(fs.readFileSync(src, "utf8"));
|
|
187
|
+
if (!fs.existsSync(dest)) {
|
|
188
|
+
writeText(dest, fs.readFileSync(src, "utf8"), dryRun);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const currentText = fs.readFileSync(dest, "utf8");
|
|
192
|
+
const current = readJsonc(dest);
|
|
193
|
+
const merged = mergeDefaults(current, defaults);
|
|
194
|
+
const currentHeader = headerComments(currentText) || defaultHeader;
|
|
195
|
+
const mergedText = currentHeader + JSON.stringify(merged, null, 2) + "\n";
|
|
196
|
+
if (stripJsonComments(currentText).trim() === JSON.stringify(merged, null, 2)) {
|
|
197
|
+
console.log(` kept existing ${dest}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
writeText(dest, mergedText, dryRun);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function copyRuntimeFile(src, dest, dryRun, options = {}) {
|
|
204
|
+
if (options.mergeJsonc) {
|
|
205
|
+
mergeJsoncFile(src, dest, dryRun);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (dryRun) {
|
|
209
|
+
console.log(` [dry-run] would copy ${src} -> ${dest}`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
213
|
+
fs.copyFileSync(src, dest);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function installRuntimeAssets(opts) {
|
|
217
|
+
if (opts.uninstall) return;
|
|
218
|
+
const files = [];
|
|
219
|
+
function addFile(src, dest, options) {
|
|
220
|
+
if (!files.some(([, existingDest]) => existingDest === dest)) files.push([src, dest, options]);
|
|
221
|
+
}
|
|
222
|
+
if (wants(opts, "statusline")) {
|
|
223
|
+
addFile(SOURCE.claudeStatusline, RUNTIME.claudeStatusline);
|
|
224
|
+
addFile(SOURCE.usageScript, RUNTIME.usageScript);
|
|
225
|
+
addFile(SOURCE.config, RUNTIME.config, { mergeJsonc: true });
|
|
226
|
+
}
|
|
227
|
+
if (wants(opts, "usage")) {
|
|
228
|
+
if (opts.agents.includes("codex")) {
|
|
229
|
+
addFile(SOURCE.codexUsageHook, RUNTIME.codexUsageHook);
|
|
230
|
+
}
|
|
231
|
+
if (opts.agents.includes("opencode")) {
|
|
232
|
+
addFile(SOURCE.opencodeUsagePlugin, RUNTIME.opencodeUsagePlugin);
|
|
233
|
+
addFile(SOURCE.opencodeUsageTui, RUNTIME.opencodeUsageTui);
|
|
234
|
+
}
|
|
235
|
+
addFile(SOURCE.usageScript, RUNTIME.usageScript);
|
|
236
|
+
addFile(SOURCE.config, RUNTIME.config, { mergeJsonc: true });
|
|
237
|
+
}
|
|
238
|
+
if (files.length === 0) return;
|
|
239
|
+
console.log(`runtime: ${INSTALL_ROOT}`);
|
|
240
|
+
for (const [src, dest, options] of files) copyRuntimeFile(src, dest, opts.dryRun, options);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parseArgs(argv) {
|
|
244
|
+
const opts = {
|
|
245
|
+
agents: [],
|
|
246
|
+
capabilities: [],
|
|
247
|
+
settings: null,
|
|
248
|
+
codexHooks: null,
|
|
249
|
+
opencodeConfigDir: null,
|
|
250
|
+
uninstall: false,
|
|
251
|
+
dryRun: false,
|
|
252
|
+
help: false,
|
|
253
|
+
};
|
|
254
|
+
function readAgentValues(index, option) {
|
|
255
|
+
const values = [];
|
|
256
|
+
let i = index + 1;
|
|
257
|
+
while (i < argv.length && ALL_AGENTS.includes(argv[i])) {
|
|
258
|
+
values.push(argv[i]);
|
|
259
|
+
i++;
|
|
260
|
+
}
|
|
261
|
+
if (values.length === 0) {
|
|
262
|
+
console.error(`Missing value for ${option}`);
|
|
263
|
+
process.exit(2);
|
|
264
|
+
}
|
|
265
|
+
return { values, next: i - 1 };
|
|
266
|
+
}
|
|
267
|
+
for (let i = 0; i < argv.length; i++) {
|
|
268
|
+
const a = argv[i];
|
|
269
|
+
switch (a) {
|
|
270
|
+
case "-a":
|
|
271
|
+
case "--agent": {
|
|
272
|
+
const parsed = readAgentValues(i, a);
|
|
273
|
+
opts.agents.push(...parsed.values);
|
|
274
|
+
i = parsed.next;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "--settings": opts.settings = argv[++i]; break;
|
|
278
|
+
case "--codex-hooks": opts.codexHooks = argv[++i]; break;
|
|
279
|
+
case "--opencode-config-dir": opts.opencodeConfigDir = argv[++i]; break;
|
|
280
|
+
case "--uninstall": opts.uninstall = true; break;
|
|
281
|
+
case "--dry-run": opts.dryRun = true; break;
|
|
282
|
+
case "-h":
|
|
283
|
+
case "--help": opts.help = true; break;
|
|
284
|
+
default:
|
|
285
|
+
if (a.startsWith("-")) {
|
|
286
|
+
console.error(`Unknown option: ${a}`);
|
|
287
|
+
process.exit(2);
|
|
288
|
+
}
|
|
289
|
+
opts.capabilities.push(a);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (opts.agents.length === 0) opts.agents = ["claude"];
|
|
293
|
+
if (!opts.help && opts.capabilities.length === 0) {
|
|
294
|
+
console.error(`Missing capability (available: ${ALL_CAPS.join(", ")})`);
|
|
295
|
+
process.exit(2);
|
|
296
|
+
}
|
|
297
|
+
for (const name of opts.capabilities) {
|
|
298
|
+
if (!ALL_CAPS.includes(name)) {
|
|
299
|
+
console.error(`Unknown capability: ${name} (available: ${ALL_CAPS.join(", ")})`);
|
|
300
|
+
process.exit(2);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return opts;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function wants(opts, cap) {
|
|
307
|
+
return opts.capabilities.length === 0 || opts.capabilities.includes(cap);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function validateAgentCapabilities(opts) {
|
|
311
|
+
const invalid = [];
|
|
312
|
+
for (const agent of opts.agents) {
|
|
313
|
+
const supported = AGENT_CAPS[agent] || [];
|
|
314
|
+
for (const cap of opts.capabilities) {
|
|
315
|
+
if (!supported.includes(cap)) invalid.push(`${cap} -a ${agent}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (invalid.length === 0) return;
|
|
319
|
+
|
|
320
|
+
console.error(`Unsupported capability/agent combination: ${invalid.join(", ")}`);
|
|
321
|
+
console.error("Supported combinations:");
|
|
322
|
+
for (const agent of ALL_AGENTS) {
|
|
323
|
+
console.error(` ${agent}: ${AGENT_CAPS[agent].join(", ")}`);
|
|
324
|
+
}
|
|
325
|
+
console.error("Claude API usage is refreshed by the statusline capability; use `statusline -a claude`.");
|
|
326
|
+
process.exit(2);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function readJson(file) {
|
|
330
|
+
if (!fs.existsSync(file)) return {};
|
|
331
|
+
// Strip a UTF-8 BOM (common on Windows-authored files) before parsing.
|
|
332
|
+
let raw = fs.readFileSync(file, "utf8");
|
|
333
|
+
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1); // strip UTF-8 BOM
|
|
334
|
+
if (!raw.trim()) return {};
|
|
335
|
+
try {
|
|
336
|
+
return JSON.parse(raw);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
throw new Error(`Cannot parse ${file} as JSON: ${err.message}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function writeJson(file, data, dryRun) {
|
|
343
|
+
const text = JSON.stringify(data, null, 2) + "\n";
|
|
344
|
+
if (dryRun) {
|
|
345
|
+
console.log(` [dry-run] would write ${file}:`);
|
|
346
|
+
console.log(text.split("\n").map((l) => " " + l).join("\n"));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
350
|
+
fs.writeFileSync(file, text);
|
|
351
|
+
console.log(` wrote ${file}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function removeFile(file, dryRun) {
|
|
355
|
+
if (dryRun) {
|
|
356
|
+
console.log(` [dry-run] would remove ${file}`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
fs.rmSync(file, { force: true });
|
|
360
|
+
console.log(` removed ${file}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function usageEntry() {
|
|
364
|
+
return {
|
|
365
|
+
hooks: [
|
|
366
|
+
{
|
|
367
|
+
type: "command",
|
|
368
|
+
command: nodeCmd(RUNTIME.codexUsageHook),
|
|
369
|
+
timeout: 5,
|
|
370
|
+
statusMessage: "Refreshing API usage",
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isOurProviderUsageEntry(entry) {
|
|
377
|
+
return (
|
|
378
|
+
entry &&
|
|
379
|
+
Array.isArray(entry.hooks) &&
|
|
380
|
+
entry.hooks.some(
|
|
381
|
+
(h) =>
|
|
382
|
+
typeof h?.command === "string" &&
|
|
383
|
+
/(?:^|[/\\])(?:usage-hook|usage)\.mjs(?:["\s]|$)/.test(h.command)
|
|
384
|
+
)
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function applyProviderUsage(cfg, { remove }) {
|
|
389
|
+
cfg.hooks = cfg.hooks || {};
|
|
390
|
+
const events = ["UserPromptSubmit", "Stop"];
|
|
391
|
+
for (const event of events) {
|
|
392
|
+
if (remove) {
|
|
393
|
+
if (cfg.hooks[event]) {
|
|
394
|
+
cfg.hooks[event] = cfg.hooks[event].filter((entry) => !isOurProviderUsageEntry(entry));
|
|
395
|
+
if (cfg.hooks[event].length === 0) delete cfg.hooks[event];
|
|
396
|
+
}
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
cfg.hooks[event] = cfg.hooks[event] || [];
|
|
400
|
+
cfg.hooks[event] = cfg.hooks[event].filter((entry) => !isOurProviderUsageEntry(entry));
|
|
401
|
+
cfg.hooks[event].push(usageEntry());
|
|
402
|
+
}
|
|
403
|
+
if (Object.keys(cfg.hooks).length === 0) delete cfg.hooks;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---- Claude: statusLine backed up in _agentTools. ----
|
|
407
|
+
|
|
408
|
+
function runClaude(opts) {
|
|
409
|
+
const settings = opts.settings || path.join(os.homedir(), ".claude", "settings.json");
|
|
410
|
+
console.log(`claude (global): ${settings}`);
|
|
411
|
+
const cfg = readJson(settings);
|
|
412
|
+
cfg[META_KEY] = cfg[META_KEY] || { version: META_VERSION, managed: {} };
|
|
413
|
+
cfg[META_KEY].managed = cfg[META_KEY].managed || {};
|
|
414
|
+
const managed = cfg[META_KEY].managed;
|
|
415
|
+
|
|
416
|
+
if (wants(opts, "statusline")) {
|
|
417
|
+
if (opts.uninstall) {
|
|
418
|
+
if (managed.statusLine) {
|
|
419
|
+
if (managed.statusLine.backup) {
|
|
420
|
+
cfg.statusLine = managed.statusLine.backup;
|
|
421
|
+
console.log(" - statusline (restored previous)");
|
|
422
|
+
} else {
|
|
423
|
+
delete cfg.statusLine;
|
|
424
|
+
console.log(" - statusline");
|
|
425
|
+
}
|
|
426
|
+
delete managed.statusLine;
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
const command = nodeCmd(RUNTIME.claudeStatusline);
|
|
430
|
+
const alreadyOurs = Boolean(managed.statusLine);
|
|
431
|
+
managed.statusLine = {
|
|
432
|
+
backup: alreadyOurs ? managed.statusLine.backup ?? null : cfg.statusLine ?? null,
|
|
433
|
+
};
|
|
434
|
+
cfg.statusLine = { type: "command", command, padding: 0, refreshInterval: 60 };
|
|
435
|
+
console.log(" + statusline");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (Object.keys(managed).length === 0) delete cfg[META_KEY];
|
|
440
|
+
writeJson(settings, cfg, opts.dryRun);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---- Codex: hooks in a standalone hooks.json. No meta key is
|
|
444
|
+
// written (Codex validates hooks.json against a schema); our hook is found by
|
|
445
|
+
// command signature instead. ----
|
|
446
|
+
|
|
447
|
+
function runCodex(opts) {
|
|
448
|
+
if (!wants(opts, "usage")) {
|
|
449
|
+
console.log("codex: nothing to do (supports: usage).");
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const file = opts.codexHooks || path.join(os.homedir(), ".codex", "hooks.json");
|
|
453
|
+
console.log(`codex (global): ${file}`);
|
|
454
|
+
const cfg = readJson(file);
|
|
455
|
+
|
|
456
|
+
if (wants(opts, "usage")) {
|
|
457
|
+
if (opts.uninstall) {
|
|
458
|
+
applyProviderUsage(cfg, { remove: true });
|
|
459
|
+
console.log(" - usage");
|
|
460
|
+
} else {
|
|
461
|
+
applyProviderUsage(cfg, { remove: false });
|
|
462
|
+
console.log(" + usage (UserPromptSubmit + Stop)");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (opts.uninstall && Object.keys(cfg).length === 0 && fs.existsSync(file)) {
|
|
467
|
+
removeFile(file, opts.dryRun);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
writeJson(file, cfg, opts.dryRun);
|
|
472
|
+
|
|
473
|
+
if (!opts.uninstall && !opts.dryRun) {
|
|
474
|
+
console.log(
|
|
475
|
+
" NOTE: Codex will not run this hook until you trust it — run `/hooks` " +
|
|
476
|
+
"inside Codex and approve the agent-tools hooks."
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ---- opencode: a server plugin captures the resolved provider and refreshes
|
|
482
|
+
// usage after a session goes idle. A TUI plugin displays the shared snapshot. ----
|
|
483
|
+
|
|
484
|
+
const OPENCODE_STUB_NAME = "agent-tools-usage.js";
|
|
485
|
+
|
|
486
|
+
function opencodeConfigDir(opts) {
|
|
487
|
+
return (
|
|
488
|
+
opts.opencodeConfigDir ||
|
|
489
|
+
process.env.OPENCODE_CONFIG_DIR ||
|
|
490
|
+
path.join(os.homedir(), ".config", "opencode")
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function runOpencode(opts) {
|
|
495
|
+
if (!wants(opts, "usage")) {
|
|
496
|
+
console.log("opencode: nothing to do (supports: usage).");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const configDir = opencodeConfigDir(opts);
|
|
501
|
+
const stub = path.join(configDir, "plugins", OPENCODE_STUB_NAME);
|
|
502
|
+
const tuiConfig = path.join(configDir, "tui.json");
|
|
503
|
+
console.log(`opencode (global): ${configDir}`);
|
|
504
|
+
|
|
505
|
+
if (opts.uninstall) {
|
|
506
|
+
if (fs.existsSync(stub)) removeFile(stub, opts.dryRun);
|
|
507
|
+
else console.log(" no agent-tools server plugin found; nothing to remove.");
|
|
508
|
+
updateOpenCodeTuiConfig(tuiConfig, { remove: true, dryRun: opts.dryRun });
|
|
509
|
+
console.log(" - usage plugin");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const target = pathToFileURL(RUNTIME.opencodeUsagePlugin).href;
|
|
514
|
+
const contents =
|
|
515
|
+
"// Generated by agent-tools installer; do not edit.\n" +
|
|
516
|
+
`export { AgentToolsUsage } from ${JSON.stringify(target)};\n`;
|
|
517
|
+
writeText(stub, contents, opts.dryRun);
|
|
518
|
+
updateOpenCodeTuiConfig(tuiConfig, { remove: false, dryRun: opts.dryRun });
|
|
519
|
+
console.log(" + usage plugin (session idle + TUI)");
|
|
520
|
+
if (!opts.dryRun) console.log(" NOTE: restart opencode to load the agent-tools usage plugin.");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const AGENTS = { claude: runClaude, codex: runCodex, opencode: runOpencode };
|
|
524
|
+
|
|
525
|
+
function main() {
|
|
526
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
527
|
+
if (opts.help) {
|
|
528
|
+
// Print only the top-of-file header comment block (stop at the first
|
|
529
|
+
// non-comment line so internal `// ----` section dividers don't leak).
|
|
530
|
+
const help = [];
|
|
531
|
+
for (const line of fs.readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n")) {
|
|
532
|
+
if (line.startsWith("#!")) continue;
|
|
533
|
+
if (line.startsWith("//")) help.push(line.replace(/^\/\/ ?/, ""));
|
|
534
|
+
else if (help.length) break;
|
|
535
|
+
}
|
|
536
|
+
console.log(help.join("\n"));
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
validateAgentCapabilities(opts);
|
|
540
|
+
installRuntimeAssets(opts);
|
|
541
|
+
for (const name of opts.agents) {
|
|
542
|
+
const run = AGENTS[name];
|
|
543
|
+
if (!run) {
|
|
544
|
+
console.error(`Unsupported agent: ${name} (supported: ${Object.keys(AGENTS).join(", ")})`);
|
|
545
|
+
process.exit(2);
|
|
546
|
+
}
|
|
547
|
+
run(opts);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
main();
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: at-commit
|
|
3
|
+
description: "Generate a Conventional Commits message from staged changes and wait for confirmation before committing. Use when the user asks to commit or generate a commit message."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Commit Message Generator
|
|
7
|
+
|
|
8
|
+
<!--
|
|
9
|
+
References (background only; rules in this file are authoritative):
|
|
10
|
+
- OpenCommit
|
|
11
|
+
- GitLens generateCommitMessage
|
|
12
|
+
-->
|
|
13
|
+
|
|
14
|
+
## Workflow
|
|
15
|
+
|
|
16
|
+
1. Run `git diff --staged` to inspect staged changes. If it is empty, tell the user to run `git add` first and stop.
|
|
17
|
+
2. Determine the description language using the Language Policy below.
|
|
18
|
+
3. Generate a message using the rules below and show it for confirmation. Do not commit immediately. When useful, provide concrete candidate messages instead of only asking a question.
|
|
19
|
+
4. Run `git commit -m` only after the user explicitly confirms, for example with "commit" or "ok". Do not run `git push` unless the user explicitly asks.
|
|
20
|
+
|
|
21
|
+
## Language Policy
|
|
22
|
+
|
|
23
|
+
Use this priority order for the human-readable description after `type(scope):`:
|
|
24
|
+
|
|
25
|
+
1. Use the language explicitly requested by the user in this turn.
|
|
26
|
+
2. Otherwise use the repository preference in `.agent-tools/config.jsonc`, if it defines `at-commit.language`.
|
|
27
|
+
3. Otherwise use the global preference in `~/.agent-tools/config.jsonc`, if it defines `at-commit.language`.
|
|
28
|
+
4. Otherwise use the language of the user's actual invocation text when it contains a clear, independent natural-language request. Ignore injected skill instructions, quoted or pasted content, code blocks, diffs, and tool output.
|
|
29
|
+
5. Otherwise match the dominant language in recent repository commit subjects.
|
|
30
|
+
6. If no dominant language is clear, default to English.
|
|
31
|
+
|
|
32
|
+
Apply this order literally and stop at the first match. Check items 2 and 3 before item 4. A bare slash command or skill invocation has no language; continue to item 5. Inspect at most the latest 20 subjects.
|
|
33
|
+
|
|
34
|
+
Keep Conventional Commits syntax tokens untranslated: `type`, optional `scope`, `!`, and `BREAKING CHANGE`. Keep identifiers, file names, package names, commands, API names, and scopes in their original language.
|
|
35
|
+
|
|
36
|
+
## Persistent Language Preference
|
|
37
|
+
|
|
38
|
+
Do not write config during ordinary commit generation. If the user explicitly asks to remember a commit-description language, offer to persist it. Ask repo vs global when scope is ambiguous.
|
|
39
|
+
|
|
40
|
+
For repo preference, create or update `.agent-tools/config.jsonc`; for global preference, use `~/.agent-tools/config.jsonc`:
|
|
41
|
+
|
|
42
|
+
```jsonc
|
|
43
|
+
{
|
|
44
|
+
"at-commit.language": "zh-CN"
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Preserve unrelated keys and comments when practical. Do not require prior installer setup.
|
|
49
|
+
|
|
50
|
+
## Rules
|
|
51
|
+
|
|
52
|
+
Read `git diff --staged` and generate a Conventional Commits title that explains WHAT changed and, when useful, WHY.
|
|
53
|
+
|
|
54
|
+
- **Title**: `type(scope): description`, <= 74 characters, no trailing period, no prefix, no quotes, no code block.
|
|
55
|
+
- **type**: choose exactly one:
|
|
56
|
+
- `feat` for a feature, `fix` for a bug fix
|
|
57
|
+
- `refactor` for behavior-preserving restructuring, `perf` for performance
|
|
58
|
+
- `docs` for documentation-only changes, `style` for formatting-only changes
|
|
59
|
+
- `test` for adding or fixing tests, `build` for build systems or dependencies, `ci` for CI config/scripts
|
|
60
|
+
- `chore` for miscellaneous changes that do not touch src/test, `revert` for reverts
|
|
61
|
+
- **scope**: optional affected module name. Use this repository's convention. In monorepos, use the app/package directory name. Omit it for cross-cutting changes or when unsure.
|
|
62
|
+
- **description**: one concise verb-object phrase in the selected natural language. Avoid filler equivalent to "update files", "this commit", "misc changes", or "several changes". Preserve identifiers, function names, file names, package names, commands, and APIs as written.
|
|
63
|
+
- **single line only**: commit messages always contain only the title line. Do not write a body or footer. Compress necessary WHY into the title.
|
|
64
|
+
- **breaking changes**: mark with `type(scope)!: description`, still as one line. Do not use a `BREAKING CHANGE` footer.
|
|
65
|
+
- **punctuation**: use ASCII punctuation.
|
|
66
|
+
|
|
67
|
+
## Generation Strategy
|
|
68
|
+
|
|
69
|
+
Before writing, identify the staged changes' through-line.
|
|
70
|
+
|
|
71
|
+
- **Main line**: include only the information needed to understand this commit. Do not pile on names unless one is the point of the change.
|
|
72
|
+
- **Significant changes**: scan for logic, contract, build, quality-gate, or user-visible behavior changes. Use that to choose `type` and decide whether `!` is needed.
|
|
73
|
+
|
|
74
|
+
## Content Discipline
|
|
75
|
+
|
|
76
|
+
The message must state confirmed code facts, not guessed intent.
|
|
77
|
+
|
|
78
|
+
- **Prefer behavior changes in `src` and `test`**: docs are supporting context only. Do not infer WHAT from docs alone.
|
|
79
|
+
- **User context beats docs**: explicit user goals can help choose WHY and wording, but WHAT and scope still come from the staged diff. Do not include context-only changes absent from the diff. If context and diff conflict, follow the diff and ask for confirmation.
|
|
80
|
+
- **Deletes and renames**: when purpose cannot be confirmed from code relationships, state only what the diff proves, such as "remove X" or "rename X to Y". Do not claim replacement, migration, or causality unless the code proves it.
|
|
81
|
+
- **Multiple changes with one goal**: provide one summary message when the changes clearly serve the same goal, such as a quality or build fix. Suggest splitting only when it would materially improve review or rollback.
|
|
82
|
+
- **Unrelated staged changes**: provide split messages for each coherent unit plus one conservative summary message. Let the user choose split or summary instead of deferring the summary to another turn.
|
|
83
|
+
- **Unclear ownership or intent**: list files that need confirmation instead of forcing a vague message. Prefer confirmation or splitting over weak words like "update", "adjust", or "tweak" when the intent is unclear.
|