Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/skillpouch/src/choose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use sp_sync::release::Released;
use sp_sync::status::Phase;

/// The kinds, as the pouch names them and as people read them.
pub const KINDS: [(&str, &str); 7] = sp_harness::POUCH_KINDS;
pub const KINDS: [(&str, &str); 8] = sp_harness::POUCH_KINDS;

/// An assistant or a kind, by id.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand All @@ -40,6 +40,7 @@ pub fn parse(text: &str) -> Option<Choice> {
|| kind == singular
|| label.to_lowercase().replace(' ', "-") == wanted
|| (singular == "mcp" && kind == "mcp-server")
|| (wanted == "settings" && kind == "setting")
})
.map(|(kind, _)| Choice::Kind(kind))
}
Expand Down Expand Up @@ -134,6 +135,8 @@ mod tests {
assert_eq!(parse("mcp"), Some(Choice::Kind("mcp_server")));
assert_eq!(parse("MCP servers"), Some(Choice::Kind("mcp_server")));
assert_eq!(parse("mcp_server"), Some(Choice::Kind("mcp_server")));
assert_eq!(parse("settings"), Some(Choice::Kind("setting")));
assert_eq!(label(Choice::Kind("setting")), "Settings");
assert_eq!(parse("nope"), None);
assert_eq!(label(Choice::Kind("mcp_server")), "MCP servers");
assert_eq!(label(Choice::Assistant("gemini-cli")), "Gemini CLI");
Expand Down
31 changes: 31 additions & 0 deletions crates/skillpouch/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ pub enum Command {
},
/// Sync this computer's pouch now.
Sync,
/// List or control the preferences synced for each assistant.
Settings {
#[command(subcommand)]
action: SettingsAction,
},
/// Open the web app to pick versions of items changed in two places.
Conflicts,
/// Choose what this computer syncs: assistants and kinds, on or off.
Expand All @@ -77,6 +82,32 @@ pub enum Command {
},
}

#[derive(Debug, Subcommand)]
pub enum SettingsAction {
/// List entries and their status, optionally for one assistant.
List {
/// Only this assistant (`claude-code`, `opencode`, …).
#[arg(value_name = "ASSISTANT")]
harness: Option<String>,
},
/// Stop syncing an entry on this computer (the pouch keeps it).
Exclude {
/// The assistant (`claude-code`, `opencode`, …).
#[arg(value_name = "ASSISTANT")]
harness: String,
/// The entry, as `settings list` shows it (`settings.json:env.API_URL`).
entry: String,
},
/// Restore an excluded entry from the pouch now.
Include {
/// The assistant (`claude-code`, `opencode`, …).
#[arg(value_name = "ASSISTANT")]
harness: String,
/// The entry, as `settings list` shows it (`settings.json:env.API_URL`).
entry: String,
},
}

#[derive(Debug, Subcommand)]
pub enum DaemonAction {
/// Start it, and start it again whenever you log in.
Expand Down
2 changes: 2 additions & 0 deletions crates/skillpouch/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod home;
mod login;
mod logout;
mod select;
mod settings;
mod status;
mod sync;
mod tui;
Expand Down Expand Up @@ -69,6 +70,7 @@ pub async fn run(cli: Cli) -> Result<ExitCode> {
Command::Logout => logout::run(&ctx).await,
Command::Status { json } => status::run(&ctx, json).await,
Command::Sync => sync::once(&ctx).await,
Command::Settings { action } => settings::run(&ctx, action),
Command::Daemon { action } => match action {
DaemonAction::Start => sync::start(&ctx),
DaemonAction::Stop => sync::stop(&ctx),
Expand Down
114 changes: 114 additions & 0 deletions crates/skillpouch/src/commands/settings.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
use crate::cli::SettingsAction;
use crate::context::Context;
use anyhow::{Result, bail};
use sp_core::home::lock_exclusive;
use sp_core::setup::SetupRecord;
use sp_sync::item::PouchDir;
use sp_sync::settings::{DeviceState, Setting};
use std::collections::BTreeSet;
use std::process::ExitCode;

pub fn run(ctx: &Context, action: SettingsAction) -> Result<ExitCode> {
let _lock = lock_exclusive(&crate::choose::sync_lock(ctx))?;
let Some(record) = SetupRecord::load(&ctx.home)? else {
bail!("Connect this computer first with `skillpouch login`.");
};
let targets = sp_harness::targets(&crate::sync::places(ctx)?);
let mut state = DeviceState::load(&ctx.home, &record.pouch_id)?;
let dir = PouchDir::new(crate::sync::pouch_dir(ctx, &record.pouch_id));
let (items, _) = dir.read()?;
let exclude = matches!(&action, SettingsAction::Exclude { .. });
match action {
SettingsAction::List { harness } => {
if let Some(id) = &harness
&& !targets.iter().any(|target| target.id == id)
{
bail!("{id} is not installed.");
}
for target in &targets {
if harness.as_deref().is_some_and(|id| id != target.id)
|| sp_harness::settings(target.id).is_empty()
{
continue;
}
println!("{}", target.name);
let item = items.get(&format!("setting/{}", target.id));
let entries = item.map(Setting::from_item).transpose()?;
let baseline = state.harnesses.get(target.id);
let keys: BTreeSet<_> = entries
.iter()
.flat_map(|item| item.entries().into_keys())
.chain(entries.iter().flat_map(|item| {
sp_sync::settings::local_entries(target.id, &target.root, item)
}))
.chain(
baseline
.into_iter()
.flat_map(|state| state.excluded.iter().cloned()),
)
.collect();
for key in keys {
let status = if baseline.is_some_and(|state| state.excluded.contains(&key)) {
"excluded on this computer"
} else if entries.as_ref().is_some_and(|setting| {
sp_sync::settings::translated_on(target.id, &target.root, setting, &key)
}) {
"translated"
} else if baseline.is_some_and(|state| state.baseline.contains_key(&key)) {
"synced"
} else {
"only in pouch"
};
println!(" {key} {status}");
}
}
}
SettingsAction::Exclude { harness, entry } | SettingsAction::Include { harness, entry } => {
if !targets
.iter()
.any(|target| target.id == harness && !sp_harness::settings(target.id).is_empty())
{
bail!("{harness} is not an installed assistant with settings sync.");
}
let item = items
.get(&format!("setting/{harness}"))
.map(Setting::from_item)
.transpose()?;
if !item.as_ref().is_some_and(|item| {
item.entries().contains_key(&entry)
|| targets
.iter()
.find(|target| target.id == harness)
.is_some_and(|target| {
sp_sync::settings::local_entries(&harness, &target.root, item)
.contains(&entry)
})
}) {
bail!("{entry} is not in {harness}'s pouch settings.");
}
let root = targets
.iter()
.find(|target| target.id == harness)
.map(|target| target.root.clone())
.ok_or_else(|| anyhow::anyhow!("assistant is not installed"))?;
let device = state.harnesses.entry(harness.clone()).or_default();
if exclude {
sp_sync::settings::exclude(&root, &harness, &entry, device);
} else {
let item = items
.get(&format!("setting/{harness}"))
.ok_or_else(|| anyhow::anyhow!("settings item is missing"))?;
sp_sync::settings::include(
&root,
&harness,
&entry,
item,
&ctx.home.root().join("backups"),
device,
)?;
}
state.save(&ctx.home, &record.pouch_id)?;
}
}
Ok(ExitCode::SUCCESS)
}
3 changes: 3 additions & 0 deletions crates/skillpouch/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,9 @@ pub fn local_signature(ctx: &Context) -> u64 {
{
add(path, false);
}
for path in sp_sync::settings::files(&target) {
add(&path, false);
}
let commands = target.commands.as_ref().map(|(dir, _)| dir);
let agents = target.agents.as_ref().map(|(dir, _)| dir);
for dir in [target.skills.as_ref(), agents, commands]
Expand Down
38 changes: 38 additions & 0 deletions crates/skillpouch/src/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,28 @@ async fn run_with(ctx: &Context, session: &mut Session, mut record: SetupRecord)
.collect();
bring_in_hooks_and_plugins(&dir, &config_targets, &mut projection, &claude_plugins)?;

let mut settings_state = sp_sync::settings::DeviceState::load(&ctx.home, &record.pouch_id)?;
// Uninstalled assistants lose their per-device preferences history, but
// an explicit `select --off` remains in the user's config.toml.
let installed: std::collections::BTreeSet<_> = sp_harness::targets(&places)
.into_iter()
.map(|target| target.id.to_string())
.collect();
settings_state
.harnesses
.retain(|id, _| installed.contains(id));
if choice.kind_on("setting") {
sp_sync::settings::capture(&dir, &targets, &mut settings_state)?;
settings_state.save(&ctx.home, &record.pouch_id)?;
}

let previously_present: std::collections::BTreeSet<_> = dir
.read()?
.0
.keys()
.filter(|key| key.starts_with("setting/"))
.cloned()
.collect();
let mut state = PouchState::load(&ctx.home, &record.pouch_id)?;
if state.epoch != remote.pouch.epoch && !state.epoch.is_empty() {
// The pouch's history was rewritten: compare everything afresh.
Expand Down Expand Up @@ -466,6 +488,22 @@ async fn run_with(ctx: &Context, session: &mut Session, mut record: SetupRecord)
}

let (mut items, _) = dir.read()?;
if choice.kind_on("setting") {
for target in &targets {
let key = format!("setting/{}", target.id);
if !items.contains_key(&key) && previously_present.contains(&key) {
settings_state.deleted.insert(target.id.into());
}
}
report.backed_up.extend(sp_sync::settings::project(
&dir,
&items,
&targets,
&mut settings_state,
&backups,
)?);
settings_state.save(&ctx.home, &record.pouch_id)?;
}
items.retain(|_, item| choice.kind_on(&item.kind));
let (next, projected) = project::project(&ctx.home, &items, &targets, &backups, &projection)?;
projection.links = next.links;
Expand Down
Loading
Loading