|
| 1 | +import { execSync } from "node:child_process"; |
| 2 | +import { existsSync, mkdirSync } from "node:fs"; |
| 3 | +import { dirname, join } from "node:path"; |
| 4 | +import * as p from "@clack/prompts"; |
| 5 | +import { adapterRegistry } from "../adapters/registry"; |
| 6 | +import type { Platform } from "../adapters/types"; |
| 7 | +import { |
| 8 | + detectInstalledAgents, |
| 9 | + getAgentMetadata, |
| 10 | + getAgentsWithAdapters, |
| 11 | +} from "../agents"; |
| 12 | +import { configExists, initConfig } from "../config/manager"; |
| 13 | +import { SUPPORTED_AGENTS } from "../config/types"; |
| 14 | +import { contractHome, expandHome } from "../utils/paths"; |
| 15 | + |
| 16 | +export async function initCommand() { |
| 17 | + p.intro("Initialize from Existing Repo"); |
| 18 | + |
| 19 | + if (configExists()) { |
| 20 | + const overwrite = await p.confirm({ |
| 21 | + message: |
| 22 | + "Configuration already exists at ~/.syncode/config.json. Overwrite?", |
| 23 | + initialValue: false, |
| 24 | + }); |
| 25 | + |
| 26 | + if (p.isCancel(overwrite) || !overwrite) { |
| 27 | + p.cancel("Initialization cancelled."); |
| 28 | + return; |
| 29 | + } |
| 30 | + } |
| 31 | + |
| 32 | + const repoUrlInput = await p.text({ |
| 33 | + message: "Repository URL", |
| 34 | + placeholder: "https://github.com/<username>/configs.git", |
| 35 | + validate: (value) => { |
| 36 | + if (!value) return "Repository URL is required"; |
| 37 | + return undefined; |
| 38 | + }, |
| 39 | + }); |
| 40 | + |
| 41 | + if (p.isCancel(repoUrlInput)) { |
| 42 | + p.cancel("Initialization cancelled."); |
| 43 | + return; |
| 44 | + } |
| 45 | + |
| 46 | + const repoPathInput = await p.text({ |
| 47 | + message: "Where should the agent configs be stored?", |
| 48 | + placeholder: "~/.syncode/repo", |
| 49 | + initialValue: "~/.syncode/repo", |
| 50 | + validate: (value) => { |
| 51 | + if (!value) return "Repository path is required"; |
| 52 | + return undefined; |
| 53 | + }, |
| 54 | + }); |
| 55 | + |
| 56 | + if (p.isCancel(repoPathInput)) { |
| 57 | + p.cancel("Initialization cancelled."); |
| 58 | + return; |
| 59 | + } |
| 60 | + |
| 61 | + const repoPath = expandHome(repoPathInput); |
| 62 | + |
| 63 | + if (existsSync(repoPath)) { |
| 64 | + const useExisting = await p.confirm({ |
| 65 | + message: `Directory ${contractHome(repoPath)} already exists. Use it?`, |
| 66 | + initialValue: true, |
| 67 | + }); |
| 68 | + |
| 69 | + if (p.isCancel(useExisting) || !useExisting) { |
| 70 | + p.cancel("Initialization cancelled."); |
| 71 | + return; |
| 72 | + } |
| 73 | + |
| 74 | + const gitDir = join(repoPath, ".git"); |
| 75 | + if (!existsSync(gitDir)) { |
| 76 | + p.cancel("Directory is not a git repository."); |
| 77 | + return; |
| 78 | + } |
| 79 | + } else { |
| 80 | + try { |
| 81 | + mkdirSync(dirname(repoPath), { recursive: true }); |
| 82 | + } catch (error) { |
| 83 | + p.cancel(`Failed to create directory: ${error}`); |
| 84 | + return; |
| 85 | + } |
| 86 | + |
| 87 | + try { |
| 88 | + execSync(`git clone "${repoUrlInput}" "${repoPath}"`, { |
| 89 | + stdio: "pipe", |
| 90 | + }); |
| 91 | + p.log.success("✓ Cloned repository"); |
| 92 | + } catch (_error) { |
| 93 | + p.cancel("Failed to clone repository."); |
| 94 | + return; |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + const platform: Platform = |
| 99 | + process.platform === "darwin" |
| 100 | + ? "macos" |
| 101 | + : process.platform === "win32" |
| 102 | + ? "windows" |
| 103 | + : "linux"; |
| 104 | + const detectedAgents = detectInstalledAgents(platform); |
| 105 | + const agentsWithAdapters = getAgentsWithAdapters(); |
| 106 | + |
| 107 | + const repoAgents = SUPPORTED_AGENTS.filter((id) => { |
| 108 | + const adapter = adapterRegistry.get(id); |
| 109 | + if (!adapter) return false; |
| 110 | + return existsSync(adapter.getRepoPath(repoPath)); |
| 111 | + }); |
| 112 | + |
| 113 | + const agentOptions = SUPPORTED_AGENTS.map((id) => { |
| 114 | + const detected = detectedAgents.includes(id); |
| 115 | + const hasAdapter = agentsWithAdapters.includes(id); |
| 116 | + const metadata = getAgentMetadata(id); |
| 117 | + const label = |
| 118 | + metadata?.displayName || id.charAt(0).toUpperCase() + id.slice(1); |
| 119 | + const hint = detected |
| 120 | + ? hasAdapter |
| 121 | + ? "Installed • Full sync" |
| 122 | + : "Installed • Metadata only" |
| 123 | + : hasAdapter |
| 124 | + ? "Not found • Full sync available" |
| 125 | + : "Not found"; |
| 126 | + |
| 127 | + return { |
| 128 | + value: id, |
| 129 | + label: detected ? `${label} (detected ✓)` : label, |
| 130 | + hint, |
| 131 | + }; |
| 132 | + }); |
| 133 | + |
| 134 | + const agentsInput = await p.multiselect({ |
| 135 | + message: "Which AI agents do you want to sync?", |
| 136 | + options: agentOptions, |
| 137 | + initialValues: repoAgents.length > 0 ? repoAgents : detectedAgents, |
| 138 | + required: false, |
| 139 | + }); |
| 140 | + |
| 141 | + if (p.isCancel(agentsInput)) { |
| 142 | + p.cancel("Initialization cancelled."); |
| 143 | + return; |
| 144 | + } |
| 145 | + |
| 146 | + const selectedAgents = agentsInput as string[]; |
| 147 | + |
| 148 | + if (selectedAgents.length === 0) { |
| 149 | + p.log.warn( |
| 150 | + "No agents selected. You can add them later by editing ~/.syncode/config.json", |
| 151 | + ); |
| 152 | + } |
| 153 | + |
| 154 | + if (selectedAgents.length > 0) { |
| 155 | + const selectedWithAdapters = selectedAgents.filter((id) => |
| 156 | + agentsWithAdapters.includes(id), |
| 157 | + ); |
| 158 | + const selectedWithoutAdapters = selectedAgents.filter( |
| 159 | + (id) => !agentsWithAdapters.includes(id), |
| 160 | + ); |
| 161 | + |
| 162 | + if (selectedWithAdapters.length > 0) { |
| 163 | + p.log.info("Using smart sync defaults:"); |
| 164 | + p.log.info( |
| 165 | + " • Symlinks: Cursor, OpenCode, Windsurf, VSCode (live sync)", |
| 166 | + ); |
| 167 | + p.log.info(" • Copy: Claude Code (preserves cache/history)"); |
| 168 | + } |
| 169 | + |
| 170 | + if (selectedWithoutAdapters.length > 0) { |
| 171 | + const agentNames = selectedWithoutAdapters |
| 172 | + .map((id) => getAgentMetadata(id)?.displayName || id) |
| 173 | + .join(", "); |
| 174 | + p.log.warn(`Note: ${agentNames} - metadata only (no sync adapter yet)`); |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + try { |
| 179 | + initConfig({ |
| 180 | + repoPath: repoPathInput, |
| 181 | + remote: repoUrlInput, |
| 182 | + agents: selectedAgents, |
| 183 | + }); |
| 184 | + |
| 185 | + const agentList = |
| 186 | + selectedAgents.length > 0 |
| 187 | + ? selectedAgents |
| 188 | + .map((id) => { |
| 189 | + const adapter = adapterRegistry.get(id); |
| 190 | + return adapter ? adapter.name : id; |
| 191 | + }) |
| 192 | + .join(", ") |
| 193 | + : "none"; |
| 194 | + |
| 195 | + p.outro( |
| 196 | + `✓ Existing repository connected! |
| 197 | +
|
| 198 | +Repository: ${contractHome(repoPath)} |
| 199 | +Configuration: ~/.syncode/config.json |
| 200 | +Agents: ${agentList} |
| 201 | +
|
| 202 | +Next steps: |
| 203 | + • Run 'syncode sync' and select "Export" |
| 204 | + • Configs in ${contractHome(repoPath)}/configs will apply to this machine`, |
| 205 | + ); |
| 206 | + } catch (error) { |
| 207 | + p.cancel(`Failed to create configuration: ${error}`); |
| 208 | + } |
| 209 | +} |
0 commit comments