@graphit/cli 0.1.51 → 0.1.106
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +3 -2
- package/README.md +99 -0
- package/bin/graphit +45 -0
- package/bin/graphit.cmd +8 -0
- package/bin/graphit.ps1 +38 -0
- package/dist/api/client.js +11 -0
- package/dist/api/client.js.map +1 -1
- package/dist/commands/auth.js +14 -6
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/ds-poll.d.ts +8 -0
- package/dist/commands/ds-poll.js +53 -0
- package/dist/commands/ds-poll.js.map +1 -0
- package/dist/commands/ds.js +113 -57
- package/dist/commands/ds.js.map +1 -1
- package/dist/commands/governance.js +15 -11
- package/dist/commands/governance.js.map +1 -1
- package/dist/commands/kb.js +14 -4
- package/dist/commands/kb.js.map +1 -1
- package/dist/commands/query.js +9 -7
- package/dist/commands/query.js.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/output/format.d.ts +1 -0
- package/dist/output/format.js +6 -0
- package/dist/output/format.js.map +1 -1
- package/dist/output/table.d.ts +1 -0
- package/dist/output/table.js +20 -3
- package/dist/output/table.js.map +1 -1
- package/dist/skill-version.d.ts +12 -0
- package/dist/skill-version.js +21 -0
- package/dist/skill-version.js.map +1 -1
- package/dist/update-check.d.ts +19 -0
- package/dist/update-check.js +105 -12
- package/dist/update-check.js.map +1 -1
- package/hooks/hooks.json +13 -0
- package/package.json +11 -3
- package/scripts/block-legacy-setup.mjs +90 -0
- package/scripts/generate-commands-doc.mjs +182 -0
- package/scripts/plugin-status.mjs +179 -29
- package/scripts/sync-plugin-version.mjs +26 -4
- package/skills/graphit/SKILL.md +172 -433
- package/skills/graphit/VERSION.json +1 -1
- package/skills/graphit/cursor/README.md +11 -0
- package/skills/graphit/graphit.mdc +3 -1
- package/skills/graphit/references/dashboard-planning.md +1 -7
- package/skills/graphit/references/data-sources.md +30 -1
- package/skills/graphit/references/filters-advanced.md +61 -0
- package/skills/graphit/references/filters.md +27 -93
- package/skills/graphit/references/governance.md +49 -66
- package/skills/graphit/references/graphit-style.md +6 -18
- package/skills/graphit/references/kb-actions.md +44 -52
- package/skills/graphit/references/kb-discovery.md +10 -13
- package/skills/graphit/references/kb-structure.md +10 -8
- package/skills/graphit/references/kb-traversal.md +16 -16
- package/skills/graphit/references/operations.md +115 -0
- package/skills/graphit/references/parameterized-metrics.md +32 -44
- package/skills/graphit/references/runtime.md +172 -0
- package/skills/graphit/references/sql-reference.md +5 -42
- package/skills/graphit/references/domain-lenses.md +0 -116
package/hooks/hooks.json
CHANGED
|
@@ -26,6 +26,19 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
+
"PreToolUse": [
|
|
30
|
+
{
|
|
31
|
+
"matcher": "Bash",
|
|
32
|
+
"hooks": [
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"command": "node",
|
|
36
|
+
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/block-legacy-setup.mjs", "--hook", "PreToolUse"],
|
|
37
|
+
"timeout": 5
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
],
|
|
29
42
|
"PostToolUse": [
|
|
30
43
|
{
|
|
31
44
|
"matcher": "Bash",
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graphit/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.106",
|
|
4
4
|
"description": "Graphit CLI - Build custom dashboards from any AI coding assistant",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/omry-graphit/graphit.git",
|
|
8
|
+
"directory": "cli"
|
|
9
|
+
},
|
|
5
10
|
"type": "module",
|
|
6
11
|
"bin": {
|
|
7
12
|
"graphit": "./dist/index.js"
|
|
@@ -10,15 +15,18 @@
|
|
|
10
15
|
"build": "npm run sync:version && tsc",
|
|
11
16
|
"dev": "tsx src/index.ts",
|
|
12
17
|
"lint": "npm run check:version && tsc --noEmit",
|
|
13
|
-
"test": "npm run build && node --test test/*.test.mjs",
|
|
18
|
+
"test": "npm run build && node scripts/generate-commands-doc.mjs --check && node --test test/*.test.mjs",
|
|
14
19
|
"sync:version": "node scripts/sync-plugin-version.mjs",
|
|
15
|
-
"check:version": "node scripts/sync-plugin-version.mjs --check"
|
|
20
|
+
"check:version": "node scripts/sync-plugin-version.mjs --check",
|
|
21
|
+
"gen:commands": "node scripts/generate-commands-doc.mjs",
|
|
22
|
+
"check:commands": "node scripts/generate-commands-doc.mjs --check"
|
|
16
23
|
},
|
|
17
24
|
"engines": {
|
|
18
25
|
"node": ">=18"
|
|
19
26
|
},
|
|
20
27
|
"files": [
|
|
21
28
|
"dist",
|
|
29
|
+
"bin",
|
|
22
30
|
"skills",
|
|
23
31
|
"hooks",
|
|
24
32
|
"scripts",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse(Bash) hook: block re-introducing a legacy copied install of the
|
|
3
|
+
// Graphit skill (`graphit setup ... --legacy-copy`) for Claude Code or Codex
|
|
4
|
+
// while the plugin bundle is the active source. The plugin bundle is the single
|
|
5
|
+
// source of truth on those two platforms; a copied snapshot drifts and shadows
|
|
6
|
+
// it, which is exactly the failure mode `graphit setup --remove-legacy-copies`
|
|
7
|
+
// exists to undo. When the about-to-run Bash command matches, this prints a
|
|
8
|
+
// remediation message and exits 2 (block). Every other command exits 0 (allow).
|
|
9
|
+
//
|
|
10
|
+
// Claude-Code-only progressive enhancement: Codex has no hook system, so this
|
|
11
|
+
// invariant is restated as prose in the skill rather than enforced there. The
|
|
12
|
+
// hook is purely additive - on the platforms that lack it, nothing changes.
|
|
13
|
+
//
|
|
14
|
+
// Defensive by contract: malformed input, a missing field, or any thrown error
|
|
15
|
+
// resolves to exit 0 (allow). The hook must never block an unrelated command or
|
|
16
|
+
// crash the agent's Bash flow on a parse error.
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { dirname } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
// Mirrors the plugin-root resolution used by plugin-status.mjs so the script is
|
|
23
|
+
// portable across the Claude Code / Codex / standalone env layouts. Unused for
|
|
24
|
+
// the block decision today, but kept consistent so future remediation can point
|
|
25
|
+
// at bundle-relative paths without re-deriving the root.
|
|
26
|
+
const pluginRoot =
|
|
27
|
+
process.env.CLAUDE_PLUGIN_ROOT ??
|
|
28
|
+
process.env.CODEX_PLUGIN_ROOT ??
|
|
29
|
+
process.env.GRAPHIT_PLUGIN_ROOT ??
|
|
30
|
+
dirname(dirname(fileURLToPath(import.meta.url)));
|
|
31
|
+
|
|
32
|
+
function readStdin() {
|
|
33
|
+
try {
|
|
34
|
+
return readFileSync(0, "utf-8");
|
|
35
|
+
} catch {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function tryParseJson(value) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(value);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Match `graphit setup` (or local dev `node dist/index.js setup`) combined with
|
|
49
|
+
// the `--legacy-copy` flag, in either order, tolerating env prefixes
|
|
50
|
+
// (GRAPHIT_API_URL=... graphit setup ...). Anchored on both the setup command
|
|
51
|
+
// and the legacy-copy flag so plain `graphit setup` and unrelated commands that
|
|
52
|
+
// merely mention "legacy" are never blocked.
|
|
53
|
+
function isLegacyCopySetup(command) {
|
|
54
|
+
if (!command) return false;
|
|
55
|
+
const isSetup = /(?:graphit|index\.js)\s+setup\b/.test(command);
|
|
56
|
+
const hasLegacyCopyFlag = /--legacy-copy\b/.test(command);
|
|
57
|
+
return isSetup && hasLegacyCopyFlag;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const REMEDIATION =
|
|
61
|
+
"[Graphit plugin] Blocked `graphit setup --legacy-copy`. On Claude Code and " +
|
|
62
|
+
"Codex the Graphit plugin bundle is the active source of truth for the skill; " +
|
|
63
|
+
"a legacy copied snapshot drifts and shadows it. Do not re-introduce a copied " +
|
|
64
|
+
"install. If a stale copy is already present, remove it with " +
|
|
65
|
+
"`graphit setup --remove-legacy-copies` after confirming the plugin is " +
|
|
66
|
+
"installed. Plugin root: " +
|
|
67
|
+
pluginRoot;
|
|
68
|
+
|
|
69
|
+
function main() {
|
|
70
|
+
const raw = readStdin();
|
|
71
|
+
if (!raw) return 0;
|
|
72
|
+
|
|
73
|
+
const payload = tryParseJson(raw);
|
|
74
|
+
if (!payload || payload.tool_name !== "Bash") return 0;
|
|
75
|
+
|
|
76
|
+
const command = payload.tool_input?.command ?? "";
|
|
77
|
+
if (!isLegacyCopySetup(command)) return 0;
|
|
78
|
+
|
|
79
|
+
process.stderr.write(`${REMEDIATION}\n`);
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let exitCode = 0;
|
|
84
|
+
try {
|
|
85
|
+
exitCode = main();
|
|
86
|
+
} catch {
|
|
87
|
+
// Never block an unrelated command or crash the agent's Bash flow on error.
|
|
88
|
+
exitCode = 0;
|
|
89
|
+
}
|
|
90
|
+
process.exit(exitCode);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Generates the command reference injected into cli/skills/graphit/SKILL.md
|
|
3
|
+
// between the COMMANDS:START / COMMANDS:END markers, from Commander metadata.
|
|
4
|
+
// The CLI is the single source of truth - this never hand-maintains command facts.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/generate-commands-doc.mjs # write the table into SKILL.md
|
|
7
|
+
// node scripts/generate-commands-doc.mjs --check # exit 1 if SKILL.md is stale (CI gate)
|
|
8
|
+
// node scripts/generate-commands-doc.mjs --stdout # print the block only (debug)
|
|
9
|
+
//
|
|
10
|
+
// Requires a prior `npm run build` (it imports the compiled registrars from dist/).
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
|
+
import { Command } from "commander";
|
|
16
|
+
|
|
17
|
+
const checkOnly = process.argv.includes("--check");
|
|
18
|
+
const toStdout = process.argv.includes("--stdout");
|
|
19
|
+
|
|
20
|
+
const cliRoot =
|
|
21
|
+
process.env.GRAPHIT_CLI_ROOT ??
|
|
22
|
+
join(fileURLToPath(new URL(".", import.meta.url)), "..");
|
|
23
|
+
const distCommandsDir = join(cliRoot, "dist", "commands");
|
|
24
|
+
const skillPath = join(cliRoot, "skills", "graphit", "SKILL.md");
|
|
25
|
+
|
|
26
|
+
const START = "<!-- COMMANDS:START -->";
|
|
27
|
+
const END = "<!-- COMMANDS:END -->";
|
|
28
|
+
|
|
29
|
+
// Mirrors index.ts registration order; groups not listed are appended alphabetically.
|
|
30
|
+
const GROUP_ORDER = [
|
|
31
|
+
"auth", "kb", "query", "metadata", "ds", "dashboard",
|
|
32
|
+
"connector", "governance", "team", "plugin", "setup",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Assemble a throwaway program by importing only the dist/ registrars (never
|
|
36
|
+
// dist/index.js - that fires the update banner + program.parse() at load).
|
|
37
|
+
async function buildProgram() {
|
|
38
|
+
if (!existsSync(distCommandsDir)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`dist/commands not found at ${distCommandsDir} - run "npm run build" first`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const program = new Command();
|
|
44
|
+
program.name("graphit");
|
|
45
|
+
const files = readdirSync(distCommandsDir)
|
|
46
|
+
.filter((f) => f.endsWith(".js"))
|
|
47
|
+
.sort(); // deterministic import order
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
const mod = await import(pathToFileURL(join(distCommandsDir, file)).href);
|
|
50
|
+
for (const [name, value] of Object.entries(mod)) {
|
|
51
|
+
if (typeof value === "function" && /^register/.test(name)) {
|
|
52
|
+
value(program);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return program;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function esc(text) {
|
|
60
|
+
return String(text ?? "").replace(/\r?\n/g, " ").replace(/\|/g, "\\|").trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatArgs(cmd) {
|
|
64
|
+
return (cmd.registeredArguments ?? [])
|
|
65
|
+
.map((arg) => {
|
|
66
|
+
const name = arg.name() + (arg.variadic ? "..." : "");
|
|
67
|
+
return arg.required ? `<${name}>` : `[${name}]`;
|
|
68
|
+
})
|
|
69
|
+
.join(" ");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatFlags(cmd) {
|
|
73
|
+
return (cmd.options ?? [])
|
|
74
|
+
.map((opt) => opt.long ?? opt.flags)
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.join(" ");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A command earns its own row if it is a leaf OR it has its own args/options
|
|
80
|
+
// (e.g. `query <sql>` both takes an arg and has the `metadata` sibling group).
|
|
81
|
+
function hasOwnInvocation(cmd) {
|
|
82
|
+
return (
|
|
83
|
+
(cmd.registeredArguments ?? []).length > 0 ||
|
|
84
|
+
(cmd.options ?? []).length > 0
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function collectRows(cmd, path, rows) {
|
|
89
|
+
const subs = cmd.commands ?? [];
|
|
90
|
+
if (subs.length === 0 || hasOwnInvocation(cmd)) {
|
|
91
|
+
rows.push({
|
|
92
|
+
command: `${path}${formatArgs(cmd) ? ` ${formatArgs(cmd)}` : ""}`,
|
|
93
|
+
description: cmd.description(),
|
|
94
|
+
flags: formatFlags(cmd),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
for (const sub of subs) {
|
|
98
|
+
collectRows(sub, `${path} ${sub.name()}`.trim(), rows);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function orderGroups(names) {
|
|
103
|
+
return [...names].sort((a, b) => {
|
|
104
|
+
const ia = GROUP_ORDER.indexOf(a);
|
|
105
|
+
const ib = GROUP_ORDER.indexOf(b);
|
|
106
|
+
if (ia === -1 && ib === -1) return a.localeCompare(b);
|
|
107
|
+
if (ia === -1) return 1;
|
|
108
|
+
if (ib === -1) return -1;
|
|
109
|
+
return ia - ib;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderBlock(program) {
|
|
114
|
+
const groups = new Map();
|
|
115
|
+
for (const top of program.commands) {
|
|
116
|
+
const rows = [];
|
|
117
|
+
collectRows(top, top.name(), rows);
|
|
118
|
+
groups.set(top.name(), { description: top.description(), rows });
|
|
119
|
+
}
|
|
120
|
+
const lines = [
|
|
121
|
+
"",
|
|
122
|
+
"_Generated from the CLI by `npm run gen:commands` - do not hand-edit between the markers. Run `graphit <cmd> --help` for exact flag values and descriptions._",
|
|
123
|
+
"",
|
|
124
|
+
];
|
|
125
|
+
for (const name of orderGroups(groups.keys())) {
|
|
126
|
+
const { description, rows } = groups.get(name);
|
|
127
|
+
lines.push(`**${name}**${description ? ` - ${esc(description)}` : ""}`);
|
|
128
|
+
for (const row of rows) {
|
|
129
|
+
const parts = [`- \`${row.command}\``];
|
|
130
|
+
if (row.description) parts.push(esc(row.description));
|
|
131
|
+
if (row.flags) parts.push(`\`${row.flags}\``);
|
|
132
|
+
lines.push(parts.join(" - "));
|
|
133
|
+
}
|
|
134
|
+
lines.push("");
|
|
135
|
+
}
|
|
136
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function injectBetweenMarkers(content, block) {
|
|
140
|
+
const start = content.indexOf(START);
|
|
141
|
+
const end = content.indexOf(END);
|
|
142
|
+
if (start === -1 || end === -1) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Missing ${START} / ${END} markers in ${skillPath} - add them before generating.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (end < start) {
|
|
148
|
+
throw new Error(`${END} appears before ${START} in ${skillPath}`);
|
|
149
|
+
}
|
|
150
|
+
const before = content.slice(0, start + START.length);
|
|
151
|
+
const after = content.slice(end);
|
|
152
|
+
return `${before}\n${block}\n${after}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const program = await buildProgram();
|
|
156
|
+
const block = renderBlock(program);
|
|
157
|
+
|
|
158
|
+
if (toStdout) {
|
|
159
|
+
process.stdout.write(block);
|
|
160
|
+
process.exit(0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Normalize CRLF before compare so Windows checkouts don't report perpetual drift.
|
|
164
|
+
const current = existsSync(skillPath)
|
|
165
|
+
? readFileSync(skillPath, "utf-8").replace(/\r\n/g, "\n")
|
|
166
|
+
: "";
|
|
167
|
+
const next = injectBetweenMarkers(current, block);
|
|
168
|
+
|
|
169
|
+
if (current === next) {
|
|
170
|
+
console.error("Command table in SKILL.md is in sync.");
|
|
171
|
+
process.exit(0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (checkOnly) {
|
|
175
|
+
console.error(
|
|
176
|
+
"SKILL.md command table is stale - run: npm run gen:commands",
|
|
177
|
+
);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
writeFileSync(skillPath, next, "utf-8");
|
|
182
|
+
console.error(`Updated command table in ${skillPath}`);
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
5
|
+
import { delimiter, dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
// Project #246: GRAPHIT_REGISTRY_URL lets tests point at a local server and
|
|
9
|
+
// supports private/enterprise registries; defaults to the public npm registry.
|
|
10
|
+
const REGISTRY_URL =
|
|
11
|
+
process.env.GRAPHIT_REGISTRY_URL ?? "https://registry.npmjs.org/@graphit/cli/latest";
|
|
12
|
+
const PACKAGE_NAME = "@graphit/cli";
|
|
13
|
+
// Project #246: ~4h TTL (was 24h) so a single-source bump reaches returning
|
|
14
|
+
// sessions within one window (ARCH-2, PERF-2).
|
|
15
|
+
const TTL_MS = 4 * 60 * 60 * 1000;
|
|
16
|
+
// Project #246: version-cache schema. Bump when the cache shape changes; older
|
|
17
|
+
// caches are then treated as a miss and refetched (self-healing).
|
|
18
|
+
const CACHE_SCHEMA_VERSION = 1;
|
|
19
|
+
// Strict semver. The wrapper enforces the same shape (SEC-6); keep them in sync.
|
|
20
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
10
21
|
const args = new Set(process.argv.slice(2));
|
|
11
22
|
const hookIndex = process.argv.indexOf("--hook");
|
|
12
23
|
const hookEvent = hookIndex >= 0 ? process.argv[hookIndex + 1] : null;
|
|
@@ -15,11 +26,12 @@ const pluginRoot =
|
|
|
15
26
|
process.env.CODEX_PLUGIN_ROOT ??
|
|
16
27
|
process.env.GRAPHIT_PLUGIN_ROOT ??
|
|
17
28
|
dirname(dirname(fileURLToPath(import.meta.url)));
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
// Project #246: the version cache must be shared between this SessionStart hook
|
|
30
|
+
// and the bin/graphit wrapper (agent Bash tool). CLAUDE_PLUGIN_DATA is set
|
|
31
|
+
// per-plugin/per-context by Claude Code and is NOT guaranteed identical across
|
|
32
|
+
// those two contexts, so it is intentionally excluded here - a fixed, graphit-
|
|
33
|
+
// owned dir guarantees both sides agree. GRAPHIT_PLUGIN_DATA overrides.
|
|
34
|
+
const cacheRoot = process.env.GRAPHIT_PLUGIN_DATA ?? join(homedir(), ".graphit");
|
|
23
35
|
|
|
24
36
|
function readJson(path) {
|
|
25
37
|
return JSON.parse(readFileSync(path, "utf-8"));
|
|
@@ -89,34 +101,128 @@ function readCache(path) {
|
|
|
89
101
|
}
|
|
90
102
|
}
|
|
91
103
|
|
|
92
|
-
function
|
|
104
|
+
function isStrictSemver(value) {
|
|
105
|
+
return typeof value === "string" && SEMVER_RE.test(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// SEC-6: the cache file is user-writable. Treat it as untrusted - require the
|
|
109
|
+
// expected schema version, package name, a strict-semver version, and a numeric
|
|
110
|
+
// timestamp. Anything else is treated as a miss (refetch / fall back).
|
|
111
|
+
function isValidVersionCache(cache) {
|
|
112
|
+
return (
|
|
113
|
+
!!cache &&
|
|
114
|
+
cache.schemaVersion === CACHE_SCHEMA_VERSION &&
|
|
115
|
+
cache.packageName === PACKAGE_NAME &&
|
|
116
|
+
isStrictSemver(cache.latestVersion) &&
|
|
117
|
+
typeof cache.checkedAt === "number"
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// SEC-4: atomic write (tmp + rename) so concurrent SessionStart writers can never
|
|
122
|
+
// leave a torn/partial file for the wrapper or a later read.
|
|
123
|
+
function atomicWriteJson(path, obj) {
|
|
124
|
+
const tmpPath = `${path}.${process.pid}.tmp`;
|
|
93
125
|
try {
|
|
94
126
|
mkdirSync(dirname(path), { recursive: true });
|
|
95
|
-
writeFileSync(
|
|
127
|
+
writeFileSync(tmpPath, JSON.stringify(obj), "utf-8");
|
|
128
|
+
renameSync(tmpPath, path);
|
|
129
|
+
} catch {
|
|
130
|
+
// Writes here must never break startup hooks; clean up any temp file.
|
|
131
|
+
try {
|
|
132
|
+
rmSync(tmpPath, { force: true });
|
|
133
|
+
} catch {
|
|
134
|
+
// ignore
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function writeCache(path, latestVersion) {
|
|
140
|
+
atomicWriteJson(path, {
|
|
141
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
142
|
+
packageName: PACKAGE_NAME,
|
|
143
|
+
latestVersion,
|
|
144
|
+
checkedAt: Date.now(),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function realpathSafe(path) {
|
|
149
|
+
try {
|
|
150
|
+
return realpathSync(path);
|
|
96
151
|
} catch {
|
|
97
|
-
|
|
152
|
+
return path;
|
|
98
153
|
}
|
|
99
154
|
}
|
|
100
155
|
|
|
156
|
+
// 4.1 (SEC-5): find a `graphit` on PATH that is NOT this plugin's bundled wrapper -
|
|
157
|
+
// i.e. a legacy global @graphit/cli install. Because plugin executables are
|
|
158
|
+
// appended to PATH (Phase 2 finding), any global install shadows the wrapper, so
|
|
159
|
+
// its mere presence is the migration trigger. Returns the first such path or null.
|
|
160
|
+
function detectForeignGraphit() {
|
|
161
|
+
const pathValue = process.env.PATH ?? "";
|
|
162
|
+
if (!pathValue) return null;
|
|
163
|
+
const names = process.platform === "win32" ? ["graphit.cmd", "graphit.ps1", "graphit"] : ["graphit"];
|
|
164
|
+
// On Windows the plugin ships graphit.cmd / graphit.ps1 alongside the POSIX
|
|
165
|
+
// `graphit`, so excluding only `bin/graphit` would leave our own .cmd/.ps1
|
|
166
|
+
// wrappers looking like a foreign global. Exclude every bundled wrapper name.
|
|
167
|
+
const ownWrapperReals = new Set(
|
|
168
|
+
names.map((name) => realpathSafe(join(pluginRoot, "bin", name))),
|
|
169
|
+
);
|
|
170
|
+
for (const dir of pathValue.split(delimiter).filter(Boolean)) {
|
|
171
|
+
for (const name of names) {
|
|
172
|
+
const candidate = join(dir, name);
|
|
173
|
+
if (!existsSync(candidate)) continue;
|
|
174
|
+
const real = realpathSafe(candidate);
|
|
175
|
+
if (ownWrapperReals.has(real)) continue; // our own bundled wrapper
|
|
176
|
+
if (/[/\\]plugins[/\\]/.test(real)) continue; // a plugin-cache wrapper
|
|
177
|
+
return candidate;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Nudge throttle (US-3.2 / US-4.1: ~once/day). State is graphit-owned and atomic.
|
|
184
|
+
const NUDGE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
185
|
+
const nudgeStatePath = join(cacheRoot, "nudge-state.json");
|
|
186
|
+
|
|
187
|
+
function readNudgeState() {
|
|
188
|
+
const state = tryReadJson(nudgeStatePath);
|
|
189
|
+
if (state && state.schemaVersion === CACHE_SCHEMA_VERSION && state.nudges && typeof state.nudges === "object") {
|
|
190
|
+
return state;
|
|
191
|
+
}
|
|
192
|
+
return { schemaVersion: CACHE_SCHEMA_VERSION, nudges: {} };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function nudgeAllowed(state, key) {
|
|
196
|
+
const last = state.nudges[key];
|
|
197
|
+
return typeof last !== "number" || Date.now() - last >= NUDGE_TTL_MS;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function persistNudges(state, keys) {
|
|
201
|
+
if (keys.length === 0) return;
|
|
202
|
+
for (const key of keys) state.nudges[key] = Date.now();
|
|
203
|
+
atomicWriteJson(nudgeStatePath, state);
|
|
204
|
+
}
|
|
205
|
+
|
|
101
206
|
async function getLatestVersion(currentVersion) {
|
|
102
207
|
if (args.has("--skip-network")) return null;
|
|
103
208
|
|
|
104
209
|
const cachePath = join(cacheRoot, "plugin-status.json");
|
|
105
210
|
const cache = readCache(cachePath);
|
|
106
|
-
|
|
107
|
-
|
|
211
|
+
const cachedVersion = isValidVersionCache(cache) ? cache.latestVersion : null;
|
|
212
|
+
if (cachedVersion && Date.now() - cache.checkedAt < TTL_MS) {
|
|
213
|
+
return cachedVersion;
|
|
108
214
|
}
|
|
109
215
|
|
|
110
216
|
try {
|
|
111
217
|
const response = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(1500) });
|
|
112
|
-
if (!response.ok) return
|
|
218
|
+
if (!response.ok) return cachedVersion;
|
|
113
219
|
const data = await response.json();
|
|
114
|
-
if (data &&
|
|
115
|
-
writeCache(cachePath,
|
|
220
|
+
if (data && isStrictSemver(data.version)) {
|
|
221
|
+
writeCache(cachePath, data.version);
|
|
116
222
|
return data.version;
|
|
117
223
|
}
|
|
118
224
|
} catch {
|
|
119
|
-
return
|
|
225
|
+
return cachedVersion;
|
|
120
226
|
}
|
|
121
227
|
|
|
122
228
|
return currentVersion;
|
|
@@ -165,7 +271,9 @@ async function collectStatus() {
|
|
|
165
271
|
[".claude-plugin/marketplace.json plugin", marketplacePlugin?.version],
|
|
166
272
|
["skills/graphit/VERSION.json", tryReadJson(join(pluginRoot, "skills", "graphit", "VERSION.json"))?.version],
|
|
167
273
|
["skills/graphit/SKILL.md", readFrontmatterVersion(join(pluginRoot, "skills", "graphit", "SKILL.md"))],
|
|
168
|
-
|
|
274
|
+
// The Cursor source file (skills/graphit/graphit.mdc) is frozen and no longer
|
|
275
|
+
// version-stamped, so it is intentionally excluded here. The copied-Cursor-home
|
|
276
|
+
// staleness checks below still run for existing Cursor users.
|
|
169
277
|
];
|
|
170
278
|
|
|
171
279
|
for (const [label, version] of versionChecks) {
|
|
@@ -204,7 +312,7 @@ async function collectStatus() {
|
|
|
204
312
|
findings.push({
|
|
205
313
|
type: "package-update",
|
|
206
314
|
message: `@graphit/cli update available: ${currentVersion} -> ${latestVersion}`,
|
|
207
|
-
remediation: "
|
|
315
|
+
remediation: "This update is for the npm CLI binary (@graphit/cli), a separate artifact from the skill bundle. Update the binary with `npm install -g @graphit/cli@latest` (not `npm update -g`, which respects the original semver range and can miss the latest). If `graphit` resolves to a custom npm prefix (compare `command -v graphit` with `npm prefix -g`), reinstall to that prefix with `npm install -g @graphit/cli@latest --prefix <dir>` (<dir> = the parent of the bin dir holding graphit). The Claude Code/Codex skill bundle updates separately via `claude plugin update graphit@graphit-plugin` and does NOT update the binary.",
|
|
208
316
|
});
|
|
209
317
|
}
|
|
210
318
|
|
|
@@ -251,13 +359,54 @@ function formatPlain(status) {
|
|
|
251
359
|
].join("\n");
|
|
252
360
|
}
|
|
253
361
|
|
|
254
|
-
|
|
255
|
-
|
|
362
|
+
// Build the SessionStart agent nudges. Unlike --json (full diagnostics), the hook
|
|
363
|
+
// surfaces only user-actionable, throttled items: the migration nudge (4.1), the
|
|
364
|
+
// thin-layer /plugin update nudge (4.3), and legacy copied-skill cleanup. It does
|
|
365
|
+
// NOT emit founder-only publish drift, nor the npm-install-g binary message - under
|
|
366
|
+
// the plugin model the binary auto-updates via npx, so the bundle nudge is correct.
|
|
367
|
+
function buildHookNudges(status) {
|
|
368
|
+
const state = readNudgeState();
|
|
369
|
+
const lines = [];
|
|
370
|
+
const shown = [];
|
|
371
|
+
|
|
372
|
+
// 4.1 Migration: a global @graphit/cli shadows the plugin's npx-backed wrapper.
|
|
373
|
+
const foreignGraphit = detectForeignGraphit();
|
|
374
|
+
if (foreignGraphit && nudgeAllowed(state, "legacy-global")) {
|
|
375
|
+
lines.push(
|
|
376
|
+
`- A legacy global \`graphit\` (@graphit/cli) at ${foreignGraphit} shadows the plugin's bundled CLI ` +
|
|
377
|
+
"(plugin executables are appended to PATH, so a global install wins). The plugin now delivers the CLI " +
|
|
378
|
+
"automatically via npx - tell the user once and offer to run `npm uninstall -g @graphit/cli` so it takes over.",
|
|
379
|
+
);
|
|
380
|
+
shown.push("legacy-global");
|
|
381
|
+
}
|
|
256
382
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
383
|
+
// 4.3 Thin-layer: the plugin bundle is behind npm latest (the binary itself
|
|
384
|
+
// auto-updates via npx; only the skill/hook/wrapper bundle needs a manual update).
|
|
385
|
+
if (
|
|
386
|
+
status.latestVersion &&
|
|
387
|
+
compareVersions(status.latestVersion, status.currentVersion) > 0 &&
|
|
388
|
+
nudgeAllowed(state, "plugin-update")
|
|
389
|
+
) {
|
|
390
|
+
lines.push(
|
|
391
|
+
`- A newer Graphit plugin is available (${status.currentVersion} -> ${status.latestVersion}). ` +
|
|
392
|
+
"Tell the user once to update the bundle with `/plugin update graphit@graphit-plugin`; " +
|
|
393
|
+
"the CLI binary itself updates automatically.",
|
|
394
|
+
);
|
|
395
|
+
shown.push("plugin-update");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Legacy copied skill snapshots are real user-side issues - surface until removed.
|
|
399
|
+
for (const finding of status.findings) {
|
|
400
|
+
if (finding.type === "legacy-copied-skill-present" || finding.type === "copied-skill-stale") {
|
|
401
|
+
lines.push(`- ${finding.message}. ${finding.remediation}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (lines.length === 0) return null;
|
|
406
|
+
return {
|
|
407
|
+
context: ["Graphit plugin status check found actionable update/version information.", ...lines].join("\n"),
|
|
408
|
+
persist: () => persistNudges(state, shown),
|
|
409
|
+
};
|
|
261
410
|
}
|
|
262
411
|
|
|
263
412
|
if (hookEvent && !shouldRunForPrompt()) {
|
|
@@ -269,14 +418,15 @@ const status = await collectStatus();
|
|
|
269
418
|
if (args.has("--json")) {
|
|
270
419
|
console.log(JSON.stringify(status, null, 2));
|
|
271
420
|
} else if (hookEvent) {
|
|
272
|
-
const
|
|
273
|
-
if (
|
|
421
|
+
const nudge = buildHookNudges(status);
|
|
422
|
+
if (nudge) {
|
|
274
423
|
console.log(JSON.stringify({
|
|
275
424
|
hookSpecificOutput: {
|
|
276
425
|
hookEventName: hookEvent,
|
|
277
|
-
additionalContext,
|
|
426
|
+
additionalContext: nudge.context,
|
|
278
427
|
},
|
|
279
428
|
}));
|
|
429
|
+
nudge.persist();
|
|
280
430
|
}
|
|
281
431
|
} else if (!args.has("--quiet") || status.findings.length > 0) {
|
|
282
432
|
console.log(formatPlain(status));
|
|
@@ -88,9 +88,29 @@ function updateVersionJson(path, packageName, version, changes) {
|
|
|
88
88
|
);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
// Project #246: stamp BOTH the frontmatter skill_version AND the Codex-facing npx
|
|
92
|
+
// invocation version (the concrete `npx -y @graphit/cli@<v>` form in the body) so
|
|
93
|
+
// check:version fails if either drifts from package.json. Non-CC surfaces (Codex,
|
|
94
|
+
// Cursor, terminal, CI) have no resolver hook, so the skill hands them a stamped
|
|
95
|
+
// concrete version, not a placeholder; `@<exact>` placeholders are left untouched.
|
|
96
|
+
function updateSkillFile(path, version, packageName, changes) {
|
|
92
97
|
const content = readFileSync(path, "utf-8");
|
|
93
|
-
|
|
98
|
+
const stamped = upsertFrontmatterField(content, "skill_version", version);
|
|
99
|
+
const escapedPkg = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
100
|
+
const npxVersionRe = new RegExp(`${escapedPkg}@\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?`, "g");
|
|
101
|
+
writeOrReport(path, stamped.replace(npxVersionRe, `${packageName}@${version}`), changes);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Project #246: stamp the baked floor version into the bin/graphit wrapper(s) so
|
|
105
|
+
// first-run/offline falls back to a real published version. Matches the bash
|
|
106
|
+
// `FLOOR_VERSION="x"` and PowerShell `$FloorVersion = "x"` markers.
|
|
107
|
+
function updateWrapperFloor(path, version, changes) {
|
|
108
|
+
if (!existsSync(path)) return;
|
|
109
|
+
const content = readFileSync(path, "utf-8");
|
|
110
|
+
const next = content
|
|
111
|
+
.replace(/^FLOOR_VERSION=".*"$/m, () => `FLOOR_VERSION="${version}"`)
|
|
112
|
+
.replace(/^\$FloorVersion = ".*"$/m, () => `$FloorVersion = "${version}"`);
|
|
113
|
+
writeOrReport(path, next, changes);
|
|
94
114
|
}
|
|
95
115
|
|
|
96
116
|
const packageJson = readJson(join(cliRoot, "package.json"));
|
|
@@ -102,8 +122,10 @@ updatePluginManifest(join(cliRoot, ".claude-plugin", "plugin.json"), version, ch
|
|
|
102
122
|
updatePluginManifest(join(cliRoot, ".codex-plugin", "plugin.json"), version, changes);
|
|
103
123
|
updateMarketplace(join(cliRoot, ".claude-plugin", "marketplace.json"), version, packageName, changes);
|
|
104
124
|
updateVersionJson(join(cliRoot, "skills", "graphit", "VERSION.json"), packageName, version, changes);
|
|
105
|
-
|
|
106
|
-
|
|
125
|
+
updateSkillFile(join(cliRoot, "skills", "graphit", "SKILL.md"), version, packageName, changes);
|
|
126
|
+
// graphit.mdc is the frozen Cursor mirror and is no longer stamped (Cursor unmaintained for Claude Code + Codex).
|
|
127
|
+
updateWrapperFloor(join(cliRoot, "bin", "graphit"), version, changes);
|
|
128
|
+
updateWrapperFloor(join(cliRoot, "bin", "graphit.ps1"), version, changes);
|
|
107
129
|
|
|
108
130
|
if (changes.length > 0) {
|
|
109
131
|
const rel = (path) => path.startsWith(`${cliRoot}/`) ? path.slice(cliRoot.length + 1) : path;
|