jettypod 4.4.118 → 4.4.121

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.
Files changed (240) hide show
  1. package/.env +4 -3
  2. package/Cargo.lock +6450 -0
  3. package/Cargo.toml +35 -0
  4. package/README.md +5 -1
  5. package/TAURI-MIGRATION-PLAN.md +840 -0
  6. package/apps/dashboard/app/connect-claude/page.tsx +5 -6
  7. package/apps/dashboard/app/decision/[id]/page.tsx +63 -58
  8. package/apps/dashboard/app/demo/gates/page.tsx +43 -45
  9. package/apps/dashboard/app/design-system/page.tsx +868 -0
  10. package/apps/dashboard/app/globals.css +80 -4
  11. package/apps/dashboard/app/install-claude/page.tsx +4 -6
  12. package/apps/dashboard/app/login/page.tsx +72 -54
  13. package/apps/dashboard/app/page.tsx +101 -48
  14. package/apps/dashboard/app/settings/page.tsx +61 -13
  15. package/apps/dashboard/app/signup/page.tsx +242 -0
  16. package/apps/dashboard/app/subscribe/page.tsx +0 -2
  17. package/apps/dashboard/app/tests/page.tsx +37 -4
  18. package/apps/dashboard/app/welcome/page.tsx +13 -16
  19. package/apps/dashboard/app/work/[id]/page.tsx +117 -118
  20. package/apps/dashboard/app/work/[id]/proof/page.tsx +1489 -0
  21. package/apps/dashboard/components/AppShell.tsx +92 -85
  22. package/apps/dashboard/components/CardMenu.tsx +45 -12
  23. package/apps/dashboard/components/ClaudePanel.tsx +771 -850
  24. package/apps/dashboard/components/ClaudePanelInput.tsx +43 -15
  25. package/apps/dashboard/components/ConnectClaudeScreen.tsx +17 -34
  26. package/apps/dashboard/components/CopyableId.tsx +3 -4
  27. package/apps/dashboard/components/DetailReviewActions.tsx +100 -0
  28. package/apps/dashboard/components/DragContext.tsx +134 -63
  29. package/apps/dashboard/components/DraggableCard.tsx +3 -5
  30. package/apps/dashboard/components/DropZone.tsx +6 -7
  31. package/apps/dashboard/components/EditableDetailDescription.tsx +7 -13
  32. package/apps/dashboard/components/EditableDetailTitle.tsx +6 -13
  33. package/apps/dashboard/components/EditableTitle.tsx +26 -7
  34. package/apps/dashboard/components/ElapsedTimer.tsx +66 -0
  35. package/apps/dashboard/components/EpicGroup.tsx +359 -0
  36. package/apps/dashboard/components/GateCard.tsx +79 -17
  37. package/apps/dashboard/components/GateChoiceCard.tsx +15 -18
  38. package/apps/dashboard/components/InstallClaudeScreen.tsx +15 -32
  39. package/apps/dashboard/components/JettyLoader.tsx +37 -0
  40. package/apps/dashboard/components/KanbanBoard.tsx +368 -958
  41. package/apps/dashboard/components/KanbanCard.tsx +740 -0
  42. package/apps/dashboard/components/LazyCard.tsx +62 -0
  43. package/apps/dashboard/components/LazyMarkdown.tsx +11 -0
  44. package/apps/dashboard/components/MainNav.tsx +38 -73
  45. package/apps/dashboard/components/MessageBlock.tsx +468 -0
  46. package/apps/dashboard/components/ModeStartCard.tsx +15 -16
  47. package/apps/dashboard/components/OnboardingWelcome.tsx +213 -0
  48. package/apps/dashboard/components/PlaceholderCard.tsx +3 -4
  49. package/apps/dashboard/components/ProjectSwitcher.tsx +30 -30
  50. package/apps/dashboard/components/PrototypeTimeline.tsx +72 -51
  51. package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +406 -388
  52. package/apps/dashboard/components/RealTimeTestsWrapper.tsx +373 -235
  53. package/apps/dashboard/components/ReviewFooter.tsx +139 -0
  54. package/apps/dashboard/components/SessionList.tsx +19 -19
  55. package/apps/dashboard/components/SubscribeContent.tsx +91 -47
  56. package/apps/dashboard/components/TestTree.tsx +16 -16
  57. package/apps/dashboard/components/TipCard.tsx +16 -17
  58. package/apps/dashboard/components/Toast.tsx +5 -6
  59. package/apps/dashboard/components/TypeIcon.tsx +55 -0
  60. package/apps/dashboard/components/ViewModeToolbar.tsx +104 -0
  61. package/apps/dashboard/components/WaveCompletionAnimation.tsx +52 -65
  62. package/apps/dashboard/components/WelcomeScreen.tsx +19 -35
  63. package/apps/dashboard/components/WorkItemHeader.tsx +4 -5
  64. package/apps/dashboard/components/WorkItemTree.tsx +11 -32
  65. package/apps/dashboard/components/settings/AccountSection.tsx +55 -35
  66. package/apps/dashboard/components/settings/AiContextSection.tsx +89 -0
  67. package/apps/dashboard/components/settings/ContextDocumentsSection.tsx +317 -0
  68. package/apps/dashboard/components/settings/EnvVarsSection.tsx +74 -152
  69. package/apps/dashboard/components/settings/GeneralSection.tsx +162 -56
  70. package/apps/dashboard/components/settings/ProjectStackSection.tsx +948 -0
  71. package/apps/dashboard/components/settings/SettingsLayout.tsx +4 -5
  72. package/apps/dashboard/components/ui/Button.tsx +104 -0
  73. package/apps/dashboard/components/ui/Input.tsx +78 -0
  74. package/apps/dashboard/components.json +1 -1
  75. package/apps/dashboard/contexts/ClaudeSessionContext.tsx +711 -418
  76. package/apps/dashboard/contexts/ConnectionStatusContext.tsx +25 -5
  77. package/apps/dashboard/contexts/UsageContext.tsx +87 -32
  78. package/apps/dashboard/dev.sh +35 -0
  79. package/apps/dashboard/eslint.config.mjs +9 -9
  80. package/apps/dashboard/hooks/useKanbanAnimation.ts +29 -0
  81. package/apps/dashboard/hooks/useKanbanUndo.ts +83 -0
  82. package/apps/dashboard/hooks/useWebSocket.ts +138 -83
  83. package/apps/dashboard/index.html +73 -0
  84. package/apps/dashboard/lib/constants.ts +43 -0
  85. package/apps/dashboard/lib/data-bridge.ts +722 -0
  86. package/apps/dashboard/lib/db.ts +69 -1265
  87. package/apps/dashboard/lib/environment-config.ts +173 -0
  88. package/apps/dashboard/lib/environment-verification.ts +119 -0
  89. package/apps/dashboard/lib/kanban-utils.ts +270 -0
  90. package/apps/dashboard/lib/proof-run.ts +495 -0
  91. package/apps/dashboard/lib/proof-scenario-runner.ts +346 -0
  92. package/apps/dashboard/lib/run-migrations.js +27 -2
  93. package/apps/dashboard/lib/service-recovery.ts +326 -0
  94. package/apps/dashboard/lib/session-state-machine.ts +1 -0
  95. package/apps/dashboard/lib/session-state-utils.ts +0 -164
  96. package/apps/dashboard/lib/session-stream-manager.ts +308 -134
  97. package/apps/dashboard/lib/shadows.ts +7 -0
  98. package/apps/dashboard/lib/stream-manager-registry.ts +46 -6
  99. package/apps/dashboard/lib/tauri-bridge.ts +102 -0
  100. package/apps/dashboard/lib/tauri.ts +106 -0
  101. package/apps/dashboard/lib/utils.ts +6 -0
  102. package/apps/dashboard/next-env.d.ts +1 -1
  103. package/apps/dashboard/package.json +21 -32
  104. package/apps/dashboard/public/bug-icon.png +0 -0
  105. package/apps/dashboard/public/buoy-icon.png +0 -0
  106. package/apps/dashboard/public/fonts/Satoshi-Variable.woff2 +0 -0
  107. package/apps/dashboard/public/fonts/Satoshi-VariableItalic.woff2 +0 -0
  108. package/apps/dashboard/public/in-flight-seagull.png +0 -0
  109. package/apps/dashboard/public/jetty-icon-loading-alt.svg +11 -0
  110. package/apps/dashboard/public/jetty-icon-loading.svg +11 -0
  111. package/apps/dashboard/public/jettypod_logo.png +0 -0
  112. package/apps/dashboard/public/pier-icon.png +0 -0
  113. package/apps/dashboard/public/star-icon.png +0 -0
  114. package/apps/dashboard/public/wrench-icon.png +0 -0
  115. package/apps/dashboard/scripts/tauri-build.js +228 -0
  116. package/apps/dashboard/scripts/upload-tauri-to-r2.js +125 -0
  117. package/apps/dashboard/scripts/ws-server.js +191 -0
  118. package/apps/dashboard/src/main.tsx +12 -0
  119. package/apps/dashboard/src/router.tsx +107 -0
  120. package/apps/dashboard/src/vite-env.d.ts +1 -0
  121. package/apps/dashboard/tsconfig.json +7 -12
  122. package/apps/dashboard/tsconfig.tsbuildinfo +1 -1
  123. package/apps/dashboard/vite.config.ts +33 -0
  124. package/apps/update-server/src/index.ts +228 -80
  125. package/claude-hooks/global-guardrails.js +14 -13
  126. package/crates/jettypod-cli/Cargo.toml +19 -0
  127. package/crates/jettypod-cli/src/commands.rs +1249 -0
  128. package/crates/jettypod-cli/src/main.rs +595 -0
  129. package/crates/jettypod-core/Cargo.toml +26 -0
  130. package/crates/jettypod-core/build.rs +98 -0
  131. package/crates/jettypod-core/migrations/V1__baseline.sql +197 -0
  132. package/crates/jettypod-core/migrations/V2__work_items_indexes.sql +6 -0
  133. package/crates/jettypod-core/migrations/V3__qa_steps.sql +2 -0
  134. package/crates/jettypod-core/src/auth.rs +294 -0
  135. package/crates/jettypod-core/src/config.rs +397 -0
  136. package/crates/jettypod-core/src/db/mod.rs +507 -0
  137. package/crates/jettypod-core/src/db/recovery.rs +114 -0
  138. package/crates/jettypod-core/src/db/startup.rs +101 -0
  139. package/crates/jettypod-core/src/db/validate.rs +149 -0
  140. package/crates/jettypod-core/src/error.rs +76 -0
  141. package/crates/jettypod-core/src/git.rs +458 -0
  142. package/crates/jettypod-core/src/lib.rs +20 -0
  143. package/crates/jettypod-core/src/sessions.rs +625 -0
  144. package/crates/jettypod-core/src/skills.rs +556 -0
  145. package/crates/jettypod-core/src/work.rs +1086 -0
  146. package/crates/jettypod-core/src/worktree.rs +628 -0
  147. package/crates/jettypod-core/src/ws.rs +767 -0
  148. package/cucumber-test.cjs +6 -0
  149. package/cucumber.js +9 -3
  150. package/docs/COMMAND_REFERENCE.md +34 -0
  151. package/hooks/post-checkout +32 -75
  152. package/hooks/post-merge +111 -10
  153. package/jest.setup.js +1 -0
  154. package/jettypod.js +145 -116
  155. package/lib/bdd-preflight.js +96 -0
  156. package/lib/chore-taxonomy.js +33 -10
  157. package/lib/database.js +36 -16
  158. package/lib/db-watcher.js +1 -1
  159. package/lib/git-hooks/pre-commit +1 -1
  160. package/lib/jettypod-backup.js +27 -4
  161. package/lib/merge-lock.js +111 -253
  162. package/lib/migrations/027-plan-at-creation-column.js +3 -1
  163. package/lib/migrations/029-remove-autoincrement.js +307 -0
  164. package/lib/migrations/029-rename-corrupted-to-cleaned.js +149 -0
  165. package/lib/migrations/030-rejection-round-columns.js +54 -0
  166. package/lib/migrations/031-session-isolation-index.js +17 -0
  167. package/lib/migrations/index.js +47 -4
  168. package/lib/schema.js +10 -5
  169. package/lib/seed-onboarding.js +1 -1
  170. package/lib/update-command/index.js +9 -175
  171. package/lib/work-commands/index.js +144 -19
  172. package/lib/work-tracking/index.js +148 -27
  173. package/lib/worktree-diagnostics.js +16 -16
  174. package/lib/worktree-facade.js +1 -1
  175. package/lib/worktree-manager.js +8 -8
  176. package/lib/worktree-reconciler.js +5 -5
  177. package/package.json +9 -2
  178. package/scripts/ndjson-to-cucumber-json.js +152 -0
  179. package/scripts/postinstall.js +25 -0
  180. package/skills-templates/bug-mode/SKILL.md +79 -20
  181. package/skills-templates/bug-planning/SKILL.md +25 -29
  182. package/skills-templates/chore-mode/SKILL.md +171 -69
  183. package/skills-templates/chore-mode/verification.js +51 -10
  184. package/skills-templates/chore-planning/SKILL.md +47 -18
  185. package/skills-templates/design-system-selection/SKILL.md +273 -0
  186. package/skills-templates/epic-planning/SKILL.md +82 -48
  187. package/skills-templates/external-transition/SKILL.md +47 -47
  188. package/skills-templates/feature-planning/SKILL.md +173 -74
  189. package/skills-templates/production-mode/SKILL.md +69 -49
  190. package/skills-templates/request-routing/SKILL.md +4 -4
  191. package/skills-templates/simple-improvement/SKILL.md +74 -29
  192. package/skills-templates/speed-mode/SKILL.md +217 -141
  193. package/skills-templates/stable-mode/SKILL.md +148 -89
  194. package/apps/dashboard/README.md +0 -36
  195. package/apps/dashboard/app/api/claude/[workItemId]/message/route.ts +0 -386
  196. package/apps/dashboard/app/api/claude/[workItemId]/pin/route.ts +0 -24
  197. package/apps/dashboard/app/api/claude/[workItemId]/route.ts +0 -167
  198. package/apps/dashboard/app/api/claude/sessions/[sessionId]/content/route.ts +0 -52
  199. package/apps/dashboard/app/api/claude/sessions/[sessionId]/message/route.ts +0 -378
  200. package/apps/dashboard/app/api/claude/sessions/[sessionId]/pin/route.ts +0 -24
  201. package/apps/dashboard/app/api/claude/sessions/cleanup/route.ts +0 -34
  202. package/apps/dashboard/app/api/claude/sessions/route.ts +0 -184
  203. package/apps/dashboard/app/api/decisions/[id]/route.ts +0 -25
  204. package/apps/dashboard/app/api/internal/set-project/route.ts +0 -17
  205. package/apps/dashboard/app/api/kanban/route.ts +0 -15
  206. package/apps/dashboard/app/api/settings/env-vars/route.ts +0 -125
  207. package/apps/dashboard/app/api/settings/general/route.ts +0 -21
  208. package/apps/dashboard/app/api/tests/route.ts +0 -9
  209. package/apps/dashboard/app/api/tests/run/route.ts +0 -82
  210. package/apps/dashboard/app/api/tests/run/stream/route.ts +0 -71
  211. package/apps/dashboard/app/api/tests/undefined/route.ts +0 -9
  212. package/apps/dashboard/app/api/usage/route.ts +0 -17
  213. package/apps/dashboard/app/api/work/[id]/description/route.ts +0 -21
  214. package/apps/dashboard/app/api/work/[id]/epic/route.ts +0 -21
  215. package/apps/dashboard/app/api/work/[id]/order/route.ts +0 -21
  216. package/apps/dashboard/app/api/work/[id]/status/route.ts +0 -21
  217. package/apps/dashboard/app/api/work/[id]/title/route.ts +0 -21
  218. package/apps/dashboard/app/layout.tsx +0 -43
  219. package/apps/dashboard/components/UpgradeBanner.tsx +0 -29
  220. package/apps/dashboard/electron/ipc-handlers.js +0 -1028
  221. package/apps/dashboard/electron/main.js +0 -2124
  222. package/apps/dashboard/electron/preload.js +0 -123
  223. package/apps/dashboard/electron/session-manager.js +0 -141
  224. package/apps/dashboard/electron-builder.config.js +0 -357
  225. package/apps/dashboard/hooks/useClaudeSessions.ts +0 -299
  226. package/apps/dashboard/lib/claude-process-manager.ts +0 -492
  227. package/apps/dashboard/lib/db-bridge.ts +0 -282
  228. package/apps/dashboard/lib/prototypes.ts +0 -202
  229. package/apps/dashboard/lib/test-results-db.ts +0 -307
  230. package/apps/dashboard/lib/tests.ts +0 -282
  231. package/apps/dashboard/next.config.js +0 -50
  232. package/apps/dashboard/postcss.config.mjs +0 -7
  233. package/apps/dashboard/public/file.svg +0 -1
  234. package/apps/dashboard/public/globe.svg +0 -1
  235. package/apps/dashboard/public/next.svg +0 -1
  236. package/apps/dashboard/public/vercel.svg +0 -1
  237. package/apps/dashboard/public/window.svg +0 -1
  238. package/apps/dashboard/scripts/download-node.js +0 -104
  239. package/apps/dashboard/scripts/upload-to-r2.js +0 -89
  240. package/docs/bdd-guidance.md +0 -390
@@ -0,0 +1,556 @@
1
+ //! Skills file synchronization.
2
+ //!
3
+ //! Copies skill definition files from the project's skills directory
4
+ //! into Claude Code's settings directory. Two modes:
5
+ //!
6
+ //! - **Full sync** (`sync_skills`): Used by CLI `jettypod init`. Creates a
7
+ //! timestamped backup of existing skills, copies fresh from source, and
8
+ //! restores the backup on failure.
9
+ //!
10
+ //! - **Optimized sync** (`sync_skills_optimized`): Used by the desktop app on
11
+ //! launch. Skips files where the destination is already newer or same-size,
12
+ //! reducing I/O on repeated launches.
13
+
14
+ use std::fs;
15
+ use std::path::{Path, PathBuf};
16
+ use std::time::SystemTime;
17
+
18
+ use crate::{CoreError, Result};
19
+
20
+ /// Options for skill synchronization.
21
+ pub struct SyncOptions {
22
+ /// Directory containing source skill templates (e.g. `./skills-templates`).
23
+ pub source_dir: PathBuf,
24
+ /// Destination `.claude/skills` directory.
25
+ pub dest_dir: PathBuf,
26
+ }
27
+
28
+ /// Full sync: backup existing skills, copy fresh from source, restore on failure.
29
+ ///
30
+ /// Used by `jettypod init`. This always overwrites destination files.
31
+ pub fn sync_skills(opts: &SyncOptions) -> Result<SyncResult> {
32
+ let source = &opts.source_dir;
33
+ let dest = &opts.dest_dir;
34
+
35
+ // Validate source exists and is non-empty.
36
+ if !source.exists() {
37
+ return Ok(SyncResult {
38
+ files_copied: 0,
39
+ files_skipped: 0,
40
+ backup_path: None,
41
+ restored: false,
42
+ warning: Some("Source skills directory does not exist".into()),
43
+ });
44
+ }
45
+
46
+ let source_entries = read_dir_entries(source)?;
47
+ if source_entries.is_empty() {
48
+ return Ok(SyncResult {
49
+ files_copied: 0,
50
+ files_skipped: 0,
51
+ backup_path: None,
52
+ restored: false,
53
+ warning: Some("Source skills directory is empty".into()),
54
+ });
55
+ }
56
+
57
+ // Create backup if destination already exists.
58
+ let backup_path = if dest.exists() {
59
+ Some(create_backup(dest)?)
60
+ } else {
61
+ None
62
+ };
63
+
64
+ // Attempt the copy.
65
+ match do_full_copy(source, dest) {
66
+ Ok(count) => Ok(SyncResult {
67
+ files_copied: count,
68
+ files_skipped: 0,
69
+ backup_path,
70
+ restored: false,
71
+ warning: None,
72
+ }),
73
+ Err(e) => {
74
+ // Restore backup on failure.
75
+ if let Some(ref bp) = backup_path {
76
+ if bp.exists() {
77
+ // Safety: only delete dest if it contains ".claude".
78
+ let resolved = dest.canonicalize().unwrap_or_else(|_| dest.to_path_buf());
79
+ if resolved.to_string_lossy().contains(".claude") {
80
+ let _ = fs::remove_dir_all(dest);
81
+ }
82
+ let _ = fs::rename(bp, dest);
83
+ return Err(CoreError::Skills(format!(
84
+ "Sync failed, backup restored: {e}"
85
+ )));
86
+ }
87
+ }
88
+ Err(CoreError::Skills(format!("Sync failed: {e}")))
89
+ }
90
+ }
91
+ }
92
+
93
+ /// Optimized sync: skip files where dest is newer or same size.
94
+ ///
95
+ /// Used by the desktop app on every launch. Reduces I/O when skills
96
+ /// haven't changed.
97
+ pub fn sync_skills_optimized(opts: &SyncOptions) -> Result<SyncResult> {
98
+ let source = &opts.source_dir;
99
+ let dest = &opts.dest_dir;
100
+
101
+ if !source.exists() {
102
+ return Ok(SyncResult {
103
+ files_copied: 0,
104
+ files_skipped: 0,
105
+ backup_path: None,
106
+ restored: false,
107
+ warning: Some("Source skills directory does not exist".into()),
108
+ });
109
+ }
110
+
111
+ let source_entries = read_dir_entries(source)?;
112
+ if source_entries.is_empty() {
113
+ return Ok(SyncResult {
114
+ files_copied: 0,
115
+ files_skipped: 0,
116
+ backup_path: None,
117
+ restored: false,
118
+ warning: Some("Source skills directory is empty".into()),
119
+ });
120
+ }
121
+
122
+ fs::create_dir_all(dest).map_err(|e| {
123
+ CoreError::Skills(format!("Failed to create dest dir {}: {e}", dest.display()))
124
+ })?;
125
+
126
+ let (copied, skipped) = copy_dir_optimized(source, dest)?;
127
+
128
+ Ok(SyncResult {
129
+ files_copied: copied,
130
+ files_skipped: skipped,
131
+ backup_path: None,
132
+ restored: false,
133
+ warning: None,
134
+ })
135
+ }
136
+
137
+ /// Result of a sync operation.
138
+ #[derive(Debug)]
139
+ pub struct SyncResult {
140
+ pub files_copied: usize,
141
+ pub files_skipped: usize,
142
+ pub backup_path: Option<PathBuf>,
143
+ pub restored: bool,
144
+ pub warning: Option<String>,
145
+ }
146
+
147
+ /// Resolve the default source skills directory relative to project root.
148
+ pub fn default_source_dir(project_root: &Path) -> PathBuf {
149
+ if let Ok(override_dir) = std::env::var("JETTYPOD_SKILLS_SOURCE_DIR") {
150
+ PathBuf::from(override_dir)
151
+ } else {
152
+ project_root.join("skills-templates")
153
+ }
154
+ }
155
+
156
+ /// Resolve the default destination skills directory relative to project root.
157
+ pub fn default_dest_dir(project_root: &Path) -> PathBuf {
158
+ project_root.join(".claude").join("skills")
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Internal helpers
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /// Read directory entries, returning an empty vec if the dir doesn't exist.
166
+ fn read_dir_entries(dir: &Path) -> Result<Vec<fs::DirEntry>> {
167
+ match fs::read_dir(dir) {
168
+ Ok(rd) => {
169
+ let mut entries = Vec::new();
170
+ for entry in rd {
171
+ entries.push(entry.map_err(|e| {
172
+ CoreError::Skills(format!("Failed to read entry in {}: {e}", dir.display()))
173
+ })?);
174
+ }
175
+ Ok(entries)
176
+ }
177
+ Err(e) => Err(CoreError::Skills(format!(
178
+ "Failed to read directory {}: {e}",
179
+ dir.display()
180
+ ))),
181
+ }
182
+ }
183
+
184
+ /// Create a timestamped backup of the skills directory.
185
+ ///
186
+ /// Naming: `skills.backup-2026-02-22T15-33-45-123Z` with counter if collision.
187
+ fn create_backup(skills_dir: &Path) -> Result<PathBuf> {
188
+ let parent = skills_dir
189
+ .parent()
190
+ .ok_or_else(|| CoreError::Skills("Skills dir has no parent".into()))?;
191
+
192
+ let now = chrono::Utc::now();
193
+ let ts = now
194
+ .format("%Y-%m-%dT%H-%M-%S-%3fZ")
195
+ .to_string();
196
+ let base_name = format!("skills.backup-{ts}");
197
+
198
+ // Handle timestamp collisions with counter.
199
+ let mut backup_name = base_name.clone();
200
+ let mut counter = 0u32;
201
+ loop {
202
+ let candidate = parent.join(&backup_name);
203
+ if !candidate.exists() {
204
+ break;
205
+ }
206
+ counter += 1;
207
+ backup_name = format!("{base_name}-{counter}");
208
+ }
209
+
210
+ let backup_path = parent.join(&backup_name);
211
+ fs::rename(skills_dir, &backup_path).map_err(|e| {
212
+ CoreError::Skills(format!(
213
+ "Failed to create backup at {}: {e}",
214
+ backup_path.display()
215
+ ))
216
+ })?;
217
+
218
+ Ok(backup_path)
219
+ }
220
+
221
+ /// Recursively copy `src` into `dest`, overwriting all files.
222
+ /// Returns the number of files copied.
223
+ fn do_full_copy(src: &Path, dest: &Path) -> Result<usize> {
224
+ fs::create_dir_all(dest).map_err(|e| {
225
+ CoreError::Skills(format!(
226
+ "Failed to create directory {}: {e}",
227
+ dest.display()
228
+ ))
229
+ })?;
230
+
231
+ let mut count = 0;
232
+ for entry in read_dir_entries(src)? {
233
+ let path = entry.path();
234
+ let name = entry.file_name();
235
+ let target = dest.join(&name);
236
+
237
+ if path.is_dir() {
238
+ count += do_full_copy(&path, &target)?;
239
+ } else {
240
+ fs::copy(&path, &target).map_err(|e| {
241
+ CoreError::Skills(format!(
242
+ "Failed to copy {} -> {}: {e}",
243
+ path.display(),
244
+ target.display()
245
+ ))
246
+ })?;
247
+ count += 1;
248
+ }
249
+ }
250
+
251
+ Ok(count)
252
+ }
253
+
254
+ /// Recursively copy `src` into `dest`, skipping files where dest is newer
255
+ /// or same size. Returns (copied, skipped) counts.
256
+ fn copy_dir_optimized(src: &Path, dest: &Path) -> Result<(usize, usize)> {
257
+ fs::create_dir_all(dest).map_err(|e| {
258
+ CoreError::Skills(format!(
259
+ "Failed to create directory {}: {e}",
260
+ dest.display()
261
+ ))
262
+ })?;
263
+
264
+ let mut copied = 0;
265
+ let mut skipped = 0;
266
+
267
+ for entry in read_dir_entries(src)? {
268
+ let path = entry.path();
269
+ let name = entry.file_name();
270
+ let target = dest.join(&name);
271
+
272
+ if path.is_dir() {
273
+ let (c, s) = copy_dir_optimized(&path, &target)?;
274
+ copied += c;
275
+ skipped += s;
276
+ } else {
277
+ if should_skip_copy(&path, &target) {
278
+ skipped += 1;
279
+ } else {
280
+ fs::copy(&path, &target).map_err(|e| {
281
+ CoreError::Skills(format!(
282
+ "Failed to copy {} -> {}: {e}",
283
+ path.display(),
284
+ target.display()
285
+ ))
286
+ })?;
287
+ copied += 1;
288
+ }
289
+ }
290
+ }
291
+
292
+ Ok((copied, skipped))
293
+ }
294
+
295
+ /// Check if we can skip copying a file (dest is newer/equal mtime AND same size).
296
+ fn should_skip_copy(src: &Path, dest: &Path) -> bool {
297
+ let dest_meta = match fs::metadata(dest) {
298
+ Ok(m) => m,
299
+ Err(_) => return false, // dest doesn't exist, must copy
300
+ };
301
+
302
+ let src_meta = match fs::metadata(src) {
303
+ Ok(m) => m,
304
+ Err(_) => return false, // can't read src, try to copy anyway
305
+ };
306
+
307
+ let src_mtime = src_meta
308
+ .modified()
309
+ .unwrap_or(SystemTime::UNIX_EPOCH);
310
+ let dest_mtime = dest_meta
311
+ .modified()
312
+ .unwrap_or(SystemTime::UNIX_EPOCH);
313
+
314
+ // Skip if dest is newer or equal AND same size.
315
+ dest_mtime >= src_mtime && dest_meta.len() == src_meta.len()
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Tests
320
+ // ---------------------------------------------------------------------------
321
+
322
+ #[cfg(test)]
323
+ mod tests {
324
+ use super::*;
325
+ use std::fs;
326
+
327
+ fn create_test_skills(dir: &Path) {
328
+ // Create a couple of skill directories with files.
329
+ let skill1 = dir.join("request-routing");
330
+ fs::create_dir_all(&skill1).unwrap();
331
+ fs::write(skill1.join("SKILL.md"), "# Request Routing\nRoutes requests.").unwrap();
332
+
333
+ let skill2 = dir.join("speed-mode");
334
+ fs::create_dir_all(&skill2).unwrap();
335
+ fs::write(skill2.join("SKILL.md"), "# Speed Mode\nFast implementation.").unwrap();
336
+ fs::write(skill2.join("test-runner.js"), "module.exports = {};").unwrap();
337
+ }
338
+
339
+ #[test]
340
+ fn test_full_sync_copies_all_files() {
341
+ let tmp = tempfile::tempdir().unwrap();
342
+ let source = tmp.path().join("skills-templates");
343
+ let dest = tmp.path().join(".claude").join("skills");
344
+
345
+ create_test_skills(&source);
346
+
347
+ let result = sync_skills(&SyncOptions {
348
+ source_dir: source,
349
+ dest_dir: dest.clone(),
350
+ })
351
+ .unwrap();
352
+
353
+ assert_eq!(result.files_copied, 3);
354
+ assert_eq!(result.files_skipped, 0);
355
+ assert!(result.backup_path.is_none()); // no pre-existing dest
356
+ assert!(result.warning.is_none());
357
+
358
+ // Verify files exist.
359
+ assert!(dest.join("request-routing/SKILL.md").exists());
360
+ assert!(dest.join("speed-mode/SKILL.md").exists());
361
+ assert!(dest.join("speed-mode/test-runner.js").exists());
362
+ }
363
+
364
+ #[test]
365
+ fn test_full_sync_creates_backup() {
366
+ let tmp = tempfile::tempdir().unwrap();
367
+ let source = tmp.path().join("skills-templates");
368
+ let dest = tmp.path().join(".claude").join("skills");
369
+
370
+ // Create initial skills.
371
+ create_test_skills(&source);
372
+ fs::create_dir_all(&dest).unwrap();
373
+ fs::write(dest.join("old-file.txt"), "old content").unwrap();
374
+
375
+ let result = sync_skills(&SyncOptions {
376
+ source_dir: source,
377
+ dest_dir: dest.clone(),
378
+ })
379
+ .unwrap();
380
+
381
+ assert!(result.backup_path.is_some());
382
+ let backup = result.backup_path.unwrap();
383
+ assert!(backup.exists());
384
+ assert!(backup.join("old-file.txt").exists());
385
+
386
+ // New skills should be in place.
387
+ assert!(dest.join("request-routing/SKILL.md").exists());
388
+ }
389
+
390
+ #[test]
391
+ fn test_full_sync_missing_source() {
392
+ let tmp = tempfile::tempdir().unwrap();
393
+ let source = tmp.path().join("nonexistent");
394
+ let dest = tmp.path().join(".claude").join("skills");
395
+
396
+ let result = sync_skills(&SyncOptions {
397
+ source_dir: source,
398
+ dest_dir: dest,
399
+ })
400
+ .unwrap();
401
+
402
+ assert_eq!(result.files_copied, 0);
403
+ assert!(result.warning.is_some());
404
+ }
405
+
406
+ #[test]
407
+ fn test_full_sync_empty_source() {
408
+ let tmp = tempfile::tempdir().unwrap();
409
+ let source = tmp.path().join("empty-skills");
410
+ let dest = tmp.path().join(".claude").join("skills");
411
+ fs::create_dir_all(&source).unwrap();
412
+
413
+ let result = sync_skills(&SyncOptions {
414
+ source_dir: source,
415
+ dest_dir: dest,
416
+ })
417
+ .unwrap();
418
+
419
+ assert_eq!(result.files_copied, 0);
420
+ assert!(result.warning.is_some());
421
+ }
422
+
423
+ #[test]
424
+ fn test_optimized_sync_skips_unchanged_files() {
425
+ let tmp = tempfile::tempdir().unwrap();
426
+ let source = tmp.path().join("skills-templates");
427
+ let dest = tmp.path().join(".claude").join("skills");
428
+
429
+ create_test_skills(&source);
430
+
431
+ // First sync — copies everything.
432
+ let r1 = sync_skills_optimized(&SyncOptions {
433
+ source_dir: source.clone(),
434
+ dest_dir: dest.clone(),
435
+ })
436
+ .unwrap();
437
+ assert_eq!(r1.files_copied, 3);
438
+ assert_eq!(r1.files_skipped, 0);
439
+
440
+ // Second sync — should skip all (dest has same mtime/size from copy).
441
+ let r2 = sync_skills_optimized(&SyncOptions {
442
+ source_dir: source.clone(),
443
+ dest_dir: dest.clone(),
444
+ })
445
+ .unwrap();
446
+ assert_eq!(r2.files_copied, 0);
447
+ assert_eq!(r2.files_skipped, 3);
448
+ }
449
+
450
+ #[test]
451
+ fn test_optimized_sync_copies_modified_files() {
452
+ let tmp = tempfile::tempdir().unwrap();
453
+ let source = tmp.path().join("skills-templates");
454
+ let dest = tmp.path().join(".claude").join("skills");
455
+
456
+ create_test_skills(&source);
457
+
458
+ // First sync.
459
+ sync_skills_optimized(&SyncOptions {
460
+ source_dir: source.clone(),
461
+ dest_dir: dest.clone(),
462
+ })
463
+ .unwrap();
464
+
465
+ // Modify a source file (different size triggers re-copy).
466
+ std::thread::sleep(std::time::Duration::from_millis(50));
467
+ fs::write(
468
+ source.join("request-routing/SKILL.md"),
469
+ "# Request Routing\nUpdated with new content that is longer.",
470
+ )
471
+ .unwrap();
472
+
473
+ // Second sync — should copy the modified file.
474
+ let r2 = sync_skills_optimized(&SyncOptions {
475
+ source_dir: source,
476
+ dest_dir: dest.clone(),
477
+ })
478
+ .unwrap();
479
+
480
+ assert_eq!(r2.files_copied, 1);
481
+ assert_eq!(r2.files_skipped, 2);
482
+
483
+ // Verify updated content.
484
+ let content = fs::read_to_string(dest.join("request-routing/SKILL.md")).unwrap();
485
+ assert!(content.contains("Updated with new content"));
486
+ }
487
+
488
+ #[test]
489
+ fn test_default_paths() {
490
+ let root = Path::new("/tmp/test-project");
491
+ assert_eq!(
492
+ default_source_dir(root),
493
+ PathBuf::from("/tmp/test-project/skills-templates")
494
+ );
495
+ assert_eq!(
496
+ default_dest_dir(root),
497
+ PathBuf::from("/tmp/test-project/.claude/skills")
498
+ );
499
+ }
500
+
501
+ #[test]
502
+ fn test_backup_naming_collision() {
503
+ let tmp = tempfile::tempdir().unwrap();
504
+ let skills = tmp.path().join(".claude").join("skills");
505
+ let claude_dir = tmp.path().join(".claude");
506
+
507
+ // Create skills dir.
508
+ fs::create_dir_all(&skills).unwrap();
509
+ fs::write(skills.join("test.txt"), "content").unwrap();
510
+
511
+ // First backup — no collision, should succeed.
512
+ let backup1 = create_backup(&skills).unwrap();
513
+ assert!(backup1.exists());
514
+ assert!(backup1.to_string_lossy().contains("skills.backup-"));
515
+
516
+ // Recreate skills dir for second backup.
517
+ fs::create_dir_all(&skills).unwrap();
518
+ fs::write(skills.join("test.txt"), "content").unwrap();
519
+
520
+ // Create a collision with the exact timestamp the second backup would use.
521
+ // Since we can't predict the exact ms, we pre-create the base name
522
+ // that will collide with the second backup attempt.
523
+ let now = chrono::Utc::now();
524
+ let ts = now.format("%Y-%m-%dT%H-%M-%S-%3fZ").to_string();
525
+ let collision = claude_dir.join(format!("skills.backup-{ts}"));
526
+ if !collision.exists() {
527
+ fs::create_dir_all(&collision).unwrap();
528
+ }
529
+
530
+ let backup2 = create_backup(&skills).unwrap();
531
+ assert!(backup2.exists());
532
+ // backup2 should have either a different timestamp or a counter suffix.
533
+ assert!(backup2 != backup1);
534
+ }
535
+
536
+ #[test]
537
+ fn test_should_skip_copy() {
538
+ let tmp = tempfile::tempdir().unwrap();
539
+ let src = tmp.path().join("src.txt");
540
+ let dest = tmp.path().join("dest.txt");
541
+
542
+ fs::write(&src, "hello").unwrap();
543
+
544
+ // Dest doesn't exist — should not skip.
545
+ assert!(!should_skip_copy(&src, &dest));
546
+
547
+ // Copy to dest — same content/size, dest mtime >= src.
548
+ fs::copy(&src, &dest).unwrap();
549
+ assert!(should_skip_copy(&src, &dest));
550
+
551
+ // Modify src to be different size — should not skip.
552
+ std::thread::sleep(std::time::Duration::from_millis(50));
553
+ fs::write(&src, "hello world longer content").unwrap();
554
+ assert!(!should_skip_copy(&src, &dest));
555
+ }
556
+ }