@@ -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+
318350enum 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+
16441870fn 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+
18582106fn unavailable_update (
18592107 locked_commit_sha : CommitSha ,
18602108 latest_commit : UpdateLatestCommit ,
0 commit comments