@@ -105,10 +105,17 @@ fn enable(h: Harness, home: &Path) {
105105 statusline_step ( home) ;
106106 }
107107 Harness :: Codex => codex_plugin_step ( home) ,
108- Harness :: OpenCode => match link_skills ( home) {
109- Ok ( ( added, kept) ) => println ! ( " skills: {added} linked, {kept} already current → {}" , opencode_skills_dir( home) . display( ) ) ,
110- Err ( e) => println ! ( " skills: {} {e:#}" , "FAILED" . bright_red( ) ) ,
111- } ,
108+ Harness :: OpenCode => {
109+ match link_skills ( home) {
110+ Ok ( ( added, kept) ) => println ! ( " skills: {added} linked, {kept} already current → {}" , opencode_skills_dir( home) . display( ) ) ,
111+ Err ( e) => println ! ( " skills: {} {e:#}" , "FAILED" . bright_red( ) ) ,
112+ }
113+ match link_opencode_plugin ( home) {
114+ Ok ( true ) => println ! ( " plugin: lifecycle plugin linked → {}" , opencode_plugin_link( home) . display( ) ) ,
115+ Ok ( false ) => println ! ( " plugin: already current" ) ,
116+ Err ( e) => println ! ( " plugin: {} {e:#}" , "FAILED" . bright_red( ) ) ,
117+ }
118+ }
112119 }
113120}
114121
@@ -210,6 +217,11 @@ fn status(h: Harness, home: &Path) {
210217 } else {
211218 println ! ( " skills: {n} linked" ) ;
212219 }
220+ if smooth_owned_link ( & opencode_plugin_link ( home) , home) {
221+ println ! ( " plugin: lifecycle plugin linked" ) ;
222+ } else {
223+ println ! ( " plugin: not linked — `th harness enable opencode`" ) ;
224+ }
213225 }
214226 }
215227}
@@ -224,6 +236,11 @@ fn disable(h: Harness, home: &Path) -> Result<()> {
224236 std:: fs:: remove_file ( l) . with_context ( || format ! ( "remove {}" , l. display( ) ) ) ?;
225237 }
226238 println ! ( " skills: {} smooth-owned links removed" , links. len( ) ) ;
239+ let plugin = opencode_plugin_link ( home) ;
240+ if smooth_owned_link ( & plugin, home) {
241+ std:: fs:: remove_file ( & plugin) . with_context ( || format ! ( "remove {}" , plugin. display( ) ) ) ?;
242+ println ! ( " plugin: lifecycle plugin link removed" ) ;
243+ }
227244 }
228245 if h == Harness :: ClaudeCode {
229246 println ! ( " plugin: left installed — remove with `claude plugin uninstall smooth-agent@smooth` if you mean it" ) ;
@@ -267,6 +284,50 @@ fn opencode_skills_dir(home: &Path) -> PathBuf {
267284 home. join ( ".opencode" ) . join ( "skills" )
268285}
269286
287+ /// Where the OpenCode lifecycle plugin (th-cc50cd) gets linked: OpenCode
288+ /// auto-loads plugin files from `~/.config/opencode/plugins/`.
289+ fn opencode_plugin_link ( home : & Path ) -> PathBuf {
290+ home. join ( ".config" ) . join ( "opencode" ) . join ( "plugins" ) . join ( "smooth-agent.js" )
291+ }
292+
293+ /// Is this path a symlink resolving into smooth-owned sources (the Claude
294+ /// plugin cache / marketplace checkout)? The ownership rule for everything
295+ /// `disable` may remove.
296+ fn smooth_owned_link ( path : & Path , home : & Path ) -> bool {
297+ std:: fs:: symlink_metadata ( path) . is_ok_and ( |m| m. file_type ( ) . is_symlink ( ) )
298+ && std:: fs:: read_link ( path) . is_ok_and ( |t| t. starts_with ( home. join ( ".claude" ) . join ( "plugins" ) ) )
299+ }
300+
301+ /// Link the smooth-agent OpenCode lifecycle plugin (session registration +
302+ /// presence on the th-mail bus). Same never-clobber rules as skills: only a
303+ /// symlink is ever replaced. Returns true when the link was (re)written.
304+ fn link_opencode_plugin ( home : & Path ) -> Result < bool > {
305+ let source = skill_source ( home)
306+ . and_then ( |skills| skills. parent ( ) . map ( |root| root. join ( "opencode" ) . join ( "smooth-agent.js" ) ) )
307+ . filter ( |p| p. is_file ( ) )
308+ . context ( "smooth-agent plugin checkout has no opencode/smooth-agent.js — update the plugin first (th harness enable claude-code)" ) ?;
309+ let link = opencode_plugin_link ( home) ;
310+ if let Some ( parent) = link. parent ( ) {
311+ std:: fs:: create_dir_all ( parent) . with_context ( || format ! ( "create {}" , parent. display( ) ) ) ?;
312+ }
313+ match std:: fs:: symlink_metadata ( & link) {
314+ Ok ( meta) if meta. file_type ( ) . is_symlink ( ) => {
315+ if std:: fs:: read_link ( & link) . is_ok_and ( |t| t == source) {
316+ return Ok ( false ) ;
317+ }
318+ std:: fs:: remove_file ( & link) ?;
319+ }
320+ Ok ( _) => anyhow:: bail!( "{} exists and is not a symlink — left alone" , link. display( ) ) ,
321+ Err ( _) => { }
322+ }
323+ #[ cfg( unix) ]
324+ std:: os:: unix:: fs:: symlink ( & source, & link) . with_context ( || format ! ( "link {}" , link. display( ) ) ) ?;
325+ #[ cfg( not( unix) ) ]
326+ anyhow:: bail!( "plugin link needs symlink support; copy {} manually" , source. display( ) ) ;
327+ #[ cfg( unix) ]
328+ Ok ( true )
329+ }
330+
270331/// Symlink every canonical skill into the OpenCode skills dir. Never clobbers
271332/// a real file/dir; repairs stale or dead symlinks. Returns (added, kept).
272333fn link_skills ( home : & Path ) -> Result < ( usize , usize ) > {
@@ -413,6 +474,9 @@ mod tests {
413474 std:: fs:: create_dir_all ( & d) . unwrap ( ) ;
414475 std:: fs:: write ( d. join ( "SKILL.md" ) , "x" ) . unwrap ( ) ;
415476 }
477+ let oc = tmp. path ( ) . join ( ".claude/plugins/marketplaces/smooth/claude-plugins/smooth-agent/opencode" ) ;
478+ std:: fs:: create_dir_all ( & oc) . unwrap ( ) ;
479+ std:: fs:: write ( oc. join ( "smooth-agent.js" ) , "export const SmoothAgent = 1;" ) . unwrap ( ) ;
416480 tmp
417481 }
418482
@@ -512,6 +576,29 @@ mod tests {
512576 assert ! ( claude_statusline_wired( tmp. path( ) ) ) ;
513577 }
514578
579+ #[ test]
580+ #[ cfg( unix) ] // exercises real symlinks
581+ fn opencode_lifecycle_plugin_links_and_disables_cleanly ( ) {
582+ let tmp = home ( ) ;
583+ assert ! ( link_opencode_plugin( tmp. path( ) ) . unwrap( ) , "first run links" ) ;
584+ assert ! ( !link_opencode_plugin( tmp. path( ) ) . unwrap( ) , "second run is a no-op" ) ;
585+ let link = opencode_plugin_link ( tmp. path ( ) ) ;
586+ assert ! ( smooth_owned_link( & link, tmp. path( ) ) ) ;
587+
588+ // A user's own real file of the same name is refused, not clobbered.
589+ std:: fs:: remove_file ( & link) . unwrap ( ) ;
590+ std:: fs:: write ( & link, "my own plugin" ) . unwrap ( ) ;
591+ assert ! ( link_opencode_plugin( tmp. path( ) ) . is_err( ) ) ;
592+ assert_eq ! ( std:: fs:: read_to_string( & link) . unwrap( ) , "my own plugin" ) ;
593+ assert ! ( !smooth_owned_link( & link, tmp. path( ) ) , "a real file is never ours to remove" ) ;
594+ std:: fs:: remove_file ( & link) . unwrap ( ) ;
595+
596+ // disable removes the link it owns.
597+ link_opencode_plugin ( tmp. path ( ) ) . unwrap ( ) ;
598+ disable ( Harness :: OpenCode , tmp. path ( ) ) . unwrap ( ) ;
599+ assert ! ( std:: fs:: symlink_metadata( & link) . is_err( ) , "disable must remove the plugin link" ) ;
600+ }
601+
515602 #[ test]
516603 fn providers_expands_all_and_rejects_junk ( ) {
517604 assert_eq ! ( providers( "all" ) . unwrap( ) , Harness :: ALL . to_vec( ) ) ;
0 commit comments