1- import { lstat , mkdir , readFile , realpath , rename , symlink , writeFile } from "node:fs/promises" ;
1+ import { lstat , mkdir , readFile , realpath , rename , rm , symlink , writeFile } from "node:fs/promises" ;
22import os from "node:os" ;
33import path from "node:path" ;
44import { fileURLToPath } from "node:url" ;
55
66const root = path . resolve ( path . dirname ( fileURLToPath ( import . meta. url ) ) , ".." ) ;
7- const claudeHome = process . env . CLAUDE_HOME ?? path . join ( os . homedir ( ) , ".claude" ) ;
7+ const dryRun = process . argv . includes ( "--dry-run" ) || process . env . CLAUDE_DEV_LINK_DRY_RUN === "1" ;
8+ const claudeHome = path . resolve ( process . env . CLAUDE_HOME ?? path . join ( os . homedir ( ) , ".claude" ) ) ;
89const manifestPath = path . join ( root , ".claude-plugin" , "plugin.json" ) ;
910const manifest = JSON . parse ( await readFile ( manifestPath , "utf8" ) ) ;
1011const marketplace = "codex-subagents-local" ;
@@ -21,6 +22,13 @@ const marketplacePluginPath = path.join(
2122) ;
2223const installPath = path . join ( claudeHome , "plugins" , "cache" , marketplace , pluginName , version ) ;
2324
25+ function assertSafePath ( targetPath ) {
26+ const resolved = path . resolve ( targetPath ) ;
27+ if ( resolved === claudeHome || ! resolved . startsWith ( `${ claudeHome } ${ path . sep } ` ) ) {
28+ throw new Error ( `Refusing to modify path outside CLAUDE_HOME: ${ targetPath } ` ) ;
29+ }
30+ }
31+
2432async function pathExists ( targetPath ) {
2533 try {
2634 await lstat ( targetPath ) ;
@@ -45,22 +53,36 @@ function backupPath(targetPath) {
4553}
4654
4755async function replaceWithSymlink ( linkPath , targetPath ) {
48- await mkdir ( path . dirname ( linkPath ) , { recursive : true } ) ;
56+ assertSafePath ( linkPath ) ;
4957 const currentTarget = await resolvedPath ( linkPath ) ;
5058 if ( currentTarget === targetPath ) return { path : linkPath , changed : false } ;
5159
60+ if ( dryRun ) {
61+ return { path : linkPath , changed : true , dryRun : true } ;
62+ }
63+
64+ await mkdir ( path . dirname ( linkPath ) , { recursive : true } ) ;
65+ const tempLink = `${ linkPath } .tmp-${ process . pid } -${ Date . now ( ) } ` ;
66+ let backup = null ;
5267 if ( await pathExists ( linkPath ) ) {
53- const backup = backupPath ( linkPath ) ;
68+ backup = backupPath ( linkPath ) ;
5469 await rename ( linkPath , backup ) ;
5570 console . log ( `Moved existing ${ linkPath } to ${ backup } ` ) ;
5671 }
5772
58- await symlink ( targetPath , linkPath , "dir" ) ;
59- return { path : linkPath , changed : true } ;
73+ try {
74+ await symlink ( targetPath , tempLink , "dir" ) ;
75+ await rename ( tempLink , linkPath ) ;
76+ return { path : linkPath , changed : true } ;
77+ } catch ( error ) {
78+ await rm ( tempLink , { recursive : true , force : true } ) . catch ( ( ) => { } ) ;
79+ if ( backup ) await rename ( backup , linkPath ) . catch ( ( ) => { } ) ;
80+ throw error ;
81+ }
6082}
6183
6284async function ensureInstalledPluginsEntry ( ) {
63- await mkdir ( path . dirname ( installedPluginsPath ) , { recursive : true } ) ;
85+ assertSafePath ( installedPluginsPath ) ;
6486 let data = { version : 2 , plugins : { } } ;
6587 if ( await pathExists ( installedPluginsPath ) ) {
6688 data = JSON . parse ( await readFile ( installedPluginsPath , "utf8" ) ) ;
@@ -83,7 +105,10 @@ async function ensureInstalledPluginsEntry() {
83105 } ;
84106
85107 data . plugins [ key ] = [ entry , ...entries . filter ( ( candidate ) => candidate !== existing ) ] ;
86- await writeFile ( installedPluginsPath , `${ JSON . stringify ( data , null , 2 ) } \n` ) ;
108+ if ( ! dryRun ) {
109+ await mkdir ( path . dirname ( installedPluginsPath ) , { recursive : true } ) ;
110+ await writeFile ( installedPluginsPath , `${ JSON . stringify ( data , null , 2 ) } \n` ) ;
111+ }
87112 return entry ;
88113}
89114
@@ -94,4 +119,5 @@ const entry = await ensureInstalledPluginsEntry();
94119console . log ( `Marketplace plugin path: ${ marketplaceLink . path } -> ${ root } ` ) ;
95120console . log ( `Installed plugin path: ${ cacheLink . path } -> ${ root } ` ) ;
96121console . log ( `Installed plugin entry: ${ entry . installPath } ` ) ;
122+ if ( dryRun ) console . log ( "Dry run only; no Claude plugin files were modified." ) ;
97123console . log ( "Claude Code CLI and Claude Desktop CLI share this ~/.claude plugin install." ) ;
0 commit comments