Skip to main content

agent_skill_installer/
agent.rs

1// SPDX-License-Identifier: LGPL-3.0-or-later
2// Copyright (C) 2026 Fabian Schmieder
3
4//! The everyday way to use the crate: one value that knows the skill, the
5//! program, the machine and where the user's answers are kept.
6//!
7//! [`Installer`] and [`Approval`] stay available for a program that wants to
8//! decide place by place; this is a thin layer over them.
9
10use std::fmt;
11use std::io;
12use std::path::PathBuf;
13
14use crate::catalog::Catalog;
15use crate::consent::Consent;
16use crate::env::Environment;
17use crate::install::{Action, Approval, Assessment, Installer, Outcome, Program, Step, Why};
18use crate::plan::PlanError;
19use crate::skill::Skill;
20
21/// Why the machine could not be looked at or the answers not be kept.
22#[derive(Debug)]
23#[non_exhaustive]
24pub enum Error {
25    /// No home directory to look for AI tools in.
26    NoHome,
27    /// No plan could be made.
28    Plan(PlanError),
29    /// The user's answers could not be read or written.
30    Consent(io::Error),
31}
32
33impl fmt::Display for Error {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            Self::NoHome => f.write_str("no home directory to look for AI tools in"),
37            Self::Plan(error) => error.fmt(f),
38            Self::Consent(error) => write!(f, "the answers about the skill: {error}"),
39        }
40    }
41}
42
43impl std::error::Error for Error {
44    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
45        match self {
46            Self::NoHome | Self::Plan(_) => None,
47            Self::Consent(error) => Some(error),
48        }
49    }
50}
51
52impl From<PlanError> for Error {
53    fn from(error: PlanError) -> Self {
54        Self::Plan(error)
55    }
56}
57
58impl From<io::Error> for Error {
59    fn from(error: io::Error) -> Self {
60        Self::Consent(error)
61    }
62}
63
64/// A program's skill on the user's machine.
65///
66/// ```no_run
67/// use agent_skill_installer::{AgentSkill, Program, Skill};
68///
69/// let skill = Skill::new("mytool", "Use mytool to do the thing.", "# mytool\n")?;
70/// let program = Program::new("mytool", "1.2.0")?;
71/// let agent = AgentSkill::new(skill, program, "/data/mytool/skill-consent")?;
72///
73/// agent.update()?; // at start, without asking
74/// if let Some(offer) = agent.offer()? {
75///     // Ask once in your own surface, naming offer.steps().
76///     offer.accept()?; // or offer.decline()?
77/// }
78/// # Ok::<(), Box<dyn std::error::Error>>(())
79/// ```
80#[derive(Debug)]
81pub struct AgentSkill {
82    catalog: Catalog,
83    env: Environment,
84    skill: Skill,
85    program: Program,
86    consent_file: PathBuf,
87}
88
89impl AgentSkill {
90    /// The skill for the user running the program, with the built-in tools.
91    ///
92    /// `consent_file` is where the places the user declined are kept; the
93    /// program's own data directory is the natural home for it.
94    ///
95    /// # Errors
96    /// Returns [`Error::NoHome`] when the process has no home directory.
97    pub fn new(
98        skill: Skill,
99        program: Program,
100        consent_file: impl Into<PathBuf>,
101    ) -> Result<Self, Error> {
102        Ok(Self {
103            catalog: Catalog::builtin(),
104            env: Environment::from_process().ok_or(Error::NoHome)?,
105            skill,
106            program,
107            consent_file: consent_file.into(),
108        })
109    }
110
111    /// The same, looking at another environment: a test's temporary home.
112    #[must_use]
113    pub fn with_environment(mut self, env: Environment) -> Self {
114        self.env = env;
115        self
116    }
117
118    /// The same, with a catalog the program extended or replaced.
119    #[must_use]
120    pub fn with_catalog(mut self, catalog: Catalog) -> Self {
121        self.catalog = catalog;
122        self
123    }
124
125    /// The tools and folders it knows, for naming them to people.
126    #[must_use]
127    pub const fn catalog(&self) -> &Catalog {
128        &self.catalog
129    }
130
131    /// The installer underneath, for a program that decides place by place.
132    #[must_use]
133    pub const fn installer(&self) -> Installer<'_> {
134        Installer::new(&self.catalog, &self.env, &self.skill, &self.program)
135    }
136
137    /// Every place and what would happen there. Writes nothing.
138    ///
139    /// # Errors
140    /// Returns an error when the answers cannot be read or no plan can be
141    /// made.
142    pub fn status(&self) -> Result<Assessment, Error> {
143        Ok(self.installer().assess(&self.consent()?)?)
144    }
145
146    /// Bring every copy this program installed and nobody changed up to the
147    /// running version. Asks nothing and installs nothing new; meant for every
148    /// start of the program.
149    ///
150    /// # Errors
151    /// Returns an error when no plan can be made.
152    pub fn update(&self) -> Result<Vec<Outcome>, Error> {
153        Ok(self.installer().update()?)
154    }
155
156    /// The question to ask, when a place exists nobody has decided on.
157    ///
158    /// # Errors
159    /// Returns an error when the answers cannot be read or no plan can be
160    /// made.
161    pub fn offer(&self) -> Result<Option<Offer<'_>>, Error> {
162        let assessment = self.status()?;
163        let steps: Vec<Step> = assessment.awaiting_approval().cloned().collect();
164        Ok((!steps.is_empty()).then_some(Offer { agent: self, steps }))
165    }
166
167    /// Install wherever the skill can go, also where the user declined
168    /// before: for an explicit request such as an `install` command.
169    ///
170    /// # Errors
171    /// Returns an error when the answers cannot be read or written, or no
172    /// plan can be made.
173    pub fn install_everywhere(&self) -> Result<Vec<Outcome>, Error> {
174        let assessment = self.status()?;
175        let places: Vec<PathBuf> = assessment
176            .steps
177            .iter()
178            .filter(|step| {
179                matches!(
180                    step.action,
181                    Action::Install | Action::Nothing(Why::Declined)
182                )
183            })
184            .map(|step| step.place.clone())
185            .collect();
186        self.install(places)
187    }
188
189    /// Remove every copy this program installed and nobody changed.
190    ///
191    /// # Errors
192    /// Returns an error when no plan can be made.
193    pub fn remove(&self) -> Result<Vec<Outcome>, Error> {
194        Ok(self.installer().remove()?)
195    }
196
197    fn consent(&self) -> io::Result<Consent> {
198        Consent::load(&self.consent_file)
199    }
200
201    /// Install into `places`, which the user agreed to, withdrawing any
202    /// earlier decline there. Also brings older unchanged copies up to date.
203    fn install(&self, places: Vec<PathBuf>) -> Result<Vec<Outcome>, Error> {
204        let mut consent = self.consent()?;
205        for place in &places {
206            consent.withdraw(place);
207        }
208        consent.save()?;
209        let assessment = self.installer().assess(&consent)?;
210        Ok(self
211            .installer()
212            .apply(&assessment, &Approval::install(places)))
213    }
214}
215
216/// Places nobody has decided on, to be put to the user once.
217#[derive(Debug)]
218pub struct Offer<'a> {
219    agent: &'a AgentSkill,
220    steps: Vec<Step>,
221}
222
223impl Offer<'_> {
224    /// The places, with the tools that read each, to name in the question.
225    #[must_use]
226    pub fn steps(&self) -> &[Step] {
227        &self.steps
228    }
229
230    /// The user agreed: install into every offered place.
231    ///
232    /// # Errors
233    /// Returns an error when the answers cannot be read or written, or no
234    /// plan can be made.
235    pub fn accept(self) -> Result<Vec<Outcome>, Error> {
236        self.agent
237            .install(self.steps.into_iter().map(|step| step.place).collect())
238    }
239
240    /// The user said no: never offer these places again. A tool installed
241    /// later is offered on its own.
242    ///
243    /// # Errors
244    /// Returns an error when the answers cannot be read or written.
245    pub fn decline(self) -> Result<(), Error> {
246        let mut consent = self.agent.consent()?;
247        for step in &self.steps {
248            consent.decline(&step.place)?;
249        }
250        Ok(consent.save()?)
251    }
252}