Skip to content

Commit 591afb2

Browse files
committed
feat(cli): add interactive Skill update (#109)
1 parent bb9fd10 commit 591afb2

10 files changed

Lines changed: 3198 additions & 37 deletions

File tree

Cargo.lock

Lines changed: 393 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ skilld-core = { path = "crates/skilld-core" }
3030
subtle = "2.6.1"
3131
tempfile = "3.27.0"
3232
terminal_size = "0.4.4"
33-
unicode-width = "0.2.2"
33+
unicode-width = "=0.2.0"
3434
url = "2.5.8"
3535
wit-bindgen = "0.60.0"
3636
zeroize = "1.9.0"

crates/skilld-command/src/lib.rs

Lines changed: 250 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ enum Command {
9595
/// Check update relations without changing files.
9696
#[arg(long)]
9797
check: bool,
98+
/// Select Skill updates in a terminal.
99+
#[arg(
100+
long,
101+
conflicts_with_all = ["skill", "check", "json", "plain"]
102+
)]
103+
interactive: bool,
98104
},
99105
/// Verify a Skill source.
100106
Verify { skill: Option<String> },
@@ -173,6 +179,16 @@ pub trait Host {
173179
))
174180
}
175181

182+
fn update_selected(&self, names: &[String]) -> Result<Vec<String>, CommandError> {
183+
let names = parse_update_selection(names)?;
184+
if let [name] = names.as_slice() {
185+
return self.update(Some(name));
186+
}
187+
Err(CommandError::unsupported_host(
188+
"Selected Skill updates are unavailable on this host",
189+
))
190+
}
191+
176192
fn update_check(&self, _name: Option<&str>) -> Result<UpdatePlanV1, CommandError> {
177193
Err(CommandError::unsupported_host(
178194
"Skill update checks are unavailable on this host",
@@ -315,6 +331,22 @@ pub struct CommandResult {
315331
pub exit_code: u8,
316332
}
317333

334+
pub fn interactive_update_requested<I, T>(args: I) -> Result<bool, clap::Error>
335+
where
336+
I: IntoIterator<Item = T>,
337+
T: Into<OsString> + Clone,
338+
{
339+
Cli::try_parse_from(args).map(|cli| {
340+
matches!(
341+
cli.command,
342+
Command::Update {
343+
interactive: true,
344+
..
345+
}
346+
)
347+
})
348+
}
349+
318350
enum CommandOutput {
319351
Lines(Vec<String>),
320352
Search(SearchOutcome),
@@ -685,8 +717,16 @@ fn dispatch<H: Host>(command: Command, host: &H) -> Result<CommandOutput, Comman
685717
total: response.total,
686718
}))
687719
}
688-
Command::Update { skill, check } => {
689-
if check {
720+
Command::Update {
721+
skill,
722+
check,
723+
interactive,
724+
} => {
725+
if interactive {
726+
Err(CommandError::unsupported_host(
727+
"Interactive Skill update needs a native terminal host",
728+
))
729+
} else if check {
690730
host.update_check(skill.as_deref())
691731
.map(CommandOutput::UpdateCheck)
692732
} else {
@@ -1458,6 +1498,14 @@ impl Host for LocalHost {
14581498
.collect())
14591499
}
14601500

1501+
fn update_selected(&self, names: &[String]) -> Result<Vec<String>, CommandError> {
1502+
let names = parse_update_selection(names)?;
1503+
let scope = InstallScope::Project;
1504+
let known = self.known_targets(scope)?;
1505+
let store = self.store(scope);
1506+
apply_update_selection(self, names, store, known)
1507+
}
1508+
14611509
fn update_check(&self, requested: Option<&str>) -> Result<UpdatePlanV1, CommandError> {
14621510
let scope = InstallScope::Project;
14631511
let known = self.known_targets(scope)?;
@@ -1641,6 +1689,184 @@ struct PreparedUpdateSelection {
16411689
expected_skill: skilld_core::LockedSkill,
16421690
}
16431691

1692+
fn apply_update_selection(
1693+
host: &LocalHost,
1694+
names: Vec<String>,
1695+
store: LocalStore,
1696+
known: Vec<ResolvedTarget>,
1697+
) -> Result<Vec<String>, CommandError> {
1698+
let provider = host.remote_provider()?;
1699+
let mut pending = Vec::new();
1700+
for name in names {
1701+
let skill_name =
1702+
skilld_core::SkillName::parse(name.clone()).map_err(CommandError::domain)?;
1703+
let view = store
1704+
.verify_content(&skill_name, &known)
1705+
.map_err(CommandError::store)?;
1706+
let LockedSource::Remote { source, .. } = &view.skill.source else {
1707+
continue;
1708+
};
1709+
if !matches!(
1710+
view.skill.source_status,
1711+
skilld_core::SourceStatus::Verified { .. }
1712+
) {
1713+
return Err(CommandError::operation(
1714+
"UNVERIFIED_SOURCE",
1715+
format!("Skill {name} needs another explicit --direct install"),
1716+
));
1717+
}
1718+
let selector = skilld_core::RemoteSelector::parse(source).map_err(CommandError::remote)?;
1719+
if matches!(selector.source().r#ref, Some(SourceRef::Commit { .. })) {
1720+
continue;
1721+
}
1722+
let latest_commit = provider
1723+
.latest_commit(&selector, false)
1724+
.map_err(CommandError::remote)?;
1725+
let LockedSource::Remote { commit_sha, .. } = &view.skill.source else {
1726+
unreachable!("the update candidate has a remote source")
1727+
};
1728+
let locked_commit_sha = CommitSha::parse(commit_sha.clone()).map_err(update_model_error)?;
1729+
if latest_commit.commit_sha == locked_commit_sha {
1730+
continue;
1731+
}
1732+
let comparison = RemoteUpdateComparison::new(
1733+
skill_name.as_str(),
1734+
&selector.source().owner,
1735+
&selector.source().repository,
1736+
locked_commit_sha,
1737+
latest_commit.commit_sha.clone(),
1738+
latest_commit.access,
1739+
)
1740+
.map_err(CommandError::remote)?;
1741+
pending.push(PendingUpdateApply {
1742+
name,
1743+
skill_name,
1744+
view,
1745+
selector,
1746+
expected_commit: latest_commit.commit_sha,
1747+
comparison,
1748+
});
1749+
}
1750+
if pending.is_empty() {
1751+
return Ok(vec![]);
1752+
}
1753+
let comparisons = pending
1754+
.iter()
1755+
.map(|pending| pending.comparison.clone())
1756+
.collect::<Vec<_>>();
1757+
let results = provider
1758+
.compare_updates(&comparisons)
1759+
.map_err(CommandError::remote)?;
1760+
if results.len() != pending.len() {
1761+
return Err(CommandError::service(
1762+
"Update comparison results were incomplete",
1763+
));
1764+
}
1765+
let mut selected = Vec::new();
1766+
for (pending, result) in pending.into_iter().zip(results) {
1767+
if pending.comparison.id != result.id {
1768+
return Err(CommandError::service(
1769+
"Update comparison results changed order",
1770+
));
1771+
}
1772+
match result.outcome {
1773+
RemoteComparisonOutcome::Ready {
1774+
relation: RemoteComparisonRelation::Ahead,
1775+
total,
1776+
..
1777+
} if total > 0 => {}
1778+
RemoteComparisonOutcome::Ready {
1779+
relation: RemoteComparisonRelation::Behind,
1780+
..
1781+
} => {
1782+
return Err(CommandError::operation(
1783+
"UPDATE_CONFIRMATION_REQUIRED",
1784+
format!(
1785+
"Skill {} needs interactive confirmation because its source moved behind",
1786+
pending.name
1787+
),
1788+
));
1789+
}
1790+
RemoteComparisonOutcome::Ready {
1791+
relation: RemoteComparisonRelation::Diverged,
1792+
..
1793+
} => {
1794+
return Err(CommandError::operation(
1795+
"UPDATE_CONFIRMATION_REQUIRED",
1796+
format!(
1797+
"Skill {} needs interactive confirmation because its source diverged",
1798+
pending.name
1799+
),
1800+
));
1801+
}
1802+
RemoteComparisonOutcome::Ready { .. } => {
1803+
return Err(CommandError::operation(
1804+
"INVALID_RESPONSE",
1805+
"GitHub returned an impossible update relation",
1806+
));
1807+
}
1808+
outcome => return Err(update_apply_failure(&pending.name, outcome)),
1809+
}
1810+
let prepared = provider
1811+
.prepare_exact(&pending.selector, &pending.expected_commit, false)
1812+
.map_err(CommandError::remote)?;
1813+
let staged = materialize_remote(&prepared.files)?;
1814+
let staged_name =
1815+
skilld_core::SkillName::from_source(staged.path()).map_err(CommandError::domain)?;
1816+
if staged_name != pending.skill_name {
1817+
return Err(CommandError::operation(
1818+
"SOURCE_MISMATCH",
1819+
format!("the updated Skill name changed from {}", pending.name),
1820+
));
1821+
}
1822+
let targets = pending
1823+
.view
1824+
.skill
1825+
.targets
1826+
.iter()
1827+
.map(|locked| {
1828+
known
1829+
.iter()
1830+
.find(|target| target.agent == locked.agent)
1831+
.cloned()
1832+
.map(|target| TargetInstall {
1833+
target,
1834+
mode: locked.mode,
1835+
})
1836+
.ok_or_else(|| {
1837+
CommandError::domain(DomainError::InvalidTarget(locked.agent.to_string()))
1838+
})
1839+
})
1840+
.collect::<Result<Vec<_>, _>>()?;
1841+
selected.push(PreparedUpdateSelection {
1842+
name: pending.name,
1843+
staged,
1844+
prepared,
1845+
targets,
1846+
expected_transaction_id: pending.view.transaction_id,
1847+
expected_skill: pending.view.skill,
1848+
});
1849+
}
1850+
let updates = selected
1851+
.iter()
1852+
.map(|selection| PreparedStoreUpdate {
1853+
source: selection.staged.path().to_owned(),
1854+
locked_source: selection.prepared.locked_source.clone(),
1855+
source_status: Some(selection.prepared.source_status.clone()),
1856+
targets: selection.targets.clone(),
1857+
expected_transaction_id: selection.expected_transaction_id.clone(),
1858+
expected_skill: selection.expected_skill.clone(),
1859+
})
1860+
.collect();
1861+
store
1862+
.apply_update_batch(updates, &known)
1863+
.map_err(CommandError::store)?;
1864+
Ok(selected
1865+
.into_iter()
1866+
.map(|selection| format!("Updated Skill {}.", selection.name))
1867+
.collect())
1868+
}
1869+
16441870
fn update_plan_item(
16451871
pending: PendingUpdateComparison,
16461872
outcome: RemoteComparisonOutcome,
@@ -1855,6 +2081,28 @@ fn selected_names(
18552081
}
18562082
}
18572083

2084+
fn parse_update_selection(names: &[String]) -> Result<Vec<String>, CommandError> {
2085+
if names.is_empty() {
2086+
return Err(CommandError::usage(
2087+
"INVALID_SELECTION",
2088+
"Select at least one Skill",
2089+
));
2090+
}
2091+
let mut unique = BTreeSet::new();
2092+
let mut parsed = Vec::with_capacity(names.len());
2093+
for name in names {
2094+
let name = skilld_core::SkillName::parse(name.clone()).map_err(CommandError::domain)?;
2095+
if !unique.insert(name.clone()) {
2096+
return Err(CommandError::usage(
2097+
"INVALID_SELECTION",
2098+
"Select each Skill once",
2099+
));
2100+
}
2101+
parsed.push(name.to_string());
2102+
}
2103+
Ok(parsed)
2104+
}
2105+
18582106
fn unavailable_update(
18592107
locked_commit_sha: CommitSha,
18602108
latest_commit: UpdateLatestCommit,

0 commit comments

Comments
 (0)