sticky-note-cli 2.9.1 → 3.0.0
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/README.md +5 -1
- package/bin/cli.js +508 -37
- package/bin/mcp-server.js +3 -3
- package/package.json +4 -1
- package/templates/CLAUDE.md +1 -0
- package/templates/copilot-instructions.md +1 -0
- package/templates/gitignore-additions.txt +4 -0
- package/templates/hooks/data-branch.js +256 -0
- package/templates/hooks/inject-context.js +14 -1
- package/templates/hooks/on-stop.js +10 -2
- package/templates/hooks/session-end.js +55 -0
- package/templates/hooks/session-start.js +208 -3
- package/templates/hooks/sticky-utils.js +112 -2
- package/templates/hooks.json +12 -12
- package/templates/settings.json +7 -7
- package/templates/sticky-note-config.json +2 -1
- package/templates/sticky-note.json +5 -5
package/bin/cli.js
CHANGED
|
@@ -25,6 +25,7 @@ const VERSION = require("../package.json").version;
|
|
|
25
25
|
|
|
26
26
|
const HOOK_FILES = [
|
|
27
27
|
"sticky-utils.js",
|
|
28
|
+
"data-branch.js", // git plumbing module for sticky-note/data branch
|
|
28
29
|
"session-start.js",
|
|
29
30
|
"session-end.js",
|
|
30
31
|
"inject-context.js",
|
|
@@ -83,26 +84,116 @@ function readTemplate(name) {
|
|
|
83
84
|
return fs.readFileSync(path.join(TEMPLATES_DIR, name), "utf-8");
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
// Hook commands in templates use relative paths (e.g. ".claude/hooks/foo.js")
|
|
88
|
+
// because Claude Code and Copilot CLI both resolve hook commands relative to
|
|
89
|
+
// the project root. Earlier versions rewrote these to absolute paths at init
|
|
90
|
+
// time, but that path was machine-specific and got committed to settings.json,
|
|
91
|
+
// causing every teammate's hooks to fail silently (issue #11). This is now a
|
|
92
|
+
// no-op, kept only to avoid breaking any external callers of the function.
|
|
93
|
+
function resolveHookPaths(_obj, _rootDir) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Detects hook commands in an existing settings.json that point at an absolute
|
|
98
|
+
// path (likely written by an older sticky-note-cli on another developer's
|
|
99
|
+
// machine). Returns the list of stale command strings, or [] if all clean.
|
|
100
|
+
function findStaleHookPaths(settings) {
|
|
101
|
+
const stale = [];
|
|
102
|
+
const hooks = (settings && settings.hooks) || {};
|
|
93
103
|
for (const entries of Object.values(hooks)) {
|
|
94
104
|
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
for (const h of entry.hooks || []) {
|
|
106
|
+
const cmd = h && h.command;
|
|
107
|
+
if (typeof cmd !== "string") continue;
|
|
108
|
+
// Absolute paths (Unix /Users, /home, /root or Windows C:/, D:/ etc.)
|
|
109
|
+
// inside the hook command are the smoking gun. Match anywhere in the
|
|
110
|
+
// string — the path may be preceded by other tokens (env vars, shell
|
|
111
|
+
// prefixes) and may contain spaces.
|
|
112
|
+
if (
|
|
113
|
+
/["'][A-Za-z]:[\\/]/.test(cmd) ||
|
|
114
|
+
/["']\/(?:Users|home|root|tmp)\//.test(cmd) ||
|
|
115
|
+
cmd.includes("$(git rev-parse")
|
|
116
|
+
) {
|
|
117
|
+
stale.push(cmd);
|
|
118
|
+
}
|
|
119
|
+
// Old relative-path format: node ".claude/hooks/foo.js"
|
|
120
|
+
// This fails when Claude Code launches from a subdirectory of the
|
|
121
|
+
// project root, because the path resolves relative to the launch cwd,
|
|
122
|
+
// not relative to where .claude/settings.json lives. The fix is to
|
|
123
|
+
// use `npx sticky-note run-hook foo` which walks up to find the hooks.
|
|
124
|
+
if (/node\s+["']\.claude[\\/]hooks[\\/][^"']+\.js["']/.test(cmd)) {
|
|
125
|
+
stale.push(cmd);
|
|
98
126
|
}
|
|
99
127
|
}
|
|
100
|
-
if (entry.bash) entry.bash = resolve(entry.bash);
|
|
101
|
-
if (entry.powershell) entry.powershell = resolve(entry.powershell);
|
|
102
|
-
if (entry.command) entry.command = resolve(entry.command);
|
|
103
128
|
}
|
|
104
129
|
}
|
|
105
|
-
return
|
|
130
|
+
return stale;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Parse a semver-ish version string ("2.9.1", "2.9.1-beta.1", "v2.9") into
|
|
134
|
+
// [major, minor, patch] numbers. Pre-release suffixes are ignored. Returns
|
|
135
|
+
// null if unparseable.
|
|
136
|
+
function parseSemver(v) {
|
|
137
|
+
if (typeof v !== "string") return null;
|
|
138
|
+
const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
139
|
+
if (!m) return null;
|
|
140
|
+
return [Number(m[1]), Number(m[2]), Number(m[3] || 0)];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Returns -1 if a < b, 0 if equal, 1 if a > b. Unparseable values sort last.
|
|
144
|
+
function compareSemver(a, b) {
|
|
145
|
+
const pa = parseSemver(a), pb = parseSemver(b);
|
|
146
|
+
if (!pa && !pb) return 0;
|
|
147
|
+
if (!pa) return 1;
|
|
148
|
+
if (!pb) return -1;
|
|
149
|
+
for (let i = 0; i < 3; i++) {
|
|
150
|
+
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
|
151
|
+
}
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// When updating an existing config, only bump min_version forward — never
|
|
156
|
+
// downgrade if a teammate has already pinned a newer minimum.
|
|
157
|
+
function bumpMinVersion(existing, current) {
|
|
158
|
+
if (!existing) return current;
|
|
159
|
+
return compareSemver(existing, current) >= 0 ? existing : current;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Add sticky-note-cli to the project package.json devDependencies so every
|
|
163
|
+
// teammate gets a pinned working copy on `npm install` (issue #12). No-ops
|
|
164
|
+
// gracefully if there is no package.json or if the entry is already present
|
|
165
|
+
// at >= the current version.
|
|
166
|
+
function pinCliInPackageJson() {
|
|
167
|
+
const pkgPath = path.join(process.cwd(), "package.json");
|
|
168
|
+
if (!fs.existsSync(pkgPath)) {
|
|
169
|
+
print(" ⏭️ No package.json found — skipping devDependency pin");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
let pkg;
|
|
173
|
+
try {
|
|
174
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
175
|
+
} catch (err) {
|
|
176
|
+
print(` ⏭️ Could not parse package.json: ${err.message}`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
pkg.devDependencies = pkg.devDependencies || {};
|
|
180
|
+
const existing = pkg.devDependencies["sticky-note-cli"];
|
|
181
|
+
const desired = `^${VERSION}`;
|
|
182
|
+
if (existing) {
|
|
183
|
+
const existingClean = String(existing).replace(/^[\^~]/, "");
|
|
184
|
+
if (compareSemver(existingClean, VERSION) >= 0) {
|
|
185
|
+
print(` ⏭️ package.json devDependencies already pin sticky-note-cli ${existing}`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
pkg.devDependencies["sticky-note-cli"] = desired;
|
|
190
|
+
// Keep keys sorted for clean diffs
|
|
191
|
+
pkg.devDependencies = Object.fromEntries(
|
|
192
|
+
Object.entries(pkg.devDependencies).sort(([a], [b]) => a.localeCompare(b))
|
|
193
|
+
);
|
|
194
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
195
|
+
print(` [OK] package.json devDependencies: sticky-note-cli ${desired}`);
|
|
196
|
+
print(" Run `npm install` to lock the version for your team.");
|
|
106
197
|
}
|
|
107
198
|
|
|
108
199
|
function makeExecutable(filePath) {
|
|
@@ -654,17 +745,32 @@ async function cmdInit() {
|
|
|
654
745
|
print(` [OK] .claude/hooks/${file}`);
|
|
655
746
|
}
|
|
656
747
|
|
|
657
|
-
// Create settings.json (Claude Code)
|
|
748
|
+
// Create settings.json (Claude Code) — uses relative hook paths so it can
|
|
749
|
+
// be safely committed and shared across teammates (issue #11).
|
|
658
750
|
const settingsTemplate = JSON.parse(readTemplate("settings.json"));
|
|
659
|
-
resolveHookPaths(settingsTemplate, process.cwd());
|
|
660
751
|
const settingsDest = path.join(process.cwd(), ".claude", "settings.json");
|
|
752
|
+
if (fs.existsSync(settingsDest)) {
|
|
753
|
+
const existing = readJsonSafe(settingsDest, {});
|
|
754
|
+
const stale = findStaleHookPaths(existing);
|
|
755
|
+
if (stale.length > 0) {
|
|
756
|
+
print(" ⚠️ .claude/settings.json contains absolute or shell-expanded hook paths:");
|
|
757
|
+
for (const cmd of stale.slice(0, 3)) print(` ${cmd}`);
|
|
758
|
+
print(" Rewriting with relative paths so hooks work for every teammate.");
|
|
759
|
+
}
|
|
760
|
+
}
|
|
661
761
|
fs.writeFileSync(settingsDest, JSON.stringify(settingsTemplate, null, 2) + "\n");
|
|
662
762
|
print(" [OK] .claude/settings.json");
|
|
663
763
|
|
|
664
|
-
// Create hooks.json (Copilot CLI)
|
|
764
|
+
// Create hooks.json (Copilot CLI) — also relative paths.
|
|
665
765
|
const hooksTemplate = JSON.parse(readTemplate("hooks.json"));
|
|
666
|
-
resolveHookPaths(hooksTemplate, process.cwd());
|
|
667
766
|
const hooksDest = path.join(githubHooksDir, "hooks.json");
|
|
767
|
+
if (fs.existsSync(hooksDest)) {
|
|
768
|
+
const existing = readJsonSafe(hooksDest, {});
|
|
769
|
+
const stale = findStaleHookPaths(existing);
|
|
770
|
+
if (stale.length > 0) {
|
|
771
|
+
print(" ⚠️ .github/hooks/hooks.json contains absolute or shell-expanded hook paths — rewriting.");
|
|
772
|
+
}
|
|
773
|
+
}
|
|
668
774
|
fs.writeFileSync(hooksDest, JSON.stringify(hooksTemplate, null, 2) + "\n");
|
|
669
775
|
print(" [OK] .github/hooks/hooks.json");
|
|
670
776
|
|
|
@@ -718,6 +824,7 @@ async function cmdInit() {
|
|
|
718
824
|
configTemplate.stale_days = staleDaysResolved;
|
|
719
825
|
configTemplate.inject_token_budget = injectTokenBudgetResolved;
|
|
720
826
|
configTemplate.hook_version = VERSION;
|
|
827
|
+
configTemplate.min_version = VERSION;
|
|
721
828
|
fs.writeFileSync(configDest, JSON.stringify(configTemplate, null, 2) + "\n");
|
|
722
829
|
} else {
|
|
723
830
|
// Update existing config with new settings
|
|
@@ -728,10 +835,15 @@ async function cmdInit() {
|
|
|
728
835
|
existing.stale_days = staleDaysResolved;
|
|
729
836
|
existing.inject_token_budget = injectTokenBudgetResolved;
|
|
730
837
|
existing.hook_version = VERSION;
|
|
838
|
+
existing.min_version = bumpMinVersion(existing.min_version, VERSION);
|
|
731
839
|
fs.writeFileSync(configDest, JSON.stringify(existing, null, 2) + "\n");
|
|
732
840
|
}
|
|
733
841
|
print(" [OK] .sticky-note/sticky-note-config.json");
|
|
734
842
|
|
|
843
|
+
// Pin sticky-note-cli in the project package.json devDependencies so every
|
|
844
|
+
// teammate gets the same version on `npm install` (issue #12).
|
|
845
|
+
pinCliInPackageJson();
|
|
846
|
+
|
|
735
847
|
// Update .gitignore
|
|
736
848
|
const gitignorePath = path.join(process.cwd(), ".gitignore");
|
|
737
849
|
const ignoreAdditions = fs
|
|
@@ -872,15 +984,9 @@ async function cmdInit() {
|
|
|
872
984
|
print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
|
|
873
985
|
}
|
|
874
986
|
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
// Install post-commit hook (auto-commit leftover .sticky-note/ changes)
|
|
881
|
-
if (installPostCommitHook()) {
|
|
882
|
-
print(" [OK] .git/hooks/post-commit (auto-sync .sticky-note/ after commit)");
|
|
883
|
-
}
|
|
987
|
+
// Data branch sync handles persistence — no pre/post-commit hooks needed
|
|
988
|
+
print(" [OK] Skipping git pre/post-commit hooks (data branch sync handles this now)");
|
|
989
|
+
print(" Run `npx sticky-note migrate` to migrate existing .sticky-note/ data.");
|
|
884
990
|
|
|
885
991
|
// Create environment directory structure
|
|
886
992
|
const envDir = path.join(stickyNoteDir, "environment");
|
|
@@ -998,8 +1104,11 @@ function cmdUpdate() {
|
|
|
998
1104
|
const settingsPath = path.join(process.cwd(), ".claude", "settings.json");
|
|
999
1105
|
if (fs.existsSync(settingsPath)) {
|
|
1000
1106
|
const settingsTemplate = JSON.parse(readTemplate("settings.json"));
|
|
1001
|
-
resolveHookPaths(settingsTemplate, process.cwd());
|
|
1002
1107
|
const existing = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
|
|
1108
|
+
const stale = findStaleHookPaths(existing);
|
|
1109
|
+
if (stale.length > 0) {
|
|
1110
|
+
print(" ⚠️ Existing settings.json had absolute hook paths — replacing with relative paths.");
|
|
1111
|
+
}
|
|
1003
1112
|
existing.hooks = settingsTemplate.hooks;
|
|
1004
1113
|
fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + "\n");
|
|
1005
1114
|
print(" [OK] .claude/settings.json (hooks updated)");
|
|
@@ -1009,7 +1118,6 @@ function cmdUpdate() {
|
|
|
1009
1118
|
const hooksPath = path.join(process.cwd(), ".github", "hooks", "hooks.json");
|
|
1010
1119
|
if (fs.existsSync(hooksPath)) {
|
|
1011
1120
|
const hooksTemplate = JSON.parse(readTemplate("hooks.json"));
|
|
1012
|
-
resolveHookPaths(hooksTemplate, process.cwd());
|
|
1013
1121
|
fs.writeFileSync(hooksPath, JSON.stringify(hooksTemplate, null, 2) + "\n");
|
|
1014
1122
|
print(" [OK] .github/hooks/hooks.json (updated)");
|
|
1015
1123
|
}
|
|
@@ -1018,11 +1126,35 @@ function cmdUpdate() {
|
|
|
1018
1126
|
if (installGitHook("post-rewrite")) {
|
|
1019
1127
|
print(" [OK] .git/hooks/post-rewrite (attribution survives rebase)");
|
|
1020
1128
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
if (
|
|
1025
|
-
|
|
1129
|
+
|
|
1130
|
+
// Remove sticky-note pre-commit and post-commit git hooks (replaced by data branch)
|
|
1131
|
+
const gitDir2 = path.join(process.cwd(), ".git");
|
|
1132
|
+
if (fs.existsSync(gitDir2)) {
|
|
1133
|
+
const gitHooksDir2 = path.join(gitDir2, "hooks");
|
|
1134
|
+
for (const hookName of ["pre-commit", "post-commit"]) {
|
|
1135
|
+
const hookPath2 = path.join(gitHooksDir2, hookName);
|
|
1136
|
+
if (!fs.existsSync(hookPath2)) continue;
|
|
1137
|
+
const content2 = fs.readFileSync(hookPath2, "utf-8");
|
|
1138
|
+
if (!content2.includes(STICKY_PRE_COMMIT_MARKER) && !content2.includes(STICKY_POST_COMMIT_MARKER)) continue;
|
|
1139
|
+
let updated2 = content2;
|
|
1140
|
+
const preStart2 = updated2.indexOf(STICKY_PRE_COMMIT_MARKER);
|
|
1141
|
+
const preEnd2 = updated2.indexOf(STICKY_PRE_COMMIT_END);
|
|
1142
|
+
if (preStart2 !== -1 && preEnd2 !== -1) {
|
|
1143
|
+
updated2 = updated2.slice(0, preStart2) + updated2.slice(preEnd2 + STICKY_PRE_COMMIT_END.length);
|
|
1144
|
+
}
|
|
1145
|
+
const postStart2 = updated2.indexOf(STICKY_POST_COMMIT_MARKER);
|
|
1146
|
+
const postEnd2 = updated2.indexOf(STICKY_POST_COMMIT_END);
|
|
1147
|
+
if (postStart2 !== -1 && postEnd2 !== -1) {
|
|
1148
|
+
updated2 = updated2.slice(0, postStart2) + updated2.slice(postEnd2 + STICKY_POST_COMMIT_END.length);
|
|
1149
|
+
}
|
|
1150
|
+
if (updated2.replace(/^#!.*\n/, "").trim() === "") {
|
|
1151
|
+
fs.unlinkSync(hookPath2);
|
|
1152
|
+
print(" [OK] Removed .git/hooks/" + hookName + " (no longer needed)");
|
|
1153
|
+
} else {
|
|
1154
|
+
fs.writeFileSync(hookPath2, updated2, "utf-8");
|
|
1155
|
+
print(" [OK] Removed sticky-note blocks from .git/hooks/" + hookName);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1026
1158
|
}
|
|
1027
1159
|
|
|
1028
1160
|
// Update hook_version in config
|
|
@@ -1161,6 +1293,33 @@ function cmdStatus() {
|
|
|
1161
1293
|
` ${fs.existsSync(hooksJsonPath) ? "[OK]" : "[ERR]"} .github/hooks/hooks.json`
|
|
1162
1294
|
);
|
|
1163
1295
|
|
|
1296
|
+
// Issue #11 guard: warn about absolute hook paths in shared config files.
|
|
1297
|
+
for (const [label, p] of [
|
|
1298
|
+
[".claude/settings.json", settingsPath],
|
|
1299
|
+
[".github/hooks/hooks.json", hooksJsonPath],
|
|
1300
|
+
]) {
|
|
1301
|
+
if (!fs.existsSync(p)) continue;
|
|
1302
|
+
const data = readJsonSafe(p, {});
|
|
1303
|
+
const stale = findStaleHookPaths(data);
|
|
1304
|
+
if (stale.length > 0) {
|
|
1305
|
+
print(` ⚠️ ${label} has absolute hook paths — hooks will fail for teammates.`);
|
|
1306
|
+
print(" Run `npx sticky-note init` to rewrite with relative paths.");
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// Issue #12 guard: warn if installed CLI is below the project's min_version.
|
|
1311
|
+
const configPathForVersionCheck = path.join(stickyDir, "sticky-note-config.json");
|
|
1312
|
+
if (fs.existsSync(configPathForVersionCheck)) {
|
|
1313
|
+
const cfg = readJsonSafe(configPathForVersionCheck, {});
|
|
1314
|
+
if (cfg.min_version && compareSemver(VERSION, cfg.min_version) < 0) {
|
|
1315
|
+
print(
|
|
1316
|
+
` ⚠️ Installed sticky-note-cli v${VERSION} is below the project's ` +
|
|
1317
|
+
`min_version v${cfg.min_version}.`
|
|
1318
|
+
);
|
|
1319
|
+
print(" Run `npm i -D sticky-note-cli@latest` to update.");
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1164
1323
|
// Sticky Note data — V2 format
|
|
1165
1324
|
const memoryPath = path.join(stickyDir, "sticky-note.json");
|
|
1166
1325
|
const configPath = path.join(stickyDir, "sticky-note-config.json");
|
|
@@ -1243,7 +1402,7 @@ function cmdStatus() {
|
|
|
1243
1402
|
const lastSeen = info.last_seen || "";
|
|
1244
1403
|
if (lastSeen) {
|
|
1245
1404
|
const ts = new Date(lastSeen).getTime();
|
|
1246
|
-
if (!isNaN(ts) && now - ts <
|
|
1405
|
+
if (!isNaN(ts) && now - ts < 60 * 60 * 1000) {
|
|
1247
1406
|
activeUsers.push(path.basename(f, ".json"));
|
|
1248
1407
|
}
|
|
1249
1408
|
}
|
|
@@ -1687,7 +1846,7 @@ function cmdWho() {
|
|
|
1687
1846
|
for (const [user, info] of Object.entries(data)) {
|
|
1688
1847
|
const lastSeen = info.last_seen || "";
|
|
1689
1848
|
const ts = lastSeen ? new Date(lastSeen).getTime() : 0;
|
|
1690
|
-
const active = !isNaN(ts) && now - ts <
|
|
1849
|
+
const active = !isNaN(ts) && now - ts < 60 * 60 * 1000;
|
|
1691
1850
|
const ago = lastSeen ? _relativeTime(lastSeen) : "unknown";
|
|
1692
1851
|
const files = (info.active_files || []).slice(0, 5).join(", ");
|
|
1693
1852
|
const marker = active ? "[ACTIVE]" : "[IDLE]";
|
|
@@ -1713,7 +1872,7 @@ function cmdWho() {
|
|
|
1713
1872
|
const info = readJsonSafe(path.join(presenceDir, f), {});
|
|
1714
1873
|
const lastSeen = info.last_seen || "";
|
|
1715
1874
|
const ts = lastSeen ? new Date(lastSeen).getTime() : 0;
|
|
1716
|
-
const active = !isNaN(ts) && now - ts <
|
|
1875
|
+
const active = !isNaN(ts) && now - ts < 60 * 60 * 1000;
|
|
1717
1876
|
users.push({ user, info, lastSeen, active });
|
|
1718
1877
|
}
|
|
1719
1878
|
|
|
@@ -3218,6 +3377,308 @@ async function cmdEnvAddPlugin() {
|
|
|
3218
3377
|
print("");
|
|
3219
3378
|
}
|
|
3220
3379
|
|
|
3380
|
+
// ──────────────────────────────────────────────
|
|
3381
|
+
// run-hook: locate and execute a hook from any directory
|
|
3382
|
+
// ──────────────────────────────────────────────
|
|
3383
|
+
|
|
3384
|
+
function cmdRunHook() {
|
|
3385
|
+
const { spawnSync } = require("child_process");
|
|
3386
|
+
const hookName = process.argv[3]; // e.g. "session-start"
|
|
3387
|
+
|
|
3388
|
+
if (!hookName) {
|
|
3389
|
+
process.stderr.write(
|
|
3390
|
+
"[sticky-note] run-hook: missing hook name\n" +
|
|
3391
|
+
" Usage: npx sticky-note run-hook <name>\n" +
|
|
3392
|
+
" Names: session-start, session-end, inject-context, pre-tool-use,\n" +
|
|
3393
|
+
" track-work, on-stop, on-error\n"
|
|
3394
|
+
);
|
|
3395
|
+
// Emit empty output so Claude Code doesn't choke on missing stdout JSON
|
|
3396
|
+
process.stdout.write(JSON.stringify({ output: "" }) + "\n");
|
|
3397
|
+
process.exit(0);
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
// Walk up from cwd to find .claude/hooks/<name>.js
|
|
3401
|
+
const hookFile = hookName.endsWith(".js") ? hookName : `${hookName}.js`;
|
|
3402
|
+
let dir = process.cwd();
|
|
3403
|
+
let hookPath = null;
|
|
3404
|
+
for (let i = 0; i < 20; i++) {
|
|
3405
|
+
const candidate = path.join(dir, ".claude", "hooks", hookFile);
|
|
3406
|
+
if (fs.existsSync(candidate)) {
|
|
3407
|
+
hookPath = candidate;
|
|
3408
|
+
break;
|
|
3409
|
+
}
|
|
3410
|
+
const parent = path.dirname(dir);
|
|
3411
|
+
if (parent === dir) break; // filesystem root
|
|
3412
|
+
dir = parent;
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
if (!hookPath) {
|
|
3416
|
+
// Hook not installed — exit silently (non-fatal)
|
|
3417
|
+
process.stdout.write(JSON.stringify({ output: "" }) + "\n");
|
|
3418
|
+
process.exit(0);
|
|
3419
|
+
}
|
|
3420
|
+
|
|
3421
|
+
// Spawn the hook with stdin/stdout/stderr inherited so Claude Code's pipe
|
|
3422
|
+
// flows through to the hook process unchanged.
|
|
3423
|
+
const result = spawnSync(process.execPath, [hookPath], {
|
|
3424
|
+
stdio: "inherit",
|
|
3425
|
+
env: process.env,
|
|
3426
|
+
});
|
|
3427
|
+
process.exit(result.status != null ? result.status : 0);
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3430
|
+
// ──────────────────────────────────────────────
|
|
3431
|
+
// MIGRATE command
|
|
3432
|
+
// ──────────────────────────────────────────────
|
|
3433
|
+
|
|
3434
|
+
/**
|
|
3435
|
+
* One-time migration from .sticky-note/ (feature-branch storage) to
|
|
3436
|
+
* .git/sticky-note/ (data-branch storage).
|
|
3437
|
+
*
|
|
3438
|
+
* Idempotent — safe to run multiple times.
|
|
3439
|
+
*/
|
|
3440
|
+
async function cmdMigrate() {
|
|
3441
|
+
printBanner();
|
|
3442
|
+
|
|
3443
|
+
if (!isGitRepo()) {
|
|
3444
|
+
print(" [ERR] Not a git repository.");
|
|
3445
|
+
process.exit(1);
|
|
3446
|
+
}
|
|
3447
|
+
|
|
3448
|
+
const cwd = process.cwd();
|
|
3449
|
+
const oldDir = path.join(cwd, ".sticky-note");
|
|
3450
|
+
|
|
3451
|
+
// Locate .git directory using plumbing (works from subdirectories)
|
|
3452
|
+
let gitDir;
|
|
3453
|
+
try {
|
|
3454
|
+
gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
|
|
3455
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
3456
|
+
cwd,
|
|
3457
|
+
}).trim();
|
|
3458
|
+
} catch (err) {
|
|
3459
|
+
print(" [ERR] Could not locate .git directory: " + err.message);
|
|
3460
|
+
process.exit(1);
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
const newDir = path.join(gitDir, "sticky-note");
|
|
3464
|
+
print(" 📂 Source: " + oldDir);
|
|
3465
|
+
print(" 📂 Target: " + newDir);
|
|
3466
|
+
print("");
|
|
3467
|
+
|
|
3468
|
+
// Step 1: Copy files from .sticky-note/ to .git/sticky-note/
|
|
3469
|
+
mkdirSafe(newDir);
|
|
3470
|
+
mkdirSafe(path.join(newDir, "audit"));
|
|
3471
|
+
mkdirSafe(path.join(newDir, "presence"));
|
|
3472
|
+
|
|
3473
|
+
let copiedFiles = 0;
|
|
3474
|
+
const srcMemory = path.join(oldDir, "sticky-note.json");
|
|
3475
|
+
const dstMemory = path.join(newDir, "sticky-note.json");
|
|
3476
|
+
if (fs.existsSync(srcMemory)) {
|
|
3477
|
+
fs.copyFileSync(srcMemory, dstMemory);
|
|
3478
|
+
copiedFiles++;
|
|
3479
|
+
print(" [OK] Copied sticky-note.json");
|
|
3480
|
+
} else if (!fs.existsSync(dstMemory)) {
|
|
3481
|
+
// No existing data — create empty memory
|
|
3482
|
+
fs.writeFileSync(
|
|
3483
|
+
dstMemory,
|
|
3484
|
+
JSON.stringify({ version: "2", project: "", threads: [] }, null, 2) + "\n"
|
|
3485
|
+
);
|
|
3486
|
+
print(" [OK] Created empty sticky-note.json (no .sticky-note/ found)");
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
const oldAuditDir = path.join(oldDir, "audit");
|
|
3490
|
+
if (fs.existsSync(oldAuditDir)) {
|
|
3491
|
+
for (const file of fs.readdirSync(oldAuditDir)) {
|
|
3492
|
+
if (file.endsWith(".jsonl")) {
|
|
3493
|
+
const dst = path.join(newDir, "audit", file);
|
|
3494
|
+
if (!fs.existsSync(dst)) {
|
|
3495
|
+
fs.copyFileSync(path.join(oldAuditDir, file), dst);
|
|
3496
|
+
copiedFiles++;
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
const oldPresenceDir = path.join(oldDir, "presence");
|
|
3503
|
+
if (fs.existsSync(oldPresenceDir)) {
|
|
3504
|
+
for (const file of fs.readdirSync(oldPresenceDir)) {
|
|
3505
|
+
if (file.endsWith(".json")) {
|
|
3506
|
+
const dst = path.join(newDir, "presence", file);
|
|
3507
|
+
if (!fs.existsSync(dst)) {
|
|
3508
|
+
fs.copyFileSync(path.join(oldPresenceDir, file), dst);
|
|
3509
|
+
copiedFiles++;
|
|
3510
|
+
}
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
if (copiedFiles > 0) print(" [OK] Copied " + copiedFiles + " file(s) to .git/sticky-note/");
|
|
3515
|
+
|
|
3516
|
+
// Step 2: Commit files to sticky-note/data branch using plumbing
|
|
3517
|
+
print("");
|
|
3518
|
+
print(" 📌 Committing to sticky-note/data branch...");
|
|
3519
|
+
try {
|
|
3520
|
+
// Inline the plumbing here (data-branch.js lives in hooks, not in CLI path)
|
|
3521
|
+
_commitFilesToDataBranch(newDir);
|
|
3522
|
+
print(" [OK] sticky-note/data branch created/updated");
|
|
3523
|
+
} catch (err) {
|
|
3524
|
+
print(" [WARN] Could not commit to data branch: " + err.message);
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
// Step 3: Add .sticky-note/ to .gitignore
|
|
3528
|
+
print("");
|
|
3529
|
+
const gitignorePath = path.join(cwd, ".gitignore");
|
|
3530
|
+
const MARKER = "# sticky-note: data branch migration";
|
|
3531
|
+
const IGNORE_LINE = ".sticky-note/";
|
|
3532
|
+
let gitignoreUpdated = false;
|
|
3533
|
+
if (fs.existsSync(gitignorePath)) {
|
|
3534
|
+
const existing = fs.readFileSync(gitignorePath, "utf-8");
|
|
3535
|
+
const gitignoreLines = existing.split(/\r?\n/).map(l => l.trim());
|
|
3536
|
+
if (!gitignoreLines.includes(IGNORE_LINE)) {
|
|
3537
|
+
fs.appendFileSync(gitignorePath, "\n" + MARKER + "\n" + IGNORE_LINE + "\n");
|
|
3538
|
+
gitignoreUpdated = true;
|
|
3539
|
+
}
|
|
3540
|
+
} else {
|
|
3541
|
+
fs.writeFileSync(gitignorePath, MARKER + "\n" + IGNORE_LINE + "\n");
|
|
3542
|
+
gitignoreUpdated = true;
|
|
3543
|
+
}
|
|
3544
|
+
if (gitignoreUpdated) {
|
|
3545
|
+
print(" [OK] Added .sticky-note/ to .gitignore");
|
|
3546
|
+
} else {
|
|
3547
|
+
print(" [OK] .gitignore already contains .sticky-note/");
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
// Step 4: Remove sticky-note pre-commit and post-commit hooks
|
|
3551
|
+
print("");
|
|
3552
|
+
const gitHooksDir = path.join(gitDir, "hooks");
|
|
3553
|
+
for (const hookName of ["pre-commit", "post-commit"]) {
|
|
3554
|
+
const hookPath = path.join(gitHooksDir, hookName);
|
|
3555
|
+
if (!fs.existsSync(hookPath)) continue;
|
|
3556
|
+
const content = fs.readFileSync(hookPath, "utf-8");
|
|
3557
|
+
if (content.includes(STICKY_PRE_COMMIT_MARKER) || content.includes(STICKY_POST_COMMIT_MARKER)) {
|
|
3558
|
+
// Remove sticky-note blocks
|
|
3559
|
+
let updated = content;
|
|
3560
|
+
// Remove pre-commit block
|
|
3561
|
+
const preStart = updated.indexOf(STICKY_PRE_COMMIT_MARKER);
|
|
3562
|
+
const preEnd = updated.indexOf(STICKY_PRE_COMMIT_END);
|
|
3563
|
+
if (preStart !== -1 && preEnd !== -1) {
|
|
3564
|
+
updated = updated.slice(0, preStart) + updated.slice(preEnd + STICKY_PRE_COMMIT_END.length);
|
|
3565
|
+
}
|
|
3566
|
+
// Remove post-commit block
|
|
3567
|
+
const postStart = updated.indexOf(STICKY_POST_COMMIT_MARKER);
|
|
3568
|
+
const postEnd = updated.indexOf(STICKY_POST_COMMIT_END);
|
|
3569
|
+
if (postStart !== -1 && postEnd !== -1) {
|
|
3570
|
+
updated = updated.slice(0, postStart) + updated.slice(postEnd + STICKY_POST_COMMIT_END.length);
|
|
3571
|
+
}
|
|
3572
|
+
// If only the shebang remains, delete the file
|
|
3573
|
+
if (updated.replace(/^#!.*\n/, "").trim() === "") {
|
|
3574
|
+
fs.unlinkSync(hookPath);
|
|
3575
|
+
print(" [OK] Removed .git/hooks/" + hookName + " (was sticky-note only)");
|
|
3576
|
+
} else {
|
|
3577
|
+
fs.writeFileSync(hookPath, updated, "utf-8");
|
|
3578
|
+
print(" [OK] Removed sticky-note blocks from .git/hooks/" + hookName);
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
// Step 5: Push data branch if remote exists
|
|
3584
|
+
print("");
|
|
3585
|
+
try {
|
|
3586
|
+
const remote = execFileSync("git", ["remote"], {
|
|
3587
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
3588
|
+
cwd,
|
|
3589
|
+
}).trim().split("\n")[0];
|
|
3590
|
+
if (remote) {
|
|
3591
|
+
print(" 🔄 Pushing sticky-note/data to " + remote + "...");
|
|
3592
|
+
execFileSync("git", ["push", remote, "refs/heads/sticky-note/data:refs/heads/sticky-note/data"], {
|
|
3593
|
+
timeout: 30000, stdio: ["pipe", "pipe", "pipe"],
|
|
3594
|
+
cwd,
|
|
3595
|
+
});
|
|
3596
|
+
print(" [OK] Pushed sticky-note/data");
|
|
3597
|
+
} else {
|
|
3598
|
+
print(" ⏭️ No remote configured — skipping push");
|
|
3599
|
+
}
|
|
3600
|
+
} catch (err) {
|
|
3601
|
+
print(" [WARN] Push failed: " + err.message);
|
|
3602
|
+
print(" [INFO] Your data is safe locally. Run `npx sticky-note migrate` again to retry.");
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
print("");
|
|
3606
|
+
print(" ✅ Migration complete!");
|
|
3607
|
+
print("");
|
|
3608
|
+
print(" Next steps:");
|
|
3609
|
+
print(" 1. Commit the .gitignore change: git add .gitignore && git commit -m 'chore: gitignore sticky-note data dir'");
|
|
3610
|
+
print(" 2. Each teammate runs: npx sticky-note init");
|
|
3611
|
+
print("");
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
/**
|
|
3615
|
+
* Inline git plumbing to commit files from a local dir to sticky-note/data.
|
|
3616
|
+
* Extracted from data-branch.js for use in CLI context (no hooks dir on PATH).
|
|
3617
|
+
*/
|
|
3618
|
+
function _commitFilesToDataBranch(srcDir) {
|
|
3619
|
+
const os = require("os");
|
|
3620
|
+
const crypto = require("crypto");
|
|
3621
|
+
const branchRef = "refs/heads/sticky-note/data";
|
|
3622
|
+
const tmpIndex = path.join(
|
|
3623
|
+
os.tmpdir(),
|
|
3624
|
+
"sticky-idx-" + process.pid + "-" + crypto.randomBytes(4).toString("hex")
|
|
3625
|
+
);
|
|
3626
|
+
|
|
3627
|
+
try {
|
|
3628
|
+
let parentSha = null;
|
|
3629
|
+
try {
|
|
3630
|
+
parentSha = execFileSync("git", ["rev-parse", "--verify", branchRef], {
|
|
3631
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
3632
|
+
}).trim();
|
|
3633
|
+
} catch (_) {}
|
|
3634
|
+
|
|
3635
|
+
const indexEnv = { ...process.env, GIT_INDEX_FILE: tmpIndex };
|
|
3636
|
+
if (parentSha) {
|
|
3637
|
+
execFileSync("git", ["read-tree", parentSha], {
|
|
3638
|
+
timeout: 5000, stdio: ["pipe", "pipe", "pipe"], env: indexEnv,
|
|
3639
|
+
});
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
// Walk srcDir and commit all files
|
|
3643
|
+
function walkAndAdd(dir, prefix) {
|
|
3644
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
3645
|
+
const full = path.join(dir, entry.name);
|
|
3646
|
+
const relPath = prefix ? prefix + "/" + entry.name : entry.name;
|
|
3647
|
+
if (entry.isDirectory()) {
|
|
3648
|
+
walkAndAdd(full, relPath);
|
|
3649
|
+
} else if (entry.isFile() && !entry.name.startsWith(".sticky-")) {
|
|
3650
|
+
const content = fs.readFileSync(full);
|
|
3651
|
+
const blobSha = execFileSync("git", ["hash-object", "-w", "--stdin"], {
|
|
3652
|
+
input: content, encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
3653
|
+
}).trim();
|
|
3654
|
+
execFileSync(
|
|
3655
|
+
"git",
|
|
3656
|
+
["update-index", "--add", "--cacheinfo", "100644," + blobSha + "," + relPath],
|
|
3657
|
+
{ timeout: 5000, stdio: ["pipe", "pipe", "pipe"], env: indexEnv }
|
|
3658
|
+
);
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
walkAndAdd(srcDir, "");
|
|
3663
|
+
|
|
3664
|
+
const treeSha = execFileSync("git", ["write-tree"], {
|
|
3665
|
+
encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], env: indexEnv,
|
|
3666
|
+
}).trim();
|
|
3667
|
+
|
|
3668
|
+
const commitArgs = ["commit-tree", treeSha, "-m", "chore(sticky-note): migrate to data branch"];
|
|
3669
|
+
if (parentSha) commitArgs.push("-p", parentSha);
|
|
3670
|
+
const commitSha = execFileSync("git", commitArgs, {
|
|
3671
|
+
encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
3672
|
+
}).trim();
|
|
3673
|
+
|
|
3674
|
+
execFileSync("git", ["update-ref", branchRef, commitSha], {
|
|
3675
|
+
timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
|
|
3676
|
+
});
|
|
3677
|
+
} finally {
|
|
3678
|
+
try { fs.unlinkSync(tmpIndex); } catch (_) {}
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3221
3682
|
// ──────────────────────────────────────────────
|
|
3222
3683
|
// Main
|
|
3223
3684
|
// ──────────────────────────────────────────────
|
|
@@ -3287,6 +3748,15 @@ async function main() {
|
|
|
3287
3748
|
case "mcp-server":
|
|
3288
3749
|
cmdMcpServer();
|
|
3289
3750
|
break;
|
|
3751
|
+
case "run-hook":
|
|
3752
|
+
cmdRunHook();
|
|
3753
|
+
break;
|
|
3754
|
+
case "migrate":
|
|
3755
|
+
cmdMigrate().catch((err) => {
|
|
3756
|
+
console.error(err);
|
|
3757
|
+
process.exit(1);
|
|
3758
|
+
});
|
|
3759
|
+
break;
|
|
3290
3760
|
case "--version":
|
|
3291
3761
|
case "-v":
|
|
3292
3762
|
print(`sticky-note v${VERSION}`);
|
|
@@ -3317,6 +3787,7 @@ async function main() {
|
|
|
3317
3787
|
print(" bootstrap Provision MCP servers with secrets from manifest");
|
|
3318
3788
|
print(" env Environment management (status, add-server, add-plugin)");
|
|
3319
3789
|
print(" mcp-server Launch the sticky-note MCP server (stdio JSON-RPC)");
|
|
3790
|
+
print(" run-hook <name> Run a hook from any subdirectory (finds .claude/hooks/ up the tree)");
|
|
3320
3791
|
print("");
|
|
3321
3792
|
print(" Options:");
|
|
3322
3793
|
print(" --version Show version");
|