keel_cli/agents_cli.rs
1//! Detection for Google's `agents-cli` project layout.
2//!
3//! `agents-cli scaffold create` writes an `agents-cli-manifest.yaml` at the
4//! project root naming the agent's own subdirectory (`agent_directory: <dir>`),
5//! and generates a `Dockerfile` that `COPY`s only `pyproject.toml`, `README.md`,
6//! `uv.lock*`, and that one agent directory into the image — a `keel.toml`
7//! sitting at the project root next to the manifest is never in that COPY set,
8//! so it silently never reaches the container. `keel init` and `keel doctor`
9//! both need to recognize this layout: init writes the generated policy
10//! straight into the agent directory instead, and doctor warns when it finds a
11//! root `keel.toml` that would be left behind.
12//!
13//! The manifest is a full YAML document, but we need exactly one scalar key out
14//! of it. Pulling in a YAML parser for that would be a real dependency (and a
15//! supply-chain surface) for one line of text, so this is a deliberate hand
16//! parse of the single `agent_directory:` key — not a general YAML reader. It
17//! tolerates the two forms `agents-cli` itself emits (bare and quoted scalars)
18//! and gives up (returns `None`) on anything else, which just means Keel falls
19//! back to treating the project as a non-agents-cli layout.
20
21use std::path::{Path, PathBuf};
22
23/// The manifest `agents-cli scaffold create` writes at the project root.
24const MANIFEST_FILENAME: &str = "agents-cli-manifest.yaml";
25
26/// Bound on how many parent directories [`find_agents_cli_layout`] will walk
27/// before giving up — a defensive limit against pathological filesystems
28/// (symlink cycles, an unexpectedly deep tree), not a realistic project depth.
29const MAX_WALK_LEVELS: usize = 8;
30
31/// The agents-cli layout facts relevant to Keel: where the manifest lives, and
32/// the agent's own subdirectory (the only directory the generated Dockerfile
33/// ships).
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AgentsCliLayout {
36 /// The directory containing `agents-cli-manifest.yaml`.
37 pub manifest_dir: PathBuf,
38 /// `<manifest_dir>/<agent_directory>`, resolved from the manifest's
39 /// `agent_directory` key. Guaranteed to exist on disk (as a directory) by
40 /// construction — [`find_agents_cli_layout`] returns `None` otherwise.
41 pub agent_dir: PathBuf,
42}
43
44/// Walk upward from `project` (inclusive) looking for `agents-cli-manifest.yaml`,
45/// bounded to [`MAX_WALK_LEVELS`] and stopping at the filesystem root. On the
46/// first manifest found, hand-parse its `agent_directory` key; return `None` if
47/// the key is missing or the directory it names does not exist.
48#[must_use]
49pub fn find_agents_cli_layout(project: &Path) -> Option<AgentsCliLayout> {
50 let mut dir = project;
51 for _ in 0..=MAX_WALK_LEVELS {
52 let candidate = dir.join(MANIFEST_FILENAME);
53 if candidate.is_file() {
54 let text = std::fs::read_to_string(&candidate).ok()?;
55 let agent_directory = parse_agent_directory(&text)?;
56 // A legitimate agents-cli manifest only ever names the agent's own
57 // subdirectory, so a `..` component is either a corrupted manifest
58 // or an attempt to walk `keel init`/`keel doctor` outside the
59 // project via `Path::join` — reject it here rather than let it
60 // through to be checked (syntactically, and therefore
61 // unreliably — see `init::agents_cli_toml_path`) against
62 // `project` later. This is layer one of two; layer two
63 // canonicalizes both sides before ever writing a file.
64 if Path::new(&agent_directory)
65 .components()
66 .any(|c| matches!(c, std::path::Component::ParentDir))
67 {
68 return None;
69 }
70 let agent_dir = dir.join(agent_directory);
71 return agent_dir.is_dir().then(|| AgentsCliLayout {
72 manifest_dir: dir.to_owned(),
73 agent_dir,
74 });
75 }
76 dir = dir.parent()?;
77 }
78 None
79}
80
81/// Hand-parse the `agent_directory: <value>` line out of an
82/// `agents-cli-manifest.yaml` document: the first line matching
83/// `^agent_directory:\s*(.+)$`, then [`extract_scalar`] pulls the actual value
84/// out of the remainder. `None` when no such line exists, or its value is
85/// empty, or an opened quote is never closed. Deliberately not a YAML parser —
86/// see the module docs.
87fn parse_agent_directory(text: &str) -> Option<String> {
88 for line in text.lines() {
89 let Some(rest) = line.strip_prefix("agent_directory:") else {
90 // Not the key on this line — keep scanning; unlike `?`, this must
91 // NOT abort the whole parse on the first non-matching line (e.g.
92 // a `schema_version:` line preceding `agent_directory:`).
93 continue;
94 };
95 let rest = rest.trim_start();
96 if rest.is_empty() {
97 return None;
98 }
99 let value = extract_scalar(rest)?;
100 return (!value.is_empty()).then_some(value);
101 }
102 None
103}
104
105/// Extract the manifest scalar value starting at `rest` (already trimmed of
106/// leading whitespace, but not trailing). Two forms, matching what
107/// `agents-cli scaffold create` itself emits:
108///
109/// - **Quoted** (`'...'` / `"..."`): captures everything up to the matching
110/// closing quote, spaces included, and discards anything after it (e.g. a
111/// trailing `# comment`). `None` if the opening quote is never closed.
112/// - **Unquoted**: captures only the first whitespace-delimited token, so a
113/// trailing inline comment (`agent_directory: app # ships in prod`) is
114/// discarded rather than folded into the value.
115fn extract_scalar(rest: &str) -> Option<String> {
116 for quote in ['\'', '"'] {
117 if let Some(inner) = rest.strip_prefix(quote) {
118 return inner.find(quote).map(|end| inner[..end].to_owned());
119 }
120 }
121 rest.split_whitespace().next().map(str::to_owned)
122}
123
124#[cfg(test)]
125mod tests {
126 use tempfile::TempDir;
127
128 use super::*;
129
130 fn write_manifest(dir: &Path, body: &str) {
131 std::fs::write(dir.join(MANIFEST_FILENAME), body).unwrap();
132 }
133
134 #[test]
135 fn found_at_project_root() {
136 let dir = TempDir::new().unwrap();
137 std::fs::create_dir(dir.path().join("app")).unwrap();
138 write_manifest(dir.path(), "schema_version: 1\nagent_directory: app\n");
139
140 let layout = find_agents_cli_layout(dir.path()).expect("layout found");
141 assert_eq!(layout.manifest_dir, dir.path());
142 assert_eq!(layout.agent_dir, dir.path().join("app"));
143 }
144
145 #[test]
146 fn found_by_walking_upward_from_a_nested_directory() {
147 let dir = TempDir::new().unwrap();
148 std::fs::create_dir(dir.path().join("app")).unwrap();
149 write_manifest(dir.path(), "agent_directory: app\n");
150 let nested = dir.path().join("app").join("sub").join("deeper");
151 std::fs::create_dir_all(&nested).unwrap();
152
153 let layout = find_agents_cli_layout(&nested).expect("layout found by walking up");
154 assert_eq!(layout.manifest_dir, dir.path());
155 assert_eq!(layout.agent_dir, dir.path().join("app"));
156 }
157
158 #[test]
159 fn not_found_when_no_manifest_exists() {
160 let dir = TempDir::new().unwrap();
161 assert!(find_agents_cli_layout(dir.path()).is_none());
162 }
163
164 #[test]
165 fn quoted_value_is_unquoted() {
166 let dir = TempDir::new().unwrap();
167 std::fs::create_dir(dir.path().join("my_agent")).unwrap();
168 write_manifest(dir.path(), "agent_directory: \"my_agent\"\n");
169
170 let layout = find_agents_cli_layout(dir.path()).expect("layout found");
171 assert_eq!(layout.agent_dir, dir.path().join("my_agent"));
172 }
173
174 #[test]
175 fn single_quoted_value_is_unquoted() {
176 let dir = TempDir::new().unwrap();
177 std::fs::create_dir(dir.path().join("my_agent")).unwrap();
178 write_manifest(dir.path(), "agent_directory: 'my_agent'\n");
179
180 let layout = find_agents_cli_layout(dir.path()).expect("layout found");
181 assert_eq!(layout.agent_dir, dir.path().join("my_agent"));
182 }
183
184 #[test]
185 fn missing_key_yields_none() {
186 let dir = TempDir::new().unwrap();
187 write_manifest(dir.path(), "schema_version: 1\n");
188 assert!(find_agents_cli_layout(dir.path()).is_none());
189 }
190
191 #[test]
192 fn nonexistent_agent_dir_yields_none() {
193 let dir = TempDir::new().unwrap();
194 write_manifest(dir.path(), "agent_directory: does_not_exist\n");
195 assert!(find_agents_cli_layout(dir.path()).is_none());
196 }
197
198 /// A manifest sitting exactly `MAX_WALK_LEVELS` parents above the starting
199 /// directory is still within the bound (the walk checks the starting
200 /// directory itself, then `MAX_WALK_LEVELS` ancestors above it) and must
201 /// be found.
202 #[test]
203 fn manifest_within_the_walk_bound_is_found() {
204 let dir = TempDir::new().unwrap();
205 std::fs::create_dir(dir.path().join("app")).unwrap();
206 write_manifest(dir.path(), "agent_directory: app\n");
207 let mut nested = dir.path().to_owned();
208 for i in 0..MAX_WALK_LEVELS {
209 nested = nested.join(format!("d{i}"));
210 }
211 std::fs::create_dir_all(&nested).unwrap();
212
213 let layout =
214 find_agents_cli_layout(&nested).expect("manifest exactly at the bound is found");
215 assert_eq!(layout.manifest_dir, dir.path());
216 }
217
218 /// A manifest sitting one level *beyond* `MAX_WALK_LEVELS` parents above
219 /// the starting directory must not be found — the walk gives up first.
220 #[test]
221 fn manifest_beyond_the_walk_bound_is_not_found() {
222 let dir = TempDir::new().unwrap();
223 std::fs::create_dir(dir.path().join("app")).unwrap();
224 write_manifest(dir.path(), "agent_directory: app\n");
225 let mut nested = dir.path().to_owned();
226 for i in 0..=MAX_WALK_LEVELS {
227 nested = nested.join(format!("d{i}"));
228 }
229 std::fs::create_dir_all(&nested).unwrap();
230
231 assert!(find_agents_cli_layout(&nested).is_none());
232 }
233
234 #[test]
235 fn inline_comment_after_an_unquoted_value_is_discarded() {
236 let dir = TempDir::new().unwrap();
237 std::fs::create_dir(dir.path().join("app")).unwrap();
238 write_manifest(dir.path(), "agent_directory: app # ships in prod\n");
239
240 let layout = find_agents_cli_layout(dir.path()).expect("layout found");
241 assert_eq!(layout.agent_dir, dir.path().join("app"));
242 }
243
244 /// Documented rule (see `extract_scalar`): a quoted value captures up to
245 /// its closing quote, spaces included — unlike the unquoted form, which
246 /// stops at the first whitespace.
247 #[test]
248 fn quoted_value_may_contain_spaces() {
249 let dir = TempDir::new().unwrap();
250 std::fs::create_dir(dir.path().join("my app")).unwrap();
251 write_manifest(dir.path(), "agent_directory: \"my app\"\n");
252
253 let layout = find_agents_cli_layout(dir.path()).expect("layout found");
254 assert_eq!(layout.agent_dir, dir.path().join("my app"));
255 }
256
257 /// CRITICAL regression: `agent_directory: ../elsewhere` must never escape
258 /// the project, even though the sibling directory it names genuinely
259 /// exists on disk. `Path::starts_with` is purely component-syntactic, so
260 /// without the `..`-rejection layer `<project>/../elsewhere` would
261 /// lexically "start with" `<project>` despite resolving outside it.
262 #[test]
263 fn parent_dir_component_in_agent_directory_yields_none() {
264 // `elsewhere` and `project` are real *siblings* on disk — both live
265 // under the same TempDir root so the fixture is self-contained and
266 // gets cleaned up, but `elsewhere` is genuinely outside `project`.
267 let root = TempDir::new().unwrap();
268 let project = root.path().join("project");
269 std::fs::create_dir(&project).unwrap();
270 std::fs::create_dir(root.path().join("elsewhere")).unwrap();
271 write_manifest(&project, "agent_directory: ../elsewhere\n");
272
273 assert!(find_agents_cli_layout(&project).is_none());
274 }
275}