diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 2af46e4d..c854d84c 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -7,7 +7,7 @@ jobs: - uses: actions/checkout@v4 # TODO: remove this step and the script when stable cargo supports # registry.global-min-publish-age (stabilized in cargo 1.100). - - run: python3 tools/check-min-publish-age.py + - run: ./tools/check-min-publish-age.py - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: "stable" diff --git a/docs/configuration.md b/docs/configuration.md index a196aa54..bb002434 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -96,11 +96,12 @@ stage_height = 480 # default is 360 ## Notes -Embeds the contents of a file as a comment inside the Stage. +Add credits, instructions, or other project notes to the Stage using a text file. ```toml notes = "NOTES.md" # default is unset ``` -The comment is skipped if the file is missing or empty, and text longer than 8000 -characters is truncated. +Notes appear in a Stage comment beneath the goboscript logo. +The logo is included even when no notes are provided. Comments longer than +8000 characters are shortened. diff --git a/src/LOGO.txt b/src/LOGO.txt new file mode 100644 index 00000000..35a044a8 --- /dev/null +++ b/src/LOGO.txt @@ -0,0 +1,9 @@ +⠀⠀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢾⣷⣾⡷⠀⠀⠀⠀⠀⠀⠀⢸⣿⡆⠀⠀⠀⠀ +⠀⣾⣿⠟⠻⣿⡇⠀⠀⠀⠀⠀⠀⠀⢸⣿⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⠀⠀⠀⠀⠀⣠⣴⣶⣶⡄⠀⠀⣀⣀⣀⡀⠀⣀⡀⠀⣀⡀⠙⠋⠀⢀⣤⣤⣤⣤⡄⢰⣶⣿⣷⣶⣶⠀⠀ +⢸⣿⠃⠀⠀⢻⣿⠀⣴⣾⡿⣿⣷⡄⢸⣿⣿⣿⣿⣦⡀⠀⠀⣰⣿⡿⣿⣷⣄⠀⠀⣾⣿⠟⠉⠛⠃⠀⣼⣿⠟⠛⠃⠀⣿⣇⣼⠟⠃⣿⡇⠸⣿⡟⠋⠉⠻⣿⣦⠀⣿⡏⠉⠁⠀⠀ +⢸⣿⠀⠀⠀⢸⣿⠀⣿⡏⠀⠈⢿⣿⢸⣿⠋⠀⠙⢿⣿⠀⢀⣿⡟⠀⠈⢻⣿⡆⠀⢿⣿⣦⣤⣤⡀⠀⣿⡏⠀⠀⠀⠀⢻⣿⠏⠀⠀⢿⣿⠀⣿⡇⠀⠀⠀⢹⣿⠀⣿⡇⠀⠀⠀⠀ +⢸⣿⡄⠀⠀⣸⣿⠀⣿⡇⠀⠀⢸⣿⢸⣿⡄⠀⠀⢸⣿⠀⢸⣿⡇⠀⠀⠀⣿⡇⠀⠀⠉⠉⠙⢿⣿⠀⣿⡇⠀⠀⠀⠀⢸⣿⠀⠀⠀⢸⣿⠀⣿⡇⠀⠀⠀⣼⣿⠀⣿⣇⠀⠀⠀⠀ +⠈⠿⣿⣿⣿⣿⣿⠀⣿⣷⡀⢀⣼⣿⠀⣿⣇⣀⣠⣿⣿⠀⠸⣿⣇⠀⠀⣰⣿⡇⢀⣤⣄⣀⣠⣾⡿⠀⣿⣧⣤⣴⣶⡆⢸⣿⠀⠀⠀⢸⣿⠀⣿⣧⣤⣴⣾⡿⠏⠀⢻⣿⣄⣀⣀⡀ +⠀⠀⠀⠀⣠⣿⡿⠀⠘⠿⠿⠿⠿⠃⠀⠛⠻⠿⠿⠟⠃⠀⠀⠻⢿⣿⣿⠿⠛⠀⠈⠛⠿⠿⠿⠛⠁⠀⠙⠛⠛⠛⠛⠁⠈⠉⠀⠀⠀⠈⠉⠀⣿⡟⠛⠋⠉⠀⠀⠀⠈⠛⠛⠛⠛⠃ +⣿⣷⣶⣿⡿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠁⠀⠀ diff --git a/src/codegen.rs b/src/codegen.rs index 5f9da23a..2b93d989 100644 --- a/src/codegen.rs +++ b/src/codegen.rs @@ -1,3 +1,4 @@ +mod ai_detection; pub mod assets; pub mod build; mod cleanup; diff --git a/src/codegen/ai_detection.rs b/src/codegen/ai_detection.rs new file mode 100644 index 00000000..447c2f1a --- /dev/null +++ b/src/codegen/ai_detection.rs @@ -0,0 +1,177 @@ +use std::path::Path; + +use crate::vfs::VFS; + +// https://github.com/vercel/vercel/blob/main/packages/detect-agent/src/index.ts +// https://github.com/sdairs/is-ai-agent/blob/main/src/lib.rs +const AGENT_NONEMPTY_ENV_VARS: &[&str] = &[ + "AMP_CURRENT_THREAD_ID", + "ANTIGRAVITY_AGENT", + "ANTIGRAVITY_PROJECT_ID", + "AUGMENT_AGENT", + "CLAUDECODE", + "CLAUDE_CODE", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_EXECPATH", + "CLAUDE_CODE_IS_COWORK", + "CLAUDE_CODE_SESSION_ID", + "CLINE_ACTIVE", + "CLINE_TASK_ID", + "CODEBUDDY", + "CODEBUDDY_PROJECT_DIR", + "CODEBUDDY_SESSION_ID", + "CODEX_CI", + "CODEX_ROOT", + "CODEX_SANDBOX", + "CODEX_SANDBOX_NETWORK_DISABLED", + "CODEX_THREAD_ID", + "COPILOT_AGENT", + "COPILOT_AGENT_JOB_ID", + "COPILOT_AGENT_SESSION_ID", + "COPILOT_CLI", + "CRUSH", + "CURSOR_AGENT", + "CURSOR_SANDBOX", + "CURSOR_TRACE_ID", + "FIREBENDER_TERMINAL", + "GEMINI_CLI", + "GOOSE_TERMINAL", + "IFLOW_CLI", + "KIRO_AGENT_PATH", + "OPENCODE", + "OPENCODE_APP_INFO", + "OPENCODE_BIN_PATH", + "OPENCODE_CLIENT", + "OPENCODE_MODES", + "OPENCODE_PID", + "OPENCODE_SERVER", + "OZ_RUN_ID", + "PI_CODING_AGENT", + "QWEN_CODE", + "ROO_CODE_TASK_ID", + "TRAE_AI_SHELL_ID", + "VECLI_DIR", + "VECLI_SANDBOX", +]; + +const AGENT_NONBLANK_ENV_VARS: &[&str] = &[ + "CODEX_SESSION_ID", + "GROK_SESSION_ID", + "HERMES_SESSION_ID", + "JUNIE_SHIM_PATH", + "PI_SESSION_ID", +]; + +const AGENT_EXACT_ENV_VALUES: &[(&str, &str)] = &[ + ("CLAUDE_CODE_CHILD_SESSION", "1"), + ("DSH_SHELL", "1"), + ("HERMES_AGENT", "true"), + ("KILO", "1"), + ("OPENCLAW_SHELL", "exec"), + ("VTCODE", "1"), +]; + +fn env_is_nonempty(names: &[&str], env: &impl Fn(&str) -> Option) -> bool { + names + .iter() + .any(|name| env(name).is_some_and(|value| !value.is_empty())) +} + +fn env_is_nonblank(names: &[&str], env: &impl Fn(&str) -> Option) -> bool { + names + .iter() + .any(|name| env(name).is_some_and(|value| !value.trim().is_empty())) +} + +fn env_equals(values: &[(&str, &str)], env: &impl Fn(&str) -> Option) -> bool { + values + .iter() + .any(|(name, expected)| env(name).is_some_and(|value| value == *expected)) +} + +fn env_trimmed_equals(name: &str, expected: &str, env: &impl Fn(&str) -> Option) -> bool { + env(name).is_some_and(|value| value.trim() == expected) +} + +fn env_contains(names: &[&str], marker: &str, env: &impl Fn(&str) -> Option) -> bool { + names + .iter() + .any(|name| env(name).is_some_and(|value| value.contains(marker))) +} + +fn env_is_enabled(names: &[&str], env: &impl Fn(&str) -> Option) -> bool { + names.iter().any(|name| { + env(name).is_some_and(|value| { + let value = value.trim(); + !value.is_empty() + && !["0", "false", "no", "off"] + .iter() + .any(|disabled| value.eq_ignore_ascii_case(disabled)) + }) + }) +} + +pub(super) fn is_ai_project(fs: &mut dyn VFS, input: &Path) -> bool { + let env = |name: &str| std::env::var(name).ok(); + let is_ai_env = env_is_nonempty(AGENT_NONEMPTY_ENV_VARS, &env) + || env_is_nonblank(AGENT_NONBLANK_ENV_VARS, &env) + || env_equals(AGENT_EXACT_ENV_VALUES, &env) + || env_is_enabled(&["AGENT", "AI_AGENT"], &env) + || env_trimmed_equals("CURSOR_EXTENSION_HOST_ROLE", "agent-exec", &env) + || env_contains(&["AWS_EXECUTION_ENV"], "AmazonQ-For-CLI", &env) + || (env_is_nonempty(&["AGENT_CONTEXT_OUT"], &env) + && env_is_nonempty(&["AGENT_DISPLAY_OUT"], &env)) + || env_contains(&["PS1", "PROMPT_COMMAND"], "###PS1JSON###", &env); + let has_ai_instructions = [ + "AGENTS.md", + "AGENTS.override.md", + "CLAUDE.md", + "GEMINI.md", + "CONVENTIONS.md", + ".aider.conf.yml", + ".cursorrules", + ".windsurfrules", + ".clinerules", + ".roorules", + ".github/copilot-instructions.md", + ".junie/AGENTS.md", + ".junie/playbook.md", + ".junie/guidelines.md", + ] + .iter() + .any(|name| fs.is_file(&input.join(name))) + || input.to_str().is_some_and(|input| { + let input = glob::Pattern::escape(input); + [ + ".github/instructions/**/*.instructions.md", + ".cursor/rules/**/*.mdc", + ".devin/rules/**/*.md", + ".windsurf/rules/**/*.md", + ".clinerules/**/*.md", + ".cline/rules/**/*.md", + ".roo/rules/**/*", + ".roo/rules-*/**/*", + ".roorules-*", + ".continue/rules/**/*.md", + ".amazonq/rules/**/*.md", + ".junie/rules/*.md", + ".junie/guidelines/**/*", + ] + .iter() + .any(|pattern| { + fs.glob(Path::new(&input).join(pattern).to_str().unwrap()) + .is_ok_and(|paths| paths.iter().any(|path| fs.is_file(path))) + }) + }); + if is_ai_env && !has_ai_instructions { + create_agents_md(fs, input); + } + is_ai_env || has_ai_instructions +} + +fn create_agents_md(fs: &mut dyn VFS, input: &Path) { + let _ = fs.create_new_file( + &input.join("AGENTS.md"), + b"See the [goboscript documentation](https://aspiz.uk/goboscript/docs).\n", + ); +} diff --git a/src/codegen/build.rs b/src/codegen/build.rs index 3327836c..29cb4243 100644 --- a/src/codegen/build.rs +++ b/src/codegen/build.rs @@ -21,12 +21,8 @@ use zip::{ use crate::{ ast::{ - ConstExpr, Project, Sprite, - Type, - Value, - Var, }, codegen::{ cleanup, @@ -49,24 +45,6 @@ use crate::{ visitor, }; -const AGENT_ENV_VARS: &[&str] = &[ - "CLAUDECODE", - "CLAUDE_CODE_ENTRYPOINT", - "CODEX_SANDBOX", - "CODEX_ROOT", - "CODEX_THREAD_ID", - "CURSOR_AGENT", - "CURSOR_TRACE_ID", - "OPENCODE", - "GEMINI_CLI", -]; - -fn is_run_by_agent() -> bool { - AGENT_ENV_VARS - .iter() - .any(|name| std::env::var_os(name).is_some()) -} - pub fn build_impl( fs: Rc>, input: PathBuf, @@ -171,19 +149,6 @@ pub fn build_impl( ); visitor::pass3::visit_project(&mut project); visitor::pass4::visit_project(&mut project); - if is_run_by_agent() { - project.stage.vars.entry("AI=true".into()).or_insert(Var { - name: "AI=true".into(), - span: 0..0, - type_: Type::Value, - default: Some(ConstExpr::Value { - value: Value::String("".into()), - span: 0..0, - }), - is_cloud: false, - is_used: true, - }); - } log::info!("{:#?}", project); let mut sb3 = Sb3::new(fs.clone(), input.clone()); sb3.project( diff --git a/src/codegen/sb3.rs b/src/codegen/sb3.rs index 2bcd0935..1c30b60a 100644 --- a/src/codegen/sb3.rs +++ b/src/codegen/sb3.rs @@ -1,48 +1,29 @@ use core::str; use std::{ cell::RefCell, - io::{ - self, - Write, - }, - path::{ - Path, - PathBuf, - }, + io::{self, Write}, + path::{Path, PathBuf}, rc::Rc, }; use anyhow::bail; use logos::Span; -use rustc_hash::{ - FxHashMap, - FxHashSet, -}; +use rustc_hash::{FxHashMap, FxHashSet}; use serde_json::json; use super::{ - node::Node, - node_id::NodeID, - node_id_factory::NodeIDFactory, - turbowarp_config::TurbowarpConfig, + node::Node, node_id::NodeID, node_id_factory::NodeIDFactory, turbowarp_config::TurbowarpConfig, }; use crate::{ ast::*, blocks::Block, codegen::{ - assets::AssetObjectStore, - datalists::read_list, + ai_detection::is_ai_project, assets::AssetObjectStore, datalists::read_list, mutation::Mutation, }, config::Config, - diagnostic::{ - DiagnosticKind, - SpriteDiagnostics, - }, - misc::{ - write_comma_io, - SmolStr, - }, + diagnostic::{DiagnosticKind, SpriteDiagnostics}, + misc::{write_comma_io, SmolStr}, vfs::VFS, }; @@ -588,23 +569,17 @@ impl Sb3 { write!(self.json, r#""isStage":{}"#, name == STAGE_NAME)?; write!(self.json, r#","name":{}"#, json!(name))?; if name == STAGE_NAME { - let notes = read_notes(&fs, input, config)?; + let notes = read_notes(&mut *fs.borrow_mut(), input, config)?; + let notes = build_notes_comment(¬es, is_ai_project(&mut *fs.borrow_mut(), input)); write!(self.json, r#","comments":{{"#)?; - let mut comma = false; - let twconfig_y = if notes.trim().is_empty() { - 0 - } else { - write_comma_io(&mut self.json, &mut comma)?; - self.comment("notes", 0, 0, NOTES_WIDTH, NOTES_HEIGHT, ¬es)?; - NOTES_HEIGHT + COMMENT_GAP - }; - write_comma_io(&mut self.json, &mut comma)?; + self.comment("notes", 0, 0, NOTES_WIDTH, NOTES_HEIGHT, ¬es)?; + write!(self.json, ",")?; self.comment( "twconfig", 0, - twconfig_y, - 350, - 170, + NOTES_HEIGHT + COMMENT_GAP, + 240, + 120, &TurbowarpConfig::from(config).to_string(), )?; write!(self.json, "}}")?; // comments @@ -1361,7 +1336,7 @@ impl Sb3 { } } -fn read_notes(fs: &Rc>, input: &Path, config: &Config) -> io::Result { +fn read_notes(fs: &mut dyn VFS, input: &Path, config: &Config) -> io::Result { let Some(notes_path) = config .notes .as_deref() @@ -1369,17 +1344,8 @@ fn read_notes(fs: &Rc>, input: &Path, config: &Config) -> io::R else { return Ok(String::new()); }; - match fs.borrow_mut().read_to_string(&input.join(notes_path)) { - Ok(notes) => { - let notes = notes.strip_prefix('\u{feff}').unwrap_or(¬es); - if notes.chars().count() > COMMENT_TEXT_LIMIT { - let mut truncated: String = notes.chars().take(COMMENT_TEXT_LIMIT - 1).collect(); - truncated.push(ELLIPSIS); - Ok(truncated) - } else { - Ok(notes.to_string()) - } - } + match fs.read_to_string(&input.join(notes_path)) { + Ok(notes) => Ok(notes), Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(String::new()), Err(err) => Err(io::Error::new( err.kind(), @@ -1388,6 +1354,20 @@ fn read_notes(fs: &Rc>, input: &Path, config: &Config) -> io::R } } +fn build_notes_comment(notes: &str, is_ai: bool) -> String { + let slop_marker = if is_ai { "510P" } else { "" }; + let notes = notes.strip_prefix('\u{feff}').unwrap_or(notes); + let mut text = format!( + "{}{slop_marker}\n\n{notes}", + include_str!("../LOGO.txt").trim_end(), + ); + if text.chars().count() > COMMENT_TEXT_LIMIT { + text = text.chars().take(COMMENT_TEXT_LIMIT - 1).collect(); + text.push(ELLIPSIS); + } + text +} + fn compute_layers(project: &Project, config: &Config) -> anyhow::Result> { let mut layers: FxHashMap = Default::default(); let mut keys: Vec<_> = project.sprites.keys().collect(); diff --git a/src/vfs.rs b/src/vfs.rs index 69203f1a..d67dd1b8 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -4,6 +4,7 @@ use std::{ self, Cursor, Read, + Write, }, path::{ Path, @@ -32,6 +33,10 @@ pub trait VFS { fn is_file(&self, path: &Path) -> bool; fn glob(&mut self, pattern: &str) -> io::Result>; + fn create_new_file(&mut self, _path: &Path, _contents: &[u8]) -> io::Result<()> { + Err(io::ErrorKind::Unsupported.into()) + } + fn read_to_string(&mut self, path: &Path) -> io::Result { let mut file = self.read_file(path)?; let mut contents = String::new(); @@ -83,6 +88,14 @@ impl VFS for RealFS { } Ok(entries) } + + fn create_new_file(&mut self, path: &Path, contents: &[u8]) -> io::Result<()> { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path)? + .write_all(contents) + } } #[derive(Tsify, Serialize, Deserialize)] @@ -177,6 +190,22 @@ impl VFS for MemFS { } Ok(entries) } + + fn create_new_file(&mut self, path: &Path, contents: &[u8]) -> io::Result<()> { + if self.is_file(path) || self.is_dir(path) { + return Err(io::ErrorKind::AlreadyExists.into()); + } + let path_str = path + .to_str() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid UTF-8 in path"))?; + self.files.insert( + path_str.to_owned(), + Data { + inner: contents.to_vec(), + }, + ); + Ok(()) + } } fn normalize_glob_path(path: &str) -> Cow<'_, str> { diff --git a/tools/check-min-publish-age.py b/tools/check-min-publish-age.py old mode 100644 new mode 100755