feat: release Dever 0.1.4 with enforceable project guidance

This commit is contained in:
Antigravity committed 2026-10-09 05:33:57 +00:00
1 parent 201bdee372
commit 89629c2197
86 files changed
+2798 -338

No files matched your search

+79 -15
View File
@@ -18,7 +18,11 @@ mod worker;
#[path = "../../../test/dever-cli-tests/build_arguments.rs"]
mod build_arguments;
const USAGE: &str = "Usage:\n dever check <project-root>\n dever api <project-root> [--output <new-file>]\n dever fmt <project-root> [--check]\n dever test <project-root>\n dever run <project-root> [-- <component>.<domain>.<cmd> '<json-object>']\n dever run <project-root> --tenant <positive-tenant-id> -- <component>.<domain>.<cmd> '<json-object>'\n dever build <project-root> --output <new-file> [--target linux-x86_64|linux-aarch64]\n dever clean <project-root>\n dever tenant migrate <project-root> <positive-tenant-id>\n dever tenant owner <project-root> <positive-tenant-id> <site> <positive-user-id>\n dever tenant component enable <project-root> <positive-tenant-id> <component>\n dever tenant component disable <project-root> <positive-tenant-id> <component>\n dever lib add|install|list|update|remove|doctor <project-root> [--target linux-x86_64|linux-aarch64] [spec ...]\n dever package add|list|update|remove|doctor <project-root> [spec ...]\n\nApplications use API, Job, and CMD declarations under module/ and config/setting.json. Native builds and tests are offline.";
#[cfg(test)]
#[path = "../../../test/dever-cli-tests/test_arguments.rs"]
mod test_arguments;
const USAGE: &str = "Usage:\n dever check <project-root>\n dever api <project-root> [--output <new-file>]\n dever fmt <project-root> [--check]\n dever test <project-root> [--filter <component[.domain[.case]]>] [--timeout-ms <positive-ms>]\n dever run <project-root> [-- <component>.<domain>.<cmd> '<json-object>']\n dever run <project-root> --tenant <positive-tenant-id> -- <component>.<domain>.<cmd> '<json-object>'\n dever build <project-root> --output <new-file> [--target linux-x86_64|linux-aarch64]\n dever clean <project-root>\n dever tenant migrate <project-root> <positive-tenant-id>\n dever tenant owner <project-root> <positive-tenant-id> <site> <positive-user-id>\n dever tenant component enable <project-root> <positive-tenant-id> <component>\n dever tenant component disable <project-root> <positive-tenant-id> <component>\n dever lib add|install|list|update|remove|doctor <project-root> [--target linux-x86_64|linux-aarch64] [spec ...]\n dever package add|list|update|remove|doctor <project-root> [spec ...]\n\nApplications use API, Job, and CMD declarations under module/ and config/setting.json. Native builds and tests are offline.";
enum Action {
Check,
@@ -28,7 +32,7 @@ enum Action {
Format {
check: bool,
},
Test,
Test(test_runner::Options),
Run {
tenant_id: Option<i64>,
arguments: Vec<OsString>,
@@ -175,7 +179,7 @@ fn execute() -> Result<ExitCode, String> {
};
let sources = SourceMap::load_with_packages(
&source_root,
matches!(action, Action::Test | Action::Format { .. })
matches!(action, Action::Test(_) | Action::Format { .. })
.then(|| root.join("test"))
.as_deref(),
&packages,
@@ -220,28 +224,52 @@ fn execute() -> Result<ExitCode, String> {
.map(|error| error.render(&sources))
.collect::<String>()
})?;
// 基线先于生产检查;延后传播失败,让未被硬错误覆盖的告警仍能输出。
let application_errors = if matches!(action, Action::Api { .. }) {
Ok(Vec::new())
} else {
api::check_baseline(&program, &root).and_then(|()| {
if matches!(action, Action::Check) || action.compiles_application() {
program.application_errors()
} else {
Ok(Vec::new())
}
})
};
for warning in program.warnings() {
if SourceMap::is_standard(warning.primary.source) {
let reported_as_error = matches!(warning.code, "W003" | "W004" | "W005")
&& !warning.related.is_empty()
&& application_errors
.as_deref()
.unwrap_or_default()
.iter()
.any(|error| {
error.code == "C016"
&& error.primary == warning.primary
&& error
.related
.iter()
.map(|label| label.span)
.eq(warning.related.iter().map(|label| label.span))
});
if SourceMap::is_standard(warning.primary.source) || reported_as_error {
continue;
}
write!(io::stderr().lock(), "{}", warning.render(&sources))
.map_err(|error| error.to_string())?;
}
let errors = application_errors?;
if let Action::Api { output } = action {
return api::write(&program, output.as_deref());
}
api::check_baseline(&program, &root)?;
if matches!(action, Action::Check) || action.compiles_application() {
let errors = program.application_errors()?;
if !errors.is_empty() {
return Err(errors.iter().map(|error| error.render(&sources)).collect());
}
if !errors.is_empty() {
return Err(errors.iter().map(|error| error.render(&sources)).collect());
}
if matches!(action, Action::Check) {
return Ok(ExitCode::SUCCESS);
}
if matches!(action, Action::Test) {
return test_runner::execute(&program, &sources);
if let Action::Test(options) = &action {
return test_runner::execute(&program, &sources, options);
}
let target = match &action {
Action::Build { target, .. } => *target,
@@ -258,7 +286,7 @@ fn execute() -> Result<ExitCode, String> {
Action::Package { .. } => unreachable!("package returned before source checking"),
Action::Api { .. } => unreachable!("api returned before baseline checking"),
Action::Format { .. } => unreachable!("fmt returned before checking"),
Action::Test => unreachable!("test returned before application compilation"),
Action::Test(_) => unreachable!("test returned before application compilation"),
Action::Run { .. }
| Action::Build { .. }
| Action::TenantMigrate { .. }
@@ -346,7 +374,7 @@ fn execute() -> Result<ExitCode, String> {
Action::Check
| Action::Api { .. }
| Action::Format { .. }
| Action::Test
| Action::Test(_)
| Action::Lib { .. }
| Action::Clean => {
unreachable!("check and fmt returned before native compilation")
@@ -506,7 +534,43 @@ fn arguments_for(arguments: &[OsString]) -> Result<(PathBuf, Action), String> {
},
))
}
[command, root] if command == "test" => Ok((root.into(), Action::Test)),
[command, root, rest @ ..] if command == "test" => {
let mut filter = None;
let mut timeout = None;
let mut options = rest.iter();
while let Some(option) = options.next() {
match option.to_str() {
Some("--filter") if filter.is_none() => {
let value = option_value(&mut options, "--filter")?
.to_str()
.ok_or("test filter must be UTF-8")?;
test_runner::validate_filter(value)?;
filter = Some(value.to_owned());
}
Some("--timeout-ms") if timeout.is_none() => {
let value = option_value(&mut options, "--timeout-ms")?
.to_str()
.ok_or("test timeout must be a positive integer in milliseconds")?;
let millis = value
.bytes()
.all(|byte| byte.is_ascii_digit())
.then(|| value.parse::<u64>().ok())
.flatten()
.filter(|millis| *millis > 0)
.ok_or("test timeout must be a positive integer in milliseconds")?;
timeout = Some(std::time::Duration::from_millis(millis));
}
_ => return Err("test accepts --filter and --timeout-ms exactly once; unknown or duplicate option".into()),
}
}
Ok((
root.into(),
Action::Test(test_runner::Options {
filter,
timeout: timeout.unwrap_or(test_runner::DEFAULT_TIMEOUT),
}),
))
}
[command, root] if command == "clean" => Ok((root.into(), Action::Clean)),
[command, root, rest @ ..] if command == "build" => {
let mut output = None;
+81 -21
View File
@@ -3,21 +3,85 @@ use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use dever_core::hir::{Program, TestCase};
use dever_core::source::SourceMap;
pub(super) fn execute(program: &Program, sources: &SourceMap) -> Result<ExitCode, String> {
mod process;
#[cfg(all(test, target_os = "linux"))]
#[path = "../../../test/dever-cli-tests/test_process.rs"]
mod tests;
pub(super) const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
pub(super) struct Options {
pub filter: Option<String>,
pub timeout: Duration,
}
pub(super) fn validate_filter(filter: &str) -> Result<(), String> {
let segments: Vec<_> = filter.split('.').collect();
if segments.len() <= 3
&& segments.iter().all(|segment| {
segment.starts_with(|character: char| character.is_ascii_lowercase())
&& segment.split('_').all(|word| {
!word.is_empty()
&& word
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
})
})
{
Ok(())
} else {
Err("test filter must be component[.domain[.case]] using lower_snake_case names".into())
}
}
fn selected_cases<'a>(
tests: &'a [TestCase],
filter: Option<&str>,
) -> Result<Vec<(usize, &'a TestCase)>, String> {
let selected: Vec<_> = tests
.iter()
.enumerate()
.filter(|(_, test)| {
filter.is_none_or(|filter| {
let identity = test.name().replace('/', ".");
identity == filter
|| identity
.strip_prefix(filter)
.is_some_and(|rest| rest.starts_with('.'))
})
})
.collect();
if selected.is_empty()
&& let Some(filter) = filter
{
return Err(format!("no tests match filter '{filter}'"));
}
Ok(selected)
}
pub(super) fn execute(
program: &Program,
sources: &SourceMap,
options: &Options,
) -> Result<ExitCode, String> {
let selected = selected_cases(program.tests(), options.filter.as_deref())?;
let not_run = program.tests().len() - selected.len();
let mut stdout = io::stdout().lock();
writeln!(stdout, "running {} tests", program.tests().len()).map_err(io_error)?;
writeln!(stdout, "running {} tests", selected.len()).map_err(io_error)?;
let mut passed = 0;
let mut failures = Vec::new();
if !program.tests().is_empty() {
if !selected.is_empty() {
match crate::compile::test_suite(program, sources) {
Ok(native) => {
for (index, test) in program.tests().iter().enumerate() {
match run_case(program, test, &native, index) {
for (index, test) in &selected {
match run_case(program, test, &native, *index, options.timeout) {
Ok(()) => {
passed += 1;
writeln!(stdout, "test {} ... ok", test.name()).map_err(io_error)?;
@@ -31,7 +95,7 @@ pub(super) fn execute(program: &Program, sources: &SourceMap) -> Result<ExitCode
}
}
Err(message) => {
for test in program.tests() {
for (_, test) in &selected {
writeln!(stdout, "test {} ... FAILED", test.name()).map_err(io_error)?;
failures.push((test.name(), CaseFailure::message(message.clone())));
}
@@ -50,7 +114,7 @@ pub(super) fn execute(program: &Program, sources: &SourceMap) -> Result<ExitCode
let status = if failed == 0 { "ok" } else { "FAILED" };
writeln!(
stdout,
"\ntest result: {status}. {passed} passed; {failed} failed; 0 not run"
"\ntest result: {status}. {passed} passed; {failed} failed; {not_run} not run"
)
.map_err(io_error)?;
Ok(if failed == 0 {
@@ -65,6 +129,7 @@ fn run_case(
test: &TestCase,
native: &dever_core::native::NativeProgram,
index: usize,
timeout: Duration,
) -> Result<(), CaseFailure> {
let project = TemporaryProject::new().map_err(CaseFailure::message)?;
if test.uses_database() {
@@ -90,20 +155,14 @@ fn run_case(
executable.display()
))
})?;
let output = Command::new(&executable)
.arg(index.to_string())
.current_dir(project.path())
.output()
.map_err(|error| CaseFailure::message(format!("cannot run test process: {error}")))?;
if output.status.success() {
Ok(())
} else {
Err(CaseFailure {
message: format!("test process exited with {}", output.status),
stdout: output.stdout,
stderr: output.stderr,
})
}
process::run(
Command::new(&executable)
.arg(index.to_string())
.env_clear()
.current_dir(project.path()),
project.path(),
timeout,
)
}
fn write_settings(root: &Path, test: &TestCase) -> Result<(), String> {
@@ -145,6 +204,7 @@ fn io_error(error: io::Error) -> String {
error.to_string()
}
#[derive(Debug)]
struct CaseFailure {
message: String,
stdout: Vec<u8>,
+198
View File
@@ -0,0 +1,198 @@
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use super::CaseFailure;
#[cfg(target_os = "linux")]
pub(super) use linux::run;
#[cfg(not(target_os = "linux"))]
pub(super) fn run(
_command: &mut Command,
_directory: &Path,
_timeout: Duration,
) -> Result<(), CaseFailure> {
Err(CaseFailure::message(
"application test processes require a supported Linux host".into(),
))
}
#[cfg(target_os = "linux")]
mod linux {
use super::*;
use std::fs::{self, File};
use std::os::unix::process::CommandExt;
use std::process::{Child, ExitStatus, Stdio};
use std::time::Instant;
use rustix::io::Errno;
use rustix::process::{self, Pid, Signal, WaitId, WaitIdOptions, WaitOptions};
pub(in crate::test_runner) fn run(
command: &mut Command,
directory: &Path,
timeout: Duration,
) -> Result<(), CaseFailure> {
let stdout = directory.join("stdout");
let stderr = directory.join("stderr");
// 文件捕获不依赖 EOF;后代持有输出描述符也不会阻塞测试结束。
let capture = |path: &Path| {
File::create_new(path).map(Stdio::from).map_err(|error| {
CaseFailure::message(format!(
"cannot capture test output '{}': {error}",
path.display()
))
})
};
command
.stdin(Stdio::null())
.stdout(capture(&stdout)?)
.stderr(capture(&stderr)?)
.process_group(0);
let subreaper = Subreaper::acquire().map_err(CaseFailure::message)?;
let started = Instant::now();
let child = command
.spawn()
.map_err(|error| CaseFailure::message(format!("cannot run test process: {error}")))?;
let group = Pid::from_raw(child.id() as i32).expect("spawned child has a positive PID");
let mut process = CaseProcess {
child,
group,
cleaned: false,
};
let outcome = process.wait_until_exit(started, timeout);
let (status, mut cleanup) = process.cleanup();
if let Err(error) = subreaper.restore() {
cleanup.push(error);
}
let mut errors: Vec<String> = outcome.err().into_iter().collect();
if errors.is_empty()
&& let Some(status) = status
&& !status.success()
{
errors.push(format!("test process exited with {status}"));
}
errors.extend(cleanup);
let mut read = |path: &Path| match fs::read(path) {
Ok(bytes) => bytes,
Err(error) => {
errors.push(format!(
"cannot read test output '{}': {error}",
path.display()
));
Vec::new()
}
};
let stdout = read(&stdout);
let stderr = read(&stderr);
if errors.is_empty() {
Ok(())
} else {
Err(CaseFailure {
message: errors.join("\n"),
stdout,
stderr,
})
}
}
struct CaseProcess {
child: Child,
group: Pid,
cleaned: bool,
}
impl CaseProcess {
fn wait_until_exit(&self, started: Instant, timeout: Duration) -> Result<(), String> {
loop {
// NOWAIT 保留 leader,组清理前不会释放它的 PID 给其它进程。
match process::waitid(
WaitId::Pid(self.group),
WaitIdOptions::EXITED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT,
) {
Ok(Some(_)) => return Ok(()),
Ok(None) | Err(Errno::INTR) => {}
Err(error) => return Err(format!("cannot observe test process: {error}")),
}
let elapsed = started.elapsed();
if elapsed >= timeout {
return Err(format!(
"test process timed out after {} ms",
timeout.as_millis()
));
}
std::thread::sleep(Duration::from_millis(5).min(timeout - elapsed));
}
}
fn cleanup(&mut self) -> (Option<ExitStatus>, Vec<String>) {
let mut errors = Vec::new();
let killed = match process::kill_process_group(self.group, Signal::KILL) {
Ok(()) | Err(Errno::SRCH) => true,
Err(error) => {
errors.push(format!("cannot terminate test process group: {error}"));
if let Err(error) = self.child.kill() {
errors.push(format!("cannot terminate test process: {error}"));
}
false
}
};
let status = match self.child.wait() {
Ok(status) => Some(status),
Err(error) => {
errors.push(format!("cannot reap test process: {error}"));
None
}
};
if killed {
loop {
// 只回收本例的进程组,不能消费编译器或其它工具的子进程。
match process::waitpgid(self.group, WaitOptions::empty()) {
Ok(Some(_)) | Err(Errno::INTR) => {}
Err(Errno::CHILD) => break,
Ok(None) => unreachable!("blocking waitpgid returns a child or ECHILD"),
Err(error) => {
errors.push(format!("cannot reap test descendant: {error}"));
break;
}
}
}
}
self.cleaned = true;
(status, errors)
}
}
impl Drop for CaseProcess {
fn drop(&mut self) {
if !self.cleaned {
let _ = self.cleanup();
}
}
}
// 测试串行执行;接管孤儿后代才能在 CLI 返回前完成 wait,而非交给 PID 1。
struct Subreaper(Option<Pid>);
impl Subreaper {
fn acquire() -> Result<Self, String> {
let previous = process::child_subreaper()
.map_err(|error| format!("cannot inspect test child reaper: {error}"))?;
process::set_child_subreaper(Some(process::getpid()))
.map_err(|error| format!("cannot acquire test child reaper: {error}"))?;
Ok(Self(previous))
}
fn restore(self) -> Result<(), String> {
process::set_child_subreaper(self.0)
.map_err(|error| format!("cannot restore test child reaper: {error}"))
}
}
impl Drop for Subreaper {
fn drop(&mut self) {
let _ = process::set_child_subreaper(self.0);
}
}
}
+12 -7
View File
@@ -5,7 +5,7 @@ use std::process::Command;
use super::release::{Layout, MachineManager, Version, platform_contract};
const USAGE: &str = "Usage:\n dever install <version|latest>\n dever update\n dever target add linux-aarch64\n dever use <version>\n dever uninstall <version>\n dever version [project-root]\n dever cache status\n dever cache clean\n dever lib add|list|update|install|remove|doctor <project-root> [--target linux-x86_64|linux-aarch64] [spec ...]\n dever package add|list|update|remove|doctor <project-root> [spec ...]\n dever check <project-root>\n dever api <project-root> [--output <new-file>]\n dever fmt <project-root> [--check]\n dever test <project-root>\n dever run <project-root> [-- <component>.<domain>.<cmd> '<json-object>']\n dever build <project-root> --output <new-file> [--target linux-x86_64|linux-aarch64]\n dever clean <project-root>\n dever tenant ...";
const USAGE: &str = "Usage:\n dever install <version|latest>\n dever update\n dever target add linux-aarch64\n dever use <version>\n dever uninstall <version>\n dever version [project-root]\n dever cache status\n dever cache clean\n dever lib add|list|update|install|remove|doctor <project-root> [--target linux-x86_64|linux-aarch64] [spec ...]\n dever package add|list|update|remove|doctor <project-root> [spec ...]\n dever check <project-root>\n dever api <project-root> [--output <new-file>]\n dever fmt <project-root> [--check]\n dever test <project-root> [--filter <component[.domain[.case]]>] [--timeout-ms <positive-ms>]\n dever run <project-root> [-- <component>.<domain>.<cmd> '<json-object>']\n dever build <project-root> --output <new-file> [--target linux-x86_64|linux-aarch64]\n dever clean <project-root>\n dever tenant ...";
#[derive(Debug)]
pub enum CommandResult {
@@ -106,13 +106,18 @@ pub fn execute_in(layout: &Layout, arguments: &[OsString]) -> Result<CommandResu
manager.resolve_skill(&version)?.display().to_string(),
))
}
[skill, install, directory] if skill == "skill" && install == "install" => {
super::skill::install(&manager, Path::new(directory))?;
[skill, install] if skill == "skill" && install == "install" => {
let root = std::env::current_dir()
.map_err(|error| format!("cannot locate current project: {error}"))?;
super::skill::install(&manager, &root)?;
Ok(CommandResult::Message(format!(
"installed dever-language skill entry in '{}'",
Path::new(directory).display()
"updated Dever development rules in '{}'",
root.join("AGENTS.md").display()
)))
}
[skill, install, ..] if skill == "skill" && install == "install" => {
Err("run 'dever skill install' in the project root; it accepts no arguments".into())
}
[command, ..] if command == "new" => {
let version = manager
.active_version()?
@@ -120,7 +125,7 @@ pub fn execute_in(layout: &Layout, arguments: &[OsString]) -> Result<CommandResu
dispatch_core(&manager, &version, arguments)
}
[help] if help == "--help" => Ok(CommandResult::Message(format!(
"{USAGE}\n dever new <new-directory> [--markdown]\n dever skill path [project-root]\n dever skill install <new-directory>"
"{USAGE}\n dever new <new-directory> [--markdown]\n dever skill path [project-root]\n dever skill install"
))),
[] => Err(USAGE.into()),
_ => dispatch_project_command(&manager, arguments),
@@ -175,7 +180,7 @@ fn dispatch_core(
Ok(CommandResult::Exited(status.code().unwrap_or(1)))
}
fn select_version(manager: &MachineManager, project_root: &Path) -> Result<Version, String> {
pub(super) fn select_version(manager: &MachineManager, project_root: &Path) -> Result<Version, String> {
let version = match project_version(project_root)? {
Some(version) => version,
None => manager
+135 -66
View File
@@ -1,12 +1,15 @@
//! A stable AI entry resolves the same active-version journal as the launcher.
//! Project rules come from the same signed release as the project's compiler.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use super::release::MachineManager;
const START: &str = "<!-- dever-language:start -->";
const END: &str = "<!-- dever-language:end -->";
static NEXT_STAGING: AtomicU64 = AtomicU64::new(0);
pub(super) fn path(manager: &MachineManager) -> Result<PathBuf, String> {
@@ -17,75 +20,141 @@ pub(super) fn path(manager: &MachineManager) -> Result<PathBuf, String> {
manager.resolve_skill(&version)
}
pub(super) fn install(manager: &MachineManager, destination: &Path) -> Result<(), String> {
path(manager)?;
let launcher = manager.layout().bin().join("dever");
let launcher = launcher
.to_str()
.ok_or("launcher path must be UTF-8 for an AI skill")?;
if launcher.chars().any(char::is_control) || launcher.contains('`') {
return Err("launcher path cannot contain control characters or backticks".into());
pub(super) fn install(manager: &MachineManager, root: &Path) -> Result<(), String> {
let module = root.join("module");
let metadata = fs::symlink_metadata(&module)
.map_err(|error| format!("run 'dever skill install' in a project root with module/: {error}"))?;
if !metadata.is_dir() {
return Err("run 'dever skill install' in a project root with a real module/ directory".into());
}
let quoted = format!("'{}'", launcher.replace('\'', "'\\''"));
let content = format!(
"---\nname: dever-language\ndescription: Develop, review and debug Dever language applications (.dever and .dever.md), manage Dever Packages and Libs, and install or update Dever. Not the Go Dever framework.\n---\n\n# Dever language\n\nBefore every Dever task, run this read-only command:\n\n```sh\n{quoted} skill path\n```\n\nRead `SKILL.md` in the returned directory completely, then follow its instructions. Resolve its reference and asset paths against that directory. This entry follows the machine's active Dever version, so `dever update` and `dever use` update the compiler and development guidance together. If resolution fails, report the error; do not use stale guidance or infer language syntax. This entry grants no permission to install, update or change a project.\n"
);
let name = destination
.file_name()
.ok_or("skill destination requires a new directory name")?;
let parent = destination
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let parent = fs::canonicalize(parent)
.map_err(|error| format!("cannot resolve skill destination parent: {error}"))?;
let destination = parent.join(name);
let staging = stage(&parent)?;
let entry = staging.join("SKILL.md");
let result = (|| {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&entry)
.map_err(|error| format!("cannot create skill entry: {error}"))?;
file.write_all(content.as_bytes())
.and_then(|()| file.sync_all())
.map_err(|error| format!("cannot persist skill entry: {error}"))?;
#[cfg(target_os = "linux")]
{
use rustix::fs::{CWD, RenameFlags, renameat_with};
renameat_with(CWD, &staging, CWD, &destination, RenameFlags::NOREPLACE)
.map_err(|error| format!("cannot publish new skill directory: {error}"))
}
#[cfg(not(target_os = "linux"))]
{
Err("skill installation is not available for this platform".into())
}
})();
if result.is_err() {
let _ = fs::remove_file(&entry);
let _ = fs::remove_dir(&staging);
let version = super::launcher::select_version(manager, root)?;
let resources = super::SignedResources::load(manager.layout(), &version)?;
resources.read("skills/dever-language/SKILL.md")?;
let template = String::from_utf8(resources.read("skills/dever-language/assets/template/AGENTS.md")?)
.map_err(|error| format!("signed AGENTS.md template must be UTF-8: {error}"))?;
let block = managed_block(&template)?.ok_or("signed AGENTS.md template has no Dever rules block")?;
if !template[..block.start].trim().is_empty() || !template[block.end..].trim().is_empty() {
return Err("signed AGENTS.md template must contain only its Dever rules block".into());
}
result
let destination = root.join("AGENTS.md");
let original = read_project_rules(&destination)?;
let current = original
.as_ref()
.map(|(text, _)| text.as_str())
.unwrap_or("");
let content = match managed_block(current)? {
Some(range) => format!(
"{}{}{}",
&current[..range.start],
&template[block],
&current[range.end..]
),
None if current.is_empty() => template,
None => {
let separator = if current.ends_with('\n') { "\n" } else { "\n\n" };
format!("{current}{separator}{template}")
}
};
if content == current {
return Ok(());
}
let staging = Staging::create(root)?;
let entry = staging.0.join("AGENTS.md");
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&entry)
.map_err(|error| format!("cannot stage project rules: {error}"))?;
file.write_all(content.as_bytes())
.map_err(|error| format!("cannot write project rules: {error}"))?;
if let Some((_, permissions)) = &original {
file.set_permissions(permissions.clone())
.map_err(|error| format!("cannot preserve AGENTS.md permissions: {error}"))?;
}
file.sync_all()
.map_err(|error| format!("cannot persist project rules: {error}"))?;
// As with source formatting, an intervening edit invalidates the prepared replacement.
if read_project_rules(&destination)? != original {
return Err("AGENTS.md changed during skill installation; run the command again".into());
}
publish(&entry, &destination, original.is_some())
}
fn stage(parent: &Path) -> Result<PathBuf, String> {
loop {
let path = parent.join(format!(
".dever-skill-{}-{}",
std::process::id(),
NEXT_STAGING.fetch_add(1, Ordering::Relaxed)
));
let mut builder = fs::DirBuilder::new();
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
match builder.create(&path) {
Ok(()) => return Ok(path),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(format!("cannot stage skill entry: {error}")),
fn read_project_rules(path: &Path) -> Result<Option<(String, fs::Permissions)>, String> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(format!("cannot inspect AGENTS.md: {error}")),
};
if !metadata.is_file() {
return Err("AGENTS.md must be a regular file, not a symlink or directory".into());
}
if metadata.permissions().readonly() {
return Err("AGENTS.md is read-only".into());
}
let text = fs::read_to_string(path)
.map_err(|error| format!("cannot read AGENTS.md as UTF-8: {error}"))?;
Ok(Some((text, metadata.permissions())))
}
fn managed_block(text: &str) -> Result<Option<Range<usize>>, String> {
let starts: Vec<_> = text.match_indices(START).map(|(offset, _)| offset).collect();
let ends: Vec<_> = text.match_indices(END).map(|(offset, _)| offset).collect();
match (starts.as_slice(), ends.as_slice()) {
([], []) => Ok(None),
([start], [end]) if start < end => Ok(Some(*start..end + END.len())),
_ => Err("AGENTS.md must have exactly one complete Dever rules block or no block".into()),
}
}
fn publish(entry: &Path, destination: &Path, replace: bool) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
use rustix::fs::{CWD, RenameFlags, renameat_with};
let flags = if replace {
RenameFlags::empty()
} else {
RenameFlags::NOREPLACE
};
renameat_with(CWD, entry, CWD, destination, flags)
.map_err(|error| format!("cannot publish AGENTS.md: {error}"))
}
#[cfg(not(target_os = "linux"))]
{
let _ = (entry, destination, replace);
Err("skill installation is not available for this platform".into())
}
}
struct Staging(PathBuf);
impl Staging {
fn create(parent: &Path) -> Result<Self, String> {
loop {
let path = parent.join(format!(
".dever-skill-{}-{}",
std::process::id(),
NEXT_STAGING.fetch_add(1, Ordering::Relaxed)
));
let mut builder = fs::DirBuilder::new();
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
match builder.create(&path) {
Ok(()) => return Ok(Self(path)),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(format!("cannot stage project rules: {error}")),
}
}
}
}
impl Drop for Staging {
fn drop(&mut self) {
let _ = fs::remove_file(self.0.join("AGENTS.md"));
let _ = fs::remove_dir(&self.0);
}
}