stack-cleaner 1.1.5
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 +199 -0
- package/bin/cli.mjs +180 -0
- package/package.json +39 -0
- package/public/scan.mjs +719 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bespoke Woodcraft Studio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# Stack Cleaner
|
|
4
|
+
|
|
5
|
+
**See, organize, and clean up your Claude Code setup: every skill, plugin, MCP server, and agent, split by _global_ vs. _project_.**
|
|
6
|
+
|
|
7
|
+
Free · open source · runs locally · sends nothing.
|
|
8
|
+
|
|
9
|
+
[**Live app →**](https://stackcleaner.com) · [Guided setup →](https://stackcleaner.com/setup) · [Try the demo →](https://stackcleaner.com/inventory)
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
Over time, Claude Code accumulates a lot: user-level skills in `~/.claude`, project skills in every repo's `.claude`, plugins from a handful of marketplaces, MCP servers wired into different projects, and a pile of sub-agents. It gets hard to answer simple questions: *what do I actually have? what do I never use? what's duplicated? what can I safely remove?*
|
|
16
|
+
|
|
17
|
+
This tool answers them. You run one command, it reads your local Claude Code install, and the web app shows everything **grouped by where it lives** (global vs. each project), with **real usage counts**, so you can spot the dead weight and build a one-click cleanup plan.
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
┌─────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
|
|
23
|
+
│ 1. Scan │ → │ 2. See │ → │ 3. Tidy │
|
|
24
|
+
│ one line │ │ global vs project │ │ cleanup plan, or │
|
|
25
|
+
│ in terminal│ │ + real usage counts │ │ hand it to Claude │
|
|
26
|
+
└─────────────┘ └──────────────────────┘ └─────────────────────┘
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
1. **Scan**: run the one-liner below. It writes `stack-cleaner.json` next to you. No dependencies, nothing sent anywhere.
|
|
30
|
+
2. **See**: drop that file into the [web app](https://stackcleaner.com/inventory). It's parsed in your browser; nothing is uploaded.
|
|
31
|
+
3. **Tidy**: tick the items you want gone and export a shell script, a paste-to-Claude prompt, or JSON.
|
|
32
|
+
|
|
33
|
+
### The scan
|
|
34
|
+
|
|
35
|
+
**The one command**, same on macOS, Windows, and Linux, no `curl`, nothing piped:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx stack-cleaner@latest
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
It runs the published npm package (which bundles the same scanner) and writes `stack-cleaner.json` next to you. Node ships with Claude Code, so you already have it.
|
|
42
|
+
|
|
43
|
+
<details>
|
|
44
|
+
<summary>Prefer not to use npm? Download the script straight from this site instead.</summary>
|
|
45
|
+
|
|
46
|
+
**macOS / Linux** (bash/zsh):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
curl -fsSL https://stackcleaner.com/scan.mjs | node
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Windows** (PowerShell): `curl` there is an alias for `Invoke-WebRequest`, so call `curl.exe` and download-then-run instead of piping:
|
|
53
|
+
|
|
54
|
+
```powershell
|
|
55
|
+
curl.exe -fsSL https://stackcleaner.com/scan.mjs -o stack-cleaner-scan.mjs; node stack-cleaner-scan.mjs
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
</details>
|
|
59
|
+
|
|
60
|
+
Prefer to read it first? It's one short, dependency-free file ([`public/scan.mjs`](public/scan.mjs)). Download then run (use `curl.exe` on Windows):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
curl -fsSL https://stackcleaner.com/scan.mjs -o stack-cleaner-scan.mjs
|
|
64
|
+
node stack-cleaner-scan.mjs
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Not comfortable in a terminal?** The [**Setup page**](https://stackcleaner.com/setup) walks you through it copy-paste by copy-paste. You never need a GitHub account or any coding.
|
|
68
|
+
|
|
69
|
+
## What it captures
|
|
70
|
+
|
|
71
|
+
For every item it records the type, the scope (global or which project), a description, and a **real usage count**: how many times you've actually invoked it, and when you last did.
|
|
72
|
+
|
|
73
|
+
| Type | Global source | Project source |
|
|
74
|
+
|------|---------------|----------------|
|
|
75
|
+
| **Skills** | `~/.claude/skills/*/SKILL.md` | `<repo>/.claude/skills/*/SKILL.md` |
|
|
76
|
+
| **Plugins** | `~/.claude/plugins/installed_plugins.json` | – |
|
|
77
|
+
| **MCP servers** | `~/.claude.json` → `mcpServers` | per-project `mcpServers` + `.mcp.json` |
|
|
78
|
+
| **Agents** | `~/.claude/agents/*.md` | `<repo>/.claude/agents/*.md` |
|
|
79
|
+
|
|
80
|
+
It enumerates **every project** Claude Code knows about (from `~/.claude.json`), not just the folder you run it in, so you get your whole picture in one pass.
|
|
81
|
+
|
|
82
|
+
### Where usage counts come from
|
|
83
|
+
|
|
84
|
+
The scan reads your local Claude Code **transcripts** (`~/.claude/projects/*.jsonl`) to count real invocations for **skills, agents, and MCP servers**, so you can finally see what's *installed but never used*, even for the types that carry no usage count in plain config. It extracts **only the tool / skill / agent / MCP-server names, the counts, and the timestamps**, never your prompts, message text, tool arguments, file paths, or command contents. It all stays local until you choose to upload the file. Pass `--no-transcripts` to skip the transcript read entirely. (Skills and plugins also keep their counts from Claude Code's own `skillUsage` / `pluginUsage` tables.)
|
|
85
|
+
|
|
86
|
+
## Privacy & safety
|
|
87
|
+
|
|
88
|
+
This is the part that matters, because the output describes your tooling.
|
|
89
|
+
|
|
90
|
+
- **Runs entirely on your machine.** The scan never makes a network request. The web app parses your file in the browser and stores it only in that browser's `localStorage`. It is never uploaded.
|
|
91
|
+
- **Transcripts: names and counts only.** To compute real usage it streams your local transcripts but extracts **only** tool/skill/agent/MCP-server names, counts, and timestamps, never prompt text, arguments, file paths, or command contents. Opt out with `--no-transcripts`.
|
|
92
|
+
- **Secrets are stripped before the file is written.** MCP `env` values, auth headers, URL credentials, and token-looking arguments are replaced with `<redacted>`. Your home directory is rewritten to `~` so your username doesn't leak.
|
|
93
|
+
- **It only reads.** The scan never modifies, installs, or removes anything. Removal commands are generated as text for *you* to review and run.
|
|
94
|
+
- **No dependencies, no build step to scan.** `scan.mjs` is plain Node with `node:` built-ins only. Read the whole thing in a minute.
|
|
95
|
+
|
|
96
|
+
## The cleanup plan
|
|
97
|
+
|
|
98
|
+
Select items and the tool builds three things:
|
|
99
|
+
|
|
100
|
+
- **Hand to Claude**: a prompt you paste back into Claude Code so it does the removals for you.
|
|
101
|
+
- **Shell script**: the exact `claude plugins uninstall …` / `rm -rf ~/.claude/skills/…` / `claude mcp remove …` commands, grouped by type, for you to review and run.
|
|
102
|
+
- **JSON**: a machine-readable selection for your own tooling.
|
|
103
|
+
|
|
104
|
+
Nothing is ever removed by this tool. You stay in control.
|
|
105
|
+
|
|
106
|
+
## Run it yourself / self-host
|
|
107
|
+
|
|
108
|
+
It's a standard Next.js (App Router) app with no backend and no database.
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
git clone https://github.com/BespokeWoodcraftStudio/stack-cleaner
|
|
112
|
+
cd stack-cleaner
|
|
113
|
+
npm install
|
|
114
|
+
npm run dev # http://localhost:3000
|
|
115
|
+
|
|
116
|
+
# scan your own machine straight from the source:
|
|
117
|
+
node public/scan.mjs
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Or install the published CLI globally and run it from anywhere:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npm i -g stack-cleaner
|
|
124
|
+
stack-cleaner # writes stack-cleaner.json in the current folder
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Deploy your own copy to Vercel (or any static-capable Next host) with one click of "Import Project". There's nothing to configure.
|
|
128
|
+
|
|
129
|
+
## The inventory schema
|
|
130
|
+
|
|
131
|
+
The scan emits, and the app reads, a single JSON shape, abbreviated below; the full field list is in [`lib/types.ts`](lib/types.ts):
|
|
132
|
+
|
|
133
|
+
```jsonc
|
|
134
|
+
{
|
|
135
|
+
"schemaVersion": 1,
|
|
136
|
+
"generatedAt": "2026-…",
|
|
137
|
+
"generator": "scan.mjs@1.0.0",
|
|
138
|
+
"machine": { "platform": "darwin", "node": "v22.0.0" },
|
|
139
|
+
"projects": ["my-app", "my-blog"],
|
|
140
|
+
"usageSummary": { // present when the transcript scan ran
|
|
141
|
+
"totalInvocations": 1280,
|
|
142
|
+
"itemsWithUsage": 24,
|
|
143
|
+
"itemsUnused": 11,
|
|
144
|
+
"transcriptsScanned": 4142,
|
|
145
|
+
"generatedFrom": "transcripts"
|
|
146
|
+
},
|
|
147
|
+
"items": [
|
|
148
|
+
{
|
|
149
|
+
"id": "skill:global:graphify",
|
|
150
|
+
"type": "skill", // skill | plugin | mcp | agent
|
|
151
|
+
"scope": "global", // global | project
|
|
152
|
+
"project": null, // basename for project-scoped items
|
|
153
|
+
"name": "graphify",
|
|
154
|
+
"description": "…",
|
|
155
|
+
"path": "~/.claude/skills/graphify",
|
|
156
|
+
"usageCount": 10, // mirrors invocationCount when matched
|
|
157
|
+
"lastUsedAt": 1718000000000,
|
|
158
|
+
"usageClass": "good", // good | warn | bad | info | unknown
|
|
159
|
+
"usageLabel": "✅ 10 uses", // display string
|
|
160
|
+
"invocationCount": 10, // transcript invocations (0 = tracked, never used)
|
|
161
|
+
"lastUsed": 1718000000000, // epoch ms of most recent invocation, or null
|
|
162
|
+
"usageSource": "transcripts", // "transcripts" | "claude-json" | "none"
|
|
163
|
+
"removeCmd": "rm -rf ~/.claude/skills/graphify"
|
|
164
|
+
}
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Because it's just JSON, you can hand-write or generate an inventory from any source and drop it in.
|
|
170
|
+
|
|
171
|
+
## Project layout
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
public/scan.mjs the local scanner (zero-dep Node)
|
|
175
|
+
lib/types.ts the shared inventory schema
|
|
176
|
+
lib/inventory.ts parse / filter / group / manifest logic
|
|
177
|
+
lib/demo.ts the built-in demo dataset
|
|
178
|
+
app/page.tsx landing
|
|
179
|
+
app/setup/page.tsx guided, non-technical walkthrough
|
|
180
|
+
app/inventory/page.tsx the tool
|
|
181
|
+
components/ ui primitives + page sections
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Documentation
|
|
185
|
+
|
|
186
|
+
- [**FAQ**](docs/FAQ.md) (or the in-app [/faq](https://stackcleaner.com/faq)): is anything uploaded, are my keys safe, can it delete things, Windows support…
|
|
187
|
+
- [**Usage guide**](docs/USAGE.md): a walkthrough of the inventory tool: filtering, selection, and the three cleanup exports.
|
|
188
|
+
- [**Setup wizard**](https://stackcleaner.com/setup): the non-technical, copy-paste path.
|
|
189
|
+
- [**Security policy**](SECURITY.md): how to privately report a redaction miss (and what we already do).
|
|
190
|
+
- [**Support / troubleshooting**](SUPPORT.md): common errors and where to get help.
|
|
191
|
+
- [**Changelog**](CHANGELOG.md) · [**Contributing**](CONTRIBUTING.md) · [**Code of Conduct**](CODE_OF_CONDUCT.md)
|
|
192
|
+
|
|
193
|
+
## Contributing
|
|
194
|
+
|
|
195
|
+
Issues and PRs welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup and the verify gate, and [SECURITY.md](SECURITY.md) for reporting a secret leak privately. Good first additions: more cleanup-command coverage, richer usage-trend views, and broader plugin-marketplace detection.
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT, see [LICENSE](LICENSE). A [Bespoke Woodcraft Studio](https://github.com/BespokeWoodcraftStudio) tool. Not affiliated with Anthropic; "Claude" and "Claude Code" are trademarks of Anthropic.
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stack-cleaner — npx CLI
|
|
3
|
+
//
|
|
4
|
+
// Scans your local Claude Code setup (skills, plugins, MCP servers, agents)
|
|
5
|
+
// plus transcript usage, and writes a redacted stack-cleaner.json you can
|
|
6
|
+
// upload to https://stackcleaner.com to explore + clean up.
|
|
7
|
+
//
|
|
8
|
+
// npx stack-cleaner
|
|
9
|
+
// npx stack-cleaner --stdout
|
|
10
|
+
// npx stack-cleaner --out my-inventory.json
|
|
11
|
+
//
|
|
12
|
+
// Privacy: only tool/server/agent/skill NAMES, counts, and timestamps are
|
|
13
|
+
// extracted from transcripts. No prompt text, args, paths, or commands.
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { dirname, resolve } from "node:path";
|
|
18
|
+
import { runScan } from "../public/scan.mjs";
|
|
19
|
+
|
|
20
|
+
const UPLOAD_URL = "https://stackcleaner.com";
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
function readVersion() {
|
|
24
|
+
try {
|
|
25
|
+
const pkgPath = resolve(__dirname, "..", "package.json");
|
|
26
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
27
|
+
return pkg.version || "0.0.0";
|
|
28
|
+
} catch {
|
|
29
|
+
return "0.0.0";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function printHelp() {
|
|
34
|
+
const v = readVersion();
|
|
35
|
+
process.stdout.write(
|
|
36
|
+
`stack-cleaner v${v}
|
|
37
|
+
|
|
38
|
+
Scan your local Claude Code setup (skills, plugins, MCP servers, agents) and
|
|
39
|
+
transcript usage into a redacted stack-cleaner.json. Then upload it to
|
|
40
|
+
${UPLOAD_URL} to explore and clean up what you're not using.
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
npx stack-cleaner [options]
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
-h, --help Show this help and exit.
|
|
47
|
+
-v, --version Print the version and exit.
|
|
48
|
+
--stdout, --print Print the inventory JSON to stdout instead of writing a file.
|
|
49
|
+
--out <file> Write the inventory to <file> (default: stack-cleaner.json).
|
|
50
|
+
--no-transcripts Skip transcript usage scanning (faster; no usage counts).
|
|
51
|
+
--transcripts-dir <dir>
|
|
52
|
+
Scan transcripts from <dir> instead of ~/.claude/projects.
|
|
53
|
+
|
|
54
|
+
Examples:
|
|
55
|
+
npx stack-cleaner
|
|
56
|
+
npx stack-cleaner --stdout > inventory.json
|
|
57
|
+
npx stack-cleaner --out ~/Desktop/inventory.json
|
|
58
|
+
npx stack-cleaner --no-transcripts
|
|
59
|
+
|
|
60
|
+
Privacy: only tool/server/agent/skill names, counts, and timestamps are read
|
|
61
|
+
from transcripts — never prompt text, arguments, file paths, or commands.
|
|
62
|
+
|
|
63
|
+
After scanning, upload the JSON at ${UPLOAD_URL}.
|
|
64
|
+
`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseArgs(argv) {
|
|
69
|
+
const opts = {
|
|
70
|
+
help: false,
|
|
71
|
+
version: false,
|
|
72
|
+
stdout: false,
|
|
73
|
+
outFile: "stack-cleaner.json",
|
|
74
|
+
transcripts: true,
|
|
75
|
+
transcriptsDir: undefined,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < argv.length; i++) {
|
|
79
|
+
const arg = argv[i];
|
|
80
|
+
switch (arg) {
|
|
81
|
+
case "-h":
|
|
82
|
+
case "--help":
|
|
83
|
+
opts.help = true;
|
|
84
|
+
break;
|
|
85
|
+
case "-v":
|
|
86
|
+
case "--version":
|
|
87
|
+
opts.version = true;
|
|
88
|
+
break;
|
|
89
|
+
case "--stdout":
|
|
90
|
+
case "--print":
|
|
91
|
+
opts.stdout = true;
|
|
92
|
+
break;
|
|
93
|
+
case "--no-transcripts":
|
|
94
|
+
opts.transcripts = false;
|
|
95
|
+
break;
|
|
96
|
+
case "--out": {
|
|
97
|
+
const next = argv[++i];
|
|
98
|
+
if (next === undefined) {
|
|
99
|
+
throw new Error("--out requires a file path");
|
|
100
|
+
}
|
|
101
|
+
opts.outFile = next;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case "--transcripts-dir": {
|
|
105
|
+
const next = argv[++i];
|
|
106
|
+
if (next === undefined) {
|
|
107
|
+
throw new Error("--transcripts-dir requires a directory path");
|
|
108
|
+
}
|
|
109
|
+
opts.transcriptsDir = next;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
default:
|
|
113
|
+
// Support --out=file / --transcripts-dir=dir style too.
|
|
114
|
+
if (arg.startsWith("--out=")) {
|
|
115
|
+
opts.outFile = arg.slice("--out=".length);
|
|
116
|
+
} else if (arg.startsWith("--transcripts-dir=")) {
|
|
117
|
+
opts.transcriptsDir = arg.slice("--transcripts-dir=".length);
|
|
118
|
+
} else {
|
|
119
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return opts;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function main() {
|
|
128
|
+
let opts;
|
|
129
|
+
try {
|
|
130
|
+
opts = parseArgs(process.argv.slice(2));
|
|
131
|
+
} catch (err) {
|
|
132
|
+
process.stderr.write(`Error: ${err.message}\n\n`);
|
|
133
|
+
process.stderr.write(`Run \`stack-cleaner --help\` for usage.\n`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (opts.help) {
|
|
139
|
+
printHelp();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (opts.version) {
|
|
144
|
+
process.stdout.write(`${readVersion()}\n`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Friendly banner — keep it on stderr so `--stdout` stays pure JSON on stdout.
|
|
149
|
+
if (!opts.stdout) {
|
|
150
|
+
process.stderr.write(`stack-cleaner v${readVersion()}\n`);
|
|
151
|
+
process.stderr.write(`Scanning your Claude Code setup...\n`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
await runScan({
|
|
156
|
+
stdout: opts.stdout,
|
|
157
|
+
outFile: opts.outFile,
|
|
158
|
+
transcripts: opts.transcripts,
|
|
159
|
+
transcriptsDir: opts.transcriptsDir,
|
|
160
|
+
});
|
|
161
|
+
} catch (err) {
|
|
162
|
+
process.stderr.write(`\nScan failed: ${err && err.message ? err.message : err}\n`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Next-step guidance (skip when piping JSON to stdout so we don't pollute it).
|
|
168
|
+
if (!opts.stdout) {
|
|
169
|
+
process.stderr.write(
|
|
170
|
+
`\nNext step:\n` +
|
|
171
|
+
` 1. Open ${UPLOAD_URL}\n` +
|
|
172
|
+
` 2. Upload the ${opts.outFile} file to explore and clean up your setup.\n`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
main().catch((err) => {
|
|
178
|
+
process.stderr.write(`\nUnexpected error: ${err && err.message ? err.message : err}\n`);
|
|
179
|
+
process.exit(1);
|
|
180
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stack-cleaner",
|
|
3
|
+
"version": "1.1.5",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "See, organize, and clean up your Claude Code skills, plugins, MCP servers, and agents (split by global vs. project).",
|
|
7
|
+
"bin": {
|
|
8
|
+
"stack-cleaner": "bin/cli.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"public/scan.mjs",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"dev": "next dev",
|
|
21
|
+
"build": "next build",
|
|
22
|
+
"start": "next start",
|
|
23
|
+
"scan": "node public/scan.mjs",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest"
|
|
26
|
+
},
|
|
27
|
+
"comment:deps": "The published package is the CLI (bin/cli.mjs + public/scan.mjs), which uses only Node built-ins — so it has no runtime dependencies and `npx stack-cleaner` installs nothing extra. next/react/react-dom power the web app and are dev/build-only (Vercel installs devDependencies during the build).",
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^22.10.0",
|
|
30
|
+
"@types/react": "^19.1.0",
|
|
31
|
+
"@types/react-dom": "^19.1.0",
|
|
32
|
+
"geist": "^1.7.2",
|
|
33
|
+
"next": "^15.5.4",
|
|
34
|
+
"react": "^19.1.0",
|
|
35
|
+
"react-dom": "^19.1.0",
|
|
36
|
+
"typescript": "^5.7.0",
|
|
37
|
+
"vitest": "^4.1.9"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/public/scan.mjs
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// =============================================================
|
|
3
|
+
// Stack Cleaner — local scan
|
|
4
|
+
//
|
|
5
|
+
// curl -fsSL https://stackcleaner.com/scan.mjs | node
|
|
6
|
+
// # or: node scan.mjs (writes ./stack-cleaner.json)
|
|
7
|
+
// node scan.mjs --stdout (prints JSON to stdout instead)
|
|
8
|
+
//
|
|
9
|
+
// Reads your local Claude Code install and writes a single JSON file
|
|
10
|
+
// describing every skill, plugin, MCP server, and agent you have — split
|
|
11
|
+
// by global (~/.claude) vs. project (each repo's .claude). Drop that file
|
|
12
|
+
// into the web app to see and organize everything.
|
|
13
|
+
//
|
|
14
|
+
// It also scans your local Claude Code transcripts (~/.claude/projects/*.jsonl)
|
|
15
|
+
// to count how often each skill, agent, and MCP server was actually invoked,
|
|
16
|
+
// and when it was last used. ONLY tool/server/agent/skill NAMES, counts, and
|
|
17
|
+
// timestamps are read from transcripts — never your prompts, messages, command
|
|
18
|
+
// text, file paths, or tool arguments. Disable with --no-transcripts.
|
|
19
|
+
//
|
|
20
|
+
// PRIVACY: this runs entirely on your machine. It never sends anything
|
|
21
|
+
// anywhere. Secrets are aggressively redacted before they ever touch the
|
|
22
|
+
// output file: MCP env values, auth headers, tokens, and URL credentials
|
|
23
|
+
// are replaced with "<redacted>". Your home directory is rewritten to "~".
|
|
24
|
+
// Skill/agent descriptions are prose blurbs copied from their frontmatter; we
|
|
25
|
+
// also scrub obvious token shapes (sk-…, ghp_…, "Authorization: …") out of them,
|
|
26
|
+
// but that pass is best-effort — don't keep secrets in a SKILL.md description.
|
|
27
|
+
// Read this file before you run it — it's short and has no dependencies.
|
|
28
|
+
//
|
|
29
|
+
// SPDX-License-Identifier: MIT
|
|
30
|
+
// =============================================================
|
|
31
|
+
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import os from "node:os";
|
|
35
|
+
import readline from "node:readline";
|
|
36
|
+
import { pathToFileURL } from "node:url";
|
|
37
|
+
|
|
38
|
+
const SCHEMA_VERSION = 1;
|
|
39
|
+
const GENERATOR = "scan.mjs@1.1.5";
|
|
40
|
+
const HOME = os.homedir();
|
|
41
|
+
const CLAUDE = path.join(HOME, ".claude");
|
|
42
|
+
|
|
43
|
+
// ---------- small helpers ----------
|
|
44
|
+
const tilde = (p) => (p && p.startsWith(HOME) ? "~" + p.slice(HOME.length) : p);
|
|
45
|
+
const exists = (p) => { try { fs.accessSync(p); return true; } catch { return false; } };
|
|
46
|
+
const readText = (p) => { try { return fs.readFileSync(p, "utf8"); } catch { return null; } };
|
|
47
|
+
const readJSON = (p) => { const t = readText(p); if (!t) return null; try { return JSON.parse(t); } catch { return null; } };
|
|
48
|
+
const listDir = (p, kind) => {
|
|
49
|
+
try {
|
|
50
|
+
return fs.readdirSync(p, { withFileTypes: true })
|
|
51
|
+
.filter((d) => (kind === "dir" ? d.isDirectory() : d.isFile()))
|
|
52
|
+
.map((d) => d.name);
|
|
53
|
+
} catch { return []; }
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Pull `name:` and `description:` out of YAML-ish frontmatter without a YAML dep.
|
|
57
|
+
// Handles single-line scalars, quoted values, and block scalars (`>`, `>-`, `|`,
|
|
58
|
+
// `|-`) — the conventional way skills write their long, multi-line descriptions.
|
|
59
|
+
function parseFrontmatter(text) {
|
|
60
|
+
if (!text) return {};
|
|
61
|
+
const m = text.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
|
|
62
|
+
if (!m) return {};
|
|
63
|
+
const out = {};
|
|
64
|
+
const lines = m[1].split(/\r?\n/);
|
|
65
|
+
for (let i = 0; i < lines.length; i++) {
|
|
66
|
+
const line = lines[i];
|
|
67
|
+
const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
68
|
+
if (!kv) continue;
|
|
69
|
+
const key = kv[1];
|
|
70
|
+
let v = kv[2].trim();
|
|
71
|
+
|
|
72
|
+
// Block scalar: `>` (folded → spaces) or `|` (literal → newlines), with an
|
|
73
|
+
// optional chomp indicator and trailing comment. The body is the following
|
|
74
|
+
// more-indented lines, ending at the first line dedented to the key or less.
|
|
75
|
+
const block = v.match(/^([|>])[+-]?\s*(?:#.*)?$/);
|
|
76
|
+
if (block) {
|
|
77
|
+
const folded = block[1] === ">";
|
|
78
|
+
const keyIndent = (line.match(/^(\s*)/) || ["", ""])[1].length;
|
|
79
|
+
const body = [];
|
|
80
|
+
let j = i + 1;
|
|
81
|
+
for (; j < lines.length; j++) {
|
|
82
|
+
if (lines[j].trim() === "") { body.push(""); continue; }
|
|
83
|
+
const ind = (lines[j].match(/^(\s*)/) || ["", ""])[1].length;
|
|
84
|
+
if (ind <= keyIndent) break;
|
|
85
|
+
body.push(lines[j]);
|
|
86
|
+
}
|
|
87
|
+
const firstContent = body.find((b) => b.trim() !== "") || "";
|
|
88
|
+
const baseIndent = (firstContent.match(/^(\s*)/) || ["", ""])[1].length;
|
|
89
|
+
const stripped = body.map((b) => b.slice(baseIndent));
|
|
90
|
+
while (stripped.length && stripped[stripped.length - 1].trim() === "") stripped.pop();
|
|
91
|
+
out[key] = folded ? stripped.join(" ").replace(/\s+/g, " ").trim() : stripped.join("\n");
|
|
92
|
+
i = j - 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
97
|
+
v = v.slice(1, -1);
|
|
98
|
+
}
|
|
99
|
+
out[key] = v;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------- secret redaction ----------
|
|
105
|
+
const SECRET_HINT = /(key|token|secret|password|passwd|auth|bearer|credential|api[_-]?key|client[_-]?secret)/i;
|
|
106
|
+
// A long opaque string that looks like a credential rather than a flag/package.
|
|
107
|
+
const LOOKS_SECRET = /^(?:[A-Za-z0-9._\-]{24,}|sk-[A-Za-z0-9._\-]+|gh[pousr]_[A-Za-z0-9]+|xox[baprs]-[A-Za-z0-9-]+)$/;
|
|
108
|
+
|
|
109
|
+
// A KEY=value pair whose key names a secret (e.g. API_KEY=…, auth-token=…).
|
|
110
|
+
const SECRET_KV = /^[A-Za-z0-9_.-]*(?:key|token|secret|password|passwd|auth|bearer|credential)[A-Za-z0-9_.-]*=.+/i;
|
|
111
|
+
|
|
112
|
+
// A bare opaque string that looks like a credential rather than a flag/package:
|
|
113
|
+
// a known token shape, or 18+ mixed letters+digits in a single run (no path,
|
|
114
|
+
// scope, or version shape) — so we don't redact package names ("playwright"),
|
|
115
|
+
// semvers ("4.22.4"), or scoped packages ("@scope/pkg").
|
|
116
|
+
function looksLikeToken(s) {
|
|
117
|
+
if (!s) return false;
|
|
118
|
+
if (LOOKS_SECRET.test(s)) return true;
|
|
119
|
+
return s.length >= 18 && /^[A-Za-z0-9._-]+$/.test(s) && /[A-Za-z]/.test(s) && /[0-9]/.test(s)
|
|
120
|
+
&& !/^v?\d+\.\d+/.test(s) && !s.includes("..");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Scrub secrets embedded *inside* a single string (a combined arg, or a prose
|
|
124
|
+
// description): known token shapes anywhere, plus a secret-named label followed
|
|
125
|
+
// by an opaque value (e.g. `Authorization: Bearer sk-…`, `X-Api-Key: abc123…`).
|
|
126
|
+
function scrubSecrets(s) {
|
|
127
|
+
if (!s) return s;
|
|
128
|
+
return String(s)
|
|
129
|
+
.replace(/sk-[A-Za-z0-9._-]{8,}|gh[pousr]_[A-Za-z0-9]{8,}|xox[baprs]-[A-Za-z0-9-]{6,}|AKIA[0-9A-Z]{12,}/g, "<redacted>")
|
|
130
|
+
.replace(/((?:authorization|bearer|api[_-]?key|access[_-]?token|client[_-]?secret|x-[a-z-]*key|token|secret|password|passwd|credential)["':=\s]+)([A-Za-z0-9._\-+/]{12,})/gi, "$1<redacted>");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function redactArgs(argv) {
|
|
134
|
+
if (!Array.isArray(argv)) return argv;
|
|
135
|
+
const out = [];
|
|
136
|
+
for (let i = 0; i < argv.length; i++) {
|
|
137
|
+
const a = String(argv[i]);
|
|
138
|
+
const prev = i > 0 ? String(argv[i - 1]) : "";
|
|
139
|
+
// Redact the value that follows a secret-introducing token (a flag like
|
|
140
|
+
// --token, or a bare label like "password"). Don't redact another flag.
|
|
141
|
+
if (prev && SECRET_HINT.test(prev) && !/^-/.test(a) && !SECRET_HINT.test(a)) {
|
|
142
|
+
out.push("<redacted>"); continue;
|
|
143
|
+
}
|
|
144
|
+
// KEY=value where the key names a secret (flag or positional form).
|
|
145
|
+
if (SECRET_KV.test(a)) { out.push(a.split("=")[0] + "=<redacted>"); continue; }
|
|
146
|
+
// Any URL / connection string: always sanitize it — userinfo, query string,
|
|
147
|
+
// and token-looking path segments (some hosted MCP endpoints key by path).
|
|
148
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(a)) {
|
|
149
|
+
try { new URL(a); out.push(redactUrl(a)); continue; } catch { /* not a URL */ }
|
|
150
|
+
}
|
|
151
|
+
// A bare opaque token passed positionally.
|
|
152
|
+
if (looksLikeToken(a)) { out.push("<redacted>"); continue; }
|
|
153
|
+
// A secret embedded inside one combined arg (e.g. a single `--header` value).
|
|
154
|
+
const scrubbed = scrubSecrets(a);
|
|
155
|
+
if (scrubbed !== a) { out.push(scrubbed); continue; }
|
|
156
|
+
// Rewrite absolute home paths so usernames don't leak in args.
|
|
157
|
+
out.push(a.startsWith(HOME) ? tilde(a) : a);
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function redactUrl(u) {
|
|
163
|
+
const raw = String(u);
|
|
164
|
+
try {
|
|
165
|
+
const url = new URL(raw);
|
|
166
|
+
const auth = url.username || url.password ? "redacted@" : "";
|
|
167
|
+
const segments = url.pathname.split("/").map((seg) => (looksLikeToken(seg) ? "<redacted>" : seg));
|
|
168
|
+
const query = url.search ? "?<redacted>" : "";
|
|
169
|
+
return `${url.protocol}//${auth}${url.host}${segments.join("/")}${query}`;
|
|
170
|
+
} catch {
|
|
171
|
+
return raw.replace(/([?&][^=]+=)[^&]+/g, "$1<redacted>");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function redactRecord(obj) {
|
|
176
|
+
if (!obj || typeof obj !== "object") return undefined;
|
|
177
|
+
const out = {};
|
|
178
|
+
for (const k of Object.keys(obj)) out[k] = "<redacted>";
|
|
179
|
+
return Object.keys(out).length ? out : undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Turn one MCP server config into a safe, human-readable summary.
|
|
183
|
+
function summarizeMcp(name, cfg) {
|
|
184
|
+
if (!cfg || typeof cfg !== "object") return { transport: "unknown" };
|
|
185
|
+
const out = {};
|
|
186
|
+
const type = cfg.type || (cfg.url ? "http" : cfg.command ? "stdio" : "unknown");
|
|
187
|
+
out.transport = type;
|
|
188
|
+
if (cfg.command) {
|
|
189
|
+
out.command = cfg.command;
|
|
190
|
+
out.args = redactArgs(cfg.args);
|
|
191
|
+
}
|
|
192
|
+
if (cfg.url) out.url = redactUrl(cfg.url);
|
|
193
|
+
const env = redactRecord(cfg.env);
|
|
194
|
+
if (env) out.env = env;
|
|
195
|
+
const headers = redactRecord(cfg.headers);
|
|
196
|
+
if (headers) out.headers = headers;
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A short, readable label for an MCP source line.
|
|
201
|
+
function mcpSourceLabel(summary) {
|
|
202
|
+
if (summary.url) { try { return new URL(summary.url).host; } catch { return summary.transport; } }
|
|
203
|
+
if (summary.command) return [summary.command, ...(summary.args || [])].join(" ").slice(0, 60);
|
|
204
|
+
return summary.transport;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A one-line description for an MCP server (no secrets — summary is pre-redacted).
|
|
208
|
+
function mcpDescription(summary) {
|
|
209
|
+
if (summary.url) { try { return `${summary.transport.toUpperCase()} MCP at ${new URL(summary.url).host}`; } catch { return `${summary.transport} MCP server`; } }
|
|
210
|
+
if (summary.command) return `stdio MCP via \`${[summary.command, ...(summary.args || [])].join(" ").slice(0, 80)}\``;
|
|
211
|
+
return `${summary.transport} MCP server`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------- usage classification ----------
|
|
215
|
+
const DAY = 86_400_000;
|
|
216
|
+
|
|
217
|
+
function classifyUsage(usageCount, lastUsedAt, { passive = false } = {}) {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
if (usageCount == null && lastUsedAt == null) return passive ? "info" : "unknown";
|
|
220
|
+
const count = usageCount || 0;
|
|
221
|
+
if (count > 0) return "good";
|
|
222
|
+
// count == 0
|
|
223
|
+
if (lastUsedAt && now - lastUsedAt < 7 * DAY) return "warn"; // recent but unused
|
|
224
|
+
return "bad";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function usageLabel(usageCount, lastUsedAt, usageClass) {
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
if (usageClass === "unknown") return "no usage signal";
|
|
230
|
+
if (usageClass === "info") return "passive";
|
|
231
|
+
if (usageCount && usageCount > 0) {
|
|
232
|
+
return `✅ ${usageCount.toLocaleString()} use${usageCount === 1 ? "" : "s"}`;
|
|
233
|
+
}
|
|
234
|
+
if (lastUsedAt && now - lastUsedAt < 7 * DAY) return "⚠️ never (recent install)";
|
|
235
|
+
return "➖ never";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------- transcript usage scanning (PRIVACY-CRITICAL) ----------
|
|
239
|
+
// Streams ~/.claude/projects/*.jsonl (and one level deeper) line-by-line and
|
|
240
|
+
// tallies how often each MCP server / agent / skill was invoked, plus the most
|
|
241
|
+
// recent invocation time. The ONLY data extracted is names + counts + the
|
|
242
|
+
// max timestamp per key — never prompt text, message prose, command text,
|
|
243
|
+
// arguments, cwd, or file paths.
|
|
244
|
+
//
|
|
245
|
+
// Returns { byKey: Map<kind:name, { count, lastUsed }>, totalInvocations, transcriptsScanned }.
|
|
246
|
+
// `kind` is one of "mcp" | "agent" | "skill". Keys are stored normalized.
|
|
247
|
+
|
|
248
|
+
// Normalize a name for matching: lowercase, drop a leading "plugin_" / "plugin:"
|
|
249
|
+
// namespace, and (for "plugin:skill" / "<plugin>:<id>" shapes) take the
|
|
250
|
+
// meaningful trailing segment. Keeps things like "vercel:deploy" → "deploy".
|
|
251
|
+
function normalizeName(s) {
|
|
252
|
+
if (!s) return "";
|
|
253
|
+
let n = String(s).toLowerCase().trim();
|
|
254
|
+
// Strip a leading plugin_ prefix (e.g. "plugin_vercel_vercel" handled by caller's
|
|
255
|
+
// server logic; here we just remove a bare "plugin_" / "plugin:" lead-in).
|
|
256
|
+
n = n.replace(/^plugin[_:]/, "");
|
|
257
|
+
// If namespaced with a colon, take the trailing segment ("vercel:deploy" → "deploy").
|
|
258
|
+
if (n.includes(":")) n = n.split(":").pop();
|
|
259
|
+
return n.trim();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Derive the MCP server name from a tool name like "mcp__<server>__<tool>".
|
|
263
|
+
// Also collapse the "plugin_<x>_<server>" convention down to "<server>" so it
|
|
264
|
+
// matches a configured MCP item named "<server>".
|
|
265
|
+
function mcpServerFromToolName(toolName) {
|
|
266
|
+
const m = String(toolName).match(/^mcp__(.+?)__/);
|
|
267
|
+
if (!m) return null;
|
|
268
|
+
let server = m[1];
|
|
269
|
+
// "plugin_vercel_vercel" → "vercel"; "plugin_claude-mem_mcp-search" → "mcp-search".
|
|
270
|
+
const pm = server.match(/^plugin_[^_]+_(.+)$/);
|
|
271
|
+
if (pm) server = pm[1];
|
|
272
|
+
return server;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function transcriptKey(kind, name) {
|
|
276
|
+
return `${kind}:${normalizeName(name)}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Discover candidate .jsonl transcript files under a projects dir:
|
|
280
|
+
// projectsDir/*.jsonl and projectsDir/<sub>/*.jsonl (one level deeper).
|
|
281
|
+
function findTranscriptFiles(projectsDir) {
|
|
282
|
+
const files = [];
|
|
283
|
+
for (const entry of listDir(projectsDir, "dir")) {
|
|
284
|
+
const sub = path.join(projectsDir, entry);
|
|
285
|
+
for (const f of listDir(sub, "file")) {
|
|
286
|
+
if (f.endsWith(".jsonl")) files.push(path.join(sub, f));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Also pick up any .jsonl sitting directly in projectsDir.
|
|
290
|
+
for (const f of listDir(projectsDir, "file")) {
|
|
291
|
+
if (f.endsWith(".jsonl")) files.push(path.join(projectsDir, f));
|
|
292
|
+
}
|
|
293
|
+
return files;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Tally one decoded transcript line into the byKey map.
|
|
297
|
+
function tallyLine(obj, byKey) {
|
|
298
|
+
if (!obj || typeof obj !== "object") return 0;
|
|
299
|
+
const content = obj.message && obj.message.content;
|
|
300
|
+
if (!Array.isArray(content)) return 0;
|
|
301
|
+
const ts = obj.timestamp ? Date.parse(obj.timestamp) : NaN;
|
|
302
|
+
const when = Number.isFinite(ts) ? ts : null;
|
|
303
|
+
let counted = 0;
|
|
304
|
+
for (const part of content) {
|
|
305
|
+
if (!part || part.type !== "tool_use" || typeof part.name !== "string") continue;
|
|
306
|
+
const toolName = part.name;
|
|
307
|
+
let key = null;
|
|
308
|
+
if (/^mcp__(.+?)__/.test(toolName)) {
|
|
309
|
+
const server = mcpServerFromToolName(toolName);
|
|
310
|
+
if (server) key = transcriptKey("mcp", server);
|
|
311
|
+
} else if (toolName === "Agent" || toolName === "Task") {
|
|
312
|
+
const sub = part.input && part.input.subagent_type;
|
|
313
|
+
if (typeof sub === "string" && sub) key = transcriptKey("agent", sub);
|
|
314
|
+
} else if (toolName === "Skill") {
|
|
315
|
+
const sk = part.input && part.input.skill;
|
|
316
|
+
if (typeof sk === "string" && sk) key = transcriptKey("skill", sk);
|
|
317
|
+
}
|
|
318
|
+
if (!key) continue;
|
|
319
|
+
const rec = byKey.get(key) || { count: 0, lastUsed: null };
|
|
320
|
+
rec.count += 1;
|
|
321
|
+
if (when != null && (rec.lastUsed == null || when > rec.lastUsed)) rec.lastUsed = when;
|
|
322
|
+
byKey.set(key, rec);
|
|
323
|
+
counted += 1;
|
|
324
|
+
}
|
|
325
|
+
return counted;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function scanOneTranscript(file, byKey) {
|
|
329
|
+
let total = 0;
|
|
330
|
+
await new Promise((resolve) => {
|
|
331
|
+
let stream;
|
|
332
|
+
try {
|
|
333
|
+
stream = fs.createReadStream(file, { encoding: "utf8" });
|
|
334
|
+
} catch { resolve(); return; }
|
|
335
|
+
stream.on("error", () => resolve());
|
|
336
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
337
|
+
rl.on("line", (line) => {
|
|
338
|
+
const s = line.trim();
|
|
339
|
+
if (!s) return;
|
|
340
|
+
let obj;
|
|
341
|
+
try { obj = JSON.parse(s); } catch { return; } // skip unparseable lines
|
|
342
|
+
total += tallyLine(obj, byKey);
|
|
343
|
+
});
|
|
344
|
+
rl.on("error", () => resolve());
|
|
345
|
+
rl.on("close", () => resolve());
|
|
346
|
+
});
|
|
347
|
+
return total;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function scanTranscripts({ transcriptsDir, quiet = false } = {}) {
|
|
351
|
+
const dir = transcriptsDir || path.join(CLAUDE, "projects");
|
|
352
|
+
const byKey = new Map();
|
|
353
|
+
if (!exists(dir)) {
|
|
354
|
+
return { byKey, totalInvocations: 0, transcriptsScanned: 0 };
|
|
355
|
+
}
|
|
356
|
+
const files = findTranscriptFiles(dir);
|
|
357
|
+
if (!quiet) {
|
|
358
|
+
process.stderr.write(` scanning ${files.length} transcript file${files.length === 1 ? "" : "s"}…\n`);
|
|
359
|
+
}
|
|
360
|
+
let totalInvocations = 0;
|
|
361
|
+
let transcriptsScanned = 0;
|
|
362
|
+
for (const file of files) {
|
|
363
|
+
totalInvocations += await scanOneTranscript(file, byKey);
|
|
364
|
+
transcriptsScanned += 1;
|
|
365
|
+
}
|
|
366
|
+
return { byKey, totalInvocations, transcriptsScanned };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---------- project labels ----------
|
|
370
|
+
// Map each eligible project dir to a unique display label. Normally the basename,
|
|
371
|
+
// but when two project paths share a basename (e.g. ~/a/web and ~/b/web) the bare
|
|
372
|
+
// basename would collapse them into one inventory group with a single (last-wins)
|
|
373
|
+
// location. Colliding ones are qualified with their parent dir ("a/web", "b/web"),
|
|
374
|
+
// falling back to the full home-relative path if even that still collides — so
|
|
375
|
+
// distinct projects always stay distinct.
|
|
376
|
+
function buildProjectLabels(paths) {
|
|
377
|
+
const byBase = new Map();
|
|
378
|
+
for (const p of paths) {
|
|
379
|
+
const b = path.basename(p);
|
|
380
|
+
const arr = byBase.get(b);
|
|
381
|
+
if (arr) arr.push(p); else byBase.set(b, [p]);
|
|
382
|
+
}
|
|
383
|
+
const labels = new Map();
|
|
384
|
+
for (const [base, group] of byBase) {
|
|
385
|
+
if (group.length === 1) { labels.set(group[0], base); continue; }
|
|
386
|
+
const byParent = new Map();
|
|
387
|
+
for (const p of group) {
|
|
388
|
+
const q = `${path.basename(path.dirname(p))}/${base}`;
|
|
389
|
+
const arr = byParent.get(q);
|
|
390
|
+
if (arr) arr.push(p); else byParent.set(q, [p]);
|
|
391
|
+
}
|
|
392
|
+
for (const [qualified, qgroup] of byParent) {
|
|
393
|
+
if (qgroup.length === 1) labels.set(qgroup[0], qualified);
|
|
394
|
+
else for (const p of qgroup) labels.set(p, tilde(p)); // last resort: unique path
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return labels;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---------- inventory build ----------
|
|
401
|
+
// Collects every skill / agent / plugin / MCP item from the local install,
|
|
402
|
+
// optionally overlays transcript usage, and returns the inventory OBJECT.
|
|
403
|
+
// Does NOT write or print anything.
|
|
404
|
+
export async function buildInventory(opts = {}) {
|
|
405
|
+
const { transcripts = true, transcriptsDir, quiet = false } = opts;
|
|
406
|
+
|
|
407
|
+
const root = readJSON(path.join(HOME, ".claude.json")) || {};
|
|
408
|
+
const skillUsage = root.skillUsage || {};
|
|
409
|
+
const pluginUsage = root.pluginUsage || {};
|
|
410
|
+
|
|
411
|
+
const items = [];
|
|
412
|
+
const projectNames = new Set();
|
|
413
|
+
// basename -> tilde'd `.claude` dir for each project that has any items. Emitted
|
|
414
|
+
// so the web app can show a project's on-disk location even when it has only MCP
|
|
415
|
+
// servers (which carry no per-item path for the UI to derive a location from).
|
|
416
|
+
const projectLocations = {};
|
|
417
|
+
|
|
418
|
+
function addSkill(scope, project, dirName, skillDir) {
|
|
419
|
+
const fm = parseFrontmatter(readText(path.join(skillDir, "SKILL.md")));
|
|
420
|
+
const name = fm.name || dirName;
|
|
421
|
+
// usage tables key skills by their invocation name (bare or plugin:skill).
|
|
422
|
+
const u = skillUsage[name] || skillUsage[dirName] || null;
|
|
423
|
+
const usageCount = u ? (u.usageCount ?? null) : null;
|
|
424
|
+
const lastUsedAt = u ? (u.lastUsedAt ?? null) : null;
|
|
425
|
+
const usageClass = classifyUsage(usageCount, lastUsedAt);
|
|
426
|
+
items.push({
|
|
427
|
+
id: `skill:${scope === "global" ? "global" : "project:" + project}:${dirName}`,
|
|
428
|
+
type: "skill", scope, project: scope === "global" ? null : project,
|
|
429
|
+
name, description: scrubSecrets(fm.description || ""),
|
|
430
|
+
path: tilde(skillDir),
|
|
431
|
+
usageCount, lastUsedAt, usageClass,
|
|
432
|
+
usageLabel: usageLabel(usageCount, lastUsedAt, usageClass),
|
|
433
|
+
// dir name is used to match transcript Skill invocations (input.skill).
|
|
434
|
+
_matchKind: "skill", _matchName: dirName,
|
|
435
|
+
removeCmd: scope === "global"
|
|
436
|
+
? `rm -rf ${tilde(skillDir)}`
|
|
437
|
+
: `git rm -r .claude/skills/${dirName}`,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function addAgent(scope, project, fileName, agentFile) {
|
|
442
|
+
const base = fileName.replace(/\.md$/, "");
|
|
443
|
+
const fm = parseFrontmatter(readText(agentFile));
|
|
444
|
+
const name = fm.name || base;
|
|
445
|
+
const u = skillUsage[name] || skillUsage[base] || null; // agents sometimes appear here too
|
|
446
|
+
const usageCount = u ? (u.usageCount ?? null) : null;
|
|
447
|
+
const lastUsedAt = u ? (u.lastUsedAt ?? null) : null;
|
|
448
|
+
const usageClass = classifyUsage(usageCount, lastUsedAt, { passive: true });
|
|
449
|
+
items.push({
|
|
450
|
+
id: `agent:${scope === "global" ? "global" : "project:" + project}:${base}`,
|
|
451
|
+
type: "agent", scope, project: scope === "global" ? null : project,
|
|
452
|
+
name, description: scrubSecrets(fm.description || ""),
|
|
453
|
+
path: tilde(agentFile),
|
|
454
|
+
usageCount, lastUsedAt, usageClass,
|
|
455
|
+
usageLabel: usageLabel(usageCount, lastUsedAt, usageClass),
|
|
456
|
+
// filename stem is used to match transcript Agent invocations (subagent_type).
|
|
457
|
+
_matchKind: "agent", _matchName: base,
|
|
458
|
+
removeCmd: scope === "global"
|
|
459
|
+
? `rm ${tilde(agentFile)}`
|
|
460
|
+
: `git rm .claude/agents/${fileName}`,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function addMcp(scope, project, name, cfg) {
|
|
465
|
+
const summary = summarizeMcp(name, cfg);
|
|
466
|
+
items.push({
|
|
467
|
+
id: `mcp:${scope === "global" ? "global" : "project:" + project}:${name}`,
|
|
468
|
+
type: "mcp", scope, project: scope === "global" ? null : project,
|
|
469
|
+
name, description: mcpDescription(summary),
|
|
470
|
+
source: mcpSourceLabel(summary),
|
|
471
|
+
usageCount: null, lastUsedAt: null, usageClass: "info",
|
|
472
|
+
usageLabel: "passive (surfaced on demand)",
|
|
473
|
+
// server name is used to match transcript mcp__<server>__ invocations.
|
|
474
|
+
_matchKind: "mcp", _matchName: name,
|
|
475
|
+
removeCmd: scope === "global"
|
|
476
|
+
? `claude mcp remove ${name} -s user`
|
|
477
|
+
: `claude mcp remove ${name}`,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ---- global skills ----
|
|
482
|
+
const globalSkillsDir = path.join(CLAUDE, "skills");
|
|
483
|
+
for (const d of listDir(globalSkillsDir, "dir")) {
|
|
484
|
+
const dir = path.join(globalSkillsDir, d);
|
|
485
|
+
if (exists(path.join(dir, "SKILL.md"))) addSkill("global", null, d, dir);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---- global agents ----
|
|
489
|
+
const globalAgentsDir = path.join(CLAUDE, "agents");
|
|
490
|
+
for (const f of listDir(globalAgentsDir, "file")) {
|
|
491
|
+
if (f.endsWith(".md")) addAgent("global", null, f, path.join(globalAgentsDir, f));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---- plugins (global; installed_plugins.json) ----
|
|
495
|
+
const installed = readJSON(path.join(CLAUDE, "plugins", "installed_plugins.json"));
|
|
496
|
+
if (installed && installed.plugins) {
|
|
497
|
+
for (const [key, entries] of Object.entries(installed.plugins)) {
|
|
498
|
+
const entry = Array.isArray(entries) ? entries[0] : entries;
|
|
499
|
+
const [pluginName, marketplace] = key.split("@");
|
|
500
|
+
const u = pluginUsage[key] || null;
|
|
501
|
+
const usageCount = u ? (u.usageCount ?? null) : null;
|
|
502
|
+
const lastUsedAt = u ? (u.lastUsedAt ?? null) : null;
|
|
503
|
+
const usageClass = classifyUsage(usageCount, lastUsedAt);
|
|
504
|
+
const installPath = entry && entry.installPath
|
|
505
|
+
? (entry.installPath.startsWith(HOME) ? tilde(entry.installPath) : "<external>")
|
|
506
|
+
: undefined;
|
|
507
|
+
items.push({
|
|
508
|
+
// Bare name; the marketplace lives in `source` (matches the demo shape).
|
|
509
|
+
id: `plugin:global:${pluginName}`,
|
|
510
|
+
type: "plugin", scope: "global", project: null,
|
|
511
|
+
name: pluginName,
|
|
512
|
+
description: "", // not stored locally; the demo/curation can add it
|
|
513
|
+
source: marketplace || "",
|
|
514
|
+
version: entry ? entry.version : undefined,
|
|
515
|
+
path: installPath,
|
|
516
|
+
usageCount, lastUsedAt, usageClass,
|
|
517
|
+
usageLabel: usageLabel(usageCount, lastUsedAt, usageClass),
|
|
518
|
+
// The CLI wants the full name@marketplace form to uninstall.
|
|
519
|
+
removeCmd: `claude plugins uninstall ${key} -y`,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ---- global MCP servers ----
|
|
525
|
+
for (const [name, cfg] of Object.entries(root.mcpServers || {})) {
|
|
526
|
+
addMcp("global", null, name, cfg);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ---- per-project (from every known project path in ~/.claude.json) ----
|
|
530
|
+
const projectPaths = new Set();
|
|
531
|
+
if (exists(path.join(process.cwd(), ".claude")) || exists(path.join(process.cwd(), ".mcp.json"))) {
|
|
532
|
+
projectPaths.add(process.cwd());
|
|
533
|
+
}
|
|
534
|
+
for (const p of Object.keys(root.projects || {})) projectPaths.add(p);
|
|
535
|
+
|
|
536
|
+
// Keep only real, non-global project dirs. The home directory's .claude IS the
|
|
537
|
+
// global scope, so a project path resolving to it (the scan ran from $HOME, or
|
|
538
|
+
// $HOME is registered in ~/.claude.json's projects) would duplicate every global
|
|
539
|
+
// skill/agent/MCP as a phantom project named after your username — skip it.
|
|
540
|
+
const eligiblePaths = [...projectPaths].filter(
|
|
541
|
+
(p) => exists(p) && path.resolve(path.join(p, ".claude")) !== path.resolve(CLAUDE),
|
|
542
|
+
);
|
|
543
|
+
// Disambiguate any project dirs that share a basename so they stay distinct.
|
|
544
|
+
const projectLabels = buildProjectLabels(eligiblePaths);
|
|
545
|
+
|
|
546
|
+
for (const projPath of eligiblePaths) {
|
|
547
|
+
const project = projectLabels.get(projPath) || path.basename(projPath);
|
|
548
|
+
const projConf = (root.projects && root.projects[projPath]) || {};
|
|
549
|
+
|
|
550
|
+
let touched = false;
|
|
551
|
+
|
|
552
|
+
// project skills
|
|
553
|
+
const pSkills = path.join(projPath, ".claude", "skills");
|
|
554
|
+
for (const d of listDir(pSkills, "dir")) {
|
|
555
|
+
const dir = path.join(pSkills, d);
|
|
556
|
+
if (exists(path.join(dir, "SKILL.md"))) { addSkill("project", project, d, dir); touched = true; }
|
|
557
|
+
}
|
|
558
|
+
// project agents
|
|
559
|
+
const pAgents = path.join(projPath, ".claude", "agents");
|
|
560
|
+
for (const f of listDir(pAgents, "file")) {
|
|
561
|
+
if (f.endsWith(".md")) { addAgent("project", project, f, path.join(pAgents, f)); touched = true; }
|
|
562
|
+
}
|
|
563
|
+
// project MCP servers — from ~/.claude.json projects[path].mcpServers and/or .mcp.json
|
|
564
|
+
const fromConf = projConf.mcpServers || {};
|
|
565
|
+
const fromFile = (readJSON(path.join(projPath, ".mcp.json")) || {}).mcpServers || {};
|
|
566
|
+
for (const [name, cfg] of Object.entries({ ...fromFile, ...fromConf })) {
|
|
567
|
+
addMcp("project", project, name, cfg); touched = true;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (touched) {
|
|
571
|
+
projectNames.add(project);
|
|
572
|
+
projectLocations[project] = tilde(path.join(projPath, ".claude"));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ---- transcript usage overlay ----
|
|
577
|
+
let usageSummary;
|
|
578
|
+
if (transcripts) {
|
|
579
|
+
const { byKey, totalInvocations, transcriptsScanned } = await scanTranscripts({ transcriptsDir, quiet });
|
|
580
|
+
|
|
581
|
+
// Match each tracked item (skill / agent / mcp) to its transcript tally.
|
|
582
|
+
let itemsWithUsage = 0;
|
|
583
|
+
let itemsUnused = 0;
|
|
584
|
+
for (const item of items) {
|
|
585
|
+
if (!item._matchKind) continue; // plugins are not transcript-tracked
|
|
586
|
+
const key = transcriptKey(item._matchKind, item._matchName);
|
|
587
|
+
const rec = byKey.get(key);
|
|
588
|
+
const count = rec ? rec.count : 0;
|
|
589
|
+
const last = rec ? rec.lastUsed : null;
|
|
590
|
+
item.invocationCount = count;
|
|
591
|
+
item.lastUsed = last;
|
|
592
|
+
item.usageSource = "transcripts";
|
|
593
|
+
if (count > 0) {
|
|
594
|
+
itemsWithUsage += 1;
|
|
595
|
+
// Light up the existing UI fields from the transcript signal.
|
|
596
|
+
item.usageCount = count;
|
|
597
|
+
item.lastUsedAt = last;
|
|
598
|
+
item.usageClass = classifyUsage(count, last);
|
|
599
|
+
item.usageLabel = usageLabel(count, last, item.usageClass);
|
|
600
|
+
} else {
|
|
601
|
+
// No transcript invocations → genuinely never used. Reflect that in the
|
|
602
|
+
// existing UI fields so the per-item badge, the "unused" filter, the
|
|
603
|
+
// stat cell, and usageSummary.itemsUnused all describe the same set.
|
|
604
|
+
// (The transcript corpus is the authoritative usage record; a tracked
|
|
605
|
+
// item absent from it has not been used.)
|
|
606
|
+
item.usageCount = 0;
|
|
607
|
+
item.lastUsedAt = last;
|
|
608
|
+
item.usageClass = classifyUsage(0, last);
|
|
609
|
+
item.usageLabel = usageLabel(0, last, item.usageClass);
|
|
610
|
+
itemsUnused += 1;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
usageSummary = {
|
|
615
|
+
totalInvocations,
|
|
616
|
+
itemsWithUsage,
|
|
617
|
+
itemsUnused,
|
|
618
|
+
transcriptsScanned,
|
|
619
|
+
generatedFrom: "transcripts",
|
|
620
|
+
};
|
|
621
|
+
} else {
|
|
622
|
+
// Transcripts disabled — preserve today's ~/.claude.json behavior and just
|
|
623
|
+
// annotate the usageSource per item so consumers know where the signal came from.
|
|
624
|
+
for (const item of items) {
|
|
625
|
+
if (item.type === "skill") {
|
|
626
|
+
item.usageSource = (item.usageCount != null || item.lastUsedAt != null) ? "claude-json" : "none";
|
|
627
|
+
} else if (item.type === "agent" || item.type === "mcp") {
|
|
628
|
+
item.usageSource = "none";
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Drop internal matching helpers before emitting.
|
|
634
|
+
for (const item of items) { delete item._matchKind; delete item._matchName; }
|
|
635
|
+
|
|
636
|
+
const inventory = {
|
|
637
|
+
schemaVersion: SCHEMA_VERSION,
|
|
638
|
+
generatedAt: new Date().toISOString(),
|
|
639
|
+
generator: GENERATOR,
|
|
640
|
+
machine: { platform: process.platform, node: process.version },
|
|
641
|
+
projects: [...projectNames].sort((a, b) => a.localeCompare(b)),
|
|
642
|
+
projectLocations,
|
|
643
|
+
items,
|
|
644
|
+
};
|
|
645
|
+
if (usageSummary) inventory.usageSummary = usageSummary;
|
|
646
|
+
return inventory;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ---------- scan: build + write/print + log summary ----------
|
|
650
|
+
export async function runScan(opts = {}) {
|
|
651
|
+
const {
|
|
652
|
+
stdout = false,
|
|
653
|
+
outFile = "stack-cleaner.json",
|
|
654
|
+
transcripts = true,
|
|
655
|
+
transcriptsDir,
|
|
656
|
+
quiet = false,
|
|
657
|
+
} = opts;
|
|
658
|
+
|
|
659
|
+
const inventory = await buildInventory({ transcripts, transcriptsDir, quiet });
|
|
660
|
+
const json = JSON.stringify(inventory, null, 2);
|
|
661
|
+
|
|
662
|
+
const outPath = path.resolve(process.cwd(), outFile);
|
|
663
|
+
if (stdout) {
|
|
664
|
+
process.stdout.write(json + "\n");
|
|
665
|
+
} else {
|
|
666
|
+
fs.writeFileSync(outPath, json);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// summary counts for the console
|
|
670
|
+
const items = inventory.items;
|
|
671
|
+
const by = (t) => items.filter((i) => i.type === t).length;
|
|
672
|
+
const g = items.filter((i) => i.scope === "global").length;
|
|
673
|
+
const p = items.length - g;
|
|
674
|
+
|
|
675
|
+
const log = (s) => process.stderr.write(s + "\n");
|
|
676
|
+
log("");
|
|
677
|
+
log(" Stack Cleaner — scan complete");
|
|
678
|
+
log(" ─────────────────────────────────────");
|
|
679
|
+
log(` skills ${by("skill")} plugins ${by("plugin")} mcp ${by("mcp")} agents ${by("agent")}`);
|
|
680
|
+
log(` ${g} global · ${p} project across ${inventory.projects.length} project${inventory.projects.length === 1 ? "" : "s"}`);
|
|
681
|
+
if (inventory.usageSummary) {
|
|
682
|
+
const us = inventory.usageSummary;
|
|
683
|
+
log(` usage: ${us.totalInvocations.toLocaleString()} invocations · ${us.itemsWithUsage} used · ${us.itemsUnused} unused (${us.transcriptsScanned} transcript${us.transcriptsScanned === 1 ? "" : "s"})`);
|
|
684
|
+
}
|
|
685
|
+
log("");
|
|
686
|
+
if (!stdout) {
|
|
687
|
+
log(` ✓ wrote ${outPath}`);
|
|
688
|
+
log(" → open the web app and drop this file in.");
|
|
689
|
+
log("");
|
|
690
|
+
}
|
|
691
|
+
return inventory;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export { scrubSecrets, redactArgs, redactUrl, looksLikeToken, buildProjectLabels };
|
|
695
|
+
|
|
696
|
+
// ---------- CLI arg parsing + auto-run guard ----------
|
|
697
|
+
function parseArgs(argv) {
|
|
698
|
+
const opts = {};
|
|
699
|
+
for (let i = 0; i < argv.length; i++) {
|
|
700
|
+
const a = argv[i];
|
|
701
|
+
if (a === "--stdout" || a === "--print") opts.stdout = true;
|
|
702
|
+
else if (a === "--no-transcripts") opts.transcripts = false;
|
|
703
|
+
else if (a === "--transcripts-dir") opts.transcriptsDir = argv[++i];
|
|
704
|
+
else if (a.startsWith("--transcripts-dir=")) opts.transcriptsDir = a.slice("--transcripts-dir=".length);
|
|
705
|
+
else if (a === "--out") opts.outFile = argv[++i];
|
|
706
|
+
else if (a.startsWith("--out=")) opts.outFile = a.slice("--out=".length);
|
|
707
|
+
}
|
|
708
|
+
return opts;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Run when invoked directly (`node scan.mjs`) or piped (`curl … | node`),
|
|
712
|
+
// but NOT when imported (e.g. by bin/cli.mjs).
|
|
713
|
+
const invokedDirectly = !process.argv[1] || import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
714
|
+
if (invokedDirectly) {
|
|
715
|
+
runScan(parseArgs(process.argv.slice(2))).catch((err) => {
|
|
716
|
+
process.stderr.write(`\n ✗ scan failed: ${err && err.message ? err.message : err}\n`);
|
|
717
|
+
process.exit(1);
|
|
718
|
+
});
|
|
719
|
+
}
|