token-pilot 0.19.2 → 0.22.2
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/.claude-plugin/hooks/hooks.json +21 -0
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +129 -0
- package/README.md +172 -315
- package/dist/agents/tp-commit-writer.md +41 -0
- package/dist/agents/tp-dead-code-finder.md +43 -0
- package/dist/agents/tp-debugger.md +45 -0
- package/dist/agents/tp-impact-analyzer.md +44 -0
- package/dist/agents/tp-migration-scout.md +43 -0
- package/dist/agents/tp-onboard.md +40 -0
- package/dist/agents/tp-pr-reviewer.md +41 -0
- package/dist/agents/tp-refactor-planner.md +42 -0
- package/dist/agents/tp-run.md +48 -0
- package/dist/agents/tp-test-triage.md +40 -0
- package/dist/agents/tp-test-writer.md +46 -0
- package/dist/cli/agent-frontmatter.d.ts +48 -0
- package/dist/cli/agent-frontmatter.js +189 -0
- package/dist/cli/bless-agents.d.ts +65 -0
- package/dist/cli/bless-agents.js +307 -0
- package/dist/cli/claudeignore.d.ts +33 -0
- package/dist/cli/claudeignore.js +88 -0
- package/dist/cli/claudemd-hygiene.d.ts +26 -0
- package/dist/cli/claudemd-hygiene.js +43 -0
- package/dist/cli/doctor-drift.d.ts +31 -0
- package/dist/cli/doctor-drift.js +130 -0
- package/dist/cli/doctor-env-check.d.ts +25 -0
- package/dist/cli/doctor-env-check.js +91 -0
- package/dist/cli/install-agents.d.ts +108 -0
- package/dist/cli/install-agents.js +402 -0
- package/dist/cli/save-doc.d.ts +42 -0
- package/dist/cli/save-doc.js +145 -0
- package/dist/cli/scan-agents.d.ts +46 -0
- package/dist/cli/scan-agents.js +227 -0
- package/dist/cli/stats.d.ts +36 -0
- package/dist/cli/stats.js +131 -0
- package/dist/cli/unbless-agents.d.ts +33 -0
- package/dist/cli/unbless-agents.js +85 -0
- package/dist/cli/uninstall-agents.d.ts +36 -0
- package/dist/cli/uninstall-agents.js +117 -0
- package/dist/config/defaults.d.ts +1 -1
- package/dist/config/defaults.js +14 -8
- package/dist/config/loader.d.ts +1 -1
- package/dist/config/loader.js +105 -11
- package/dist/core/context-registry.d.ts +16 -1
- package/dist/core/context-registry.js +60 -28
- package/dist/core/event-log.d.ts +79 -0
- package/dist/core/event-log.js +190 -0
- package/dist/core/session-registry.d.ts +43 -0
- package/dist/core/session-registry.js +113 -0
- package/dist/core/session-savings.d.ts +19 -0
- package/dist/core/session-savings.js +60 -0
- package/dist/handlers/session-budget.d.ts +32 -0
- package/dist/handlers/session-budget.js +61 -0
- package/dist/handlers/session-snapshot-persist.d.ts +22 -0
- package/dist/handlers/session-snapshot-persist.js +76 -0
- package/dist/hooks/adaptive-threshold.d.ts +27 -0
- package/dist/hooks/adaptive-threshold.js +46 -0
- package/dist/hooks/format-deny-message.d.ts +21 -0
- package/dist/hooks/format-deny-message.js +147 -0
- package/dist/hooks/installer.js +121 -31
- package/dist/hooks/path-safety.d.ts +16 -0
- package/dist/hooks/path-safety.js +34 -0
- package/dist/hooks/post-bash.d.ts +46 -0
- package/dist/hooks/post-bash.js +77 -0
- package/dist/hooks/session-start.d.ts +45 -0
- package/dist/hooks/session-start.js +179 -0
- package/dist/hooks/summary-ast-index.d.ts +28 -0
- package/dist/hooks/summary-ast-index.js +122 -0
- package/dist/hooks/summary-head-tail.d.ts +15 -0
- package/dist/hooks/summary-head-tail.js +78 -0
- package/dist/hooks/summary-pipeline.d.ts +35 -0
- package/dist/hooks/summary-pipeline.js +63 -0
- package/dist/hooks/summary-regex.d.ts +14 -0
- package/dist/hooks/summary-regex.js +130 -0
- package/dist/hooks/summary-types.d.ts +29 -0
- package/dist/hooks/summary-types.js +9 -0
- package/dist/index.d.ts +15 -3
- package/dist/index.js +509 -149
- package/dist/integration/context-mode-detector.d.ts +7 -1
- package/dist/integration/context-mode-detector.js +51 -15
- package/dist/server/tool-definitions.d.ts +149 -0
- package/dist/server/tool-definitions.js +424 -202
- package/dist/server.d.ts +1 -1
- package/dist/server.js +456 -179
- package/dist/templates/agent-builder.d.ts +49 -0
- package/dist/templates/agent-builder.js +104 -0
- package/dist/types.d.ts +38 -4
- package/package.json +4 -2
- package/skills/stats/SKILL.md +13 -2
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent scanner + classifier (subtasks 3.2 + 3.3).
|
|
3
|
+
*
|
|
4
|
+
* Discovers agent .md files from three locations:
|
|
5
|
+
* 1. project .claude/agents/**\/*.md (scope: 'project')
|
|
6
|
+
* 2. user ~/.claude/agents/**\/*.md (scope: 'user')
|
|
7
|
+
* 3. plugin-contributed agents (scope: 'plugin')
|
|
8
|
+
*
|
|
9
|
+
* For each file: parses frontmatter, computes body hash, returns a ScannedAgent.
|
|
10
|
+
* Never throws — bad/unreadable files are skipped with a one-line stderr note.
|
|
11
|
+
* Symlinks pointing outside the scope's nominal root are skipped.
|
|
12
|
+
*/
|
|
13
|
+
import { readFile, lstat, realpath, readdir } from "node:fs/promises";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { join, resolve, basename } from "node:path";
|
|
16
|
+
import { parseFrontmatter, parseToolsField, } from "./agent-frontmatter.js";
|
|
17
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
18
|
+
function sha256(s) {
|
|
19
|
+
return createHash("sha256").update(s).digest("hex");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Recursively list all *.md files under a directory.
|
|
23
|
+
* Returns absolute paths. Returns [] if the directory doesn't exist.
|
|
24
|
+
*/
|
|
25
|
+
async function listMdFiles(dir) {
|
|
26
|
+
const results = [];
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
entries = await readdir(dir, { withFileTypes: true, encoding: "utf-8" });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
const name = entry.name;
|
|
36
|
+
const fullPath = join(dir, name);
|
|
37
|
+
if (entry.isDirectory()) {
|
|
38
|
+
const nested = await listMdFiles(fullPath);
|
|
39
|
+
results.push(...nested);
|
|
40
|
+
}
|
|
41
|
+
else if (entry.isFile() && name.endsWith(".md")) {
|
|
42
|
+
results.push(fullPath);
|
|
43
|
+
}
|
|
44
|
+
// Skip symlinks entirely at this level — handled per-file below
|
|
45
|
+
}
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Expand glob patterns (supporting only the trailing `*.md` form used for
|
|
50
|
+
* plugin cache paths). For patterns without wildcards the path is treated
|
|
51
|
+
* literally. Never throws.
|
|
52
|
+
*/
|
|
53
|
+
async function resolveGlobs(patterns) {
|
|
54
|
+
const results = [];
|
|
55
|
+
for (const pattern of patterns) {
|
|
56
|
+
if (!pattern.includes("*")) {
|
|
57
|
+
// Literal file path
|
|
58
|
+
try {
|
|
59
|
+
const stat = await lstat(pattern);
|
|
60
|
+
if (stat.isFile())
|
|
61
|
+
results.push(resolve(pattern));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// ignore missing
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// Only handle trailing `*.md` glob (the only form used for plugin dirs)
|
|
69
|
+
const starIdx = pattern.indexOf("*");
|
|
70
|
+
const dir = pattern.slice(0, starIdx);
|
|
71
|
+
const suffix = pattern.slice(starIdx + 1); // e.g. ".md"
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = await readdir(dir.replace(/\/$/, ""), {
|
|
75
|
+
withFileTypes: true,
|
|
76
|
+
encoding: "utf-8",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const name = entry.name;
|
|
84
|
+
if (entry.isFile() && name.endsWith(suffix)) {
|
|
85
|
+
results.push(resolve(join(dir.replace(/\/$/, ""), name)));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return results;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Check whether filePath is a symlink that resolves outside of rootDir.
|
|
93
|
+
* Returns true if the file should be skipped.
|
|
94
|
+
*/
|
|
95
|
+
async function isSymlinkOutsideRoot(filePath, rootDir) {
|
|
96
|
+
let stat;
|
|
97
|
+
try {
|
|
98
|
+
stat = await lstat(filePath);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (!stat.isSymbolicLink())
|
|
104
|
+
return false;
|
|
105
|
+
try {
|
|
106
|
+
const real = await realpath(filePath);
|
|
107
|
+
const normalRoot = resolve(rootDir) + "/";
|
|
108
|
+
return !real.startsWith(normalRoot);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// If we can't resolve, skip it to be safe
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Parse one agent file. Returns a ScannedAgent or null if the file should be
|
|
117
|
+
* skipped (parse failure, missing name, symlink outside root).
|
|
118
|
+
*/
|
|
119
|
+
async function parseAgentFile(filePath, scope, scopeRoot) {
|
|
120
|
+
// Symlink guard
|
|
121
|
+
if (await isSymlinkOutsideRoot(filePath, scopeRoot)) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
let content;
|
|
125
|
+
try {
|
|
126
|
+
content = await readFile(filePath, "utf-8");
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
process.stderr.write(`token-pilot scan-agents: skipping ${filePath}: ${err instanceof Error ? err.message : err}\n`);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const { meta, body } = parseFrontmatter(content);
|
|
133
|
+
// Must have a name
|
|
134
|
+
const name = typeof meta.name === "string" && meta.name.trim()
|
|
135
|
+
? meta.name.trim()
|
|
136
|
+
: basename(filePath, ".md");
|
|
137
|
+
// If body is empty AND no name in frontmatter AND no description, it's likely
|
|
138
|
+
// a file with no frontmatter at all — skip it
|
|
139
|
+
if (!meta.name && !meta.description && body === content) {
|
|
140
|
+
process.stderr.write(`token-pilot scan-agents: skipping ${filePath}: no frontmatter found\n`);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const tools = parseToolsField(meta.tools);
|
|
144
|
+
const description = typeof meta.description === "string" ? meta.description : "";
|
|
145
|
+
const bodyHash = sha256(body);
|
|
146
|
+
// blessed: check token_pilot.blessed === true (explicit marker, not substring)
|
|
147
|
+
const blessed = meta.token_pilot !== null &&
|
|
148
|
+
typeof meta.token_pilot === "object" &&
|
|
149
|
+
meta.token_pilot.blessed === true;
|
|
150
|
+
return {
|
|
151
|
+
name,
|
|
152
|
+
path: filePath,
|
|
153
|
+
scope,
|
|
154
|
+
tools,
|
|
155
|
+
description,
|
|
156
|
+
bodyHash,
|
|
157
|
+
blessed,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// ─── scanAgents ───────────────────────────────────────────────────────────────
|
|
161
|
+
/**
|
|
162
|
+
* Scan all agent directories and return parsed ScannedAgent entries.
|
|
163
|
+
* Never throws.
|
|
164
|
+
*/
|
|
165
|
+
export async function scanAgents(opts) {
|
|
166
|
+
const results = [];
|
|
167
|
+
// 1. Project .claude/agents/
|
|
168
|
+
const projectAgentsDir = join(opts.projectRoot, ".claude", "agents");
|
|
169
|
+
const projectFiles = await listMdFiles(projectAgentsDir);
|
|
170
|
+
for (const filePath of projectFiles) {
|
|
171
|
+
const agent = await parseAgentFile(filePath, "project", projectAgentsDir);
|
|
172
|
+
if (agent)
|
|
173
|
+
results.push(agent);
|
|
174
|
+
}
|
|
175
|
+
// 2. User ~/.claude/agents/
|
|
176
|
+
const userAgentsDir = join(opts.homeDir, ".claude", "agents");
|
|
177
|
+
const userFiles = await listMdFiles(userAgentsDir);
|
|
178
|
+
for (const filePath of userFiles) {
|
|
179
|
+
const agent = await parseAgentFile(filePath, "user", userAgentsDir);
|
|
180
|
+
if (agent)
|
|
181
|
+
results.push(agent);
|
|
182
|
+
}
|
|
183
|
+
// 3. Plugin cache globs
|
|
184
|
+
const pluginFiles = await resolveGlobs(opts.pluginCacheGlob);
|
|
185
|
+
for (const filePath of pluginFiles) {
|
|
186
|
+
// For plugin files, use the file's parent directory as the scope root
|
|
187
|
+
const pluginRoot = resolve(filePath, "..");
|
|
188
|
+
const agent = await parseAgentFile(filePath, "plugin", pluginRoot);
|
|
189
|
+
if (agent)
|
|
190
|
+
results.push(agent);
|
|
191
|
+
}
|
|
192
|
+
return results;
|
|
193
|
+
}
|
|
194
|
+
// ─── classifyAgent ────────────────────────────────────────────────────────────
|
|
195
|
+
const TP_PREFIX = "mcp__token-pilot__";
|
|
196
|
+
function hasTokenPilotTool(tools) {
|
|
197
|
+
return tools.some((t) => t.startsWith(TP_PREFIX));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Classify an agent by its tools field.
|
|
201
|
+
*
|
|
202
|
+
* A — wildcard (tools: * | All tools) → already has MCP access
|
|
203
|
+
* A — explicit list that already contains mcp__token-pilot__* → already has access
|
|
204
|
+
* B — exclusion form where mcp__token-pilot__ is NOT excluded → has access
|
|
205
|
+
* C — explicit list without mcp__token-pilot__* → candidate for blessing
|
|
206
|
+
* C — exclusion form that explicitly excludes mcp__token-pilot__* → needs blessing
|
|
207
|
+
*/
|
|
208
|
+
export function classifyAgent(agent) {
|
|
209
|
+
const { tools } = agent;
|
|
210
|
+
switch (tools.kind) {
|
|
211
|
+
case "wildcard":
|
|
212
|
+
return "A";
|
|
213
|
+
case "exclusion": {
|
|
214
|
+
// If the exclusion list mentions mcp__token-pilot__ → agent lacks access
|
|
215
|
+
const excludesTP = tools.excluded.some((e) => e.startsWith(TP_PREFIX));
|
|
216
|
+
return excludesTP ? "C" : "B";
|
|
217
|
+
}
|
|
218
|
+
case "explicit": {
|
|
219
|
+
// If the explicit list already contains mcp__token-pilot__ → treat as A
|
|
220
|
+
if (hasTokenPilotTool(tools.tools))
|
|
221
|
+
return "A";
|
|
222
|
+
// Otherwise it's a restricted list → C candidate
|
|
223
|
+
return "C";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
//# sourceMappingURL=scan-agents.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 6 subtask 6.3 — `token-pilot stats` CLI.
|
|
3
|
+
*
|
|
4
|
+
* Reads from `<projectRoot>/.token-pilot/hook-events.jsonl` and renders
|
|
5
|
+
* one of three views:
|
|
6
|
+
* - default : totals + top files by savedTokens
|
|
7
|
+
* - --session : events filtered to a single session_id (explicit arg
|
|
8
|
+
* or the most recent session in the log)
|
|
9
|
+
* - --by-agent : events grouped by agent_type, sorted desc by savedTokens
|
|
10
|
+
*
|
|
11
|
+
* formatStats() is pure (events in → string out) so tests drive it
|
|
12
|
+
* directly without touching the filesystem.
|
|
13
|
+
*/
|
|
14
|
+
import { type HookEvent } from "../core/event-log.js";
|
|
15
|
+
export interface StatsOptions {
|
|
16
|
+
/**
|
|
17
|
+
* `true` → pick the session_id of the most recent event.
|
|
18
|
+
* `string` → filter to that specific session_id.
|
|
19
|
+
* `undefined` → no session filter.
|
|
20
|
+
*/
|
|
21
|
+
session?: boolean | string;
|
|
22
|
+
byAgent?: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Pure formatter. Takes the full event list and options; returns the
|
|
26
|
+
* rendered text block. Multi-line; no trailing newline.
|
|
27
|
+
*/
|
|
28
|
+
export declare function formatStats(events: HookEvent[], opts: StatsOptions): string;
|
|
29
|
+
/**
|
|
30
|
+
* CLI entry: `token-pilot stats [--session[=<id>]] [--by-agent]`.
|
|
31
|
+
* Prints to stdout and returns exit code 0.
|
|
32
|
+
*/
|
|
33
|
+
export declare function handleStats(argv: string[], opts?: {
|
|
34
|
+
projectRoot?: string;
|
|
35
|
+
}): Promise<number>;
|
|
36
|
+
//# sourceMappingURL=stats.d.ts.map
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 6 subtask 6.3 — `token-pilot stats` CLI.
|
|
3
|
+
*
|
|
4
|
+
* Reads from `<projectRoot>/.token-pilot/hook-events.jsonl` and renders
|
|
5
|
+
* one of three views:
|
|
6
|
+
* - default : totals + top files by savedTokens
|
|
7
|
+
* - --session : events filtered to a single session_id (explicit arg
|
|
8
|
+
* or the most recent session in the log)
|
|
9
|
+
* - --by-agent : events grouped by agent_type, sorted desc by savedTokens
|
|
10
|
+
*
|
|
11
|
+
* formatStats() is pure (events in → string out) so tests drive it
|
|
12
|
+
* directly without touching the filesystem.
|
|
13
|
+
*/
|
|
14
|
+
import { loadEvents } from "../core/event-log.js";
|
|
15
|
+
function sumSaved(events) {
|
|
16
|
+
return events.reduce((sum, e) => sum + e.savedTokens, 0);
|
|
17
|
+
}
|
|
18
|
+
function groupBy(events, keyOf) {
|
|
19
|
+
const out = new Map();
|
|
20
|
+
for (const e of events) {
|
|
21
|
+
const k = keyOf(e);
|
|
22
|
+
const bucket = out.get(k);
|
|
23
|
+
if (bucket)
|
|
24
|
+
bucket.push(e);
|
|
25
|
+
else
|
|
26
|
+
out.set(k, [e]);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
function pad(label, width) {
|
|
31
|
+
return label.length >= width
|
|
32
|
+
? label
|
|
33
|
+
: label + " ".repeat(width - label.length);
|
|
34
|
+
}
|
|
35
|
+
function pickMostRecentSession(events) {
|
|
36
|
+
if (events.length === 0)
|
|
37
|
+
return null;
|
|
38
|
+
let latest = events[0];
|
|
39
|
+
for (const e of events)
|
|
40
|
+
if (e.ts > latest.ts)
|
|
41
|
+
latest = e;
|
|
42
|
+
return latest.session_id;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Pure formatter. Takes the full event list and options; returns the
|
|
46
|
+
* rendered text block. Multi-line; no trailing newline.
|
|
47
|
+
*/
|
|
48
|
+
export function formatStats(events, opts) {
|
|
49
|
+
let scope = events;
|
|
50
|
+
let sessionLabel = null;
|
|
51
|
+
// --session filter
|
|
52
|
+
if (opts.session !== undefined) {
|
|
53
|
+
const target = opts.session === true ? pickMostRecentSession(events) : opts.session;
|
|
54
|
+
if (!target) {
|
|
55
|
+
return "No events yet.";
|
|
56
|
+
}
|
|
57
|
+
sessionLabel = target;
|
|
58
|
+
scope = events.filter((e) => e.session_id === target);
|
|
59
|
+
if (scope.length === 0) {
|
|
60
|
+
return `No events for session ${target}.`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (scope.length === 0) {
|
|
64
|
+
return "No events yet.";
|
|
65
|
+
}
|
|
66
|
+
const lines = [];
|
|
67
|
+
const total = sumSaved(scope);
|
|
68
|
+
const sessionSuffix = sessionLabel ? ` (session ${sessionLabel})` : "";
|
|
69
|
+
lines.push(`token-pilot stats${sessionSuffix} — ${scope.length} event${scope.length === 1 ? "" : "s"}, ~${total} tokens saved`);
|
|
70
|
+
if (opts.byAgent) {
|
|
71
|
+
// Group by agent_type (null → "main").
|
|
72
|
+
const groups = groupBy(scope, (e) => (e.agent_type ?? "main"));
|
|
73
|
+
const rows = [...groups.entries()]
|
|
74
|
+
.map(([agent, evs]) => ({
|
|
75
|
+
agent,
|
|
76
|
+
saved: sumSaved(evs),
|
|
77
|
+
count: evs.length,
|
|
78
|
+
}))
|
|
79
|
+
.sort((a, b) => b.saved - a.saved);
|
|
80
|
+
lines.push("");
|
|
81
|
+
lines.push("By agent:");
|
|
82
|
+
for (const r of rows) {
|
|
83
|
+
lines.push(` ${pad(r.agent, 20)} ${r.count.toString().padStart(4)}× events ~${r.saved} tokens saved`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
// Default view: top files by savedTokens.
|
|
88
|
+
const groups = groupBy(scope, (e) => e.file);
|
|
89
|
+
const rows = [...groups.entries()]
|
|
90
|
+
.map(([file, evs]) => ({
|
|
91
|
+
file,
|
|
92
|
+
saved: sumSaved(evs),
|
|
93
|
+
count: evs.length,
|
|
94
|
+
}))
|
|
95
|
+
.sort((a, b) => b.saved - a.saved)
|
|
96
|
+
.slice(0, 10);
|
|
97
|
+
lines.push("");
|
|
98
|
+
lines.push("Top files:");
|
|
99
|
+
for (const r of rows) {
|
|
100
|
+
lines.push(` ${pad(r.file, 40)} ${r.count.toString().padStart(3)}× ~${r.saved} tokens saved`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
// ─── CLI wrapper ─────────────────────────────────────────────────────────────
|
|
106
|
+
function parseFlag(argv, key) {
|
|
107
|
+
for (const a of argv) {
|
|
108
|
+
if (a === `--${key}`)
|
|
109
|
+
return true;
|
|
110
|
+
if (a.startsWith(`--${key}=`))
|
|
111
|
+
return a.slice(key.length + 3);
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* CLI entry: `token-pilot stats [--session[=<id>]] [--by-agent]`.
|
|
117
|
+
* Prints to stdout and returns exit code 0.
|
|
118
|
+
*/
|
|
119
|
+
export async function handleStats(argv, opts) {
|
|
120
|
+
const projectRoot = opts?.projectRoot ?? process.cwd();
|
|
121
|
+
const events = await loadEvents(projectRoot);
|
|
122
|
+
const session = parseFlag(argv, "session");
|
|
123
|
+
const byAgent = parseFlag(argv, "by-agent");
|
|
124
|
+
const rendered = formatStats(events, {
|
|
125
|
+
session: session === undefined ? undefined : session,
|
|
126
|
+
byAgent: byAgent === true,
|
|
127
|
+
});
|
|
128
|
+
process.stdout.write(rendered + "\n");
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=stats.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse side of `bless-agents` (Phase 3 subtask 3.6).
|
|
3
|
+
*
|
|
4
|
+
* Walks `./.claude/agents/*.md` in the project, parses each file's
|
|
5
|
+
* frontmatter, and deletes only files that carry the explicit
|
|
6
|
+
* `token_pilot.blessed: true` marker. Files without that marker — user's
|
|
7
|
+
* own customised agents, other plugins' overrides — are left untouched.
|
|
8
|
+
*
|
|
9
|
+
* The function never throws out: every I/O failure is captured in the
|
|
10
|
+
* returned summary so the caller can surface one human-readable stderr
|
|
11
|
+
* message afterwards.
|
|
12
|
+
*/
|
|
13
|
+
export interface UnblessOptions {
|
|
14
|
+
/** Project root — .claude/agents is resolved relative to this. */
|
|
15
|
+
projectRoot: string;
|
|
16
|
+
/** Specific agent names (without ".md"). Ignored when `all` is true. */
|
|
17
|
+
names: string[];
|
|
18
|
+
/** When true, remove every blessed file regardless of `names`. */
|
|
19
|
+
all: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface UnblessSummary {
|
|
22
|
+
/** Count of files deleted. */
|
|
23
|
+
removed: number;
|
|
24
|
+
/** Count of files intentionally skipped (missing, not blessed, etc.). */
|
|
25
|
+
skipped: number;
|
|
26
|
+
/** Details for skipped entries; useful for stderr reporting. */
|
|
27
|
+
skipDetails: Array<{
|
|
28
|
+
name: string;
|
|
29
|
+
reason: string;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
export declare function unblessAgents(opts: UnblessOptions): Promise<UnblessSummary>;
|
|
33
|
+
//# sourceMappingURL=unbless-agents.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse side of `bless-agents` (Phase 3 subtask 3.6).
|
|
3
|
+
*
|
|
4
|
+
* Walks `./.claude/agents/*.md` in the project, parses each file's
|
|
5
|
+
* frontmatter, and deletes only files that carry the explicit
|
|
6
|
+
* `token_pilot.blessed: true` marker. Files without that marker — user's
|
|
7
|
+
* own customised agents, other plugins' overrides — are left untouched.
|
|
8
|
+
*
|
|
9
|
+
* The function never throws out: every I/O failure is captured in the
|
|
10
|
+
* returned summary so the caller can surface one human-readable stderr
|
|
11
|
+
* message afterwards.
|
|
12
|
+
*/
|
|
13
|
+
import { readdir, readFile, unlink } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { parseFrontmatter } from "./agent-frontmatter.js";
|
|
16
|
+
function isBlessedMarker(meta) {
|
|
17
|
+
const tp = meta.token_pilot;
|
|
18
|
+
if (typeof tp !== "object" || tp === null)
|
|
19
|
+
return false;
|
|
20
|
+
return tp.blessed === true;
|
|
21
|
+
}
|
|
22
|
+
export async function unblessAgents(opts) {
|
|
23
|
+
const summary = { removed: 0, skipped: 0, skipDetails: [] };
|
|
24
|
+
const agentsDir = join(opts.projectRoot, ".claude", "agents");
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = await readdir(agentsDir);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// No .claude/agents dir → nothing to do; treat as success.
|
|
31
|
+
return summary;
|
|
32
|
+
}
|
|
33
|
+
const files = entries.filter((f) => f.endsWith(".md"));
|
|
34
|
+
const filesByName = new Map(files.map((f) => [f.replace(/\.md$/, ""), f]));
|
|
35
|
+
const targetNames = opts.all
|
|
36
|
+
? files.map((f) => f.replace(/\.md$/, ""))
|
|
37
|
+
: opts.names;
|
|
38
|
+
for (const name of targetNames) {
|
|
39
|
+
const fileName = filesByName.get(name);
|
|
40
|
+
if (!fileName) {
|
|
41
|
+
summary.skipped++;
|
|
42
|
+
summary.skipDetails.push({ name, reason: "not found" });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const fullPath = join(agentsDir, fileName);
|
|
46
|
+
let body;
|
|
47
|
+
try {
|
|
48
|
+
body = await readFile(fullPath, "utf-8");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
summary.skipped++;
|
|
52
|
+
summary.skipDetails.push({ name, reason: "read error" });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
let meta;
|
|
56
|
+
try {
|
|
57
|
+
({ meta } = parseFrontmatter(body));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
summary.skipped++;
|
|
61
|
+
summary.skipDetails.push({ name, reason: "malformed frontmatter" });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!isBlessedMarker(meta)) {
|
|
65
|
+
summary.skipped++;
|
|
66
|
+
summary.skipDetails.push({ name, reason: "no blessed marker" });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
await unlink(fullPath);
|
|
71
|
+
summary.removed++;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
summary.skipped++;
|
|
75
|
+
summary.skipDetails.push({ name, reason: "delete failed" });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Emit one human-readable stderr line when we actually removed something.
|
|
79
|
+
if (summary.removed > 0) {
|
|
80
|
+
const plural = summary.removed === 1 ? "agent" : "agents";
|
|
81
|
+
process.stderr.write(`[token-pilot] Unblessed ${summary.removed} ${plural}.\n`);
|
|
82
|
+
}
|
|
83
|
+
return summary;
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=unbless-agents.js.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 5 subtask 5.5 — uninstall tp-* agents.
|
|
3
|
+
*
|
|
4
|
+
* Removes only files in the target scope's `.claude/agents/` that have
|
|
5
|
+
* `token_pilot_body_hash` in their frontmatter. Files without the marker
|
|
6
|
+
* are user-owned and are never touched. Scope is required — no global
|
|
7
|
+
* default — to prevent accidental deletion from the wrong location.
|
|
8
|
+
*
|
|
9
|
+
* Symmetric to install-agents: separation of core (uninstallAgents) and
|
|
10
|
+
* CLI wrapper (handleUninstallAgents) mirrors the Phase 3 bless/unbless
|
|
11
|
+
* split.
|
|
12
|
+
*/
|
|
13
|
+
export type Scope = "user" | "project";
|
|
14
|
+
export interface UninstallOptions {
|
|
15
|
+
scope: Scope;
|
|
16
|
+
projectRoot: string;
|
|
17
|
+
homeDir: string;
|
|
18
|
+
}
|
|
19
|
+
export interface UninstallResult {
|
|
20
|
+
removed: string[];
|
|
21
|
+
skipped: Array<{
|
|
22
|
+
name: string;
|
|
23
|
+
reason: string;
|
|
24
|
+
}>;
|
|
25
|
+
targetDir: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function uninstallAgents(opts: UninstallOptions): Promise<UninstallResult>;
|
|
28
|
+
/**
|
|
29
|
+
* CLI entry: `token-pilot uninstall-agents --scope=user|project`.
|
|
30
|
+
* Returns the exit code (0 success, 1 error).
|
|
31
|
+
*/
|
|
32
|
+
export declare function handleUninstallAgents(argv: string[], opts?: {
|
|
33
|
+
homeDir?: string;
|
|
34
|
+
projectRoot?: string;
|
|
35
|
+
}): Promise<number>;
|
|
36
|
+
//# sourceMappingURL=uninstall-agents.d.ts.map
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 5 subtask 5.5 — uninstall tp-* agents.
|
|
3
|
+
*
|
|
4
|
+
* Removes only files in the target scope's `.claude/agents/` that have
|
|
5
|
+
* `token_pilot_body_hash` in their frontmatter. Files without the marker
|
|
6
|
+
* are user-owned and are never touched. Scope is required — no global
|
|
7
|
+
* default — to prevent accidental deletion from the wrong location.
|
|
8
|
+
*
|
|
9
|
+
* Symmetric to install-agents: separation of core (uninstallAgents) and
|
|
10
|
+
* CLI wrapper (handleUninstallAgents) mirrors the Phase 3 bless/unbless
|
|
11
|
+
* split.
|
|
12
|
+
*/
|
|
13
|
+
import { readdir, readFile, unlink } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { parseFrontmatter } from "./agent-frontmatter.js";
|
|
17
|
+
function targetDirFor(opts) {
|
|
18
|
+
const root = opts.scope === "user" ? opts.homeDir : opts.projectRoot;
|
|
19
|
+
return join(root, ".claude", "agents");
|
|
20
|
+
}
|
|
21
|
+
function hasTpHashMarker(meta) {
|
|
22
|
+
const h = meta.token_pilot_body_hash;
|
|
23
|
+
return typeof h === "string" && h.length > 0;
|
|
24
|
+
}
|
|
25
|
+
export async function uninstallAgents(opts) {
|
|
26
|
+
const target = targetDirFor(opts);
|
|
27
|
+
const result = {
|
|
28
|
+
removed: [],
|
|
29
|
+
skipped: [],
|
|
30
|
+
targetDir: target,
|
|
31
|
+
};
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = await readdir(target);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Target dir missing → nothing to do.
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
const tpFiles = entries.filter((f) => f.endsWith(".md") && f.startsWith("tp-"));
|
|
41
|
+
for (const entry of tpFiles) {
|
|
42
|
+
const name = entry.replace(/\.md$/, "");
|
|
43
|
+
const fullPath = join(target, entry);
|
|
44
|
+
let md;
|
|
45
|
+
try {
|
|
46
|
+
md = await readFile(fullPath, "utf-8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
result.skipped.push({ name, reason: "read failed" });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
let meta;
|
|
53
|
+
try {
|
|
54
|
+
({ meta } = parseFrontmatter(md));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
result.skipped.push({ name, reason: "malformed frontmatter" });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!hasTpHashMarker(meta)) {
|
|
61
|
+
result.skipped.push({
|
|
62
|
+
name,
|
|
63
|
+
reason: "not installed by token-pilot (no token_pilot_body_hash)",
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
await unlink(fullPath);
|
|
69
|
+
result.removed.push(name);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
result.skipped.push({ name, reason: "delete failed" });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
// ─── CLI wrapper ─────────────────────────────────────────────────────────────
|
|
78
|
+
function parseFlag(argv, key) {
|
|
79
|
+
for (const a of argv) {
|
|
80
|
+
if (a === `--${key}`)
|
|
81
|
+
return "true";
|
|
82
|
+
if (a.startsWith(`--${key}=`))
|
|
83
|
+
return a.slice(key.length + 3);
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* CLI entry: `token-pilot uninstall-agents --scope=user|project`.
|
|
89
|
+
* Returns the exit code (0 success, 1 error).
|
|
90
|
+
*/
|
|
91
|
+
export async function handleUninstallAgents(argv, opts) {
|
|
92
|
+
const scopeArg = parseFlag(argv, "scope");
|
|
93
|
+
if (scopeArg !== "user" && scopeArg !== "project") {
|
|
94
|
+
process.stderr.write("Usage: token-pilot uninstall-agents --scope=user|project\n");
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
const result = await uninstallAgents({
|
|
98
|
+
scope: scopeArg,
|
|
99
|
+
projectRoot: opts?.projectRoot ?? process.cwd(),
|
|
100
|
+
homeDir: opts?.homeDir ?? homedir(),
|
|
101
|
+
});
|
|
102
|
+
if (result.removed.length > 0) {
|
|
103
|
+
const plural = result.removed.length === 1 ? "agent" : "agents";
|
|
104
|
+
process.stderr.write(`[token-pilot] Removed ${result.removed.length} ${plural} from ${result.targetDir}.\n`);
|
|
105
|
+
}
|
|
106
|
+
if (result.skipped.length > 0) {
|
|
107
|
+
process.stderr.write(`[token-pilot] Skipped ${result.skipped.length}:\n`);
|
|
108
|
+
for (const s of result.skipped) {
|
|
109
|
+
process.stderr.write(` - ${s.name}: ${s.reason}\n`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (result.removed.length === 0 && result.skipped.length === 0) {
|
|
113
|
+
process.stderr.write(`[token-pilot] No tp-* agents found in ${result.targetDir}.\n`);
|
|
114
|
+
}
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=uninstall-agents.js.map
|