lakonai 0.6.1
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/LICENSE +21 -0
- package/README.md +359 -0
- package/assets/logo.svg +12 -0
- package/bin/lakon.js +177 -0
- package/package.json +53 -0
- package/src/filters/cat.js +18 -0
- package/src/filters/git.js +95 -0
- package/src/filters/grep.js +14 -0
- package/src/filters/index.js +35 -0
- package/src/filters/ls.js +34 -0
- package/src/filters/utils.js +29 -0
- package/src/hooks/bash-rewrite.js +54 -0
- package/src/hooks/grep-guard.js +83 -0
- package/src/hooks/read-guard.js +183 -0
- package/src/hooks/session-start.js +33 -0
- package/src/hooks/stop-hook.js +84 -0
- package/src/hooks/throttle.js +32 -0
- package/src/hooks/version-check.js +177 -0
- package/src/install/backup.js +70 -0
- package/src/install/claude-commands.js +71 -0
- package/src/install/claude-hook.js +177 -0
- package/src/install/index.js +159 -0
- package/src/install/paths.js +9 -0
- package/src/install/platforms.js +129 -0
- package/src/rules/caveman.md +103 -0
- package/src/tracking.js +232 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
|
|
9
|
+
const CHECK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
const FETCH_TIMEOUT_MS = 1500;
|
|
11
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/lakon/latest';
|
|
12
|
+
|
|
13
|
+
function lakonHome() {
|
|
14
|
+
/* c8 ignore next */
|
|
15
|
+
return process.env.LAKON_HOME || path.join(os.homedir(), '.lakon');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cachePath() {
|
|
19
|
+
return path.join(lakonHome(), 'version.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readCache() {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(cachePath(), 'utf8'));
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeCache(data) {
|
|
31
|
+
try {
|
|
32
|
+
fs.mkdirSync(lakonHome(), { recursive: true });
|
|
33
|
+
fs.writeFileSync(cachePath(), JSON.stringify(data));
|
|
34
|
+
/* c8 ignore next 3 */
|
|
35
|
+
} catch {
|
|
36
|
+
// never let cache write break anything
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function installedVersionMarkerPath() {
|
|
41
|
+
return path.join(lakonHome(), 'installed-version.json');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function currentVersion() {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(fs.readFileSync(installedVersionMarkerPath(), 'utf8')).version;
|
|
47
|
+
} catch {
|
|
48
|
+
// ignore — fall through to package.json lookup
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return require('../../package.json').version;
|
|
52
|
+
/* c8 ignore next 3 */
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeInstalledVersionMarker(version) {
|
|
59
|
+
try {
|
|
60
|
+
fs.mkdirSync(lakonHome(), { recursive: true });
|
|
61
|
+
fs.writeFileSync(installedVersionMarkerPath(), JSON.stringify({ version }));
|
|
62
|
+
/* c8 ignore next 3 */
|
|
63
|
+
} catch {
|
|
64
|
+
// never let marker write break install
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function semverCmp(a, b) {
|
|
69
|
+
const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0);
|
|
70
|
+
const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0);
|
|
71
|
+
for (let i = 0; i < 3; i++) {
|
|
72
|
+
const da = pa[i] || 0;
|
|
73
|
+
const db = pb[i] || 0;
|
|
74
|
+
if (da !== db) return da - db;
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* c8 ignore next */
|
|
80
|
+
function fetchLatest(url = process.env.LAKON_REGISTRY_URL || REGISTRY_URL, timeout = FETCH_TIMEOUT_MS) {
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
let settled = false;
|
|
83
|
+
const finish = (v) => {
|
|
84
|
+
if (!settled) {
|
|
85
|
+
settled = true;
|
|
86
|
+
resolve(v);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
try {
|
|
90
|
+
/* c8 ignore next */
|
|
91
|
+
const client = url.startsWith('http://') ? http : https;
|
|
92
|
+
const req = client.get(url, { timeout }, (res) => {
|
|
93
|
+
if (res.statusCode !== 200) {
|
|
94
|
+
res.resume();
|
|
95
|
+
finish(null);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
let body = '';
|
|
99
|
+
res.on('data', (c) => (body += c));
|
|
100
|
+
res.on('end', () => {
|
|
101
|
+
try {
|
|
102
|
+
/* c8 ignore next */
|
|
103
|
+
finish(JSON.parse(body).version || null);
|
|
104
|
+
} catch {
|
|
105
|
+
finish(null);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
req.on('error', () => finish(null));
|
|
110
|
+
req.on('timeout', () => {
|
|
111
|
+
try { req.destroy(); /* c8 ignore next */ } catch {}
|
|
112
|
+
finish(null);
|
|
113
|
+
});
|
|
114
|
+
/* c8 ignore next 3 */
|
|
115
|
+
} catch {
|
|
116
|
+
finish(null);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isDisabled() {
|
|
122
|
+
return process.env.LAKON_NO_UPDATE_CHECK === '1';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function checkForUpdate({ force = false } = {}) {
|
|
126
|
+
if (isDisabled()) return null;
|
|
127
|
+
const current = currentVersion();
|
|
128
|
+
/* c8 ignore next */
|
|
129
|
+
if (!current) return null;
|
|
130
|
+
|
|
131
|
+
const cache = readCache();
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const fresh = cache && typeof cache.t === 'number' && now - cache.t < CHECK_TTL_MS;
|
|
134
|
+
|
|
135
|
+
if (!force && fresh) {
|
|
136
|
+
if (cache.latest && semverCmp(cache.latest, current) > 0) {
|
|
137
|
+
return { current, latest: cache.latest, available: true };
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const latest = await fetchLatest();
|
|
143
|
+
if (latest) writeCache({ t: now, latest });
|
|
144
|
+
if (latest && semverCmp(latest, current) > 0) {
|
|
145
|
+
return { current, latest, available: true };
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getCachedUpdate() {
|
|
151
|
+
if (isDisabled()) return null;
|
|
152
|
+
const current = currentVersion();
|
|
153
|
+
/* c8 ignore next */
|
|
154
|
+
if (!current) return null;
|
|
155
|
+
const cache = readCache();
|
|
156
|
+
if (!cache || !cache.latest) return null;
|
|
157
|
+
if (semverCmp(cache.latest, current) > 0) {
|
|
158
|
+
return { current, latest: cache.latest, available: true };
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function formatNotice(info) {
|
|
164
|
+
return `lakon ${info.latest} available (you have ${info.current}). Update: npm i -g lakon@latest`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
checkForUpdate,
|
|
169
|
+
getCachedUpdate,
|
|
170
|
+
formatNotice,
|
|
171
|
+
semverCmp,
|
|
172
|
+
currentVersion,
|
|
173
|
+
cachePath,
|
|
174
|
+
fetchLatest,
|
|
175
|
+
writeInstalledVersionMarker,
|
|
176
|
+
installedVersionMarkerPath,
|
|
177
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
function backupRoot() {
|
|
8
|
+
const base = process.env.LAKON_HOME || path.join(os.homedir(), '.lakon');
|
|
9
|
+
return path.join(base, 'backups');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function platformDir(platformId) {
|
|
13
|
+
return path.join(backupRoot(), platformId);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function manifestPath(platformId) {
|
|
17
|
+
return path.join(platformDir(platformId), 'manifest.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function listBackups(platformId) {
|
|
21
|
+
try { return JSON.parse(fs.readFileSync(manifestPath(platformId), 'utf8')); }
|
|
22
|
+
catch { return []; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function hasBackupFor(platformId, filePath) {
|
|
26
|
+
return listBackups(platformId).some((e) => e.source === filePath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function backupFile(platformId, filePath, { skipIfExists = true } = {}) {
|
|
30
|
+
if (!fs.existsSync(filePath)) return null;
|
|
31
|
+
if (skipIfExists && hasBackupFor(platformId, filePath)) return null;
|
|
32
|
+
|
|
33
|
+
const dir = platformDir(platformId);
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
const ts = Date.now();
|
|
36
|
+
const dest = path.join(dir, `${path.basename(filePath)}.${ts}.bak`);
|
|
37
|
+
fs.copyFileSync(filePath, dest);
|
|
38
|
+
|
|
39
|
+
const entries = listBackups(platformId);
|
|
40
|
+
entries.push({ ts, source: filePath, backup: dest });
|
|
41
|
+
fs.writeFileSync(manifestPath(platformId), JSON.stringify(entries, null, 2));
|
|
42
|
+
return dest;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function latestPerSource(platformId) {
|
|
46
|
+
const entries = listBackups(platformId);
|
|
47
|
+
const bySource = new Map();
|
|
48
|
+
for (const e of entries) bySource.set(e.source, e);
|
|
49
|
+
return [...bySource.values()];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function restoreAllBackups(platformId) {
|
|
53
|
+
const entries = latestPerSource(platformId);
|
|
54
|
+
const restored = [];
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (!fs.existsSync(entry.backup)) continue;
|
|
57
|
+
fs.mkdirSync(path.dirname(entry.source), { recursive: true });
|
|
58
|
+
fs.copyFileSync(entry.backup, entry.source);
|
|
59
|
+
restored.push(entry);
|
|
60
|
+
}
|
|
61
|
+
return restored;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
backupFile,
|
|
66
|
+
listBackups,
|
|
67
|
+
hasBackupFor,
|
|
68
|
+
restoreAllBackups,
|
|
69
|
+
backupRoot,
|
|
70
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { claudeConfigDir } = require('./paths');
|
|
6
|
+
|
|
7
|
+
const COMMANDS = [
|
|
8
|
+
{
|
|
9
|
+
name: 'gain',
|
|
10
|
+
body: `---
|
|
11
|
+
description: Show lakon token savings (raw vs filtered, per window and top commands).
|
|
12
|
+
allowed-tools: Bash(lakon gain:*), Bash(lak gain:*)
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
Run \`lakon gain\` and show the output verbatim. Do not summarize — the table is the answer.
|
|
16
|
+
`,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'reset',
|
|
20
|
+
body: `---
|
|
21
|
+
description: Wipe the lakon savings log.
|
|
22
|
+
allowed-tools: Bash(lakon reset:*)
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Run \`lakon reset\` and show the output. Confirm with the user before running if they didn't explicitly ask to clear.
|
|
26
|
+
`,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'inspect',
|
|
30
|
+
body: `---
|
|
31
|
+
description: Run a command once through lakon and compare raw vs filtered token counts.
|
|
32
|
+
argument-hint: <command> [args...]
|
|
33
|
+
allowed-tools: Bash(lakon inspect:*)
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
Run \`lakon inspect $ARGUMENTS\` and show the output verbatim.
|
|
37
|
+
`,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
function commandsDir(home) {
|
|
42
|
+
return path.join(claudeConfigDir(home), 'commands', 'lakon');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function installCommands(home) {
|
|
46
|
+
const dir = commandsDir(home);
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
48
|
+
const written = [];
|
|
49
|
+
for (const c of COMMANDS) {
|
|
50
|
+
const p = path.join(dir, `${c.name}.md`);
|
|
51
|
+
fs.writeFileSync(p, c.body, 'utf8');
|
|
52
|
+
written.push(`/lakon:${c.name}`);
|
|
53
|
+
}
|
|
54
|
+
return written;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function uninstallCommands(home) {
|
|
58
|
+
const dir = commandsDir(home);
|
|
59
|
+
const removed = [];
|
|
60
|
+
for (const c of COMMANDS) {
|
|
61
|
+
const p = path.join(dir, `${c.name}.md`);
|
|
62
|
+
try { fs.unlinkSync(p); removed.push(p); } catch {}
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const left = fs.readdirSync(dir);
|
|
66
|
+
if (!left.length) fs.rmdirSync(dir);
|
|
67
|
+
} catch {}
|
|
68
|
+
return removed;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { installCommands, uninstallCommands, commandsDir };
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { backupFile } = require('./backup');
|
|
6
|
+
const { claudeConfigDir } = require('./paths');
|
|
7
|
+
|
|
8
|
+
const HOOKS = [
|
|
9
|
+
{
|
|
10
|
+
basename: 'lakon-bash-rewrite.js',
|
|
11
|
+
src: path.join(__dirname, '..', 'hooks', 'bash-rewrite.js'),
|
|
12
|
+
event: 'PreToolUse',
|
|
13
|
+
matcher: 'Bash',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
basename: 'lakon-read-guard.js',
|
|
17
|
+
src: path.join(__dirname, '..', 'hooks', 'read-guard.js'),
|
|
18
|
+
event: 'PreToolUse',
|
|
19
|
+
matcher: 'Read',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
basename: 'lakon-grep-guard.js',
|
|
23
|
+
src: path.join(__dirname, '..', 'hooks', 'grep-guard.js'),
|
|
24
|
+
event: 'PreToolUse',
|
|
25
|
+
matcher: 'Grep',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
basename: 'lakon-stop-hook.js',
|
|
29
|
+
src: path.join(__dirname, '..', 'hooks', 'stop-hook.js'),
|
|
30
|
+
event: 'Stop',
|
|
31
|
+
matcher: null,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
basename: 'lakon-session-start.js',
|
|
35
|
+
src: path.join(__dirname, '..', 'hooks', 'session-start.js'),
|
|
36
|
+
event: 'SessionStart',
|
|
37
|
+
matcher: null,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const SUPPORT_FILES = [
|
|
42
|
+
{ basename: 'throttle.js', src: path.join(__dirname, '..', 'hooks', 'throttle.js') },
|
|
43
|
+
{ basename: 'version-check.js', src: path.join(__dirname, '..', 'hooks', 'version-check.js') },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const ALL_BASENAMES = [...HOOKS.map((h) => h.basename), ...SUPPORT_FILES.map((s) => s.basename)];
|
|
47
|
+
|
|
48
|
+
function hookDest(home, basename) {
|
|
49
|
+
return path.join(claudeConfigDir(home), 'hooks', basename);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function settingsPath(home) {
|
|
53
|
+
return path.join(claudeConfigDir(home), 'settings.json');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readSettings(home) {
|
|
57
|
+
const p = settingsPath(home);
|
|
58
|
+
if (!fs.existsSync(p)) return { ok: true, data: {} };
|
|
59
|
+
try {
|
|
60
|
+
return { ok: true, data: JSON.parse(fs.readFileSync(p, 'utf8')) };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { ok: false, error: err.message };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeSettings(home, data) {
|
|
67
|
+
fs.writeFileSync(settingsPath(home), JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function entryHasHook(entry, basename) {
|
|
71
|
+
/* c8 ignore next */
|
|
72
|
+
if (!entry || !Array.isArray(entry.hooks)) return false;
|
|
73
|
+
return entry.hooks.some((h) => h && typeof h.command === 'string' && h.command.includes(basename));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function mergeHook(data, hookDef, dest) {
|
|
77
|
+
/* c8 ignore next */
|
|
78
|
+
const eventKey = hookDef.event || 'PreToolUse';
|
|
79
|
+
data.hooks = data.hooks || {};
|
|
80
|
+
data.hooks[eventKey] = data.hooks[eventKey] || [];
|
|
81
|
+
|
|
82
|
+
if (hookDef.matcher) {
|
|
83
|
+
const existing = data.hooks[eventKey].find((e) => e.matcher === hookDef.matcher);
|
|
84
|
+
if (existing) {
|
|
85
|
+
if (!entryHasHook(existing, hookDef.basename)) {
|
|
86
|
+
/* c8 ignore next */
|
|
87
|
+
existing.hooks = existing.hooks || [];
|
|
88
|
+
existing.hooks.push({ type: 'command', command: dest });
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
data.hooks[eventKey].push({
|
|
92
|
+
matcher: hookDef.matcher,
|
|
93
|
+
hooks: [{ type: 'command', command: dest }],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
const existing = data.hooks[eventKey].find(
|
|
98
|
+
(e) => !e.matcher && entryHasHook(e, hookDef.basename)
|
|
99
|
+
);
|
|
100
|
+
if (!existing) {
|
|
101
|
+
data.hooks[eventKey].push({
|
|
102
|
+
hooks: [{ type: 'command', command: dest }],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function installHook(home) {
|
|
109
|
+
const sp = settingsPath(home);
|
|
110
|
+
if (fs.existsSync(sp)) backupFile('claude-code', sp);
|
|
111
|
+
|
|
112
|
+
const { ok, data, error } = readSettings(home);
|
|
113
|
+
if (!ok) {
|
|
114
|
+
return { hookFile: null, settingsMerged: false, note: `settings.json could not be parsed (${error}). Add hook entries manually — see README.` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const installed = [];
|
|
118
|
+
for (const s of SUPPORT_FILES) {
|
|
119
|
+
const dest = hookDest(home, s.basename);
|
|
120
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
121
|
+
fs.copyFileSync(s.src, dest);
|
|
122
|
+
fs.chmodSync(dest, 0o644);
|
|
123
|
+
}
|
|
124
|
+
for (const h of HOOKS) {
|
|
125
|
+
const dest = hookDest(home, h.basename);
|
|
126
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
127
|
+
fs.copyFileSync(h.src, dest);
|
|
128
|
+
fs.chmodSync(dest, 0o755);
|
|
129
|
+
mergeHook(data, h, dest);
|
|
130
|
+
installed.push(dest);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
writeSettings(home, data);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const { writeInstalledVersionMarker } = require('../hooks/version-check');
|
|
137
|
+
writeInstalledVersionMarker(require('../../package.json').version);
|
|
138
|
+
/* c8 ignore next 3 */
|
|
139
|
+
} catch {
|
|
140
|
+
// never let marker write break install
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { hookFile: installed.join(', '), settingsMerged: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function uninstallHook(home) {
|
|
147
|
+
const { ok, data } = readSettings(home);
|
|
148
|
+
if (ok && data.hooks && typeof data.hooks === 'object') {
|
|
149
|
+
for (const eventKey of Object.keys(data.hooks)) {
|
|
150
|
+
/* c8 ignore next */
|
|
151
|
+
if (!Array.isArray(data.hooks[eventKey])) continue;
|
|
152
|
+
data.hooks[eventKey] = data.hooks[eventKey]
|
|
153
|
+
.map((entry) => {
|
|
154
|
+
/* c8 ignore next */
|
|
155
|
+
if (!Array.isArray(entry.hooks)) return entry;
|
|
156
|
+
const remaining = entry.hooks.filter(
|
|
157
|
+
(h) => !(h.command && ALL_BASENAMES.some((b) => h.command.includes(b)))
|
|
158
|
+
);
|
|
159
|
+
if (remaining.length === 0) return null;
|
|
160
|
+
return { ...entry, hooks: remaining };
|
|
161
|
+
})
|
|
162
|
+
.filter(Boolean);
|
|
163
|
+
if (data.hooks[eventKey].length === 0) delete data.hooks[eventKey];
|
|
164
|
+
}
|
|
165
|
+
if (Object.keys(data.hooks).length === 0) delete data.hooks;
|
|
166
|
+
if (fs.existsSync(settingsPath(home))) writeSettings(home, data);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const h of [...HOOKS, ...SUPPORT_FILES]) {
|
|
170
|
+
const dest = hookDest(home, h.basename);
|
|
171
|
+
if (fs.existsSync(dest)) {
|
|
172
|
+
try { fs.unlinkSync(dest); /* c8 ignore next */ } catch {}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { installHook, uninstallHook, hookDest, HOOK_BASENAMES: ALL_BASENAMES };
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const platforms = require('./platforms');
|
|
7
|
+
const { listBackups } = require('./backup');
|
|
8
|
+
|
|
9
|
+
const RULE_PATH = path.join(__dirname, '..', 'rules', 'caveman.md');
|
|
10
|
+
|
|
11
|
+
const OK = '✅';
|
|
12
|
+
const FAIL = '❌';
|
|
13
|
+
const ARROW = '→';
|
|
14
|
+
const BULLET = '•';
|
|
15
|
+
|
|
16
|
+
function readRule() {
|
|
17
|
+
return fs.readFileSync(RULE_PATH, 'utf8');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function shortenPath(p) {
|
|
21
|
+
/* c8 ignore next */
|
|
22
|
+
if (!p) return p;
|
|
23
|
+
const home = os.homedir();
|
|
24
|
+
if (p.startsWith(home)) return '~' + p.slice(home.length);
|
|
25
|
+
return p;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function padLabel(label, width = 26) {
|
|
29
|
+
/* c8 ignore next */
|
|
30
|
+
return label.length >= width ? label : label + ' '.repeat(width - label.length);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function detectPlatforms() {
|
|
34
|
+
const home = os.homedir();
|
|
35
|
+
return platforms.list().filter((p) => p.detect(home));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function install({ only, here = false } = {}) {
|
|
39
|
+
const rule = readRule();
|
|
40
|
+
const home = os.homedir();
|
|
41
|
+
const all = platforms.list();
|
|
42
|
+
|
|
43
|
+
let targets;
|
|
44
|
+
if (only) {
|
|
45
|
+
targets = all.filter((p) => p.id === only);
|
|
46
|
+
} else {
|
|
47
|
+
targets = detectPlatforms().filter((p) => p.scope === 'global');
|
|
48
|
+
if (here) {
|
|
49
|
+
targets = targets.concat(all.filter((p) => p.scope === 'project'));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!targets.length) {
|
|
54
|
+
if (only) {
|
|
55
|
+
process.stdout.write(`${FAIL} unknown platform "${only}". Run \`lakon list\` to see options.\n`);
|
|
56
|
+
} else {
|
|
57
|
+
process.stdout.write(`${FAIL} no supported global platforms detected. Install Claude Code, Codex, or Gemini CLI first — or run \`lakon install --here\` to write per-project rules (Cursor/Windsurf/Cline) in the current directory.\n`);
|
|
58
|
+
}
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
process.stdout.write('\nlakon install\n');
|
|
64
|
+
process.stdout.write('─────────────\n');
|
|
65
|
+
|
|
66
|
+
for (const p of targets) {
|
|
67
|
+
try {
|
|
68
|
+
const result = p.install({ home, rule, id: p.id });
|
|
69
|
+
process.stdout.write(`${OK} ${padLabel(p.label)} ${ARROW} ${shortenPath(result)}\n`);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
process.stdout.write(`${FAIL} ${padLabel(p.label)} ${ARROW} ${err.message}\n`);
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
process.stdout.write('\n');
|
|
77
|
+
if (!only && !here) {
|
|
78
|
+
process.stdout.write(` ${BULLET} Per-project rules (Cursor/Windsurf/Cline) were skipped.\n`);
|
|
79
|
+
process.stdout.write(` Inside a repo? Run \`lakon install --here\` to add them in the current directory.\n`);
|
|
80
|
+
}
|
|
81
|
+
process.stdout.write(` ${BULLET} \`lakon uninstall\` removes only the lakon block (keeps your other content).\n`);
|
|
82
|
+
process.stdout.write(` ${BULLET} \`lakon revert\` restores files to pre-install state from backup.\n`);
|
|
83
|
+
process.stdout.write(` ${BULLET} \`lakon gain\` shows how many tokens you've saved.\n`);
|
|
84
|
+
process.stdout.write('\nRestart your AI agent (or open a new session) for the rule to take effect.\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function uninstall() {
|
|
88
|
+
const home = os.homedir();
|
|
89
|
+
process.stdout.write('\nlakon uninstall\n');
|
|
90
|
+
process.stdout.write('───────────────\n');
|
|
91
|
+
let any = false;
|
|
92
|
+
for (const p of platforms.list()) {
|
|
93
|
+
try {
|
|
94
|
+
const result = p.uninstall({ home });
|
|
95
|
+
if (result) {
|
|
96
|
+
process.stdout.write(`${OK} ${padLabel(p.label)} ${ARROW} removed from ${shortenPath(result)}\n`);
|
|
97
|
+
any = true;
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
process.stdout.write(`${FAIL} ${padLabel(p.label)} ${ARROW} ${err.message}\n`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!any) process.stdout.write(' (nothing installed)\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function revert({ only } = {}) {
|
|
107
|
+
const targets = only
|
|
108
|
+
? platforms.list().filter((p) => p.id === only)
|
|
109
|
+
: platforms.list();
|
|
110
|
+
|
|
111
|
+
process.stdout.write('\nlakon revert\n');
|
|
112
|
+
process.stdout.write('────────────\n');
|
|
113
|
+
|
|
114
|
+
let any = false;
|
|
115
|
+
for (const p of targets) {
|
|
116
|
+
const entries = platforms.revertPlatform(p.id);
|
|
117
|
+
if (entries && entries.length) {
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const when = new Date(entry.ts).toISOString().replace('T', ' ').slice(0, 19);
|
|
120
|
+
process.stdout.write(`${OK} ${padLabel(p.label)} ${ARROW} ${shortenPath(entry.source)}\n`);
|
|
121
|
+
process.stdout.write(` ${' '.repeat(25)} (restored from backup taken ${when})\n`);
|
|
122
|
+
}
|
|
123
|
+
any = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!any) {
|
|
127
|
+
process.stdout.write(`${FAIL} no backups found. (Backups only exist for files that already had content before install.)\n`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function backupsReport() {
|
|
132
|
+
const lines = ['', 'lakon — backup history', '──────────────────────'];
|
|
133
|
+
let any = false;
|
|
134
|
+
for (const p of platforms.list()) {
|
|
135
|
+
const entries = listBackups(p.id);
|
|
136
|
+
if (!entries.length) continue;
|
|
137
|
+
any = true;
|
|
138
|
+
lines.push('');
|
|
139
|
+
lines.push(`${p.label} (${p.id}):`);
|
|
140
|
+
for (const e of entries) {
|
|
141
|
+
const when = new Date(e.ts).toISOString().replace('T', ' ').slice(0, 19);
|
|
142
|
+
lines.push(` ${when} ${shortenPath(e.source)}`);
|
|
143
|
+
lines.push(` ${ARROW} ${shortenPath(e.backup)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!any) lines.push('\n (no backups yet)');
|
|
147
|
+
return lines.join('\n') + '\n';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function listPlatforms() {
|
|
151
|
+
return platforms.list().map((p) => {
|
|
152
|
+
const detected = p.detect(os.homedir());
|
|
153
|
+
const mark = detected ? OK : ' ';
|
|
154
|
+
const scope = p.scope === 'project' ? '[project]' : '[global] ';
|
|
155
|
+
return `${mark} ${p.id.padEnd(14)} ${scope} ${p.label}`;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { install, uninstall, revert, listPlatforms, backupsReport };
|