Skip to main content

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}