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}