Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Changelog

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- `codea screen-size [preset]` reads or sets the screen size of the running
project's viewer, so a project can be checked at another aspect ratio or
orientation. Presets are `match-display`, `iphone-portrait`,
`iphone-landscape`, `tv` and `square`. Reading it also reports the viewer's
current pixel size, taken from its laid-out bounds rather than the preset's
nominal size. Needs a Codea build that
provides the `setScreenSize` and `getScreenSize` Air Code tools.

## [0.1.6] - 2026-04-02

### Added

- MCP usage examples in the README.

### Fixed

- `codea status` reports the paused and idle timer states correctly.

### Changed

- Reworked the release mechanism.

## [0.1.5] - 2026-03-30

### Added

- WiX template for the Windows MSI installer.

## [0.1.4] - 2026-03-29

### Added

- Windows installer packaging.

## [0.1.3] - 2026-03-29

### Added

- Update notifications, cached between runs.

## [0.1.2] - 2026-03-29

### Added

- A progress spinner while discovering devices.

## [0.1.1] - 2026-03-29

### Changed

- More reliable fallback when creating a local project.

## [0.1.0] - 2026-03-29

First release. Connects to a Codea or Carbide runtime over Air Code to discover
hosts, save connection profiles, manage projects, collections, templates and
dependencies, run and stop projects, execute Lua, inspect and change a project's
runtime type, query the API docs and autocomplete data, capture screenshots,
stream logs, push and pull project files, and create local projects. Ships
Homebrew, PowerShell and MSI installers.

[Unreleased]: https://github.com/twolivesleft/codea-cli/compare/v0.1.6...HEAD
[0.1.6]: https://github.com/twolivesleft/codea-cli/compare/v0.1.5...v0.1.6
[0.1.5]: https://github.com/twolivesleft/codea-cli/compare/v0.1.4...v0.1.5
[0.1.4]: https://github.com/twolivesleft/codea-cli/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/twolivesleft/codea-cli/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/twolivesleft/codea-cli/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/twolivesleft/codea-cli/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/twolivesleft/codea-cli/releases/tag/v0.1.0
5 changes: 0 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,6 @@ serde_json = "1"
inherits = "release"
lto = "thin"

[package.metadata.release]
publish = false
allow-branch = ["main"]
pre-release-commit-message = "chore: release {{version}}"

[package.metadata.wix]
upgrade-guid = "088E8154-4DE5-4E0E-A1C0-5CC9EBC0027E"
path-guid = "12910971-CFB5-405D-BCE1-383C7724B3D9"
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ codea logs --follow
- `ls`, `new`, `rename`, `move`, `delete`
- `pull`, `push`
- `run`, `stop`, `restart`, `pause`, `resume`, `paused`, `exec`
- `screenshot`, `idle-timer`, `logs`, `clear-logs`
- `screenshot`, `screen-size`, `idle-timer`, `logs`, `clear-logs`
- `collections ls|new|delete`
- `templates ls|add|remove`
- `deps ls|available|add|remove`
Expand Down
4 changes: 4 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ Always prefer `--wait` over asking the user to manually switch to Codea first.
| `codea paused [on\|off]` | Get or set paused state |
| `codea screenshot [--output <file>]` | Capture a screenshot |
| `codea idle-timer <on\|off>` | Get or set idle timer |
| `codea screen-size` | Show the viewer's screen size preset, and its current pixel size once the viewer has laid out |
| `codea screen-size <preset>` | Set the viewer's screen size: `match-display`, `iphone-portrait`, `iphone-landscape`, `tv`, `square` |
| `codea logs` | Get log output |
| `codea logs --head N` | Get first N lines |
| `codea logs --tail N` | Get last N lines |
Expand Down Expand Up @@ -459,6 +461,8 @@ light.pop()
- Always `pull` before editing to get the latest files from device
- Use `sleep 2` or similar between `run` and `screenshot` to let the project render a frame
- `exec` requires a project to already be running
- `screen-size` resizes the running viewer, so use it to screenshot a project at several sizes or orientations without touching the project's code. `WIDTH`/`HEIGHT` inside Lua change with the preset, so this is a real resize, not a crop. It needs a project running and only the app's viewer supports it, so it reports an error rather than failing silently when no project is running or the target is a standalone Runner
- Reading `screen-size` is a live query, not a stored setting: the size it reports is the viewer's laid-out bounds rather than the preset's nominal size. Under `match-display` it is expected to follow the window, though that has not been observed — resizing the window on an iPad needs Stage Manager or split view. It can in principle report a preset with no size before the viewer lays out, but that has never been seen in practice, so don't build a workflow around it
- Screenshot returns a PNG — save it and use vision to inspect results; do not open it in an external app unless the user explicitly asks
- `codea logs` accumulates all output since last `clear-logs`; use `--head 20` when Codea is spamming a repeated error to find the original cause
- File paths on device use `codea://` URIs internally; you don't need to deal with these directly
14 changes: 14 additions & 0 deletions release.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# The only home for cargo-release config: keys here are overridden by
# [package.metadata.release] in Cargo.toml, so that section must stay absent.
# Distribution is handled by cargo-dist from the pushed tag, not by crates.io.
publish = false

allow-branch = ["main"]
tag-name = "v{{version}}"
tag-message = "chore: Release {{crate_name}} version {{version}}"
pre-release-commit-message = "chore: release {{version}}"

pre-release-replacements = [
{ file = "CHANGELOG.md", search = "## \\[Unreleased\\]", replace = "## [Unreleased]\n\n## [{{version}}] - {{date}}", exactly = 1 },
{ file = "CHANGELOG.md", search = "\\[Unreleased\\]: (.*)/compare/v(.*)\\.\\.\\.HEAD", replace = "[Unreleased]: ${1}/compare/v{{version}}...HEAD\n[{{version}}]: ${1}/compare/v${2}...v{{version}}", exactly = 1 },
]
4 changes: 4 additions & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ Always prefer `--wait` over asking the user to manually switch to Codea first.
| `codea paused [on\|off]` | Get or set paused state |
| `codea screenshot [--output <file>]` | Capture a screenshot |
| `codea idle-timer <on\|off>` | Get or set idle timer |
| `codea screen-size` | Show the viewer's screen size preset, and its current pixel size once the viewer has laid out |
| `codea screen-size <preset>` | Set the viewer's screen size: `match-display`, `iphone-portrait`, `iphone-landscape`, `tv`, `square` |
| `codea logs` | Get log output |
| `codea logs --head N` | Get first N lines |
| `codea logs --tail N` | Get last N lines |
Expand Down Expand Up @@ -459,6 +461,8 @@ light.pop()
- Always `pull` before editing to get the latest files from device
- Use `sleep 2` or similar between `run` and `screenshot` to let the project render a frame
- `exec` requires a project to already be running
- `screen-size` resizes the running viewer, so use it to screenshot a project at several sizes or orientations without touching the project's code. `WIDTH`/`HEIGHT` inside Lua change with the preset, so this is a real resize, not a crop. It needs a project running and only the app's viewer supports it, so it reports an error rather than failing silently when no project is running or the target is a standalone Runner
- Reading `screen-size` is a live query, not a stored setting: the size it reports is the viewer's laid-out bounds rather than the preset's nominal size. Under `match-display` it is expected to follow the window, though that has not been observed — resizing the window on an iPad needs Stage Manager or split view. It can in principle report a preset with no size before the viewer lays out, but that has never been seen in practice, so don't build a workflow around it
- Screenshot returns a PNG — save it and use vision to inspect results; do not open it in an external app unless the user explicitly asks
- `codea logs` accumulates all output since last `clear-logs`; use `--head 20` when Codea is spamming a repeated error to find the original cause
- File paths on device use `codea://` URIs internally; you don't need to deal with these directly
163 changes: 162 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ use crate::discover::{DiscoverEvent, discover_devices_with_progress};
use crate::local::create_local_project;
use crate::mcp::{MCPClient, maybe_base64_text};

const SCREEN_SIZE_PRESETS: [(&str, &str); 5] = [
("match-display", "Match Display"),
("iphone-portrait", "iPhone (Portrait)"),
("iphone-landscape", "iPhone (Landscape)"),
("tv", "TV (16:9)"),
("square", "Square (1:1)"),
];

#[derive(Parser, Debug)]
#[command(name = "codea")]
#[command(about = "Codea CLI — connect to Codea on your device.")]
Expand Down Expand Up @@ -53,6 +61,8 @@ enum Commands {
Screenshot(ScreenshotArgs),
#[command(name = "idle-timer")]
IdleTimer(IdleTimerArgs),
#[command(name = "screen-size")]
ScreenSize(ScreenSizeArgs),
Logs(LogsArgs),
#[command(name = "clear-logs")]
ClearLogs(ProfileArg),
Expand Down Expand Up @@ -156,6 +166,17 @@ struct IdleTimerArgs {
profile: String,
}

#[derive(Args, Debug)]
struct ScreenSizeArgs {
#[arg(
value_name = "preset",
help = "match-display, iphone-portrait, iphone-landscape, tv, or square"
)]
preset: Option<String>,
#[arg(long, default_value = "default")]
profile: String,
}

#[derive(Args, Debug)]
struct LogsArgs {
#[arg(long)]
Expand Down Expand Up @@ -358,6 +379,7 @@ fn run() -> Result<()> {
Commands::Exec(args) => exec_command(args, cli.wait),
Commands::Screenshot(args) => screenshot_command(args, cli.wait),
Commands::IdleTimer(args) => idle_timer_command(args, cli.wait),
Commands::ScreenSize(args) => screen_size_command(args, cli.wait),
Commands::Logs(args) => logs_command(args, cli.wait),
Commands::ClearLogs(args) => clear_logs_command(&args.profile, cli.wait),
Commands::New(args) => new_command(args, cli.wait),
Expand Down Expand Up @@ -807,6 +829,96 @@ fn idle_timer_command(args: IdleTimerArgs, wait: bool) -> Result<()> {
Ok(())
}

fn screen_size_command(args: ScreenSizeArgs, wait: bool) -> Result<()> {
if let Some(preset) = args.preset.as_deref()
&& !SCREEN_SIZE_PRESETS.iter().any(|(id, _)| *id == preset)
{
bail!(
"Invalid preset '{}'. Use one of: {}.",
preset,
screen_size_preset_ids()
);
}

let mut client = client_for_profile(&args.profile, wait)?;
match args.preset.as_deref() {
None => {
let (preset, size) = parse_screen_size(&client.get_screen_size()?)?;
match size {
Some(size) => println!("Screen size: {}, {size}", describe_screen_size(&preset)),
None => println!("Screen size: {}", describe_screen_size(&preset)),
}
}
Some(preset) => {
let message = client.set_screen_size(preset)?;
if message.trim().is_empty() {
println!("Screen size: {}", describe_screen_size(preset));
} else {
println!("{message}");
}
}
}
Ok(())
}

fn screen_size_preset_ids() -> String {
SCREEN_SIZE_PRESETS
.iter()
.map(|(id, _)| *id)
.collect::<Vec<_>>()
.join(", ")
}

/// Maps a preset id to its display name, which lives here rather than on the
/// device so the reported id stays the contract.
fn describe_screen_size(preset: &str) -> String {
match SCREEN_SIZE_PRESETS.iter().find(|(id, _)| *id == preset) {
Some((id, label)) => format!("{id} \u{2014} {label}"),
None => preset.to_string(),
}
}

/// Codea reports the current screen size as `{"preset": id}`, plus `width` and
/// `height` once the viewer has laid out. Those two are omitted rather than
/// null before first layout, and they are the viewer's laid-out bounds rather
/// than the preset's nominal size, so under `match-display` they are expected
/// to follow the window.
fn parse_screen_size(text: &str) -> Result<(String, Option<String>)> {
let trimmed = text.trim();
if trimmed.is_empty() {
bail!("Codea did not report a screen size.");
}
let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
bail!("Codea reported an unreadable screen size: {trimmed}");
};

let preset = value
.get("preset")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("Codea reported a screen size without a preset: {trimmed}"))?;
let size = match (
value.get("width").and_then(Value::as_f64),
value.get("height").and_then(Value::as_f64),
) {
(Some(width), Some(height)) => Some(format!(
"{} \u{00d7} {}",
format_dimension(width),
format_dimension(height)
)),
_ => None,
};
Ok((preset.to_string(), size))
}

/// The viewer's bounds are fractional in principle, so avoid printing "1920.0".
fn format_dimension(value: f64) -> String {
if value.fract() == 0.0 {
format!("{}", value as i64)
} else {
format!("{value}")
}
}

fn logs_command(args: LogsArgs, wait: bool) -> Result<()> {
let mut client = client_for_profile(&args.profile, wait)?;
if args.follow {
Expand Down Expand Up @@ -1652,7 +1764,10 @@ fn project_name(path: &str) -> String {

#[cfg(test)]
mod tests {
use super::{completion_kind_name, parse_collection_project, resolve_runtime_filter};
use super::{
completion_kind_name, describe_screen_size, parse_collection_project, parse_screen_size,
resolve_runtime_filter,
};

#[test]
fn parse_collection_project_supports_icloud_prefix() {
Expand Down Expand Up @@ -1694,4 +1809,50 @@ mod tests {
assert_eq!(completion_kind_name(3), Some("function"));
assert_eq!(completion_kind_name(999), None);
}

#[test]
fn parse_screen_size_reads_preset_and_live_dimensions() {
let (preset, size) =
parse_screen_size(r#"{"height":1080,"preset":"tv","width":1920}"#).unwrap();
assert_eq!(preset, "tv");
assert_eq!(size.as_deref(), Some("1920 \u{00d7} 1080"));
}

#[test]
fn parse_screen_size_accepts_a_viewer_that_has_not_laid_out() {
let (preset, size) = parse_screen_size(r#"{"preset":"tv"}"#).unwrap();
assert_eq!(preset, "tv");
assert_eq!(size, None);
}

#[test]
fn parse_screen_size_ignores_a_lone_dimension() {
let (preset, size) = parse_screen_size(r#"{"preset":"square","width":1112}"#).unwrap();
assert_eq!(preset, "square");
assert_eq!(size, None);
}

#[test]
fn parse_screen_size_keeps_fractional_bounds_but_not_trailing_zeros() {
let (_, size) =
parse_screen_size(r#"{"preset":"match-display","width":1592.5,"height":1192}"#)
.unwrap();
assert_eq!(size.as_deref(), Some("1592.5 \u{00d7} 1192"));
}

#[test]
fn parse_screen_size_rejects_unusable_answers() {
assert!(parse_screen_size(" ").is_err());
assert!(parse_screen_size("tv").is_err());
assert!(parse_screen_size(r#"{"width":1920,"height":1080}"#).is_err());
}

#[test]
fn describe_screen_size_falls_back_to_unknown_id() {
assert_eq!(
describe_screen_size("match-display"),
"match-display \u{2014} Match Display"
);
assert_eq!(describe_screen_size("holodeck"), "holodeck");
}
}
10 changes: 10 additions & 0 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,16 @@ impl MCPClient {
)?))
}

pub fn get_screen_size(&mut self) -> Result<String> {
Ok(Self::text(&self.call_tool("getScreenSize", json!({}))?))
}

pub fn set_screen_size(&mut self, preset: &str) -> Result<String> {
Ok(Self::text(
&self.call_tool("setScreenSize", json!({"preset": preset}))?,
))
}

pub fn get_function_help(&mut self, function_name: &str) -> Result<Value> {
Self::json_result(
&self.call_tool("getFunctionHelp", json!({"functionName": function_name}))?,
Expand Down
Loading