mind-cli 0.25.0

A manager for agent tooling (skills, agents, rules, tools) that melds arbitrary git repos and links items into your agent directories.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
//! Unmanaged lobe items: skills/agents/rules present in a configured agent home
//! that `mind` did not install (spec/unmanaged.md). They are surfaced read-only
//! by `recall` and `probe`, and removable via `forget` with a distinct warning.

use std::collections::BTreeMap;
use std::path::PathBuf;

use crate::error::{ItemKind, MindError, Result};
use crate::manifest::Manifest;
use crate::paths::Paths;
use crate::resolve::ItemRef;
use crate::sanitize::ItemKey;

/// A skill/agent/rule present in an agent home that `mind` did not install.
#[derive(Debug, Clone)]
pub struct UnmanagedItem {
    pub kind: ItemKind,
    /// The on-disk entry name: a skill directory name, or an agent/rule file
    /// stem (the `.md` suffix stripped).
    pub name: String,
    /// The lobe path(s) occupying this item, sorted, one per agent home.
    pub paths: Vec<PathBuf>,
}

impl UnmanagedItem {
    /// `kind:name`, matching the manifest key form so refs resolve uniformly.
    ///
    /// Returns an [`ItemKey`] (DSC-95): the name is a lobe filesystem
    /// component and can carry ANSI/control/bidi code points, so the raw and
    /// display readings are distinct types. `.as_str()` for identity (ref
    /// resolution); `.display()` at every human/`--json` print site --
    /// `ItemKey` has no `Display`, so passing it straight to
    /// `println!`/`format!` is a compile error.
    pub fn key(&self) -> ItemKey {
        ItemKey::new(format!("{}:{}", self.kind.as_str(), self.name))
    }

    /// Convenience for [`ItemKey::display`] on `self.key()` (DSC-95).
    pub fn display_key(&self) -> String {
        self.key().display()
    }
}

/// Scan every configured agent home for unmanaged items (UNM-1): kind-dir entries
/// whose path is not a managed link recorded in the manifest. Deduplicated by
/// `(kind, name)` across lobes, each recording the lobe paths it occupies, sorted
/// by `(kind, name)`.
pub fn scan(paths: &Paths, manifest: &Manifest) -> Result<Vec<UnmanagedItem>> {
    // Every managed link path, for the "is this mind's own link?" test. Install
    // records links via the same `agent_homes` paths we walk here (STO-21), so a
    // direct path comparison matches.
    let managed: std::collections::HashSet<PathBuf> = manifest
        .items
        .values()
        .flat_map(|it| it.links.iter())
        .map(PathBuf::from)
        .collect();

    let mut found: BTreeMap<(ItemKind, String), Vec<PathBuf>> = BTreeMap::new();
    for lobe in paths.agent_homes()? {
        // Unmanaged detection is kind-agnostic: scan every lobe path regardless
        // of its `kinds` filter (a filtered lobe can still hold a hand-placed
        // item of an excluded kind).
        let home = &lobe.path;
        // Tools are never linked into an agent home (tooling.md TOOL-3), so only
        // the linkable kinds are scanned.
        for kind in ItemKind::LINKABLE {
            // A missing kind dir simply has no items.
            let Ok(rd) = std::fs::read_dir(home.join(kind.dir())) else {
                continue;
            };
            for entry in rd.flatten() {
                let path = entry.path();
                if managed.contains(&path) {
                    continue; // mind's own link
                }
                let Some(name) = item_name(kind, &entry) else {
                    continue;
                };
                found.entry((kind, name)).or_default().push(path);
            }
        }
    }

    Ok(found
        .into_iter()
        .map(|((kind, name), mut paths)| {
            paths.sort();
            UnmanagedItem { kind, name, paths }
        })
        .collect())
}

/// The item name for a kind-dir entry, or `None` when the entry is not a
/// well-formed item of that kind. A skill is the directory `skills/<name>`; an
/// agent/rule is the file `<name>.md`.
fn item_name(kind: ItemKind, entry: &std::fs::DirEntry) -> Option<String> {
    let raw = entry.file_name();
    let name = raw.to_str()?;
    match kind {
        ItemKind::Skill => Some(name.to_string()),
        ItemKind::Agent | ItemKind::Rule => name.strip_suffix(".md").map(str::to_string),
        ItemKind::Tool => None,
    }
}

/// Select every unmanaged item matching the optional ref `r` (UNM-7).
///
/// - `None`  -> all items (the no-ref "remove everything" form).
/// - `Some(r)` with a source qualifier -> empty (unmanaged items have no source).
/// - `Some(r)` with a glob name -> every item whose name matches the pattern,
///   filtered by `r.kind` when given.
/// - `Some(r)` with an exact name -> items whose name equals `r.name`, filtered
///   by `r.kind`.
///
/// Managed items can never appear here because `scan` already excludes them.
// spec: UNM-7
pub fn select<'a>(items: &'a [UnmanagedItem], r: Option<&ItemRef>) -> Vec<&'a UnmanagedItem> {
    let Some(r) = r else {
        return items.iter().collect();
    };
    if r.source.is_some() {
        return vec![];
    }
    if crate::resolve::is_glob(&r.name) {
        let pattern = match glob::Pattern::new(&r.name) {
            Ok(p) => p,
            Err(_) => return vec![],
        };
        items
            .iter()
            .filter(|it| r.kind.is_none_or(|k| it.kind == k) && pattern.matches(&it.name))
            .collect()
    } else {
        items
            .iter()
            .filter(|it| r.kind.is_none_or(|k| it.kind == k) && it.name == r.name)
            .collect()
    }
}

/// Find the single unmanaged item matching `r` (UNM-4). A source-qualified ref
/// never matches (unmanaged items have no source). Errors `NotInstalled` on no
/// match and `AmbiguousItem` on more than one (a bare name shared across kinds).
pub fn resolve<'a>(items: &'a [UnmanagedItem], r: &ItemRef) -> Result<&'a UnmanagedItem> {
    if r.source.is_some() {
        return Err(MindError::NotInstalled {
            name: r.name.clone(),
        });
    }
    let matches: Vec<&UnmanagedItem> = items
        .iter()
        .filter(|it| it.name == r.name && r.kind.is_none_or(|k| it.kind == k))
        .collect();
    match matches.as_slice() {
        [] => Err(MindError::NotInstalled {
            name: r.name.clone(),
        }),
        [only] => Ok(only),
        many => Err(MindError::AmbiguousItem {
            query: r.name.clone(),
            // spec: DSC-95 -- `MindError::AmbiguousItem`'s `#[error(...)]`
            // Display joins `candidates` verbatim, with no sanitizing step of
            // its own; a lobe filesystem entry name is not restricted against
            // ANSI/control/bidi code points the way a catalog item name is.
            candidates: many.iter().map(|it| it.key().display()).collect(),
        }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::resolve::parse_item_ref;

    fn make_items() -> Vec<UnmanagedItem> {
        vec![
            UnmanagedItem {
                kind: ItemKind::Skill,
                name: "review".to_string(),
                paths: vec![],
            },
            UnmanagedItem {
                kind: ItemKind::Skill,
                name: "style".to_string(),
                paths: vec![],
            },
            UnmanagedItem {
                kind: ItemKind::Agent,
                name: "dev".to_string(),
                paths: vec![],
            },
        ]
    }

    /// select(None) returns all items.
    // spec: UNM-7
    #[test]
    fn select_none_returns_all() {
        let items = make_items();
        let result = select(&items, None);
        assert_eq!(result.len(), 3);
    }

    /// select with a glob `*` matches all items.
    // spec: UNM-7
    #[test]
    fn select_glob_star_matches_all() {
        let items = make_items();
        let r = parse_item_ref("*").unwrap();
        let result = select(&items, Some(&r));
        assert_eq!(result.len(), 3);
    }

    /// select with a kind-qualified glob `skill:*` matches only skills.
    // spec: UNM-7
    #[test]
    fn select_kind_glob_filters_by_kind() {
        let items = make_items();
        let r = parse_item_ref("skill:*").unwrap();
        let result = select(&items, Some(&r));
        assert_eq!(result.len(), 2);
        assert!(result.iter().all(|it| it.kind == ItemKind::Skill));
    }

    /// select with an exact name matches only that item.
    // spec: UNM-7
    #[test]
    fn select_exact_name() {
        let items = make_items();
        let r = parse_item_ref("review").unwrap();
        let result = select(&items, Some(&r));
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].name, "review");
    }

    /// select with a kind-qualified exact name matches only that item.
    // spec: UNM-7
    #[test]
    fn select_kind_exact_name() {
        let items = make_items();
        let r = parse_item_ref("agent:dev").unwrap();
        let result = select(&items, Some(&r));
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].kind, ItemKind::Agent);
        assert_eq!(result[0].name, "dev");
    }

    /// select with a source-qualified ref always returns empty (unmanaged items
    /// have no source).
    // spec: UNM-7
    #[test]
    fn select_source_qualified_returns_empty() {
        let items = make_items();
        let r = parse_item_ref("owner/repo#skill:review").unwrap();
        let result = select(&items, Some(&r));
        assert!(result.is_empty());
    }

    /// select with a ref that matches nothing returns empty.
    // spec: UNM-7
    #[test]
    fn select_no_match_returns_empty() {
        let items = make_items();
        let r = parse_item_ref("nope").unwrap();
        let result = select(&items, Some(&r));
        assert!(result.is_empty());
    }

    /// A bare exact name shared across kinds matches EVERY kind with that name
    /// (the bulk `select` path treats it uniformly), unlike the single-item
    /// `resolve` path which errors `AmbiguousItem`. Both are removed.
    // spec: UNM-7
    #[test]
    fn select_bare_name_matches_all_kinds() {
        let items = vec![
            UnmanagedItem {
                kind: ItemKind::Skill,
                name: "shared".to_string(),
                paths: vec![],
            },
            UnmanagedItem {
                kind: ItemKind::Agent,
                name: "shared".to_string(),
                paths: vec![],
            },
            UnmanagedItem {
                kind: ItemKind::Rule,
                name: "other".to_string(),
                paths: vec![],
            },
        ];
        let r = parse_item_ref("shared").unwrap();
        let result = select(&items, Some(&r));
        assert_eq!(result.len(), 2, "both `shared` items must match");
        assert!(result.iter().all(|it| it.name == "shared"));
    }

    /// A glob with a kind filter that matches no item of that kind returns empty
    /// even when the bare name pattern would match a different kind.
    // spec: UNM-7
    #[test]
    fn select_kind_glob_excludes_other_kinds() {
        let items = make_items(); // review/style skills, dev agent
        let r = parse_item_ref("agent:*e*").unwrap();
        let result = select(&items, Some(&r));
        // Only the agent `dev` matches `*e*` among agents.
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].kind, ItemKind::Agent);
        assert_eq!(result[0].name, "dev");
    }

    /// select with a glob that matches nothing returns empty.
    // spec: UNM-7
    #[test]
    fn select_glob_no_match_returns_empty() {
        let items = make_items();
        let r = parse_item_ref("nope*").unwrap();
        let result = select(&items, Some(&r));
        assert!(result.is_empty());
    }

    /// `key()` is the `kind:name` manifest form, and `item_name` strips `.md`
    /// only for agents/rules.
    /// spec: UNM-1
    #[test]
    fn key_and_name_forms() {
        let u = UnmanagedItem {
            kind: ItemKind::Agent,
            name: "dev".to_string(),
            paths: vec![],
        };
        assert_eq!(u.key(), "agent:dev");
        assert_eq!(
            UnmanagedItem {
                kind: ItemKind::Skill,
                name: "review".to_string(),
                paths: vec![]
            }
            .key(),
            "skill:review"
        );
    }

    /// `resolve`'s `AmbiguousItem` candidates must reach the caller already
    /// sanitized (DSC-95). This is the ONE unrestricted sanitize site in the
    /// codebase: an unmanaged item's `name` is a lobe filesystem entry read
    /// straight off disk, not gated by `is_safe_item_name` the way a catalog
    /// name is (DSC-96 only guards catalog names). So an ESC byte can
    /// actually reach `it.key().display()` here in practice, unlike most
    /// other sanitize call sites which are defense-in-depth against an input
    /// that is already restricted upstream. Reverting `key().display()` to
    /// `key().as_str().to_string()` at the `candidates` call site would still
    /// compile and would still pass every OTHER test in this module -- only
    /// this one exercises the raw ESC byte actually surviving to the
    /// `candidates` list.
    // spec: DSC-95
    #[test]
    fn resolve_candidates_are_sanitized_for_a_hostile_unmanaged_name() {
        let items = vec![
            UnmanagedItem {
                kind: ItemKind::Skill,
                name: "evil\x1b[31mname".to_string(),
                paths: vec![],
            },
            UnmanagedItem {
                kind: ItemKind::Agent,
                name: "evil\x1b[31mname".to_string(),
                paths: vec![],
            },
        ];
        let r = parse_item_ref("evil\x1b[31mname").unwrap();
        let err = resolve(&items, &r).unwrap_err();
        match err {
            MindError::AmbiguousItem { candidates, .. } => {
                assert!(
                    candidates.iter().all(|c| !c.contains('\x1b')),
                    "candidates must not carry a raw ESC byte: {candidates:?}"
                );
                assert!(
                    candidates.iter().any(|c| c.contains("evil")),
                    "candidates must still name the item: {candidates:?}"
                );
            }
            other => panic!("expected AmbiguousItem, got {other:?}"),
        }
    }

    /// resolve matches by name (kind-qualified disambiguates), rejects a
    /// source-qualified ref, and errors on ambiguity.
    /// spec: UNM-4
    #[test]
    fn resolve_matches_kind_and_rejects_source() {
        let items = vec![
            UnmanagedItem {
                kind: ItemKind::Skill,
                name: "x".to_string(),
                paths: vec![],
            },
            UnmanagedItem {
                kind: ItemKind::Agent,
                name: "x".to_string(),
                paths: vec![],
            },
        ];
        // A bare name shared across kinds is ambiguous.
        assert!(matches!(
            resolve(&items, &parse_item_ref("x").unwrap()),
            Err(MindError::AmbiguousItem { .. })
        ));
        // A kind prefix disambiguates.
        assert_eq!(
            resolve(&items, &parse_item_ref("agent:x").unwrap())
                .unwrap()
                .kind,
            ItemKind::Agent
        );
        // A source-qualified ref never matches an unmanaged item.
        assert!(matches!(
            resolve(&items, &parse_item_ref("owner/repo#skill:x").unwrap()),
            Err(MindError::NotInstalled { .. })
        ));
        // A miss is NotInstalled.
        assert!(matches!(
            resolve(&items, &parse_item_ref("nope").unwrap()),
            Err(MindError::NotInstalled { .. })
        ));
    }
}