From 63467fb435b625f6ffaf7731abe00cb80156d799 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 8 Jan 2026 15:55:58 +0100 Subject: [PATCH 1/6] Initial commit with task details Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/link-foundation/start/issues/70 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e32808d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/link-foundation/start/issues/70 +Your prepared branch: issue-70-210190e0ad55 +Your prepared working directory: /tmp/gh-issue-solver-1767884157078 + +Proceed. From 56c8f8fb933593bb9fbfef862f2abfc03c9a2ddf Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 8 Jan 2026 16:06:30 +0100 Subject: [PATCH 2/6] feat(rust): Rename spine to timeline, add virtual command visualization for Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements issue #70 with the following changes: 1. Renamed "spine" concept to "timeline" throughout the codebase: - SPINE constant renamed to TIMELINE_MARKER (with deprecated alias) - create_spine_line renamed to create_timeline_line (with deprecated alias) - create_empty_spine_line renamed to create_empty_timeline_line (with deprecated alias) - Updated documentation in output_blocks.rs, REQUIREMENTS.md, ARCHITECTURE.md 2. Added virtual command visualization for Docker isolation: - When Docker needs to pull an image, it's shown as a separate virtual command - `$ docker pull ` is displayed before the actual user command - Pull output is streamed with result marker (✓ or ✗) - Virtual commands only appear when the image needs to be pulled 3. New functions for virtual command support: - docker_image_exists() - Check if a Docker image exists locally - docker_pull_image() - Pull image with streaming output - create_virtual_command_block() - Format virtual command line - create_virtual_command_result() - Format result marker for virtual commands - create_timeline_separator() - Separator between virtual and user commands 4. StartBlockOptions now has defer_command field: - When true, command line is omitted from start block - Used for Docker isolation to show virtual commands first 5. Version bumped to 0.20.0 Example output when pulling an image: ``` │ session ... │ start ... │ │ isolation docker │ $ docker pull alpine:latest ... pull output ... ✓ │ $ echo hi hi ✓ ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- ARCHITECTURE.md | 2 +- REQUIREMENTS.md | 27 ++++++- js/package.json | 2 +- rust/Cargo.toml | 2 +- rust/src/bin/main.rs | 4 ++ rust/src/lib/isolation.rs | 94 ++++++++++++++++++++++++ rust/src/lib/mod.rs | 20 ++++-- rust/src/lib/output_blocks.rs | 119 +++++++++++++++++++++---------- rust/tests/output_blocks_test.rs | 39 ++++++++-- 9 files changed, 256 insertions(+), 53 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ba5d2b7..6ef6d6f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -205,7 +205,7 @@ By default, all isolation environments automatically exit after command completi │ │ Execution │ │ stdout/stderr│ │ Temp Log File │ │ │ └─────────────┘ └──────────────┘ └───────────────────┘ │ │ │ -│ Console Output Format (Status Spine): │ +│ Console Output Format (Timeline): │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ │ session abc-123-def-456 │ │ │ │ │ start 2024-01-15 10:30:45 │ │ diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 13b88e2..445951a 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -242,14 +242,35 @@ Note: `--auto-remove-docker-container` is only valid with `--isolated docker` an ## Output Format -The output uses a "status spine" format that is width-independent, lossless, and works uniformly in TTY, tmux, SSH, CI, and log files. +The output uses a "timeline" format that is width-independent, lossless, and works uniformly in TTY, tmux, SSH, CI, and log files. Format conventions: -- `│` prefix → tool metadata -- `$` prefix → executed command +- `│` prefix → tool metadata (timeline marker) +- `$` prefix → executed command (can be virtual command for setup steps or user command) - No prefix → program output (stdout/stderr) - `✓` / `✗` → result marker (success/failure) +### Virtual Commands + +When running commands in isolation environments that require setup steps (like Docker pulling an image), the timeline shows these as separate virtual commands. This makes the execution flow transparent: + +``` +$ docker pull alpine:latest ← virtual command (setup step) + +Unable to find image 'alpine:latest' locally +... pull output ... + +✓ +│ +$ echo hi ← user command + +hi + +✓ +``` + +Virtual commands are only displayed when the setup step actually occurs (e.g., `docker pull` is shown only if the image needs to be downloaded). + ### Success Case ``` diff --git a/js/package.json b/js/package.json index e97f791..88147ba 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "start-command", - "version": "0.19.1", + "version": "0.20.0", "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub", "main": "src/bin/cli.js", "exports": { diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 7c4034c..33b8253 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "start-command" -version = "0.14.0" +version = "0.20.0" edition = "2021" authors = ["Link Foundation"] description = "Gamification of coding, execute any command with ability to auto-report issues on GitHub" diff --git a/rust/src/bin/main.rs b/rust/src/bin/main.rs index 561c836..2acba54 100644 --- a/rust/src/bin/main.rs +++ b/rust/src/bin/main.rs @@ -523,6 +523,8 @@ fn run_with_isolation( } // Print start block with session ID and isolation info + // For Docker isolation, defer the command display since virtual commands may be shown first + let is_docker_isolation = environment == Some("docker"); let extra_lines_refs: Vec<&str> = extra_lines.iter().map(|s| s.as_str()).collect(); println!( "{}", @@ -537,6 +539,7 @@ fn run_with_isolation( }, style: None, width: None, + defer_command: is_docker_isolation, }) ); println!(); @@ -667,6 +670,7 @@ fn run_direct( extra_lines: None, style: None, width: None, + defer_command: false, }) ); println!(); diff --git a/rust/src/lib/isolation.rs b/rust/src/lib/isolation.rs index 05c9af4..8495c4f 100644 --- a/rust/src/lib/isolation.rs +++ b/rust/src/lib/isolation.rs @@ -467,6 +467,83 @@ pub fn run_in_ssh(command: &str, options: &IsolationOptions) -> IsolationResult } } +/// Check if a Docker image exists locally +pub fn docker_image_exists(image: &str) -> bool { + Command::new("docker") + .args(["image", "inspect", image]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Pull a Docker image with output streaming +/// Returns (success, output) tuple +pub fn docker_pull_image(image: &str) -> (bool, String) { + use std::io::{BufRead, BufReader}; + + // Print the virtual command line + println!( + "{}", + crate::output_blocks::create_virtual_command_block(&format!("docker pull {}", image)) + ); + println!(); + + let mut child = match Command::new("docker") + .args(["pull", image]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + let error_msg = format!("Failed to run docker pull: {}", e); + eprintln!("{}", error_msg); + println!(); + println!( + "{}", + crate::output_blocks::create_virtual_command_result(false) + ); + return (false, error_msg); + } + }; + + let mut output = String::new(); + + // Read and display stdout + if let Some(stdout) = child.stdout.take() { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + println!("{}", line); + output.push_str(&line); + output.push('\n'); + } + } + + // Read and display stderr + if let Some(stderr) = child.stderr.take() { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + eprintln!("{}", line); + output.push_str(&line); + output.push('\n'); + } + } + + let success = child.wait().map(|s| s.success()).unwrap_or(false); + + // Print result marker and separator + println!(); + println!( + "{}", + crate::output_blocks::create_virtual_command_result(success) + ); + println!("{}", crate::output_blocks::create_timeline_separator()); + + (success, output) +} + /// Run command in Docker container pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResult { if !is_command_available("docker") { @@ -490,6 +567,19 @@ pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResu } }; + // Check if image exists locally; if not, pull it as a virtual command + if !docker_image_exists(&image) { + let (pull_success, _pull_output) = docker_pull_image(&image); + if !pull_success { + return IsolationResult { + success: false, + message: format!("Failed to pull Docker image: {}", image), + exit_code: Some(1), + ..Default::default() + }; + } + } + let container_name = options .session .clone() @@ -497,6 +587,10 @@ pub fn run_in_docker(command: &str, options: &IsolationOptions) -> IsolationResu let (_, _) = get_shell(); + // Print the user command (this appears after any virtual commands like docker pull) + println!("{}", crate::output_blocks::create_command_line(command)); + println!(); + if options.detached { let effective_command = if options.keep_alive { format!("{}; exec /bin/sh", command) diff --git a/rust/src/lib/mod.rs b/rust/src/lib/mod.rs index 404e651..3df9b28 100644 --- a/rust/src/lib/mod.rs +++ b/rust/src/lib/mod.rs @@ -23,17 +23,22 @@ pub use execution_store::{ }; pub use failure_handler::{handle_failure, Config as FailureConfig}; pub use isolation::{ - create_log_footer, create_log_header, create_log_path, get_default_docker_image, get_timestamp, - is_command_available, run_as_isolated_user, run_isolated, write_log_file, IsolationOptions, - IsolationResult, LogHeaderParams, + create_log_footer, create_log_header, create_log_path, docker_image_exists, docker_pull_image, + get_default_docker_image, get_timestamp, is_command_available, run_as_isolated_user, + run_isolated, write_log_file, IsolationOptions, IsolationResult, LogHeaderParams, }; pub use output_blocks::{ - // Status spine format API + // Timeline format API (formerly "status spine") create_command_line, - create_empty_spine_line, + create_empty_spine_line, // deprecated, use create_empty_timeline_line + create_empty_timeline_line, create_finish_block, - create_spine_line, + create_spine_line, // deprecated, use create_timeline_line create_start_block, + create_timeline_line, + create_timeline_separator, + create_virtual_command_block, + create_virtual_command_result, escape_for_links_notation, format_duration, format_value_for_links_notation, @@ -44,8 +49,9 @@ pub use output_blocks::{ IsolationMetadata, StartBlockOptions, FAILURE_MARKER, - SPINE, + SPINE, // deprecated, use TIMELINE_MARKER SUCCESS_MARKER, + TIMELINE_MARKER, }; pub use signal_handler::{clear_current_execution, set_current_execution, setup_signal_handlers}; pub use status_formatter::{ diff --git a/rust/src/lib/output_blocks.rs b/rust/src/lib/output_blocks.rs index dae5f0f..7669dd6 100644 --- a/rust/src/lib/output_blocks.rs +++ b/rust/src/lib/output_blocks.rs @@ -1,17 +1,22 @@ //! Output formatting utilities for nicely rendered command blocks //! -//! Provides "status spine" format: a width-independent, lossless output format +//! Provides "timeline" format: a width-independent, lossless output format //! that works in TTY, tmux, SSH, CI, and logs. //! //! Core concepts: -//! - `│` prefix → tool metadata -//! - `$` → executed command +//! - `│` prefix → tool metadata (timeline marker) +//! - `$` → executed command (virtual or user command) //! - No prefix → program output (stdout/stderr) //! - Result marker (`✓` / `✗`) appears after output use regex::Regex; -/// Metadata spine character +/// Timeline marker character (formerly called "spine") +/// Used to prefix metadata lines in the timeline format +pub const TIMELINE_MARKER: &str = "│"; + +/// Alias for backward compatibility +#[deprecated(since = "0.20.0", note = "Use TIMELINE_MARKER instead")] pub const SPINE: &str = "│"; /// Success result marker @@ -20,15 +25,27 @@ pub const SUCCESS_MARKER: &str = "✓"; /// Failure result marker pub const FAILURE_MARKER: &str = "✗"; -/// Create a metadata line with spine prefix -pub fn create_spine_line(label: &str, value: &str) -> String { +/// Create a metadata line with timeline marker prefix +pub fn create_timeline_line(label: &str, value: &str) -> String { // Pad label to 10 characters for alignment - format!("{} {:10}{}", SPINE, label, value) + format!("{} {:10}{}", TIMELINE_MARKER, label, value) +} + +/// Alias for backward compatibility +#[deprecated(since = "0.20.0", note = "Use create_timeline_line instead")] +pub fn create_spine_line(label: &str, value: &str) -> String { + create_timeline_line(label, value) +} + +/// Create an empty timeline line (just the timeline marker character) +pub fn create_empty_timeline_line() -> String { + TIMELINE_MARKER.to_string() } -/// Create an empty spine line (just the spine character) +/// Alias for backward compatibility +#[deprecated(since = "0.20.0", note = "Use create_empty_timeline_line instead")] pub fn create_empty_spine_line() -> String { - SPINE.to_string() + create_empty_timeline_line() } /// Create a command line with $ prefix @@ -36,6 +53,27 @@ pub fn create_command_line(command: &str) -> String { format!("$ {}", command) } +/// Create a virtual command block for setup steps (like docker pull) +/// Virtual commands are displayed separately in the timeline to show +/// intermediate steps that the tool performs automatically +pub fn create_virtual_command_block(command: &str) -> String { + create_command_line(command) +} + +/// Create a result marker line for a virtual command +pub fn create_virtual_command_result(success: bool) -> String { + if success { + SUCCESS_MARKER.to_string() + } else { + FAILURE_MARKER.to_string() + } +} + +/// Create a separator line between virtual commands and user commands +pub fn create_timeline_separator() -> String { + create_empty_timeline_line() +} + /// Get the result marker based on exit code pub fn get_result_marker(exit_code: i32) -> &'static str { if exit_code == 0 { @@ -96,7 +134,7 @@ pub fn parse_isolation_metadata(extra_lines: &[&str]) -> IsolationMetadata { metadata } -/// Generate isolation metadata lines for spine format +/// Generate isolation metadata lines for timeline format pub fn generate_isolation_lines( metadata: &IsolationMetadata, container_or_screen_name: Option<&str>, @@ -104,15 +142,15 @@ pub fn generate_isolation_lines( let mut lines = Vec::new(); if let Some(ref isolation) = metadata.isolation { - lines.push(create_spine_line("isolation", isolation)); + lines.push(create_timeline_line("isolation", isolation)); } if let Some(ref mode) = metadata.mode { - lines.push(create_spine_line("mode", mode)); + lines.push(create_timeline_line("mode", mode)); } if let Some(ref image) = metadata.image { - lines.push(create_spine_line("image", image)); + lines.push(create_timeline_line("image", image)); } // Use provided container/screen name or fall back to metadata.session @@ -123,12 +161,12 @@ pub fn generate_isolation_lines( if let Some(name) = name { match isolation.as_str() { - "docker" => lines.push(create_spine_line("container", &name)), - "screen" => lines.push(create_spine_line("screen", &name)), - "tmux" => lines.push(create_spine_line("tmux", &name)), + "docker" => lines.push(create_timeline_line("container", &name)), + "screen" => lines.push(create_timeline_line("screen", &name)), + "tmux" => lines.push(create_timeline_line("tmux", &name)), "ssh" => { if let Some(ref endpoint) = metadata.endpoint { - lines.push(create_spine_line("endpoint", endpoint)); + lines.push(create_timeline_line("endpoint", endpoint)); } } _ => {} @@ -137,7 +175,7 @@ pub fn generate_isolation_lines( } if let Some(ref user) = metadata.user { - lines.push(create_spine_line("user", user)); + lines.push(create_timeline_line("user", user)); } lines @@ -151,31 +189,36 @@ pub struct StartBlockOptions<'a> { pub extra_lines: Option>, pub style: Option<&'a str>, pub width: Option, + /// If true, the command line is omitted from the start block + /// (useful when virtual commands will be shown before the actual command) + pub defer_command: bool, } -/// Create a start block for command execution using status spine format +/// Create a start block for command execution using timeline format pub fn create_start_block(options: &StartBlockOptions) -> String { let mut lines = Vec::new(); // Header: session and start time - lines.push(create_spine_line("session", options.session_id)); - lines.push(create_spine_line("start", options.timestamp)); + lines.push(create_timeline_line("session", options.session_id)); + lines.push(create_timeline_line("start", options.timestamp)); // Parse and add isolation metadata if present if let Some(ref extra) = options.extra_lines { let metadata = parse_isolation_metadata(extra); if metadata.isolation.is_some() { - lines.push(create_empty_spine_line()); + lines.push(create_empty_timeline_line()); lines.extend(generate_isolation_lines(&metadata, None)); } } - // Empty spine line before command - lines.push(create_empty_spine_line()); + // Empty timeline line before command (always needed for separation) + lines.push(create_empty_timeline_line()); - // Command line - lines.push(create_command_line(options.command)); + // Command line (unless deferred for virtual command handling) + if !options.defer_command { + lines.push(create_command_line(options.command)); + } lines.join("\n") } @@ -208,7 +251,7 @@ pub struct FinishBlockOptions<'a> { pub width: Option, } -/// Create a finish block for command execution using status spine format +/// Create a finish block for command execution using timeline format /// /// Bottom block ordering rules: /// 1. Result marker (✓ or ✗) @@ -216,7 +259,7 @@ pub struct FinishBlockOptions<'a> { /// 3. duration /// 4. exit code /// 5. (repeated isolation metadata, if any) -/// 6. empty spine line +/// 6. empty timeline line /// 7. log path (always second-to-last) /// 8. session ID (always last) pub fn create_finish_block(options: &FinishBlockOptions) -> String { @@ -226,29 +269,32 @@ pub fn create_finish_block(options: &FinishBlockOptions) -> String { lines.push(get_result_marker(options.exit_code).to_string()); // Finish metadata - lines.push(create_spine_line("finish", options.timestamp)); + lines.push(create_timeline_line("finish", options.timestamp)); if let Some(duration_ms) = options.duration_ms { - lines.push(create_spine_line("duration", &format_duration(duration_ms))); + lines.push(create_timeline_line( + "duration", + &format_duration(duration_ms), + )); } - lines.push(create_spine_line("exit", &options.exit_code.to_string())); + lines.push(create_timeline_line("exit", &options.exit_code.to_string())); // Repeat isolation metadata if present if let Some(ref extra) = options.extra_lines { let metadata = parse_isolation_metadata(extra); if metadata.isolation.is_some() { - lines.push(create_empty_spine_line()); + lines.push(create_empty_timeline_line()); lines.extend(generate_isolation_lines(&metadata, None)); } } - // Empty spine line before final two entries - lines.push(create_empty_spine_line()); + // Empty timeline line before final two entries + lines.push(create_empty_timeline_line()); // Log and session are ALWAYS last (in that order) - lines.push(create_spine_line("log", options.log_path)); - lines.push(create_spine_line("session", options.session_id)); + lines.push(create_timeline_line("log", options.log_path)); + lines.push(create_timeline_line("session", options.session_id)); lines.join("\n") } @@ -466,6 +512,7 @@ mod tests { extra_lines: Some(extra_lines), style: None, width: None, + defer_command: false, }); // Issue #67: The start block should include the session name for reconnection assert!(block.contains("│ session uuid-123")); diff --git a/rust/tests/output_blocks_test.rs b/rust/tests/output_blocks_test.rs index 28bb9de..86b75f5 100644 --- a/rust/tests/output_blocks_test.rs +++ b/rust/tests/output_blocks_test.rs @@ -1,16 +1,16 @@ //! Tests for output_blocks module //! -//! Tests for the "status spine" format: width-independent, lossless output. +//! Tests for the "timeline" format: width-independent, lossless output. use start_command::{ create_finish_block, create_start_block, escape_for_links_notation, format_duration, get_result_marker, parse_isolation_metadata, FinishBlockOptions, StartBlockOptions, - FAILURE_MARKER, SPINE, SUCCESS_MARKER, + FAILURE_MARKER, SUCCESS_MARKER, TIMELINE_MARKER, }; #[test] -fn test_spine_constants() { - assert_eq!(SPINE, "│"); +fn test_timeline_constants() { + assert_eq!(TIMELINE_MARKER, "│"); assert_eq!(SUCCESS_MARKER, "✓"); assert_eq!(FAILURE_MARKER, "✗"); } @@ -46,6 +46,7 @@ fn test_create_start_block() { extra_lines: None, style: Some("rounded"), width: Some(50), + defer_command: false, }); assert!(block.contains("│ session test-uuid")); @@ -66,6 +67,7 @@ fn test_create_start_block_with_isolation() { extra_lines: Some(extra), style: Some("rounded"), width: Some(60), + defer_command: false, }); assert!(block.contains("│ session test-uuid")); @@ -90,6 +92,7 @@ fn test_create_start_block_with_docker_isolation() { extra_lines: Some(extra), style: None, width: None, + defer_command: false, }); assert!(block.contains("│ isolation docker")); @@ -97,6 +100,34 @@ fn test_create_start_block_with_docker_isolation() { assert!(block.contains("│ container docker-container-123")); } +#[test] +fn test_create_start_block_with_defer_command() { + // When defer_command is true, the command line should be omitted + // (used when virtual commands will be shown separately) + let extra = vec![ + "[Isolation] Environment: docker, Mode: attached", + "[Isolation] Image: alpine:latest", + "[Isolation] Session: docker-container-456", + ]; + let block = create_start_block(&StartBlockOptions { + session_id: "test-uuid", + timestamp: "2025-01-01 00:00:00", + command: "echo hello", + extra_lines: Some(extra), + style: None, + width: None, + defer_command: true, + }); + + assert!(block.contains("│ session test-uuid")); + assert!(block.contains("│ isolation docker")); + assert!(block.contains("│ image alpine:latest")); + assert!( + !block.contains("$ echo hello"), + "Command should not appear when defer_command is true" + ); +} + #[test] fn test_create_finish_block() { let block = create_finish_block(&FinishBlockOptions { From 9ec7aa76276aacdfff2b8eee896a46f4c6a6c049 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 8 Jan 2026 16:07:26 +0100 Subject: [PATCH 3/6] Revert "Initial commit with task details" This reverts commit 63467fb435b625f6ffaf7731abe00cb80156d799. --- CLAUDE.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index e32808d..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -Issue to solve: https://github.com/link-foundation/start/issues/70 -Your prepared branch: issue-70-210190e0ad55 -Your prepared working directory: /tmp/gh-issue-solver-1767884157078 - -Proceed. From ad9e7965433d82c25aea5dcb5724cb7b8da97d5e Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 8 Jan 2026 16:18:29 +0100 Subject: [PATCH 4/6] fix: Add changelog fragments and resolve deprecation warnings in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added Rust changelog fragment (rust/changelog.d/70.md) documenting the spine → timeline rename and virtual command features - Added JavaScript changeset (js/.changeset/issue-70-timeline-virtual-commands.md) for version management - Fixed Clippy deprecation warnings by adding #[allow(deprecated)] to re-exports of deprecated items - Renamed test function test_create_spine_line → test_create_timeline_line to use new API All changes ensure CI checks pass while maintaining backward compatibility with deprecated API names. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../issue-70-timeline-virtual-commands.md | 22 +++++++++++++++++++ rust/changelog.d/70.md | 19 ++++++++++++++++ rust/src/lib/mod.rs | 1 + rust/src/lib/output_blocks.rs | 4 ++-- 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 js/.changeset/issue-70-timeline-virtual-commands.md create mode 100644 rust/changelog.d/70.md diff --git a/js/.changeset/issue-70-timeline-virtual-commands.md b/js/.changeset/issue-70-timeline-virtual-commands.md new file mode 100644 index 0000000..4f198c7 --- /dev/null +++ b/js/.changeset/issue-70-timeline-virtual-commands.md @@ -0,0 +1,22 @@ +--- +'start-command': minor +--- + +feat: Rename spine to timeline, add virtual command visualization for Docker + +This release updates the internal terminology from "spine" to "timeline" for the status output format, and adds automatic visualization of Docker image pull operations as virtual commands. + +**Timeline terminology:** +- The `│` prefixed output format is now consistently referred to as "timeline" format throughout the codebase +- All deprecated "spine" names remain available for backwards compatibility +- API changes are reflected in the Rust library (no breaking changes in JS) + +**Virtual command visualization:** +- When Docker isolation requires pulling an image, it now appears as a separate `$ docker pull ` command +- Pull output is streamed in real-time with success (✓) or failure (✗) markers +- Only shown when the image actually needs to be pulled (not when using cached images) +- Provides better visibility into what's happening during Docker-based command execution + +Version bumped to 0.20.0 to match the Rust library version. + +Fixes #70 diff --git a/rust/changelog.d/70.md b/rust/changelog.d/70.md new file mode 100644 index 0000000..1f8dfc9 --- /dev/null +++ b/rust/changelog.d/70.md @@ -0,0 +1,19 @@ +feat: Rename spine to timeline, add virtual command visualization for Docker + +- Renamed "spine" terminology to "timeline" throughout the codebase + - `SPINE` constant → `TIMELINE_MARKER` (old name deprecated) + - `create_spine_line()` → `create_timeline_line()` (old name deprecated) + - `create_empty_spine_line()` → `create_empty_timeline_line()` (old name deprecated) +- Added virtual command visualization for Docker image pulls + - When Docker isolation requires pulling an image, it's shown as `$ docker pull ` + - Pull output is streamed in real-time with result markers (✓/✗) + - Only displayed when image actually needs to be pulled (conditional display) +- New API additions: + - `create_virtual_command_block()` - for formatting virtual commands + - `create_virtual_command_result()` - for result markers + - `docker_image_exists()` - check if image is available locally + - `docker_pull_image()` - pull with streaming output + - `StartBlockOptions.defer_command` - defer command display for multi-step execution +- All deprecated items have backward-compatible aliases for smooth migration + +Fixes #70 diff --git a/rust/src/lib/mod.rs b/rust/src/lib/mod.rs index 3df9b28..c2be7e5 100644 --- a/rust/src/lib/mod.rs +++ b/rust/src/lib/mod.rs @@ -27,6 +27,7 @@ pub use isolation::{ get_default_docker_image, get_timestamp, is_command_available, run_as_isolated_user, run_isolated, write_log_file, IsolationOptions, IsolationResult, LogHeaderParams, }; +#[allow(deprecated)] pub use output_blocks::{ // Timeline format API (formerly "status spine") create_command_line, diff --git a/rust/src/lib/output_blocks.rs b/rust/src/lib/output_blocks.rs index 7669dd6..f6f0f9b 100644 --- a/rust/src/lib/output_blocks.rs +++ b/rust/src/lib/output_blocks.rs @@ -366,8 +366,8 @@ mod tests { use super::*; #[test] - fn test_create_spine_line() { - let line = create_spine_line("session", "abc123"); + fn test_create_timeline_line() { + let line = create_timeline_line("session", "abc123"); assert!(line.starts_with("│")); assert!(line.contains("session")); assert!(line.contains("abc123")); From 010c07b64066834e38036910a3963d291b86b2ae Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 8 Jan 2026 16:22:41 +0100 Subject: [PATCH 5/6] fix: Reformat changeset to pass Prettier formatting check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed bold markdown syntax and adjusted formatting to comply with Prettier's style requirements. This should resolve the JavaScript CI/CD formatting check failure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- js/.changeset/issue-70-timeline-virtual-commands.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/js/.changeset/issue-70-timeline-virtual-commands.md b/js/.changeset/issue-70-timeline-virtual-commands.md index 4f198c7..13e0225 100644 --- a/js/.changeset/issue-70-timeline-virtual-commands.md +++ b/js/.changeset/issue-70-timeline-virtual-commands.md @@ -6,12 +6,14 @@ feat: Rename spine to timeline, add virtual command visualization for Docker This release updates the internal terminology from "spine" to "timeline" for the status output format, and adds automatic visualization of Docker image pull operations as virtual commands. -**Timeline terminology:** +Timeline terminology changes: + - The `│` prefixed output format is now consistently referred to as "timeline" format throughout the codebase - All deprecated "spine" names remain available for backwards compatibility - API changes are reflected in the Rust library (no breaking changes in JS) -**Virtual command visualization:** +Virtual command visualization for Docker: + - When Docker isolation requires pulling an image, it now appears as a separate `$ docker pull ` command - Pull output is streamed in real-time with success (✓) or failure (✗) markers - Only shown when the image actually needs to be pulled (not when using cached images) From bd7dff7ad0ae3d0db604ccdb804895de4bb9b912 Mon Sep 17 00:00:00 2001 From: konard Date: Thu, 8 Jan 2026 16:35:50 +0100 Subject: [PATCH 6/6] ci: Add version change detection to prevent manual version bumps in PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "Check for Manual Version Changes" job to js.yml that fails if package.json version is modified (skips automated release PRs) - Add "Check for Manual Version Changes" job to rust.yml that fails if Cargo.toml version is modified - Revert manual version changes that were introduced in previous commits - js/package.json: 0.20.0 → 0.19.1 - rust/Cargo.toml: 0.20.0 → 0.14.0 This ensures versions are only changed by the CI/CD pipeline through changesets (JS) or changelog fragments (Rust), preventing merge conflicts and maintaining consistent release workflows. Addresses feedback from PR #71 comments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/js.yml | 39 ++++++++++++++++++++++++++++++++++++++ .github/workflows/rust.yml | 33 ++++++++++++++++++++++++++++++++ js/package.json | 2 +- rust/Cargo.toml | 2 +- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 9a59d76..990f296 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -72,6 +72,45 @@ jobs: GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: node scripts/detect-code-changes.mjs + # === VERSION CHANGE CHECK === + # Prohibit manual version changes in package.json - versions should only be changed by CI/CD + version-check: + name: Check for Manual Version Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for version changes in package.json + run: | + # Skip check for automated release PRs + if [[ "${{ github.head_ref }}" == "changeset-release/"* ]] || [[ "${{ github.head_ref }}" == "changeset-js-"* ]]; then + echo "Skipping version check for automated release PR" + exit 0 + fi + + # Get the diff for package.json + VERSION_DIFF=$(git diff origin/${{ github.base_ref }}...HEAD -- js/package.json | grep -E '^\+.*"version"' || true) + + if [ -n "$VERSION_DIFF" ]; then + echo "::error::Manual version change detected in js/package.json" + echo "" + echo "Version changes in package.json are prohibited in pull requests." + echo "Versions are managed automatically by the CI/CD pipeline using changesets." + echo "" + echo "To request a release:" + echo " 1. Add a changeset file describing your changes" + echo " 2. The release workflow will automatically bump the version when merged" + echo "" + echo "Detected change:" + echo "$VERSION_DIFF" + exit 1 + fi + + echo "No manual version changes detected - check passed" + # === CHANGESET CHECK === changeset-check: name: Check for Changesets diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9beba73..75f6850 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -70,6 +70,39 @@ jobs: GITHUB_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: node scripts/detect-code-changes.mjs + # === VERSION CHANGE CHECK === + # Prohibit manual version changes in Cargo.toml - versions should only be changed by CI/CD + version-check: + name: Check for Manual Version Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for version changes in Cargo.toml + run: | + # Get the diff for Cargo.toml + VERSION_DIFF=$(git diff origin/${{ github.base_ref }}...HEAD -- rust/Cargo.toml | grep -E '^\+version\s*=' || true) + + if [ -n "$VERSION_DIFF" ]; then + echo "::error::Manual version change detected in rust/Cargo.toml" + echo "" + echo "Version changes in Cargo.toml are prohibited in pull requests." + echo "Versions are managed automatically by the CI/CD pipeline using changelog fragments." + echo "" + echo "To request a release:" + echo " 1. Add a changelog fragment in rust/changelog.d/ describing your changes" + echo " 2. The release workflow will automatically bump the version when merged" + echo "" + echo "Detected change:" + echo "$VERSION_DIFF" + exit 1 + fi + + echo "No manual version changes detected - check passed" + # === CHANGELOG CHECK === changelog: name: Changelog Fragment Check diff --git a/js/package.json b/js/package.json index 88147ba..e97f791 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "start-command", - "version": "0.20.0", + "version": "0.19.1", "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub", "main": "src/bin/cli.js", "exports": { diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 33b8253..7c4034c 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "start-command" -version = "0.20.0" +version = "0.14.0" edition = "2021" authors = ["Link Foundation"] description = "Gamification of coding, execute any command with ability to auto-report issues on GitHub"