@kaitranntt/ccs 4.1.6 → 4.3.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.
@@ -3,6 +3,8 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
+ const ora = require('ora');
7
+ const { colored } = require('./helpers');
6
8
 
7
9
  /**
8
10
  * ClaudeSymlinkManager - Manages selective symlinks from ~/.ccs/.claude/ to ~/.claude/
@@ -35,41 +37,62 @@ class ClaudeSymlinkManager {
35
37
  * Install CCS items to user's ~/.claude/ via selective symlinks
36
38
  * Safe: backs up existing files before creating symlinks
37
39
  */
38
- install() {
40
+ install(silent = false) {
41
+ const spinner = silent ? null : ora('Installing CCS items to ~/.claude/').start();
42
+
39
43
  // Ensure ~/.ccs/.claude/ exists (should be shipped with package)
40
44
  if (!fs.existsSync(this.ccsClaudeDir)) {
41
- console.log('[!] CCS .claude/ directory not found, skipping symlink installation');
45
+ const msg = 'CCS .claude/ directory not found, skipping symlink installation';
46
+ if (spinner) {
47
+ spinner.warn(`[!] ${msg}`);
48
+ } else {
49
+ console.log(`[!] ${msg}`);
50
+ }
42
51
  return;
43
52
  }
44
53
 
45
54
  // Create ~/.claude/ if missing
46
55
  if (!fs.existsSync(this.userClaudeDir)) {
47
- console.log('[i] Creating ~/.claude/ directory');
56
+ if (!silent) {
57
+ if (spinner) spinner.text = 'Creating ~/.claude/ directory';
58
+ }
48
59
  fs.mkdirSync(this.userClaudeDir, { recursive: true, mode: 0o700 });
49
60
  }
50
61
 
51
62
  // Install each CCS item
63
+ let installed = 0;
52
64
  for (const item of this.ccsItems) {
53
- this._installItem(item);
65
+ if (!silent && spinner) {
66
+ spinner.text = `Installing ${item.target}...`;
67
+ }
68
+ const result = this._installItem(item, silent);
69
+ if (result) installed++;
54
70
  }
55
71
 
56
- console.log('[OK] Delegation commands and skills installed to ~/.claude/');
72
+ const msg = `${installed}/${this.ccsItems.length} items installed to ~/.claude/`;
73
+ if (spinner) {
74
+ spinner.succeed(colored('[OK]', 'green') + ` ${msg}`);
75
+ } else {
76
+ console.log(`[OK] ${msg}`);
77
+ }
57
78
  }
58
79
 
59
80
  /**
60
81
  * Install a single CCS item with conflict handling
61
82
  * @param {Object} item - Item descriptor {source, target, type}
83
+ * @param {boolean} silent - Suppress individual item messages
84
+ * @returns {boolean} True if installed successfully
62
85
  * @private
63
86
  */
64
- _installItem(item) {
87
+ _installItem(item, silent = false) {
65
88
  const sourcePath = path.join(this.ccsClaudeDir, item.source);
66
89
  const targetPath = path.join(this.userClaudeDir, item.target);
67
90
  const targetDir = path.dirname(targetPath);
68
91
 
69
92
  // Ensure source exists
70
93
  if (!fs.existsSync(sourcePath)) {
71
- console.log(`[!] Source not found: ${item.source}, skipping`);
72
- return;
94
+ if (!silent) console.log(`[!] Source not found: ${item.source}, skipping`);
95
+ return false;
73
96
  }
74
97
 
75
98
  // Create target parent directory if needed
@@ -81,26 +104,30 @@ class ClaudeSymlinkManager {
81
104
  if (fs.existsSync(targetPath)) {
82
105
  // Check if it's already the correct symlink
83
106
  if (this._isOurSymlink(targetPath, sourcePath)) {
84
- return; // Already correct, skip
107
+ return true; // Already correct, counts as success
85
108
  }
86
109
 
87
110
  // Backup existing file/directory
88
- this._backupItem(targetPath);
111
+ this._backupItem(targetPath, silent);
89
112
  }
90
113
 
91
114
  // Create symlink
92
115
  try {
93
116
  const symlinkType = item.type === 'directory' ? 'dir' : 'file';
94
117
  fs.symlinkSync(sourcePath, targetPath, symlinkType);
95
- console.log(`[OK] Symlinked ${item.target}`);
118
+ if (!silent) console.log(`[OK] Symlinked ${item.target}`);
119
+ return true;
96
120
  } catch (err) {
97
121
  // Windows fallback: stub for now, full implementation in v4.2
98
122
  if (process.platform === 'win32') {
99
- console.log(`[!] Symlink failed for ${item.target} (Windows fallback deferred to v4.2)`);
100
- console.log(`[i] Enable Developer Mode or wait for next update`);
123
+ if (!silent) {
124
+ console.log(`[!] Symlink failed for ${item.target} (Windows fallback deferred to v4.2)`);
125
+ console.log(`[i] Enable Developer Mode or wait for next update`);
126
+ }
101
127
  } else {
102
- console.log(`[!] Failed to symlink ${item.target}: ${err.message}`);
128
+ if (!silent) console.log(`[!] Failed to symlink ${item.target}: ${err.message}`);
103
129
  }
130
+ return false;
104
131
  }
105
132
  }
106
133
 
@@ -131,9 +158,10 @@ class ClaudeSymlinkManager {
131
158
  /**
132
159
  * Backup existing item before replacing with symlink
133
160
  * @param {string} itemPath - Path to item to backup
161
+ * @param {boolean} silent - Suppress backup messages
134
162
  * @private
135
163
  */
136
- _backupItem(itemPath) {
164
+ _backupItem(itemPath, silent = false) {
137
165
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0];
138
166
  const backupPath = `${itemPath}.backup-${timestamp}`;
139
167
 
@@ -147,9 +175,9 @@ class ClaudeSymlinkManager {
147
175
  }
148
176
 
149
177
  fs.renameSync(itemPath, finalBackupPath);
150
- console.log(`[i] Backed up existing item to ${path.basename(finalBackupPath)}`);
178
+ if (!silent) console.log(`[i] Backed up existing item to ${path.basename(finalBackupPath)}`);
151
179
  } catch (err) {
152
- console.log(`[!] Failed to backup ${itemPath}: ${err.message}`);
180
+ if (!silent) console.log(`[!] Failed to backup ${itemPath}: ${err.message}`);
153
181
  throw err; // Don't proceed if backup fails
154
182
  }
155
183
  }
@@ -230,8 +258,10 @@ class ClaudeSymlinkManager {
230
258
  * Same as install() but with explicit sync message
231
259
  */
232
260
  sync() {
233
- console.log('[i] Syncing delegation commands and skills to ~/.claude/...');
234
- this.install();
261
+ console.log('');
262
+ console.log(colored('Syncing CCS Components...', 'cyan'));
263
+ console.log('');
264
+ this.install(false);
235
265
  }
236
266
  }
237
267
 
@@ -0,0 +1,243 @@
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 { colored } = require('./helpers');
8
+
9
+ const UPDATE_CHECK_FILE = path.join(os.homedir(), '.ccs', 'update-check.json');
10
+ const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
11
+ const GITHUB_API_URL = 'https://api.github.com/repos/kaitranntt/ccs/releases/latest';
12
+ const NPM_REGISTRY_URL = 'https://registry.npmjs.org/@kaitranntt/ccs/latest';
13
+ const REQUEST_TIMEOUT = 5000; // 5 seconds
14
+
15
+ /**
16
+ * Compare semantic versions
17
+ * @param {string} v1 - First version (e.g., "4.1.6")
18
+ * @param {string} v2 - Second version
19
+ * @returns {number} - 1 if v1 > v2, -1 if v1 < v2, 0 if equal
20
+ */
21
+ function compareVersions(v1, v2) {
22
+ const parts1 = v1.replace(/^v/, '').split('.').map(Number);
23
+ const parts2 = v2.replace(/^v/, '').split('.').map(Number);
24
+
25
+ for (let i = 0; i < 3; i++) {
26
+ const p1 = parts1[i] || 0;
27
+ const p2 = parts2[i] || 0;
28
+ if (p1 > p2) return 1;
29
+ if (p1 < p2) return -1;
30
+ }
31
+ return 0;
32
+ }
33
+
34
+ /**
35
+ * Fetch latest version from GitHub releases
36
+ * @returns {Promise<string|null>} - Latest version or null on error
37
+ */
38
+ function fetchLatestVersionFromGitHub() {
39
+ return new Promise((resolve) => {
40
+ const req = https.get(GITHUB_API_URL, {
41
+ headers: { 'User-Agent': 'CCS-Update-Checker' },
42
+ timeout: REQUEST_TIMEOUT
43
+ }, (res) => {
44
+ let data = '';
45
+
46
+ res.on('data', (chunk) => {
47
+ data += chunk;
48
+ });
49
+
50
+ res.on('end', () => {
51
+ try {
52
+ if (res.statusCode !== 200) {
53
+ resolve(null);
54
+ return;
55
+ }
56
+
57
+ const release = JSON.parse(data);
58
+ const version = release.tag_name?.replace(/^v/, '') || null;
59
+ resolve(version);
60
+ } catch (err) {
61
+ resolve(null);
62
+ }
63
+ });
64
+ });
65
+
66
+ req.on('error', () => resolve(null));
67
+ req.on('timeout', () => {
68
+ req.destroy();
69
+ resolve(null);
70
+ });
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Fetch latest version from npm registry
76
+ * @returns {Promise<string|null>} - Latest version or null on error
77
+ */
78
+ function fetchLatestVersionFromNpm() {
79
+ return new Promise((resolve) => {
80
+ const req = https.get(NPM_REGISTRY_URL, {
81
+ headers: { 'User-Agent': 'CCS-Update-Checker' },
82
+ timeout: REQUEST_TIMEOUT
83
+ }, (res) => {
84
+ let data = '';
85
+
86
+ res.on('data', (chunk) => {
87
+ data += chunk;
88
+ });
89
+
90
+ res.on('end', () => {
91
+ try {
92
+ if (res.statusCode !== 200) {
93
+ resolve(null);
94
+ return;
95
+ }
96
+
97
+ const packageData = JSON.parse(data);
98
+ const version = packageData.version || null;
99
+ resolve(version);
100
+ } catch (err) {
101
+ resolve(null);
102
+ }
103
+ });
104
+ });
105
+
106
+ req.on('error', () => resolve(null));
107
+ req.on('timeout', () => {
108
+ req.destroy();
109
+ resolve(null);
110
+ });
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Read update check cache
116
+ * @returns {Object} - Cache object
117
+ */
118
+ function readCache() {
119
+ try {
120
+ if (!fs.existsSync(UPDATE_CHECK_FILE)) {
121
+ return { last_check: 0, latest_version: null, dismissed_version: null };
122
+ }
123
+
124
+ const data = fs.readFileSync(UPDATE_CHECK_FILE, 'utf8');
125
+ return JSON.parse(data);
126
+ } catch (err) {
127
+ return { last_check: 0, latest_version: null, dismissed_version: null };
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Write update check cache
133
+ * @param {Object} cache - Cache object to write
134
+ */
135
+ function writeCache(cache) {
136
+ try {
137
+ const ccsDir = path.join(os.homedir(), '.ccs');
138
+ if (!fs.existsSync(ccsDir)) {
139
+ fs.mkdirSync(ccsDir, { recursive: true, mode: 0o700 });
140
+ }
141
+
142
+ fs.writeFileSync(UPDATE_CHECK_FILE, JSON.stringify(cache, null, 2), 'utf8');
143
+ } catch (err) {
144
+ // Silently fail - not critical
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Check for updates (async, non-blocking)
150
+ * @param {string} currentVersion - Current CCS version
151
+ * @param {boolean} force - Force check even if within interval
152
+ * @param {string} installMethod - Installation method ('npm' or 'direct')
153
+ * @returns {Promise<Object>} - Update result object with status and data
154
+ */
155
+ async function checkForUpdates(currentVersion, force = false, installMethod = 'direct') {
156
+ const cache = readCache();
157
+ const now = Date.now();
158
+
159
+ // Check if we should check for updates
160
+ if (!force && (now - cache.last_check < CHECK_INTERVAL)) {
161
+ // Use cached result if available
162
+ if (cache.latest_version && compareVersions(cache.latest_version, currentVersion) > 0) {
163
+ // Don't show if user dismissed this version
164
+ if (cache.dismissed_version === cache.latest_version) {
165
+ return { status: 'no_update', reason: 'dismissed' };
166
+ }
167
+ return { status: 'update_available', latest: cache.latest_version, current: currentVersion };
168
+ }
169
+ return { status: 'no_update', reason: 'cached' };
170
+ }
171
+
172
+ // Fetch latest version from appropriate source
173
+ let latestVersion;
174
+ let fetchError = null;
175
+
176
+ if (installMethod === 'npm') {
177
+ latestVersion = await fetchLatestVersionFromNpm();
178
+ if (!latestVersion) fetchError = 'npm_registry_error';
179
+ } else {
180
+ latestVersion = await fetchLatestVersionFromGitHub();
181
+ if (!latestVersion) fetchError = 'github_api_error';
182
+ }
183
+
184
+ // Update cache
185
+ cache.last_check = now;
186
+ if (latestVersion) {
187
+ cache.latest_version = latestVersion;
188
+ }
189
+ writeCache(cache);
190
+
191
+ // Handle fetch errors
192
+ if (fetchError) {
193
+ return {
194
+ status: 'check_failed',
195
+ reason: fetchError,
196
+ message: `Failed to check for updates: ${fetchError.replace(/_/g, ' ')}`
197
+ };
198
+ }
199
+
200
+ // Check if update available
201
+ if (latestVersion && compareVersions(latestVersion, currentVersion) > 0) {
202
+ // Don't show if user dismissed this version
203
+ if (cache.dismissed_version === latestVersion) {
204
+ return { status: 'no_update', reason: 'dismissed' };
205
+ }
206
+ return { status: 'update_available', latest: latestVersion, current: currentVersion };
207
+ }
208
+
209
+ return { status: 'no_update', reason: 'latest' };
210
+ }
211
+
212
+ /**
213
+ * Show update notification
214
+ * @param {Object} updateInfo - Update information
215
+ */
216
+ function showUpdateNotification(updateInfo) {
217
+ console.log('');
218
+ console.log(colored('═══════════════════════════════════════════════════════', 'cyan'));
219
+ console.log(colored(` Update available: ${updateInfo.current} → ${updateInfo.latest}`, 'yellow'));
220
+ console.log(colored('═══════════════════════════════════════════════════════', 'cyan'));
221
+ console.log('');
222
+ console.log(` Run ${colored('ccs update', 'yellow')} to update`);
223
+ console.log('');
224
+ }
225
+
226
+ /**
227
+ * Dismiss update notification for a specific version
228
+ * @param {string} version - Version to dismiss
229
+ */
230
+ function dismissUpdate(version) {
231
+ const cache = readCache();
232
+ cache.dismissed_version = version;
233
+ writeCache(cache);
234
+ }
235
+
236
+ module.exports = {
237
+ compareVersions,
238
+ checkForUpdates,
239
+ showUpdateNotification,
240
+ dismissUpdate,
241
+ readCache,
242
+ writeCache
243
+ };
package/lib/ccs CHANGED
@@ -2,7 +2,7 @@
2
2
  set -euo pipefail
3
3
 
4
4
  # Version (updated by scripts/bump-version.sh)
5
- CCS_VERSION="4.1.6"
5
+ CCS_VERSION="4.3.0"
6
6
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
7
  readonly CONFIG_FILE="${CCS_CONFIG:-$HOME/.ccs/config.json}"
8
8
  readonly PROFILES_JSON="$HOME/.ccs/profiles.json"
@@ -199,6 +199,7 @@ show_help() {
199
199
  echo -e "${CYAN}Diagnostics:${RESET}"
200
200
  echo -e " ${YELLOW}ccs doctor${RESET} Run health check and diagnostics"
201
201
  echo -e " ${YELLOW}ccs sync${RESET} Sync delegation commands and skills"
202
+ echo -e " ${YELLOW}ccs update${RESET} Update CCS to latest version"
202
203
  echo ""
203
204
 
204
205
  echo -e "${CYAN}Flags:${RESET}"
@@ -591,6 +592,124 @@ sync_run() {
591
592
  echo ""
592
593
  }
593
594
 
595
+ # --- Update Command ---
596
+
597
+ update_run() {
598
+ echo ""
599
+ echo -e "${CYAN}Checking for updates...${RESET}"
600
+ echo ""
601
+
602
+ # Detect installation method
603
+ local install_method="direct"
604
+ if command -v npm &>/dev/null && npm list -g @kaitranntt/ccs &>/dev/null 2>&1; then
605
+ install_method="npm"
606
+ fi
607
+
608
+ # Fetch latest version from appropriate source
609
+ local latest_version=""
610
+ if command -v curl &>/dev/null; then
611
+ if [[ "$install_method" == "npm" ]]; then
612
+ # Check npm registry for npm installations
613
+ latest_version=$(curl -fsSL https://registry.npmjs.org/@kaitranntt/ccs/latest 2>/dev/null | \
614
+ grep '"version"' | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9.]+)".*/\1/')
615
+ else
616
+ # Check GitHub releases for direct installations
617
+ latest_version=$(curl -fsSL https://api.github.com/repos/kaitranntt/ccs/releases/latest 2>/dev/null | \
618
+ grep '"tag_name"' | sed -E 's/.*"v?([0-9.]+)".*/\1/')
619
+ fi
620
+ fi
621
+
622
+ if [[ -z "$latest_version" ]]; then
623
+ echo -e "${YELLOW}[!] Unable to check for updates${RESET}"
624
+ echo ""
625
+ echo "Try manually:"
626
+ if [[ "$install_method" == "npm" ]]; then
627
+ echo -e " ${YELLOW}npm install -g @kaitranntt/ccs@latest${RESET}"
628
+ else
629
+ echo -e " ${YELLOW}curl -fsSL ccs.kaitran.ca/install | bash${RESET}"
630
+ fi
631
+ echo ""
632
+ exit 1
633
+ fi
634
+
635
+ # Compare versions
636
+ if [[ "$latest_version" == "$CCS_VERSION" ]]; then
637
+ echo -e "${GREEN}[OK] You are already on the latest version (${CCS_VERSION})${RESET}"
638
+ echo ""
639
+ exit 0
640
+ fi
641
+
642
+ # Check if update available
643
+ local current_major=$(echo "$CCS_VERSION" | cut -d. -f1)
644
+ local current_minor=$(echo "$CCS_VERSION" | cut -d. -f2)
645
+ local current_patch=$(echo "$CCS_VERSION" | cut -d. -f3)
646
+
647
+ local latest_major=$(echo "$latest_version" | cut -d. -f1)
648
+ local latest_minor=$(echo "$latest_version" | cut -d. -f2)
649
+ local latest_patch=$(echo "$latest_version" | cut -d. -f3)
650
+
651
+ local is_newer=0
652
+ if [[ $latest_major -gt $current_major ]]; then
653
+ is_newer=1
654
+ elif [[ $latest_major -eq $current_major ]] && [[ $latest_minor -gt $current_minor ]]; then
655
+ is_newer=1
656
+ elif [[ $latest_major -eq $current_major ]] && [[ $latest_minor -eq $current_minor ]] && [[ $latest_patch -gt $current_patch ]]; then
657
+ is_newer=1
658
+ fi
659
+
660
+ if [[ $is_newer -eq 0 ]]; then
661
+ echo -e "${GREEN}[OK] You are on version ${CCS_VERSION} (latest is ${latest_version})${RESET}"
662
+ echo ""
663
+ exit 0
664
+ fi
665
+
666
+ echo -e "${YELLOW}[i] Update available: ${CCS_VERSION} → ${latest_version}${RESET}"
667
+ echo ""
668
+
669
+ # Perform update based on installation method
670
+ if [[ "$install_method" == "npm" ]]; then
671
+ echo -e "${CYAN}Updating via npm...${RESET}"
672
+ echo ""
673
+
674
+ if npm install -g @kaitranntt/ccs@latest; then
675
+ echo ""
676
+ echo -e "${GREEN}[OK] Update successful!${RESET}"
677
+ echo ""
678
+ echo -e "Run ${YELLOW}ccs --version${RESET} to verify"
679
+ echo ""
680
+ exit 0
681
+ else
682
+ echo ""
683
+ echo -e "${RED}[X] Update failed${RESET}"
684
+ echo ""
685
+ echo "Try manually:"
686
+ echo -e " ${YELLOW}npm install -g @kaitranntt/ccs@latest${RESET}"
687
+ echo ""
688
+ exit 1
689
+ fi
690
+ else
691
+ echo -e "${CYAN}Updating via installer...${RESET}"
692
+ echo ""
693
+
694
+ if curl -fsSL ccs.kaitran.ca/install | bash; then
695
+ echo ""
696
+ echo -e "${GREEN}[OK] Update successful!${RESET}"
697
+ echo ""
698
+ echo -e "Run ${YELLOW}ccs --version${RESET} to verify"
699
+ echo ""
700
+ exit 0
701
+ else
702
+ echo ""
703
+ echo -e "${RED}[X] Update failed${RESET}"
704
+ echo ""
705
+ echo "Try manually:"
706
+ echo -e " ${YELLOW}curl -fsSL ccs.kaitran.ca/install | bash${RESET}"
707
+ echo ""
708
+ exit 1
709
+ fi
710
+ fi
711
+ }
712
+
594
713
  # --- Claude CLI Detection Logic ---
595
714
 
596
715
  detect_claude_cli() {
@@ -1669,6 +1788,12 @@ if [[ $# -gt 0 ]] && [[ "${1}" == "sync" || "${1}" == "--sync" ]]; then
1669
1788
  exit $?
1670
1789
  fi
1671
1790
 
1791
+ # Special case: update command
1792
+ if [[ $# -gt 0 ]] && [[ "${1}" == "update" || "${1}" == "--update" ]]; then
1793
+ update_run
1794
+ exit $?
1795
+ fi
1796
+
1672
1797
  # Run auto-recovery before main logic
1673
1798
  auto_recover || {
1674
1799
  msg_error "Auto-recovery failed. Check permissions."