synap 0.1.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/skills/synap-assistant/SKILL.md +476 -0
- package/README.md +200 -0
- package/package.json +50 -0
- package/scripts/postinstall.js +58 -0
- package/src/cli.js +1734 -0
- package/src/deletion-log.js +105 -0
- package/src/preferences.js +175 -0
- package/src/skill-installer.js +175 -0
- package/src/storage.js +803 -0
- package/src/templates/user-preferences-template.md +16 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deletion-log.js - Audit logging for deleted entries, enables restore
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
|
|
9
|
+
// Storage directory
|
|
10
|
+
const CONFIG_DIR = process.env.SYNAP_DIR || path.join(os.homedir(), '.config', 'synap');
|
|
11
|
+
const DELETION_LOG_FILE = path.join(CONFIG_DIR, 'deletion-log.json');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ensure config directory exists
|
|
15
|
+
*/
|
|
16
|
+
function ensureConfigDir() {
|
|
17
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
18
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Atomic file write
|
|
24
|
+
*/
|
|
25
|
+
function atomicWriteSync(filePath, data) {
|
|
26
|
+
const tmpPath = filePath + '.tmp';
|
|
27
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
28
|
+
fs.renameSync(tmpPath, filePath);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Load deletion log
|
|
33
|
+
*/
|
|
34
|
+
function loadLog() {
|
|
35
|
+
ensureConfigDir();
|
|
36
|
+
if (!fs.existsSync(DELETION_LOG_FILE)) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(fs.readFileSync(DELETION_LOG_FILE, 'utf8'));
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Save deletion log
|
|
48
|
+
*/
|
|
49
|
+
function saveLog(log) {
|
|
50
|
+
ensureConfigDir();
|
|
51
|
+
atomicWriteSync(DELETION_LOG_FILE, log);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Log entries before deletion
|
|
56
|
+
* Each entry gets a deletedAt timestamp for tracking
|
|
57
|
+
*/
|
|
58
|
+
async function logDeletions(entries) {
|
|
59
|
+
const log = loadLog();
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
log.unshift({
|
|
64
|
+
...entry,
|
|
65
|
+
deletedAt: now
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Keep last 1000 deletions
|
|
70
|
+
if (log.length > 1000) {
|
|
71
|
+
log.length = 1000;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
saveLog(log);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get deletion log
|
|
79
|
+
*/
|
|
80
|
+
async function getLog() {
|
|
81
|
+
return loadLog();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Remove entries from deletion log after restore
|
|
86
|
+
*/
|
|
87
|
+
async function removeFromLog(ids) {
|
|
88
|
+
const log = loadLog();
|
|
89
|
+
const filtered = log.filter(e => !ids.some(id => e.id === id || e.id.startsWith(id)));
|
|
90
|
+
saveLog(filtered);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Clear the entire deletion log
|
|
95
|
+
*/
|
|
96
|
+
async function clearLog() {
|
|
97
|
+
saveLog([]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
logDeletions,
|
|
102
|
+
getLog,
|
|
103
|
+
removeFromLog,
|
|
104
|
+
clearLog
|
|
105
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* preferences.js - User preferences storage and helpers
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const storage = require('./storage');
|
|
8
|
+
|
|
9
|
+
const TEMPLATE_PATH = path.join(__dirname, 'templates', 'user-preferences-template.md');
|
|
10
|
+
const PREFERENCES_FILE = path.join(storage.CONFIG_DIR, 'user-preferences.md');
|
|
11
|
+
const MAX_LINES = 500;
|
|
12
|
+
|
|
13
|
+
function ensureConfigDir() {
|
|
14
|
+
if (!fs.existsSync(storage.CONFIG_DIR)) {
|
|
15
|
+
fs.mkdirSync(storage.CONFIG_DIR, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getPreferencesPath() {
|
|
20
|
+
return PREFERENCES_FILE;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readTemplate() {
|
|
24
|
+
if (!fs.existsSync(TEMPLATE_PATH)) {
|
|
25
|
+
throw new Error(`Preferences template not found: ${TEMPLATE_PATH}`);
|
|
26
|
+
}
|
|
27
|
+
return fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validatePreferences(content) {
|
|
31
|
+
if (typeof content !== 'string') {
|
|
32
|
+
return { valid: false, error: 'Preferences must be a string' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (content.includes('\0')) {
|
|
36
|
+
return { valid: false, error: 'Preferences contain invalid null bytes' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const trimmedContent = content.replace(/(\r?\n)+$/, '');
|
|
40
|
+
const lineCount = trimmedContent.split(/\r?\n/).length;
|
|
41
|
+
if (lineCount > MAX_LINES) {
|
|
42
|
+
return { valid: false, error: `Preferences must be ${MAX_LINES} lines or fewer` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { valid: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function savePreferences(content) {
|
|
49
|
+
const validation = validatePreferences(content);
|
|
50
|
+
if (!validation.valid) {
|
|
51
|
+
throw new Error(validation.error);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
ensureConfigDir();
|
|
55
|
+
const tmpPath = `${PREFERENCES_FILE}.tmp`;
|
|
56
|
+
fs.writeFileSync(tmpPath, content, 'utf8');
|
|
57
|
+
fs.renameSync(tmpPath, PREFERENCES_FILE);
|
|
58
|
+
return content;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function loadPreferences() {
|
|
62
|
+
ensureConfigDir();
|
|
63
|
+
if (!fs.existsSync(PREFERENCES_FILE)) {
|
|
64
|
+
const template = readTemplate();
|
|
65
|
+
savePreferences(template);
|
|
66
|
+
return template;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const content = fs.readFileSync(PREFERENCES_FILE, 'utf8');
|
|
70
|
+
const validation = validatePreferences(content);
|
|
71
|
+
if (!validation.valid) {
|
|
72
|
+
throw new Error(validation.error);
|
|
73
|
+
}
|
|
74
|
+
return content;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resetPreferences() {
|
|
78
|
+
const template = readTemplate();
|
|
79
|
+
return savePreferences(template);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseSectionTarget(section) {
|
|
83
|
+
const trimmed = section.trim();
|
|
84
|
+
if (!trimmed) {
|
|
85
|
+
throw new Error('Section name is required');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const match = trimmed.match(/^(#{1,6})\s*(.+)$/);
|
|
89
|
+
if (match) {
|
|
90
|
+
return { level: match[1].length, name: match[2].trim() };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { level: null, name: trimmed };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function findSection(lines, target) {
|
|
97
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
98
|
+
const match = lines[i].match(/^(#{1,6})\s*(.+?)\s*$/);
|
|
99
|
+
if (!match) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const level = match[1].length;
|
|
103
|
+
const name = match[2].trim();
|
|
104
|
+
|
|
105
|
+
if (target.level && level !== target.level) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (name.toLowerCase() === target.name.toLowerCase()) {
|
|
110
|
+
return { index: i, level };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function appendToSection(section, text) {
|
|
117
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
118
|
+
throw new Error('Append text is required');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const target = parseSectionTarget(section);
|
|
122
|
+
const content = loadPreferences();
|
|
123
|
+
const lines = content.split(/\r?\n/);
|
|
124
|
+
const match = findSection(lines, target);
|
|
125
|
+
const trimmedText = text.replace(/\s+$/, '');
|
|
126
|
+
|
|
127
|
+
if (!match) {
|
|
128
|
+
const headingLevel = target.level || 2;
|
|
129
|
+
const headingLine = `${'#'.repeat(headingLevel)} ${target.name}`;
|
|
130
|
+
const newLines = [...lines];
|
|
131
|
+
|
|
132
|
+
if (newLines.length > 0 && newLines[newLines.length - 1].trim() !== '') {
|
|
133
|
+
newLines.push('');
|
|
134
|
+
}
|
|
135
|
+
newLines.push(headingLine, '');
|
|
136
|
+
newLines.push(...trimmedText.split(/\r?\n/));
|
|
137
|
+
|
|
138
|
+
return savePreferences(newLines.join('\n'));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let insertIndex = lines.length;
|
|
142
|
+
for (let i = match.index + 1; i < lines.length; i += 1) {
|
|
143
|
+
const headingMatch = lines[i].match(/^(#{1,6})\s*(.+?)\s*$/);
|
|
144
|
+
if (!headingMatch) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const level = headingMatch[1].length;
|
|
148
|
+
if (level <= match.level) {
|
|
149
|
+
insertIndex = i;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const insertLines = trimmedText.split(/\r?\n/);
|
|
155
|
+
const lineBefore = lines[insertIndex - 1];
|
|
156
|
+
if (lineBefore !== undefined && lineBefore.trim() !== '') {
|
|
157
|
+
insertLines.unshift('');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const updated = [...lines];
|
|
161
|
+
updated.splice(insertIndex, 0, ...insertLines);
|
|
162
|
+
|
|
163
|
+
return savePreferences(updated.join('\n'));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
getPreferencesPath,
|
|
168
|
+
loadPreferences,
|
|
169
|
+
savePreferences,
|
|
170
|
+
appendToSection,
|
|
171
|
+
resetPreferences,
|
|
172
|
+
validatePreferences,
|
|
173
|
+
PREFERENCES_FILE,
|
|
174
|
+
TEMPLATE_PATH
|
|
175
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill-installer.js - Install/uninstall Claude Code skill
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
const SKILL_NAME = 'synap-assistant';
|
|
11
|
+
const SKILL_SOURCE = 'synap-cli';
|
|
12
|
+
const SOURCE_SKILL_DIR = path.join(__dirname, '..', '.claude', 'skills', SKILL_NAME);
|
|
13
|
+
const TARGET_SKILL_DIR = path.join(os.homedir(), '.claude', 'skills', SKILL_NAME);
|
|
14
|
+
const SOURCE_SKILL_FILE = path.join(SOURCE_SKILL_DIR, 'SKILL.md');
|
|
15
|
+
const TARGET_SKILL_FILE = path.join(TARGET_SKILL_DIR, 'SKILL.md');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get MD5 hash of content
|
|
19
|
+
*/
|
|
20
|
+
function getHash(content) {
|
|
21
|
+
return crypto.createHash('md5').update(content).digest('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function extractFrontMatter(content) {
|
|
25
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
26
|
+
if (!match) {
|
|
27
|
+
return { frontMatter: null, body: content };
|
|
28
|
+
}
|
|
29
|
+
return { frontMatter: match[1], body: content.slice(match[0].length) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseFrontMatter(frontMatter) {
|
|
33
|
+
const data = {};
|
|
34
|
+
if (!frontMatter) {
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
const lines = frontMatter.split('\n');
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
40
|
+
if (match) {
|
|
41
|
+
data[match[1]] = match[2].trim();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function applyFrontMatterUpdates(content, updates) {
|
|
48
|
+
const { frontMatter, body } = extractFrontMatter(content);
|
|
49
|
+
const lines = frontMatter ? frontMatter.split('\n') : [];
|
|
50
|
+
const updatedLines = [];
|
|
51
|
+
const handled = new Set();
|
|
52
|
+
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
55
|
+
if (match && Object.prototype.hasOwnProperty.call(updates, match[1])) {
|
|
56
|
+
const key = match[1];
|
|
57
|
+
const value = updates[key];
|
|
58
|
+
handled.add(key);
|
|
59
|
+
if (value === null || value === undefined) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
updatedLines.push(`${key}: ${value}`);
|
|
63
|
+
} else {
|
|
64
|
+
updatedLines.push(line);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
69
|
+
if (handled.has(key)) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (value === null || value === undefined) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
updatedLines.push(`${key}: ${value}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const front = `---\n${updatedLines.join('\n')}\n---\n`;
|
|
79
|
+
return front + body;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getCanonicalContent(content) {
|
|
83
|
+
return applyFrontMatterUpdates(content, { source: SKILL_SOURCE, hash: null });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildSkillContent(content) {
|
|
87
|
+
const canonical = getCanonicalContent(content);
|
|
88
|
+
const hash = getHash(canonical);
|
|
89
|
+
const withHash = applyFrontMatterUpdates(canonical, { source: SKILL_SOURCE, hash });
|
|
90
|
+
return { canonical, hash, content: withHash };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function extractMetadata(content) {
|
|
94
|
+
const { frontMatter } = extractFrontMatter(content);
|
|
95
|
+
const data = parseFrontMatter(frontMatter);
|
|
96
|
+
return {
|
|
97
|
+
source: data.source || null,
|
|
98
|
+
hash: data.hash || null
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Install the skill
|
|
104
|
+
*/
|
|
105
|
+
async function install(options = {}) {
|
|
106
|
+
// Check if source skill exists
|
|
107
|
+
if (!fs.existsSync(SOURCE_SKILL_FILE)) {
|
|
108
|
+
throw new Error(`Source skill not found: ${SOURCE_SKILL_FILE}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const sourceContent = fs.readFileSync(SOURCE_SKILL_FILE, 'utf8');
|
|
112
|
+
const { content: normalizedSourceContent, hash: sourceHash } = buildSkillContent(sourceContent);
|
|
113
|
+
|
|
114
|
+
// Ensure target directory exists
|
|
115
|
+
if (!fs.existsSync(TARGET_SKILL_DIR)) {
|
|
116
|
+
fs.mkdirSync(TARGET_SKILL_DIR, { recursive: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check if target exists
|
|
120
|
+
if (fs.existsSync(TARGET_SKILL_FILE)) {
|
|
121
|
+
const targetContent = fs.readFileSync(TARGET_SKILL_FILE, 'utf8');
|
|
122
|
+
const { source: targetSource, hash: targetHash } = extractMetadata(targetContent);
|
|
123
|
+
const canonicalTarget = getCanonicalContent(targetContent);
|
|
124
|
+
const canonicalTargetHash = getHash(canonicalTarget);
|
|
125
|
+
const targetMatchesSource = canonicalTargetHash === sourceHash;
|
|
126
|
+
|
|
127
|
+
if (targetSource !== SKILL_SOURCE && !options.force) {
|
|
128
|
+
return { installed: false, needsForce: true };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (targetSource === SKILL_SOURCE && !options.force) {
|
|
132
|
+
if (targetMatchesSource && targetContent === normalizedSourceContent) {
|
|
133
|
+
return { installed: false, skipped: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (targetHash) {
|
|
137
|
+
if (targetHash !== canonicalTargetHash) {
|
|
138
|
+
return { installed: false, needsForce: true };
|
|
139
|
+
}
|
|
140
|
+
} else if (!targetMatchesSource) {
|
|
141
|
+
return { installed: false, needsForce: true };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const backupFile = TARGET_SKILL_FILE + '.backup';
|
|
146
|
+
fs.writeFileSync(backupFile, targetContent);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Install
|
|
150
|
+
fs.writeFileSync(TARGET_SKILL_FILE, normalizedSourceContent);
|
|
151
|
+
|
|
152
|
+
return { installed: true };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Uninstall the skill
|
|
157
|
+
*/
|
|
158
|
+
async function uninstall() {
|
|
159
|
+
if (fs.existsSync(TARGET_SKILL_FILE)) {
|
|
160
|
+
fs.unlinkSync(TARGET_SKILL_FILE);
|
|
161
|
+
}
|
|
162
|
+
if (fs.existsSync(TARGET_SKILL_DIR)) {
|
|
163
|
+
try {
|
|
164
|
+
fs.rmdirSync(TARGET_SKILL_DIR);
|
|
165
|
+
} catch {
|
|
166
|
+
// Directory not empty, that's ok
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { uninstalled: true };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
install,
|
|
174
|
+
uninstall
|
|
175
|
+
};
|