sticky-note-cli 3.1.39 → 3.1.41
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/bin/cli.js +13 -3
- package/package.json +3 -2
- package/src/core/sticky-dir.js +23 -0
- package/src/index.js +57 -0
- package/src/mcp/config.js +55 -0
- package/src/mcp/detection.js +127 -0
- package/src/utils/file-io.js +96 -0
- package/src/utils/git.js +27 -0
- package/src/utils/hook.js +74 -0
- package/src/utils/package.js +41 -0
- package/src/utils/semver.js +34 -0
- package/src/utils/ui.js +38 -0
- package/templates/hooks/data-branch.js +104 -16
- package/templates/hooks/pre-tool-use.js +5 -1
- package/templates/hooks/sticky-utils.js +9 -0
package/bin/cli.js
CHANGED
|
@@ -720,12 +720,22 @@ async function cmdInit() {
|
|
|
720
720
|
// Teammate flow: URL already in committed config — just need their API key
|
|
721
721
|
print(`\n Cloud backend detected: ${configStickyUrl}`);
|
|
722
722
|
const keyAnswer = await ask(rl, "Enter your STICKY_API_KEY (get it from your team lead)", "");
|
|
723
|
-
|
|
723
|
+
// GitHub push token — needed so session-end can push the data branch from
|
|
724
|
+
// background hook processes that don't have keychain/credential-manager access.
|
|
725
|
+
print("\n A GitHub Personal Access Token (PAT) is needed so sticky-note can push");
|
|
726
|
+
print(" thread data in the background without requiring interactive credential access.");
|
|
727
|
+
print(" Create one at https://github.com/settings/tokens with 'repo' scope (or");
|
|
728
|
+
print(" 'contents: write' for fine-grained tokens). Your team lead may share one.");
|
|
729
|
+
const pushTokenAnswer = await ask(rl, "Enter your STICKY_PUSH_TOKEN (GitHub PAT)", "");
|
|
730
|
+
if (keyAnswer.trim() || pushTokenAnswer.trim()) {
|
|
724
731
|
const envPath = path.join(process.cwd(), ".env.sticky");
|
|
725
|
-
|
|
732
|
+
let envContent = `STICKY_URL=${configStickyUrl}\n`;
|
|
733
|
+
if (keyAnswer.trim()) envContent += `STICKY_API_KEY=${keyAnswer.trim()}\n`;
|
|
734
|
+
if (pushTokenAnswer.trim()) envContent += `STICKY_PUSH_TOKEN=${pushTokenAnswer.trim()}\n`;
|
|
735
|
+
fs.writeFileSync(envPath, envContent, "utf-8");
|
|
726
736
|
print(" [OK] .env.sticky written");
|
|
727
737
|
} else {
|
|
728
|
-
print(" ⏭️ No
|
|
738
|
+
print(" ⏭️ No keys entered — skipping .env.sticky (local-only mode)");
|
|
729
739
|
}
|
|
730
740
|
} else {
|
|
731
741
|
const cloudAnswer = await ask(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sticky-note-cli",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.41",
|
|
4
4
|
"description": "Human-to-human handoff for AI coding assistants. Git-backed shared memory for Claude Code and Copilot CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"bin/",
|
|
28
|
+
"src/",
|
|
28
29
|
"templates/",
|
|
29
30
|
"sticky-server/"
|
|
30
31
|
],
|
|
@@ -35,6 +36,6 @@
|
|
|
35
36
|
"test": "node test/smoke.test.js"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
|
-
"sticky-note-cli": "^3.1.
|
|
39
|
+
"sticky-note-cli": "^3.1.38"
|
|
39
40
|
}
|
|
40
41
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { execFileSync } = require("child_process");
|
|
4
|
+
|
|
5
|
+
function getActiveStickyDir(cwd) {
|
|
6
|
+
const root = cwd || process.cwd();
|
|
7
|
+
try {
|
|
8
|
+
const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
|
|
9
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"], cwd: root,
|
|
10
|
+
}).trim();
|
|
11
|
+
const dataBranchDir = path.join(gitDir, "sticky-note");
|
|
12
|
+
if (fs.existsSync(path.join(dataBranchDir, "sticky-note.json"))) {
|
|
13
|
+
return dataBranchDir;
|
|
14
|
+
}
|
|
15
|
+
} catch (_) {
|
|
16
|
+
// not a git repo or git unavailable
|
|
17
|
+
}
|
|
18
|
+
return path.join(root, ".sticky-note");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = {
|
|
22
|
+
getActiveStickyDir,
|
|
23
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const ui = require("./utils/ui");
|
|
2
|
+
const fileIo = require("./utils/file-io");
|
|
3
|
+
const semver = require("./utils/semver");
|
|
4
|
+
const git = require("./utils/git");
|
|
5
|
+
const hook = require("./utils/hook");
|
|
6
|
+
const pkg = require("./utils/package");
|
|
7
|
+
const mcpConfig = require("./mcp/config");
|
|
8
|
+
const mcpDetection = require("./mcp/detection");
|
|
9
|
+
const stickyDir = require("./core/sticky-dir");
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
ui: {
|
|
13
|
+
print: ui.print,
|
|
14
|
+
debugLog: ui.debugLog,
|
|
15
|
+
printBanner: ui.printBanner,
|
|
16
|
+
ask: ui.ask,
|
|
17
|
+
VERSION: ui.VERSION,
|
|
18
|
+
},
|
|
19
|
+
fileIo: {
|
|
20
|
+
mkdirSafe: fileIo.mkdirSafe,
|
|
21
|
+
copyFile: fileIo.copyFile,
|
|
22
|
+
readTemplate: fileIo.readTemplate,
|
|
23
|
+
readJsonSafe: fileIo.readJsonSafe,
|
|
24
|
+
readEnvSticky: fileIo.readEnvSticky,
|
|
25
|
+
makeExecutable: fileIo.makeExecutable,
|
|
26
|
+
updateInstructionSection: fileIo.updateInstructionSection,
|
|
27
|
+
countJsonlLines: fileIo.countJsonlLines,
|
|
28
|
+
},
|
|
29
|
+
semver: {
|
|
30
|
+
parseSemver: semver.parseSemver,
|
|
31
|
+
compareSemver: semver.compareSemver,
|
|
32
|
+
bumpMinVersion: semver.bumpMinVersion,
|
|
33
|
+
parseIntOr: semver.parseIntOr,
|
|
34
|
+
},
|
|
35
|
+
git: {
|
|
36
|
+
isGitRepo: git.isGitRepo,
|
|
37
|
+
getGitUserName: git.getGitUserName,
|
|
38
|
+
},
|
|
39
|
+
hook: {
|
|
40
|
+
resolveHookPaths: hook.resolveHookPaths,
|
|
41
|
+
findStaleHookPaths: hook.findStaleHookPaths,
|
|
42
|
+
installGitHook: hook.installGitHook,
|
|
43
|
+
},
|
|
44
|
+
pkg: {
|
|
45
|
+
pinCliInPackageJson: pkg.pinCliInPackageJson,
|
|
46
|
+
},
|
|
47
|
+
mcp: {
|
|
48
|
+
getCopilotCliConfigDir: mcpConfig.getCopilotCliConfigDir,
|
|
49
|
+
getCopilotCliMcpConfigPath: mcpConfig.getCopilotCliMcpConfigPath,
|
|
50
|
+
ensureMcpInCopilotCliConfig: mcpConfig.ensureMcpInCopilotCliConfig,
|
|
51
|
+
detectMcpServers: mcpDetection.detectMcpServers,
|
|
52
|
+
detectSkills: mcpDetection.detectSkills,
|
|
53
|
+
},
|
|
54
|
+
core: {
|
|
55
|
+
getActiveStickyDir: stickyDir.getActiveStickyDir,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
function getCopilotCliConfigDir() {
|
|
5
|
+
if (process.env.COPILOT_HOME) return process.env.COPILOT_HOME;
|
|
6
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
7
|
+
if (!home) return null;
|
|
8
|
+
return path.join(home, ".copilot");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getCopilotCliMcpConfigPath() {
|
|
12
|
+
const dir = getCopilotCliConfigDir();
|
|
13
|
+
return dir ? path.join(dir, "mcp-config.json") : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ensureMcpInCopilotCliConfig(serverName, serverConfig) {
|
|
17
|
+
try {
|
|
18
|
+
const configDir = getCopilotCliConfigDir();
|
|
19
|
+
if (!configDir || !fs.existsSync(configDir)) return false;
|
|
20
|
+
|
|
21
|
+
const configPath = path.join(configDir, "mcp-config.json");
|
|
22
|
+
let config = {};
|
|
23
|
+
if (fs.existsSync(configPath)) {
|
|
24
|
+
try {
|
|
25
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
26
|
+
} catch (_) {
|
|
27
|
+
config = {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
config.mcpServers = config.mcpServers || {};
|
|
32
|
+
const existing = config.mcpServers[serverName];
|
|
33
|
+
if (existing) {
|
|
34
|
+
if (!existing.tools) {
|
|
35
|
+
existing.tools = ["*"];
|
|
36
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!serverConfig.tools) serverConfig.tools = ["*"];
|
|
43
|
+
config.mcpServers[serverName] = serverConfig;
|
|
44
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
45
|
+
return true;
|
|
46
|
+
} catch (_) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
getCopilotCliConfigDir,
|
|
53
|
+
getCopilotCliMcpConfigPath,
|
|
54
|
+
ensureMcpInCopilotCliConfig,
|
|
55
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const os = require("os");
|
|
4
|
+
const { getCopilotCliMcpConfigPath } = require("./config");
|
|
5
|
+
const { debugLog } = require("../utils/ui");
|
|
6
|
+
|
|
7
|
+
function detectMcpServers() {
|
|
8
|
+
const servers = new Map();
|
|
9
|
+
|
|
10
|
+
const mcpJsonPath = path.join(process.cwd(), ".mcp.json");
|
|
11
|
+
if (fs.existsSync(mcpJsonPath)) {
|
|
12
|
+
try {
|
|
13
|
+
const mcpJson = JSON.parse(fs.readFileSync(mcpJsonPath, "utf-8"));
|
|
14
|
+
const mcpServers = mcpJson.mcpServers || {};
|
|
15
|
+
for (const [name, config] of Object.entries(mcpServers)) {
|
|
16
|
+
const entry = {
|
|
17
|
+
name,
|
|
18
|
+
type: config.type || config.transport || "unknown",
|
|
19
|
+
source: ".mcp.json",
|
|
20
|
+
};
|
|
21
|
+
if (config.command) entry.command = config.command;
|
|
22
|
+
if (config.args) entry.args = config.args;
|
|
23
|
+
if (config.env) entry.env = config.env;
|
|
24
|
+
if (config.url) entry.url = config.url;
|
|
25
|
+
servers.set(name, entry);
|
|
26
|
+
}
|
|
27
|
+
} catch (err) { debugLog("detectMcpServers .mcp.json: " + (err.message || err)); }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const copilotMcpPath = getCopilotCliMcpConfigPath();
|
|
31
|
+
if (copilotMcpPath && fs.existsSync(copilotMcpPath)) {
|
|
32
|
+
try {
|
|
33
|
+
const copilotMcp = JSON.parse(fs.readFileSync(copilotMcpPath, "utf-8"));
|
|
34
|
+
const mcpServers = copilotMcp.mcpServers || {};
|
|
35
|
+
for (const [name, config] of Object.entries(mcpServers)) {
|
|
36
|
+
if (servers.has(name)) continue;
|
|
37
|
+
const entry = {
|
|
38
|
+
name,
|
|
39
|
+
type: config.type || config.transport || "unknown",
|
|
40
|
+
source: "~/.copilot/mcp-config.json",
|
|
41
|
+
};
|
|
42
|
+
if (config.command) entry.command = config.command;
|
|
43
|
+
if (config.args) entry.args = config.args;
|
|
44
|
+
if (config.env) entry.env = config.env;
|
|
45
|
+
if (config.url) entry.url = config.url;
|
|
46
|
+
servers.set(name, entry);
|
|
47
|
+
}
|
|
48
|
+
} catch (err) { debugLog("detectMcpServers copilot mcp-config: " + (err.message || err)); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const globalClaudeSettingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
|
52
|
+
if (fs.existsSync(globalClaudeSettingsPath)) {
|
|
53
|
+
try {
|
|
54
|
+
const globalSettings = JSON.parse(fs.readFileSync(globalClaudeSettingsPath, "utf-8"));
|
|
55
|
+
const globalMcp = globalSettings.mcpServers || {};
|
|
56
|
+
for (const [name, config] of Object.entries(globalMcp)) {
|
|
57
|
+
if (servers.has(name)) continue;
|
|
58
|
+
if (config.type === "permission-detected") continue;
|
|
59
|
+
const entry = {
|
|
60
|
+
name,
|
|
61
|
+
type: config.type || config.transport || "stdio",
|
|
62
|
+
source: "~/.claude/settings.json",
|
|
63
|
+
};
|
|
64
|
+
if (config.command) entry.command = config.command;
|
|
65
|
+
if (config.args) entry.args = config.args;
|
|
66
|
+
if (config.env) entry.env = config.env;
|
|
67
|
+
if (config.url) entry.url = config.url;
|
|
68
|
+
servers.set(name, entry);
|
|
69
|
+
}
|
|
70
|
+
} catch (err) { debugLog("detectMcpServers global settings: " + (err.message || err)); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const localSettingsPath = path.join(process.cwd(), ".claude", "settings.local.json");
|
|
74
|
+
if (fs.existsSync(localSettingsPath)) {
|
|
75
|
+
try {
|
|
76
|
+
const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, "utf-8"));
|
|
77
|
+
const permissions = [
|
|
78
|
+
...(localSettings.allow || []),
|
|
79
|
+
...(localSettings.permissions?.allow || []),
|
|
80
|
+
];
|
|
81
|
+
for (const perm of permissions) {
|
|
82
|
+
if (typeof perm === "string" && perm.startsWith("mcp__")) {
|
|
83
|
+
const serverName = perm.split("__")[1];
|
|
84
|
+
if (serverName && !servers.has(serverName)) {
|
|
85
|
+
servers.set(serverName, {
|
|
86
|
+
name: serverName,
|
|
87
|
+
type: "account-mcp",
|
|
88
|
+
source: "settings.local.json",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch (err) { debugLog("detectMcpServers settings.local.json: " + (err.message || err)); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Array.from(servers.values());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function detectSkills() {
|
|
100
|
+
const skills = new Set();
|
|
101
|
+
|
|
102
|
+
const localSettingsPath = path.join(process.cwd(), ".claude", "settings.local.json");
|
|
103
|
+
if (fs.existsSync(localSettingsPath)) {
|
|
104
|
+
try {
|
|
105
|
+
const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, "utf-8"));
|
|
106
|
+
const permissions = [
|
|
107
|
+
...(localSettings.allow || []),
|
|
108
|
+
...(localSettings.permissions?.allow || []),
|
|
109
|
+
];
|
|
110
|
+
for (const perm of permissions) {
|
|
111
|
+
if (typeof perm === "string") {
|
|
112
|
+
const match = perm.match(/^Skill\(([^)]+)\)/);
|
|
113
|
+
if (match) {
|
|
114
|
+
skills.add(match[1]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch (err) { debugLog("detectSkills: " + (err.message || err)); }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return Array.from(skills);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
detectMcpServers,
|
|
126
|
+
detectSkills,
|
|
127
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
function mkdirSafe(dir) {
|
|
5
|
+
if (!fs.existsSync(dir)) {
|
|
6
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function copyFile(src, dest) {
|
|
11
|
+
fs.copyFileSync(src, dest);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readTemplate(name, templatesDir) {
|
|
15
|
+
return fs.readFileSync(path.join(templatesDir, name), "utf-8");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readJsonSafe(filePath, fallback) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readEnvSticky(cwd) {
|
|
27
|
+
try {
|
|
28
|
+
const raw = fs.readFileSync(path.join(cwd, ".env.sticky"), "utf-8");
|
|
29
|
+
let url = "", key = "";
|
|
30
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
31
|
+
const t = line.trim();
|
|
32
|
+
if (t.startsWith("STICKY_URL=")) url = t.slice("STICKY_URL=".length).trim();
|
|
33
|
+
else if (t.startsWith("STICKY_API_KEY=")) key = t.slice("STICKY_API_KEY=".length).trim();
|
|
34
|
+
}
|
|
35
|
+
return { url, key };
|
|
36
|
+
} catch (_) {
|
|
37
|
+
return { url: "", key: "" };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeExecutable(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
fs.chmodSync(filePath, 0o755);
|
|
44
|
+
} catch {
|
|
45
|
+
// Windows doesn't support chmod
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SECTION_START = "<!-- sticky-note:start";
|
|
50
|
+
const SECTION_END = "<!-- sticky-note:end -->";
|
|
51
|
+
|
|
52
|
+
function updateInstructionSection(destPath, templateContent, heading) {
|
|
53
|
+
if (!fs.existsSync(destPath)) {
|
|
54
|
+
fs.writeFileSync(destPath, templateContent);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const existing = fs.readFileSync(destPath, "utf-8");
|
|
59
|
+
const startIdx = existing.indexOf(SECTION_START);
|
|
60
|
+
const endIdx = existing.indexOf(SECTION_END);
|
|
61
|
+
|
|
62
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
63
|
+
const before = existing.slice(0, startIdx);
|
|
64
|
+
const after = existing.slice(endIdx + SECTION_END.length);
|
|
65
|
+
fs.writeFileSync(destPath, before + templateContent.trim() + after);
|
|
66
|
+
} else if (heading && existing.includes(heading)) {
|
|
67
|
+
const headingIdx = existing.indexOf(heading);
|
|
68
|
+
const before = existing.slice(0, headingIdx);
|
|
69
|
+
fs.writeFileSync(destPath, before + templateContent);
|
|
70
|
+
} else {
|
|
71
|
+
const sep = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
72
|
+
fs.writeFileSync(destPath, existing + sep + templateContent);
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function countJsonlLines(filePath) {
|
|
78
|
+
if (!fs.existsSync(filePath)) return 0;
|
|
79
|
+
try {
|
|
80
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
81
|
+
return content.split(/\r?\n/).filter((l) => l.trim()).length;
|
|
82
|
+
} catch {
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
mkdirSafe,
|
|
89
|
+
copyFile,
|
|
90
|
+
readTemplate,
|
|
91
|
+
readJsonSafe,
|
|
92
|
+
readEnvSticky,
|
|
93
|
+
makeExecutable,
|
|
94
|
+
updateInstructionSection,
|
|
95
|
+
countJsonlLines,
|
|
96
|
+
};
|
package/src/utils/git.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const { execFileSync } = require("child_process");
|
|
2
|
+
|
|
3
|
+
function isGitRepo() {
|
|
4
|
+
try {
|
|
5
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { stdio: "pipe" });
|
|
6
|
+
return true;
|
|
7
|
+
} catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getGitUserName() {
|
|
13
|
+
try {
|
|
14
|
+
const name = execFileSync("git", ["config", "user.name"], {
|
|
15
|
+
encoding: "utf-8",
|
|
16
|
+
stdio: "pipe",
|
|
17
|
+
}).trim();
|
|
18
|
+
return name || null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
isGitRepo,
|
|
26
|
+
getGitUserName,
|
|
27
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
function resolveHookPaths(_obj, _rootDir) {
|
|
5
|
+
return;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function findStaleHookPaths(settings) {
|
|
9
|
+
const stale = [];
|
|
10
|
+
const hooks = (settings && settings.hooks) || {};
|
|
11
|
+
for (const entries of Object.values(hooks)) {
|
|
12
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
13
|
+
for (const h of entry.hooks || []) {
|
|
14
|
+
const cmd = h && h.command;
|
|
15
|
+
if (typeof cmd !== "string") continue;
|
|
16
|
+
if (
|
|
17
|
+
/["'][A-Za-z]:[\\/]/.test(cmd) ||
|
|
18
|
+
/["']\/(?:Users|home|root|tmp)\//.test(cmd) ||
|
|
19
|
+
cmd.includes("$(git rev-parse")
|
|
20
|
+
) {
|
|
21
|
+
stale.push(cmd);
|
|
22
|
+
}
|
|
23
|
+
if (/^npx\s+sticky-note\s+run-hook\s+\S+/.test(cmd)) {
|
|
24
|
+
stale.push(cmd);
|
|
25
|
+
}
|
|
26
|
+
if (/^node\s+"?\.claude\/hooks\//.test(cmd)) {
|
|
27
|
+
stale.push(cmd);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return stale;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function installGitHook(hookName, templatesDir) {
|
|
36
|
+
try {
|
|
37
|
+
const gitDir = path.join(process.cwd(), ".git");
|
|
38
|
+
if (!fs.existsSync(gitDir)) return false;
|
|
39
|
+
const hooksDir = path.join(gitDir, "hooks");
|
|
40
|
+
|
|
41
|
+
if (!fs.existsSync(hooksDir)) {
|
|
42
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const src = path.join(templatesDir, "hooks", hookName + ".js");
|
|
46
|
+
if (!fs.existsSync(src)) return false;
|
|
47
|
+
|
|
48
|
+
if (process.platform === "win32") {
|
|
49
|
+
const shimContent =
|
|
50
|
+
`@echo off\r\nnode "%~dp0..\\..\\templates\\hooks\\${hookName}.js" %*\r\n`;
|
|
51
|
+
const dest = path.join(hooksDir, hookName);
|
|
52
|
+
fs.writeFileSync(dest, shimContent, "utf-8");
|
|
53
|
+
} else {
|
|
54
|
+
const shimContent =
|
|
55
|
+
`#!/bin/sh\nnode "$(dirname "$0")/../../templates/hooks/${hookName}.js" "$@"\n`;
|
|
56
|
+
const dest = path.join(hooksDir, hookName);
|
|
57
|
+
fs.writeFileSync(dest, shimContent, "utf-8");
|
|
58
|
+
try {
|
|
59
|
+
fs.chmodSync(dest, 0o755);
|
|
60
|
+
} catch {
|
|
61
|
+
// Windows doesn't support chmod
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
resolveHookPaths,
|
|
72
|
+
findStaleHookPaths,
|
|
73
|
+
installGitHook,
|
|
74
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { compareSemver } = require("./semver");
|
|
4
|
+
const { print } = require("./ui");
|
|
5
|
+
const { VERSION } = require("./ui");
|
|
6
|
+
|
|
7
|
+
function pinCliInPackageJson() {
|
|
8
|
+
const pkgPath = path.join(process.cwd(), "package.json");
|
|
9
|
+
if (!fs.existsSync(pkgPath)) {
|
|
10
|
+
print(" ⏭️ No package.json found — skipping devDependency pin");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
let pkg;
|
|
14
|
+
try {
|
|
15
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
16
|
+
} catch (err) {
|
|
17
|
+
print(` ⏭️ Could not parse package.json: ${err.message}`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
pkg.devDependencies = pkg.devDependencies || {};
|
|
21
|
+
const existing = pkg.devDependencies["sticky-note-cli"];
|
|
22
|
+
const desired = `^${VERSION}`;
|
|
23
|
+
if (existing) {
|
|
24
|
+
const existingClean = String(existing).replace(/^[\^~]/, "");
|
|
25
|
+
if (compareSemver(existingClean, VERSION) >= 0) {
|
|
26
|
+
print(` ⏭️ package.json devDependencies already pin sticky-note-cli ${existing}`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
pkg.devDependencies["sticky-note-cli"] = desired;
|
|
31
|
+
pkg.devDependencies = Object.fromEntries(
|
|
32
|
+
Object.entries(pkg.devDependencies).sort(([a], [b]) => a.localeCompare(b))
|
|
33
|
+
);
|
|
34
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
35
|
+
print(` [OK] package.json devDependencies: sticky-note-cli ${desired}`);
|
|
36
|
+
print(" Run `npm install` to lock the version for your team.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
pinCliInPackageJson,
|
|
41
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function parseSemver(v) {
|
|
2
|
+
if (typeof v !== "string") return null;
|
|
3
|
+
const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
4
|
+
if (!m) return null;
|
|
5
|
+
return [Number(m[1]), Number(m[2]), Number(m[3] || 0)];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function compareSemver(a, b) {
|
|
9
|
+
const pa = parseSemver(a), pb = parseSemver(b);
|
|
10
|
+
if (!pa && !pb) return 0;
|
|
11
|
+
if (!pa) return 1;
|
|
12
|
+
if (!pb) return -1;
|
|
13
|
+
for (let i = 0; i < 3; i++) {
|
|
14
|
+
if (pa[i] !== pb[i]) return pa[i] < pb[i] ? -1 : 1;
|
|
15
|
+
}
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function bumpMinVersion(existing, current) {
|
|
20
|
+
if (!existing) return current;
|
|
21
|
+
return compareSemver(existing, current) >= 0 ? existing : current;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseIntOr(str, fallback) {
|
|
25
|
+
const n = parseInt(str, 10);
|
|
26
|
+
return Number.isNaN(n) ? fallback : n;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = {
|
|
30
|
+
parseSemver,
|
|
31
|
+
compareSemver,
|
|
32
|
+
bumpMinVersion,
|
|
33
|
+
parseIntOr,
|
|
34
|
+
};
|
package/src/utils/ui.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
const VERSION = require("../../package.json").version;
|
|
5
|
+
|
|
6
|
+
function print(msg) {
|
|
7
|
+
process.stdout.write(msg + "\n");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function debugLog(msg) {
|
|
11
|
+
if (process.env.STICKY_DEBUG) {
|
|
12
|
+
process.stderr.write(`[sticky-note] ${msg}\n`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function printBanner() {
|
|
17
|
+
print("");
|
|
18
|
+
print(` 📌 sticky-note v${VERSION}`);
|
|
19
|
+
print(" Human-to-human handoff for AI coding assistants");
|
|
20
|
+
print("");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ask(rl, question, defaultVal) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const suffix = defaultVal !== undefined ? ` (${defaultVal})` : "";
|
|
26
|
+
rl.question(` ${question}${suffix}: `, (answer) => {
|
|
27
|
+
resolve(answer.trim() || (defaultVal !== undefined ? String(defaultVal) : ""));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
print,
|
|
34
|
+
debugLog,
|
|
35
|
+
printBanner,
|
|
36
|
+
ask,
|
|
37
|
+
VERSION,
|
|
38
|
+
};
|
|
@@ -19,6 +19,52 @@ const DATA_REF = "refs/heads/" + DATA_BRANCH;
|
|
|
19
19
|
|
|
20
20
|
const GIT_OPTS = { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] };
|
|
21
21
|
|
|
22
|
+
// ── Credential helpers ────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read STICKY_PUSH_TOKEN from env or .env.sticky file.
|
|
26
|
+
* Returns the token string or null.
|
|
27
|
+
*/
|
|
28
|
+
function _getPushToken() {
|
|
29
|
+
if (process.env.STICKY_PUSH_TOKEN) return process.env.STICKY_PUSH_TOKEN;
|
|
30
|
+
try {
|
|
31
|
+
// Walk up to find .env.sticky
|
|
32
|
+
let dir = process.cwd();
|
|
33
|
+
for (let i = 0; i < 20; i++) {
|
|
34
|
+
const envPath = path.join(dir, ".env.sticky");
|
|
35
|
+
if (fs.existsSync(envPath)) {
|
|
36
|
+
const content = fs.readFileSync(envPath, "utf-8");
|
|
37
|
+
const match = content.match(/^STICKY_PUSH_TOKEN=(.+)$/m);
|
|
38
|
+
if (match) return match[1].trim();
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
const parent = path.dirname(dir);
|
|
42
|
+
if (parent === dir) break;
|
|
43
|
+
dir = parent;
|
|
44
|
+
}
|
|
45
|
+
} catch (_) {}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Given a remote name, return an authenticated push URL if STICKY_PUSH_TOKEN
|
|
51
|
+
* is configured. Embeds the token into the HTTPS URL so the push works from
|
|
52
|
+
* any process context without needing a credential helper.
|
|
53
|
+
* Returns null if no token or remote URL is not HTTPS.
|
|
54
|
+
*/
|
|
55
|
+
function _getAuthenticatedUrl(remote) {
|
|
56
|
+
const token = _getPushToken();
|
|
57
|
+
if (!token) return null;
|
|
58
|
+
try {
|
|
59
|
+
const url = execFileSync("git", ["remote", "get-url", remote], GIT_OPTS).trim();
|
|
60
|
+
if (!url.startsWith("https://")) return null;
|
|
61
|
+
// Insert token: https://TOKEN@github.com/...
|
|
62
|
+
return url.replace(/^https:\/\/([^@]*)@?/, `https://${token}@`);
|
|
63
|
+
} catch (_) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
22
68
|
// ── Git helpers ───────────────────────────────────────────
|
|
23
69
|
|
|
24
70
|
function getDefaultRemote() {
|
|
@@ -41,11 +87,23 @@ function readFileFromBranch(ref, filePath) {
|
|
|
41
87
|
}
|
|
42
88
|
}
|
|
43
89
|
|
|
90
|
+
/** Returns all file paths in a git ref as an array. */
|
|
91
|
+
function listFilesInBranch(ref) {
|
|
92
|
+
try {
|
|
93
|
+
const out = execFileSync("git", ["ls-tree", "-r", "--name-only", ref], GIT_OPTS);
|
|
94
|
+
return out.trim().split(/\r?\n/).filter(Boolean);
|
|
95
|
+
} catch (_) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
44
100
|
/**
|
|
45
101
|
* Commit { relativePath: content } to a branch using git plumbing.
|
|
46
102
|
* Does not touch the working tree or the main index. Returns the new commit SHA.
|
|
103
|
+
* explicitParentSha: if provided, use this SHA as the parent instead of the
|
|
104
|
+
* current local branch tip (used during retry to parent off the remote SHA).
|
|
47
105
|
*/
|
|
48
|
-
function commitFilesToBranch(branchName, fileMap) {
|
|
106
|
+
function commitFilesToBranch(branchName, fileMap, explicitParentSha) {
|
|
49
107
|
const branchRef = "refs/heads/" + branchName;
|
|
50
108
|
const tmpIndex = path.join(
|
|
51
109
|
os.tmpdir(),
|
|
@@ -53,13 +111,15 @@ function commitFilesToBranch(branchName, fileMap) {
|
|
|
53
111
|
);
|
|
54
112
|
|
|
55
113
|
try {
|
|
56
|
-
let parentSha = null;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
114
|
+
let parentSha = explicitParentSha || null;
|
|
115
|
+
if (!parentSha) {
|
|
116
|
+
try {
|
|
117
|
+
parentSha = execFileSync("git", ["rev-parse", "--verify", branchRef], {
|
|
118
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
119
|
+
}).trim();
|
|
120
|
+
} catch (_) {
|
|
121
|
+
// Branch doesn't exist yet — first commit creates it as orphan
|
|
122
|
+
}
|
|
63
123
|
}
|
|
64
124
|
|
|
65
125
|
const indexEnv = { ...process.env, GIT_INDEX_FILE: tmpIndex };
|
|
@@ -150,10 +210,13 @@ function pushDataBranch(remote, branchName, maxRetries, localMemPath, loadJsonFn
|
|
|
150
210
|
if (!remote) return { ok: false, error: "no remote configured" };
|
|
151
211
|
|
|
152
212
|
const pushSpec = DATA_REF + ":" + DATA_REF;
|
|
213
|
+
// Use token-authenticated URL if available — bypasses credential helpers so
|
|
214
|
+
// pushes work from background hook processes (no keychain/credential-manager needed).
|
|
215
|
+
const pushTarget = _getAuthenticatedUrl(remote) || remote;
|
|
153
216
|
|
|
154
217
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
155
218
|
try {
|
|
156
|
-
execFileSync("git", ["push",
|
|
219
|
+
execFileSync("git", ["push", pushTarget, pushSpec], {
|
|
157
220
|
timeout: 30000, stdio: ["pipe", "pipe", "pipe"],
|
|
158
221
|
});
|
|
159
222
|
return { ok: true, error: null };
|
|
@@ -162,15 +225,40 @@ function pushDataBranch(remote, branchName, maxRetries, localMemPath, loadJsonFn
|
|
|
162
225
|
return { ok: false, error: err.message };
|
|
163
226
|
}
|
|
164
227
|
|
|
165
|
-
// Fetch
|
|
228
|
+
// Fetch remote, build a merged file map (local files + remote files +
|
|
229
|
+
// merged sticky-note.json), then re-commit parented off the remote SHA
|
|
230
|
+
// so the next push attempt is a fast-forward.
|
|
166
231
|
try {
|
|
167
232
|
const fetchResult = fetchDataBranch(remote, branchName);
|
|
168
|
-
if (fetchResult.ok && fetchResult.remoteRef
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
233
|
+
if (fetchResult.ok && fetchResult.remoteRef) {
|
|
234
|
+
// Get the remote SHA to use as parent for the re-commit
|
|
235
|
+
const remoteSha = execFileSync(
|
|
236
|
+
"git", ["rev-parse", fetchResult.remoteRef], GIT_OPTS
|
|
237
|
+
).trim();
|
|
238
|
+
|
|
239
|
+
// Build merged file map: start with remote files as base, then
|
|
240
|
+
// overlay local files so this user's audit/presence are preserved
|
|
241
|
+
const fileMap = {};
|
|
242
|
+
const localRef = "refs/heads/" + branchName;
|
|
243
|
+
for (const f of listFilesInBranch(fetchResult.remoteRef)) {
|
|
244
|
+
const content = readFileFromBranch(fetchResult.remoteRef, f);
|
|
245
|
+
if (content !== null) fileMap[f] = content;
|
|
246
|
+
}
|
|
247
|
+
for (const f of listFilesInBranch(localRef)) {
|
|
248
|
+
const content = readFileFromBranch(localRef, f);
|
|
249
|
+
if (content !== null) fileMap[f] = content;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Merge sticky-note.json from both sides
|
|
253
|
+
if (localMemPath && fileMap["sticky-note.json"]) {
|
|
254
|
+
mergeAndSaveFromRemote(
|
|
255
|
+
localMemPath, fileMap["sticky-note.json"], loadJsonFn, saveJsonFn
|
|
256
|
+
);
|
|
257
|
+
fileMap["sticky-note.json"] = fs.readFileSync(localMemPath, "utf-8");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (Object.keys(fileMap).length > 0) {
|
|
261
|
+
commitFilesToBranch(branchName, fileMap, remoteSha);
|
|
174
262
|
}
|
|
175
263
|
}
|
|
176
264
|
} catch (_) {}
|
|
@@ -193,7 +193,11 @@ async function main() {
|
|
|
193
193
|
const toolInput = hookInput.tool_input || hookInput.input || hookInput.toolArgs || {};
|
|
194
194
|
const cmd = typeof toolInput === "string" ? toolInput : (toolInput.command || "");
|
|
195
195
|
if (/\bgit\s+(pull|merge|rebase|fetch)\b/.test(cmd)) {
|
|
196
|
-
try {
|
|
196
|
+
try {
|
|
197
|
+
syncStickyNote({});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
_emit(`[STICKY-NOTE] ⚠️ Auto-sync before git operation failed: ${err && err.message ? err.message : err}\nSticky-note files may be dirty — run \`npx sticky-note pull\` manually to avoid conflicts.`);
|
|
200
|
+
}
|
|
197
201
|
}
|
|
198
202
|
}
|
|
199
203
|
|
|
@@ -641,6 +641,15 @@ function appendAuditLineBoth(entry, cloud) {
|
|
|
641
641
|
// ── Environment helpers ───────────────────────────────────
|
|
642
642
|
|
|
643
643
|
function getUser() {
|
|
644
|
+
// Prefer git config user.name — consistent across machines regardless of OS login name.
|
|
645
|
+
// Fall back to OS env vars, then "unknown".
|
|
646
|
+
try {
|
|
647
|
+
const { execFileSync } = require("child_process");
|
|
648
|
+
const name = execFileSync("git", ["config", "user.name"], {
|
|
649
|
+
encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
|
|
650
|
+
}).trim();
|
|
651
|
+
if (name) return name;
|
|
652
|
+
} catch (_) {}
|
|
644
653
|
return process.env.USER || process.env.USERNAME || "unknown";
|
|
645
654
|
}
|
|
646
655
|
|