install-context.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. #!/usr/bin/env bun
  2. /**
  3. * install-context.ts
  4. * TypeScript context installer for OAC Claude Code Plugin
  5. *
  6. * Downloads context files from OpenAgents Control repository using git sparse-checkout
  7. * Supports profile-based installation (essential, standard, extended, specialized, all)
  8. */
  9. import { existsSync, writeFileSync, readFileSync } from 'fs'
  10. import { join, relative, dirname } from 'path'
  11. import type { InstallOptions, InstallResult, Profile } from './types/registry'
  12. import type { Manifest, ManifestComponent } from './types/manifest'
  13. import { fetchRegistry, filterContextByProfile, filterContextByIds, getUniquePaths } from './utils/registry-fetcher'
  14. import { sparseClone, copyFiles, cleanup, checkGitAvailable } from './utils/git-sparse'
  15. // Configuration
  16. const GITHUB_REPO = 'darrenhinde/OpenAgentsControl'
  17. const GITHUB_BRANCH = 'main'
  18. const CONTEXT_SOURCE_PATH = '.opencode/context'
  19. const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || process.cwd()
  20. const CONTEXT_DIR = join(PLUGIN_ROOT, 'context')
  21. const MANIFEST_FILE = join(PLUGIN_ROOT, '.context-manifest.json')
  22. // Colors for output
  23. const colors = {
  24. reset: '\x1b[0m',
  25. red: '\x1b[31m',
  26. green: '\x1b[32m',
  27. yellow: '\x1b[33m',
  28. blue: '\x1b[34m',
  29. cyan: '\x1b[36m',
  30. bold: '\x1b[1m',
  31. }
  32. // Logging helpers
  33. const log = {
  34. info: (msg: string) => console.log(`${colors.blue}ℹ${colors.reset} ${msg}`),
  35. success: (msg: string) => console.log(`${colors.green}✓${colors.reset} ${msg}`),
  36. warning: (msg: string) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`),
  37. error: (msg: string) => console.error(`${colors.red}✗${colors.reset} ${msg}`),
  38. header: (msg: string) => console.log(`\n${colors.bold}${colors.cyan}${msg}${colors.reset}\n`),
  39. }
  40. /**
  41. * Main installation function
  42. */
  43. export async function installContext(options: InstallOptions = {}): Promise<InstallResult> {
  44. const {
  45. profile = 'essential',
  46. customComponents = [],
  47. dryRun = false,
  48. force = false,
  49. verbose = false,
  50. } = options
  51. try {
  52. // Check dependencies
  53. if (!checkGitAvailable()) {
  54. throw new Error('Git is not available. Please install git.')
  55. }
  56. // Check if already installed
  57. if (existsSync(MANIFEST_FILE) && !force) {
  58. log.warning('Context already installed. Use --force to reinstall.')
  59. const manifest = JSON.parse(readFileSync(MANIFEST_FILE, 'utf-8')) as Manifest
  60. return {
  61. success: true,
  62. manifest: convertManifestToInstallManifest(manifest),
  63. }
  64. }
  65. log.header('Context Installer')
  66. log.info(`Profile: ${profile}`)
  67. log.info(`Repository: ${GITHUB_REPO}`)
  68. log.info(`Branch: ${GITHUB_BRANCH}`)
  69. log.info(`Dry run: ${dryRun}`)
  70. console.log('')
  71. // Fetch registry
  72. log.info('Fetching registry from GitHub...')
  73. const registry = await fetchRegistry({
  74. source: 'github',
  75. repository: GITHUB_REPO,
  76. branch: GITHUB_BRANCH,
  77. })
  78. log.success(`Registry version: ${registry.version}`)
  79. log.success(`Context components available: ${registry.components.contexts?.length || 0}`)
  80. console.log('')
  81. // Filter components by profile or custom IDs
  82. let components
  83. if (customComponents.length > 0) {
  84. log.info('Filtering by custom component IDs...')
  85. components = filterContextByIds(registry, customComponents)
  86. } else {
  87. log.info(`Filtering by profile: ${profile}`)
  88. components = filterContextByProfile(registry, profile)
  89. }
  90. log.success(`Selected ${components.length} components`)
  91. if (verbose) {
  92. console.log('')
  93. log.info('Components to install:')
  94. for (const component of components) {
  95. console.log(` - ${component.id}: ${component.path}`)
  96. }
  97. }
  98. console.log('')
  99. if (dryRun) {
  100. log.info('Dry run mode - no files will be downloaded')
  101. return {
  102. success: true,
  103. manifest: {
  104. version: '1.0.0',
  105. profile: customComponents.length > 0 ? 'custom' : profile,
  106. source: {
  107. repository: GITHUB_REPO,
  108. branch: GITHUB_BRANCH,
  109. commit: 'dry-run',
  110. downloaded_at: new Date().toISOString(),
  111. },
  112. context: components.map((c) => ({
  113. id: c.id,
  114. name: c.name,
  115. path: c.path,
  116. local_path: join(CONTEXT_DIR, c.path.replace(`${CONTEXT_SOURCE_PATH}/`, '')),
  117. category: c.category,
  118. })),
  119. },
  120. }
  121. }
  122. // Get unique directory paths for sparse checkout
  123. const sparsePaths = getUniquePaths(components)
  124. if (verbose) {
  125. log.info('Sparse checkout paths:')
  126. for (const path of sparsePaths) {
  127. console.log(` - ${path}`)
  128. }
  129. console.log('')
  130. }
  131. // Download using git sparse-checkout
  132. log.info('Downloading context files...')
  133. const tempDir = join(PLUGIN_ROOT, '.tmp-context-download')
  134. const cloneResult = await sparseClone({
  135. repository: GITHUB_REPO,
  136. branch: GITHUB_BRANCH,
  137. paths: sparsePaths,
  138. targetDir: tempDir,
  139. verbose,
  140. })
  141. if (!cloneResult.success) {
  142. throw new Error(`Git sparse clone failed: ${cloneResult.error}`)
  143. }
  144. log.success('Files downloaded successfully')
  145. console.log('')
  146. // Copy files to context directory
  147. log.info('Copying files to context directory...')
  148. const sourceContextDir = join(tempDir, CONTEXT_SOURCE_PATH)
  149. copyFiles(sourceContextDir, CONTEXT_DIR, verbose)
  150. log.success(`Files copied to: ${CONTEXT_DIR}`)
  151. console.log('')
  152. // Create manifest
  153. log.info('Creating manifest...')
  154. const manifestComponents: ManifestComponent[] = components.map((c) => ({
  155. id: c.id,
  156. name: c.name,
  157. path: c.path,
  158. local_path: join(CONTEXT_DIR, c.path.replace(`${CONTEXT_SOURCE_PATH}/`, '')),
  159. category: c.category,
  160. }))
  161. const manifest: Manifest = {
  162. version: '1.0.0',
  163. profile: customComponents.length > 0 ? 'custom' : profile,
  164. source: {
  165. repository: GITHUB_REPO,
  166. branch: GITHUB_BRANCH,
  167. commit: cloneResult.commit,
  168. downloaded_at: new Date().toISOString(),
  169. },
  170. context: manifestComponents,
  171. }
  172. writeFileSync(MANIFEST_FILE, JSON.stringify(manifest, null, 2))
  173. log.success(`Manifest created: ${MANIFEST_FILE}`)
  174. console.log('')
  175. // Write .oac.json at project root so context-scout uses fast path on next session
  176. const projectRoot = dirname(PLUGIN_ROOT)
  177. const oacJsonPath = join(projectRoot, '.oac.json')
  178. const contextRelPath = relative(projectRoot, CONTEXT_DIR).replace(/\\/g, '/')
  179. if (!existsSync(oacJsonPath)) {
  180. const oacConfig = { version: '1', context: { root: contextRelPath } }
  181. writeFileSync(oacJsonPath, JSON.stringify(oacConfig, null, 2))
  182. log.success(`.oac.json created at project root → context.root = "${contextRelPath}"`)
  183. } else {
  184. log.info(`.oac.json already exists at project root — skipping (use --force to overwrite)`)
  185. }
  186. console.log('')
  187. // Clean up temp directory
  188. cleanup(tempDir, verbose)
  189. // Verify installation
  190. log.info('Verifying installation...')
  191. let filesExist = 0
  192. let filesMissing = 0
  193. for (const component of manifestComponents) {
  194. if (existsSync(component.local_path)) {
  195. filesExist++
  196. if (verbose) {
  197. log.success(`${component.id}: EXISTS`)
  198. }
  199. } else {
  200. filesMissing++
  201. log.error(`${component.id}: MISSING`)
  202. }
  203. }
  204. console.log('')
  205. log.success(`Installation complete!`)
  206. log.info(`Files verified: ${filesExist}/${manifestComponents.length}`)
  207. if (filesMissing > 0) {
  208. log.warning(`Missing files: ${filesMissing}`)
  209. }
  210. return {
  211. success: true,
  212. manifest: convertManifestToInstallManifest(manifest),
  213. }
  214. } catch (error) {
  215. log.error('Installation failed')
  216. log.error(error instanceof Error ? error.message : String(error))
  217. return {
  218. success: false,
  219. manifest: {
  220. version: '1.0.0',
  221. profile: 'essential',
  222. source: {
  223. repository: GITHUB_REPO,
  224. branch: GITHUB_BRANCH,
  225. commit: '',
  226. downloaded_at: new Date().toISOString(),
  227. },
  228. context: [],
  229. },
  230. errors: [error instanceof Error ? error.message : String(error)],
  231. }
  232. }
  233. }
  234. /**
  235. * Convert Manifest to InstallManifest format
  236. */
  237. function convertManifestToInstallManifest(manifest: Manifest): InstallResult['manifest'] {
  238. return manifest as InstallResult['manifest']
  239. }
  240. /**
  241. * Show usage information
  242. */
  243. function showUsage(): void {
  244. console.log(`
  245. ${colors.bold}Usage:${colors.reset} bun run install-context.ts [OPTIONS]
  246. ${colors.bold}OPTIONS:${colors.reset}
  247. --profile=PROFILE Installation profile (default: essential)
  248. Options: essential, standard, extended, specialized, all
  249. --component=ID Install specific component by ID (can be used multiple times)
  250. Example: --component=core-standards --component=openagents-repo
  251. --dry-run Show what would be installed without downloading
  252. --force Force reinstall even if context exists
  253. --verbose Show detailed output
  254. --help Show this help message
  255. ${colors.bold}PROFILES:${colors.reset}
  256. essential Minimal components for basic functionality
  257. standard Standard components for typical use
  258. extended Extended components for advanced features
  259. specialized Specialized components for specific domains
  260. all All available context
  261. ${colors.bold}EXAMPLES:${colors.reset}
  262. # Install essential profile (default)
  263. bun run install-context.ts
  264. # Install standard profile
  265. bun run install-context.ts --profile=standard
  266. # Install specific components
  267. bun run install-context.ts --component=core-standards --component=openagents-repo
  268. # Dry run to see what would be installed
  269. bun run install-context.ts --profile=extended --dry-run
  270. # Force reinstall
  271. bun run install-context.ts --force --verbose
  272. `)
  273. }
  274. /**
  275. * CLI entry point
  276. */
  277. async function main(): Promise<void> {
  278. const args = process.argv.slice(2)
  279. // Parse arguments
  280. let profile: Profile = 'essential'
  281. const customComponents: string[] = []
  282. let dryRun = false
  283. let force = false
  284. let verbose = false
  285. for (const arg of args) {
  286. if (arg === '--help' || arg === '-h') {
  287. showUsage()
  288. process.exit(0)
  289. } else if (arg === '--dry-run') {
  290. dryRun = true
  291. } else if (arg === '--force') {
  292. force = true
  293. } else if (arg === '--verbose' || arg === '-v') {
  294. verbose = true
  295. } else if (arg.startsWith('--profile=')) {
  296. profile = arg.split('=')[1] as Profile
  297. } else if (arg.startsWith('--component=')) {
  298. customComponents.push(arg.split('=')[1])
  299. } else {
  300. log.error(`Unknown argument: ${arg}`)
  301. showUsage()
  302. process.exit(1)
  303. }
  304. }
  305. // Run installation
  306. const result = await installContext({
  307. profile,
  308. customComponents,
  309. dryRun,
  310. force,
  311. verbose,
  312. })
  313. if (!result.success) {
  314. process.exit(1)
  315. }
  316. }
  317. // Run main function
  318. main().catch((error) => {
  319. console.error('Fatal error:', error)
  320. process.exit(1)
  321. })