cydonia 0.1.3

Desktop workspace for the ACP agents you run, keeping what they produce as files on your disk
Documentation
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
430
431
432
433
434
435
436
437
438
439
//! Auto-generated settings — written with defaults on first run, read on
//! launch. Editable, but never requires user maintenance.

use crate::{memory, model::watch};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::PathBuf};

#[derive(Debug, Serialize, Deserialize)]
pub struct Settings {
    /// The ceiling decoded covers run under, in megabytes. A bare key, so it
    /// is declared above `features`: one written after that table would belong
    /// to it.
    #[serde(default = "cover_memory")]
    pub cover_memory: u64,
    /// How long a project's `.cydonia/` has to go quiet before the watch
    /// re-reads it, in milliseconds — see [`crate::model::watch`]. Bare, so it
    /// belongs above `features` for the reason above.
    ///
    /// Read through [`watch::bounce`] and never used raw: this file is edited
    /// by hand, and the ends of the range are what a hand cannot reach past.
    #[serde(default = "watch_bounce")]
    pub watch_bounce: u64,
    /// What the app will show. Every bare key has to go above it, and every
    /// table below — `[[agents]]` is the one that follows.
    #[serde(default)]
    pub features: Features,
    /// The tool server this app answers on. A table, so it sits between the
    /// two that are already here and never above a bare key.
    #[serde(default)]
    pub mcp: Mcp,
    #[serde(default)]
    pub agents: Vec<Agent>,
}

/// Cydonia as an MCP server: the tools an agent reaches a project's boards
/// through.
///
/// On by default, and still opens nothing until `sessions` is on — the only
/// caller is an agent, and that switch is what decides whether any run. This
/// one is for saying no to the port while still running them.
#[derive(Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Mcp {
    pub serve: bool,
    /// Whether the tools that change a project are offered at all. Off is a
    /// server an agent can read a board through and not touch it — the tools
    /// are left out of the list rather than refused on the call, because a
    /// tool an agent can see is one it will spend a turn trying.
    pub write: bool,
}

impl Default for Mcp {
    fn default() -> Self {
        Self {
            serve: true,
            write: false,
        }
    }
}

/// The surfaces a project can hold, minus articles — the one thing the app is
/// for, and so not something to be able to switch off.
///
/// Boards are on to begin with: a board is files in the project and nothing
/// runs to hold one, so a fresh install is a place to write and a place to
/// plan. The other two are off until they are asked for.
#[derive(Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Features {
    /// Whether sessions may be opened. A session is the only thing that starts
    /// an agent, and an agent is a package this machine downloads and runs, so
    /// this is a gate over that as much as over the pane.
    pub sessions: bool,
    pub boards: bool,
    pub tables: bool,
}

impl Default for Features {
    fn default() -> Self {
        Self {
            sessions: false,
            boards: true,
            tables: false,
        }
    }
}

/// One switchable surface, named rather than reached as a field so the settings
/// section can list them and one writer can put any of them in the file.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Feature {
    Sessions,
    Boards,
    Tables,
}

impl Feature {
    /// The order the Features section lists them in. Sessions first: it is the
    /// one that decides whether anything runs on this machine.
    pub const ALL: [Self; 3] = [Self::Sessions, Self::Boards, Self::Tables];

    /// The key it is written under, inside `[features]`.
    fn key(self) -> &'static str {
        match self {
            Self::Sessions => "sessions",
            Self::Boards => "boards",
            Self::Tables => "tables",
        }
    }

    pub fn on(self, features: &Features) -> bool {
        match self {
            Self::Sessions => features.sessions,
            Self::Boards => features.boards,
            Self::Tables => features.tables,
        }
    }

    pub fn set(self, features: &mut Features, on: bool) {
        match self {
            Self::Sessions => features.sessions = on,
            Self::Boards => features.boards = on,
            Self::Tables => features.tables = on,
        }
    }
}

/// One launchable ACP agent: `command args...` spawned over stdio.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Agent {
    pub name: String,
    /// The registry agent this was installed from, when it came from there.
    /// A hand-written entry has none, and is never touched by the installer.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    pub command: String,
    #[serde(default)]
    pub args: Vec<String>,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub env: BTreeMap<String, String>,
}

/// What the cover ceiling is when the file does not say.
fn cover_memory() -> u64 {
    memory::DEFAULT_LIMIT / 1_000_000
}

/// And the watch's bounce, which the watch itself owns.
fn watch_bounce() -> u64 {
    watch::BOUNCE
}

/// The launchers that resolve a package name on every run. An installed
/// agent's command is a path to an unpacked executable, which resolves nothing.
const RUNNERS: [&str; 3] = ["npx", "bunx", "pnpx"];

impl Agent {
    /// Whether every npm package this entry names carries an exact version.
    /// `npx pkg@latest` resolves against the registry on every launch, which is
    /// a different program each time.
    pub fn pinned(&self) -> bool {
        if !RUNNERS.contains(&self.command.as_str()) {
            return true;
        }
        self.args
            .iter()
            .filter(|arg| !arg.starts_with('-'))
            .all(|spec| {
                let name = cacp_agents::package_name(spec);
                spec.len() > name.len()
                    && spec[name.len() + 1..].starts_with(|c: char| c.is_ascii_digit())
            })
    }
}

impl Default for Settings {
    fn default() -> Self {
        let npx = |name: &str, pkg: &str| Agent {
            name: name.into(),
            id: None,
            command: "npx".into(),
            args: vec!["-y".into(), pkg.into()],
            env: BTreeMap::new(),
        };
        // `npx` resolves a dist-tag against the npm registry on every launch,
        // so these carry the version the ACP registry pins.
        Self {
            cover_memory: cover_memory(),
            watch_bounce: watch_bounce(),
            features: Features::default(),
            mcp: Mcp::default(),
            agents: vec![
                npx("claude", "@agentclientprotocol/claude-agent-acp@0.73.0"),
                npx("codex", "@agentclientprotocol/codex-acp@1.8.0"),
            ],
        }
    }
}

/// Where installed agents are put — `$XDG_DATA_HOME/cydonia`, defaulting to
/// `~/.local/share/cydonia`. Programs, not preferences, so they do not belong
/// beside the files a person edits.
pub fn data_dir() -> Result<PathBuf> {
    if let Ok(xdg) = std::env::var("XDG_DATA_HOME")
        && !xdg.is_empty()
    {
        return Ok(PathBuf::from(xdg).join("cydonia"));
    }
    Ok(dirs::home_dir()
        .context("no home directory on this system")?
        .join(".local")
        .join("share")
        .join("cydonia"))
}

/// Cydonia's config directory: `$XDG_CONFIG_HOME/cydonia`, defaulting to
/// `~/.config/cydonia` — on macOS too, so a hand-edited settings.toml sits
/// where its neighbours do rather than in Application Support.
pub fn dir() -> Result<PathBuf> {
    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
        && !xdg.is_empty()
    {
        return Ok(PathBuf::from(xdg).join("cydonia"));
    }
    Ok(dirs::home_dir()
        .context("no home directory on this system")?
        .join(".config")
        .join("cydonia"))
}

pub fn load() -> Result<Settings> {
    let dir = dir()?;
    let path = dir.join("settings.toml");
    if !path.exists() {
        let settings = Settings::default();
        std::fs::create_dir_all(&dir)?;
        let body = format!(
            "# generated by cydonia — edits are kept, deleting regenerates defaults\n\n{}",
            toml::to_string_pretty(&settings)?
        );
        std::fs::write(&path, body)?;
        return Ok(settings);
    }
    let content = std::fs::read_to_string(&path)?;
    let mut settings: Settings = toml::from_str(&content)
        .with_context(|| format!("invalid settings: {}", path.display()))?;
    // A floating tag is a different program on every launch. The line stays in
    // the file, where it can be read and fixed; it just never launches.
    settings.agents.retain(Agent::pinned);
    Ok(settings)
}

/// Switch a feature on or off in the file.
///
/// Edited with `toml_edit` for the reason [`put_agent`] is: the file is meant
/// to be opened by hand, and a round trip would drop every comment in it. The
/// table is put in explicitly rather than sprung from the index, because a
/// table that arrives that way is implicit and prints no header of its own.
pub fn set_feature(feature: Feature, on: bool) -> Result<()> {
    let path = dir()?.join("settings.toml");
    let body = std::fs::read_to_string(&path).unwrap_or_default();
    let mut doc: toml_edit::DocumentMut =
        body.parse().context("settings.toml is not valid toml")?;
    let features = doc["features"].or_insert(toml_edit::table());
    let Some(features) = features.as_table_mut() else {
        anyhow::bail!("`features` in settings.toml is not a table");
    };
    features.set_implicit(false);
    features[feature.key()] = toml_edit::value(on);
    std::fs::write(&path, doc.to_string())?;
    Ok(())
}

/// Write one key of `[mcp]`. The same `toml_edit` round trip as
/// [`set_feature`], and for the same reason: the comments survive it.
pub fn set_mcp(key: &str, on: bool) -> Result<()> {
    let path = dir()?.join("settings.toml");
    let body = std::fs::read_to_string(&path).unwrap_or_default();
    let mut doc: toml_edit::DocumentMut =
        body.parse().context("settings.toml is not valid toml")?;
    let mcp = doc["mcp"].or_insert(toml_edit::table());
    let Some(mcp) = mcp.as_table_mut() else {
        anyhow::bail!("`mcp` in settings.toml is not a table");
    };
    mcp.set_implicit(false);
    mcp[key] = toml_edit::value(on);
    std::fs::write(&path, doc.to_string())?;
    Ok(())
}

/// Move the cover ceiling in the file, in megabytes.
pub fn set_cover_memory(mb: u64) -> Result<()> {
    let path = dir()?.join("settings.toml");
    let body = std::fs::read_to_string(&path).unwrap_or_default();
    let mut doc: toml_edit::DocumentMut =
        body.parse().context("settings.toml is not valid toml")?;
    doc["cover_memory"] = toml_edit::value(mb as i64);
    std::fs::write(&path, doc.to_string())?;
    Ok(())
}

/// Move the watch's bounce in the file, in milliseconds.
pub fn set_watch_bounce(ms: u64) -> Result<()> {
    let path = dir()?.join("settings.toml");
    let body = std::fs::read_to_string(&path).unwrap_or_default();
    let mut doc: toml_edit::DocumentMut =
        body.parse().context("settings.toml is not valid toml")?;
    doc["watch_bounce"] = toml_edit::value(ms as i64);
    std::fs::write(&path, doc.to_string())?;
    Ok(())
}

/// Put `agent` in the file, replacing whichever entry already launches it.
///
/// `supersedes` is the npm package the agent is published as, which is how an
/// install claims the hand-written `@latest` entry that shipped as a default
/// instead of sitting next to it. A replaced entry keeps its own `name`: the
/// person who wrote it chose that, and only the command underneath has moved.
///
/// Edited in place with `toml_edit` rather than re-serialised: this file is
/// meant to be opened and changed by hand, and a round trip through a value
/// tree would silently delete every comment in it.
pub fn put_agent(agent: &Agent, supersedes: Option<&str>) -> Result<()> {
    let path = dir()?.join("settings.toml");
    let body = std::fs::read_to_string(&path).unwrap_or_default();
    let mut doc: toml_edit::DocumentMut =
        body.parse().context("settings.toml is not valid toml")?;

    let agents = doc["agents"].or_insert(toml_edit::Item::ArrayOfTables(
        toml_edit::ArrayOfTables::new(),
    ));
    let Some(agents) = agents.as_array_of_tables_mut() else {
        anyhow::bail!("`agents` in settings.toml is not a list of tables");
    };
    let existing = agents
        .iter()
        .position(|table| claims(table, agent, supersedes));
    let name = existing
        .and_then(|ix| agents.get(ix))
        .and_then(|table| table.get("name"))
        .and_then(|n| n.as_str())
        .unwrap_or(&agent.name)
        .to_owned();
    // Whatever preceded the entry — the file's header, a note the user left
    // above it — is trivia hanging off the table, and replacing the table
    // throws it away unless it is carried across by hand.
    let decor = existing
        .and_then(|ix| agents.get(ix))
        .map(|table| table.decor().clone());

    let mut entry = toml_edit::Table::new();
    entry["name"] = toml_edit::value(name);
    if let Some(id) = &agent.id {
        entry["id"] = toml_edit::value(id.clone());
    }
    entry["command"] = toml_edit::value(agent.command.clone());
    let mut args = toml_edit::Array::new();
    for arg in &agent.args {
        args.push(arg.as_str());
    }
    entry["args"] = toml_edit::value(args);
    if !agent.env.is_empty() {
        let mut env = toml_edit::InlineTable::new();
        for (key, value) in &agent.env {
            env.insert(key, value.as_str().into());
        }
        entry["env"] = toml_edit::value(env);
    }

    match existing {
        Some(ix) => {
            if let Some(decor) = decor {
                *entry.decor_mut() = decor;
            }
            *agents.get_mut(ix).expect("position is in range") = entry;
        }
        None => agents.push(entry),
    }
    std::fs::write(&path, doc.to_string())?;
    Ok(())
}

/// Drop the entry installed from registry agent `id`.
pub fn remove_agent(id: &str) -> Result<()> {
    let path = dir()?.join("settings.toml");
    let body = std::fs::read_to_string(&path).unwrap_or_default();
    let mut doc: toml_edit::DocumentMut =
        body.parse().context("settings.toml is not valid toml")?;
    let Some(agents) = doc
        .get_mut("agents")
        .and_then(|a| a.as_array_of_tables_mut())
    else {
        return Ok(());
    };
    let Some(ix) = agents
        .iter()
        .position(|table| table.get("id").and_then(|i| i.as_str()) == Some(id))
    else {
        return Ok(());
    };
    // The file's header hangs off whichever entry comes first. If that is the
    // one being dropped, the header has to move down onto its successor or it
    // leaves with it.
    let prefix = agents
        .get(ix)
        .and_then(|table| table.decor().prefix().cloned());
    agents.remove(ix);
    if ix == 0
        && let Some(prefix) = prefix
    {
        match agents.get_mut(0) {
            Some(first) => first.decor_mut().set_prefix(prefix),
            None => doc.as_table_mut().decor_mut().set_prefix(prefix),
        }
    }
    std::fs::write(&path, doc.to_string())?;
    Ok(())
}

/// Whether an existing entry is the one this install replaces: the same
/// registry agent, or a launcher for the same npm package.
fn claims(table: &toml_edit::Table, agent: &Agent, supersedes: Option<&str>) -> bool {
    let field = |key| table.get(key).and_then(|v| v.as_str());
    if agent.id.is_some() && field("id") == agent.id.as_deref() {
        return true;
    }
    let Some(package) = supersedes else {
        return false;
    };
    table
        .get("args")
        .and_then(|args| args.as_array())
        .is_some_and(|args| {
            args.iter()
                .filter_map(|v| v.as_str())
                .any(|arg| cacp_agents::package_name(arg) == package)
        })
}