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.
@@ -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
+ };