tap-the-sign 0.2.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/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +6 -0
- package/.cursor-plugin/plugin.json +13 -0
- package/LICENSE +21 -0
- package/README.md +160 -0
- package/commands/tap-the-sign-off.md +2 -0
- package/commands/tap-the-sign-on.md +2 -0
- package/commands/tap-the-sign-status.md +2 -0
- package/docs/REQUIREMENTS.md +99 -0
- package/hooks/hooks.claude.json +28 -0
- package/hooks/hooks.codex.json +28 -0
- package/hooks/hooks.cursor.json +18 -0
- package/hooks-bin/claude-arm.sh +6 -0
- package/hooks-bin/claude-stop.sh +6 -0
- package/hooks-bin/codex-arm.sh +6 -0
- package/hooks-bin/codex-stop.sh +6 -0
- package/hooks-bin/constants.mjs +33 -0
- package/hooks-bin/cursor-arm.sh +6 -0
- package/hooks-bin/cursor-stop.sh +6 -0
- package/hooks-bin/paths.mjs +101 -0
- package/hooks-bin/run-hook.mjs +175 -0
- package/hooks-bin/skill-check.mjs +70 -0
- package/hooks-bin/state.mjs +48 -0
- package/hooks-bin/tap-the-sign-env.sh +31 -0
- package/install.bun.sh +27 -0
- package/install.sh +28 -0
- package/package.json +52 -0
- package/scripts/sync-hooks-bin.mjs +20 -0
- package/src/cli.mjs +133 -0
- package/src/constants.mjs +33 -0
- package/src/hooks/runner.mjs +175 -0
- package/src/install-thermo.mjs +62 -0
- package/src/install.mjs +171 -0
- package/src/merge-config.mjs +95 -0
- package/src/paths.mjs +101 -0
- package/src/skill-check.mjs +70 -0
- package/src/state.mjs +48 -0
- package/src/uninstall.mjs +69 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
FOLLOW_UP_MESSAGE,
|
|
5
|
+
PHASE_DONE,
|
|
6
|
+
PHASE_PLANNING,
|
|
7
|
+
PHASE_PREFLIGHT,
|
|
8
|
+
SENTINEL_DISABLE,
|
|
9
|
+
SENTINEL_ENABLE,
|
|
10
|
+
SENTINEL_STATUS,
|
|
11
|
+
SKILL_MISSING_WARNING,
|
|
12
|
+
} from './constants.mjs';
|
|
13
|
+
import { getEnabledPath } from './paths.mjs';
|
|
14
|
+
import { isThermoSkillInstalled } from './skill-check.mjs';
|
|
15
|
+
import {
|
|
16
|
+
getSessionId,
|
|
17
|
+
readState,
|
|
18
|
+
transitionPhase,
|
|
19
|
+
} from './state.mjs';
|
|
20
|
+
|
|
21
|
+
function readStdin() {
|
|
22
|
+
return fs.readFileSync(0, 'utf8');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isEnabled() {
|
|
26
|
+
return fs.existsSync(getEnabledPath());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isPlanMode(input, host) {
|
|
30
|
+
if (host === 'cursor') {
|
|
31
|
+
return input.composer_mode === 'plan';
|
|
32
|
+
}
|
|
33
|
+
return input.permission_mode === 'plan';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function handleToggle(prompt, host) {
|
|
37
|
+
const enabledPath = getEnabledPath();
|
|
38
|
+
const dir = path.dirname(enabledPath);
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
|
|
41
|
+
if (prompt.includes(SENTINEL_ENABLE)) {
|
|
42
|
+
fs.writeFileSync(enabledPath, '1\n');
|
|
43
|
+
return respondToggle(host, 'tap-the-sign hook enabled.');
|
|
44
|
+
}
|
|
45
|
+
if (prompt.includes(SENTINEL_DISABLE)) {
|
|
46
|
+
if (fs.existsSync(enabledPath)) fs.unlinkSync(enabledPath);
|
|
47
|
+
return respondToggle(host, 'tap-the-sign hook disabled.');
|
|
48
|
+
}
|
|
49
|
+
if (prompt.includes(SENTINEL_STATUS)) {
|
|
50
|
+
const status = isEnabled() ? 'enabled' : 'disabled';
|
|
51
|
+
const skill = isThermoSkillInstalled() ? 'installed' : 'missing';
|
|
52
|
+
return respondToggle(
|
|
53
|
+
host,
|
|
54
|
+
`tap-the-sign hook is ${status}. Thermo skill is ${skill}.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function respondToggle(host, message) {
|
|
61
|
+
if (host === 'cursor') {
|
|
62
|
+
return JSON.stringify({ continue: false, user_message: message });
|
|
63
|
+
}
|
|
64
|
+
return JSON.stringify({ decision: 'block', reason: message });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function respondEmpty() {
|
|
68
|
+
return '{}';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function respondFollowUp(host, message) {
|
|
72
|
+
if (host === 'cursor') {
|
|
73
|
+
return JSON.stringify({ followup_message: message });
|
|
74
|
+
}
|
|
75
|
+
return JSON.stringify({
|
|
76
|
+
decision: 'block',
|
|
77
|
+
reason: message,
|
|
78
|
+
systemMessage: message,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function respondWarning(host, warning) {
|
|
83
|
+
console.error(warning);
|
|
84
|
+
if (host === 'cursor') {
|
|
85
|
+
return respondEmpty();
|
|
86
|
+
}
|
|
87
|
+
return JSON.stringify({ systemMessage: warning });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runHook({ event, host }) {
|
|
91
|
+
const raw = readStdin();
|
|
92
|
+
let input = {};
|
|
93
|
+
try {
|
|
94
|
+
input = JSON.parse(raw || '{}');
|
|
95
|
+
} catch {
|
|
96
|
+
input = {};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const prompt = input.prompt ?? '';
|
|
100
|
+
|
|
101
|
+
const toggleResponse = handleToggle(prompt, host);
|
|
102
|
+
if (toggleResponse) {
|
|
103
|
+
process.stdout.write(toggleResponse);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!isEnabled()) {
|
|
108
|
+
process.stdout.write(respondEmpty());
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const sessionId = getSessionId(input);
|
|
113
|
+
|
|
114
|
+
if (event === 'arm') {
|
|
115
|
+
if (!isPlanMode(input, host)) {
|
|
116
|
+
process.stdout.write(respondEmpty());
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
transitionPhase(sessionId, PHASE_PLANNING, { host });
|
|
120
|
+
process.stdout.write(respondEmpty());
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (event === 'stop') {
|
|
125
|
+
const status = input.status ?? 'completed';
|
|
126
|
+
if (status !== 'completed') {
|
|
127
|
+
process.stdout.write(respondEmpty());
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const state = readState(sessionId);
|
|
132
|
+
const phase = state.phase ?? PHASE_DONE;
|
|
133
|
+
|
|
134
|
+
if (phase === PHASE_PLANNING) {
|
|
135
|
+
if (!isThermoSkillInstalled()) {
|
|
136
|
+
transitionPhase(sessionId, PHASE_DONE);
|
|
137
|
+
process.stdout.write(respondWarning(host, SKILL_MISSING_WARNING));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
transitionPhase(sessionId, PHASE_PREFLIGHT);
|
|
141
|
+
process.stdout.write(respondFollowUp(host, FOLLOW_UP_MESSAGE));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (phase === PHASE_PREFLIGHT) {
|
|
146
|
+
transitionPhase(sessionId, PHASE_DONE);
|
|
147
|
+
process.stdout.write(respondEmpty());
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
process.stdout.write(respondEmpty());
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
process.stdout.write(respondEmpty());
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const isDirectRun = process.argv[1]?.includes('run-hook.mjs') ||
|
|
159
|
+
process.argv[1]?.includes('runner.mjs');
|
|
160
|
+
|
|
161
|
+
if (isDirectRun) {
|
|
162
|
+
const args = process.argv.slice(2);
|
|
163
|
+
let event = 'arm';
|
|
164
|
+
let host = 'cursor';
|
|
165
|
+
for (let i = 0; i < args.length; i++) {
|
|
166
|
+
if (args[i] === '--event' && args[i + 1]) event = args[++i];
|
|
167
|
+
if (args[i] === '--host' && args[i + 1]) host = args[++i];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
runHook({ event, host }).catch((err) => {
|
|
171
|
+
console.error(err);
|
|
172
|
+
process.stdout.write('{}');
|
|
173
|
+
process.exit(1);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { SKILL_NAME } from './constants.mjs';
|
|
3
|
+
import { findThermoSkill } from './skill-check.mjs';
|
|
4
|
+
|
|
5
|
+
const SKILLS_REPO = 'cursor/plugins';
|
|
6
|
+
const SKILL_FALLBACK_URL =
|
|
7
|
+
'https://github.com/cursor/plugins/tree/main/cursor-team-kit/skills/thermo-nuclear-code-quality-review';
|
|
8
|
+
|
|
9
|
+
function runSkillsAdd(args) {
|
|
10
|
+
const result = spawnSync('npx', ['skills', 'add', ...args], {
|
|
11
|
+
stdio: 'inherit',
|
|
12
|
+
shell: false,
|
|
13
|
+
});
|
|
14
|
+
return result.status === 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function installThermo({ global = false } = {}) {
|
|
18
|
+
const baseArgs = [
|
|
19
|
+
SKILLS_REPO,
|
|
20
|
+
'--skill',
|
|
21
|
+
SKILL_NAME,
|
|
22
|
+
'-a',
|
|
23
|
+
'cursor',
|
|
24
|
+
'-a',
|
|
25
|
+
'claude-code',
|
|
26
|
+
'-a',
|
|
27
|
+
'codex',
|
|
28
|
+
'--copy',
|
|
29
|
+
'-y',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
if (global) {
|
|
33
|
+
baseArgs.push('-g');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log('Installing thermo skill via npx skills add...');
|
|
37
|
+
let ok = runSkillsAdd(baseArgs);
|
|
38
|
+
|
|
39
|
+
if (!ok) {
|
|
40
|
+
console.log('Retrying with direct skill path...');
|
|
41
|
+
const fallbackArgs = [SKILL_FALLBACK_URL, '-y'];
|
|
42
|
+
if (global) fallbackArgs.push('-g');
|
|
43
|
+
ok = runSkillsAdd(fallbackArgs);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!ok) {
|
|
47
|
+
console.error('Failed to install thermo skill.');
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const skill = findThermoSkill();
|
|
52
|
+
if (!skill.found) {
|
|
53
|
+
console.error(
|
|
54
|
+
'skills add completed but thermo skill was not found in expected locations.',
|
|
55
|
+
);
|
|
56
|
+
console.error('Try: /add-plugin cursor-team-kit');
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log(`Thermo skill found at: ${skill.path}`);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
package/src/install.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { TAP_THE_SIGN_MARKER } from './constants.mjs';
|
|
4
|
+
import {
|
|
5
|
+
copyDirRecursive,
|
|
6
|
+
mergeClaudeStyleHooks,
|
|
7
|
+
mergeCursorHooks,
|
|
8
|
+
readJsonFile,
|
|
9
|
+
writeJsonFile,
|
|
10
|
+
} from './merge-config.mjs';
|
|
11
|
+
import {
|
|
12
|
+
PACKAGE_ROOT,
|
|
13
|
+
getConfigRoots,
|
|
14
|
+
setInstallScope,
|
|
15
|
+
} from './paths.mjs';
|
|
16
|
+
|
|
17
|
+
function hookScript(event, host, hooksDir) {
|
|
18
|
+
return path.join(hooksDir, `${host}-${event}.sh`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildCursorFragment(hooksDir) {
|
|
22
|
+
return {
|
|
23
|
+
version: 1,
|
|
24
|
+
hooks: {
|
|
25
|
+
beforeSubmitPrompt: [
|
|
26
|
+
{
|
|
27
|
+
[TAP_THE_SIGN_MARKER]: true,
|
|
28
|
+
command: hookScript('arm', 'cursor', hooksDir),
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
stop: [
|
|
32
|
+
{
|
|
33
|
+
[TAP_THE_SIGN_MARKER]: true,
|
|
34
|
+
command: hookScript('stop', 'cursor', hooksDir),
|
|
35
|
+
loop_limit: 1,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function claudeHookEntry(command) {
|
|
43
|
+
return {
|
|
44
|
+
hooks: [
|
|
45
|
+
{
|
|
46
|
+
[TAP_THE_SIGN_MARKER]: true,
|
|
47
|
+
type: 'command',
|
|
48
|
+
command,
|
|
49
|
+
timeout: 30,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildClaudeFragment(hooksDir) {
|
|
56
|
+
return {
|
|
57
|
+
hooks: {
|
|
58
|
+
UserPromptSubmit: [
|
|
59
|
+
claudeHookEntry(hookScript('arm', 'claude', hooksDir)),
|
|
60
|
+
],
|
|
61
|
+
Stop: [claudeHookEntry(hookScript('stop', 'claude', hooksDir))],
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildCodexFragment(hooksDir) {
|
|
67
|
+
return {
|
|
68
|
+
hooks: {
|
|
69
|
+
UserPromptSubmit: [
|
|
70
|
+
claudeHookEntry(hookScript('arm', 'codex', hooksDir)),
|
|
71
|
+
],
|
|
72
|
+
Stop: [claudeHookEntry(hookScript('stop', 'codex', hooksDir))],
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function installHookPayload(roots) {
|
|
78
|
+
const srcHooks = path.join(PACKAGE_ROOT, 'src', 'hooks');
|
|
79
|
+
const srcLib = path.join(PACKAGE_ROOT, 'src');
|
|
80
|
+
|
|
81
|
+
for (const dir of [roots.cursorHooksDir, roots.codexHooksDir]) {
|
|
82
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
83
|
+
copyDirRecursive(path.join(PACKAGE_ROOT, 'hooks-bin'), dir);
|
|
84
|
+
|
|
85
|
+
fs.copyFileSync(
|
|
86
|
+
path.join(srcHooks, 'runner.mjs'),
|
|
87
|
+
path.join(dir, 'run-hook.mjs'),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
for (const file of [
|
|
91
|
+
'constants.mjs',
|
|
92
|
+
'paths.mjs',
|
|
93
|
+
'state.mjs',
|
|
94
|
+
'skill-check.mjs',
|
|
95
|
+
]) {
|
|
96
|
+
fs.copyFileSync(path.join(srcLib, file), path.join(dir, file));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const envFile = path.join(dir, 'tap-the-sign.env');
|
|
100
|
+
const envLines = [
|
|
101
|
+
`TAP_THE_SIGN_ROOT=${dir}`,
|
|
102
|
+
roots.global
|
|
103
|
+
? 'TAP_THE_SIGN_GLOBAL=1'
|
|
104
|
+
: `TAP_THE_SIGN_PROJECT_ROOT=${roots.projectRoot}`,
|
|
105
|
+
];
|
|
106
|
+
fs.writeFileSync(envFile, envLines.join('\n') + '\n');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function installCursorCommands(roots) {
|
|
111
|
+
const src = path.join(PACKAGE_ROOT, 'commands');
|
|
112
|
+
if (!fs.existsSync(src)) return;
|
|
113
|
+
fs.mkdirSync(roots.cursorCommandsDir, { recursive: true });
|
|
114
|
+
for (const file of fs.readdirSync(src)) {
|
|
115
|
+
fs.copyFileSync(
|
|
116
|
+
path.join(src, file),
|
|
117
|
+
path.join(roots.cursorCommandsDir, file),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function removeLegacyHookDirs(roots) {
|
|
123
|
+
const legacyDirs = [
|
|
124
|
+
path.join(roots.projectRoot, '.cursor', 'hooks', 'plan-sandwich'),
|
|
125
|
+
path.join(roots.projectRoot, '.codex', 'hooks', 'plan-sandwich'),
|
|
126
|
+
];
|
|
127
|
+
for (const dir of legacyDirs) {
|
|
128
|
+
if (fs.existsSync(dir)) {
|
|
129
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function install({
|
|
135
|
+
global = false,
|
|
136
|
+
projectRoot = null,
|
|
137
|
+
cursor = true,
|
|
138
|
+
claude = true,
|
|
139
|
+
codex = true,
|
|
140
|
+
} = {}) {
|
|
141
|
+
setInstallScope({ global, projectRoot });
|
|
142
|
+
const roots = getConfigRoots();
|
|
143
|
+
|
|
144
|
+
fs.mkdirSync(roots.tapTheSignDir, { recursive: true });
|
|
145
|
+
fs.mkdirSync(path.join(roots.tapTheSignDir, 'state'), { recursive: true });
|
|
146
|
+
|
|
147
|
+
installHookPayload(roots);
|
|
148
|
+
removeLegacyHookDirs(roots);
|
|
149
|
+
|
|
150
|
+
if (cursor) {
|
|
151
|
+
const fragment = buildCursorFragment(roots.cursorHooksDir);
|
|
152
|
+
const existing = readJsonFile(roots.cursorHooksJson);
|
|
153
|
+
writeJsonFile(roots.cursorHooksJson, mergeCursorHooks(existing, fragment));
|
|
154
|
+
installCursorCommands(roots);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (claude) {
|
|
158
|
+
const fragment = buildClaudeFragment(roots.cursorHooksDir);
|
|
159
|
+
const existing = readJsonFile(roots.claudeSettings) ?? {};
|
|
160
|
+
const merged = { ...existing, ...mergeClaudeStyleHooks(existing, fragment) };
|
|
161
|
+
writeJsonFile(roots.claudeSettings, merged);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (codex) {
|
|
165
|
+
const fragment = buildCodexFragment(roots.codexHooksDir);
|
|
166
|
+
const existing = readJsonFile(roots.codexHooksJson);
|
|
167
|
+
writeJsonFile(roots.codexHooksJson, mergeClaudeStyleHooks(existing, fragment));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return roots;
|
|
171
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { LEGACY_MARKERS } from './constants.mjs';
|
|
4
|
+
|
|
5
|
+
export function isTapTheSignEntry(entry) {
|
|
6
|
+
if (!entry) return false;
|
|
7
|
+
return LEGACY_MARKERS.some((marker) => entry[marker] === true);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function filterTapTheSignOwned(entries) {
|
|
11
|
+
if (!Array.isArray(entries)) return [];
|
|
12
|
+
return entries.filter((e) => !isTapTheSignEntry(e));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function mergeCursorHooks(existing, fragment) {
|
|
16
|
+
const base = existing ?? { version: 1, hooks: {} };
|
|
17
|
+
const merged = { version: 1, hooks: { ...base.hooks } };
|
|
18
|
+
for (const [event, entries] of Object.entries(fragment.hooks ?? {})) {
|
|
19
|
+
merged.hooks[event] = [...filterTapTheSignOwned(base.hooks?.[event]), ...entries];
|
|
20
|
+
}
|
|
21
|
+
return merged;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function mergeClaudeStyleHooks(existing, fragment) {
|
|
25
|
+
const base = existing ?? { hooks: {} };
|
|
26
|
+
const merged = { hooks: { ...base.hooks } };
|
|
27
|
+
|
|
28
|
+
for (const [event, newGroups] of Object.entries(fragment.hooks ?? {})) {
|
|
29
|
+
const existingGroups = base.hooks?.[event] ?? [];
|
|
30
|
+
const cleaned = existingGroups
|
|
31
|
+
.map((group) => ({
|
|
32
|
+
...group,
|
|
33
|
+
hooks: (group.hooks ?? []).filter((h) => !isTapTheSignEntry(h)),
|
|
34
|
+
}))
|
|
35
|
+
.filter((group) => (group.hooks ?? []).length > 0);
|
|
36
|
+
|
|
37
|
+
merged.hooks[event] = [...cleaned, ...newGroups];
|
|
38
|
+
}
|
|
39
|
+
return merged;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readJsonFile(filePath) {
|
|
43
|
+
if (!fs.existsSync(filePath)) return null;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function writeJsonFile(filePath, data) {
|
|
52
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
53
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function stripTapTheSignFromCursorHooks(config) {
|
|
57
|
+
if (!config?.hooks) return { version: 1, hooks: {} };
|
|
58
|
+
const hooks = {};
|
|
59
|
+
for (const [event, entries] of Object.entries(config.hooks)) {
|
|
60
|
+
const filtered = filterTapTheSignOwned(entries);
|
|
61
|
+
if (filtered.length) hooks[event] = filtered;
|
|
62
|
+
}
|
|
63
|
+
return { version: 1, hooks };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function stripTapTheSignFromClaudeHooks(config) {
|
|
67
|
+
if (!config?.hooks) return { hooks: {} };
|
|
68
|
+
const hooks = {};
|
|
69
|
+
for (const [event, list] of Object.entries(config.hooks)) {
|
|
70
|
+
const cleaned = (list ?? [])
|
|
71
|
+
.map((group) => ({
|
|
72
|
+
...group,
|
|
73
|
+
hooks: (group.hooks ?? []).filter((h) => !isTapTheSignEntry(h)),
|
|
74
|
+
}))
|
|
75
|
+
.filter((group) => (group.hooks ?? []).length > 0);
|
|
76
|
+
if (cleaned.length) hooks[event] = cleaned;
|
|
77
|
+
}
|
|
78
|
+
return { hooks };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function copyDirRecursive(src, dest) {
|
|
82
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
83
|
+
for (const name of fs.readdirSync(src)) {
|
|
84
|
+
const from = path.join(src, name);
|
|
85
|
+
const to = path.join(dest, name);
|
|
86
|
+
if (fs.statSync(from).isDirectory()) {
|
|
87
|
+
copyDirRecursive(from, to);
|
|
88
|
+
} else {
|
|
89
|
+
fs.copyFileSync(from, to);
|
|
90
|
+
if (name.endsWith('.sh')) {
|
|
91
|
+
fs.chmodSync(to, 0o755);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/paths.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
export const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
8
|
+
|
|
9
|
+
let installScope = { global: false, projectRoot: null };
|
|
10
|
+
|
|
11
|
+
function initScopeFromEnv() {
|
|
12
|
+
if (process.env.TAP_THE_SIGN_GLOBAL === '1' || process.env.PLAN_SANDWICH_GLOBAL === '1') {
|
|
13
|
+
installScope = { global: true, projectRoot: os.homedir() };
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const projectRoot =
|
|
17
|
+
process.env.TAP_THE_SIGN_PROJECT_ROOT ?? process.env.PLAN_SANDWICH_PROJECT_ROOT;
|
|
18
|
+
if (projectRoot) {
|
|
19
|
+
installScope = { global: false, projectRoot };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
initScopeFromEnv();
|
|
24
|
+
|
|
25
|
+
export function setInstallScope({ global = false, projectRoot = null } = {}) {
|
|
26
|
+
installScope = {
|
|
27
|
+
global,
|
|
28
|
+
projectRoot: projectRoot ?? findProjectRoot(process.cwd()),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getInstallScope() {
|
|
33
|
+
return { ...installScope };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function findProjectRoot(startDir) {
|
|
37
|
+
let dir = path.resolve(startDir);
|
|
38
|
+
const root = path.parse(dir).root;
|
|
39
|
+
while (dir !== root) {
|
|
40
|
+
if (
|
|
41
|
+
fs.existsSync(path.join(dir, '.git')) ||
|
|
42
|
+
fs.existsSync(path.join(dir, '.cursor')) ||
|
|
43
|
+
fs.existsSync(path.join(dir, 'package.json'))
|
|
44
|
+
) {
|
|
45
|
+
return dir;
|
|
46
|
+
}
|
|
47
|
+
dir = path.dirname(dir);
|
|
48
|
+
}
|
|
49
|
+
return path.resolve(startDir);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getProjectRoot() {
|
|
53
|
+
return installScope.projectRoot ?? findProjectRoot(process.cwd());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getConfigRoots() {
|
|
57
|
+
if (installScope.global) {
|
|
58
|
+
const home = os.homedir();
|
|
59
|
+
return {
|
|
60
|
+
projectRoot: home,
|
|
61
|
+
cursorHooksDir: path.join(home, '.cursor', 'hooks', 'tap-the-sign'),
|
|
62
|
+
cursorHooksJson: path.join(home, '.cursor', 'hooks.json'),
|
|
63
|
+
cursorCommandsDir: path.join(home, '.cursor', 'commands'),
|
|
64
|
+
claudeSettings: path.join(home, '.claude', 'settings.json'),
|
|
65
|
+
codexHooksDir: path.join(home, '.codex', 'hooks', 'tap-the-sign'),
|
|
66
|
+
codexHooksJson: path.join(home, '.codex', 'hooks.json'),
|
|
67
|
+
tapTheSignDir: path.join(home, '.config', 'tap-the-sign'),
|
|
68
|
+
global: true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const projectRoot = getProjectRoot();
|
|
73
|
+
return {
|
|
74
|
+
projectRoot,
|
|
75
|
+
cursorHooksDir: path.join(projectRoot, '.cursor', 'hooks', 'tap-the-sign'),
|
|
76
|
+
cursorHooksJson: path.join(projectRoot, '.cursor', 'hooks.json'),
|
|
77
|
+
cursorCommandsDir: path.join(projectRoot, '.cursor', 'commands'),
|
|
78
|
+
claudeSettings: path.join(projectRoot, '.claude', 'settings.json'),
|
|
79
|
+
codexHooksDir: path.join(projectRoot, '.codex', 'hooks', 'tap-the-sign'),
|
|
80
|
+
codexHooksJson: path.join(projectRoot, '.codex', 'hooks.json'),
|
|
81
|
+
tapTheSignDir: path.join(projectRoot, '.tap-the-sign'),
|
|
82
|
+
global: false,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getEnabledPath() {
|
|
87
|
+
return path.join(getConfigRoots().tapTheSignDir, 'enabled');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getStateDir() {
|
|
91
|
+
return path.join(getConfigRoots().tapTheSignDir, 'state');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveInstalledRunner() {
|
|
95
|
+
const roots = getConfigRoots();
|
|
96
|
+
const runner = path.join(roots.cursorHooksDir, 'run-hook.mjs');
|
|
97
|
+
if (fs.existsSync(runner)) {
|
|
98
|
+
return runner;
|
|
99
|
+
}
|
|
100
|
+
return path.join(PACKAGE_ROOT, 'src', 'hooks', 'runner.mjs');
|
|
101
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { SKILL_NAME } from './constants.mjs';
|
|
5
|
+
import { getProjectRoot } from './paths.mjs';
|
|
6
|
+
|
|
7
|
+
function walkForSkill(dir, maxDepth = 6, depth = 0) {
|
|
8
|
+
if (!fs.existsSync(dir) || depth > maxDepth) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const skillFile = path.join(
|
|
13
|
+
dir,
|
|
14
|
+
'skills',
|
|
15
|
+
SKILL_NAME,
|
|
16
|
+
'SKILL.md',
|
|
17
|
+
);
|
|
18
|
+
if (fs.existsSync(skillFile)) {
|
|
19
|
+
return skillFile;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const directSkill = path.join(dir, SKILL_NAME, 'SKILL.md');
|
|
23
|
+
if (fs.existsSync(directSkill)) {
|
|
24
|
+
return directSkill;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (!entry.isDirectory()) continue;
|
|
36
|
+
if (entry.name === 'node_modules' || entry.name === '.git') continue;
|
|
37
|
+
const found = walkForSkill(path.join(dir, entry.name), maxDepth, depth + 1);
|
|
38
|
+
if (found) return found;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function findThermoSkill() {
|
|
44
|
+
const home = os.homedir();
|
|
45
|
+
const projectRoot = getProjectRoot();
|
|
46
|
+
|
|
47
|
+
const searchRoots = [
|
|
48
|
+
path.join(projectRoot, '.agents', 'skills'),
|
|
49
|
+
path.join(projectRoot, '.cursor', 'skills'),
|
|
50
|
+
path.join(projectRoot, '.claude', 'skills'),
|
|
51
|
+
path.join(home, '.cursor', 'skills'),
|
|
52
|
+
path.join(home, '.codex', 'skills'),
|
|
53
|
+
path.join(home, '.cursor', 'plugins'),
|
|
54
|
+
path.join(home, '.claude', 'plugins'),
|
|
55
|
+
path.join(home, '.cursor', 'plugins', 'cache'),
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
for (const root of searchRoots) {
|
|
59
|
+
const found = walkForSkill(root);
|
|
60
|
+
if (found) {
|
|
61
|
+
return { found: true, path: found };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { found: false, path: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isThermoSkillInstalled() {
|
|
69
|
+
return findThermoSkill().found;
|
|
70
|
+
}
|