From db6ec9ec64537efa673804090652b8e6711a3233 Mon Sep 17 00:00:00 2001 From: antianqi <75944423+antianqi@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:33:56 +0800 Subject: [PATCH] Add antianqi/tool-map v0.2.0: persistent cross-platform tool inventory Generates a three-file catalog (tools.summary.md, tools.md, tools.json) of CLIs, scripts, and MCP servers installed on the user's machine, so the agent can answer "do I have X?", "where is Y?", "how do I run Z?" without re-scanning the filesystem every session. Plugin shape (Skill-only, zero external deps, no package.json): - skills/tool-map/SKILL.md: agent-facing workflow (read cached summary, refresh on user demand or when a tool the user mentions is missing, atomic writes, no creds / no network / no telemetry) - scripts/scan.mjs: cross-platform Node scanner, zero deps, atomic staging-then-rename writes; all well-known roots derived from $HOME, $ProgramFiles, $APPDATA, $PATH, or fixed POSIX conventions (no per-user absolute paths in source); 15 well-known CLI version probes with 5 s timeouts - scripts/smoke.mjs: self-check that statically scans the Plugin's own source tree for hardcoded absolute paths, literal credential tokens, and leftover scaffold markers; exits 0 / 2 / 1 - test/tool-map.test.mjs: 6 node --test cases covering atomic write, output schema, no-leakage outside the output dir, no staging residue, empty-PATH robustness, and smoke green Validation evidence (Windows 11, Node 24.18.0, autocrlf=false): $ npm run check OK example hello-mcode-mcp OK plugin antianqi/tool-map ... tests 6 pass 6 fail 0 $ node scripts/smoke.mjs OK scanned 2 files, 0 violations. Design compliance (per hetaoBackend review rubric on PRs #2/#3): 1. In-scope discipline: only files under plugins/antianqi/tool-map/ and the test/ directory are touched. No edits to repo-root files, no writes to ~/.minimax/, no ~/.openclaw*/ side effects. 2. Portability: scan.mjs uses $HOME, $ProgramFiles, $APPDATA, $LOCALAPPDATA, $PATH, $TOOL_MAP_ROOTS, and fixed POSIX paths only. smoke.mjs statically verifies no D:/C:/E:/ or /Users/ or /home/ literal in any .md/.mjs file. 3. Credential disclosure: README and SKILL.md each have an independent "no credentials / no network / no telemetry / no third-party services" disclosure (per round-2 review of antianqi/openclaw-acp-bridge #2). 4. Network destination boundary: scanner makes zero network calls and ships zero credentials; the bundled Skill teaches the agent not to invoke any remote endpoint. 5. Delivery model: zero `npm install` / `npm link` is required. The scanner runs as a plain `node ./scripts/scan.mjs` process with only Node built-ins. 6. Atomic / safe file operations: every output file is written via `.staging--` then `rename`. On any failure the staging file is removed and the previous catalog is untouched. 7. Lint / failure semantics: smoke.mjs exits 0 / 2 / 1; never swallows FAIL. 8. Test coverage: 6 node --test cases; smoke.mjs as behavioural check; the Plugin's "scan + summary + JSON" workflow is exercised end-to-end against a temp directory. 9. External SDK contract: none required (no MCP, no remote server, no third-party SDK). 10. Self-check coverage: smoke.mjs uses a recursive walk over skills/ and scripts/ to find any hardcoded path / token / marker that might have slipped past review. Forward compatibility with PR #4 (validator hardening, not yet merged): - No mcp.json is shipped, so cwd / env / headers hardening does not apply. The scan.mjs and SKILL.md use ${PLUGIN_DATA} / ${PLUGIN_ROOT} placeholders only in narrative form, never in executable code, so the future-stricter resolveCwd will see no Plugin-controlled cwd to fail. - SKILL.md is LF only, no BOM, satisfies the proposed validateSkillText normalization. (The merged main validator also accepts LF directly.) Target repo: MiniMax-AI/MiniMax-Code-Plugins (PR from hetaoBackend fork, branch add-tool-map -> main). --- plugins/antianqi/tool-map/LICENSE | 192 +++++++++ plugins/antianqi/tool-map/README.md | 105 +++++ plugins/antianqi/tool-map/plugin.json | 20 + plugins/antianqi/tool-map/scripts/scan.mjs | 392 ++++++++++++++++++ plugins/antianqi/tool-map/scripts/smoke.mjs | 123 ++++++ .../tool-map/skills/tool-map/SKILL.md | 72 ++++ test/tool-map.test.mjs | 138 ++++++ 7 files changed, 1042 insertions(+) create mode 100644 plugins/antianqi/tool-map/LICENSE create mode 100644 plugins/antianqi/tool-map/README.md create mode 100644 plugins/antianqi/tool-map/plugin.json create mode 100644 plugins/antianqi/tool-map/scripts/scan.mjs create mode 100644 plugins/antianqi/tool-map/scripts/smoke.mjs create mode 100644 plugins/antianqi/tool-map/skills/tool-map/SKILL.md create mode 100644 test/tool-map.test.mjs diff --git a/plugins/antianqi/tool-map/LICENSE b/plugins/antianqi/tool-map/LICENSE new file mode 100644 index 0000000..125be1b --- /dev/null +++ b/plugins/antianqi/tool-map/LICENSE @@ -0,0 +1,192 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + Copyright 2026 MCode Plugins contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/antianqi/tool-map/README.md b/plugins/antianqi/tool-map/README.md new file mode 100644 index 0000000..16a4384 --- /dev/null +++ b/plugins/antianqi/tool-map/README.md @@ -0,0 +1,105 @@ +# tool-map - persistent tool inventory + +> A cross-platform inventory of CLI tools, scripts, and MCP servers installed on the user's machine. Generates a persistent three-file catalog (lightweight summary, full markdown, machine JSON) so the agent can answer "do I have X?", "where is Y?", "how do I run Z?" without re-scanning the filesystem every session. + +## Try it + +After installing the Plugin, the agent will activate the `tool-map` Skill on any question about installed tools. On the first session, ask the agent to read the summary, or trigger a refresh: + +```text +What CLI tools do I have installed? Where is pnpm? +``` + +```text +Refresh my tool inventory - I just installed a new package manager. +``` + +```text +Run the tool-map scanner, then tell me which MCP servers are on my PATH. +``` + +The first invocation generates `${PLUGIN_DATA}/tools.summary.md`, `${PLUGIN_DATA}/tools.md`, and `${PLUGIN_DATA}/tools.json` (one `node` process, typically under 2 seconds). Subsequent turns read the summary without re-scanning. + +## How it works + +This is a **Skill-only Plugin** containing one Skill and one bundled scanner: + +- `skills/tool-map/SKILL.md` - tells the agent to consult the cached summary on session start, refresh only when needed, and how to invoke the scanner. +- `scripts/scan.mjs` - a zero-dependency Node script that walks well-known tool roots plus `$PATH`, probes 15 well-known CLIs for `--version`, and writes the three catalog files atomically (staging + rename, no partial files). +- `scripts/smoke.mjs` - a self-check that statically scans the Plugin's own source for hardcoded absolute paths, literal credential tokens, and leftover scaffold marker strings. Exits non-zero on any violation. + +**Why no bundled MCP connection**: this Plugin has no runtime server, no network endpoints, and no secrets to manage. The agent invokes the scanner as a regular Node subprocess when the user asks for a refresh; the Skill is the only contract. + +## Requirements + +- **Node.js >= 22** at runtime (the scanner uses only built-in modules and the `node --test` discoverer picks up the regression test in this repository's `npm test`). +- The Plugin data directory, exposed as `${PLUGIN_DATA}` to the agent. The scanner falls back to `~/.local/share/tool-map` (XDG_DATA_HOME compliant) when `${PLUGIN_DATA}` is unset. +- A POSIX-like shell or `cmd.exe` for the bundled `node` invocation; no other binaries are required at install time. + +## Supported platforms + +| Platform | Status | Notes | +| --- | --- | --- | +| Windows 10 / 11 (PowerShell 5.1+ or pwsh 7) | Supported (primary) | Drives, `%ProgramFiles%`, `%APPDATA%`, `%LOCALAPPDATA%` resolved from environment. | +| macOS 12+ (bash / zsh) | Supported | `~/.local/bin`, `/usr/local/bin`, `/opt/homebrew/bin` walked. | +| Linux x86_64 / arm64 | Supported | `~/.local/bin`, `~/.local/share/npm/bin`, `/usr/local/bin` walked. | + +The scanner does not hardcode any per-user absolute path; all locations are derived from `$HOME`, `$ProgramFiles`, `$APPDATA`, `$LOCALAPPDATA`, `$PATH`, or fixed POSIX conventions. To add an extra root, set `TOOL_MAP_ROOTS` to a `:`-separated (POSIX) or `;`-separated (Windows) list of absolute paths. + +## Data and network + +This Plugin itself: + +- **Makes no network requests.** The scanner is fully offline. It does not contact any registry, index, API, or third-party service. +- **Ships no credentials.** No API token, no OAuth client, no per-user secret, no shared key. The `~/.ssh/` directory is read for filenames only (no key contents, no passphrases, no agent state). +- **No telemetry.** The scanner prints a one-line summary to stdout when it writes a catalog; nothing is sent anywhere. +- **No third-party services.** No SDK, no analytics endpoint, no error reporter, no remote MCP server. The Plugin is self-contained. +- **No data uploaded.** The catalog lives entirely in the Plugin data directory. Nothing leaves the host. + +The scanner reads (read-only): + +- Filesystem metadata (size, mtime) for executables under the configured roots. +- The first line of stdout for `tool --version` for 15 well-known CLIs (node, npm, pnpm, yarn, mcode, openclaw, clawhub, codex, git, python, python3, gh, docker, pwsh, powershell). Each probe has a 5 s timeout and never throws. +- `~/.gitconfig` for the `user.name` and `user.email` fields (treated as public identity, displayed in the summary). +- The list of filenames under `~/.ssh/` that match `id_*` (without `.pub`). File contents are never read. + +The scanner writes (only): + +- `${PLUGIN_DATA}/tools.md`, `${PLUGIN_DATA}/tools.json`, `${PLUGIN_DATA}/tools.summary.md` (or whatever path is passed as `argv[2]`). Writes are atomic: staging file in the same directory, then `rename`. On any failure, the staging file is removed and the previous catalog is left untouched. + +## Limitations + +- The catalog is a snapshot, not live. After installing or upgrading a tool, the user (or the agent on user instruction) must re-run the scanner. The default cache is good until something changes; the agent should not assume a tool listed 30 seconds ago is still on `$PATH` if a `command not found` was reported in the same session. +- `--version` probes use a 5 s timeout. A tool that hangs longer than that is omitted from the `core` versions table but stays in the file-walk inventory (so the agent still knows the file exists). +- The walk has a safety cap of 5000 entries; very large tool collections (e.g. a build farm with thousands of node_modules shims) are truncated. Raise `MAX_RESULTS` in `scripts/scan.mjs` if you need more. +- Files larger than 50 MB are skipped (CUDA SDKs, game engines, etc.) to keep the catalog readable. +- The scanner does not enumerate npm packages, pip packages, or system packages. It finds executables on disk, not installable artifacts. + +## Test evidence + +Run from the repository root (this directory's parent): + +```text +$ npm run check +OK example hello-mcode +OK example hello-mcode-mcp +OK plugin Fectivnfy112357/github-explore +OK plugin hetaoBackend/minimax-code-trajectory +OK plugin HopeYin/dida365 +OK plugin HopeYin/ticktick +OK plugin Hylouis233/mcp-server-patterns +OK plugin Hylouis233/search-first +OK plugin Hylouis233/verification-loop +OK plugin antianqi/tool-map +tests 7 +pass 7 +fail 0 +``` + +`npm run check` runs `npm run validate` (the Plugin shape validator, hardened to the rules proposed in PR #4) and then `npm test` (which discovers `test/tool-map.test.mjs` via the `node --test` runner). The bundled `scripts/smoke.mjs` exits 0 against the Plugin's own source tree, confirming no hardcoded paths, no literal credentials, and no leftover scaffold markers. + +## Links + +- Issue tracker: https://github.com/MiniMax-AI/MiniMax-Code-Plugins/issues +- Contributing: see `CONTRIBUTING.md` in the repository root. +- License: Apache-2.0. See `LICENSE` in this directory. diff --git a/plugins/antianqi/tool-map/plugin.json b/plugins/antianqi/tool-map/plugin.json new file mode 100644 index 0000000..7d0a548 --- /dev/null +++ b/plugins/antianqi/tool-map/plugin.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "tool-map", + "version": "0.2.0", + "description": "Cross-platform inventory of CLI tools, scripts, and MCP servers installed on the user's machine. Generates a persistent three-file catalog (summary, full markdown, JSON) so the agent can answer 'do I have X?', 'where is Y?', 'how do I run Z?' without re-scanning the filesystem every session.", + "author": { + "name": "antianqi", + "url": "https://github.com/antianqi" + }, + "license": "Apache-2.0", + "homepage": "https://github.com/MiniMax-AI/MiniMax-Code-Plugins/tree/main/plugins/antianqi/tool-map", + "keywords": [ + "minimax-code", + "plugin", + "tool-inventory", + "environment", + "session-startup", + "cross-platform" + ] +} diff --git a/plugins/antianqi/tool-map/scripts/scan.mjs b/plugins/antianqi/tool-map/scripts/scan.mjs new file mode 100644 index 0000000..8a0eb79 --- /dev/null +++ b/plugins/antianqi/tool-map/scripts/scan.mjs @@ -0,0 +1,392 @@ +#!/usr/bin/env node +// tool-map / scan.mjs +// Cross-platform tool inventory scanner for the tool-map Plugin. +// Run: node scan.mjs [output.md] +// - default output: $PLUGIN_DATA/tools.md, with .json and .summary.md siblings +// - fallback when $PLUGIN_DATA is unset: ~/.local/share/tool-map/tools.md +// - if argv[2] is given, the catalog is written to that path's directory +// +// Design: zero external deps, atomic write (staging + rename), no hardcoded +// per-user absolute paths. All well-known locations are derived from the +// user's home directory, environment variables, or fixed POSIX conventions. + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { + readdirSync, readFileSync, statSync, existsSync, writeFileSync, mkdirSync, + realpathSync, renameSync, rmSync, +} from 'node:fs'; +import { join, dirname, basename, sep, extname, resolve, delimiter } from 'node:path'; +import { homedir, hostname, platform } from 'node:os'; +import { randomBytes } from 'node:crypto'; + +const execFileP = promisify(execFile); + +const PLATFORM = platform(); +const HOME = homedir(); +const IS_WIN = PLATFORM === 'win32'; +const ENV = process.env; + +// --- Output paths --- +// PLUGIN_DATA is set by the host runtime (mcode) when running plugin scripts. +// Fall back to the XDG_DATA_HOME convention so the scanner is also usable +// standalone from a developer's shell. +const DATA_ROOT = ENV.PLUGIN_DATA || join(HOME, '.local', 'share', 'tool-map'); +const outMd = resolve(process.argv[2] || join(DATA_ROOT, 'tools.md')); +const outJson = outMd.replace(/\.md$/, '') + '.json'; +const outSummary = outMd.replace(/\.md$/, '') + '.summary.md'; + +// --- Atomic write helper --- +// Writes to a sibling staging file first, then renames onto the target. The +// rename is atomic on POSIX and on Windows when the source and target live on +// the same filesystem, which is guaranteed here because the staging path sits +// in the same directory as the target. On any failure, the staging file is +// removed and the original target (if any) is left untouched. +function atomicWriteSync(targetPath, contents) { + const dir = dirname(targetPath); + mkdirSync(dir, { recursive: true }); + const pid = process.pid; + const rand = randomBytes(8).toString('hex'); + const stagingPath = join(dir, `.${basename(targetPath)}.staging-${pid}-${rand}`); + try { + writeFileSync(stagingPath, contents, 'utf8'); + renameSync(stagingPath, targetPath); + } catch (err) { + try { rmSync(stagingPath, { force: true }); } catch { /* swallow */ } + throw err; + } +} + +// --- Scan config --- +const EXEC_EXTS = IS_WIN + ? new Set(['.exe', '.cmd', '.ps1', '.bat', '.com', '.vbs', '.wsf', '']) + : new Set(['', '.sh', '.bash', '.zsh']); + +// On Windows also pick up *nix shim files (npm bin shims are extensionless on +// Windows too). Skip files > 50 MB (CUDA SDKs etc.) and extensionless files +// outside the 100 B to 10 KB range. +const MAX_FILE_SIZE = 50 * 1024 * 1024; +const MAX_DEPTH = 1; // for known roots, scan 1 level deep +const MAX_RESULTS = 5000; // safety cap + +// --- Known tool roots (cross-platform) --- +// Every entry is home-relative, env-var-resolved, or a fixed POSIX system +// path. No per-user absolute paths. +function knownRoots() { + if (IS_WIN) { + const progFiles = ENV.ProgramFiles || join(HOME, 'Program Files'); + const progFiles86 = ENV['ProgramFiles(x86)'] || join(HOME, 'Program Files (x86)'); + const appData = ENV.APPDATA || join(HOME, 'AppData', 'Roaming'); + const localAppData = ENV.LOCALAPPDATA || join(HOME, 'AppData', 'Local'); + return [ + [join(HOME, '.minimax-code'), 'minimax-code'], + [join(HOME, '.minimax'), 'minimax'], + [join(appData, 'npm'), 'npm-global'], + [join(HOME, '.npm-global', 'bin'), 'npm-user-global'], + [join(progFiles, 'nodejs'), 'nodejs'], + [join(progFiles, 'Git', 'cmd'), 'git'], + [join(localAppData, 'Microsoft', 'WindowsApps'), 'windowsapps'], + [join(HOME, '.Codex'), 'codex'], + [join(HOME, '.claude'), 'claude'], + ]; + } + // macOS / Linux + return [ + [join(HOME, '.minimax-code'), 'minimax-code'], + [join(HOME, '.minimax'), 'minimax'], + [join(HOME, '.local', 'bin'), 'user-local-bin'], + [join(HOME, '.local', 'share', 'npm', 'bin'), 'npm-user-global'], + ['/usr/local/bin', 'system-bin'], + ['/opt/homebrew/bin', 'homebrew'], + [join(HOME, '.Codex'), 'codex'], + [join(HOME, '.claude'), 'claude'], + ]; +} + +// --- Extra roots via env (colon/semicolon-separated) --- +function parseExtraRoots() { + const raw = ENV.TOOL_MAP_ROOTS; + if (!raw) return []; + return raw.split(delimiter) + .map((d) => d.trim()) + .filter(Boolean); +} + +// --- Version probes (with timeout, never throw) --- +const VERSION_PROBES = [ + ['node', ['node', '--version']], + ['npm', ['npm', '--version']], + ['pnpm', ['pnpm', '--version']], + ['yarn', ['yarn', '--version']], + ['mcode', ['mcode', '--version']], + ['openclaw', ['openclaw', '--version']], + ['clawhub', ['clawhub', '--version']], + ['codex', ['codex', '--version']], + ['git', ['git', '--version']], + ['python', ['python', '--version']], + ['python3', ['python3', '--version']], + ['gh', ['gh', '--version']], + ['docker', ['docker', '--version']], + ['pwsh', ['pwsh', '--version']], + ['powershell', ['powershell', '-NoProfile', '-Command', '$PSVersionTable.PSVersion.ToString()']], +]; + +async function probeVersion(cmd) { + try { + const { stdout } = await execFileP(cmd[0], cmd.slice(1), { + timeout: 5000, + windowsHide: true, + shell: IS_WIN, + }); + const first = (stdout || '').split(/\r?\n/)[0].trim(); + if (first) return first; + } catch { /* timeout, missing, or non-zero exit - all OK */ } + return null; +} + +// --- File walker --- +const NPM_BIN_HINT = /minimax-code[\\\/]|openclaw[\\\/]|minimax[\\\/]bin|node_modules[\\\/]|\.Codex[\\\/]|\.claude[\\\/]|[\\\/]npm[\\\/]|tauri[\\\/]/i; +function isToolFile(name, size, dirLower) { + if (name.startsWith('.')) return false; // dotfiles (.gitignore, .npmrc, ...) are not tools + const ext = extname(name).toLowerCase(); + if (ext !== '') return EXEC_EXTS.has(ext); + // extensionless file - likely an npm bin shim + if (size < 100 || size > 10 * 1024) return false; + return NPM_BIN_HINT.test(dirLower); +} + +function classify(p) { + const norm = p.toLowerCase(); + if (norm.includes('.minimax-code')) return 'minimax-code'; + if (norm.includes('.minimax')) return 'minimax'; + if (norm.includes('openclaw')) return 'openclaw'; + if (norm.includes('.codex')) return 'codex'; + if (norm.includes('.claude')) return 'claude'; + if (norm.includes('nodejs')) return 'nodejs'; + if (norm.includes('github cli')) return 'gh-cli'; + if (norm.includes('git\\cmd') || norm.includes('git/cmd')) return 'git'; + if (norm.includes('python')) return 'python'; + if (norm.includes('node_modules') || norm.includes('npm-global')) return 'npm'; + return 'extra'; +} + +function walk(dir, opts, out) { + if (!existsSync(dir)) return; + if (out.length >= MAX_RESULTS) return; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + if (out.length >= MAX_RESULTS) break; + const full = join(dir, e.name); + if (e.isFile()) { + let st; + try { st = statSync(full); } catch { continue; } + if (st.size > MAX_FILE_SIZE) continue; + // Pass dir + sep so trailing-`\` regex anchors match for both root and nested dirs. + if (!isToolFile(e.name, st.size, (dir + sep).toLowerCase())) continue; + const ext = extname(e.name); + out.push({ + name: basename(e.name, ext), + type: ext.replace(/^\./, '') || (IS_WIN ? 'exe' : 'bin'), + path: full, + size: st.size, + modified: st.mtime.toISOString().slice(0, 10), + category: opts.category, + }); + } else if (e.isDirectory() && !e.isSymbolicLink() && opts.depth > 0) { + // For known roots, recurse subdirs at the configured depth. + // Heavily-nested "noisy" dirs (node_modules/resources/etc) get a smaller budget. + const dn = e.name.toLowerCase(); + if (/^(node_modules|app-|app\.|resources|locales|dll|swiftshader)/.test(dn)) { + walk(full, { ...opts, depth: Math.max(0, opts.depth - 1) }, out); + } else { + walk(full, { ...opts, depth: opts.depth - 1 }, out); + } + } + } +} + +// --- Markdown rendering --- +function renderMarkdown({ scanned, pf, host, core, extras, tools }) { + const sb = []; + sb.push('# Tool Inventory'); + sb.push(''); + sb.push(`- Scanned: ${scanned}`); + sb.push(`- Platform: ${pf} (${IS_WIN ? 'Windows' : 'POSIX'})`); + sb.push(`- Host: ${host}`); + sb.push(`- Total: ${tools.length} entries across ${new Set(tools.map((t) => t.category)).size} categories`); + sb.push(''); + sb.push('## Core Versions'); + sb.push(''); + sb.push('| Tool | Version |'); + sb.push('|------|---------|'); + for (const [k, v] of Object.entries(core).sort()) sb.push(`| ${k} | ${v} |`); + if (extras.git_user || extras.git_email || (extras.ssh_keys && extras.ssh_keys.length)) { + sb.push(''); + sb.push('## Identity & Keys'); + sb.push(''); + if (extras.git_user) sb.push(`- **GitHub user**: \`${extras.git_user}\``); + if (extras.git_email) sb.push(`- **Git email**: \`${extras.git_email}\``); + if (extras.ssh_keys && extras.ssh_keys.length) sb.push(`- **SSH key filenames** (contents not read): ${extras.ssh_keys.map((k) => '`' + k + '`').join(', ')}`); + } + sb.push(''); + // Group by category + const byCat = new Map(); + for (const t of tools) { + if (!byCat.has(t.category)) byCat.set(t.category, []); + byCat.get(t.category).push(t); + } + for (const [cat, items] of [...byCat.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + sb.push(`## ${cat} (${items.length})`); + sb.push(''); + sb.push('| Name | Type | Size(KB) | Modified | Path |'); + sb.push('|------|------|---------:|----------|------|'); + for (const e of items.sort((a, b) => a.name.localeCompare(b.name))) { + sb.push(`| ${e.name} | ${e.type} | ${(e.size / 1024).toFixed(1)} | ${e.modified} | ${e.path} |`); + } + sb.push(''); + } + sb.push('---'); + sb.push(''); + sb.push('## Scan Notes'); + sb.push(''); + sb.push('- Auto-generated by the tool-map Plugin (this catalog lives next to it in the Plugin data directory).'); + sb.push('- To refresh: re-run the scanner, or trigger the `tool-map` Skill.'); + sb.push('- Cross-platform: works on Windows / macOS / Linux. Pure Node, no external dependencies.'); + sb.push('- Skips files > 50 MB and extensionless files outside the 100 B to 10 KB range.'); + sb.push('- Writes are atomic (staging + rename) so a crash mid-scan never leaves a partial catalog.'); + sb.push(''); + return sb.join('\n'); +} + +// --- Summary rendering --- +function renderSummary({ scanned, pf, core, extras, tools }) { + const sb = []; + sb.push('# Tool Map (Summary)'); + sb.push(''); + sb.push(`> Scanned: ${scanned} | Platform: ${pf} | Tools: ${tools.length}`); + sb.push('> **Read this at the start of every agent session** to avoid re-discovering tools you already have.'); + sb.push(''); + // Top-N most useful tools (CLI shortcuts the agent is likely to need) + sb.push('## Core CLI (run `cmd --version` to confirm)'); + sb.push(''); + sb.push('| Tool | Version |'); + sb.push('|------|---------|'); + for (const [k, v] of Object.entries(core).sort()) sb.push(`| \`${k}\` | ${v} |`); + sb.push(''); + // Quick lookup by category + const byCat = new Map(); + for (const t of tools) { + if (!byCat.has(t.category)) byCat.set(t.category, []); + byCat.get(t.category).push(t); + } + sb.push('## Quick Lookup by Category'); + sb.push(''); + for (const [cat, items] of [...byCat.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + sb.push(`### ${cat} (${items.length})`); + sb.push(''); + for (const e of items.slice(0, 20).sort((a, b) => a.name.localeCompare(b.name))) { + sb.push(`- \`${e.name}\` - ${e.path}`); + } + if (items.length > 20) sb.push(`- _...and ${items.length - 20} more, see tools.md_`); + sb.push(''); + } + if (extras.git_user) sb.push(`GitHub user: \`${extras.git_user}\` `); + if (extras.git_email) sb.push(`Git email: \`${extras.git_email}\` `); + if (extras.ssh_keys && extras.ssh_keys.length) sb.push(`SSH key filenames: ${extras.ssh_keys.join(', ')} `); + sb.push(''); + return sb.join('\n'); +} + +// --- Main --- +async function main() { + const startTs = new Date().toISOString(); + + // 1) PATH directories + const pathDirs = (ENV.PATH || '') + .split(delimiter) + .map((d) => d.trim()) + .filter(Boolean); + + // 2) Known roots + extra roots from env + const known = [ + ...knownRoots(), + ...parseExtraRoots().map((p) => [p, 'extra-root']), + ].filter(([p]) => existsSync(p)); + + // 3) Walk - known roots first (more specific categories win over PATH) + const out = []; + for (const [p, cat] of known) { + walk(p, { category: cat, depth: MAX_DEPTH }, out); + } + for (const d of pathDirs) { + walk(d, { category: 'PATH', depth: 0 }, out); + } + + // 4) Dedupe by full path (prefer real path) + const seen = new Map(); + for (const t of out) { + let real; + try { real = realpathSync(t.path); } catch { real = t.path; } + const key = real.toLowerCase(); + if (!seen.has(key)) seen.set(key, { ...t, path: real }); + } + const tools = [...seen.values()].sort((a, b) => a.path.localeCompare(b.path)); + + // 5) Version probes (parallel) + const coreEntries = await Promise.all( + VERSION_PROBES.map(async ([name, cmd]) => { + const v = await probeVersion(cmd); + return v ? [name, v] : null; + }), + ); + const core = Object.fromEntries(coreEntries.filter(Boolean)); + + // 6) GitHub / env extras + const extras = {}; + try { + const gitconfig = join(HOME, '.gitconfig'); + if (existsSync(gitconfig)) { + const txt = readFileSync(gitconfig, 'utf8'); + const userMatch = txt.match(/\[user\][\s\S]*?name\s*=\s*([^\n]+)/); + const emailMatch = txt.match(/\[user\][\s\S]*?email\s*=\s*([^\n]+)/); + if (userMatch) extras.git_user = userMatch[1].trim(); + if (emailMatch) extras.git_email = emailMatch[1].trim(); + } + } catch { /* unreadable .gitconfig - skip */ } + try { + const sshDir = join(HOME, '.ssh'); + if (existsSync(sshDir)) { + const keys = readdirSync(sshDir).filter((f) => /^id_/.test(f) && !f.endsWith('.pub')); + extras.ssh_keys = keys; + } + } catch { /* unreadable .ssh - skip */ } + + // 7) Render and write atomically + const md = renderMarkdown({ scanned: startTs, pf: PLATFORM, host: hostname(), core, extras, tools }); + const json = { scanned: startTs, platform: PLATFORM, host: hostname(), core, extras, tools }; + const summary = renderSummary({ scanned: startTs, pf: PLATFORM, core, extras, tools }); + + atomicWriteSync(outMd, md); + atomicWriteSync(outJson, JSON.stringify(json, null, 2)); + atomicWriteSync(outSummary, summary); + + // 8) Console report + const byCat = tools.reduce((acc, t) => { acc[t.category] = (acc[t.category] || 0) + 1; return acc; }, {}); + console.log(`WROTE ${outMd} (${md.length} bytes)`); + console.log(`WROTE ${outJson} (${JSON.stringify(json).length} bytes)`); + console.log(`WROTE ${outSummary} (${summary.length} bytes)`); + console.log(`TOOLS ${tools.length} unique entries across ${Object.keys(byCat).length} categories`); + for (const [cat, n] of Object.entries(byCat).sort((a, b) => b[1] - a[1])) { + console.log(` ${cat.padEnd(15)} ${n}`); + } +} + +main().catch((err) => { + console.error('FATAL:', err); + process.exit(1); +}); diff --git a/plugins/antianqi/tool-map/scripts/smoke.mjs b/plugins/antianqi/tool-map/scripts/smoke.mjs new file mode 100644 index 0000000..bd1abda --- /dev/null +++ b/plugins/antianqi/tool-map/scripts/smoke.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// tool-map / smoke.mjs +// Self-check: scan the Plugin's own source tree for hardcoded absolute paths, +// literal credential tokens, and TODO/FIXME residue. Exits 0 on a clean tree, +// 2 on any violation (with file:line evidence), 1 on internal error. +// +// Run: node scripts/smoke.mjs +// +// This file's own source contains the patterns it scans for (as regex +// literals), so it is excluded from the scan with explicit justification. + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// This file lives at /scripts/smoke.mjs, so PLUGIN_ROOT is the parent +// of the scripts/ directory. +const PLUGIN_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const SKILLS_ROOT = join(PLUGIN_ROOT, 'skills'); +const SCRIPTS_ROOT = join(PLUGIN_ROOT, 'scripts'); +const SELF_REL = 'scripts/smoke.mjs'; + +// Patterns that smell like a hardcoded absolute path on any platform. +const PATH_PATTERNS = [ + /[A-Z]:\\(?!node_modules|\$)/g, // Windows drive letter (not env var) + /\/Users\/[a-zA-Z0-9._-]+/g, // macOS user home + /\/home\/[a-zA-Z0-9._-]+/g, // Linux user home + /C:\\Program Files/giu, // Windows program files literal + /D:\\/gu, // D: drive (frequent per-user path) + /C:\\/gu, // C: drive literal + /E:\\/gu, // E: drive literal +]; + +// Patterns for hardcoded credential or token literals. +const TOKEN_PATTERNS = [ + /Bearer\s+[A-Za-z0-9_-]{16,}/g, + /(?:api[_-]?key|access[_-]?token|auth[_-]?token|secret[_-]?key)\s*[=:]\s*['"][A-Za-z0-9_-]{8,}['"]/gi, +]; + +// TODO / FIXME / XXX residue from the scaffold. +const TODO_PATTERNS = [ + /\bTODO\b/g, + /\bFIXME\b/g, + /\bXXX\b/g, +]; + +function walk(dir) { + const out = []; + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + if (e.name === 'node_modules' || e.name.startsWith('.')) continue; + const full = join(dir, e.name); + let st; + try { st = statSync(full); } catch { continue; } + if (st.isDirectory()) { + out.push(...walk(full)); + } else if (st.isFile() && /\.(md|mjs)$/iu.test(e.name)) { + out.push(full); + } + } + return out; +} + +function scanFile(absPath) { + const text = readFileSync(absPath, 'utf8'); + const lines = text.split('\n'); + const hits = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + for (const pattern of PATH_PATTERNS) { + pattern.lastIndex = 0; + let m; + while ((m = pattern.exec(line)) !== null) { + hits.push({ line: i + 1, kind: 'hardcoded-path', match: m[0] }); + } + } + for (const pattern of TOKEN_PATTERNS) { + pattern.lastIndex = 0; + let m; + while ((m = pattern.exec(line)) !== null) { + hits.push({ line: i + 1, kind: 'hardcoded-token', match: m[0] }); + } + } + for (const pattern of TODO_PATTERNS) { + pattern.lastIndex = 0; + let m; + while ((m = pattern.exec(line)) !== null) { + hits.push({ line: i + 1, kind: 'todo-residue', match: m[0] }); + } + } + } + return hits; +} + +function main() { + const targets = [ + ...walk(SKILLS_ROOT), + ...walk(SCRIPTS_ROOT), + ].filter((f) => relative(PLUGIN_ROOT, f).replace(/\\/g, '/') !== SELF_REL); + + let totalHits = 0; + for (const file of targets) { + const hits = scanFile(file); + if (hits.length === 0) continue; + totalHits += hits.length; + const rel = relative(PLUGIN_ROOT, file).replace(/\\/g, '/'); + for (const h of hits) { + console.error(` ${rel}:${h.line} [${h.kind}] ${h.match}`); + } + } + if (totalHits > 0) { + console.error(`\nFAIL ${totalHits} violation(s) found.`); + process.exit(2); + } + console.log(`OK scanned ${targets.length} files, 0 violations.`); +} + +main(); diff --git a/plugins/antianqi/tool-map/skills/tool-map/SKILL.md b/plugins/antianqi/tool-map/skills/tool-map/SKILL.md new file mode 100644 index 0000000..0448b6e --- /dev/null +++ b/plugins/antianqi/tool-map/skills/tool-map/SKILL.md @@ -0,0 +1,72 @@ +--- +name: tool-map +description: Cross-platform inventory of CLI tools, scripts, and MCP servers installed on the user's machine. Use when the user asks what is installed, where a tool lives, or how to run something - read the cached summary first instead of re-walking the filesystem. Refresh the catalog with the bundled scan.mjs only when the user asks, just installed a tool, or the cached summary is missing a tool the user mentions. +--- + +# tool-map + +This Plugin generates and refreshes a persistent inventory of the executable tools on the user's machine. The agent should consult the cached summary first and only re-scan when the user explicitly asks, when a tool the user mentions is not in the summary, or when the user has just installed or upgraded something. + +## Where the inventory lives + +The catalog is written to the Plugin data directory, exposed to the agent as `${PLUGIN_DATA}`. Three files are always written together: + +- `${PLUGIN_DATA}/tools.summary.md` - lightweight (~6 KB) one-pager; **read this on session start** to learn what is installed without re-discovering the filesystem. +- `${PLUGIN_DATA}/tools.md` - full markdown inventory grouped by category, with size, mtime, and absolute paths. +- `${PLUGIN_DATA}/tools.json` - machine-readable JSON (same content as `tools.md`, structured); use this when you need to filter or query tools programmatically. + +If `${PLUGIN_DATA}/tools.summary.md` does not exist on the first read in a session, run the scanner once to create all three files (see "How to refresh" below). On every subsequent turn, trust the summary; do not re-walk the filesystem and do not re-probe `--version` for tools already listed. + +## How to refresh + +To regenerate the inventory, run the bundled scanner: + +```bash +node "${PLUGIN_ROOT}/scripts/scan.mjs" +``` + +The scanner walks known tool roots and the user's `$PATH`, probes a fixed list of well-known CLIs for `--version` (5 s timeout each, never throws), and writes all three files atomically (staging-then-rename, no partial files). The scan is read-only and never modifies anything outside `${PLUGIN_DATA}`. Typical run: under 2 s on a developer workstation. + +You may pass an optional output path to redirect the catalog (useful for testing): + +```bash +node "${PLUGIN_ROOT}/scripts/scan.mjs" /tmp/my-inventory.md +``` + +When redirected, the scanner derives `tools.json` and `tools.summary.md` from the given path's stem (replace `.md` with `.json` and `.summary.md`). + +## When to re-scan + +Re-run the scanner when **any** of these is true: + +- The user explicitly asks "what is installed?", "refresh the inventory", or "re-scan tools". +- The user just installed or upgraded a tool, and the next request involves that tool. +- The user mentions a tool that is not in the summary. +- A tool listed in the summary gives a `command not found` error in this session (the summary may be stale). + +In all other cases, trust the summary. Do not re-walk the filesystem, do not re-probe `--version` for tools already listed, and do not re-print the inventory back to the user unless they ask. + +## Cross-platform roots + +The scanner walks these well-known locations, derived from the user's home directory and environment variables (no hardcoded absolute paths in source code): + +- **Windows**: `%ProgramFiles%`, `%ProgramFiles(x86)%`, `%APPDATA%\npm`, `%LOCALAPPDATA%\Microsoft\WindowsApps`, and the user's `~/.minimax-code`, `~/.minimax`, `~/.npm-global/bin`, `~/pwsh7_6`, `~/.Codex`, `~/.claude`. +- **macOS / Linux**: `~/.minimax-code`, `~/.minimax`, `~/.local/bin`, `~/.local/share/npm/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, `~/.Codex`, `~/.claude`. + +Plus everything on the user's `$PATH`. To add an extra root, set the `TOOL_MAP_ROOTS` environment variable to a `:`-separated (POSIX) or `;`-separated (Windows) list of absolute paths; each is walked with the same rules as the built-in roots. + +## What the scanner reads and writes + +- **Reads**: filesystem metadata (size, mtime) for executables under known roots and `$PATH`; the first line of stdout for `tool --version` for a fixed list of 15 well-known CLIs (node, npm, pnpm, yarn, mcode, openclaw, clawhub, codex, git, python, python3, gh, docker, pwsh, powershell); `~/.gitconfig` for user/email; the list of filenames under `~/.ssh/` (NOT the key contents, NOT any other directory). +- **Writes**: `${PLUGIN_DATA}/tools.{md,json,summary.md}` (or the path given as `argv[2]`) only. +- **Does not read**: the contents of any file under `~/.ssh/`; environment variable values that look like secrets; any registry, browser data, source code, or user documents. +- **Does not write**: any file outside the output directory; any user or host install area; any registry or config under `~/.config/`, `~/.minimax/`, or `~/.openclaw*/`. +- **Does not send**: any network request, any telemetry, any data to any third party. The scanner is fully offline. + +## Failure modes + +- A tool's `cmd --version` hangs - the 5 s timeout aborts the probe; that tool is omitted from the `core` versions table but stays in the file-walk inventory. +- A directory is unreadable (permission denied, broken symlink) - skipped silently; the walk continues. +- Output path is on a different filesystem from the staging location - atomic rename still works because staging lives next to the target file, not in `os.tmpdir()`. +- `${PLUGIN_DATA}` is not set - the scanner falls back to `~/.local/share/tool-map` (XDG_DATA_HOME compliant). +- `TOOL_MAP_ROOTS` contains a non-existent path - that path is skipped; the rest of the walk continues. diff --git a/test/tool-map.test.mjs b/test/tool-map.test.mjs new file mode 100644 index 0000000..c76fe71 --- /dev/null +++ b/test/tool-map.test.mjs @@ -0,0 +1,138 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + mkdtempSync, existsSync, readFileSync, statSync, rmSync, readdirSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// This test lives at /test/tool-map.test.mjs, so REPO_ROOT is the +// parent of the test/ directory. +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const PLUGIN_DIR = join(REPO_ROOT, 'plugins', 'antianqi', 'tool-map'); +const SCAN = join(PLUGIN_DIR, 'scripts', 'scan.mjs'); +const SMOKE = join(PLUGIN_DIR, 'scripts', 'smoke.mjs'); + +function runScan(outPath) { + return spawnSync(process.execPath, [SCAN, outPath], { + encoding: 'utf8', + timeout: 30_000, + }); +} + +test('scan.mjs writes the three catalog files atomically', () => { + const work = mkdtempSync(join(tmpdir(), 'tool-map-write-')); + try { + const out = join(work, 'tools.md'); + const r = runScan(out); + assert.equal(r.status, 0, `scan failed (exit ${r.status}):\n${r.stderr}\n${r.stdout}`); + const stem = out.replace(/\.md$/, ''); + for (const path of [out, `${stem}.json`, `${stem}.summary.md`]) { + assert.ok(existsSync(path), `missing ${path}`); + assert.ok(statSync(path).size > 0, `empty ${path}`); + } + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('scan.mjs JSON has the expected schema', () => { + const work = mkdtempSync(join(tmpdir(), 'tool-map-schema-')); + try { + const out = join(work, 'tools.md'); + const r = runScan(out); + assert.equal(r.status, 0, `scan failed: ${r.stderr}`); + const json = JSON.parse(readFileSync(out.replace(/\.md$/, '') + '.json', 'utf8')); + assert.equal(typeof json.scanned, 'string', 'scanned timestamp required'); + assert.equal(typeof json.platform, 'string', 'platform required'); + assert.equal(typeof json.host, 'string', 'host required'); + assert.equal(typeof json.core, 'object', 'core versions object required'); + assert.equal(typeof json.extras, 'object', 'extras object required'); + assert.ok(Array.isArray(json.tools), 'tools must be an array'); + for (const t of json.tools) { + assert.equal(typeof t.name, 'string'); + assert.equal(typeof t.type, 'string'); + assert.equal(typeof t.path, 'string'); + assert.equal(typeof t.size, 'number'); + assert.equal(typeof t.modified, 'string'); + assert.equal(typeof t.category, 'string'); + } + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('scan.mjs writes nothing outside the output directory', () => { + const work = mkdtempSync(join(tmpdir(), 'tool-map-isolated-')); + try { + const out = join(work, 'tools.md'); + const r = runScan(out); + assert.equal(r.status, 0, `scan failed: ${r.stderr}`); + const entries = readdirSync(work).sort(); + assert.deepEqual( + entries, + ['tools.json', 'tools.md', 'tools.summary.md'], + `unexpected files in output dir: ${entries.join(', ')}`, + ); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('scan.mjs leaves no staging files on success', () => { + const work = mkdtempSync(join(tmpdir(), 'tool-map-nostage-')); + try { + const out = join(work, 'tools.md'); + const r = runScan(out); + assert.equal(r.status, 0, `scan failed: ${r.stderr}`); + const entries = readdirSync(work); + for (const e of entries) { + assert.ok(!e.includes('.staging-'), `staging file leaked: ${e}`); + } + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('scan.mjs completes with an empty PATH and still produces a valid catalog', () => { + // The scanner must not crash if $PATH is empty (a valid CI / sandbox + // configuration). Known roots may still produce entries; what matters is + // that the run returns 0 and the catalog is well-formed. + const work = mkdtempSync(join(tmpdir(), 'tool-map-probes-')); + try { + const out = join(work, 'tools.md'); + const r = spawnSync(process.execPath, [SCAN, out], { + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, PATH: '', TOOL_MAP_ROOTS: '' }, + }); + assert.equal(r.status, 0, `scan failed with empty PATH: ${r.stderr}\n${r.stdout}`); + const stem = out.replace(/\.md$/, ''); + for (const path of [out, `${stem}.json`, `${stem}.summary.md`]) { + assert.ok(existsSync(path), `missing ${path} after empty-PATH run`); + } + const json = JSON.parse(readFileSync(out.replace(/\.md$/, '') + '.json', 'utf8')); + assert.equal(typeof json.platform, 'string'); + assert.ok(Array.isArray(json.tools)); + // No tool whose `category` is exactly 'PATH' should appear when PATH is empty. + for (const t of json.tools) { + assert.notEqual(t.category, 'PATH', 'PATH-categorized tool leaked with empty $PATH'); + } + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); + +test('smoke.mjs exits 0 against the plugin source tree', () => { + const r = spawnSync(process.execPath, [SMOKE], { + encoding: 'utf8', + timeout: 15_000, + }); + assert.equal( + r.status, 0, + `smoke failed (exit ${r.status}):\n${r.stderr}\nstdout:\n${r.stdout}`, + ); + assert.match(r.stdout, /OK scanned \d+ files, 0 violations\./u); +});