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.
This commit is contained in:
@@ -185,10 +185,7 @@ describe("OpenAPI / Swagger", () => {
|
||||
// so the spec must not advertise one (a LICENSE url would 404).
|
||||
assert.equal(res.body.info.license, undefined);
|
||||
assert.equal(res.body["x-issues-url"], pkg.bugs.url);
|
||||
assert.match(
|
||||
res.body.info.contact.url,
|
||||
/git\.smartgift\.io\.vn\/Smartgift-AI\/Claude-Code-Monitor/
|
||||
);
|
||||
assert.match(res.body.info.contact.url, /git\.smartgift\.io\.vn\/Smartgift-AI\/AgentHub/);
|
||||
|
||||
for (const pathName of EXPECTED_API_PATHS) {
|
||||
assert.ok(res.body.paths[pathName], `Expected path ${pathName} to be documented`);
|
||||
|
||||
@@ -248,7 +248,7 @@ describe("legacy hook cleanup", () => {
|
||||
TMP_HOME,
|
||||
"plugins",
|
||||
"cache",
|
||||
"claude-code-agent-monitor-plugins",
|
||||
"agenthub",
|
||||
"agenthub",
|
||||
"abc123",
|
||||
"scripts",
|
||||
|
||||
@@ -79,16 +79,21 @@ describe("plugin marketplace", () => {
|
||||
|
||||
for (const entry of subdirEntries) {
|
||||
describe(`entry: ${entry.name}`, () => {
|
||||
it("has name, path, description, tags", () => {
|
||||
it("has name, source, description, tags", () => {
|
||||
assert.equal(typeof entry.name, "string");
|
||||
assert.equal(entry.path, `plugins/${entry.name}`);
|
||||
// `source`, not `path`: the `claude plugin install` CLI validates the
|
||||
// entry against a schema that only knows `source`, so a `path` entry
|
||||
// fails with "This plugin's marketplace entry is invalid: source:
|
||||
// Invalid input" — the plugin is listed but uninstallable.
|
||||
assert.equal(entry.source, `./plugins/${entry.name}`);
|
||||
assert.equal(entry.path, undefined, "`path` is not a source key the CLI accepts");
|
||||
assert.equal(typeof entry.description, "string");
|
||||
assert.ok(entry.description.length > 20);
|
||||
assert.ok(Array.isArray(entry.tags) && entry.tags.length > 0);
|
||||
});
|
||||
|
||||
it("path exists on disk", () => {
|
||||
assert.ok(fs.existsSync(path.join(REPO_ROOT, entry.path)));
|
||||
it("source exists on disk", () => {
|
||||
assert.ok(fs.existsSync(path.join(REPO_ROOT, entry.source)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @file repo-links.test.js
|
||||
* @description Guards the two staleness classes that shipped broken commands to
|
||||
* users for weeks: (1) GitHub-shaped references in a repo that is self-hosted on
|
||||
* Gitea — the `owner/repo` marketplace shorthand resolves to github.com ONLY
|
||||
* (verified against the CLI: it rejects `host/owner/repo` outright and ignores
|
||||
* GH_HOST / GITHUB_HOST / CLAUDE_CODE_MARKETPLACE_URL), plus raw.githubusercontent
|
||||
* / *.github.io / `/blob/<branch>` URLs that 404 against Gitea; and (2) a stale
|
||||
* committed `openapi.yaml`, which had silently drifted a whole minor version
|
||||
* behind package.json. Pure file reads plus one in-process spec render.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
// Only git-tracked text files: keeps node_modules, client/dist and every
|
||||
// build artifact out without hand-maintaining an ignore list.
|
||||
const TRACKED_EXT = /\.(md|json|js|cjs|mjs|ts|tsx|sh|ya?ml|html|css)$/;
|
||||
|
||||
// This file states every banned pattern verbatim — in the rules below and in
|
||||
// the comments explaining them — so it must exclude itself or it fails on its
|
||||
// own source. Derived from __filename rather than hardcoded so a rename can't
|
||||
// silently re-arm the self-report.
|
||||
const SELF = path.relative(REPO_ROOT, __filename);
|
||||
|
||||
function trackedFiles() {
|
||||
// No GIT_* scrub here, deliberately: under this repo's pre-commit hook
|
||||
// GIT_INDEX_FILE points at the index being committed, so `ls-files` lints
|
||||
// exactly what is about to land. Scrubbing it would lint the previous commit
|
||||
// instead and let a newly staged violation through.
|
||||
const out = execFileSync("git", ["ls-files", "-z"], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 20_000_000,
|
||||
});
|
||||
return (
|
||||
out
|
||||
.split("\0")
|
||||
.filter((f) => f && TRACKED_EXT.test(f))
|
||||
.filter((f) => f !== SELF)
|
||||
// Dated design docs and plans record what was true when written; rewriting
|
||||
// history to satisfy a lint would be a lie about the decision record.
|
||||
.filter((f) => !f.startsWith("docs/superpowers/"))
|
||||
// Generated from server/openapi-extra/* (which IS linted): the drift test
|
||||
// below covers it, so linting both would only double-report one fix.
|
||||
.filter((f) => f !== "openapi.yaml" && !f.endsWith("package-lock.json"))
|
||||
);
|
||||
}
|
||||
|
||||
// Each rule: what to look for, and what to do instead. A `skip` pattern is
|
||||
// tested against the MATCHED TEXT ONLY, never the surrounding line — a skip
|
||||
// written against nearby prose silently never fires. Add one only when the
|
||||
// exception is visible inside the match itself.
|
||||
const RULES = [
|
||||
{
|
||||
name: "GitHub owner/repo marketplace shorthand",
|
||||
// `claude plugin marketplace add Smartgift-AI/AgentHub` — resolves to
|
||||
// github.com and fails, because this repo lives on Gitea.
|
||||
re: /plugin marketplace add\s+(?!https?:\/\/)(?!\.{0,2}\/)(?!["'$])[A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*/g,
|
||||
fix: "use the full https:// clone URL — the shorthand is GitHub-only",
|
||||
// Anthropic's own marketplace really does live on GitHub.
|
||||
skip: [/anthropics\/claude-plugins-official/],
|
||||
},
|
||||
{
|
||||
name: "GitHub URL for a Smartgift repo",
|
||||
re: /(?:https?:\/\/)?(?:www\.)?github\.com\/[Ss]martgift[\w-]*\/[\w.-]+/g,
|
||||
fix: "point at git.smartgift.io.vn",
|
||||
skip: [],
|
||||
},
|
||||
{
|
||||
name: "raw.githubusercontent.com",
|
||||
// Our repos are not on GitHub, so every raw URL 404s. Same-origin paths or
|
||||
// a Gitea /raw/branch/<ref>/ URL instead.
|
||||
re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g,
|
||||
fix: "use a same-origin path, or a Gitea /raw/branch/<ref>/ URL",
|
||||
skip: [],
|
||||
},
|
||||
{
|
||||
name: "GitHub Pages host for a Smartgift site",
|
||||
re: /[\w-]*smartgift[\w-]*\.github\.io/gi,
|
||||
fix: "there is no GitHub Pages site — link the Gitea docs directory",
|
||||
skip: [],
|
||||
},
|
||||
{
|
||||
name: "GitHub /blob/<branch>/ path syntax on a Gitea URL",
|
||||
// Gitea serves files at /src/branch/<ref>/, never /blob/<ref>/.
|
||||
re: /git\.smartgift\.io\.vn\/[^\s"')\]]*\/blob\/[\w.-]+\//g,
|
||||
fix: "Gitea serves files at /src/branch/<ref>/",
|
||||
skip: [],
|
||||
},
|
||||
{
|
||||
name: "retired repository name",
|
||||
// Gitea 301-redirects the old name, so these never fail loudly — they just
|
||||
// rot until someone turns the redirect off.
|
||||
re: /Claude-Code-Monitor|Claude-Code-Agent-Monitor/g,
|
||||
fix: "the repository is Smartgift-AI/AgentHub",
|
||||
skip: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe("repo links and generated files", () => {
|
||||
const files = trackedFiles();
|
||||
|
||||
it("finds tracked text files to lint", () => {
|
||||
assert.ok(files.length > 50, `expected a populated file list, got ${files.length}`);
|
||||
});
|
||||
|
||||
for (const rule of RULES) {
|
||||
it(`no ${rule.name}`, () => {
|
||||
const hits = [];
|
||||
for (const rel of files) {
|
||||
const text = fs.readFileSync(path.join(REPO_ROOT, rel), "utf8");
|
||||
// A fresh regex per file: the /g rules carry lastIndex between calls.
|
||||
for (const m of text.matchAll(new RegExp(rule.re.source, rule.re.flags))) {
|
||||
if (rule.skip.some((s) => s.test(m[0]))) continue;
|
||||
const line = text.slice(0, m.index).split("\n").length;
|
||||
hits.push(`${rel}:${line}: ${m[0].trim()}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(hits, [], `${rule.fix}\n ${hits.join("\n ")}`);
|
||||
});
|
||||
}
|
||||
|
||||
it("committed openapi.yaml matches the generator output", () => {
|
||||
const { render, OUT } = require("../../scripts/generate-openapi-yaml");
|
||||
assert.equal(
|
||||
fs.readFileSync(OUT, "utf8"),
|
||||
render().text,
|
||||
"openapi.yaml is stale — run `npm run openapi:yaml` and commit the result"
|
||||
);
|
||||
});
|
||||
});
|
||||
+7
-4
@@ -25,7 +25,7 @@ function loadOrCreateVapidKeys() {
|
||||
const vapidKeys = loadOrCreateVapidKeys();
|
||||
|
||||
webpush.setVapidDetails(
|
||||
"https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||
"https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
|
||||
vapidKeys.publicKey,
|
||||
vapidKeys.privateKey
|
||||
);
|
||||
@@ -94,9 +94,12 @@ async function sendPushToAll(db, title, body) {
|
||||
const payload = JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
icon: "https://raw.githubusercontent.com/Smartgift-AI/Claude-Code-Monitor/main/client/public/favicon.ico",
|
||||
badge:
|
||||
"https://raw.githubusercontent.com/Smartgift-AI/Claude-Code-Monitor/main/client/public/favicon.ico",
|
||||
// Same-origin path, resolved by the service worker against its own scope
|
||||
// (client/public/sw.js spreads these straight into showNotification). An
|
||||
// absolute forge/raw URL would 404 — this repo is self-hosted on Gitea and
|
||||
// ships favicon.svg, not favicon.ico.
|
||||
icon: "/favicon.svg",
|
||||
badge: "/favicon.svg",
|
||||
silent: false,
|
||||
sound: "default",
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ const { execFile } = require("child_process");
|
||||
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
// This build tracks its OWN repository only: origin points at
|
||||
// git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor. "upstream" is
|
||||
// git.smartgift.io.vn/Smartgift-AI/AgentHub. "upstream" is
|
||||
// deliberately absent from the priority list — a stray upstream remote must
|
||||
// never make the update checker report commits from somebody else's repo.
|
||||
const REMOTE_PRIORITY = ["origin"];
|
||||
|
||||
@@ -40,10 +40,7 @@ const schemas = {
|
||||
description:
|
||||
"Distinct, non-empty session working directories (the `cwd` column), sorted ascending. Powers the cwd filter dropdown.",
|
||||
items: { type: "string" },
|
||||
example: [
|
||||
"/Users/son/WebstormProjects/Claude-Code-Agent-Monitor",
|
||||
"/Users/son/code/another-project",
|
||||
],
|
||||
example: ["/Users/dev/AgentHub", "/Users/son/code/another-project"],
|
||||
},
|
||||
sources: {
|
||||
type: "array",
|
||||
@@ -527,10 +524,7 @@ const paths = {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SessionsFacetsResponse" },
|
||||
example: {
|
||||
cwds: [
|
||||
"/Users/son/WebstormProjects/Claude-Code-Agent-Monitor",
|
||||
"/Users/son/code/another-project",
|
||||
],
|
||||
cwds: ["/Users/dev/AgentHub", "/Users/son/code/another-project"],
|
||||
sources: ["local", "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -97,7 +97,7 @@ const GENERIC_WEBHOOK_TARGET_EXAMPLE = {
|
||||
|
||||
const SETTINGS_INFO_EXAMPLE = {
|
||||
db: {
|
||||
path: "/Users/son/WebstormProjects/Claude-Code-Agent-Monitor/server/data/agent-monitor.db",
|
||||
path: "/Users/dev/AgentHub/server/data/agent-monitor.db",
|
||||
size: 48922624,
|
||||
counts: {
|
||||
sessions: 412,
|
||||
@@ -351,7 +351,7 @@ const IMPORT_UPLOAD_EXAMPLE = {
|
||||
|
||||
const UPDATE_STATUS_AVAILABLE_EXAMPLE = {
|
||||
git_repo: true,
|
||||
repo_root: "/Users/son/WebstormProjects/Claude-Code-Agent-Monitor",
|
||||
repo_root: "/Users/dev/AgentHub",
|
||||
update_available: true,
|
||||
commits_behind: 4,
|
||||
remote_ref: "origin/master",
|
||||
@@ -363,7 +363,7 @@ const UPDATE_STATUS_AVAILABLE_EXAMPLE = {
|
||||
|
||||
const UPDATE_STATUS_UP_TO_DATE_EXAMPLE = {
|
||||
git_repo: true,
|
||||
repo_root: "/Users/son/WebstormProjects/Claude-Code-Agent-Monitor",
|
||||
repo_root: "/Users/dev/AgentHub",
|
||||
update_available: false,
|
||||
commits_behind: 0,
|
||||
remote_ref: "origin/master",
|
||||
|
||||
@@ -43,7 +43,7 @@ const exampleSession = {
|
||||
id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
name: "Refactor pricing route + add cost endpoint",
|
||||
status: "active",
|
||||
cwd: "/Users/son/WebstormProjects/Claude-Code-Agent-Monitor",
|
||||
cwd: "/Users/dev/AgentHub",
|
||||
model: "claude-opus-4-20250514",
|
||||
started_at: "2026-06-25T14:02:11.004Z",
|
||||
ended_at: null,
|
||||
@@ -60,7 +60,7 @@ const exampleCompletedSession = {
|
||||
id: "1a2b3c4d-5e6f-4071-8293-a4b5c6d7e8f9",
|
||||
name: "Fix flaky transcript pagination test",
|
||||
status: "completed",
|
||||
cwd: "/Users/son/WebstormProjects/Claude-Code-Agent-Monitor",
|
||||
cwd: "/Users/dev/AgentHub",
|
||||
model: "claude-sonnet-4-20250514",
|
||||
started_at: "2026-06-24T09:12:00.000Z",
|
||||
ended_at: "2026-06-24T09:48:32.501Z",
|
||||
@@ -176,7 +176,7 @@ const paths = {
|
||||
example: {
|
||||
id: "b7f3a2c1-4e5d-4a8b-9c2f-1d6e8a0b3c4d",
|
||||
name: "Refactor pricing route + add cost endpoint",
|
||||
cwd: "/Users/son/WebstormProjects/Claude-Code-Agent-Monitor",
|
||||
cwd: "/Users/dev/AgentHub",
|
||||
model: "claude-opus-4-20250514",
|
||||
metadata: { source: "hook", git_branch: "feat/spend-budgets" },
|
||||
},
|
||||
|
||||
@@ -875,7 +875,7 @@ const paths = {
|
||||
items: [
|
||||
{
|
||||
kind: "dashboard",
|
||||
path: "/Users/dev/Claude-Code-Agent-Monitor",
|
||||
path: "/Users/dev/AgentHub",
|
||||
label: "Dashboard server",
|
||||
},
|
||||
{ kind: "home", path: "/Users/dev", label: "Home" },
|
||||
|
||||
Reference in New Issue
Block a user