@@ -67,6 +67,13 @@ enum Command {
6767 #[ arg( long) ]
6868 force : bool ,
6969 } ,
70+
71+ /// Archive current SPEC.md and IMPLEMENTATION_PLAN.md, then reset to blank
72+ Archive {
73+ /// Skip confirmation prompt
74+ #[ arg( long) ]
75+ force : bool ,
76+ } ,
7077}
7178
7279#[ tokio:: main]
@@ -93,6 +100,9 @@ async fn main() -> Result<()> {
93100 Command :: Clean { force } => {
94101 clean_cmd ( force) ?;
95102 }
103+ Command :: Archive { force } => {
104+ archive_cmd ( force) ?;
105+ }
96106 }
97107
98108 Ok ( ( ) )
@@ -149,6 +159,105 @@ fn clean_cmd(force: bool) -> Result<()> {
149159 Ok ( ( ) )
150160}
151161
162+ fn archive_cmd ( force : bool ) -> Result < ( ) > {
163+ let cwd = Path :: new ( "." ) ;
164+ let archivable_files = files:: find_archivable_files ( cwd) ;
165+
166+ if archivable_files. is_empty ( ) {
167+ println ! ( "No archivable files found." ) ;
168+ return Ok ( ( ) ) ;
169+ }
170+
171+ let file_count = archivable_files. len ( ) ;
172+
173+ if !force {
174+ eprint ! (
175+ "Archive {} file{}? [y/N] " ,
176+ file_count,
177+ if file_count == 1 { "" } else { "s" }
178+ ) ;
179+ io:: stderr ( ) . flush ( ) ?;
180+
181+ let mut input = String :: new ( ) ;
182+ io:: stdin ( ) . read_line ( & mut input) ?;
183+
184+ let answer = input. trim ( ) . to_lowercase ( ) ;
185+ if answer != "y" && answer != "yes" {
186+ std:: process:: exit ( error:: exit:: ERROR ) ;
187+ }
188+ }
189+
190+ // Ensure .ralphctl is in .gitignore
191+ update_gitignore ( cwd) ?;
192+
193+ // Create timestamped archive directory
194+ let timestamp = generate_timestamp ( ) ;
195+ let archive_dir = files:: archive_base_dir ( cwd) . join ( & timestamp) ;
196+ fs:: create_dir_all ( & archive_dir) ?;
197+
198+ // Copy files to archive
199+ for path in & archivable_files {
200+ let filename = path. file_name ( ) . unwrap ( ) ;
201+ let dest = archive_dir. join ( filename) ;
202+ fs:: copy ( path, dest) ?;
203+ }
204+
205+ // Reset original files to blank templates
206+ for path in & archivable_files {
207+ let blank = generate_blank_content ( path) ;
208+ fs:: write ( path, blank) ?;
209+ }
210+
211+ println ! (
212+ "Archived {} file{} to {}" ,
213+ file_count,
214+ if file_count == 1 { "" } else { "s" } ,
215+ archive_dir. display( )
216+ ) ;
217+
218+ Ok ( ( ) )
219+ }
220+
221+ /// Generate a filesystem-safe timestamp for archive directories.
222+ fn generate_timestamp ( ) -> String {
223+ chrono:: Local :: now ( ) . format ( "%Y-%m-%dT%H-%M-%S" ) . to_string ( )
224+ }
225+
226+ /// Generate blank content for a given file.
227+ fn generate_blank_content ( path : & Path ) -> & ' static str {
228+ let filename = path. file_name ( ) . and_then ( |n| n. to_str ( ) ) . unwrap_or ( "" ) ;
229+ match filename {
230+ files:: SPEC_FILE => "# Specification\n \n " ,
231+ files:: IMPLEMENTATION_PLAN_FILE => "# Implementation Plan\n \n " ,
232+ _ => "" ,
233+ }
234+ }
235+
236+ /// Update .gitignore to include .ralphctl if not already present.
237+ fn update_gitignore ( dir : & Path ) -> Result < ( ) > {
238+ let gitignore_path = dir. join ( ".gitignore" ) ;
239+ let entry = files:: RALPHCTL_DIR ;
240+
241+ if gitignore_path. exists ( ) {
242+ let content = fs:: read_to_string ( & gitignore_path) ?;
243+ // Check if entry already exists (as a complete line)
244+ if content. lines ( ) . any ( |line| line. trim ( ) == entry) {
245+ return Ok ( ( ) ) ;
246+ }
247+ // Append entry with newline handling
248+ let suffix = if content. ends_with ( '\n' ) || content. is_empty ( ) {
249+ format ! ( "{}\n " , entry)
250+ } else {
251+ format ! ( "\n {}\n " , entry)
252+ } ;
253+ fs:: write ( & gitignore_path, content + & suffix) ?;
254+ } else {
255+ fs:: write ( & gitignore_path, format ! ( "{}\n " , entry) ) ?;
256+ }
257+
258+ Ok ( ( ) )
259+ }
260+
152261fn run_cmd ( max_iterations : u32 , pause : bool , model : Option < & str > ) -> Result < ( ) > {
153262 // Step 1: Validate required files exist
154263 run:: validate_required_files ( ) ?;
@@ -351,14 +460,11 @@ NEVER use paths from other context (like ~/.claude/CLAUDE.md). The path above is
351460 cmd. arg ( "--model" ) . arg ( m) ;
352461 }
353462
354- let status = cmd
355- . arg ( INITIAL_PROMPT )
356- . status ( )
357- . inspect_err ( |e| {
358- if e. kind ( ) == std:: io:: ErrorKind :: NotFound {
359- error:: die ( "claude not found in PATH" ) ;
360- }
361- } ) ?;
463+ let status = cmd. arg ( INITIAL_PROMPT ) . status ( ) . inspect_err ( |e| {
464+ if e. kind ( ) == std:: io:: ErrorKind :: NotFound {
465+ error:: die ( "claude not found in PATH" ) ;
466+ }
467+ } ) ?;
362468
363469 if !status. success ( ) {
364470 error:: die ( & format ! (
0 commit comments