11use std:: fs;
2- use std:: os:: unix:: fs:: PermissionsExt ;
2+ use std:: os:: unix:: fs:: { PermissionsExt , symlink } ;
33use std:: path:: { Path , PathBuf } ;
44use std:: process:: Command ;
55
@@ -10,6 +10,12 @@ fn executable(path: &Path, body: &str) {
1010
1111fn clean_path ( ) -> tempfile:: TempDir {
1212 let bin = tempfile:: tempdir ( ) . unwrap ( ) ;
13+ symlink ( env ! ( "CARGO_BIN_EXE_st2" ) , bin. path ( ) . join ( "st2" ) ) . unwrap ( ) ;
14+ let git = std:: env:: split_paths ( & std:: env:: var_os ( "PATH" ) . unwrap ( ) )
15+ . map ( |dir| dir. join ( "git" ) )
16+ . find ( |path| path. is_file ( ) )
17+ . expect ( "the native authoring guide requires git on PATH" ) ;
18+ symlink ( git, bin. path ( ) . join ( "git" ) ) . unwrap ( ) ;
1319 executable (
1420 & bin. path ( ) . join ( "pty" ) ,
1521 "#!/bin/sh\n if [ \" $1\" = \" list\" ]; then printf '[]\\ n'; fi\n exit 0\n " ,
@@ -19,6 +25,19 @@ fn clean_path() -> tempfile::TempDir {
1925 bin
2026}
2127
28+ fn clean_st2 ( bin : & Path , state : & Path , hooks : & Path ) -> Command {
29+ let mut command = Command :: new ( bin. join ( "st2" ) ) ;
30+ command
31+ . env ( "PATH" , bin)
32+ . env ( "XDG_STATE_HOME" , state)
33+ . env ( "ST_HOOKS" , hooks)
34+ . env_remove ( "CATALOG" )
35+ . env_remove ( "ST_ROOT" )
36+ . env_remove ( "PTY_ROOT" )
37+ . env_remove ( "ST_AGENT" ) ;
38+ command
39+ }
40+
2241fn native_catalog ( root : & Path , workspace : & Path ) {
2342 let declaration = root. join ( "agents/h/worker/agent.kdl" ) ;
2443 fs:: create_dir_all ( declaration. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
@@ -33,6 +52,193 @@ fn native_catalog(root: &Path, workspace: &Path) {
3352 . unwrap ( ) ;
3453}
3554
55+ #[ test]
56+ fn clean_path_executes_the_maintained_native_authoring_guide ( ) {
57+ let bin = clean_path ( ) ;
58+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
59+ let state = tmp. path ( ) . join ( "state" ) ;
60+ let hooks = tmp. path ( ) . join ( "hooks" ) ;
61+ let catalog = tmp. path ( ) . join ( "catalog" ) ;
62+ let codex_workspace = tmp. path ( ) . join ( "codex-workspace" ) ;
63+ let claude_workspace = tmp. path ( ) . join ( "claude-workspace" ) ;
64+ fs:: create_dir_all ( catalog. join ( "agents/clean/codex" ) ) . unwrap ( ) ;
65+ fs:: create_dir_all ( catalog. join ( "agents/clean/claude" ) ) . unwrap ( ) ;
66+ fs:: create_dir_all ( catalog. join ( "_templates" ) ) . unwrap ( ) ;
67+ fs:: create_dir_all ( & codex_workspace) . unwrap ( ) ;
68+ fs:: create_dir_all ( & claude_workspace) . unwrap ( ) ;
69+
70+ let manifest = Path :: new ( env ! ( "CARGO_MANIFEST_DIR" ) ) ;
71+ let codex = fs:: read_to_string ( manifest. join ( "examples/native/agent-codex.kdl" ) )
72+ . unwrap ( )
73+ . replace ( "<identity>" , "codex" )
74+ . replace ( "<host>" , "clean" )
75+ . replace ( "<workspace>" , & codex_workspace. display ( ) . to_string ( ) )
76+ . replace ( "<boot prompt>" , "boot" ) ;
77+ let claude = fs:: read_to_string ( manifest. join ( "examples/native/agent-claude.kdl" ) )
78+ . unwrap ( )
79+ . replace ( "<identity>" , "claude" )
80+ . replace ( "<host>" , "clean" )
81+ . replace ( "<workspace>" , & claude_workspace. display ( ) . to_string ( ) )
82+ . replace ( "<boot prompt>" , "boot" ) ;
83+ fs:: write (
84+ catalog. join ( "agents/clean/codex/agent.kdl" ) ,
85+ codex. as_bytes ( ) ,
86+ )
87+ . unwrap ( ) ;
88+ fs:: write (
89+ catalog. join ( "agents/clean/claude/agent.kdl" ) ,
90+ claude. as_bytes ( ) ,
91+ )
92+ . unwrap ( ) ;
93+ fs:: write (
94+ catalog. join ( "_templates/clean.codex.AGENTS.md" ) ,
95+ "# Clean Codex agent\n " ,
96+ )
97+ . unwrap ( ) ;
98+ fs:: write (
99+ catalog. join ( "_templates/clean.claude.persona.md" ) ,
100+ "# Clean Claude persona\n " ,
101+ )
102+ . unwrap ( ) ;
103+ fs:: copy (
104+ manifest. join ( "templates/bus.st2.md" ) ,
105+ catalog. join ( "_templates/bus.st2.md" ) ,
106+ )
107+ . unwrap ( ) ;
108+
109+ for workspace in [ & codex_workspace, & claude_workspace] {
110+ let git = Command :: new ( bin. path ( ) . join ( "git" ) )
111+ . args ( [ "init" , "-q" ] )
112+ . arg ( workspace)
113+ . env ( "PATH" , bin. path ( ) )
114+ . status ( )
115+ . unwrap ( ) ;
116+ assert ! ( git. success( ) ) ;
117+ }
118+
119+ let install = clean_st2 ( bin. path ( ) , & state, & hooks)
120+ . args ( [ "hooks" , "install" ] )
121+ . output ( )
122+ . unwrap ( ) ;
123+ assert ! (
124+ install. status. success( ) ,
125+ "{}" ,
126+ String :: from_utf8_lossy( & install. stderr)
127+ ) ;
128+ let verify = clean_st2 ( bin. path ( ) , & state, & hooks)
129+ . args ( [ "hooks" , "verify" ] )
130+ . output ( )
131+ . unwrap ( ) ;
132+ assert ! (
133+ verify. status. success( ) ,
134+ "{}" ,
135+ String :: from_utf8_lossy( & verify. stderr)
136+ ) ;
137+
138+ let validate = clean_st2 ( bin. path ( ) , & state, & hooks)
139+ . arg ( "validate" )
140+ . arg ( "--catalog" )
141+ . arg ( & catalog)
142+ . output ( )
143+ . unwrap ( ) ;
144+ assert ! (
145+ validate. status. success( ) ,
146+ "stdout:\n {}\n stderr:\n {}" ,
147+ String :: from_utf8_lossy( & validate. stdout) ,
148+ String :: from_utf8_lossy( & validate. stderr)
149+ ) ;
150+
151+ let materialize = || {
152+ clean_st2 ( bin. path ( ) , & state, & hooks)
153+ . arg ( "up" )
154+ . arg ( "--catalog" )
155+ . arg ( & catalog)
156+ . args ( [ "--host" , "clean" , "--materialize-only" ] )
157+ . output ( )
158+ . unwrap ( )
159+ } ;
160+ let first = materialize ( ) ;
161+ assert ! (
162+ first. status. success( ) ,
163+ "stdout:\n {}\n stderr:\n {}" ,
164+ String :: from_utf8_lossy( & first. stdout) ,
165+ String :: from_utf8_lossy( & first. stderr)
166+ ) ;
167+ let codex_settings = fs:: read ( codex_workspace. join ( ".codex/hooks.json" ) ) . unwrap ( ) ;
168+ let claude_settings = fs:: read ( claude_workspace. join ( ".claude/settings.local.json" ) ) . unwrap ( ) ;
169+ let second = materialize ( ) ;
170+ assert ! (
171+ second. status. success( ) ,
172+ "stdout:\n {}\n stderr:\n {}" ,
173+ String :: from_utf8_lossy( & second. stdout) ,
174+ String :: from_utf8_lossy( & second. stderr)
175+ ) ;
176+ assert_eq ! (
177+ fs:: read( codex_workspace. join( ".codex/hooks.json" ) ) . unwrap( ) ,
178+ codex_settings
179+ ) ;
180+ assert_eq ! (
181+ fs:: read( claude_workspace. join( ".claude/settings.local.json" ) ) . unwrap( ) ,
182+ claude_settings
183+ ) ;
184+
185+ let receipt: serde_json:: Value =
186+ serde_json:: from_slice ( & fs:: read ( hooks. join ( "current.json" ) ) . unwrap ( ) ) . unwrap ( ) ;
187+ let selected = hooks. join ( receipt[ "directory" ] . as_str ( ) . unwrap ( ) ) ;
188+ for ( settings, names) in [
189+ (
190+ serde_json:: from_slice :: < serde_json:: Value > ( & codex_settings) . unwrap ( ) ,
191+ vec ! [
192+ "codex-session-start.sh" ,
193+ "codex-pre-compact.sh" ,
194+ "codex-stop.sh" ,
195+ ] ,
196+ ) ,
197+ (
198+ serde_json:: from_slice :: < serde_json:: Value > ( & claude_settings) . unwrap ( ) ,
199+ vec ! [
200+ "claude-session-start.sh" ,
201+ "claude-pre-compact.sh" ,
202+ "claude-stop-failure.sh" ,
203+ ] ,
204+ ) ,
205+ ] {
206+ let encoded = settings. to_string ( ) ;
207+ for name in names {
208+ assert ! (
209+ encoded. contains( & selected. join( name) . display( ) . to_string( ) ) ,
210+ "{name} did not resolve into {}:\n {encoded}" ,
211+ selected. display( )
212+ ) ;
213+ }
214+ }
215+
216+ assert_eq ! (
217+ fs:: read_to_string( codex_workspace. join( "AGENTS.md" ) ) . unwrap( ) ,
218+ "# Clean Codex agent\n "
219+ ) ;
220+ assert_eq ! (
221+ fs:: read_to_string( claude_workspace. join( ".st2/PERSONA.md" ) ) . unwrap( ) ,
222+ "# Clean Claude persona\n "
223+ ) ;
224+ for workspace in [ & codex_workspace, & claude_workspace] {
225+ let status = Command :: new ( bin. path ( ) . join ( "git" ) )
226+ . arg ( "-C" )
227+ . arg ( workspace)
228+ . args ( [ "status" , "--porcelain" ] )
229+ . env ( "PATH" , bin. path ( ) )
230+ . output ( )
231+ . unwrap ( ) ;
232+ assert ! ( status. status. success( ) ) ;
233+ assert ! (
234+ status. stdout. is_empty( ) ,
235+ "{} was left dirty:\n {}" ,
236+ workspace. display( ) ,
237+ String :: from_utf8_lossy( & status. stdout)
238+ ) ;
239+ }
240+ }
241+
36242#[ test]
37243fn clean_path_supports_help_validate_env_and_doctor ( ) {
38244 let bin = clean_path ( ) ;
@@ -53,7 +259,14 @@ fn clean_path_supports_help_validate_env_and_doctor() {
53259 . unwrap ( ) ;
54260 assert ! ( help. status. success( ) ) ;
55261 let help = String :: from_utf8_lossy ( & help. stdout ) ;
56- for command in [ "validate" , "message" , "ding" , "hooks" , "compile-agent" ] {
262+ for command in [
263+ "validate" ,
264+ "message" ,
265+ "ding" ,
266+ "hooks" ,
267+ "compile-agent" ,
268+ "completions" ,
269+ ] {
57270 assert ! ( help. contains( command) , "missing {command} in help:\n {help}" ) ;
58271 }
59272 for removed in [
0 commit comments