ocs-stats 1.1.2 → 1.1.4

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 CHANGED
@@ -7,6 +7,8 @@ const args = process.argv.slice(2);
7
7
  const command = args[0];
8
8
  const isGlobal = args.includes('--global') || args.includes('-g');
9
9
  const isHelp = args.includes('--help') || args.includes('-h');
10
+ const isForce = args.includes('--force') || args.includes('-f');
11
+ const isCheck = args.includes('--check') || args.includes('-c');
10
12
 
11
13
  if (isHelp) {
12
14
  console.log(`
@@ -15,7 +17,9 @@ ocs-stats - Install OpenCode skills and agents
15
17
  Usage:
16
18
  npx ocs-stats Install to current project
17
19
  npx ocs-stats --global Install globally (~/.opencode)
18
- npx ocs-stats update Update skills (removes existing)
20
+ npx ocs-stats update Update skills (smart merge)
21
+ npx ocs-stats update --check Check for updates
22
+ npx ocs-stats update --force Force fresh install
19
23
  npx ocs-stats stats Show security agent progress
20
24
  npx ocs-stats stats testing Show testing agent progress
21
25
  npx ocs-stats display-xp <amount> "<reason>"
@@ -23,11 +27,15 @@ Usage:
23
27
 
24
28
  Options:
25
29
  -g, --global Install to user home directory
30
+ -f, --force Force fresh install (delete + copy)
31
+ -c, --check Check for updates without applying
26
32
  -h, --help Show this help message
27
33
 
28
34
  Examples:
29
35
  npx ocs-stats
30
36
  npx ocs-stats update
37
+ npx ocs-stats update --check
38
+ npx ocs-stats update --force
31
39
  npx ocs-stats stats
32
40
  npx ocs-stats stats testing
33
41
  npx ocs-stats display-xp 35 "Fixed high issue"
@@ -36,21 +44,22 @@ Examples:
36
44
  process.exit(0);
37
45
  }
38
46
 
39
- if (command === 'update') {
40
- update({ isGlobal }).catch((err) => {
41
- console.error('Error:', err.message);
42
- process.exit(1);
43
- });
44
- process.exit(0);
47
+ else if (command === 'update') {
48
+ update({ isGlobal, force: isForce, checkOnly: isCheck })
49
+ .then(() => process.exit(0))
50
+ .catch((err) => {
51
+ console.error('Error:', err.message);
52
+ process.exit(1);
53
+ });
45
54
  }
46
55
 
47
- if (command === 'stats') {
56
+ else if (command === 'stats') {
48
57
  const category = args[1] || 'security';
49
58
  stats(category);
50
59
  process.exit(0);
51
60
  }
52
61
 
53
- if (command === 'display-xp') {
62
+ else if (command === 'display-xp') {
54
63
  const amount = args[1];
55
64
  const reason = args.slice(2).join(' ') || 'XP earned';
56
65
  const category = reason.includes('[testing]') ? 'testing' : 'security';
@@ -59,7 +68,9 @@ if (command === 'display-xp') {
59
68
  process.exit(0);
60
69
  }
61
70
 
62
- init({ isGlobal }).catch((err) => {
63
- console.error('Error:', err.message);
64
- process.exit(1);
65
- });
71
+ else {
72
+ init({ isGlobal }).catch((err) => {
73
+ console.error('Error:', err.message);
74
+ process.exit(1);
75
+ });
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ocs-stats",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "OpenCode Skills - One-click installer with gamified XP stats",
5
5
  "type": "module",
6
6
  "bin": {
package/src/init.js CHANGED
@@ -1,12 +1,32 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
+ import { performUpdate, checkForUpdates, formatUpdateSummary } from './merge.js';
4
5
 
5
6
  const __filename = fileURLToPath(import.meta.url);
6
7
  const __dirname = path.dirname(__filename);
7
8
 
8
9
  const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
9
10
 
11
+ function copyDir(src, dest) {
12
+ fs.mkdirSync(dest, { recursive: true });
13
+
14
+ const entries = fs.readdirSync(src, { withFileTypes: true });
15
+
16
+ for (const entry of entries) {
17
+ if (entry.name === '.git') continue;
18
+
19
+ const srcPath = path.join(src, entry.name);
20
+ const destPath = path.join(dest, entry.name);
21
+
22
+ if (entry.isDirectory()) {
23
+ copyDir(srcPath, destPath);
24
+ } else {
25
+ fs.copyFileSync(srcPath, destPath);
26
+ }
27
+ }
28
+ }
29
+
10
30
  export async function init({ isGlobal = false } = {}) {
11
31
  const targetDir = isGlobal
12
32
  ? path.join(process.env.HOME || process.env.USERPROFILE, '.opencode')
@@ -43,7 +63,7 @@ export async function init({ isGlobal = false } = {}) {
43
63
  }
44
64
  }
45
65
 
46
- export async function update({ isGlobal = false } = {}) {
66
+ export async function update({ isGlobal = false, force = false, checkOnly = false } = {}) {
47
67
  const targetDir = isGlobal
48
68
  ? path.join(process.env.HOME || process.env.USERPROFILE, '.opencode')
49
69
  : path.join(process.cwd(), '.opencode');
@@ -55,35 +75,32 @@ export async function update({ isGlobal = false } = {}) {
55
75
  process.exit(1);
56
76
  }
57
77
 
58
- // Remove existing
59
- fs.rmSync(targetDir, { recursive: true, force: true });
60
- console.log(' Removed existing .opencode folder');
61
-
62
- // Reinstall
63
- copyDir(TEMPLATES_DIR, targetDir);
64
- console.log(' Installed fresh copy\n');
78
+ if (force) {
79
+ console.log(' Force mode: Removing existing .opencode folder');
80
+ fs.rmSync(targetDir, { recursive: true, force: true });
81
+ copyDir(TEMPLATES_DIR, targetDir);
82
+ console.log(' Fresh install complete\n');
83
+ return;
84
+ }
65
85
 
66
- console.log('Updated successfully!\n');
67
- console.log('What was installed:');
68
- console.log(' * Agents: security, testing');
69
- console.log(' * Skills: commit, memories, mobile, security, testing, webapp');
70
- console.log(' * Security: XP tracking, knowledge base');
71
- console.log(' * Testing: XP tracking, knowledge base\n');
72
- }
86
+ if (checkOnly) {
87
+ console.log(' Checking for updates...\n');
88
+ const updateInfo = await checkForUpdates(targetDir);
89
+ if (!updateInfo.hasUpdates) {
90
+ console.log(` Already up to date (v${updateInfo.currentVersion})\n`);
91
+ return;
92
+ }
93
+ formatUpdateSummary({ ...updateInfo, results: updateInfo.changes }, true);
94
+ return;
95
+ }
73
96
 
74
- function copyDir(src, dest) {
75
- fs.mkdirSync(dest, { recursive: true });
76
-
77
- const entries = fs.readdirSync(src, { withFileTypes: true });
97
+ console.log(' Updating...\n');
98
+ const updateResult = await performUpdate(targetDir, { force, checkOnly });
78
99
 
79
- for (const entry of entries) {
80
- const srcPath = path.join(src, entry.name);
81
- const destPath = path.join(dest, entry.name);
82
-
83
- if (entry.isDirectory()) {
84
- copyDir(srcPath, destPath);
85
- } else {
86
- fs.copyFileSync(srcPath, destPath);
87
- }
100
+ if (!updateResult.updated) {
101
+ console.log(` Already up to date (v${updateResult.currentVersion})\n`);
102
+ return;
88
103
  }
104
+
105
+ formatUpdateSummary(updateResult, false);
89
106
  }
package/src/merge.js ADDED
@@ -0,0 +1,351 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ const __filename = fileURLToPath(import.meta.url);
7
+ const __dirname = path.dirname(__filename);
8
+
9
+ const TEMPLATES_DIR = path.join(__dirname, '..', 'templates');
10
+
11
+ export const FILE_STATE = {
12
+ NEW: 'new',
13
+ UNMODIFIED: 'unmodified',
14
+ MODIFIED: 'modified',
15
+ DELETED: 'deleted',
16
+ };
17
+
18
+ export const FILE_CATEGORY = {
19
+ XP_DATA: 'xp_data',
20
+ MEMORIES: 'memories',
21
+ KNOWLEDGE: 'knowledge',
22
+ SKILL: 'skill',
23
+ AGENT: 'agent',
24
+ };
25
+
26
+ const PROTECTED_CATEGORIES = [FILE_CATEGORY.XP_DATA, FILE_CATEGORY.MEMORIES];
27
+ const MERGE_CATEGORIES = [FILE_CATEGORY.KNOWLEDGE];
28
+
29
+ function hashFile(filePath) {
30
+ if (!fs.existsSync(filePath)) return null;
31
+ const content = fs.readFileSync(filePath, 'utf-8');
32
+ return crypto.createHash('md5').update(content).digest('hex');
33
+ }
34
+
35
+ function getCategory(relativePath) {
36
+ const pathLower = relativePath.toLowerCase();
37
+
38
+ if (pathLower.includes('/xp.json') || pathLower.endsWith('xp.json')) {
39
+ return FILE_CATEGORY.XP_DATA;
40
+ }
41
+ if (pathLower.includes('/memories/') || pathLower.includes('memories/')) {
42
+ return FILE_CATEGORY.MEMORIES;
43
+ }
44
+ if (pathLower.includes('/knowledge.md')) {
45
+ return FILE_CATEGORY.KNOWLEDGE;
46
+ }
47
+ if (pathLower.includes('/skills/')) {
48
+ return FILE_CATEGORY.SKILL;
49
+ }
50
+ if (pathLower.includes('/agents/')) {
51
+ return FILE_CATEGORY.AGENT;
52
+ }
53
+ return FILE_CATEGORY.SKILL;
54
+ }
55
+
56
+ function compareVersion(v1, v2) {
57
+ const parts1 = v1.split('.').map(Number);
58
+ const parts2 = v2.split('.').map(Number);
59
+ for (let i = 0; i < 3; i++) {
60
+ if (parts1[i] > parts2[i]) return 1;
61
+ if (parts1[i] < parts2[i]) return -1;
62
+ }
63
+ return 0;
64
+ }
65
+
66
+ function getAllTemplateFiles(srcDir, baseDir = '') {
67
+ const files = [];
68
+ if (!fs.existsSync(srcDir)) return files;
69
+
70
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
71
+ for (const entry of entries) {
72
+ if (entry.name === '.git') continue;
73
+
74
+ const relativePath = path.join(baseDir, entry.name);
75
+ const srcPath = path.join(srcDir, entry.name);
76
+
77
+ if (entry.isDirectory()) {
78
+ files.push(...getAllTemplateFiles(srcPath, relativePath));
79
+ } else {
80
+ files.push(relativePath);
81
+ }
82
+ }
83
+ return files;
84
+ }
85
+
86
+ function findNewSections(oldContent, newContent) {
87
+ const newLines = newContent.split('\n');
88
+ const oldLines = oldContent.split('\n');
89
+ const newSections = [];
90
+
91
+ let inNewSection = false;
92
+ let newSectionStart = -1;
93
+
94
+ for (let i = 0; i < newLines.length; i++) {
95
+ const line = newLines[i];
96
+ const isHeader = /^#{1,3}\s+/.test(line);
97
+
98
+ if (isHeader && !oldContent.includes(line)) {
99
+ if (inNewSection && newSectionStart > -1) {
100
+ newSections.push(newLines.slice(newSectionStart, i).join('\n'));
101
+ }
102
+ newSectionStart = i;
103
+ inNewSection = true;
104
+ }
105
+ }
106
+
107
+ if (inNewSection && newSectionStart > -1) {
108
+ newSections.push(newLines.slice(newSectionStart).join('\n'));
109
+ }
110
+
111
+ return newSections;
112
+ }
113
+
114
+ function mergeKnowledgeFiles(userContent, templateContent) {
115
+ const newSections = findNewSections(userContent, templateContent);
116
+
117
+ if (newSections.length === 0) {
118
+ return { action: 'unchanged', content: userContent };
119
+ }
120
+
121
+ let merged = userContent;
122
+
123
+ for (const section of newSections) {
124
+ merged += '\n\n---\n\n' + section;
125
+ }
126
+
127
+ return { action: 'merged', content: merged };
128
+ }
129
+
130
+ export async function checkForUpdates(targetDir) {
131
+ const versionFile = path.join(targetDir, '.ocs-version');
132
+ const currentVersion = fs.existsSync(versionFile)
133
+ ? fs.readFileSync(versionFile, 'utf-8').trim()
134
+ : '0.0.0';
135
+
136
+ const templateVersion = fs.readFileSync(path.join(TEMPLATES_DIR, '.ocs-version'), 'utf-8').trim();
137
+
138
+ const comparison = compareVersion(templateVersion, currentVersion);
139
+
140
+ if (comparison <= 0) {
141
+ return {
142
+ hasUpdates: false,
143
+ currentVersion,
144
+ templateVersion,
145
+ changes: []
146
+ };
147
+ }
148
+
149
+ const templateFiles = getAllTemplateFiles(TEMPLATES_DIR);
150
+ const changes = [];
151
+
152
+ for (const relativePath of templateFiles) {
153
+ const templatePath = path.join(TEMPLATES_DIR, relativePath);
154
+ const targetPath = path.join(targetDir, relativePath);
155
+
156
+ const templateHash = hashFile(templatePath);
157
+ const targetHash = hashFile(targetPath);
158
+ const category = getCategory(relativePath);
159
+
160
+ let state;
161
+ if (!targetHash) {
162
+ state = FILE_STATE.NEW;
163
+ } else if (templateHash === targetHash) {
164
+ state = FILE_STATE.UNMODIFIED;
165
+ } else {
166
+ state = FILE_STATE.MODIFIED;
167
+ }
168
+
169
+ changes.push({
170
+ relativePath,
171
+ category,
172
+ state,
173
+ templateHash,
174
+ targetHash
175
+ });
176
+ }
177
+
178
+ return {
179
+ hasUpdates: comparison > 0,
180
+ currentVersion,
181
+ templateVersion,
182
+ changes
183
+ };
184
+ }
185
+
186
+ export async function performUpdate(targetDir, options = {}) {
187
+ const { force = false, checkOnly = false } = options;
188
+
189
+ const versionFile = path.join(targetDir, '.ocs-version');
190
+ const currentVersion = fs.existsSync(versionFile)
191
+ ? fs.readFileSync(versionFile, 'utf-8').trim()
192
+ : '0.0.0';
193
+
194
+ const templateVersion = fs.readFileSync(path.join(TEMPLATES_DIR, '.ocs-version'), 'utf-8').trim();
195
+
196
+ if (compareVersion(templateVersion, currentVersion) <= 0 && !force) {
197
+ console.log(` Already up to date (v${currentVersion})\n`);
198
+ return { updated: false, changes: [] };
199
+ }
200
+
201
+ const templateFiles = getAllTemplateFiles(TEMPLATES_DIR);
202
+
203
+ const results = {
204
+ updated: [],
205
+ added: [],
206
+ conflicts: [],
207
+ skipped: [],
208
+ merged: []
209
+ };
210
+
211
+ for (const relativePath of templateFiles) {
212
+ const templatePath = path.join(TEMPLATES_DIR, relativePath);
213
+ const targetPath = path.join(targetDir, relativePath);
214
+ const category = getCategory(relativePath);
215
+
216
+ const isVersionFile = relativePath === '.ocs-version';
217
+ const templateHash = hashFile(templatePath);
218
+ const targetHash = hashFile(targetPath);
219
+
220
+ if (!targetHash) {
221
+ if (!checkOnly) {
222
+ const destDir = path.dirname(targetPath);
223
+ if (!fs.existsSync(destDir)) {
224
+ fs.mkdirSync(destDir, { recursive: true });
225
+ }
226
+ fs.copyFileSync(templatePath, targetPath);
227
+ }
228
+ results.added.push(relativePath);
229
+ continue;
230
+ }
231
+
232
+ if (isVersionFile) {
233
+ if (!checkOnly) {
234
+ fs.copyFileSync(templatePath, targetPath);
235
+ }
236
+ results.updated.push(relativePath);
237
+ continue;
238
+ }
239
+
240
+ if (PROTECTED_CATEGORIES.includes(category)) {
241
+ results.skipped.push({ path: relativePath, reason: 'protected (user data)' });
242
+ continue;
243
+ }
244
+
245
+ if (templateHash === targetHash) {
246
+ if (!checkOnly) {
247
+ fs.copyFileSync(templatePath, targetPath);
248
+ }
249
+ results.updated.push(relativePath);
250
+ continue;
251
+ }
252
+
253
+ if (MERGE_CATEGORIES.includes(category)) {
254
+ const userContent = fs.readFileSync(targetPath, 'utf-8');
255
+ const templateContent = fs.readFileSync(templatePath, 'utf-8');
256
+
257
+ const mergeResult = mergeKnowledgeFiles(userContent, templateContent);
258
+
259
+ if (mergeResult.action === 'merged' && !checkOnly) {
260
+ fs.writeFileSync(targetPath, mergeResult.content, 'utf-8');
261
+ }
262
+
263
+ results.merged.push({ path: relativePath, sectionsAdded: mergeResult.action === 'merged' ? 1 : 0 });
264
+ continue;
265
+ }
266
+
267
+ if (checkOnly) {
268
+ results.conflicts.push({ path: relativePath, category });
269
+ continue;
270
+ }
271
+
272
+ results.conflicts.push({ path: relativePath, category, needsPrompt: true });
273
+ }
274
+
275
+ if (!checkOnly) {
276
+ fs.writeFileSync(versionFile, templateVersion, 'utf-8');
277
+ }
278
+
279
+ return {
280
+ updated: true,
281
+ currentVersion,
282
+ templateVersion,
283
+ results
284
+ };
285
+ }
286
+
287
+ export function formatUpdateSummary(updateResult, checkOnly = false) {
288
+ const { results, templateVersion } = updateResult;
289
+
290
+ console.log('');
291
+
292
+ if (checkOnly) {
293
+ console.log(` Template version: v${templateVersion}`);
294
+ console.log(` Status: ${updateResult.hasUpdates ? 'Update available' : 'Up to date'}`);
295
+ } else {
296
+ console.log(` Updated to v${templateVersion}`);
297
+ }
298
+
299
+ if (results.added.length > 0) {
300
+ console.log(`\n Added (${results.added.length}):`);
301
+ for (const file of results.added.slice(0, 5)) {
302
+ console.log(` + ${file}`);
303
+ }
304
+ if (results.added.length > 5) {
305
+ console.log(` ... and ${results.added.length - 5} more`);
306
+ }
307
+ }
308
+
309
+ if (results.updated.length > 0) {
310
+ console.log(`\n Updated (${results.updated.length}):`);
311
+ for (const file of results.updated.slice(0, 5)) {
312
+ console.log(` ~ ${file}`);
313
+ }
314
+ if (results.updated.length > 5) {
315
+ console.log(` ... and ${results.updated.length - 5} more`);
316
+ }
317
+ }
318
+
319
+ if (results.merged.length > 0) {
320
+ console.log(`\n Merged (${results.merged.length}):`);
321
+ for (const file of results.merged.slice(0, 3)) {
322
+ console.log(` ◊ ${file.path}`);
323
+ }
324
+ if (results.merged.length > 3) {
325
+ console.log(` ... and ${results.merged.length - 3} more`);
326
+ }
327
+ }
328
+
329
+ if (results.skipped.length > 0) {
330
+ console.log(`\n Skipped - protected (${results.skipped.length}):`);
331
+ for (const file of results.skipped.slice(0, 3)) {
332
+ console.log(` ⊘ ${file.path} (${file.reason})`);
333
+ }
334
+ if (results.skipped.length > 3) {
335
+ console.log(` ... and ${results.skipped.length - 3} more`);
336
+ }
337
+ }
338
+
339
+ if (results.conflicts.length > 0) {
340
+ console.log(`\n Conflicts (${results.conflicts.length}):`);
341
+ for (const file of results.conflicts.slice(0, 5)) {
342
+ console.log(` ⚠ ${file.path}`);
343
+ }
344
+ if (results.conflicts.length > 5) {
345
+ console.log(` ... and ${results.conflicts.length - 5} more`);
346
+ }
347
+ console.log(`\n Run without --check to resolve conflicts`);
348
+ }
349
+
350
+ console.log('');
351
+ }
@@ -0,0 +1 @@
1
+ 1.1.4