gsd-lite 0.7.6 → 0.7.7
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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/gsd-auto-update.cjs +34 -0
- package/hooks/gsd-session-init.cjs +139 -0
- package/install.js +26 -0
- package/package.json +1 -1
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"name": "gsd",
|
|
14
14
|
"source": "./",
|
|
15
15
|
"description": "AI orchestration tool — GSD management shell + Superpowers quality core. 5 commands, 4 agents, 5 workflows, MCP server, context monitoring.",
|
|
16
|
-
"version": "0.7.
|
|
16
|
+
"version": "0.7.7",
|
|
17
17
|
"keywords": [
|
|
18
18
|
"orchestration",
|
|
19
19
|
"mcp",
|
|
@@ -29,6 +29,35 @@ const LOCK_STALE_MS = 10_000;
|
|
|
29
29
|
const LOCK_RETRY_MS = 50;
|
|
30
30
|
const LOCK_MAX_RETRIES = 100;
|
|
31
31
|
|
|
32
|
+
// ── Orphan Detection ───────────────────────────────────────
|
|
33
|
+
// Mirrors gsd-session-init.cjs Phase 0. When the plugin was removed via
|
|
34
|
+
// /plugin uninstall but install.js-written state (hook files, runtime dir,
|
|
35
|
+
// settings.json) survives, getInstallMode() falls through to 'manual' and a
|
|
36
|
+
// new GitHub release would re-trigger install.js — resurrecting the plugin.
|
|
37
|
+
// Guard here so checkForUpdate is a no-op until the orphan is cleaned up
|
|
38
|
+
// (session-init Phase 0 handles the cleanup itself).
|
|
39
|
+
function isOrphan() {
|
|
40
|
+
const installModeMarker = path.join(claudeDir, 'gsd', '.install-mode');
|
|
41
|
+
let mode = null;
|
|
42
|
+
try { mode = fs.readFileSync(installModeMarker, 'utf8').trim(); } catch { /* missing → fall through */ }
|
|
43
|
+
if (mode === 'manual') return false;
|
|
44
|
+
if (mode === 'plugin') {
|
|
45
|
+
try {
|
|
46
|
+
const data = JSON.parse(fs.readFileSync(path.join(claudeDir, 'plugins', 'installed_plugins.json'), 'utf8'));
|
|
47
|
+
return !data.plugins?.['gsd@gsd'];
|
|
48
|
+
} catch { return false; }
|
|
49
|
+
}
|
|
50
|
+
// Pre-marker fallback: orphan iff every cached version has .orphaned_at.
|
|
51
|
+
const cacheBase = path.join(claudeDir, 'plugins', 'cache', 'gsd', 'gsd');
|
|
52
|
+
if (!fs.existsSync(cacheBase)) return false;
|
|
53
|
+
try {
|
|
54
|
+
const dirs = fs.readdirSync(cacheBase, { withFileTypes: true })
|
|
55
|
+
.filter(e => e.isDirectory() && /^\d+\.\d+\.\d+/.test(e.name));
|
|
56
|
+
if (dirs.length === 0) return false;
|
|
57
|
+
return dirs.every(d => fs.existsSync(path.join(cacheBase, d.name, '.orphaned_at')));
|
|
58
|
+
} catch { return false; }
|
|
59
|
+
}
|
|
60
|
+
|
|
32
61
|
// ── Main Entry ─────────────────────────────────────────────
|
|
33
62
|
async function checkForUpdate(options = {}) {
|
|
34
63
|
const {
|
|
@@ -43,6 +72,10 @@ async function checkForUpdate(options = {}) {
|
|
|
43
72
|
const installMode = getInstallMode();
|
|
44
73
|
|
|
45
74
|
try {
|
|
75
|
+
if (isOrphan()) {
|
|
76
|
+
if (verbose) console.log('Skipping update check (plugin uninstalled — orphan state)');
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
46
79
|
if (!force && shouldSkipUpdateCheck()) {
|
|
47
80
|
if (verbose) console.log('Skipping update check (dev mode or auto-update in progress)');
|
|
48
81
|
return null;
|
|
@@ -643,6 +676,7 @@ module.exports = {
|
|
|
643
676
|
compareVersions,
|
|
644
677
|
getInstallMode,
|
|
645
678
|
isDevMode,
|
|
679
|
+
isOrphan,
|
|
646
680
|
shouldCheck,
|
|
647
681
|
shouldSkipUpdateCheck,
|
|
648
682
|
validateExtractedPackage,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// GSD-Lite SessionStart hook
|
|
3
|
+
// 0. Orphan self-cleanup: if /plugin uninstall removed the plugin but left
|
|
4
|
+
// install.js-written state behind, run inline cleanup and exit.
|
|
3
5
|
// 1. Cleans up stale temp files (throttled to once/day).
|
|
4
6
|
// 2. Auto-registers statusLine in settings.json if not already configured.
|
|
5
7
|
// 3. Self-heals .mcp.json if missing.
|
|
@@ -16,6 +18,143 @@ const os = require('node:os');
|
|
|
16
18
|
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
17
19
|
const settingsPath = path.join(claudeDir, 'settings.json');
|
|
18
20
|
|
|
21
|
+
// ── Phase 0: Orphan self-cleanup ──
|
|
22
|
+
// /plugin uninstall only touches installed_plugins.json + enabledPlugins; it
|
|
23
|
+
// leaves hook scripts, settings.json registrations, the runtime dir, and the
|
|
24
|
+
// composite statusline registry in place — so hooks keep firing and Phases
|
|
25
|
+
// 2/5 below will self-heal the stale state on every session. Detect that case
|
|
26
|
+
// here and remove our footprint before any other phase runs.
|
|
27
|
+
function isOrphan() {
|
|
28
|
+
// .install-mode marker (written by install.js ≥ 0.7.7) is authoritative.
|
|
29
|
+
const installModeMarker = path.join(claudeDir, 'gsd', '.install-mode');
|
|
30
|
+
let mode = null;
|
|
31
|
+
try { mode = fs.readFileSync(installModeMarker, 'utf8').trim(); } catch { /* missing → fall through */ }
|
|
32
|
+
if (mode === 'manual') return false; // npx install never enters /plugin uninstall path
|
|
33
|
+
if (mode === 'plugin') {
|
|
34
|
+
try {
|
|
35
|
+
const data = JSON.parse(fs.readFileSync(path.join(claudeDir, 'plugins', 'installed_plugins.json'), 'utf8'));
|
|
36
|
+
return !data.plugins?.['gsd@gsd'];
|
|
37
|
+
} catch { return false; } // registry unreadable → don't assume orphan
|
|
38
|
+
}
|
|
39
|
+
// Pre-marker installs: fallback heuristic. Claude Code stamps
|
|
40
|
+
// .orphaned_at inside cache version dirs when the entry is removed from
|
|
41
|
+
// installed_plugins.json. Orphan iff every cached version has the marker.
|
|
42
|
+
const cacheBase = path.join(claudeDir, 'plugins', 'cache', 'gsd', 'gsd');
|
|
43
|
+
if (!fs.existsSync(cacheBase)) return false;
|
|
44
|
+
try {
|
|
45
|
+
const dirs = fs.readdirSync(cacheBase, { withFileTypes: true })
|
|
46
|
+
.filter(e => e.isDirectory() && /^\d+\.\d+\.\d+/.test(e.name));
|
|
47
|
+
if (dirs.length === 0) return false;
|
|
48
|
+
return dirs.every(d => fs.existsSync(path.join(cacheBase, d.name, '.orphaned_at')));
|
|
49
|
+
} catch { return false; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function atomicWriteJson(filePath, value) {
|
|
53
|
+
const tmp = filePath + `.gsd-orphan-${process.pid}-${Date.now()}.tmp`;
|
|
54
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n');
|
|
55
|
+
fs.renameSync(tmp, filePath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cleanupOrphan() {
|
|
59
|
+
// 1. Composite statusLine registry — call removeProvider BEFORE deleting
|
|
60
|
+
// the lib file it lives in.
|
|
61
|
+
try {
|
|
62
|
+
const compositeLib = path.join(claudeDir, 'hooks', 'lib', 'statusline-composite.cjs');
|
|
63
|
+
if (fs.existsSync(compositeLib)) {
|
|
64
|
+
const { removeProvider } = require(compositeLib);
|
|
65
|
+
removeProvider();
|
|
66
|
+
}
|
|
67
|
+
} catch { /* best effort */ }
|
|
68
|
+
|
|
69
|
+
// 2. settings.json — mirror uninstall.js logic.
|
|
70
|
+
try {
|
|
71
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
72
|
+
let changed = false;
|
|
73
|
+
for (const name of ['gsd', 'gsd-lite']) {
|
|
74
|
+
if (settings.mcpServers?.[name]) { delete settings.mcpServers[name]; changed = true; }
|
|
75
|
+
const pluginKey = `${name}@${name}`;
|
|
76
|
+
if (settings.enabledPlugins?.[pluginKey]) { delete settings.enabledPlugins[pluginKey]; changed = true; }
|
|
77
|
+
if (settings.extraKnownMarketplaces?.[name]) { delete settings.extraKnownMarketplaces[name]; changed = true; }
|
|
78
|
+
}
|
|
79
|
+
if (settings.extraKnownMarketplaces && Object.keys(settings.extraKnownMarketplaces).length === 0) {
|
|
80
|
+
delete settings.extraKnownMarketplaces;
|
|
81
|
+
}
|
|
82
|
+
if (settings.statusLine?.command?.includes('gsd-statusline')
|
|
83
|
+
|| settings.statusLine?.command?.includes('context-monitor.js')) {
|
|
84
|
+
delete settings.statusLine;
|
|
85
|
+
changed = true;
|
|
86
|
+
}
|
|
87
|
+
if (settings.hooks) {
|
|
88
|
+
if (typeof settings.hooks.StatusLine === 'string'
|
|
89
|
+
&& (settings.hooks.StatusLine.includes('gsd-statusline')
|
|
90
|
+
|| settings.hooks.StatusLine.includes('context-monitor.js'))) {
|
|
91
|
+
delete settings.hooks.StatusLine;
|
|
92
|
+
changed = true;
|
|
93
|
+
}
|
|
94
|
+
for (const [hookType, identifier] of [
|
|
95
|
+
['PostToolUse', 'gsd-context-monitor'],
|
|
96
|
+
['PostToolUse', 'context-monitor.js'],
|
|
97
|
+
['SessionStart', 'gsd-session-init'],
|
|
98
|
+
['Stop', 'gsd-session-stop'],
|
|
99
|
+
]) {
|
|
100
|
+
if (Array.isArray(settings.hooks[hookType])) {
|
|
101
|
+
const before = settings.hooks[hookType].length;
|
|
102
|
+
settings.hooks[hookType] = settings.hooks[hookType].filter(e =>
|
|
103
|
+
!e.hooks?.some(h => h.command?.includes(identifier)));
|
|
104
|
+
if (settings.hooks[hookType].length < before) changed = true;
|
|
105
|
+
if (settings.hooks[hookType].length === 0) delete settings.hooks[hookType];
|
|
106
|
+
} else if (typeof settings.hooks[hookType] === 'string'
|
|
107
|
+
&& settings.hooks[hookType].includes(identifier)) {
|
|
108
|
+
delete settings.hooks[hookType];
|
|
109
|
+
changed = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (changed) atomicWriteJson(settingsPath, settings);
|
|
114
|
+
} catch { /* best effort */ }
|
|
115
|
+
|
|
116
|
+
// 3. plugins/known_marketplaces.json
|
|
117
|
+
try {
|
|
118
|
+
const known = path.join(claudeDir, 'plugins', 'known_marketplaces.json');
|
|
119
|
+
const data = JSON.parse(fs.readFileSync(known, 'utf8'));
|
|
120
|
+
let dirty = false;
|
|
121
|
+
for (const n of ['gsd', 'gsd-lite']) {
|
|
122
|
+
if (n in data) { delete data[n]; dirty = true; }
|
|
123
|
+
}
|
|
124
|
+
if (dirty) atomicWriteJson(known, data);
|
|
125
|
+
} catch { /* best effort */ }
|
|
126
|
+
|
|
127
|
+
// 4. Hook script files
|
|
128
|
+
for (const name of ['context-monitor.js', 'gsd-statusline.cjs', 'gsd-context-monitor.cjs', 'gsd-auto-update.cjs', 'gsd-session-stop.cjs']) {
|
|
129
|
+
try { fs.rmSync(path.join(claudeDir, 'hooks', name), { force: true }); } catch { /* best effort */ }
|
|
130
|
+
}
|
|
131
|
+
// 5. Hook lib files (GSD-owned only — don't touch other plugins' libs)
|
|
132
|
+
for (const lib of ['gsd-finder.cjs', 'statusline-composite.cjs', 'semver-sort.cjs']) {
|
|
133
|
+
try { fs.rmSync(path.join(claudeDir, 'hooks', 'lib', lib), { force: true }); } catch { /* best effort */ }
|
|
134
|
+
}
|
|
135
|
+
// 6. Runtime dir + plugin marketplace + cache dirs (current + legacy names)
|
|
136
|
+
for (const dir of [
|
|
137
|
+
path.join(claudeDir, 'gsd'),
|
|
138
|
+
path.join(claudeDir, 'gsd-lite'),
|
|
139
|
+
path.join(claudeDir, 'plugins', 'marketplaces', 'gsd'),
|
|
140
|
+
path.join(claudeDir, 'plugins', 'marketplaces', 'gsd-lite'),
|
|
141
|
+
path.join(claudeDir, 'plugins', 'cache', 'gsd'),
|
|
142
|
+
path.join(claudeDir, 'plugins', 'cache', 'gsd-lite'),
|
|
143
|
+
]) {
|
|
144
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
145
|
+
}
|
|
146
|
+
// 7. Self-removal — delete this script last. The running process keeps the
|
|
147
|
+
// file handle until exit (POSIX); on Windows this may fail silently and
|
|
148
|
+
// Phase 0's next-session check will retry.
|
|
149
|
+
try { fs.rmSync(path.join(claudeDir, 'hooks', 'gsd-session-init.cjs'), { force: true }); } catch { /* best effort */ }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (isOrphan()) {
|
|
153
|
+
console.log('⚠ GSD-Lite plugin uninstalled — cleaning up orphaned hooks and runtime.');
|
|
154
|
+
try { cleanupOrphan(); } catch { /* best effort — never block session start */ }
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
|
|
19
158
|
// Safety: exit after 4s regardless (hook timeout is 5s)
|
|
20
159
|
setTimeout(() => process.exit(0), 4000).unref();
|
|
21
160
|
|
package/install.js
CHANGED
|
@@ -211,6 +211,11 @@ export function main() {
|
|
|
211
211
|
// 6. Stable runtime for MCP server
|
|
212
212
|
copyDir(join(__dirname, 'src'), join(RUNTIME_DIR, 'src'), 'runtime/src → ~/.claude/gsd/src/');
|
|
213
213
|
copyFile(join(__dirname, 'package.json'), join(RUNTIME_DIR, 'package.json'), 'runtime/package.json → ~/.claude/gsd/package.json');
|
|
214
|
+
// Copy uninstall.js so the SessionStart hook's Phase 0 orphan-cleanup can
|
|
215
|
+
// spawn it when /plugin uninstall has removed the plugin without running our
|
|
216
|
+
// uninstaller. Without this, hooks/runtime/settings.json entries written by
|
|
217
|
+
// install.js outlive the plugin and keep firing.
|
|
218
|
+
copyFile(join(__dirname, 'uninstall.js'), join(RUNTIME_DIR, 'uninstall.js'), 'runtime/uninstall.js → ~/.claude/gsd/uninstall.js');
|
|
214
219
|
// Copy lock file so `npm ci` works when node_modules are not present (npx scenario)
|
|
215
220
|
const lockFile = join(__dirname, 'package-lock.json');
|
|
216
221
|
if (existsSync(lockFile)) {
|
|
@@ -289,6 +294,27 @@ export function main() {
|
|
|
289
294
|
if (statusLineRegistered || hooksRegistered) {
|
|
290
295
|
log(' ✓ GSD-Lite hooks registered in settings.json');
|
|
291
296
|
}
|
|
297
|
+
|
|
298
|
+
// Record install mode so SessionStart's Phase 0 orphan-cleanup can
|
|
299
|
+
// distinguish "plugin uninstalled" from "npx install". Without this marker
|
|
300
|
+
// the hook falls back to the .orphaned_at cache heuristic, which is fine
|
|
301
|
+
// for pre-marker users but more guess-y.
|
|
302
|
+
try {
|
|
303
|
+
const installModeMarker = join(RUNTIME_DIR, '.install-mode');
|
|
304
|
+
writeFileSync(installModeMarker, (isPluginInstall ? 'plugin' : 'manual') + '\n');
|
|
305
|
+
} catch { /* best effort — marker missing falls back to heuristic */ }
|
|
306
|
+
|
|
307
|
+
// Clear stale .orphaned_at from the current plugin cache version, in case
|
|
308
|
+
// the user previously uninstalled (Claude Code stamps it) and is now
|
|
309
|
+
// reinstalling via /plugin. Leaving it would make orphan-cleanup mis-fire
|
|
310
|
+
// on the next session.
|
|
311
|
+
if (isPluginInstall) {
|
|
312
|
+
try {
|
|
313
|
+
const currentVersion = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8')).version;
|
|
314
|
+
const orphanMarker = join(CLAUDE_DIR, 'plugins', 'cache', 'gsd', 'gsd', currentVersion, '.orphaned_at');
|
|
315
|
+
if (existsSync(orphanMarker)) rmSync(orphanMarker, { force: true });
|
|
316
|
+
} catch { /* best effort */ }
|
|
317
|
+
}
|
|
292
318
|
} else {
|
|
293
319
|
log(' [dry-run] Would register MCP server in settings.json');
|
|
294
320
|
}
|