diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md index 1a5cdc3..801f955 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Specs, plans, design options, architecture decisions — these are not paperwork ## How It Works -A centralized `.agents/` directory and a universal `AGENTS.md` instruction file that every agent reads. Agents follow a **mode-tailored execution protocol**: depending on the selected mode (Prototype, Spec Driven, Rigid), they read relevant specs, create plans, explore options, and update artifacts before and after every code change. +A centralized `.agents/` directory and a universal `AGENTS.md` instruction file that every agent reads. Agents follow a **mode-tailored execution protocol**: depending on the selected mode (Prototype, Spec Driven, or Rigid), they read relevant specs, create plans, explore options, and update artifacts before and after every code change. ``` .agents/ @@ -45,24 +45,49 @@ A centralized `.agents/` directory and a universal `AGENTS.md` instruction file ## Quick Start ```bash -# 1. Run the interactive setup script with a target directory -./codewiser.sh ./my-project +# Interactively set up codewiser in a target project directory +bunx codewiser my-project -# 2. Select your AI agents and development mode (Prototype, Spec Driven, or Rigid) -# The script downloads the relevant skills, spec templates, and creates -# agent-specific configs with symlinks to the shared `.agents/skills/` directory. +# Or via npm (after publishing) +npx codewiser my-project -# 3. Start coding — agents read shared context from AGENTS.md (customized with your -# mode's execution protocol), load skills from `.agents/skills/`, and follow the -# tailored workflow defined by the selected mode. +# Or if linked locally +codewiser my-project ``` -On Windows (PowerShell): +The CLI guides you through an interactive session: -```powershell -.\codewiser.ps1 .\my-project +1. **Select AI agents** — Choose which coding agents to configure (OpenCode, Claude Code, Cursor, Antigravity, Kilo Code) +2. **Configure mode** — Pick a development mode (Prototype, Spec Driven, or Rigid) — or select workflows in legacy mode +3. **Download files** — Downloads shared skills, spec templates, and manifest from GitHub +4. **Generate configs** — Creates agent-specific configuration files +5. **Create symlinks** — Symlinks shared skills into each agent's private directory + +Use `← Back` options to navigate between steps. Press `Esc` at any time to exit. + +## Managing the Source Repo + +Codewiser syncs skills and specs from a GitHub repo's `codewiser.json` manifest. The source repo/branch is resolved from three tiers only: the project's `./codewiser.json` (if it sets `repo`/`branch`), then your user-profile `~/.codewiser.json` (if set), and finally the bundled default (`yallma3/codewiser` @ `main`). The fetched manifest's own `repo`/`branch` fields are informational and never override the effective source. + +From the root of a project that has a `codewiser.json`: + +```bash +# Show the effective repo/branch and which source each value comes from +codewiser repo + +# Point the manifest at a different repo/branch +codewiser repo set owner/repo --branch main + +# Remove the overrides so the built-in defaults apply +codewiser repo reset + +# Set a machine-wide (user profile) default instead of ./codewiser.json +codewiser repo set owner/repo --branch main -g +codewiser repo reset -g # clear the user-wide default ``` +`--repo` and `--branch` flags on the main command act as one-off overrides for a single run; they do not persist. Repo/branch resolution order is: CLI flag → `./codewiser.json` in the current directory (what `codewiser repo set` edits) → `~/.codewiser.json` in your user profile (what `codewiser repo set -g` edits) → bundled default (`yallma3/codewiser` @ `main`). `/.codewiser.json` only tracks downloaded file versions (for re-run comparisons); it never overrides the manifest. + ## Supported Agents | Agent | Config File | Integration | @@ -85,23 +110,49 @@ The exact workflow depends on the selected mode (Prototype, Spec Driven, or Rigi ## Adding a New Skill -Skills are shared across all agents. Create a file at `.agents/skills//SKILL.md` with instructions for what the skill does. Then add it to the relevant workflow stage's `files` section in `manifest.json` with an initial version. The setup script symlinks this directory into each agent's private config so every agent can load it. +Skills are shared across all agents. Create a file at `.agents/skills//SKILL.md` with instructions for what the skill does. Then add it to the relevant workflow stage's `files` section in `codewiser.json` with an initial version. The setup script symlinks this directory into each agent's private config so every agent can load it. Example: the [git-worktrees skill](.agents/skills/shared/git-worktrees/SKILL.md) was added to teach agents how to isolate feature work using branches and worktrees during concurrent multi-agent development. -## Setup Scripts +## Requirements -| Platform | Script | Source | -|---|---|---| -| Linux / macOS | `codewiser.sh` | Downloads `AGENTS.md`, skills, and specs from `https://github.com/yallma3/codewiser` | -| Windows | `codewiser.ps1` | Same logic via PowerShell with `Invoke-WebRequest` | +- **Bun** (recommended) or **Node.js 20.12+** +- Git -Both scripts use `manifest.json` to track artifact versions organized by development modes. During setup, you select which AI agents and which mode (**Prototype**, **Spec Driven**, or **Rigid**) to use. The selected mode determines which skills are downloaded and customizes `AGENTS.md` with the appropriate execution protocol. +## Development -## Requirements +```bash +# Clone and install +git clone https://github.com/yallma3/codewiser.git +cd codewiser +bun install -- Bash **or** PowerShell 5+ -- Git +# Link globally (optional) +bun link + +# Run directly +bun start my-project + +# Or after linking +codewiser my-project +``` + +## How the CLI Works + +The CLI uses [@clack/prompts](https://github.com/natemoo-re/clack) for interactive prompts and [meow](https://github.com/sindresorhus/meow) for CLI argument parsing. It downloads skills and specs from the [codewiser](https://github.com/yallma3/codewiser) repository based on a `codewiser.json` that tracks artifact versions organized by development modes. + +### Architecture + +- `src/index.ts` — Entry point, parses CLI arguments, resolves target directory, dispatches subcommands +- `src/commands/init.ts` — State machine orchestrating the 5-step setup process +- `src/commands/repo.ts` — `repo` subcommand: get/set/reset `repo`/`branch` in the manifest or user-profile (`-g`) +- `src/utils/ui.ts` — Prompt wrappers with stdin resilience (@clack wrappers) +- `src/utils/prompts.ts` — Typed prompt functions for agent/mode/workflow selection +- `src/utils/download.ts` — HTTP download via `fetch()` + `Bun.write()` +- `src/utils/manifest.ts` — Manifest parsing, version comparison, file flattening +- `src/utils/config.ts` — `.codewiser.json` / `~/.codewiser.json` read/write, repo/branch resolution +- `src/utils/generate-configs.ts` — Agent config file generation +- `src/utils/symlinks.ts` — Symlink creation with admin retry and copy fallback ## License diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..01f7761 --- /dev/null +++ b/bun.lock @@ -0,0 +1,47 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "codewiser-cli", + "dependencies": { + "@clack/prompts": "^1.7.0", + "meow": "^14.1.0", + "picocolors": "^1.1.1", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="], + + "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="], + + "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], + + "meow": ["meow@14.1.0", "", {}, "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/.agents/manifest.json b/codewiser.json similarity index 97% rename from .agents/manifest.json rename to codewiser.json index f9c42b3..b2ccd60 100644 --- a/.agents/manifest.json +++ b/codewiser.json @@ -1,6 +1,6 @@ { - "$schema": "manifest.json", - "description": "Version manifest for downloadable framework artifacts. Organized by modes. Used by codewiser.sh to detect and prompt for updates.", + "$schema": "codewiser.json", + "description": "Version manifest for downloadable framework artifacts. Organized by modes. Used by codewiser CLI to detect and prompt for updates.", "version": "3.0.0", "modes": { "prototype": { @@ -143,5 +143,7 @@ ".agents/specs/spec-index.json": "1.0.0" } } - } -} + }, + "repo": "mostafamm44/codewiser", + "branch": "sync/codewiser.json" +} \ No newline at end of file diff --git a/codewiser.ps1 b/codewiser.ps1 deleted file mode 100644 index 89676b8..0000000 --- a/codewiser.ps1 +++ /dev/null @@ -1,562 +0,0 @@ -param( - [Parameter(Mandatory=$true)] - [string]$TargetDir -) - -<# -.SYNOPSIS - Initializes the codewiser multi-agent framework in a target directory. -.DESCRIPTION - Downloads shared skills, specs, and creates agent-specific configs - (OpenCode, Claude Code, Cursor, Antigravity, Kilo Code). - Requires PowerShell 5+ and Git. -.NOTES - If you see "running scripts is disabled", run: - Set-ExecutionPolicy RemoteSigned -Scope CurrentUser - If symlinks fail, enable Developer Mode or run as Administrator. -#> - -# Enable TLS 1.2 for GitHub downloads (disabled by default on many Windows builds) -[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - -$RAW_BASE = "https://raw.githubusercontent.com/yallma3/codewiser/main" - -$resolvedDir = Resolve-Path $TargetDir -ErrorAction SilentlyContinue -if ($resolvedDir) { - $TargetDir = $resolvedDir -} else { - $TargetDir = [System.IO.Path]::GetFullPath($TargetDir) -} -New-Item -ItemType Directory -Path $TargetDir -Force | Out-Null - -Write-Host ">> Setting up codewiser in $TargetDir..." - -# --- Agent Selection (checkbox-style) --- -$choices = @("OpenCode / MiMo / Crush", "Claude Code", "Cursor", "Antigravity", "Kilo Code") -$selections = @($false, $false, $false, $false, $false) - -Write-Host "" -Write-Host "Select AI agents (enter number to toggle, 'd' when done):" - -$agentDone = $false -while (-not $agentDone) { - for ($i = 0; $i -lt $choices.Length; $i++) { - $mark = " " - if ($selections[$i]) { $mark = "x" } - Write-Host " [$mark] $($i+1). $($choices[$i])" - } - Write-Host " [ ] d) Done" - Write-Host " [ ] c) Cancel" - $choice = Read-Host "> " - - switch ($choice) { - { $_ -in "1","2","3","4","5" } { - $idx = [int]$choice - 1 - $selections[$idx] = -not $selections[$idx] - } - { $_ -in "d","D","" } { - $any = $false - foreach ($s in $selections) { if ($s) { $any = $true; break } } - if (-not $any) { - Write-Host "No agents selected. Cancelled." - exit 0 - } - $agentDone = $true - break - } - { $_ -in "c","C" } { Write-Host "Cancelled."; exit 0 } - default { Write-Host " Invalid choice." } - } -} - -$use_opencode = $selections[0] -$use_claude = $selections[1] -$use_cursor = $selections[2] -$use_antigravity = $selections[3] -$use_kilo = $selections[4] - -# --- 1. Create directory structure --- -Write-Host "" -Write-Host ">> Creating directories..." -mkdir "$TargetDir\.agents" -Force | Out-Null -mkdir "$TargetDir\.agents\skills" -Force | Out-Null -mkdir "$TargetDir\.agents\specs" -Force | Out-Null -mkdir "$TargetDir\.agents\plans" -Force | Out-Null -mkdir "$TargetDir\.agents\research" -Force | Out-Null - -if ($use_claude) { mkdir "$TargetDir\.claude" -Force | Out-Null } -if ($use_cursor) { mkdir "$TargetDir\.cursor" -Force | Out-Null } -if ($use_antigravity) { mkdir "$TargetDir\.antigravity" -Force | Out-Null } -if ($use_kilo) { mkdir "$TargetDir\.kilo" -Force | Out-Null } - -# --- Helper: download a file --- -function Download { - param([string]$Url, [string]$Dest) - mkdir (Split-Path $Dest -Parent) -Force | Out-Null - try { - Invoke-WebRequest -Uri $Url -OutFile $Dest -ErrorAction Stop | Out-Null - return $true - } catch { - return $false - } -} - -# --- Helper: compare versions (returns $true if v1 < v2) --- -function Version-Lt { - param([string]$v1, [string]$v2) - $parts1 = $v1.Split('.') - $parts2 = $v2.Split('.') - for ($i = 0; $i -lt [Math]::Max($parts1.Length, $parts2.Length); $i++) { - $p1 = if ($i -lt $parts1.Length) { [int]$parts1[$i] } else { 0 } - $p2 = if ($i -lt $parts2.Length) { [int]$parts2[$i] } else { 0 } - if ($p1 -lt $p2) { return $true } - if ($p1 -gt $p2) { return $false } - } - return $false -} - -# --- Helper: get version string for a file path from local manifest --- -function Get-ManifestVersion { - param([string]$ManifestPath, [string]$FilePath) - if (-not (Test-Path $ManifestPath)) { return $null } - $obj = Get-Content $ManifestPath -Raw | ConvertFrom-Json - if ($obj.modes) { - foreach ($mode in $obj.modes.PSObject.Properties.Value) { - if ($mode.files.$FilePath) { - $v = $mode.files.$FilePath - return (Extract-Version $v) - } - } - return $null - } elseif ($obj.workflows) { - foreach ($wf in $obj.workflows.PSObject.Properties.Value) { - foreach ($stage in $wf.stages.PSObject.Properties.Value) { - if ($stage.files.$FilePath) { - $v = $stage.files.$FilePath - return (Extract-Version $v) - } - } - } - return $null - } elseif ($obj.files) { - return (Extract-Version $obj.files.$FilePath) - } - return $null -} - -function Extract-Version { - param([object]$Value) - if ($Value -is [string]) { return $Value } - if ($Value.version) { return $Value.version } - return "0.0.0" -} - -# --- Helper: flatten selected workflows into a file dictionary --- -function Flatten-WorkflowFiles { - param([PSObject]$ManifestObj, [int[]]$SelectedIndices) - $wfNames = @($ManifestObj.workflows.PSObject.Properties.Name) - $files = @{} - foreach ($idx in $SelectedIndices) { - $wfName = $wfNames[$idx] - $wf = $ManifestObj.workflows.$wfName - foreach ($stage in $wf.stages.PSObject.Properties.Value) { - foreach ($entry in $stage.files.PSObject.Properties) { - $files[$entry.Name] = Extract-Version $entry.Value - } - } - } - return $files -} - -# --- 2. Download remote manifest --- -Write-Host "" -Write-Host ">> Fetching manifest..." - -$localManifestPath = "$TargetDir\.agents\manifest.json" -$remoteManifest = New-TemporaryFile - -$manifestOk = Download -Url "$RAW_BASE/.agents/manifest.json" -Dest $remoteManifest.FullName -if (-not $manifestOk) { - Write-Host " !! Failed to download manifest. Aborting." - Remove-Item $remoteManifest.FullName -Force -ErrorAction SilentlyContinue - exit 1 -} - -$remoteManifestObj = Get-Content $remoteManifest.FullName -Raw | ConvertFrom-Json - -$localManifestObj = $null -if (Test-Path $localManifestPath) { - $localManifestObj = Get-Content $localManifestPath -Raw | ConvertFrom-Json -} - -# Detect format and optionally prompt for mode/workflow selection -$remoteFiles = @{} -$selectedMode = "" - -if ($remoteManifestObj.modes) { - # --- Mode Selection (single choice) --- - $modeNames = @($remoteManifestObj.modes.PSObject.Properties.Name) - - if ($modeNames.Count -eq 0) { - Write-Host " !! No modes found in manifest. Aborting." - Remove-Item $remoteManifest.FullName -Force -ErrorAction SilentlyContinue - exit 1 - } - - Write-Host "" - Write-Host "Select development mode (enter number):" - - $modeDone = $false - while (-not $modeDone) { - for ($i = 0; $i -lt $modeNames.Count; $i++) { - $desc = $remoteManifestObj.modes.$($modeNames[$i]).description - $mark = " " - if ($selectedMode -eq $modeNames[$i]) { $mark = "x" } - Write-Host " [$mark] $($i+1). $($modeNames[$i])" - Write-Host " $desc" - } - Write-Host " [ ] c) Cancel" - $choice = Read-Host "> " - - switch ($choice) { - { $_ -match '^\d+$' } { - $idx = [int]$choice - 1 - if ($idx -ge $modeNames.Count) { - Write-Host " Invalid choice." - break - } - $selectedMode = $modeNames[$idx] - $modeDone = $true - } - { $_ -in "c","C" } { Write-Host "Cancelled."; Remove-Item $remoteManifest.FullName -Force -ErrorAction SilentlyContinue; exit 0 } - default { Write-Host " Invalid choice." } - } - } - - $modeDesc = $remoteManifestObj.modes.$selectedMode.description - - Write-Host "" - Write-Host ">> Checking for updates..." - Write-Host " Mode: $selectedMode -> $modeDesc" - - # Display skills for the selected mode - $modeFiles = $remoteManifestObj.modes.$selectedMode.files - $skills = @() - foreach ($entry in $modeFiles.PSObject.Properties) { - if ($entry.Name -like '*/SKILL.md') { - $skills += ($entry.Name -split '/')[-2] - } - } - if ($skills) { - Write-Host " Skills:" - foreach ($s in ($skills | Sort-Object)) { Write-Host " - $s" } - } - - # Flatten files for this mode (extract version strings) - foreach ($entry in $modeFiles.PSObject.Properties) { - $remoteFiles[$entry.Name] = Extract-Version $entry.Value - } - - $skillDirs = @() -} elseif ($remoteManifestObj.workflows) { - # --- Backward compatibility: legacy workflows structure (v2.x) --- - $wfNames = @($remoteManifestObj.workflows.PSObject.Properties.Name) - $wfSelections = @($false) * $wfNames.Count - - Write-Host "" - Write-Host "Select workflows to install (enter number to toggle, 'd' when done):" - - $wfDone = $false - while (-not $wfDone) { - for ($i = 0; $i -lt $wfNames.Count; $i++) { - $mark = " " - if ($wfSelections[$i]) { $mark = "x" } - Write-Host " [$mark] $($i+1). $($wfNames[$i])" - } - Write-Host " [ ] d) Done" - Write-Host " [ ] c) Cancel" - $choice = Read-Host "> " - - switch ($choice) { - { $_ -match '^\d+$' } { - $idx = [int]$choice - 1 - if ($idx -ge $wfNames.Count) { - Write-Host " Invalid choice." - break - } - $wfSelections[$idx] = -not $wfSelections[$idx] - } - { $_ -in "d","D","" } { - $any = $false - foreach ($s in $wfSelections) { if ($s) { $any = $true; break } } - if (-not $any) { - Write-Host " !! No workflows selected." - break - } - $wfDone = $true - break - } - { $_ -in "c","C" } { Write-Host "Cancelled."; Remove-Item $remoteManifest.FullName -Force -ErrorAction SilentlyContinue; exit 0 } - default { Write-Host " Invalid choice." } - } - } - - $selectedIndices = @() - $selectedNames = @() - for ($i = 0; $i -lt $wfSelections.Count; $i++) { - if ($wfSelections[$i]) { - $selectedIndices += $i - $selectedNames += $wfNames[$i] - } - } - - $skillDirs = @() - - Write-Host "" - Write-Host ">> Checking for updates..." - Write-Host " Workflows: $($selectedNames -join ' ')" - - $allFiles = @{} - foreach ($wfName in $remoteManifestObj.workflows.PSObject.Properties.Name) { - $wf = $remoteManifestObj.workflows.$wfName - $wfFiles = @() - foreach ($stage in $wf.stages.PSObject.Properties.Value) { - foreach ($entry in $stage.files.PSObject.Properties) { - $wfFiles += $entry.Name - } - } - $allFiles[$wfName] = $wfFiles - } - $allSkills = @{} - foreach ($wfName in $allFiles.Keys) { - foreach ($f in $allFiles[$wfName]) { - if ($f -like '*/SKILL.md') { - $skillName = ($f -split '/')[-2] - $allSkills[$skillName] = $true - } - } - } - $essential = @($allSkills.Keys | Where-Object { $_ -notlike 'frontend-*' -and $_ -notlike 'backend-*' -and $_ -notlike 'testdriven-*' } | Sort-Object) - $frontend = @($allSkills.Keys | Where-Object { $_ -like 'frontend-*' } | Sort-Object) - $backend = @($allSkills.Keys | Where-Object { $_ -like 'backend-*' } | Sort-Object) - $testdriven = @($allSkills.Keys | Where-Object { $_ -like 'testdriven-*' } | Sort-Object) - if ($essential) { - Write-Host " Essential Skills:" - foreach ($s in $essential) { Write-Host " - $s" } - } - if ($frontend) { - Write-Host " frontend:" - foreach ($s in $frontend) { Write-Host " - $s" } - } - if ($backend) { - Write-Host " backend:" - foreach ($s in $backend) { Write-Host " - $s" } - } - if ($testdriven) { - Write-Host " testdriven:" - foreach ($s in $testdriven) { Write-Host " - $s" } - } - - $remoteFiles = Flatten-WorkflowFiles -ManifestObj $remoteManifestObj -SelectedIndices $selectedIndices -} elseif ($remoteManifestObj.files) { - # --- Backward compatibility: flat files structure (v1.x) --- - Write-Host "" - Write-Host ">> Checking for updates..." - - foreach ($entry in $remoteManifestObj.files.PSObject.Properties) { - $remoteFiles[$entry.Name] = Extract-Version $entry.Value - } - $skillDirs = @() -} else { - Write-Host " !! Unknown manifest format. Aborting." - Remove-Item $remoteManifest.FullName -Force -ErrorAction SilentlyContinue - exit 1 -} - -# --- 3. Download/update files --- -foreach ($entry in $remoteFiles.GetEnumerator()) { - $path = $entry.Key - $remoteVer = $entry.Value - - $localPath = $path.Replace('/', '\') - $dest = "$TargetDir\$localPath" - $url = "$RAW_BASE/$path" - - if (-not (Test-Path $dest)) { - mkdir (Split-Path $dest -Parent) -Force | Out-Null - Write-Host " >> $path (new)" - $ok = Download -Url $url -Dest $dest - if (-not $ok) { Write-Host " !! Failed to download $path" } - } else { - if ($path -like '*/SKILL.md') { - $localVer = Get-ManifestVersion -ManifestPath $localManifestPath -FilePath $path - if (-not $localVer) { $localVer = "0.0.0" } - if (Version-Lt $localVer $remoteVer) { - Write-Host " >> $path ($localVer -> $remoteVer)" - $answer = Read-Host " Overwrite? [y/N]" - if ($answer -eq "y" -or $answer -eq "Y") { - $ok = Download -Url $url -Dest $dest - if (-not $ok) { Write-Host " !! Failed to download $path" } - } else { - Write-Host " Skipped." - } - continue - } - } - Write-Host " $path (up to date)" - } -} - -Remove-Item $remoteManifest.FullName -Force -ErrorAction SilentlyContinue - -# Save updated local manifest -Download -Url "$RAW_BASE/.agents/manifest.json" -Dest $localManifestPath | Out-Null - -# --- Customize AGENTS.md with mode-specific Execution Protocol --- -if ($selectedMode -and (Test-Path "$TargetDir\AGENTS.md")) { - $modeTitle = (Get-Culture).TextInfo.ToTitleCase(($selectedMode -replace '-', ' ')) - $protocolHeader = "## ${modeTitle} Execution Protocol" - - $agentsContent = Get-Content "$TargetDir\AGENTS.md" -Raw - if ($agentsContent -notmatch [regex]::Escape($protocolHeader)) { - Write-Host "" - Write-Host ">> Adding $modeTitle Execution Protocol to AGENTS.md..." - $protocolSection = @" - -$protocolHeader - -All file modifications or code generation tasks in this project MUST follow the -${modeTitle} lifecycle defined below. The bootstrap skill will complete this -section with the full protocol after initial project analysis. - -"@ - Add-Content -Path "$TargetDir\AGENTS.md" -Value $protocolSection -NoNewline - } -} - -# --- 4. Generate supplementary explicit configurations --- -if ($use_opencode -and -not (Test-Path "$TargetDir\opencode.json")) { - Write-Host ">> Creating opencode.json..." - if ($skillDirs.Count -gt 0) { - $config = @{ - '$schema' = 'https://opencode.ai/config.json' - skills = @{ - paths = @($skillDirs | ForEach-Object { ".agents/skills/$_" }) - } - instructions = @(($skillDirs | ForEach-Object { ".agents/skills/$_/**/SKILL.md" }) + "AGENTS.md") - } - $config | ConvertTo-Json -Depth 3 | Set-Content "$TargetDir\opencode.json" - } else { - $config = @{ - '$schema' = 'https://opencode.ai/config.json' - skills = @{ - paths = @(".agents/skills") - } - instructions = @(".agents/skills/**/SKILL.md", "AGENTS.md") - } - $config | ConvertTo-Json -Depth 3 | Set-Content "$TargetDir\opencode.json" - } -} - -if ($use_claude -and -not (Test-Path "$TargetDir\CLAUDE.md")) { - Write-Host ">> Creating CLAUDE.md..." - @' -# Claude Code Settings - -@AGENTS.md - -## Claude-Specific Instructions -- Utilize the symlinked skills located in `.claude/skills/` when triggered. -'@ | Set-Content "$TargetDir\CLAUDE.md" -NoNewline -} - -if ($use_antigravity -and -not (Test-Path "$TargetDir\.antigravity\workflows.json")) { - Write-Host ">> Creating .antigravity/workflows.json..." - mkdir "$TargetDir\.antigravity" -Force | Out-Null - $config = @{ - workflows = @( - @{ - name = "example" - description = "Example workflow referencing shared .agents/skills" - } - ) - } - $config | ConvertTo-Json -Depth 3 | Set-Content "$TargetDir\.antigravity\workflows.json" -} - -if ($use_kilo -and -not (Test-Path "$TargetDir\.kilo\config.json")) { - Write-Host ">> Creating .kilo/config.json..." - mkdir "$TargetDir\.kilo" -Force | Out-Null - if ($skillDirs.Count -gt 0) { - $instructions = @("AGENTS.md") - $instructions += $skillDirs | ForEach-Object { ".agents/skills/$_/**/SKILL.md" } - $config = @{ - '$schema' = 'https://app.kilo.ai/config.json' - instructions = $instructions - } - $config | ConvertTo-Json -Depth 3 | Set-Content "$TargetDir\.kilo\config.json" - } else { - $config = @{ - '$schema' = 'https://app.kilo.ai/config.json' - instructions = @("AGENTS.md", ".agents/skills/*/SKILL.md") - } - $config | ConvertTo-Json -Depth 3 | Set-Content "$TargetDir\.kilo\config.json" - } -} - -# --- 5. Create symbolic links --- -Write-Host "" -Write-Host ">> Linking agent skill directories..." - -function Migrate-And-Symlink { - param([string]$Src, [string]$Dest, [string]$Label) - $srcPath = "$TargetDir\$Src" - $destAbs = "$TargetDir\.agents\skills" - - if ((Test-Path $srcPath) -and -not (Get-Item $srcPath).Attributes -band [System.IO.FileAttributes]::ReparsePoint) { - Write-Host " -> Migrating existing $Label assets into .agents..." - Get-ChildItem "$srcPath\*" -ErrorAction SilentlyContinue | Copy-Item -Destination "$TargetDir\.agents\skills\" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item $srcPath -Recurse -Force -ErrorAction SilentlyContinue - } - - if (Test-Path $srcPath) { - Remove-Item $srcPath -Recurse -Force -ErrorAction SilentlyContinue - } - - try { - New-Item -ItemType SymbolicLink -Path $srcPath -Target $Dest -Force -ErrorAction Stop | Out-Null - Write-Host " -> Linked $srcPath -> $Dest" - } catch { - Write-Host " !! Failed to create symlink: $srcPath -> $Dest" - Write-Host " !! Windows requires Administrator privileges or Developer Mode for symlinks." - Write-Host " !! See: https://learn.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development" - $answer = Read-Host " Retry as admin? [y/N]" - if ($answer -eq "y" -or $answer -eq "Y") { - $psi = New-Object System.Diagnostics.ProcessStartInfo - $psi.FileName = "powershell.exe" - $psi.Verb = "runas" - $psi.Arguments = "-NoProfile -Command `"New-Item -ItemType SymbolicLink -Path '$srcPath' -Target '$Dest' -Force`"" - try { - [System.Diagnostics.Process]::Start($psi) | Out-Null - Write-Host " -> Admin elevation requested for symlink creation." - } catch { - Write-Host " !! Admin elevation failed. Falling back to copy instead of symlink." - Copy-Item -Path "$destAbs\*" -Destination $srcPath -Recurse -Force -ErrorAction SilentlyContinue - } - } else { - Write-Host " !! Falling back to copy instead of symlink." - Copy-Item -Path "$destAbs\*" -Destination $srcPath -Recurse -Force -ErrorAction SilentlyContinue - } - } -} - -if ($use_claude) { Migrate-And-Symlink -Src ".claude\skills" -Dest "..\.agents\skills" -Label "Claude Code" } -if ($use_cursor) { Migrate-And-Symlink -Src ".cursor\skills" -Dest "..\.agents\skills" -Label "Cursor" } - -Write-Host "" -Write-Host ">> Symbolic links created:" -if ($use_claude) { Write-Host " - $TargetDir\.claude\skills -> ..\.agents\skills" } -if ($use_cursor) { Write-Host " - $TargetDir\.cursor\skills -> ..\.agents\skills" } - -Write-Host "" -Write-Host ">> Done. Target: $TargetDir" diff --git a/codewiser.sh b/codewiser.sh deleted file mode 100755 index 217519b..0000000 --- a/codewiser.sh +++ /dev/null @@ -1,606 +0,0 @@ -#!/bin/bash - -set -e - -RAW_BASE="https://raw.githubusercontent.com/yallma3/codewiser/main" -VERBOSE=false - -# --- Argument validation --- -while [ $# -gt 0 ]; do - case "$1" in - -v|--verbose) VERBOSE=true; shift ;; - -h|--help) - echo "Usage: $0 [-v] " - echo " Initializes the multi-agent framework in the specified directory." - echo " -v, --verbose Print verbose messages for download and parsing" - exit 0 - ;; - *) break ;; - esac -done - -if [ $# -lt 1 ]; then - echo "Usage: $0 [-v] " - echo " Initializes the multi-agent framework in the specified directory." - exit 1 -fi - -TARGET_DIR="$(realpath "$1")" -mkdir -p "$TARGET_DIR" - -echo ">> Setting up codewiser in $TARGET_DIR..." - -# --- Helper: download with wget, fallback to curl --- -download() { - local url="$1" - local dest="$2" - mkdir -p "$(dirname "$dest")" - if $VERBOSE; then echo " ⬇ Downloading $url"; fi - if command -v wget &>/dev/null; then - if $VERBOSE; then - wget -O "$dest" "$url" 2>&1 && return 0 - else - wget -q -O "$dest" "$url" 2>/dev/null && return 0 - fi - fi - if command -v curl &>/dev/null; then - if $VERBOSE; then - curl -SL -o "$dest" "$url" 2>&1 && return 0 - else - curl -sSL -o "$dest" "$url" 2>/dev/null && return 0 - fi - fi - return 1 -} - -# --- Helper: parse JSON with python3 --- -json_parse() { - local manifest="$1" - local expr="$2" - if $VERBOSE; then echo " 🔍 Parsing $manifest"; fi - if command -v python3 &>/dev/null; then - if $VERBOSE; then - python3 -c " -import json,sys -with open('$manifest') as f: - d = json.load(f) -$expr -" - else - python3 -c " -import json,sys -with open('$manifest') as f: - d = json.load(f) -$expr -" 2>/dev/null - fi - fi -} - -# --- Helper: get version for a file path from local manifest --- -get_manifest_version() { - local manifest="$1" - local path="$2" - if command -v python3 &>/dev/null; then - python3 -c " -import json,sys -with open('$manifest') as f: - d = json.load(f) -if 'modes' in d: - for mode in d['modes'].values(): - if '$path' in mode.get('files', {}): - v = mode['files']['$path'] - print(v if isinstance(v, str) else v.get('version', '0.0.0')) - sys.exit(0) -elif 'workflows' in d: - for wf in d['workflows'].values(): - for stage in wf.get('stages', {}).values(): - if '$path' in stage.get('files', {}): - v = stage['files']['$path'] - print(v if isinstance(v, str) else v.get('version', '0.0.0')) - sys.exit(0) -elif 'files' in d and '$path' in d['files']: - v = d['files']['$path'] - print(v if isinstance(v, str) else v.get('version', '0.0.0')) -" 2>/dev/null - fi -} - -# --- Helper: compare two dot-separated version strings (returns 0 if v1 < v2) --- -version_lt() { - [ "$(printf '%s\n' "$1" "$2" | sort -V | head -n 1)" != "$2" ] -} - -# --- Agent Selection (checkbox-style) --- -declare -a selections=(0 0 0 0 0) -choices=("OpenCode / MiMo / Crush" "Claude Code" "Cursor" "Antigravity" "Kilo Code") - -echo "" -echo "Select AI agents (enter number to toggle, 'd' when done):" - -while true; do - for i in "${!choices[@]}"; do - mark=" " - [[ ${selections[$i]} -eq 1 ]] && mark="x" - echo " [$mark] $((i+1))) ${choices[$i]}" - done - echo " [ ] d) Done" - echo " [ ] c) Cancel" - read -p "> " input - input=${input//$'\r'} - - case "$input" in - [1-5]) - idx=$((input-1)) - [[ ${selections[$idx]} -eq 1 ]] && selections[$idx]=0 || selections[$idx]=1 - ;; - d|D|"") - any_selected=false - for s in "${selections[@]}"; do - [[ $s -eq 1 ]] && any_selected=true && break - done - if ! $any_selected; then - echo "No agents selected. Cancelled." - exit 0 - fi - break - ;; - c|C) echo "Cancelled."; exit 0 ;; - *) echo " Invalid choice." ;; - esac -done - -use_opencode=false; [[ ${selections[0]} -eq 1 ]] && use_opencode=true -use_claude=false; [[ ${selections[1]} -eq 1 ]] && use_claude=true -use_cursor=false; [[ ${selections[2]} -eq 1 ]] && use_cursor=true -use_antigravity=false; [[ ${selections[3]} -eq 1 ]] && use_antigravity=true -use_kilo=false; [[ ${selections[4]} -eq 1 ]] && use_kilo=true - -# --- 1. Create directory structure --- -echo "" -echo ">> Creating directories..." -mkdir -p "$TARGET_DIR/.agents/skills" \ - "$TARGET_DIR/.agents/specs" \ - "$TARGET_DIR/.agents/plans" - -$use_claude && mkdir -p "$TARGET_DIR/.claude" -$use_cursor && mkdir -p "$TARGET_DIR/.cursor" -$use_antigravity && mkdir -p "$TARGET_DIR/.antigravity" -$use_kilo && mkdir -p "$TARGET_DIR/.kilo" - -# --- 2. Download remote manifest --- -echo "" -echo ">> Fetching manifest..." - -REMOTE_MANIFEST=$(mktemp) -LOCAL_MANIFEST="$TARGET_DIR/.agents/manifest.json" - -download "$RAW_BASE/.agents/manifest.json" "$REMOTE_MANIFEST" -if [ ! -s "$REMOTE_MANIFEST" ]; then - echo " !! Failed to download manifest. Aborting." - rm -f "$REMOTE_MANIFEST" - exit 1 -fi - -# Detect manifest format: modes, workflows, or flat files -HAS_MODES=$(json_parse "$REMOTE_MANIFEST" "print('true' if 'modes' in d else 'false')") - -SELECTED_MODE="" - -if [ "$HAS_MODES" = "true" ]; then - # --- Mode Selection (single choice) --- - mapfile -t MODE_NAMES < <(json_parse "$REMOTE_MANIFEST" " -for mode in d.get('modes', {}): - print(mode) -") - - if [ ${#MODE_NAMES[@]} -eq 0 ]; then - echo " !! No modes found in manifest. Aborting." - rm -f "$REMOTE_MANIFEST" - exit 1 - fi - - echo "" - echo "Select development mode (enter number):" - - while true; do - for i in "${!MODE_NAMES[@]}"; do - desc=$(json_parse "$REMOTE_MANIFEST" "print(d['modes']['${MODE_NAMES[$i]}'].get('description', ''))") - if [ "$i" -eq $((SELECTED_MODE_IDX)) ] 2>/dev/null; then - echo " [x] $((i+1))) ${MODE_NAMES[$i]}" - else - echo " [ ] $((i+1))) ${MODE_NAMES[$i]}" - fi - echo " $desc" - done - echo " [ ] c) Cancel" - read -p "> " input - input=${input//$'\r'} - - case "$input" in - [1-9]*) - idx=$((input-1)) - [ $idx -ge ${#MODE_NAMES[@]} ] && echo " Invalid choice." && continue - SELECTED_MODE="${MODE_NAMES[$idx]}" - SELECTED_MODE_IDX=$idx - break - ;; - c|C) echo "Cancelled."; rm -f "$REMOTE_MANIFEST"; exit 0 ;; - *) echo " Invalid choice." ;; - esac - done - - SELECTED_MODE_DESC=$(json_parse "$REMOTE_MANIFEST" "print(d['modes']['$SELECTED_MODE'].get('description', ''))") - - echo "" - echo ">> Checking for updates..." - echo " Mode: $SELECTED_MODE — $SELECTED_MODE_DESC" - - # Get list of skills for display - python3 -c " -import json -with open('$REMOTE_MANIFEST') as f: - d = json.load(f) -mode = d.get('modes', {}).get('$SELECTED_MODE', {}) -skills = [] -for fpath in mode.get('files', {}): - if fpath.endswith('/SKILL.md'): - skills.append(fpath.split('/')[-2]) -if skills: - print(' Skills:') - for s in sorted(skills): - print(f' - {s}') -" 2>/dev/null || true - - # Extract paths and versions as pipe-delimited lines - RAW_FILE_LIST=$(json_parse "$REMOTE_MANIFEST" " -mode = d.get('modes', {}).get('$SELECTED_MODE', {}) -files = mode.get('files', {}) -for path, ver in files.items(): - v = ver if isinstance(ver, str) else ver.get('version', '0.0.0') - print(path + '|' + v) -") - - REMOTE_PATHS=() - declare -A REMOTE_VERSIONS - while IFS='|' read -r path ver; do - [ -z "$path" ] && continue - REMOTE_PATHS+=("$path") - REMOTE_VERSIONS["$path"]="$ver" - done <<< "$RAW_FILE_LIST" -elif [ "$(json_parse "$REMOTE_MANIFEST" "print('true' if 'workflows' in d else 'false')")" = "true" ]; then - # --- Backward compatibility: legacy workflows structure (v2.x) --- - mapfile -t WORKFLOW_NAMES < <(json_parse "$REMOTE_MANIFEST" " -for wf in d.get('workflows', {}): - print(wf) -") - - if [ ${#WORKFLOW_NAMES[@]} -eq 0 ]; then - echo " !! No workflows found in manifest. Aborting." - rm -f "$REMOTE_MANIFEST" - exit 1 - fi - - declare -a wf_selections - for i in "${!WORKFLOW_NAMES[@]}"; do - wf_selections[$i]=0 - done - - echo "" - echo "Select workflows to install (enter number to toggle, 'd' when done):" - - while true; do - for i in "${!WORKFLOW_NAMES[@]}"; do - mark=" " - [[ ${wf_selections[$i]} -eq 1 ]] && mark="x" - echo " [$mark] $((i+1))) ${WORKFLOW_NAMES[$i]}" - done - echo " [ ] d) Done" - echo " [ ] c) Cancel" - read -p "> " input - input=${input//$'\r'} - - case "$input" in - [1-9]*) - idx=$((input-1)) - [ $idx -ge ${#WORKFLOW_NAMES[@]} ] && echo " Invalid choice." && continue - [[ ${wf_selections[$idx]} -eq 1 ]] && wf_selections[$idx]=0 || wf_selections[$idx]=1 - ;; - d|D|"") - any_selected=false - for s in "${wf_selections[@]}"; do - [[ $s -eq 1 ]] && any_selected=true && break - done - if ! $any_selected; then - echo " !! No workflows selected." - continue - fi - break - ;; - c|C) echo "Cancelled."; rm -f "$REMOTE_MANIFEST"; exit 0 ;; - *) echo " Invalid choice." ;; - esac - done - - SELECTED_WF_INDICES=() - WF_SELECTED_NAMES=() - for i in "${!wf_selections[@]}"; do - if [[ ${wf_selections[$i]} -eq 1 ]]; then - SELECTED_WF_INDICES+=("$i") - WF_SELECTED_NAMES+=("${WORKFLOW_NAMES[$i]}") - fi - done - - SELECTED_WF_INDICES_JOINED=$(IFS=,; echo "${SELECTED_WF_INDICES[*]}") - - echo "" - echo ">> Checking for updates..." - echo " Workflows: ${WF_SELECTED_NAMES[*]}" - - python3 -c " -import json -with open('$REMOTE_MANIFEST') as f: - d = json.load(f) -wfs = list(d.get('workflows', {})) -selected = [${SELECTED_WF_INDICES_JOINED}] -selected_names = [wfs[i] for i in selected] -all_skills = set() -for name in wfs: - for stage in d['workflows'][name].get('stages', {}).values(): - for fpath in stage.get('files', {}): - if fpath.endswith('/SKILL.md'): - all_skills.add(fpath.split('/')[-2]) -essential = set() -frontend = set() -backend = set() -testdriven = set() -for s in sorted(all_skills): - if s.startswith('frontend-'): - frontend.add(s) - elif s.startswith('backend-'): - backend.add(s) - elif s.startswith('testdriven-'): - testdriven.add(s) - else: - essential.add(s) -if essential: - print(' Essential Skills:') - for s in sorted(essential): - print(f' - {s}') -if frontend: - print(' frontend:') - for s in sorted(frontend): - print(f' - {s}') -if backend: - print(' backend:') - for s in sorted(backend): - print(f' - {s}') -if testdriven: - print(' testdriven:') - for s in sorted(testdriven): - print(f' - {s}') -" 2>/dev/null || true - - RAW_FILE_LIST=$(json_parse "$REMOTE_MANIFEST" " -wfs = list(d.get('workflows', {})) -selected = [${SELECTED_WF_INDICES_JOINED}] -files = {} -for idx in selected: - wf_name = wfs[idx] - wf = d['workflows'][wf_name] - for sname, stage in wf.get('stages', {}).items(): - for fpath, fver in stage.get('files', {}).items(): - files[fpath] = fver -for path, ver in files.items(): - v = ver if isinstance(ver, str) else ver.get('version', '0.0.0') - print(path + '|' + v) -") - - REMOTE_PATHS=() - declare -A REMOTE_VERSIONS - while IFS='|' read -r path ver; do - [ -z "$path" ] && continue - REMOTE_PATHS+=("$path") - REMOTE_VERSIONS["$path"]="$ver" - done <<< "$RAW_FILE_LIST" -else - # --- Backward compatibility: flat files structure (v1.x) --- - echo "" - echo ">> Checking for updates..." - - mapfile -t REMOTE_PATHS < <( - grep -o '"[^"]*\.\(md\|json\)"[[:space:]]*:' "$REMOTE_MANIFEST" | tr -d '"' | sed 's/://' - ) - - declare -A REMOTE_VERSIONS - for path in "${REMOTE_PATHS[@]}"; do - [ -z "$path" ] && continue - escaped_path=$(echo "$path" | sed 's|\.|\\.|g; s|/|\\/|g') - ver=$(grep -o "\"$escaped_path\"[[:space:]]*:[[:space:]]*\"[0-9.]*\"" "$REMOTE_MANIFEST" 2>/dev/null \ - | grep -o '"[0-9.]*"' | tr -d '"') - REMOTE_VERSIONS["$path"]="$ver" - done -fi - -# Track whether skill dirs list was computed (only relevant for workflow/legacy paths) -SKILL_DIRS_DEDUP=() -SKILL_DIRS_PYTHON="[]" - -# --- 3. Download/update files --- -for path in "${REMOTE_PATHS[@]}"; do - [ -z "$path" ] && continue - - dest="$TARGET_DIR/$path" - url="$RAW_BASE/$path" - remote_ver="${REMOTE_VERSIONS[$path]}" - - if [ ! -f "$dest" ]; then - mkdir -p "$(dirname "$dest")" - echo " >> $path (new)" - download "$url" "$dest" || echo " !! Failed to download $path" - else - if [[ "$path" == */SKILL.md ]]; then - local_ver=$(get_manifest_version "$LOCAL_MANIFEST" "$path") - [ -z "$local_ver" ] && local_ver="0.0.0" - if version_lt "$local_ver" "$remote_ver"; then - echo " >> $path ($local_ver -> $remote_ver)" - read -p " Overwrite? [y/N] " answer - if [ "$answer" = "y" ] || [ "$answer" = "Y" ]; then - download "$url" "$dest" || echo " !! Failed to download $path" - else - echo " Skipped." - fi - continue - fi - fi - echo " $path (up to date)" - fi -done - -rm -f "$REMOTE_MANIFEST" - -# Save updated local manifest -download "$RAW_BASE/.agents/manifest.json" "$LOCAL_MANIFEST" || true - -# --- Customize AGENTS.md with mode-specific Execution Protocol --- -if [ -n "$SELECTED_MODE" ] && [ -f "$TARGET_DIR/AGENTS.md" ]; then - MODE_TITLE=$(echo "$SELECTED_MODE" | sed 's/-/ /g' | sed 's/\b\(.\)/\u\1/g') - PROTOCOL_HEADER="## ${MODE_TITLE} Execution Protocol" - - if ! grep -qF "$PROTOCOL_HEADER" "$TARGET_DIR/AGENTS.md" 2>/dev/null; then - echo "" - echo ">> Adding $MODE_TITLE Execution Protocol to AGENTS.md..." - cat << EOF >> "$TARGET_DIR/AGENTS.md" - -$PROTOCOL_HEADER - -All file modifications or code generation tasks in this project MUST follow the -${MODE_TITLE} lifecycle defined below. The bootstrap skill will complete this -section with the full protocol after initial project analysis. - -EOF - fi -fi - -# --- 4. Generate supplementary explicit configurations --- -if $use_opencode && [ ! -f "$TARGET_DIR/opencode.json" ]; then - echo ">> Creating opencode.json..." - if [ "${#SKILL_DIRS_DEDUP[@]}" -gt 0 ]; then - TARGET_DIR="$TARGET_DIR" python3 -c " -import json, os -skill_dirs = $SKILL_DIRS_PYTHON -target = os.environ['TARGET_DIR'] -config = { - '\$schema': 'https://opencode.ai/config.json', - 'skills': { - 'paths': ['.agents/skills/' + d for d in skill_dirs] - }, - 'instructions': ['.agents/skills/' + d + '/**/SKILL.md' for d in skill_dirs] + ['AGENTS.md'] -} -with open(os.path.join(target, 'opencode.json'), 'w') as f: - json.dump(config, f, indent=2) -" - else - # Fallback for v1.x flat manifest format - cat << EOF > "$TARGET_DIR/opencode.json" -{ - "\$schema": "https://opencode.ai/config.json", - "skills": { - "paths": [ - ".agents/skills" - ] - }, - "instructions": [ - ".agents/skills/**/SKILL.md", - "AGENTS.md" - ] -} -EOF - fi -fi - -if $use_claude && [ ! -f "$TARGET_DIR/CLAUDE.md" ]; then - echo ">> Creating CLAUDE.md..." - cat << 'EOF' > "$TARGET_DIR/CLAUDE.md" -# Claude Code Settings - -@AGENTS.md - -## Claude-Specific Instructions -- Utilize the symlinked skills located in `.claude/skills/` when triggered. -EOF -fi - -if $use_antigravity && [ ! -f "$TARGET_DIR/.antigravity/workflows.json" ]; then - echo ">> Creating .antigravity/workflows.json..." - cat << 'EOF' > "$TARGET_DIR/.antigravity/workflows.json" -{ - "workflows": [ - { - "name": "example", - "description": "Example workflow referencing shared .agents/skills" - } - ] -} -EOF -fi - -if $use_kilo && [ ! -f "$TARGET_DIR/.kilo/config.json" ]; then - echo ">> Creating .kilo/config.json..." - if [ "${#SKILL_DIRS_DEDUP[@]}" -gt 0 ]; then - TARGET_DIR="$TARGET_DIR" python3 -c " -import json, os -skill_dirs = $SKILL_DIRS_PYTHON -target = os.environ['TARGET_DIR'] -instructions = ['AGENTS.md'] -instructions += ['.agents/skills/' + d + '/**/SKILL.md' for d in skill_dirs] -config = { - '\$schema': 'https://app.kilo.ai/config.json', - 'instructions': instructions -} -with open(os.path.join(target, '.kilo', 'config.json'), 'w') as f: - json.dump(config, f, indent=2) -" - else - cat << 'EOF' > "$TARGET_DIR/.kilo/config.json" -{ - "\$schema": "https://app.kilo.ai/config.json", - "instructions": ["AGENTS.md", ".agents/skills/*/SKILL.md"] -} -EOF - fi -fi - -# --- 5. Create symbolic links --- -echo "" -echo ">> Linking agent skill directories..." - -migrate_and_symlink() { - local src="$TARGET_DIR/$1" - local dest="$2" - local label="$3" - - if [ -d "$src" ] && [ ! -L "$src" ]; then - echo " -> Migrating existing $label assets..." - cp -r "$src"/* "$TARGET_DIR/.agents/skills/" 2>/dev/null || true - rm -rf "$src" - fi - - if [ -L "$src" ] || [ -e "$src" ]; then - rm -rf "$src" - fi - - ln -s "$dest" "$src" - echo " -> Linked $src -> $dest" -} - -$use_claude && migrate_and_symlink ".claude/skills" "../.agents/skills" "Claude Code" -$use_cursor && migrate_and_symlink ".cursor/skills" "../.agents/skills" "Cursor" - -echo "" -echo " Links:" -$use_claude && echo " - $TARGET_DIR/.claude/skills → ../.agents/skills" -$use_cursor && echo " - $TARGET_DIR/.cursor/skills → ../.agents/skills" - -echo "" -echo ">> Done. Target: $TARGET_DIR" diff --git a/package.json b/package.json new file mode 100644 index 0000000..4a09005 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "codewiser", + "bin": { + "codewiser": "src/index.ts" + }, + "type": "module", + "version": "0.1.2", + "scripts": { + "start": "bun src/index.ts", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@clack/prompts": "^1.7.0", + "meow": "^14.1.0", + "picocolors": "^1.1.1" + } +} diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..fd0ad04 --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,339 @@ +import { existsSync, mkdirSync } from "fs"; +import { join, resolve } from "path"; +import { + showTitle, showDone, stepHeader, info, warn, error, success, item, fileStatus, runSpinner, pick, confirmPrompt, + BACK, EXIT, +} from "../utils/ui"; +import { download } from "../utils/download"; +import { selectAgents, selectMode, selectWorkflows, confirmOverwrite } from "../utils/prompts"; +import { + versionLt, + flattenModeFiles, + flattenWorkflowFiles, + detectManifestFormat, +} from "../utils/manifest"; +import { + generateOpenCodeConfig, + generateClaudeMD, + generateAntigravityConfig, + generateKiloConfig, + addExecutionProtocolToAgentsMD, +} from "../utils/generate-configs"; +import { createAllSymlinks } from "../utils/symlinks"; +import { readConfig, writeConfig, readGlobalConfig, resolveRepo, resolveBranch, buildRawBase, describeRepoSource, describeBranchSource } from "../utils/config"; +import { readManifest } from "./repo"; +import type { SelectedAgents } from "../utils/prompts"; + +export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: string): Promise { + showTitle(); + + const targetDir = resolve(targetDirInput); + if (!existsSync(targetDir)) { + mkdirSync(targetDir, { recursive: true }); + } + info(`Setting up codewiser in ${targetDir}`); + + const existingConfig = readConfig(targetDir); + const localManifest = readManifest(process.cwd()); + const globalConfig = readGlobalConfig(); + const repo = resolveRepo(process.cwd(), cliRepo, localManifest?.repo, globalConfig?.repo); + const branch = resolveBranch(process.cwd(), cliBranch, localManifest?.branch, globalConfig?.branch); + const RAW_BASE = buildRawBase(repo, branch); + info(`Repo: ${repo} (branch: ${branch})`); + info(` from ${describeRepoSource(process.cwd(), cliRepo, localManifest?.repo, globalConfig?.repo)} / ${describeBranchSource(process.cwd(), cliBranch, localManifest?.branch, globalConfig?.branch)}`); + + let agents: SelectedAgents | null = null; + let selectedMode = ""; + let skillDirs: string[] = []; + let remoteFiles: Record = {}; + let cachedManifest: Record | null = null; + let agentStepVisited = false; + + type Step = "agents" | "mode" | "confirm" | "done"; + let current: Step = "agents"; + + const stepLabels: Record = { + agents: "Select Agents", + mode: "Configure Mode", + confirm: "Confirm Selections", + done: "", + }; + + let iterations = 0; + + while (current !== "done") { + iterations++; + if (iterations > 20) { + error("Too many navigation steps. Aborting."); + return; + } + + const stepNum = ["agents", "mode", "confirm"].indexOf(current) + 1; + + stepHeader(stepNum, stepLabels[current]); + + switch (current) { + case "agents": { + if (!agentStepVisited) { + agentStepVisited = true; + const detected = detectInstalledAgents(targetDir); + if (detected) { + agents = detected; + info(`Auto-detected: ${agentNames(detected).join(", ")}`); + } + } else { + agents = null; + } + + if (!agents) { + const result = await selectAgents(); + if (result === EXIT) return; + if (result === BACK) { + const quit = await confirmPrompt("Quit codewiser?"); + if (quit === EXIT) return; + if (quit === BACK) break; + if (quit) return; + break; + } + agents = result; + } + + current = "mode"; + break; + } + + case "mode": { + if (!cachedManifest) { + const manifestUrl = `${RAW_BASE}/codewiser.json`; + const result = await runSpinner("Fetching manifest...", async () => { + const res = await fetch(manifestUrl, { signal: AbortSignal.timeout(10000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${manifestUrl} is not reachable`); + return (await res.json()) as Record; + }); + if (result === BACK) { + error(`Could not load codewiser.json from ${RAW_BASE}`); + info(`Resolved from ${describeRepoSource(process.cwd(), cliRepo, localManifest?.repo, globalConfig?.repo)} / ${describeBranchSource(process.cwd(), cliBranch, localManifest?.branch, globalConfig?.branch)}`); + info("Check the repo/branch, then retry."); + info("To switch repo/branch: codewiser repo set --branch "); + return; + } + cachedManifest = result; + } + + const format = detectManifestFormat(cachedManifest); + + switch (format.type) { + case "modes": { + const modeResult = await selectMode(format.modes); + if (modeResult === EXIT) return; + if (modeResult === BACK) { current = "agents"; break; } + selectedMode = modeResult; + + const desc = format.modes[selectedMode]?.description ?? ""; + success(`Mode: ${selectedMode}${desc ? ` — ${desc}` : ""}`); + + const modeObj = format.modes[selectedMode]; + if (!modeObj) { error("Invalid mode selection"); return; } + remoteFiles = flattenModeFiles(modeObj); + skillDirs = extractSkillDirs(remoteFiles); + + if (skillDirs.length > 0) { + info("Skills:"); + for (const s of skillDirs.sort()) item(s); + } + current = "confirm"; + break; + } + + case "workflows": { + const wfResult = await selectWorkflows(format.workflows); + if (wfResult === EXIT) return; + if (wfResult === BACK) { current = "agents"; break; } + + remoteFiles = flattenWorkflowFiles(cachedManifest, wfResult); + const wfNames = Object.keys(format.workflows); + const selectedNames = wfResult.map((i) => wfNames[i]!); + success(`Workflows: ${selectedNames.join(", ")}`); + + skillDirs = extractSkillDirs(remoteFiles); + if (skillDirs.length > 0) { + info("Skills:"); + for (const s of skillDirs) item(s); + } + current = "confirm"; + break; + } + + case "files": { + for (const [path, val] of Object.entries(format.files)) { + remoteFiles[path] = typeof val === "string" ? val : "0.0.0"; + } + current = "confirm"; + break; + } + + default: + error("Unknown manifest format. Aborting."); + return; + } + break; + } + + case "confirm": { + const agentNamesList = agents ? agentNames(agents) : []; + info(`Agents: ${agentNamesList.join(", ") || "None"}`); + info(`Mode: ${selectedMode || "None"}`); + if (skillDirs.length > 0) { + info("Skills:"); + for (const s of skillDirs.sort()) item(s); + } + + const choice = await pick("Create project with these selections?", [ + { value: "create", label: "Yes, create everything!" }, + { value: "back-agents", label: "← Back to agents" }, + { value: "back-mode", label: "← Back to mode" }, + { value: "quit", label: "Quit" }, + ]); + + if (choice === EXIT || choice === "quit") return; + if (choice === "back-agents") { current = "agents"; break; } + if (choice === "back-mode") { current = "mode"; break; } + + current = "done"; + break; + } + } + } + + // Phase 2: Execute (all file operations) + info("Creating directories..."); + mkdirSync(join(targetDir, ".agents", "skills"), { recursive: true }); + mkdirSync(join(targetDir, ".agents", "specs"), { recursive: true }); + mkdirSync(join(targetDir, ".agents", "plans"), { recursive: true }); + mkdirSync(join(targetDir, ".agents", "research"), { recursive: true }); + + if (agents?.claude) mkdirSync(join(targetDir, ".claude"), { recursive: true }); + if (agents?.cursor) mkdirSync(join(targetDir, ".cursor"), { recursive: true }); + if (agents?.antigravity) mkdirSync(join(targetDir, ".antigravity"), { recursive: true }); + if (agents?.kilo) mkdirSync(join(targetDir, ".kilo"), { recursive: true }); + + success("Directories created"); + + stepHeader(4, "Download Files"); + + let downloaded = 0; + for (const [filePath, remoteVer] of Object.entries(remoteFiles)) { + const dest = join(targetDir, ...filePath.split("/")); + const url = `${RAW_BASE}/${filePath}`; + + if (!existsSync(dest)) { + const ok = await download(url, dest); + if (ok) { downloaded++; fileStatus(filePath, "new"); } + else warn(`Failed: ${filePath}`); + } else if (filePath.endsWith("/SKILL.md")) { + const localVer = existingConfig?.files?.[filePath] ?? "0.0.0"; + if (versionLt(localVer, remoteVer)) { + const overwrite = await confirmOverwrite(filePath, localVer, remoteVer); + if (overwrite === EXIT) return; + if (overwrite) { + const ok = await download(url, dest); + if (ok) { downloaded++; fileStatus(filePath, "updated"); } + else warn(`Failed: ${filePath}`); + } + } else { + fileStatus(filePath, "current"); + } + } else { + fileStatus(filePath, "current"); + } + } + + if (downloaded > 0) success(`${downloaded} file(s) downloaded`); + else info("All files up to date"); + + const fileVersions: Record = {}; + for (const [filePath, ver] of Object.entries(remoteFiles)) { + fileVersions[filePath] = ver; + } + writeConfig(targetDir, { files: fileVersions }); + + stepHeader(5, "Generate Configs"); + generateOpenCodeConfig(targetDir, skillDirs, agents?.opencode ?? false); + generateClaudeMD(targetDir, agents?.claude ?? false); + generateAntigravityConfig(targetDir, agents?.antigravity ?? false); + generateKiloConfig(targetDir, skillDirs, agents?.kilo ?? false); + addExecutionProtocolToAgentsMD(targetDir, selectedMode); + success("Configurations generated"); + + stepHeader(6, "Create Symlinks"); + const symlinkConfigs: Array<{ relativeSrc: string; relativeDest: string; label: string }> = []; + if (agents?.claude) { + symlinkConfigs.push({ relativeSrc: join(".claude", "skills"), relativeDest: join(".agents", "skills"), label: "Claude Code" }); + } + if (agents?.cursor) { + symlinkConfigs.push({ relativeSrc: join(".cursor", "skills"), relativeDest: join(".agents", "skills"), label: "Cursor" }); + } + + if (symlinkConfigs.length > 0) { + await createAllSymlinks(targetDir, symlinkConfigs); + for (const cfg of symlinkConfigs) success(`Linked ${cfg.relativeSrc}`); + } else { + info("No symlinks to create"); + } + + showDone(targetDir); +} + +function detectInstalledAgents(targetDir: string): SelectedAgents | null { + const detected: SelectedAgents = { + opencode: false, + claude: false, + cursor: false, + antigravity: false, + kilo: false, + }; + + let found = false; + + if (existsSync(join(targetDir, "opencode.json"))) { + detected.opencode = true; + found = true; + } + if (existsSync(join(targetDir, "CLAUDE.md"))) { + detected.claude = true; + found = true; + } + if (existsSync(join(targetDir, ".cursor"))) { + detected.cursor = true; + found = true; + } + if (existsSync(join(targetDir, ".antigravity"))) { + detected.antigravity = true; + found = true; + } + if (existsSync(join(targetDir, ".kilo"))) { + detected.kilo = true; + found = true; + } + + return found ? detected : null; +} + +function agentNames(agents: SelectedAgents): string[] { + const names: string[] = []; + if (agents.opencode) names.push("OpenCode"); + if (agents.claude) names.push("Claude Code"); + if (agents.cursor) names.push("Cursor"); + if (agents.antigravity) names.push("Antigravity"); + if (agents.kilo) names.push("Kilo Code"); + return names; +} + +function extractSkillDirs(files: Record): string[] { + return [...new Set( + Object.keys(files) + .filter((f) => f.endsWith("/SKILL.md")) + .map((f) => f.split("/").slice(-2, -1)[0]!) + .filter(Boolean), + )].sort(); +} diff --git a/src/commands/repo.ts b/src/commands/repo.ts new file mode 100644 index 0000000..e87121d --- /dev/null +++ b/src/commands/repo.ts @@ -0,0 +1,149 @@ +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import { error, info, success, EXIT } from "../utils/ui"; +import { validateRepoFormat, resolveRepo, resolveBranch, describeRepoSource, describeBranchSource, readGlobalConfig, writeGlobalConfig, getGlobalConfigPath } from "../utils/config"; +import { selectBranch } from "../utils/prompts"; + +export const MANIFEST_FILENAME = "codewiser.json"; + +export interface Manifest { + repo?: string; + branch?: string; + [key: string]: unknown; +} + +export function getManifestPath(dir: string): string { + return join(dir, MANIFEST_FILENAME); +} + +export function readManifest(dir: string): Manifest | null { + const p = getManifestPath(dir); + if (!existsSync(p)) return null; + try { + return JSON.parse(readFileSync(p, "utf-8")) as Manifest; + } catch { + return null; + } +} + +export function writeManifest(dir: string, manifest: Manifest): void { + writeFileSync(getManifestPath(dir), JSON.stringify(manifest, null, 2), "utf-8"); +} + +export function repoGet(dir: string = process.cwd()): void { + const manifest = readManifest(dir); + const global = readGlobalConfig(); + const globalPath = getGlobalConfigPath(); + + const repo = resolveRepo(dir, undefined, manifest?.repo, global?.repo); + const branch = resolveBranch(dir, undefined, manifest?.branch, global?.branch); + info(`repo: ${repo}`); + info(`branch: ${branch}`); + info(` from ${describeRepoSource(dir, undefined, manifest?.repo, global?.repo)} / ${describeBranchSource(dir, undefined, manifest?.branch, global?.branch)}`); + + if (manifest) { + info(`project config: ./codewiser.json → ${manifest.repo ?? "not set"} / ${manifest.branch ?? "not set"}`); + } else { + info("project config: ./codewiser.json → not present"); + } + + if (global) { + info(`global config: ${globalPath} → ${global.repo ?? "not set"} / ${global.branch ?? "not set"}`); + } else { + info(`global config: ${globalPath} → not present`); + } + + if (!manifest?.repo && !global?.repo) { + info("(using built-in default; run 'codewiser repo set --branch ' or add -g for a user default)"); + } +} + +export async function repoSet(repo: string, branch?: string, dir: string = process.cwd(), global = false): Promise { + if (!validateRepoFormat(repo)) { + error(`Invalid repo format: "${repo}". Expected / (e.g. yallma3/codewiser).`); + process.exitCode = 1; + return; + } + + if (global) { + if (!branch) { + const selected = await selectBranch(); + if (selected === EXIT) { + process.exitCode = 1; + return; + } + branch = selected; + } + const cfg = readGlobalConfig() ?? {}; + const prevRepo = cfg.repo; + cfg.repo = repo; + cfg.branch = branch; + writeGlobalConfig(cfg); + success(`repo set (global) to ${repo} (branch: ${branch})`); + info(`updated ${getGlobalConfigPath()}`); + if (prevRepo && prevRepo !== repo) info(`was: ${prevRepo}`); + return; + } + + const manifest = readManifest(dir); + if (!manifest) { + error(`No ${MANIFEST_FILENAME} found in ${dir}`); + info(`Run this command from the root of a project that has a ${MANIFEST_FILENAME} manifest, or add -g to set a user-wide default.`); + process.exitCode = 1; + return; + } + if (!branch) { + const selected = await selectBranch(); + if (selected === EXIT) { + process.exitCode = 1; + return; + } + branch = selected; + } + const prevRepo = manifest.repo; + manifest.repo = repo; + manifest.branch = branch; + writeManifest(dir, manifest); + success(`repo set to ${repo} (branch: ${branch})`); + info(`updated ${getManifestPath(dir)}`); + if (prevRepo && prevRepo !== repo) info(`was: ${prevRepo}`); +} + +export function repoReset(dir: string = process.cwd(), global = false): void { + if (global) { + const cfg = readGlobalConfig(); + if (!cfg) { + info(`No global config at ${getGlobalConfigPath()}; nothing to reset`); + return; + } + const hadOverride = cfg.repo !== undefined || cfg.branch !== undefined; + delete cfg.repo; + delete cfg.branch; + writeGlobalConfig(cfg); + if (hadOverride) success("global repo overrides removed"); + else info("no global repo/branch overrides were set"); + info(`updated ${getGlobalConfigPath()}`); + return; + } + + const manifest = readManifest(dir); + if (!manifest) { + error(`No ${MANIFEST_FILENAME} found in ${dir}`); + info(`Run this command from the root of a project that has a ${MANIFEST_FILENAME} manifest, or add -g to clear the user-wide default.`); + process.exitCode = 1; + return; + } + const hadOverride = manifest.repo !== undefined || manifest.branch !== undefined; + delete manifest.repo; + delete manifest.branch; + writeManifest(dir, manifest); + const repo = resolveRepo(dir, undefined, undefined, readGlobalConfig()?.repo); + const branch = resolveBranch(dir, undefined, undefined, readGlobalConfig()?.branch); + if (hadOverride) { + success(`repo overrides removed; will now use ${repo}@${branch}`); + info(` from ${describeRepoSource(dir, undefined, undefined, readGlobalConfig()?.repo)} / ${describeBranchSource(dir, undefined, undefined, readGlobalConfig()?.branch)}`); + } else { + info("no repo/branch overrides were set"); + } + info(`updated ${getManifestPath(dir)}`); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2f9baef --- /dev/null +++ b/src/index.ts @@ -0,0 +1,59 @@ +import meow from "meow"; +import { resolve } from "path"; +import { init } from "./commands/init"; +import { repoGet, repoSet, repoReset } from "./commands/repo"; + +const cli = meow( + ` + Usage + $ codewiser + $ codewiser repo Show the effective repo/branch config + $ codewiser repo set Set the repo in ./codewiser.json [--branch ] + $ codewiser repo reset Remove repo/branch overrides from ./codewiser.json + + Options + --repo GitHub repository to sync from (default: project, then user profile, then built-in) + --branch Git branch to use (default: project, then user profile, then built-in) + -g, --global Apply to your user profile (~/.codewiser.json) instead of ./codewiser.json + --help Show this help + --version Show version +`, + { + importMeta: import.meta, + flags: { + repo: { type: "string" }, + branch: { type: "string" }, + global: { type: "boolean", shortFlag: "g" }, + }, + }, +); + +const [first, second, third] = cli.input; + +if (first === "repo") { + const action = second ?? "get"; + if (action === "set") { + if (!third) { + console.error("error: is required (e.g. codewiser repo set yallma3/codewiser)"); + console.error(cli.help); + process.exit(1); + } + await repoSet(third, cli.flags.branch, process.cwd(), cli.flags.global); + } else if (action === "reset") { + repoReset(process.cwd(), cli.flags.global); + } else if (action === "get") { + repoGet(); + } else { + console.error(`error: unknown repo action "${action}" (expected get, set, or reset)`); + console.error(cli.help); + process.exit(1); + } +} else { + if (!first) { + console.error("error: is required"); + console.error(cli.help); + process.exit(1); + } + const targetDir = resolve(process.cwd(), "..", first); + await init(targetDir, cli.flags.repo, cli.flags.branch); +} diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..ef3bbc3 --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,105 @@ +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; + +export interface CodewiserConfig { + repo?: string; + branch?: string; + files?: Record; +} + +export const DEFAULT_REPO = "yallma3/codewiser"; + +export const DEFAULT_BRANCH = "main"; + +export const CONFIG_FILENAME = ".codewiser.json"; + +export function getConfigPath(targetDir: string): string { + return join(targetDir, CONFIG_FILENAME); +} + +export function getGlobalConfigPath(): string { + return join(homedir(), CONFIG_FILENAME); +} + +function parseConfig(raw: unknown): CodewiserConfig | null { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const candidate = raw as Record; + const config: CodewiserConfig = {}; + if (typeof candidate.repo === "string") config.repo = candidate.repo; + if (typeof candidate.branch === "string") config.branch = candidate.branch; + const files = candidate.files; + if (typeof files === "object" && files !== null && !Array.isArray(files)) { + const fileVersions: Record = {}; + for (const [name, version] of Object.entries(files as Record)) { + if (typeof version === "string") fileVersions[name] = version; + } + config.files = fileVersions; + } + return config; +} + +function readConfigFromPath(path: string): CodewiserConfig | null { + if (!existsSync(path)) return null; + try { + return parseConfig(JSON.parse(readFileSync(path, "utf-8"))); + } catch { + return null; + } +} + +function writeConfigToPath(path: string, config: CodewiserConfig): void { + writeFileSync(path, JSON.stringify(config, null, 2), "utf-8"); +} + +export function readConfig(targetDir: string): CodewiserConfig | null { + return readConfigFromPath(getConfigPath(targetDir)); +} + +export function writeConfig(targetDir: string, config: CodewiserConfig): void { + writeConfigToPath(getConfigPath(targetDir), config); +} + +export function readGlobalConfig(): CodewiserConfig | null { + return readConfigFromPath(getGlobalConfigPath()); +} + +export function writeGlobalConfig(config: CodewiserConfig): void { + writeConfigToPath(getGlobalConfigPath(), config); +} + +export function resolveRepo(_dir: string, cliRepo?: string, manifestRepo?: string, globalRepo?: string): string { + if (cliRepo) return cliRepo; + if (manifestRepo) return manifestRepo; + if (globalRepo) return globalRepo; + return DEFAULT_REPO; +} + +export function resolveBranch(_dir: string, cliBranch?: string, manifestBranch?: string, globalBranch?: string): string { + if (cliBranch) return cliBranch; + if (manifestBranch) return manifestBranch; + if (globalBranch) return globalBranch; + return DEFAULT_BRANCH; +} + +export function describeRepoSource(_dir: string, cliRepo?: string, manifestRepo?: string, globalRepo?: string): string { + if (cliRepo) return "--repo flag"; + if (manifestRepo) return "./codewiser.json"; + if (globalRepo) return "user profile (~/.codewiser.json)"; + return "built-in default"; +} + +export function describeBranchSource(_dir: string, cliBranch?: string, manifestBranch?: string, globalBranch?: string): string { + if (cliBranch) return "--branch flag"; + if (manifestBranch) return "./codewiser.json"; + if (globalBranch) return "user profile (~/.codewiser.json)"; + return "built-in default"; +} + +export function buildRawBase(repo: string, branch: string): string { + return `https://raw.githubusercontent.com/${repo}/${branch}`; +} + +export function validateRepoFormat(repo: string): boolean { + return /^[\w.-]+\/[\w.-]+$/.test(repo); +} diff --git a/src/utils/download.ts b/src/utils/download.ts new file mode 100644 index 0000000..028b7fe --- /dev/null +++ b/src/utils/download.ts @@ -0,0 +1,18 @@ +import { existsSync, mkdirSync } from "fs"; +import { dirname } from "path"; + +export async function download(url: string, dest: string): Promise { + const dir = dirname(dest); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + try { + const res = await fetch(url); + if (!res.ok) return false; + await Bun.write(dest, res); + return true; + } catch { + return false; + } +} diff --git a/src/utils/generate-configs.ts b/src/utils/generate-configs.ts new file mode 100644 index 0000000..cce5344 --- /dev/null +++ b/src/utils/generate-configs.ts @@ -0,0 +1,130 @@ +import { writeFileSync, existsSync, readFileSync, appendFileSync, mkdirSync } from "fs"; +import { join } from "path"; + +export function generateOpenCodeConfig(targetDir: string, skillDirs: string[], useOpencode: boolean): void { + if (!useOpencode) return; + const dest = join(targetDir, "opencode.json"); + + const config: Record = { + $schema: "https://opencode.ai/config.json", + }; + + if (skillDirs.length > 0) { + config.skills = { paths: skillDirs.map((d) => `.agents/skills/${d}`) }; + config.instructions = [...skillDirs.map((d) => `.agents/skills/${d}/**/SKILL.md`), "AGENTS.md"]; + } else { + config.skills = { paths: [".agents/skills"] }; + config.instructions = [".agents/skills/**/SKILL.md", "AGENTS.md"]; + } + + writeFileSync(dest, JSON.stringify(config, null, 2), "utf-8"); +} + +export function generateClaudeMD(targetDir: string, useClaude: boolean): void { + if (!useClaude) return; + const dest = join(targetDir, "CLAUDE.md"); + + writeFileSync( + dest, + `# Claude Code Settings + +@AGENTS.md + +## Claude-Specific Instructions +- Utilize the symlinked skills located in .claude/skills/ when triggered. +`, + "utf-8", + ); +} + +export function generateAntigravityConfig(targetDir: string, useAntigravity: boolean): void { + if (!useAntigravity) return; + const dir = join(targetDir, ".antigravity"); + const dest = join(dir, "workflows.json"); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const config = { + workflows: [ + { + name: "example", + description: "Example workflow referencing shared .agents/skills", + }, + ], + }; + + writeFileSync(dest, JSON.stringify(config, null, 2), "utf-8"); +} + +export function generateKiloConfig(targetDir: string, skillDirs: string[], useKilo: boolean): void { + if (!useKilo) return; + const dir = join(targetDir, ".kilo"); + const dest = join(dir, "config.json"); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const config: Record = { + $schema: "https://app.kilo.ai/config.json", + }; + + if (skillDirs.length > 0) { + config.instructions = ["AGENTS.md", ...skillDirs.map((d) => `.agents/skills/${d}/**/SKILL.md`)]; + } else { + config.instructions = ["AGENTS.md", ".agents/skills/*/SKILL.md"]; + } + + writeFileSync(dest, JSON.stringify(config, null, 2), "utf-8"); +} + +export function addExecutionProtocolToAgentsMD(targetDir: string, selectedMode: string): void { + if (!selectedMode) return; + const dest = join(targetDir, "AGENTS.md"); + if (!existsSync(dest)) return; + + const modeTitle = selectedMode + .split("-") + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" "); + const protocolHeader = `## ${modeTitle} Execution Protocol`; + + const content = readFileSync(dest, "utf-8"); + if (content.includes(protocolHeader)) return; + + const lines = content.split("\n"); + const result: string[] = []; + let skipping = false; + + for (const line of lines) { + const isProtoHeader = line.startsWith("## ") && line.includes("Execution Protocol"); + + if (!skipping && isProtoHeader) { + skipping = true; + continue; + } + + if (skipping) { + if (isProtoHeader) continue; + if (line.startsWith("## ")) { + skipping = false; + result.push(line); + continue; + } + continue; + } + + result.push(line); + } + + const cleaned = result.join("\n").trimEnd(); + const protocolBody = `${protocolHeader}\n\nAll file modifications or code generation tasks in this project MUST follow the\n${modeTitle} lifecycle defined below. The bootstrap skill will complete this\nsection with the full protocol after initial project analysis.\n`; + + if (cleaned !== content.trimEnd()) { + writeFileSync(dest, cleaned + "\n\n" + protocolBody, "utf-8"); + } else { + appendFileSync(dest, "\n\n" + protocolBody, "utf-8"); + } +} diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts new file mode 100644 index 0000000..a11b957 --- /dev/null +++ b/src/utils/manifest.ts @@ -0,0 +1,72 @@ +export function versionLt(v1: string, v2: string): boolean { + const p1 = v1.split(".").map(Number); + const p2 = v2.split(".").map(Number); + const len = Math.max(p1.length, p2.length); + for (let i = 0; i < len; i++) { + const a = p1[i] ?? 0; + const b = p2[i] ?? 0; + if (a < b) return true; + if (a > b) return false; + } + return false; +} + +export function extractVersion(value: unknown): string { + if (typeof value === "string") return value; + if (value && typeof value === "object" && "version" in (value as Record)) { + return String((value as Record).version); + } + return "0.0.0"; +} + +export type ModeEntry = { description?: string; files?: Record }; +export type ManifestModes = Record; + +export function flattenModeFiles(modeObj: ModeEntry): Record { + const result: Record = {}; + if (modeObj.files) { + for (const [path, val] of Object.entries(modeObj.files)) { + result[path] = extractVersion(val); + } + } + return result; +} + +export type WorkflowStage = { files?: Record }; +export type WorkflowEntry = { stages?: Record }; + +export function flattenWorkflowFiles( + manifestObj: { workflows?: Record }, + selectedIndices: number[], +): Record { + const result: Record = {}; + const wfNames = Object.keys(manifestObj.workflows ?? {}); + for (const idx of selectedIndices) { + const wfName = wfNames[idx]; + if (!wfName) continue; + const wf = manifestObj.workflows?.[wfName]; + if (!wf?.stages) continue; + const stages = Object.values(wf.stages) as WorkflowStage[]; + for (const stage of stages) { + if (stage.files) { + for (const [path, val] of Object.entries(stage.files)) { + result[path] = extractVersion(val); + } + } + } + } + return result; +} + +export type ManifestFormat = + | { type: "modes"; modes: ManifestModes } + | { type: "workflows"; workflows: Record } + | { type: "files"; files: Record } + | { type: "unknown" }; + +export function detectManifestFormat(obj: Record): ManifestFormat { + if (obj.modes) return { type: "modes", modes: obj.modes as ManifestModes }; + if (obj.workflows) return { type: "workflows", workflows: obj.workflows as Record }; + if (obj.files) return { type: "files", files: obj.files as Record }; + return { type: "unknown" }; +} diff --git a/src/utils/prompts.ts b/src/utils/prompts.ts new file mode 100644 index 0000000..55e3b03 --- /dev/null +++ b/src/utils/prompts.ts @@ -0,0 +1,98 @@ +import { pick, pickMany, confirmPrompt, textPrompt, BACK, EXIT } from "./ui"; + +export interface SelectedAgents { + opencode: boolean; + claude: boolean; + cursor: boolean; + antigravity: boolean; + kilo: boolean; +} + +export async function selectAgents(): Promise { + const result = await pickMany( + "Select AI agents:", + [ + { value: "opencode", label: "OpenCode / MiMo / Crush" }, + { value: "claude", label: "Claude Code" }, + { value: "cursor", label: "Cursor" }, + { value: "antigravity", label: "Antigravity" }, + { value: "kilo", label: "Kilo Code" }, + ], + { required: true }, + ); + + if (result === BACK || result === EXIT) return result; + + return { + opencode: result.includes("opencode"), + claude: result.includes("claude"), + cursor: result.includes("cursor"), + antigravity: result.includes("antigravity"), + kilo: result.includes("kilo"), + }; +} + +export async function selectMode( + modes: Record, +): Promise { + const result = await pick( + "Select development mode:", + [ + ...Object.entries(modes).map(([name, m]) => ({ + value: name, + label: name, + hint: m?.description, + })), + { value: "__back", label: "← Back to agents" }, + ], + ); + if (result === EXIT || result === BACK) return result; + if (result === "__back") return BACK; + return result; +} + +export async function selectWorkflows( + workflows: Record, +): Promise { + const wfNames = Object.keys(workflows); + const result = await pickMany( + "Select workflows to install:", + [ + ...wfNames.map((name) => ({ value: name, label: name })), + { value: "__back", label: "← Back to agents" }, + ], + { required: false }, + ); + if (result === BACK || result === EXIT) return result; + if (Array.isArray(result) && result.includes("__back")) return BACK; + return result.map((name: string) => wfNames.indexOf(name)); +} + +export async function confirmOverwrite( + path: string, + localVer: string, + remoteVer: string, +): Promise { + return confirmPrompt(`Overwrite ${path} (${localVer} -> ${remoteVer})?`); +} + +export async function selectBranch(): Promise { + const choice = await pick("Which branch should codewiser sync from?", [ + { value: "main", label: "main", hint: "(default)" }, + { value: "__custom", label: "Type a custom branch name..." }, + { value: "__cancel", label: "Cancel" }, + ]); + if (choice === EXIT || choice === "__cancel") return EXIT; + + let branch: string = choice; + if (choice === "__custom") { + const custom = await textPrompt({ message: "Branch name:", placeholder: "e.g. feature/x" }); + if (custom === EXIT) return EXIT; + branch = custom.trim(); + if (!branch) return EXIT; + } + + const ok = await confirmPrompt(`Sync from branch "${branch}"?`); + if (ok === EXIT || !ok) return EXIT; + return branch; +} diff --git a/src/utils/symlinks.ts b/src/utils/symlinks.ts new file mode 100644 index 0000000..eb45b04 --- /dev/null +++ b/src/utils/symlinks.ts @@ -0,0 +1,80 @@ +import { existsSync, lstatSync, symlinkSync, unlinkSync, rmSync, cpSync } from "fs"; +import { join } from "path"; +import { confirm } from "@clack/prompts"; +import { isCancel, cancel } from "@clack/prompts"; +import { error, warn } from "./ui"; + +interface SymlinkConfig { + relativeSrc: string; + relativeDest: string; + label: string; +} + +export async function createAllSymlinks(targetDir: string, configs: SymlinkConfig[]): Promise { + for (const cfg of configs) { + await handleSymlink(targetDir, cfg); + } +} + +async function handleSymlink(targetDir: string, cfg: SymlinkConfig): Promise { + const srcPath = join(targetDir, cfg.relativeSrc); + const destPath = join(targetDir, cfg.relativeDest); + + if (existsSync(srcPath)) { + try { + const stat = lstatSync(srcPath); + if (stat.isSymbolicLink()) { + unlinkSync(srcPath); + } else { + const remove = await confirm({ + message: `Replace existing directory ${srcPath} with a symlink?`, + initialValue: false, + }); + if (isCancel(remove)) { + cancel("Cancelled"); + return; + } + if (!remove) { + warn(`Preserved existing directory ${srcPath}`); + return; + } + rmSync(srcPath, { recursive: true, force: true }); + } + } catch (e) { + error(`Failed to prepare ${srcPath}: ${String(e)}`); + return; + } + } + + try { + symlinkSync(destPath, srcPath, "junction"); + } catch { + const result = await confirm({ + message: `Symlink failed for ${cfg.label}. Retry as admin?`, + initialValue: false, + }); + if (isCancel(result)) { + cancel("Cancelled"); + return; + } + + if (result) { + if (process.platform !== "win32") return; + const { spawnSync } = await import("child_process"); + const psQuote = (s: string): string => "'" + s.replace(/'/g, "''") + "'"; + const inner = `New-Item -ItemType SymbolicLink -Path ${psQuote(srcPath)} -Target ${psQuote(destPath)} -Force`; + const enc = Buffer.from(inner, "utf16le").toString("base64"); + const script = `Start-Process -Verb RunAs -Wait -FilePath powershell.exe -ArgumentList @('-NoProfile','-EncodedCommand','${enc}')`; + spawnSync("powershell.exe", ["-NoProfile", "-Command", script], { stdio: "inherit" }); + } + + if (!existsSync(srcPath) || !lstatSync(srcPath).isSymbolicLink()) { + copyDirContents(destPath, srcPath); + } + } +} + +function copyDirContents(src: string, dest: string): void { + if (!existsSync(src)) return; + cpSync(src, dest, { recursive: true, force: true }); +} diff --git a/src/utils/ui.ts b/src/utils/ui.ts new file mode 100644 index 0000000..3068bd3 --- /dev/null +++ b/src/utils/ui.ts @@ -0,0 +1,118 @@ +import { select, multiselect, confirm, isCancel, spinner, text } from "@clack/prompts"; +import pc from "picocolors"; + +export const BACK = "back"; +export const EXIT = "exit"; + +type Option = { value: T; label?: string; hint?: string }; + +// Global cleanup on Ctrl+C +process.on("SIGINT", () => { + try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch {} + process.exit(130); +}); + +export function showTitle(): void { + const w = 39; + const text = "C O D E W I S E R"; + const pad = w - text.length; + const l = " ".repeat(Math.floor(pad / 2)); + const r = " ".repeat(Math.ceil(pad / 2)); + console.log(`\n ${pc.bold(pc.cyan("╔" + "═".repeat(w) + "╗"))}`); + console.log(` ${pc.bold(pc.cyan("║" + " ".repeat(w) + "║"))}`); + console.log(` ${pc.bold(pc.cyan("║"))}${l}${pc.bold(pc.white(text))}${r}${pc.bold(pc.cyan("║"))}`); + console.log(` ${pc.bold(pc.cyan("║" + " ".repeat(w) + "║"))}`); + console.log(` ${pc.bold(pc.cyan("╚" + "═".repeat(w) + "╝"))}\n`); +} + +export function showDone(targetDir: string): void { + console.log(`\n ${pc.green("✔")} ${pc.bold("Done.")} Target: ${pc.cyan(targetDir)}\n`); +} + +export function stepHeader(num: number, title: string): void { + console.log(`\n${pc.bold(pc.cyan(` ─── Step ${num}: ${title} ───`))}\n`); +} + +export function info(msg: string): void { + console.log(` ${pc.blue("●")} ${msg}`); +} + +export function warn(msg: string): void { + console.log(` ${pc.yellow("▲")} ${msg}`); +} + +export function error(msg: string): void { + console.log(` ${pc.red("✖")} ${msg}`); +} + +export function success(msg: string): void { + console.log(` ${pc.green("✔")} ${msg}`); +} + +export function item(label: string): void { + console.log(` ${pc.dim("•")} ${label}`); +} + +export function fileStatus(path: string, kind: "new" | "updated" | "current"): void { + const icons = { new: pc.green("+"), updated: pc.yellow("~"), current: pc.dim("=") }; + console.log(` ${icons[kind]} ${path}`); +} + +export async function pick( + message: string, + options: Option[], +): Promise { + const result = await select({ + message, + options: options as any, + }); + if (isCancel(result)) return EXIT; + return result as T; +} + +export async function pickMany( + message: string, + options: Option[], + config?: { required?: boolean }, +): Promise { + const result = await multiselect({ + message, + options: options as any, + required: config?.required ?? false, + }); + if (isCancel(result)) return EXIT; + return result as T[]; +} + +export async function confirmPrompt(message: string): Promise { + const result = await confirm({ + message, + initialValue: true, + }); + if (isCancel(result)) return EXIT; + return result as boolean; +} + +export async function textPrompt(opts: { message: string; placeholder?: string; initialValue?: string }): Promise { + const result = await text({ + message: opts.message, + placeholder: opts.placeholder, + initialValue: opts.initialValue, + }); + if (isCancel(result)) return EXIT; + return result; +} + +export async function runSpinner(label: string, fn: () => Promise): Promise { + const s = spinner(); + s.start(label); + try { + const result = await fn(); + return result; + } catch (e) { + error(String(e)); + return BACK; + } finally { + s.stop(label); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b2e7497 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}