@graphit/cli 0.2.379 → 0.2.384
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 +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/bin/graphit +1 -1
- package/bin/graphit.ps1 +1 -1
- package/dist/commands/ds/sql-history.d.ts +29 -0
- package/dist/commands/ds/sql-history.js +69 -0
- package/dist/commands/ds/sql-history.js.map +1 -0
- package/dist/commands/ds.js +3 -0
- package/dist/commands/ds.js.map +1 -1
- package/dist/commands/kb-batch.js +1 -1
- package/dist/commands/kb-batch.js.map +1 -1
- package/dist/commands/kb.js +2 -21
- package/dist/commands/kb.js.map +1 -1
- package/dist/skill-guard.js +0 -2
- package/dist/skill-guard.js.map +1 -1
- package/hooks/hooks.json +12 -1
- package/package.json +1 -1
- package/scripts/fixtures/kb-cutover-project293.json +13 -0
- package/scripts/plugin-status/claude-install.mjs +215 -0
- package/scripts/plugin-status/cloud-refresh.mjs +97 -0
- package/scripts/plugin-status/common.mjs +107 -0
- package/scripts/plugin-status/hook-io.mjs +200 -0
- package/scripts/plugin-status.mjs +68 -505
- package/scripts/show-query-result.mjs +23 -4
- package/scripts/sync-plugin-marketplace.sh +18 -0
- package/scripts/verb-policy-source.json +49 -30
- package/scripts/verify-kb-cutover.mjs +134 -0
- package/skills/graphit/SKILL.md +7 -7
- package/skills/graphit/VERSION.json +1 -1
- package/skills/graphit/references/governance.md +2 -2
- package/skills/graphit/references/kb-actions.md +5 -5
- package/skills/graphit/references/kb-discovery.md +1 -1
- package/skills/graphit/references/metric-families.md +1 -1
- package/skills/graphit/references/onboarding.md +1 -1
- package/skills/graphit/references/page-load.md +38 -0
- package/skills/graphit/references/runtime.md +5 -5
- package/skills/graphit/references/semantic-authoring.md +5 -5
- package/skills/graphit/references/share.md +4 -4
- package/skills/graphit-build/SKILL.md +1 -1
- package/skills/graphit-explore/SKILL.md +1 -1
- package/skills/graphit-share/SKILL.md +5 -5
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Feature #1042: the Claude Code installed-plugin inspection and `--repair`, split
|
|
2
|
+
// out of plugin-status.mjs unchanged. The entry script passes its plugin root and
|
|
3
|
+
// REPAIRABLE_BUNDLE_ENTRIES in (the list stays in the entry file, where a test reads it).
|
|
4
|
+
|
|
5
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
CLAUDE_MARKETPLACE_NAME,
|
|
11
|
+
CLAUDE_PLUGIN_NAME,
|
|
12
|
+
PLUGIN_ID,
|
|
13
|
+
atomicWriteJson,
|
|
14
|
+
isStrictSemver,
|
|
15
|
+
isWithinDirectory,
|
|
16
|
+
readFrontmatterVersion,
|
|
17
|
+
sanitizePathPart,
|
|
18
|
+
tryReadJson,
|
|
19
|
+
versionIsBehind,
|
|
20
|
+
} from "./common.mjs";
|
|
21
|
+
|
|
22
|
+
export function claudePluginsRoot() {
|
|
23
|
+
return process.env.CLAUDE_PLUGINS_ROOT ?? join(homedir(), ".claude", "plugins");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function claudeInstalledPluginsPath(root = claudePluginsRoot()) {
|
|
27
|
+
return join(root, "installed_plugins.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function claudeVersionedCachePath(version, root = claudePluginsRoot()) {
|
|
31
|
+
return join(
|
|
32
|
+
root,
|
|
33
|
+
"cache",
|
|
34
|
+
CLAUDE_MARKETPLACE_NAME,
|
|
35
|
+
CLAUDE_PLUGIN_NAME,
|
|
36
|
+
sanitizePathPart(version),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readClaudeInstalledFile(root = claudePluginsRoot()) {
|
|
41
|
+
const path = claudeInstalledPluginsPath(root);
|
|
42
|
+
const data = tryReadJson(path);
|
|
43
|
+
if (!data || typeof data !== "object") return null;
|
|
44
|
+
return { path, data };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function graphitInstallEntries(installedData) {
|
|
48
|
+
const plugins = installedData.plugins && typeof installedData.plugins === "object"
|
|
49
|
+
? installedData.plugins
|
|
50
|
+
: installedData;
|
|
51
|
+
const raw = plugins?.[PLUGIN_ID];
|
|
52
|
+
if (!raw) return [];
|
|
53
|
+
if (Array.isArray(raw)) {
|
|
54
|
+
return raw
|
|
55
|
+
.filter((entry) => entry && typeof entry === "object")
|
|
56
|
+
.map((entry) => ({ entry }));
|
|
57
|
+
}
|
|
58
|
+
if (typeof raw === "object") {
|
|
59
|
+
return [{ entry: raw }];
|
|
60
|
+
}
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pluginEntryVersion(entry) {
|
|
65
|
+
if (!entry || typeof entry !== "object") return null;
|
|
66
|
+
const installPath = typeof entry.installPath === "string" ? entry.installPath : null;
|
|
67
|
+
const skillVersion = installPath
|
|
68
|
+
? readFrontmatterVersion(join(installPath, "skills", "graphit", "SKILL.md"))
|
|
69
|
+
: null;
|
|
70
|
+
const versionJson = installPath
|
|
71
|
+
? tryReadJson(join(installPath, "skills", "graphit", "VERSION.json"))?.version
|
|
72
|
+
: null;
|
|
73
|
+
return {
|
|
74
|
+
installPath,
|
|
75
|
+
entryVersion: typeof entry.version === "string" ? entry.version : null,
|
|
76
|
+
skillVersion,
|
|
77
|
+
versionJson: versionJson ?? null,
|
|
78
|
+
effectiveVersion: skillVersion ?? versionJson ?? (typeof entry.version === "string" ? entry.version : null),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function repairSourceLooksCurrent(sourceRoot, currentVersion) {
|
|
83
|
+
if (!isStrictSemver(currentVersion)) return false;
|
|
84
|
+
const skillVersion = readFrontmatterVersion(join(sourceRoot, "skills", "graphit", "SKILL.md"));
|
|
85
|
+
const versionJson = tryReadJson(join(sourceRoot, "skills", "graphit", "VERSION.json"))?.version;
|
|
86
|
+
const claudePlugin = tryReadJson(join(sourceRoot, ".claude-plugin", "plugin.json"))?.version;
|
|
87
|
+
return skillVersion === currentVersion && versionJson === currentVersion && claudePlugin === currentVersion;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function copyRepairableBundle(sourceRoot, targetRoot, repairableEntries) {
|
|
91
|
+
if (!isWithinDirectory(targetRoot, claudePluginsRoot())) {
|
|
92
|
+
throw new Error(`Refusing to repair outside Claude plugins directory: ${targetRoot}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const tmpRoot = `${targetRoot}.tmp-${process.pid}-${Date.now()}`;
|
|
96
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
97
|
+
mkdirSync(tmpRoot, { recursive: true });
|
|
98
|
+
try {
|
|
99
|
+
for (const entry of repairableEntries) {
|
|
100
|
+
const source = join(sourceRoot, entry);
|
|
101
|
+
if (!existsSync(source)) continue;
|
|
102
|
+
cpSync(source, join(tmpRoot, entry), {
|
|
103
|
+
recursive: true,
|
|
104
|
+
force: true,
|
|
105
|
+
verbatimSymlinks: true,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
chmodSync(join(tmpRoot, "bin", "graphit"), 0o755);
|
|
110
|
+
} catch {
|
|
111
|
+
// Windows or missing wrapper; integrity checks catch real packaging drift.
|
|
112
|
+
}
|
|
113
|
+
mkdirSync(dirname(targetRoot), { recursive: true });
|
|
114
|
+
rmSync(targetRoot, { recursive: true, force: true });
|
|
115
|
+
renameSync(tmpRoot, targetRoot);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function updateClaudeInstalledEntriesOnDisk(installedFile, staleRecords, targetPath, currentVersion) {
|
|
123
|
+
const now = new Date().toISOString();
|
|
124
|
+
for (const record of staleRecords) {
|
|
125
|
+
record.entry.installPath = targetPath;
|
|
126
|
+
record.entry.version = currentVersion;
|
|
127
|
+
record.entry.lastUpdated = now;
|
|
128
|
+
if (typeof record.entry.installedAt !== "string") {
|
|
129
|
+
record.entry.installedAt = now;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
atomicWriteJson(installedFile.path, installedFile.data);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function inspectClaudeInstalledPlugin(currentVersion, repair, { pluginRoot, repairableEntries }) {
|
|
136
|
+
const installedFile = readClaudeInstalledFile();
|
|
137
|
+
if (!installedFile) return { findings: [], metadata: [] };
|
|
138
|
+
|
|
139
|
+
const metadata = [];
|
|
140
|
+
const entries = graphitInstallEntries(installedFile.data);
|
|
141
|
+
if (entries.length === 0) return { findings: [], metadata };
|
|
142
|
+
|
|
143
|
+
const staleRecords = [];
|
|
144
|
+
for (const record of entries) {
|
|
145
|
+
const versionInfo = pluginEntryVersion(record.entry);
|
|
146
|
+
metadata.push({
|
|
147
|
+
label: `Claude Code installed ${PLUGIN_ID}`,
|
|
148
|
+
version: versionInfo.effectiveVersion,
|
|
149
|
+
installPath: versionInfo.installPath,
|
|
150
|
+
entryVersion: versionInfo.entryVersion,
|
|
151
|
+
skillVersion: versionInfo.skillVersion,
|
|
152
|
+
scope: record.entry.scope ?? "user",
|
|
153
|
+
projectPath: record.entry.projectPath ?? null,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (
|
|
157
|
+
versionIsBehind(versionInfo.effectiveVersion, currentVersion) ||
|
|
158
|
+
versionIsBehind(versionInfo.entryVersion, currentVersion)
|
|
159
|
+
) {
|
|
160
|
+
staleRecords.push({ ...record, versionInfo });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (staleRecords.length === 0) return { findings: [], metadata };
|
|
165
|
+
|
|
166
|
+
const staleVersions = [...new Set(staleRecords.map((record) => record.versionInfo.effectiveVersion ?? "unknown"))].join(", ");
|
|
167
|
+
const targetPath = claudeVersionedCachePath(currentVersion);
|
|
168
|
+
|
|
169
|
+
if (!repair) {
|
|
170
|
+
return {
|
|
171
|
+
metadata,
|
|
172
|
+
findings: [
|
|
173
|
+
{
|
|
174
|
+
type: "claude-installed-plugin-stale",
|
|
175
|
+
message: `Claude Code installed Graphit plugin is stale (${staleVersions}), expected ${currentVersion}`,
|
|
176
|
+
remediation: "Run `graphit plugin status --repair`, then restart/reload Claude Code. If you prefer the native plugin manager, run `/plugin marketplace update graphit-plugin`, then `/plugin update graphit@graphit-plugin`, then restart/reload.",
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
if (!repairSourceLooksCurrent(pluginRoot, currentVersion)) {
|
|
184
|
+
throw new Error(`current npm/plugin bundle at ${pluginRoot} is missing synced ${currentVersion} plugin files`);
|
|
185
|
+
}
|
|
186
|
+
const targetSkillVersion = readFrontmatterVersion(join(targetPath, "skills", "graphit", "SKILL.md"));
|
|
187
|
+
if (targetSkillVersion !== currentVersion) {
|
|
188
|
+
copyRepairableBundle(pluginRoot, targetPath, repairableEntries);
|
|
189
|
+
}
|
|
190
|
+
updateClaudeInstalledEntriesOnDisk(installedFile, staleRecords, targetPath, currentVersion);
|
|
191
|
+
return {
|
|
192
|
+
metadata,
|
|
193
|
+
findings: [
|
|
194
|
+
{
|
|
195
|
+
type: "claude-installed-plugin-repaired",
|
|
196
|
+
message: `Claude Code installed Graphit plugin was stale (${staleVersions}); repaired disk cache to ${currentVersion}`,
|
|
197
|
+
remediation: `Restart/reload Claude Code so it loads the repaired plugin cache at ${targetPath}. The already-running session may still have the old SKILL.md in memory until then.`,
|
|
198
|
+
repaired: true,
|
|
199
|
+
installPath: targetPath,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
} catch (error) {
|
|
204
|
+
return {
|
|
205
|
+
metadata,
|
|
206
|
+
findings: [
|
|
207
|
+
{
|
|
208
|
+
type: "claude-installed-plugin-stale",
|
|
209
|
+
message: `Claude Code installed Graphit plugin is stale (${staleVersions}), expected ${currentVersion}; auto-repair failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
210
|
+
remediation: "Run `/plugin marketplace update graphit-plugin`, then `/plugin update graphit@graphit-plugin`, then restart/reload Claude Code. `graphit plugin status --repair` can repair from the npm bundle once the local files are writable.",
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Feature #1042: on a cloud session's fresh start, update the installed plugin to the
|
|
2
|
+
// latest release before the session uses it. Cloud environments reuse a setup-script
|
|
3
|
+
// snapshot for about a week, and the agent itself may not run `claude plugin update`
|
|
4
|
+
// (auto mode denies it) or `/reload-plugins` (user-only); a hook is configured code, and
|
|
5
|
+
// a cloud VM is thrown away after the run, so an update there touches nothing that lasts.
|
|
6
|
+
// Laptops never reach this module: their sessions keep today's "update available" notice.
|
|
7
|
+
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { compareVersions, isStrictSemver, tryParseJson, tryReadJson } from "./common.mjs";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_BUDGET_MS = 90_000;
|
|
14
|
+
|
|
15
|
+
export function cloudRefreshApplies({ hookEvent, source, env }) {
|
|
16
|
+
return (
|
|
17
|
+
hookEvent === "SessionStart" &&
|
|
18
|
+
source === "startup" &&
|
|
19
|
+
env.CLAUDE_CODE_REMOTE === "true" &&
|
|
20
|
+
env.GRAPHIT_PLUGIN_SELF_UPDATE !== "0"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Claude Code installs a marketplace plugin at <plugins>/cache/<marketplace>/<plugin>/<version>
|
|
25
|
+
// and keeps the marketplace clone at <plugins>/marketplaces/<marketplace>. Anything else
|
|
26
|
+
// (a --plugin-dir checkout, the npm package) is not ours to update.
|
|
27
|
+
function pluginCacheLayout(pluginRoot) {
|
|
28
|
+
const pluginDir = dirname(resolve(pluginRoot));
|
|
29
|
+
const marketplaceDir = dirname(pluginDir);
|
|
30
|
+
const cacheDir = dirname(marketplaceDir);
|
|
31
|
+
if (basename(cacheDir) !== "cache") return null;
|
|
32
|
+
return { pluginsRoot: dirname(cacheDir), marketplace: basename(marketplaceDir), plugin: basename(pluginDir) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function marketplaceVersion({ pluginsRoot, marketplace, plugin }) {
|
|
36
|
+
const clone = join(pluginsRoot, "marketplaces", marketplace, ".claude-plugin");
|
|
37
|
+
const plugins = tryReadJson(join(clone, "marketplace.json"))?.plugins;
|
|
38
|
+
const listed = Array.isArray(plugins) ? plugins.find((entry) => entry?.name === plugin) : null;
|
|
39
|
+
return listed?.version ?? tryReadJson(join(clone, "plugin.json"))?.version ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function installedVersionFromList(stdout, id) {
|
|
43
|
+
const parsed = tryParseJson(stdout);
|
|
44
|
+
const entries = Array.isArray(parsed) ? parsed : parsed?.plugins ?? parsed?.installed ?? [];
|
|
45
|
+
if (!Array.isArray(entries)) return null;
|
|
46
|
+
const entry = entries.find((item) => item?.id === id || `${item?.name}@${item?.marketplace}` === id);
|
|
47
|
+
return isStrictSemver(entry?.version) ? entry.version : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function runClaude(args, deadline) {
|
|
51
|
+
const remaining = deadline - Date.now();
|
|
52
|
+
if (remaining <= 0) return Promise.resolve({ error: "timeout" });
|
|
53
|
+
return new Promise((done) => {
|
|
54
|
+
execFile("claude", args, { timeout: remaining, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
55
|
+
if (!error) return done({ stdout });
|
|
56
|
+
if (error.code === "ENOENT") return done({ error: "claude not found" });
|
|
57
|
+
if (error.killed) return done({ error: "timeout" });
|
|
58
|
+
const lastLine = String(stderr ?? "").trim().split("\n").pop();
|
|
59
|
+
return done({ error: lastLine || `exit ${error.code}` });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Returns the one line the hook prints at a cloud startup. Never throws: every failure
|
|
65
|
+
// leaves the installed version in place and says why.
|
|
66
|
+
export async function refreshPluginInCloud({ pluginRoot, installed, fetchLatest, env }) {
|
|
67
|
+
const line = (text) => `Graphit plugin: ${text}`;
|
|
68
|
+
if (!isStrictSemver(installed)) return line("update skipped: the installed version is unreadable");
|
|
69
|
+
|
|
70
|
+
// Stage 1 - the cheap gate: nothing newer published, nothing to do.
|
|
71
|
+
const latest = await fetchLatest();
|
|
72
|
+
if (!isStrictSemver(latest)) return line(`update failed, running ${installed}: could not read the published version`);
|
|
73
|
+
if (compareVersions(latest, installed) <= 0) return line(`current (${installed})`);
|
|
74
|
+
|
|
75
|
+
const layout = pluginCacheLayout(pluginRoot);
|
|
76
|
+
if (!layout) return line(`update skipped, running ${installed}: not a plugin-cache install`);
|
|
77
|
+
const id = `${layout.plugin}@${layout.marketplace}`;
|
|
78
|
+
const budget = Number(env.GRAPHIT_PLUGIN_REFRESH_BUDGET_MS);
|
|
79
|
+
const deadline = Date.now() + (budget > 0 ? budget : DEFAULT_BUDGET_MS);
|
|
80
|
+
|
|
81
|
+
// Stage 2 - npm publishes before the marketplace; update only what the marketplace offers.
|
|
82
|
+
const catalog = await runClaude(["plugin", "marketplace", "update", layout.marketplace], deadline);
|
|
83
|
+
if (catalog.error) return line(`update failed, running ${installed}: ${catalog.error}`);
|
|
84
|
+
const offered = marketplaceVersion(layout);
|
|
85
|
+
if (isStrictSemver(offered) && compareVersions(offered, installed) <= 0) {
|
|
86
|
+
return line(`marketplace still at ${offered} (npm has ${latest})`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Stage 3 - the update itself, then the version now on disk.
|
|
90
|
+
const update = await runClaude(["plugin", "update", id], deadline);
|
|
91
|
+
if (update.error) return line(`update failed, running ${installed}: ${update.error}`);
|
|
92
|
+
const list = await runClaude(["plugin", "list", "--json"], deadline);
|
|
93
|
+
const after = list.error ? null : installedVersionFromList(list.stdout, id);
|
|
94
|
+
if (!after) return line(`updated ${installed} -> unknown on disk (could not read the installed version)`);
|
|
95
|
+
if (compareVersions(after, installed) <= 0) return line(`update failed, running ${installed}: version unchanged after update`);
|
|
96
|
+
return line(`updated ${installed} -> ${after} on disk`);
|
|
97
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Feature #1042: shared constants and pure helpers, split out of plugin-status.mjs
|
|
2
|
+
// (855 lines) before the cloud refresh lands. No module-level state and no
|
|
3
|
+
// process.argv/env reads, so every sibling module can import it.
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
7
|
+
|
|
8
|
+
export const PACKAGE_NAME = "@graphit/cli";
|
|
9
|
+
export const PLUGIN_ID = "graphit@graphit-plugin";
|
|
10
|
+
export const CLAUDE_MARKETPLACE_NAME = "graphit-plugin";
|
|
11
|
+
export const CLAUDE_PLUGIN_NAME = "graphit";
|
|
12
|
+
// Project #246: version-cache schema. Bump when the cache shape changes; older
|
|
13
|
+
// caches are then treated as a miss and refetched (self-healing).
|
|
14
|
+
export const CACHE_SCHEMA_VERSION = 1;
|
|
15
|
+
// Strict semver. The wrapper enforces the same shape (SEC-6); keep them in sync.
|
|
16
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
17
|
+
|
|
18
|
+
export function readJson(path) {
|
|
19
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function tryReadJson(path) {
|
|
23
|
+
try {
|
|
24
|
+
if (!existsSync(path)) return null;
|
|
25
|
+
return readJson(path);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function tryParseJson(value) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(value);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readFrontmatterVersion(path) {
|
|
40
|
+
try {
|
|
41
|
+
const content = readFileSync(path, "utf-8");
|
|
42
|
+
return content.match(/^skill_version:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1] ?? null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isStrictSemver(value) {
|
|
49
|
+
return typeof value === "string" && SEMVER_RE.test(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function compareVersions(left, right) {
|
|
53
|
+
const leftParts = left.split(".").map(Number);
|
|
54
|
+
const rightParts = right.split(".").map(Number);
|
|
55
|
+
|
|
56
|
+
for (let index = 0; index < 3; index += 1) {
|
|
57
|
+
const leftPart = leftParts[index] ?? 0;
|
|
58
|
+
const rightPart = rightParts[index] ?? 0;
|
|
59
|
+
if (leftPart > rightPart) return 1;
|
|
60
|
+
if (leftPart < rightPart) return -1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function versionIsBehind(version, currentVersion) {
|
|
67
|
+
if (!isStrictSemver(currentVersion)) return false;
|
|
68
|
+
if (!version || !isStrictSemver(version)) return true;
|
|
69
|
+
return compareVersions(version, currentVersion) < 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function sanitizePathPart(value) {
|
|
73
|
+
return value.replace(/[^a-zA-Z0-9\-_.]/g, "-");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isWithinDirectory(child, parent) {
|
|
77
|
+
const parentResolved = resolve(parent);
|
|
78
|
+
const childResolved = resolve(child);
|
|
79
|
+
const rel = relative(parentResolved, childResolved);
|
|
80
|
+
return rel === "" || (!!rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function realpathSafe(path) {
|
|
84
|
+
try {
|
|
85
|
+
return realpathSync(path);
|
|
86
|
+
} catch {
|
|
87
|
+
return path;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// SEC-4: atomic write (tmp + rename) so concurrent SessionStart writers can never
|
|
92
|
+
// leave a torn/partial file for the wrapper or a later read.
|
|
93
|
+
export function atomicWriteJson(path, obj) {
|
|
94
|
+
const tmpPath = `${path}.${process.pid}.tmp`;
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
97
|
+
writeFileSync(tmpPath, JSON.stringify(obj), "utf-8");
|
|
98
|
+
renameSync(tmpPath, path);
|
|
99
|
+
} catch {
|
|
100
|
+
// Writes here must never break startup hooks; clean up any temp file.
|
|
101
|
+
try {
|
|
102
|
+
rmSync(tmpPath, { force: true });
|
|
103
|
+
} catch {
|
|
104
|
+
// ignore
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Feature #1042: the hook-only paths of plugin-status.mjs, split out unchanged
|
|
2
|
+
// except that stdin is read once by the entry script and passed in (it can be read
|
|
3
|
+
// only once, and the cloud refresh also needs the SessionStart payload).
|
|
4
|
+
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { delimiter, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CACHE_SCHEMA_VERSION,
|
|
10
|
+
atomicWriteJson,
|
|
11
|
+
compareVersions,
|
|
12
|
+
realpathSafe,
|
|
13
|
+
tryReadJson,
|
|
14
|
+
} from "./common.mjs";
|
|
15
|
+
|
|
16
|
+
// Nudge throttle (US-3.2 / US-4.1: ~once/day). State is graphit-owned and atomic.
|
|
17
|
+
const NUDGE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
function nudgeStatePath(cacheRoot) {
|
|
20
|
+
return join(cacheRoot, "nudge-state.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readNudgeState(cacheRoot) {
|
|
24
|
+
const state = tryReadJson(nudgeStatePath(cacheRoot));
|
|
25
|
+
if (state && state.schemaVersion === CACHE_SCHEMA_VERSION && state.nudges && typeof state.nudges === "object") {
|
|
26
|
+
return state;
|
|
27
|
+
}
|
|
28
|
+
return { schemaVersion: CACHE_SCHEMA_VERSION, nudges: {} };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function nudgeAllowed(state, key) {
|
|
32
|
+
const last = state.nudges[key];
|
|
33
|
+
return typeof last !== "number" || Date.now() - last >= NUDGE_TTL_MS;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function persistNudges(cacheRoot, state, keys) {
|
|
37
|
+
if (keys.length === 0) return;
|
|
38
|
+
for (const key of keys) state.nudges[key] = Date.now();
|
|
39
|
+
atomicWriteJson(nudgeStatePath(cacheRoot), state);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 4.1 (SEC-5): find a `graphit` on PATH that is NOT this plugin's bundled wrapper -
|
|
43
|
+
// i.e. a legacy global @graphit/cli install. Because plugin executables are
|
|
44
|
+
// appended to PATH (Phase 2 finding), any global install shadows the wrapper, so
|
|
45
|
+
// its mere presence is the migration trigger. Returns the first such path or null.
|
|
46
|
+
function detectForeignGraphit(pluginRoot) {
|
|
47
|
+
const pathValue = process.env.PATH ?? "";
|
|
48
|
+
if (!pathValue) return null;
|
|
49
|
+
const names = process.platform === "win32" ? ["graphit.cmd", "graphit.ps1", "graphit"] : ["graphit"];
|
|
50
|
+
// On Windows the plugin ships graphit.cmd / graphit.ps1 alongside the POSIX
|
|
51
|
+
// `graphit`, so excluding only `bin/graphit` would leave our own .cmd/.ps1
|
|
52
|
+
// wrappers looking like a foreign global. Exclude every bundled wrapper name.
|
|
53
|
+
const ownWrapperReals = new Set(
|
|
54
|
+
names.map((name) => realpathSafe(join(pluginRoot, "bin", name))),
|
|
55
|
+
);
|
|
56
|
+
for (const dir of pathValue.split(delimiter).filter(Boolean)) {
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
const candidate = join(dir, name);
|
|
59
|
+
if (!existsSync(candidate)) continue;
|
|
60
|
+
const real = realpathSafe(candidate);
|
|
61
|
+
if (ownWrapperReals.has(real)) continue; // our own bundled wrapper
|
|
62
|
+
if (/[/\\]plugins[/\\]/.test(real)) continue; // a plugin-cache wrapper
|
|
63
|
+
return candidate;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// `raw` is the hook's stdin (null when it could not be read). A UserPromptSubmit run
|
|
70
|
+
// continues only for a Graphit update/status question; every other run continues.
|
|
71
|
+
export function shouldRunForPrompt(hookEvent, raw, parsed) {
|
|
72
|
+
if (hookEvent !== "UserPromptSubmit") return true;
|
|
73
|
+
if (raw === null) return false;
|
|
74
|
+
|
|
75
|
+
const prompt = String(parsed?.prompt ?? parsed?.user_prompt ?? raw).toLowerCase();
|
|
76
|
+
return (
|
|
77
|
+
prompt.includes("graphit") &&
|
|
78
|
+
/\b(update|version|status|doctor|plugin|stale|staleness)\b/.test(prompt)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Feature #743: stamp a per-session marker so the CLI can tell whether the plugin
|
|
83
|
+
// actually loaded THIS session. This SessionStart hook only runs when Claude Code
|
|
84
|
+
// loaded the plugin at session start - a mid-session install never runs it, so the
|
|
85
|
+
// marker is absent and the CLI can warn. The SessionStart payload carries session_id
|
|
86
|
+
// (the same value as the Bash tool's CLAUDE_CODE_SESSION_ID - both from getSessionId()),
|
|
87
|
+
// and the marker is keyed by it so concurrent sessions never clobber each other.
|
|
88
|
+
export function stampSessionMarker(hookEvent, parsed, cacheRoot) {
|
|
89
|
+
if (hookEvent !== "SessionStart") return;
|
|
90
|
+
const sessionId = String(parsed?.session_id ?? "").trim();
|
|
91
|
+
// The id becomes a filename, so accept only an id-shaped token (path-safety).
|
|
92
|
+
if (!/^[A-Za-z0-9._-]{1,128}$/.test(sessionId)) return;
|
|
93
|
+
const dir = join(cacheRoot, "sessions");
|
|
94
|
+
try {
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
// Feature #765: marker CONTENT is a capability signal. The CLI arms the
|
|
97
|
+
// skill-invocation tripwire only when the marker declares skill-ack; legacy
|
|
98
|
+
// markers (bare timestamp) keep it dormant, so a new npm CLI over an old
|
|
99
|
+
// bundle can never block a session that cannot stamp.
|
|
100
|
+
//
|
|
101
|
+
// This is an implication, NOT an equivalence: within the plugin-bundle install
|
|
102
|
+
// path the hook, SKILL.md, and CLI bin ship together, so caps => the loaded
|
|
103
|
+
// SKILL.md teaches the attestation. It does NOT hold for legacy copied
|
|
104
|
+
// snapshots (`graphit setup --legacy-copy`, see src/skill-version.ts), where a
|
|
105
|
+
// stale copied SKILL.md can be loaded alongside a current plugin hook. Those
|
|
106
|
+
// sessions are why every block message carries the "already invoked?" escape.
|
|
107
|
+
writeFileSync(
|
|
108
|
+
join(dir, sessionId),
|
|
109
|
+
JSON.stringify({ caps: ["skill-ack"], ts: Date.now() }),
|
|
110
|
+
"utf-8",
|
|
111
|
+
);
|
|
112
|
+
pruneOldSessionMarkers(dir);
|
|
113
|
+
} catch {
|
|
114
|
+
// Best-effort: a missing marker only costs an extra (harmless) CLI warning.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Feature #765: the `--skill-ack` session attestation is stamped in src/skill-guard.ts
|
|
119
|
+
// (`stampSkillAck`), called from the `plugin status` command BEFORE it delegates here.
|
|
120
|
+
// It deliberately does NOT live in this script: stamping must not depend on a second
|
|
121
|
+
// artifact, or a package missing this file could leave the gate armed with no way to
|
|
122
|
+
// clear it. A failed write is reported there rather than swallowed.
|
|
123
|
+
|
|
124
|
+
function pruneOldSessionMarkers(dir) {
|
|
125
|
+
const cutoff = Date.now() - 48 * 60 * 60 * 1000;
|
|
126
|
+
try {
|
|
127
|
+
for (const name of readdirSync(dir)) {
|
|
128
|
+
try {
|
|
129
|
+
if (statSync(join(dir, name)).mtimeMs < cutoff) unlinkSync(join(dir, name));
|
|
130
|
+
} catch {
|
|
131
|
+
/* skip an unreadable entry */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
/* dir missing or unreadable - nothing to prune */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Feature #1042: the SessionStart hook's Claude Code timeout is 120 s so the cloud
|
|
140
|
+
// refresh fits, which also applies to a laptop's startup. Every other hook run arms
|
|
141
|
+
// this cutoff, so a laptop can never wait longer than the 5 s it did before. unref'd:
|
|
142
|
+
// it never keeps a finished run alive.
|
|
143
|
+
export function armCutoff(ms, exit = (code) => process.exit(code)) {
|
|
144
|
+
const timer = setTimeout(() => exit(0), ms);
|
|
145
|
+
timer.unref();
|
|
146
|
+
return timer;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Build the SessionStart agent nudges. Unlike --json (full diagnostics), the hook
|
|
150
|
+
// surfaces only user-actionable, throttled items: the migration nudge (4.1), the
|
|
151
|
+
// thin-layer /plugin update nudge (4.3), and legacy copied-skill cleanup. It does
|
|
152
|
+
// NOT emit founder-only publish drift, nor the npm-install-g binary message - under
|
|
153
|
+
// the plugin model the binary auto-updates via npx, so the bundle nudge is correct.
|
|
154
|
+
export function buildHookNudges(status, { cacheRoot, pluginRoot, suppressPluginUpdate = false }) {
|
|
155
|
+
const state = readNudgeState(cacheRoot);
|
|
156
|
+
const lines = [];
|
|
157
|
+
const shown = [];
|
|
158
|
+
|
|
159
|
+
// 4.1 Migration: a global @graphit/cli shadows the plugin's npx-backed wrapper.
|
|
160
|
+
const foreignGraphit = detectForeignGraphit(pluginRoot);
|
|
161
|
+
if (foreignGraphit && nudgeAllowed(state, "legacy-global")) {
|
|
162
|
+
lines.push(
|
|
163
|
+
`- A legacy global \`graphit\` (@graphit/cli) at ${foreignGraphit} shadows the plugin's bundled CLI ` +
|
|
164
|
+
"(plugin executables are appended to PATH, so a global install wins). The plugin now delivers the CLI " +
|
|
165
|
+
"automatically via npx - tell the user once and offer to run `npm uninstall -g @graphit/cli` so it takes over.",
|
|
166
|
+
);
|
|
167
|
+
shown.push("legacy-global");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 4.3 Thin-layer: the plugin bundle is behind npm latest (the binary itself
|
|
171
|
+
// auto-updates via npx; only the skill/hook/wrapper bundle needs a manual update).
|
|
172
|
+
// Feature #1042: a cloud startup already updated (or reported why not), and its
|
|
173
|
+
// agent cannot run /plugin update anyway, so the notice is left out there.
|
|
174
|
+
if (
|
|
175
|
+
!suppressPluginUpdate &&
|
|
176
|
+
status.latestVersion &&
|
|
177
|
+
compareVersions(status.latestVersion, status.currentVersion) > 0 &&
|
|
178
|
+
nudgeAllowed(state, "plugin-update")
|
|
179
|
+
) {
|
|
180
|
+
lines.push(
|
|
181
|
+
`- A newer Graphit plugin is available (${status.currentVersion} -> ${status.latestVersion}). ` +
|
|
182
|
+
"Tell the user once to update it with `/plugin marketplace update graphit-plugin`, then " +
|
|
183
|
+
"`/plugin update graphit@graphit-plugin`, then restart Claude Code; the CLI itself already runs the latest release.",
|
|
184
|
+
);
|
|
185
|
+
shown.push("plugin-update");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Legacy copied skill snapshots are real user-side issues - surface until removed.
|
|
189
|
+
for (const finding of status.findings) {
|
|
190
|
+
if (finding.type === "legacy-copied-skill-present" || finding.type === "copied-skill-stale") {
|
|
191
|
+
lines.push(`- ${finding.message}. ${finding.remediation}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (lines.length === 0) return null;
|
|
196
|
+
return {
|
|
197
|
+
context: ["Graphit plugin status check found actionable update/version information.", ...lines].join("\n"),
|
|
198
|
+
persist: () => persistNudges(cacheRoot, state, shown),
|
|
199
|
+
};
|
|
200
|
+
}
|