Files
AgentHub/scripts/install-hooks.js
nntrivi2001 e972267a42 fix(plugins): make the 10 focused plugins installable, finish the AgentHub rename
The ten `agenthub-*` plugins were listed by the marketplace but could never be
installed — two schema mistakes present since the entries were first authored
(57dc915), both invisible until you actually run `claude plugin install`:

- marketplace entries used `"path": "plugins/<name>"`; the CLI only accepts
  `"source"`, so every one failed with
  `This plugin's marketplace entry is invalid: source: Invalid input`
- each `plugin.json` carried npm's `"repository": { "type", "url" }` object;
  the schema wants a plain string, so the next attempt failed with
  `Validation errors: repository: Invalid input`

Verified by installing all 11 entries into a throwaway CLAUDE_CONFIG_DIR:
1/11 before, 11/11 after. `plugins-marketplace.test.js` was asserting the
broken shape, which is why CI stayed green — it now asserts `source` and
rejects `path`.

Marketplace renamed `claude-code-agent-monitor-plugins` -> `agenthub`, so the
install line is `agenthub@agenthub`. ARCHITECTURE.md documented a marketplace
name (`smartgift-claude-code-monitor`) that never existed in any revision.

The documented `/plugin marketplace add Smartgift-AI/Claude-Code-Monitor` also
never worked: the `owner/repo` shorthand resolves to github.com only, and this
repo is self-hosted on Gitea. Confirmed the CLI rejects `host/owner/repo` and
ignores GH_HOST / GITHUB_HOST / CLAUDE_CODE_MARKETPLACE_URL, so the full
https:// clone URL is the only supported form. Same GitHub-habit class fixed
elsewhere: raw.githubusercontent.com icon URLs (404, and the repo ships
favicon.svg not favicon.ico — the client had the same dead /favicon.ico),
a smartgift.github.io docs link (404), and Gitea `/blob/<ref>/` paths that
must be `/src/branch/<ref>/`.

Repository renamed to Smartgift-AI/AgentHub throughout. Gitea 301-redirects
the old name, so these were stale rather than broken; `docs/superpowers/`
keeps the old name because those are dated decision records.

Adds `repo-links.test.js` to guard both classes (it runs under the existing
pre-commit gate) plus an openapi.yaml drift check — the committed spec had
silently fallen a minor version behind package.json. `generate-openapi-yaml.js`
now exports `render()` so the test compares against one definition of the dump
options instead of a second copy.
2026-09-10 11:03:14 +07:00

210 lines
7.8 KiB
JavaScript

#!/usr/bin/env node
/**
* Installs Claude Code hooks that forward events to the Agent Dashboard.
* Modifies ~/.claude/settings.json to add hook entries.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("fs");
const path = require("path");
const { getSettingsPath } = require("../server/lib/claude-home");
const SETTINGS_PATH = getSettingsPath();
const HOOK_HANDLER = path.resolve(__dirname, "hook-handler.js").replace(/\\/g, "/");
/**
* True when the plugin bootstrap has run on this machine — i.e. the `agenthub`
* plugin is (or was) installed and supplies its own hook entries.
* Read-only and never throws; `plugin-bootstrap.js` is not required here to
* keep this module free of a circular dependency.
*
* @returns {boolean}
*/
function pluginBootstrapRan() {
try {
const { getDataDir } = require("../server/lib/claude-home");
return fs.existsSync(path.join(getDataDir(), "runtime", "state.json"));
} catch {
return false;
}
}
function envFlag(name) {
return ["1", "true", "yes", "on"].includes(String(process.env[name] || "").toLowerCase());
}
/**
* True when this process is running inside a container (Docker, Podman, or a
* Kubernetes pod). Detected via the Docker/Podman marker files, the OCI/systemd
* `container` env var, and a Linux cgroup heuristic. `AGENTHUB_FORCE_CONTAINER=1`
* forces a positive result and `AGENTHUB_FORCE_HOST=1` forces a negative result
* (used by tests / to override misfiring detection).
*
* Why this matters (GitHub #193): the hook command written into
* `~/.claude/settings.json` embeds the absolute handler path resolved here.
* Inside a container that path (e.g. `/app/scripts/hook-handler.js`) does not
* exist on the host. When `~/.claude` is bind-mounted, installing from the
* container poisons the host settings and every host hook fails with
* `MODULE_NOT_FOUND`. Claude Code runs on the host, so hooks must be installed
* on the host.
*
* @returns {boolean}
*/
function isInsideContainer() {
if (envFlag("AGENTHUB_FORCE_CONTAINER")) return true;
if (envFlag("AGENTHUB_FORCE_HOST")) return false;
try {
if (fs.existsSync("/.dockerenv")) return true; // Docker
if (fs.existsSync("/run/.containerenv")) return true; // Podman
} catch {
/* fs probe failed — fall through to other signals */
}
// systemd-nspawn / Podman (and often Docker) export `container`.
if (typeof process.env.container === "string" && process.env.container.length > 0) return true;
// Linux cgroup heuristic — covers Docker, containerd, Kubernetes, Podman.
try {
const cgroup = fs.readFileSync("/proc/self/cgroup", "utf8");
if (/\b(docker|containerd|kubepods|libpod|podman)\b/.test(cgroup)) return true;
} catch {
/* not Linux / no cgroup file — not a container by this signal */
}
return false;
}
/** Multi-line message explaining why a container install is refused. */
function containerRefusalMessage() {
return [
"✖ Refusing to install Claude Code hooks from inside a container.",
"",
` The hook command would embed this handler path:`,
` ${HOOK_HANDLER}`,
` written into:`,
` ${SETTINGS_PATH}`,
"",
" Claude Code runs on the HOST. When ~/.claude is bind-mounted, a",
" container-internal handler path does not exist on the host, so every host",
" hook fails with MODULE_NOT_FOUND (e.g. the SessionEnd hook). See issue #193.",
"",
" → Install hooks ON THE HOST instead:",
" npm run install-hooks",
" # or: node /path/to/AgentHub/scripts/install-hooks.js",
"",
" The host handler POSTs to http://localhost:4820, which the container already",
" publishes — so a host-installed hook reaches the containerized dashboard.",
"",
" If you genuinely run Claude Code inside this same container, override with:",
" AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks",
].join("\n");
}
// Hook types to install. Some support matchers, some don't.
const HOOKS_WITH_MATCHER = ["PreToolUse", "PostToolUse", "Stop", "SubagentStop", "Notification"];
// UserPromptSubmit fires the instant the user hits enter — the only reliable
// signal that the user has resumed for *text-only* turns (no PreToolUse will
// fire until Claude calls a tool, which never happens for plain-text replies).
// Without it the Waiting badge persists through the entire generation of a
// text response. SessionStart / SessionEnd / UserPromptSubmit don't take
// tool-name matchers, hence the separate list.
const HOOKS_WITHOUT_MATCHER = ["SessionStart", "SessionEnd", "UserPromptSubmit"];
const HOOK_TYPES = [...HOOKS_WITH_MATCHER, ...HOOKS_WITHOUT_MATCHER];
function makeHookEntry(hookType) {
const entry = {
hooks: [
{
type: "command",
command: `node "${HOOK_HANDLER}" ${hookType}`,
},
],
};
if (HOOKS_WITH_MATCHER.includes(hookType)) {
entry.matcher = "*";
}
return entry;
}
function isOurEntry(entry) {
// Matches old format (entry.command) and new format (entry.hooks[].command)
if (entry.command && entry.command.includes("hook-handler.js")) return true;
if (Array.isArray(entry.hooks)) {
return entry.hooks.some((h) => h.command && h.command.includes("hook-handler.js"));
}
return false;
}
function installHooks(silent = false) {
// Host-only guard (issue #193): never write a container-internal handler path
// into a (potentially bind-mounted) host settings file. Honors an explicit
// opt-out for the rare case of running Claude Code inside this same container.
if (isInsideContainer() && !envFlag("AGENTHUB_ALLOW_CONTAINER_HOOKS")) {
if (!silent) console.error(containerRefusalMessage());
return false;
}
// The `agenthub` plugin installs the same eight hooks itself. Running both means
// every event is POSTed twice and token/cost figures double — warn loudly
// rather than silently double-count. (`/agenthub-doctor` reports the same state.)
if (!silent && pluginBootstrapRan()) {
console.warn(
"WARNING: the agenthub plugin is installed and already provides these hooks.\n" +
" Installing them again double-counts every event. Uninstall the\n" +
" plugin first, or skip this step. Run /agenthub-doctor to check."
);
}
let settings = {};
if (fs.existsSync(SETTINGS_PATH)) {
try {
const raw = fs.readFileSync(SETTINGS_PATH, "utf8");
settings = JSON.parse(raw);
} catch (err) {
if (!silent) console.error(`Failed to parse ${SETTINGS_PATH}:`, err.message);
return false;
}
}
if (!settings.hooks) settings.hooks = {};
let installed = 0;
let updated = 0;
for (const hookType of HOOK_TYPES) {
if (!settings.hooks[hookType]) settings.hooks[hookType] = [];
const existing = settings.hooks[hookType].findIndex(isOurEntry);
const entry = makeHookEntry(hookType);
if (existing >= 0) {
settings.hooks[hookType][existing] = entry;
updated++;
} else {
settings.hooks[hookType].push(entry);
installed++;
}
}
const dir = path.dirname(SETTINGS_PATH);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n", "utf8");
if (!silent) {
console.log(`Hook handler: ${HOOK_HANDLER}`);
console.log(`Settings file: ${SETTINGS_PATH}`);
console.log(`Installed: ${installed} new, updated: ${updated} existing`);
console.log("Claude Code hooks configured. Start a new Claude Code session to begin tracking.");
}
return true;
}
if (require.main === module) {
// Non-zero exit on refusal/failure so CI and shell users notice it.
if (!installHooks(false)) process.exitCode = 1;
}
// isOurEntry is also used by scripts/plugin-bootstrap.js to strip hook entries
// a previous checkout install left behind (they would double-count events).
module.exports = { installHooks, isInsideContainer, isOurEntry };