From 9c410cee409ef412b5d226c4150c0cf4d7714556 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Mon, 27 Jul 2026 13:38:33 +0300 Subject: [PATCH 01/19] feat: replace shell scripts with TypeScript CLI --- .gitignore | 34 ++ README.md | 70 ++-- bun.lock | 47 +++ codewiser.ps1 | 562 ------------------------------- codewiser.sh | 606 ---------------------------------- package.json | 22 ++ src/commands/init.ts | 264 +++++++++++++++ src/index.ts | 30 ++ src/utils/download.ts | 17 + src/utils/generate-configs.ts | 111 +++++++ src/utils/manifest.ts | 104 ++++++ src/utils/prompts.ts | 77 +++++ src/utils/symlinks.ts | 74 +++++ src/utils/ui.ts | 109 ++++++ tsconfig.json | 30 ++ 15 files changed, 967 insertions(+), 1190 deletions(-) create mode 100644 .gitignore create mode 100644 bun.lock delete mode 100644 codewiser.ps1 delete mode 100755 codewiser.sh create mode 100644 package.json create mode 100644 src/commands/init.ts create mode 100644 src/index.ts create mode 100644 src/utils/download.ts create mode 100644 src/utils/generate-configs.ts create mode 100644 src/utils/manifest.ts create mode 100644 src/utils/prompts.ts create mode 100644 src/utils/symlinks.ts create mode 100644 src/utils/ui.ts create mode 100644 tsconfig.json 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..74cfe1e 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,23 +45,25 @@ 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. ## Supported Agents @@ -89,19 +91,43 @@ Skills are shared across all agents. Create a file at `.agents/skills/ - -# 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..26a9774 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "codewiser-cli", + "bin": { + "codewiser": "src/index.ts" + }, + "type": "module", + "private": true, + "scripts": { + "start": "bun src/index.ts" + }, + "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..205dfbc --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,264 @@ +import { existsSync, mkdirSync } from "fs"; +import { 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, + getManifestVersion, + flattenModeFiles, + flattenWorkflowFiles, + detectManifestFormat, +} from "../utils/manifest"; +import { + generateOpenCodeConfig, + generateClaudeMD, + generateAntigravityConfig, + generateKiloConfig, + addExecutionProtocolToAgentsMD, +} from "../utils/generate-configs"; +import { createAllSymlinks } from "../utils/symlinks"; +import type { SelectedAgents } from "../utils/prompts"; + +const RAW_BASE = "https://raw.githubusercontent.com/yallma3/codewiser/main"; + +export async function init(targetDirInput: string): Promise { + showTitle(); + + const targetDir = resolve(targetDirInput); + if (!existsSync(targetDir)) { + mkdirSync(targetDir, { recursive: true }); + } + info(`Setting up codewiser in ${targetDir}`); + + let agents: SelectedAgents | null = null; + let selectedMode = ""; + let skillDirs: string[] = []; + let remoteFiles: Record = {}; + let cachedManifest: Record | null = null; + + type Step = "agents" | "mode" | "download" | "configs" | "symlinks"; + let current: Step = "agents"; + + const stepLabels: Record = { + agents: "Select Agents", + mode: "Configure Mode", + download: "Download Files", + configs: "Generate Configs", + symlinks: "Create Symlinks", + }; + + let iterations = 0; + + while (current !== "symlinks") { + iterations++; + if (iterations > 20) { + error("Too many navigation steps. Aborting."); + return; + } + + const stepNum = ["agents", "mode", "download", "configs", "symlinks"].indexOf(current) + 1; + + stepHeader(stepNum, stepLabels[current]); + + switch (current) { + case "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; + + mkdirSync(`${targetDir}\\.agents\\skills`, { recursive: true }); + mkdirSync(`${targetDir}\\.agents\\specs`, { recursive: true }); + mkdirSync(`${targetDir}\\.agents\\plans`, { recursive: true }); + mkdirSync(`${targetDir}\\.agents\\research`, { recursive: true }); + + if (agents.claude) mkdirSync(`${targetDir}\\.claude`, { recursive: true }); + if (agents.cursor) mkdirSync(`${targetDir}\\.cursor`, { recursive: true }); + if (agents.antigravity) mkdirSync(`${targetDir}\\.antigravity`, { recursive: true }); + if (agents.kilo) mkdirSync(`${targetDir}\\.kilo`, { recursive: true }); + + success("Directories created"); + current = "mode"; + break; + } + + case "mode": { + if (!cachedManifest) { + const manifestUrl = `${RAW_BASE}/.agents/manifest.json`; + const result = await runSpinner("Fetching manifest...", async () => { + const res = await fetch(manifestUrl); + if (!res.ok) throw new Error(`HTTP ${res.status}: Failed to download manifest`); + const data = (await res.json()) as Record; + cachedManifest = data; + return data; + }); + if (result === BACK) { current = "agents"; break; } + 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 = "download"; + 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 = "download"; + break; + } + + case "files": { + for (const [path, val] of Object.entries(format.files)) { + remoteFiles[path] = typeof val === "string" ? val : "0.0.0"; + } + current = "download"; + break; + } + + default: + error("Unknown manifest format. Aborting."); + return; + } + break; + } + + case "download": { + const manifestUrl = `${RAW_BASE}/.agents/manifest.json`; + const localManifestPath = `${targetDir}\\.agents\\manifest.json`; + + const nav = await pick("Navigate:", [ + { value: "continue", label: "Continue with download" }, + { value: "back", label: "← Back to mode selection" }, + { value: "quit", label: "Quit" }, + ]); + if (nav === EXIT) return; + if (nav === "quit") return; + if (nav === "back") { current = "mode"; break; } + + let downloaded = 0; + let wentBack = false; + for (const [filePath, remoteVer] of Object.entries(remoteFiles)) { + const localPath = filePath.replace(/\//g, "\\"); + const dest = `${targetDir}\\${localPath}`; + 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 = getManifestVersion(localManifestPath, filePath) ?? "0.0.0"; + if (versionLt(localVer, remoteVer)) { + const overwrite = await confirmOverwrite(filePath, localVer, remoteVer); + if (overwrite === EXIT) return; + if (overwrite === BACK) { current = "mode"; wentBack = true; break; } + 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 (wentBack) break; + + if (downloaded > 0) success(`${downloaded} file(s) downloaded`); + else info("All files up to date"); + + await download(manifestUrl, localManifestPath); + + current = "configs"; + break; + } + + case "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"); + current = "symlinks"; + break; + } + } + + } + + stepHeader(5, "Create Symlinks"); + + const symlinkConfigs: Array<{ relativeSrc: string; relativeDest: string; label: string }> = []; + if (agents?.claude) { + symlinkConfigs.push({ relativeSrc: ".claude\\skills", relativeDest: "..\\.agents\\skills", label: "Claude Code" }); + } + if (agents?.cursor) { + symlinkConfigs.push({ relativeSrc: ".cursor\\skills", relativeDest: "..\\.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 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/index.ts b/src/index.ts new file mode 100644 index 0000000..b3b61db --- /dev/null +++ b/src/index.ts @@ -0,0 +1,30 @@ +import meow from "meow"; +import { resolve } from "path"; +import { init } from "./commands/init"; + +const cli = meow( + ` + Usage + $ codewiser + + Options + --help Show this help + --version Show version +`, + { + importMeta: import.meta, + flags: {}, + }, +); + +const name = cli.input[0]; + +if (!name) { + console.error("error: is required"); + console.error(cli.help); + process.exit(1); +} + +const targetDir = resolve(process.cwd(), "..", name); + +await init(targetDir); diff --git a/src/utils/download.ts b/src/utils/download.ts new file mode 100644 index 0000000..fcae467 --- /dev/null +++ b/src/utils/download.ts @@ -0,0 +1,17 @@ +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..618963c --- /dev/null +++ b/src/utils/generate-configs.ts @@ -0,0 +1,111 @@ +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"); + if (existsSync(dest)) return; + + 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"); + if (existsSync(dest)) return; + + 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(dest)) return; + + 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(dest)) return; + + 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 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. + +`; + appendFileSync(dest, protocolSection, "utf-8"); +} diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts new file mode 100644 index 0000000..fe3aba9 --- /dev/null +++ b/src/utils/manifest.ts @@ -0,0 +1,104 @@ +import { readFileSync, existsSync } from "fs"; + +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 function getManifestVersion(manifestPath: string, filePath: string): string | null { + if (!existsSync(manifestPath)) return null; + try { + const obj = JSON.parse(readFileSync(manifestPath, "utf-8")) as Record; + const modes = obj.modes as Record }> | undefined; + if (modes) { + for (const mode of Object.values(modes)) { + if (mode.files?.[filePath]) return extractVersion(mode.files[filePath]); + } + return null; + } + const workflows = obj.workflows as Record }> }> | undefined; + if (workflows) { + for (const wf of Object.values(workflows)) { + if (wf.stages) { + for (const stage of Object.values(wf.stages)) { + if (stage.files?.[filePath]) return extractVersion(stage.files[filePath]); + } + } + } + return null; + } + const files = obj.files as Record | undefined; + if (files?.[filePath]) return extractVersion(files[filePath]); + return null; + } catch { + return null; + } +} + +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..fd46cfb --- /dev/null +++ b/src/utils/prompts.ts @@ -0,0 +1,77 @@ +import { pick, pickMany, confirmPrompt, 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})?`); +} diff --git a/src/utils/symlinks.ts b/src/utils/symlinks.ts new file mode 100644 index 0000000..e12eb4f --- /dev/null +++ b/src/utils/symlinks.ts @@ -0,0 +1,74 @@ +import { existsSync, lstatSync, symlinkSync, unlinkSync, rmSync, mkdirSync, readdirSync } from "fs"; +import { join } from "path"; +import { confirm } from "@clack/prompts"; +import { isCancel, cancel } from "@clack/prompts"; + +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 { + copyDirContents(srcPath, destPath); + rmSync(srcPath, { recursive: true, force: true }); + } + } catch { + // ignore + } + } + + 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"); + + if (result) { + const { spawnSync } = await import("child_process"); + spawnSync("powershell.exe", [ + "-NoProfile", + "-Command", + `New-Item -ItemType SymbolicLink -Path '${srcPath}' -Target '${destPath}' -Force`, + ], { stdio: "inherit" }); + } + + if (!existsSync(srcPath) || !lstatSync(srcPath).isSymbolicLink()) { + copyDirContents(destPath, srcPath); + } + } +} + +function copyDirContents(src: string, dest: string): void { + if (!existsSync(src)) return; + mkdirSync(dest, { recursive: true }); + const entries = readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + const s = join(src, entry.name); + const d = join(dest, entry.name); + if (entry.isDirectory()) { + copyDirContents(s, d); + } else { + const content = Bun.file(s); + Bun.write(d, content); + } + } +} diff --git a/src/utils/ui.ts b/src/utils/ui.ts new file mode 100644 index 0000000..509ba9c --- /dev/null +++ b/src/utils/ui.ts @@ -0,0 +1,109 @@ +import { select, multiselect, confirm, isCancel, spinner } 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 runSpinner(label: string, fn: () => Promise): Promise { + const s = spinner(); + s.start(label); + try { + const result = await fn(); + return result; + } catch (e) { + s.stop(label); + 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 + } +} From ec4c620c660c7fa67f5142b760a156ce894b3ce7 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Tue, 28 Jul 2026 12:27:10 +0300 Subject: [PATCH 02/19] detecting ai agents --- src/commands/init.ts | 240 ++++++++++++++++++++++++++---------------- src/utils/symlinks.ts | 2 +- 2 files changed, 152 insertions(+), 90 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 205dfbc..a6300cf 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync } from "fs"; -import { resolve } from "path"; +import { join, resolve } from "path"; import { showTitle, showDone, stepHeader, info, warn, error, success, item, fileStatus, runSpinner, pick, confirmPrompt, BACK, EXIT, @@ -39,55 +39,57 @@ export async function init(targetDirInput: string): Promise { let skillDirs: string[] = []; let remoteFiles: Record = {}; let cachedManifest: Record | null = null; + let agentStepVisited = false; - type Step = "agents" | "mode" | "download" | "configs" | "symlinks"; + type Step = "agents" | "mode" | "confirm" | "done"; let current: Step = "agents"; const stepLabels: Record = { agents: "Select Agents", mode: "Configure Mode", - download: "Download Files", - configs: "Generate Configs", - symlinks: "Create Symlinks", + confirm: "Confirm Selections", + done: "", }; let iterations = 0; - while (current !== "symlinks") { + while (current !== "done") { iterations++; if (iterations > 20) { error("Too many navigation steps. Aborting."); return; } - const stepNum = ["agents", "mode", "download", "configs", "symlinks"].indexOf(current) + 1; + const stepNum = ["agents", "mode", "confirm"].indexOf(current) + 1; stepHeader(stepNum, stepLabels[current]); switch (current) { case "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; + if (!agentStepVisited) { + agentStepVisited = true; + const detected = detectInstalledAgents(targetDir); + if (detected) { + agents = detected; + info(`Auto-detected: ${agentNames(detected).join(", ")}`); + } + } else { + agents = null; } - agents = result; - - mkdirSync(`${targetDir}\\.agents\\skills`, { recursive: true }); - mkdirSync(`${targetDir}\\.agents\\specs`, { recursive: true }); - mkdirSync(`${targetDir}\\.agents\\plans`, { recursive: true }); - mkdirSync(`${targetDir}\\.agents\\research`, { recursive: true }); - if (agents.claude) mkdirSync(`${targetDir}\\.claude`, { recursive: true }); - if (agents.cursor) mkdirSync(`${targetDir}\\.cursor`, { recursive: true }); - if (agents.antigravity) mkdirSync(`${targetDir}\\.antigravity`, { recursive: true }); - if (agents.kilo) mkdirSync(`${targetDir}\\.kilo`, { recursive: true }); + 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; + } - success("Directories created"); current = "mode"; break; } @@ -127,7 +129,7 @@ export async function init(targetDirInput: string): Promise { info("Skills:"); for (const s of skillDirs.sort()) item(s); } - current = "download"; + current = "confirm"; break; } @@ -146,7 +148,7 @@ export async function init(targetDirInput: string): Promise { info("Skills:"); for (const s of skillDirs) item(s); } - current = "download"; + current = "confirm"; break; } @@ -154,7 +156,7 @@ export async function init(targetDirInput: string): Promise { for (const [path, val] of Object.entries(format.files)) { remoteFiles[path] = typeof val === "string" ? val : "0.0.0"; } - current = "download"; + current = "confirm"; break; } @@ -165,83 +167,98 @@ export async function init(targetDirInput: string): Promise { break; } - case "download": { - const manifestUrl = `${RAW_BASE}/.agents/manifest.json`; - const localManifestPath = `${targetDir}\\.agents\\manifest.json`; + 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 nav = await pick("Navigate:", [ - { value: "continue", label: "Continue with download" }, - { value: "back", label: "← Back to mode selection" }, + 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 (nav === EXIT) return; - if (nav === "quit") return; - if (nav === "back") { current = "mode"; break; } - - let downloaded = 0; - let wentBack = false; - for (const [filePath, remoteVer] of Object.entries(remoteFiles)) { - const localPath = filePath.replace(/\//g, "\\"); - const dest = `${targetDir}\\${localPath}`; - 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 = getManifestVersion(localManifestPath, filePath) ?? "0.0.0"; - if (versionLt(localVer, remoteVer)) { - const overwrite = await confirmOverwrite(filePath, localVer, remoteVer); - if (overwrite === EXIT) return; - if (overwrite === BACK) { current = "mode"; wentBack = true; break; } - 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 (wentBack) break; - if (downloaded > 0) success(`${downloaded} file(s) downloaded`); - else info("All files up to date"); + if (choice === EXIT || choice === "quit") return; + if (choice === "back-agents") { current = "agents"; break; } + if (choice === "back-mode") { current = "mode"; break; } - await download(manifestUrl, localManifestPath); - - current = "configs"; + current = "done"; break; } + } + } - case "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"); - current = "symlinks"; - break; + // Phase 2: Execute (all file operations) + info("Creating directories..."); + mkdirSync(`${targetDir}\\.agents\\skills`, { recursive: true }); + mkdirSync(`${targetDir}\\.agents\\specs`, { recursive: true }); + mkdirSync(`${targetDir}\\.agents\\plans`, { recursive: true }); + mkdirSync(`${targetDir}\\.agents\\research`, { recursive: true }); + + if (agents?.claude) mkdirSync(`${targetDir}\\.claude`, { recursive: true }); + if (agents?.cursor) mkdirSync(`${targetDir}\\.cursor`, { recursive: true }); + if (agents?.antigravity) mkdirSync(`${targetDir}\\.antigravity`, { recursive: true }); + if (agents?.kilo) mkdirSync(`${targetDir}\\.kilo`, { recursive: true }); + + success("Directories created"); + + stepHeader(4, "Download Files"); + const manifestUrl = `${RAW_BASE}/.agents/manifest.json`; + const localManifestPath = `${targetDir}\\.agents\\manifest.json`; + + let downloaded = 0; + for (const [filePath, remoteVer] of Object.entries(remoteFiles)) { + const localPath = filePath.replace(/\//g, "\\"); + const dest = `${targetDir}\\${localPath}`; + 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 = getManifestVersion(localManifestPath, 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"); } - } - stepHeader(5, "Create Symlinks"); + if (downloaded > 0) success(`${downloaded} file(s) downloaded`); + else info("All files up to date"); + + await download(manifestUrl, localManifestPath); + 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: ".claude\\skills", relativeDest: "..\\.agents\\skills", label: "Claude Code" }); + symlinkConfigs.push({ relativeSrc: ".claude\\skills", relativeDest: ".agents\\skills", label: "Claude Code" }); } if (agents?.cursor) { - symlinkConfigs.push({ relativeSrc: ".cursor\\skills", relativeDest: "..\\.agents\\skills", label: "Cursor" }); + symlinkConfigs.push({ relativeSrc: ".cursor\\skills", relativeDest: ".agents\\skills", label: "Cursor" }); } if (symlinkConfigs.length > 0) { @@ -254,6 +271,51 @@ export async function init(targetDirInput: string): Promise { 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) diff --git a/src/utils/symlinks.ts b/src/utils/symlinks.ts index e12eb4f..fbb276b 100644 --- a/src/utils/symlinks.ts +++ b/src/utils/symlinks.ts @@ -47,7 +47,7 @@ async function handleSymlink(targetDir: string, cfg: SymlinkConfig): Promise Date: Tue, 28 Jul 2026 13:05:38 +0300 Subject: [PATCH 03/19] replace mode execution protocol in agent.md --- src/utils/generate-configs.ts | 43 +++++++++++++++++++++++++---------- src/utils/symlinks.ts | 1 - 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/utils/generate-configs.ts b/src/utils/generate-configs.ts index 618963c..cce5344 100644 --- a/src/utils/generate-configs.ts +++ b/src/utils/generate-configs.ts @@ -4,7 +4,6 @@ import { join } from "path"; export function generateOpenCodeConfig(targetDir: string, skillDirs: string[], useOpencode: boolean): void { if (!useOpencode) return; const dest = join(targetDir, "opencode.json"); - if (existsSync(dest)) return; const config: Record = { $schema: "https://opencode.ai/config.json", @@ -24,7 +23,6 @@ export function generateOpenCodeConfig(targetDir: string, skillDirs: string[], u export function generateClaudeMD(targetDir: string, useClaude: boolean): void { if (!useClaude) return; const dest = join(targetDir, "CLAUDE.md"); - if (existsSync(dest)) return; writeFileSync( dest, @@ -43,7 +41,6 @@ export function generateAntigravityConfig(targetDir: string, useAntigravity: boo if (!useAntigravity) return; const dir = join(targetDir, ".antigravity"); const dest = join(dir, "workflows.json"); - if (existsSync(dest)) return; if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); @@ -65,7 +62,6 @@ export function generateKiloConfig(targetDir: string, skillDirs: string[], useKi if (!useKilo) return; const dir = join(targetDir, ".kilo"); const dest = join(dir, "config.json"); - if (existsSync(dest)) return; if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); @@ -98,14 +94,37 @@ export function addExecutionProtocolToAgentsMD(targetDir: string, selectedMode: const content = readFileSync(dest, "utf-8"); if (content.includes(protocolHeader)) return; - const protocolSection = ` - -${protocolHeader} + 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); + } -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. + 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`; -`; - appendFileSync(dest, protocolSection, "utf-8"); + 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/symlinks.ts b/src/utils/symlinks.ts index fbb276b..aeaf10d 100644 --- a/src/utils/symlinks.ts +++ b/src/utils/symlinks.ts @@ -25,7 +25,6 @@ async function handleSymlink(targetDir: string, cfg: SymlinkConfig): Promise Date: Thu, 30 Jul 2026 17:39:02 +0300 Subject: [PATCH 04/19] add codewiser.json --- README.md | 8 +++--- .agents/manifest.json => codewiser.json | 6 ++-- package.json | 2 +- src/commands/init.ts | 23 +++++++++------ src/index.ts | 13 ++++++--- src/utils/config.ts | 38 +++++++++++++++++++++++++ src/utils/manifest.ts | 32 --------------------- 7 files changed, 70 insertions(+), 52 deletions(-) rename .agents/manifest.json => codewiser.json (97%) create mode 100644 src/utils/config.ts diff --git a/README.md b/README.md index 74cfe1e..2ced94d 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ 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. @@ -100,8 +100,8 @@ Example: the [git-worktrees skill](.agents/skills/shared/git-worktrees/SKILL.md) ```bash # Clone and install -git clone https://github.com/yallma3/codewiser-cli.git -cd codewiser-cli +git clone https://github.com/yallma3/codewiser.git +cd codewiser bun install # Link globally (optional) @@ -116,7 +116,7 @@ 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 `manifest.json` that tracks artifact versions organized by development modes. +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 diff --git a/.agents/manifest.json b/codewiser.json similarity index 97% rename from .agents/manifest.json rename to codewiser.json index f9c42b3..cafdd25 100644 --- a/.agents/manifest.json +++ b/codewiser.json @@ -1,7 +1,9 @@ { - "$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", + "repo": "yallma3/codewiser", + "branch": "main", "modes": { "prototype": { "description": "Rapid prototyping — BRD, UX design, then code. No formal planning or test requirements.", diff --git a/package.json b/package.json index 26a9774..856631a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "codewiser-cli", + "name": "codewiser", "bin": { "codewiser": "src/index.ts" }, diff --git a/src/commands/init.ts b/src/commands/init.ts index a6300cf..a9ded68 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -8,7 +8,6 @@ import { download } from "../utils/download"; import { selectAgents, selectMode, selectWorkflows, confirmOverwrite } from "../utils/prompts"; import { versionLt, - getManifestVersion, flattenModeFiles, flattenWorkflowFiles, detectManifestFormat, @@ -21,11 +20,10 @@ import { addExecutionProtocolToAgentsMD, } from "../utils/generate-configs"; import { createAllSymlinks } from "../utils/symlinks"; +import { readConfig, writeConfig, resolveRepo, resolveBranch, buildRawBase } from "../utils/config"; import type { SelectedAgents } from "../utils/prompts"; -const RAW_BASE = "https://raw.githubusercontent.com/yallma3/codewiser/main"; - -export async function init(targetDirInput: string): Promise { +export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: string): Promise { showTitle(); const targetDir = resolve(targetDirInput); @@ -34,6 +32,11 @@ export async function init(targetDirInput: string): Promise { } info(`Setting up codewiser in ${targetDir}`); + const existingConfig = readConfig(targetDir); + const repo = resolveRepo(cliRepo, existingConfig?.repo); + const branch = resolveBranch(cliBranch, existingConfig?.branch); + const RAW_BASE = buildRawBase(repo, branch); + let agents: SelectedAgents | null = null; let selectedMode = ""; let skillDirs: string[] = []; @@ -96,7 +99,7 @@ export async function init(targetDirInput: string): Promise { case "mode": { if (!cachedManifest) { - const manifestUrl = `${RAW_BASE}/.agents/manifest.json`; + const manifestUrl = `${RAW_BASE}/codewiser.json`; const result = await runSpinner("Fetching manifest...", async () => { const res = await fetch(manifestUrl); if (!res.ok) throw new Error(`HTTP ${res.status}: Failed to download manifest`); @@ -208,8 +211,6 @@ export async function init(targetDirInput: string): Promise { success("Directories created"); stepHeader(4, "Download Files"); - const manifestUrl = `${RAW_BASE}/.agents/manifest.json`; - const localManifestPath = `${targetDir}\\.agents\\manifest.json`; let downloaded = 0; for (const [filePath, remoteVer] of Object.entries(remoteFiles)) { @@ -222,7 +223,7 @@ export async function init(targetDirInput: string): Promise { if (ok) { downloaded++; fileStatus(filePath, "new"); } else warn(`Failed: ${filePath}`); } else if (filePath.endsWith("/SKILL.md")) { - const localVer = getManifestVersion(localManifestPath, filePath) ?? "0.0.0"; + const localVer = existingConfig?.files?.[filePath] ?? "0.0.0"; if (versionLt(localVer, remoteVer)) { const overwrite = await confirmOverwrite(filePath, localVer, remoteVer); if (overwrite === EXIT) return; @@ -242,7 +243,11 @@ export async function init(targetDirInput: string): Promise { if (downloaded > 0) success(`${downloaded} file(s) downloaded`); else info("All files up to date"); - await download(manifestUrl, localManifestPath); + const fileVersions: Record = {}; + for (const [filePath, ver] of Object.entries(remoteFiles)) { + fileVersions[filePath] = ver; + } + writeConfig(targetDir, { repo, branch, files: fileVersions }); stepHeader(5, "Generate Configs"); generateOpenCodeConfig(targetDir, skillDirs, agents?.opencode ?? false); diff --git a/src/index.ts b/src/index.ts index b3b61db..5da340b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,12 +8,17 @@ const cli = meow( $ codewiser Options - --help Show this help - --version Show version + --repo GitHub repository to sync from (default: yallma3/codewiser) + --branch Git branch to use (default: main) + --help Show this help + --version Show version `, { importMeta: import.meta, - flags: {}, + flags: { + repo: { type: "string" }, + branch: { type: "string" }, + }, }, ); @@ -27,4 +32,4 @@ if (!name) { const targetDir = resolve(process.cwd(), "..", name); -await init(targetDir); +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..23142e6 --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,38 @@ +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; + +export interface CodewiserConfig { + repo: string; + branch: string; + files?: Record; +} + +const DEFAULT_REPO = "yallma3/codewiser"; +const DEFAULT_BRANCH = "main"; + +export function readConfig(targetDir: string): CodewiserConfig | null { + const configPath = join(targetDir, ".codewiser.json"); + if (!existsSync(configPath)) return null; + try { + return JSON.parse(readFileSync(configPath, "utf-8")) as CodewiserConfig; + } catch { + return null; + } +} + +export function writeConfig(targetDir: string, config: CodewiserConfig): void { + const configPath = join(targetDir, ".codewiser.json"); + writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); +} + +export function resolveRepo(cliRepo: string | undefined, configRepo: string | undefined): string { + return cliRepo || configRepo || DEFAULT_REPO; +} + +export function resolveBranch(cliBranch: string | undefined, configBranch: string | undefined): string { + return cliBranch || configBranch || DEFAULT_BRANCH; +} + +export function buildRawBase(repo: string, branch: string): string { + return `https://raw.githubusercontent.com/${repo}/${branch}`; +} diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts index fe3aba9..a11b957 100644 --- a/src/utils/manifest.ts +++ b/src/utils/manifest.ts @@ -1,5 +1,3 @@ -import { readFileSync, existsSync } from "fs"; - export function versionLt(v1: string, v2: string): boolean { const p1 = v1.split(".").map(Number); const p2 = v2.split(".").map(Number); @@ -21,36 +19,6 @@ export function extractVersion(value: unknown): string { return "0.0.0"; } -export function getManifestVersion(manifestPath: string, filePath: string): string | null { - if (!existsSync(manifestPath)) return null; - try { - const obj = JSON.parse(readFileSync(manifestPath, "utf-8")) as Record; - const modes = obj.modes as Record }> | undefined; - if (modes) { - for (const mode of Object.values(modes)) { - if (mode.files?.[filePath]) return extractVersion(mode.files[filePath]); - } - return null; - } - const workflows = obj.workflows as Record }> }> | undefined; - if (workflows) { - for (const wf of Object.values(workflows)) { - if (wf.stages) { - for (const stage of Object.values(wf.stages)) { - if (stage.files?.[filePath]) return extractVersion(stage.files[filePath]); - } - } - } - return null; - } - const files = obj.files as Record | undefined; - if (files?.[filePath]) return extractVersion(files[filePath]); - return null; - } catch { - return null; - } -} - export type ModeEntry = { description?: string; files?: Record }; export type ManifestModes = Record; From c279a95e2bb34f6102d5a2b95c87caf5735c38f1 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Thu, 30 Jul 2026 17:49:09 +0300 Subject: [PATCH 05/19] add codewiser.json --- codewiser.json | 4 ++-- src/utils/config.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codewiser.json b/codewiser.json index cafdd25..311b113 100644 --- a/codewiser.json +++ b/codewiser.json @@ -2,8 +2,8 @@ "$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", - "repo": "yallma3/codewiser", - "branch": "main", + "repo": "mostafamm44/codewiser", + "branch": "add/codewiser.json", "modes": { "prototype": { "description": "Rapid prototyping — BRD, UX design, then code. No formal planning or test requirements.", diff --git a/src/utils/config.ts b/src/utils/config.ts index 23142e6..d34290b 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -7,8 +7,8 @@ export interface CodewiserConfig { files?: Record; } -const DEFAULT_REPO = "yallma3/codewiser"; -const DEFAULT_BRANCH = "main"; +const DEFAULT_REPO ="mostafamm44/codewiser"; +const DEFAULT_BRANCH = "add/codewiser.json"; export function readConfig(targetDir: string): CodewiserConfig | null { const configPath = join(targetDir, ".codewiser.json"); From 74bc37e8315157df3d1212b415c74a5a33264613 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Thu, 30 Jul 2026 18:09:44 +0300 Subject: [PATCH 06/19] ff --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 804723a..f73de63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,11 @@ Global instructions and behavioral constraints live in this file. Project-specific scripts, technology choices, and setup instructions are documented in `README.md`. + + + + new repo ## Core Directives - `.agents/skills/` — What the agents can do (Commands/Capabilities). - `.agents/specs/` — What the agents are building (System & Product Architecture). From 5ea8befca4a814f4a5efa5fbfb9bc37e5206123b Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Thu, 30 Jul 2026 18:32:28 +0300 Subject: [PATCH 07/19] new changes --- src/commands/init.ts | 1 + src/index.ts | 4 ++-- src/utils/config.ts | 50 ++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index a9ded68..2e5cc6a 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -36,6 +36,7 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: const repo = resolveRepo(cliRepo, existingConfig?.repo); const branch = resolveBranch(cliBranch, existingConfig?.branch); const RAW_BASE = buildRawBase(repo, branch); + info(`Repo: ${repo} (branch: ${branch})`); let agents: SelectedAgents | null = null; let selectedMode = ""; diff --git a/src/index.ts b/src/index.ts index 5da340b..b9f13f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,8 +8,8 @@ const cli = meow( $ codewiser Options - --repo GitHub repository to sync from (default: yallma3/codewiser) - --branch Git branch to use (default: main) + --repo GitHub repository to sync from (default: auto-detect from git remote) + --branch Git branch to use (default: auto-detect from current branch) --help Show this help --version Show version `, diff --git a/src/utils/config.ts b/src/utils/config.ts index d34290b..097a7fd 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync, writeFileSync } from "fs"; import { join } from "path"; +import { execSync } from "child_process"; export interface CodewiserConfig { repo: string; @@ -25,14 +26,55 @@ export function writeConfig(targetDir: string, config: CodewiserConfig): void { writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); } -export function resolveRepo(cliRepo: string | undefined, configRepo: string | undefined): string { - return cliRepo || configRepo || DEFAULT_REPO; +export function resolveRepo(cliRepo?: string, configRepo?: string): string { + if (cliRepo) return cliRepo; + if (configRepo) return configRepo; + const gitRepo = detectGitRemote(); + return gitRepo || DEFAULT_REPO; } -export function resolveBranch(cliBranch: string | undefined, configBranch: string | undefined): string { - return cliBranch || configBranch || DEFAULT_BRANCH; +export function resolveBranch(cliBranch?: string, configBranch?: string): string { + if (cliBranch) return cliBranch; + if (configBranch) return configBranch; + const gitBranch = detectGitBranch(); + return gitBranch || DEFAULT_BRANCH; } export function buildRawBase(repo: string, branch: string): string { return `https://raw.githubusercontent.com/${repo}/${branch}`; } + +const GIT_URL_PATTERNS = [ + /github\.com[/:](.+?)(?:\.git)?$/, + /git@github\.com:(.+?)(?:\.git)?$/, +]; + +function detectGitRemote(): string | null { + try { + const url = execSync("git remote get-url origin", { + encoding: "utf-8", + timeout: 3000, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + for (const pattern of GIT_URL_PATTERNS) { + const m = url.match(pattern); + if (m) return m[1] ?? null; + } + return null; + } catch { + return null; + } +} + +function detectGitBranch(): string | null { + try { + const branch = execSync("git rev-parse --abbrev-ref HEAD", { + encoding: "utf-8", + timeout: 3000, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return branch === "HEAD" ? null : branch; + } catch { + return null; + } +} From c90dbf10a9e34c196d2d48b776480ba47a4e8a32 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Fri, 31 Jul 2026 08:49:37 +0300 Subject: [PATCH 08/19] add command repo --- AGENTS.md | 4 - README.md | 21 ++++- package.json | 3 +- src/commands/init.ts | 13 ++- src/commands/repo.ts | 55 +++++++++++++ src/index.ts | 35 +++++--- src/utils/config.test.ts | 158 +++++++++++++++++++++++++++++++++++++ src/utils/config.ts | 60 ++++++++------ src/utils/manifest.test.ts | 97 +++++++++++++++++++++++ 9 files changed, 404 insertions(+), 42 deletions(-) create mode 100644 src/commands/repo.ts create mode 100644 src/utils/config.test.ts create mode 100644 src/utils/manifest.test.ts diff --git a/AGENTS.md b/AGENTS.md index f73de63..804723a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,11 +2,7 @@ Global instructions and behavioral constraints live in this file. Project-specific scripts, technology choices, and setup instructions are documented in `README.md`. - - - - new repo ## Core Directives - `.agents/skills/` — What the agents can do (Commands/Capabilities). - `.agents/specs/` — What the agents are building (System & Product Architecture). diff --git a/README.md b/README.md index 2ced94d..57b7a38 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,23 @@ The CLI guides you through an interactive session: 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. By default it auto-detects the repo from your project's git remote and falls back to the bundled default (`mostafamm44/codewiser`). + +```bash +# Show the current repo/branch a project will sync from +codewiser repo my-project + +# Point a project at a different repo/branch (persisted in .codewiser.json) +codewiser repo my-project owner/repo --branch main + +# Reset back to the auto-detected defaults +codewiser repo my-project --reset +``` + +`--repo` and `--branch` flags on the main command act as one-off overrides for a single run; they do not persist. The resolved value is written to `/.codewiser.json`. + ## Supported Agents | Agent | Config File | Integration | @@ -120,12 +137,14 @@ The CLI uses [@clack/prompts](https://github.com/natemoo-re/clack) for interacti ### Architecture -- `src/index.ts` — Entry point, parses CLI arguments, resolves target directory +- `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: view/set/reset the source repo in `.codewiser.json` - `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` read/write, repo/branch resolution, git auto-detection - `src/utils/generate-configs.ts` — Agent config file generation - `src/utils/symlinks.ts` — Symlink creation with admin retry and copy fallback diff --git a/package.json b/package.json index 856631a..b346a66 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "type": "module", "private": true, "scripts": { - "start": "bun src/index.ts" + "start": "bun src/index.ts", + "test": "bun test" }, "devDependencies": { "@types/bun": "latest" diff --git a/src/commands/init.ts b/src/commands/init.ts index 2e5cc6a..bdaafae 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -33,8 +33,8 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: info(`Setting up codewiser in ${targetDir}`); const existingConfig = readConfig(targetDir); - const repo = resolveRepo(cliRepo, existingConfig?.repo); - const branch = resolveBranch(cliBranch, existingConfig?.branch); + const repo = resolveRepo(targetDir, cliRepo, existingConfig?.repo); + const branch = resolveBranch(targetDir, cliBranch, existingConfig?.branch); const RAW_BASE = buildRawBase(repo, branch); info(`Repo: ${repo} (branch: ${branch})`); @@ -103,12 +103,17 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: const manifestUrl = `${RAW_BASE}/codewiser.json`; const result = await runSpinner("Fetching manifest...", async () => { const res = await fetch(manifestUrl); - if (!res.ok) throw new Error(`HTTP ${res.status}: Failed to download manifest`); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${manifestUrl} is not reachable`); const data = (await res.json()) as Record; cachedManifest = data; return data; }); - if (result === BACK) { current = "agents"; break; } + if (result === BACK) { + error(`Could not load codewiser.json from ${RAW_BASE}`); + info("Check the repo/branch, then retry."); + info("To switch repo/branch: codewiser repo [--branch ]"); + return; + } cachedManifest = result; } diff --git a/src/commands/repo.ts b/src/commands/repo.ts new file mode 100644 index 0000000..5b5497a --- /dev/null +++ b/src/commands/repo.ts @@ -0,0 +1,55 @@ +import { mkdirSync } from "fs"; +import { error, info, success } from "../utils/ui"; +import { + readConfig, + writeConfig, + resolveRepo, + resolveBranch, + validateRepoFormat, + getConfigPath, +} from "../utils/config"; + +export interface RepoOptions { + branch?: string; + reset?: boolean; + show?: boolean; +} + +export async function repoCommand(targetDir: string, repoArg?: string, opts: RepoOptions = {}): Promise { + mkdirSync(targetDir, { recursive: true }); + + if (repoArg) { + if (!validateRepoFormat(repoArg)) { + error(`Invalid repo format: "${repoArg}". Expected / (e.g. mostafamm44/codewiser).`); + process.exitCode = 1; + return; + } + const config = readConfig(targetDir) ?? { + repo: resolveRepo(targetDir), + branch: resolveBranch(targetDir), + }; + const branch = opts.branch ?? config.branch; + writeConfig(targetDir, { repo: repoArg, branch, files: config.files }); + success(`repo set to ${repoArg} (branch: ${branch})`); + info(`saved to ${getConfigPath(targetDir)}`); + return; + } + + if (opts.reset) { + const config = readConfig(targetDir); + const repo = resolveRepo(targetDir); + const branch = resolveBranch(targetDir); + writeConfig(targetDir, { repo, branch, files: config?.files }); + success(`repo reset to ${repo} (branch: ${branch})`); + info(`saved to ${getConfigPath(targetDir)}`); + return; + } + + const config = readConfig(targetDir); + const repo = resolveRepo(targetDir, undefined, config?.repo); + const branch = resolveBranch(targetDir, undefined, config?.branch); + info(`repo: ${repo}`); + info(`branch: ${branch}`); + if (config) info(`config: ${getConfigPath(targetDir)}`); + else info("no .codewiser.json yet — will use auto-detected values"); +} diff --git a/src/index.ts b/src/index.ts index b9f13f9..55c9ff2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,19 @@ import meow from "meow"; import { resolve } from "path"; import { init } from "./commands/init"; +import { repoCommand } from "./commands/repo"; const cli = meow( ` Usage $ codewiser + $ codewiser repo [] [--branch ] + $ codewiser repo --reset Options --repo GitHub repository to sync from (default: auto-detect from git remote) --branch Git branch to use (default: auto-detect from current branch) + --reset Reset repo/branch to the auto-detected defaults --help Show this help --version Show version `, @@ -18,18 +22,31 @@ const cli = meow( flags: { repo: { type: "string" }, branch: { type: "string" }, + reset: { type: "boolean", default: false }, }, }, ); -const name = cli.input[0]; +const resolveTarget = (name: string): string => resolve(process.cwd(), "..", name); -if (!name) { - console.error("error: is required"); - console.error(cli.help); - process.exit(1); -} - -const targetDir = resolve(process.cwd(), "..", name); +const [first, second, third] = cli.input; -await init(targetDir, cli.flags.repo, cli.flags.branch); +if (first === "repo") { + if (!second) { + console.error("error: is required"); + console.error(cli.help); + process.exit(1); + } + const repoArg = third && !third.startsWith("-") ? third : undefined; + await repoCommand(resolveTarget(second), repoArg, { + branch: cli.flags.branch, + reset: cli.flags.reset, + }); +} else { + if (!first) { + console.error("error: is required"); + console.error(cli.help); + process.exit(1); + } + await init(resolveTarget(first), cli.flags.repo, cli.flags.branch); +} diff --git a/src/utils/config.test.ts b/src/utils/config.test.ts new file mode 100644 index 0000000..b1c2665 --- /dev/null +++ b/src/utils/config.test.ts @@ -0,0 +1,158 @@ +import { test, expect, describe } from "bun:test"; +import { execSync } from "child_process"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { + resolveRepo, + resolveBranch, + buildRawBase, + validateRepoFormat, + parseGitUrl, + readConfig, + writeConfig, + getConfigPath, + DEFAULT_REPO, + DEFAULT_BRANCH, +} from "./config"; + +function tempDir(): string { + return mkdtempSync(join(tmpdir(), "codewiser-test-")); +} + +describe("parseGitUrl", () => { + test("parses https URLs", () => { + expect(parseGitUrl("https://github.com/mostafamm44/codewiser.git")).toBe("mostafamm44/codewiser"); + expect(parseGitUrl("https://github.com/mostafamm44/codewiser")).toBe("mostafamm44/codewiser"); + }); + + test("parses ssh URLs", () => { + expect(parseGitUrl("git@github.com:yallma3/codewiser.git")).toBe("yallma3/codewiser"); + expect(parseGitUrl("ssh://git@github.com/yallma3/codewiser")).toBe("yallma3/codewiser"); + }); + + test("returns null for non-github or malformed URLs", () => { + expect(parseGitUrl("https://gitlab.com/foo/bar.git")).toBeNull(); + expect(parseGitUrl("not a url")).toBeNull(); + expect(parseGitUrl("https://github.com/single")).toBeNull(); + }); +}); + +describe("validateRepoFormat", () => { + test("accepts owner/repo", () => { + expect(validateRepoFormat("mostafamm44/codewiser")).toBe(true); + expect(validateRepoFormat("org-1/repo.name_v2")).toBe(true); + }); + + test("rejects malformed values", () => { + expect(validateRepoFormat("single")).toBe(false); + expect(validateRepoFormat("a/b/c")).toBe(false); + expect(validateRepoFormat("")).toBe(false); + expect(validateRepoFormat("owner/repo with space")).toBe(false); + }); +}); + +describe("buildRawBase", () => { + test("builds raw.githubusercontent URL", () => { + expect(buildRawBase("foo/bar", "main")).toBe("https://raw.githubusercontent.com/foo/bar/main"); + }); +}); + +describe("resolveRepo", () => { + test("prefers cli flag over config", () => { + const dir = tempDir(); + try { + writeConfig(dir, { repo: "from/config", branch: "b" }); + expect(resolveRepo(dir, "from/cli", "from/config")).toBe("from/cli"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("uses config when no cli flag", () => { + const dir = tempDir(); + try { + writeConfig(dir, { repo: "from/config", branch: "b" }); + expect(resolveRepo(dir, undefined, "from/config")).toBe("from/config"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("falls back to default in a non-git dir", () => { + const dir = tempDir(); + try { + expect(resolveRepo(dir)).toBe(DEFAULT_REPO); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("auto-detects from git remote", () => { + const dir = tempDir(); + try { + execSync("git init", { cwd: dir, stdio: "ignore" }); + execSync("git remote add origin https://github.com/foo/bar.git", { cwd: dir, stdio: "ignore" }); + expect(resolveRepo(dir)).toBe("foo/bar"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("resolveBranch", () => { + test("prefers cli flag over config", () => { + const dir = tempDir(); + try { + writeConfig(dir, { repo: "r", branch: "config-branch" }); + expect(resolveBranch(dir, "cli-branch", "config-branch")).toBe("cli-branch"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("falls back to default in a non-git dir", () => { + const dir = tempDir(); + try { + expect(resolveBranch(dir)).toBe(DEFAULT_BRANCH); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("auto-detects current branch", () => { + const dir = tempDir(); + try { + execSync("git init", { cwd: dir, stdio: "ignore" }); + execSync("git checkout -b feat/x", { cwd: dir, stdio: "ignore" }); + expect(resolveBranch(dir)).toBe("feat/x"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("readConfig/writeConfig", () => { + test("round-trips config", () => { + const dir = tempDir(); + try { + writeConfig(dir, { repo: "foo/bar", branch: "main", files: { "a/SKILL.md": "1.2.3" } }); + expect(getConfigPath(dir).endsWith(".codewiser.json")).toBe(true); + const cfg = readConfig(dir); + expect(cfg?.repo).toBe("foo/bar"); + expect(cfg?.branch).toBe("main"); + expect(cfg?.files?.["a/SKILL.md"]).toBe("1.2.3"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("returns null when missing", () => { + const dir = tempDir(); + try { + expect(readConfig(dir)).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/utils/config.ts b/src/utils/config.ts index 097a7fd..2751eb5 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -8,11 +8,17 @@ export interface CodewiserConfig { files?: Record; } -const DEFAULT_REPO ="mostafamm44/codewiser"; -const DEFAULT_BRANCH = "add/codewiser.json"; +export const DEFAULT_REPO = "mostafamm44/codewiser"; +export const DEFAULT_BRANCH = "add/codewiser.json"; + +export const CONFIG_FILENAME = ".codewiser.json"; + +export function getConfigPath(targetDir: string): string { + return join(targetDir, CONFIG_FILENAME); +} export function readConfig(targetDir: string): CodewiserConfig | null { - const configPath = join(targetDir, ".codewiser.json"); + const configPath = getConfigPath(targetDir); if (!existsSync(configPath)) return null; try { return JSON.parse(readFileSync(configPath, "utf-8")) as CodewiserConfig; @@ -22,59 +28,67 @@ export function readConfig(targetDir: string): CodewiserConfig | null { } export function writeConfig(targetDir: string, config: CodewiserConfig): void { - const configPath = join(targetDir, ".codewiser.json"); + const configPath = getConfigPath(targetDir); writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); } -export function resolveRepo(cliRepo?: string, configRepo?: string): string { +export function resolveRepo(dir: string, cliRepo?: string, configRepo?: string): string { if (cliRepo) return cliRepo; if (configRepo) return configRepo; - const gitRepo = detectGitRemote(); - return gitRepo || DEFAULT_REPO; + return detectGitRemote(dir) || DEFAULT_REPO; } -export function resolveBranch(cliBranch?: string, configBranch?: string): string { +export function resolveBranch(dir: string, cliBranch?: string, configBranch?: string): string { if (cliBranch) return cliBranch; if (configBranch) return configBranch; - const gitBranch = detectGitBranch(); - return gitBranch || DEFAULT_BRANCH; + return detectGitBranch(dir) || DEFAULT_BRANCH; } export function buildRawBase(repo: string, branch: string): string { return `https://raw.githubusercontent.com/${repo}/${branch}`; } -const GIT_URL_PATTERNS = [ - /github\.com[/:](.+?)(?:\.git)?$/, - /git@github\.com:(.+?)(?:\.git)?$/, -]; +export function validateRepoFormat(repo: string): boolean { + return /^[\w.-]+\/[\w.-]+$/.test(repo); +} -function detectGitRemote(): string | null { +export function parseGitUrl(url: string): string | null { + for (const pattern of GIT_URL_PATTERNS) { + const m = url.match(pattern); + if (m && m[1]) return m[1]; + } + return null; +} + +function detectGitRemote(dir: string): string | null { try { const url = execSync("git remote get-url origin", { + cwd: dir, encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"], }).trim(); - for (const pattern of GIT_URL_PATTERNS) { - const m = url.match(pattern); - if (m) return m[1] ?? null; - } - return null; + return parseGitUrl(url); } catch { return null; } } -function detectGitBranch(): string | null { +function detectGitBranch(dir: string): string | null { try { - const branch = execSync("git rev-parse --abbrev-ref HEAD", { + const branch = execSync("git symbolic-ref --short HEAD", { + cwd: dir, encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "ignore"], }).trim(); - return branch === "HEAD" ? null : branch; + return branch || null; } catch { return null; } } + +const GIT_URL_PATTERNS = [ + /github\.com[/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/, + /git@github\.com:([\w.-]+\/[\w.-]+?)(?:\.git)?$/, +]; diff --git a/src/utils/manifest.test.ts b/src/utils/manifest.test.ts new file mode 100644 index 0000000..7a19145 --- /dev/null +++ b/src/utils/manifest.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; +import { + versionLt, + extractVersion, + flattenModeFiles, + flattenWorkflowFiles, + detectManifestFormat, +} from "./manifest"; + +describe("versionLt", () => { + test("compares patch, minor, major", () => { + expect(versionLt("1.0.0", "1.0.1")).toBe(true); + expect(versionLt("1.0.1", "1.0.0")).toBe(false); + expect(versionLt("1.9.9", "2.0.0")).toBe(true); + expect(versionLt("2.0.0", "1.9.9")).toBe(false); + }); + + test("equal versions are not less", () => { + expect(versionLt("1.0.0", "1.0.0")).toBe(false); + }); + + test("handles different segment lengths", () => { + expect(versionLt("1.0", "1.0.0")).toBe(false); + expect(versionLt("1.0.0", "1.0.1")).toBe(true); + expect(versionLt("1.0.0.1", "1.0.0")).toBe(false); + }); +}); + +describe("extractVersion", () => { + test("returns strings as-is", () => { + expect(extractVersion("1.2.3")).toBe("1.2.3"); + }); + + test("reads version from objects", () => { + expect(extractVersion({ version: "2.0.0", source: "https://x" })).toBe("2.0.0"); + }); + + test("defaults to 0.0.0", () => { + expect(extractVersion(undefined)).toBe("0.0.0"); + expect(extractVersion({})).toBe("0.0.0"); + expect(extractVersion(42)).toBe("0.0.0"); + }); +}); + +describe("flattenModeFiles", () => { + test("flattens string and object entries", () => { + const result = flattenModeFiles({ + files: { + "AGENTS.md": "1.0.0", + "a/SKILL.md": { version: "2.0.0", source: "https://x" }, + }, + }); + expect(result["AGENTS.md"]).toBe("1.0.0"); + expect(result["a/SKILL.md"]).toBe("2.0.0"); + }); + + test("returns empty when no files", () => { + expect(flattenModeFiles({})).toEqual({}); + }); +}); + +describe("detectManifestFormat", () => { + test("detects modes", () => { + expect(detectManifestFormat({ modes: { prototype: {} } }).type).toBe("modes"); + }); + + test("detects workflows", () => { + expect(detectManifestFormat({ workflows: { wf: {} } }).type).toBe("workflows"); + }); + + test("detects files", () => { + expect(detectManifestFormat({ files: { "a.md": "1.0.0" } }).type).toBe("files"); + }); + + test("detects unknown", () => { + expect(detectManifestFormat({}).type).toBe("unknown"); + }); +}); + +describe("flattenWorkflowFiles", () => { + test("flattens files from selected workflows only", () => { + const manifest = { + workflows: { + wf1: { stages: { s1: { files: { "a.md": "1.0.0" } } } }, + wf2: { stages: { s1: { files: { "b.md": "2.0.0" } } } }, + }, + }; + expect(flattenWorkflowFiles(manifest, [0])).toEqual({ "a.md": "1.0.0" }); + expect(flattenWorkflowFiles(manifest, [1])).toEqual({ "b.md": "2.0.0" }); + expect(flattenWorkflowFiles(manifest, [0, 1])).toEqual({ "a.md": "1.0.0", "b.md": "2.0.0" }); + }); + + test("skips invalid indices", () => { + const manifest = { workflows: { wf1: { stages: { s1: { files: { "a.md": "1.0.0" } } } } } }; + expect(flattenWorkflowFiles(manifest, [5])).toEqual({}); + }); +}); From 4841000abd101d37d02b11ca322e92621ca5d548 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Sun, 2 Aug 2026 12:32:04 +0300 Subject: [PATCH 09/19] - repo set/reset edit repo/branch in ./codewiser.json --- README.md | 20 ++--- codewiser.json | 4 +- src/commands/init.ts | 25 ++++-- src/commands/repo.ts | 124 ++++++++++++++++++----------- src/index.ts | 35 ++++---- src/utils/config.test.ts | 158 ------------------------------------- src/utils/config.ts | 31 ++++++-- src/utils/manifest.test.ts | 97 ----------------------- 8 files changed, 154 insertions(+), 340 deletions(-) delete mode 100644 src/utils/config.test.ts delete mode 100644 src/utils/manifest.test.ts diff --git a/README.md b/README.md index 57b7a38..d559656 100644 --- a/README.md +++ b/README.md @@ -67,20 +67,22 @@ Use `← Back` options to navigate between steps. Press `Esc` at any time to exi ## Managing the Source Repo -Codewiser syncs skills and specs from a GitHub repo's `codewiser.json` manifest. By default it auto-detects the repo from your project's git remote and falls back to the bundled default (`mostafamm44/codewiser`). +Codewiser syncs skills and specs from a GitHub repo's `codewiser.json` manifest. The manifest declares its own `repo` and `branch`; when present, the CLI uses them for all file downloads. Otherwise it auto-detects the repo from your project's git remote and falls back to the bundled default (`mostafamm44/codewiser`). + +From the root of a project that has a `codewiser.json`: ```bash -# Show the current repo/branch a project will sync from -codewiser repo my-project +# Show the repo/branch the manifest declares (or the defaults) +codewiser repo -# Point a project at a different repo/branch (persisted in .codewiser.json) -codewiser repo my-project owner/repo --branch main +# Point the manifest at a different repo/branch +codewiser repo set owner/repo --branch main -# Reset back to the auto-detected defaults -codewiser repo my-project --reset +# Remove the overrides so the built-in defaults apply +codewiser repo reset ``` -`--repo` and `--branch` flags on the main command act as one-off overrides for a single run; they do not persist. The resolved value is written to `/.codewiser.json`. +`--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) → project's git remote/branch → bundled default. `/.codewiser.json` only tracks downloaded file versions (for re-run comparisons); it never overrides the manifest. ## Supported Agents @@ -139,7 +141,7 @@ The CLI uses [@clack/prompts](https://github.com/natemoo-re/clack) for interacti - `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: view/set/reset the source repo in `.codewiser.json` +- `src/commands/repo.ts` — `repo` subcommand: get/set/reset `repo`/`branch` in the manifest - `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()` diff --git a/codewiser.json b/codewiser.json index 311b113..8df2598 100644 --- a/codewiser.json +++ b/codewiser.json @@ -2,8 +2,6 @@ "$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", - "repo": "mostafamm44/codewiser", - "branch": "add/codewiser.json", "modes": { "prototype": { "description": "Rapid prototyping — BRD, UX design, then code. No formal planning or test requirements.", @@ -146,4 +144,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/commands/init.ts b/src/commands/init.ts index bdaafae..8c67e69 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -20,7 +20,8 @@ import { addExecutionProtocolToAgentsMD, } from "../utils/generate-configs"; import { createAllSymlinks } from "../utils/symlinks"; -import { readConfig, writeConfig, resolveRepo, resolveBranch, buildRawBase } from "../utils/config"; +import { readConfig, writeConfig, 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 { @@ -33,10 +34,12 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: info(`Setting up codewiser in ${targetDir}`); const existingConfig = readConfig(targetDir); - const repo = resolveRepo(targetDir, cliRepo, existingConfig?.repo); - const branch = resolveBranch(targetDir, cliBranch, existingConfig?.branch); - const RAW_BASE = buildRawBase(repo, branch); + const localManifest = readManifest(process.cwd()); + let repo = resolveRepo(targetDir, cliRepo, localManifest?.repo); + let branch = resolveBranch(targetDir, cliBranch, localManifest?.branch); + let RAW_BASE = buildRawBase(repo, branch); info(`Repo: ${repo} (branch: ${branch})`); + info(` from ${describeRepoSource(targetDir, cliRepo, localManifest?.repo)} / ${describeBranchSource(targetDir, cliBranch, localManifest?.branch)}`); let agents: SelectedAgents | null = null; let selectedMode = ""; @@ -110,11 +113,21 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: }); if (result === BACK) { error(`Could not load codewiser.json from ${RAW_BASE}`); + info(`Resolved from ${describeRepoSource(targetDir, cliRepo, localManifest?.repo)} / ${describeBranchSource(targetDir, cliBranch, localManifest?.branch)}`); info("Check the repo/branch, then retry."); - info("To switch repo/branch: codewiser repo [--branch ]"); + info("To switch repo/branch: codewiser repo set --branch "); return; } cachedManifest = result; + + const manifestRepo = typeof cachedManifest.repo === "string" ? cachedManifest.repo : undefined; + const manifestBranch = typeof cachedManifest.branch === "string" ? cachedManifest.branch : undefined; + if (manifestRepo && manifestBranch) { + repo = manifestRepo; + branch = manifestBranch; + RAW_BASE = buildRawBase(repo, branch); + info(`Manifest sources files from ${repo}@${branch}`); + } } const format = detectManifestFormat(cachedManifest); @@ -253,7 +266,7 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: for (const [filePath, ver] of Object.entries(remoteFiles)) { fileVersions[filePath] = ver; } - writeConfig(targetDir, { repo, branch, files: fileVersions }); + writeConfig(targetDir, { files: fileVersions }); stepHeader(5, "Generate Configs"); generateOpenCodeConfig(targetDir, skillDirs, agents?.opencode ?? false); diff --git a/src/commands/repo.ts b/src/commands/repo.ts index 5b5497a..55bf58a 100644 --- a/src/commands/repo.ts +++ b/src/commands/repo.ts @@ -1,55 +1,91 @@ -import { mkdirSync } from "fs"; +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; import { error, info, success } from "../utils/ui"; -import { - readConfig, - writeConfig, - resolveRepo, - resolveBranch, - validateRepoFormat, - getConfigPath, -} from "../utils/config"; - -export interface RepoOptions { +import { validateRepoFormat, DEFAULT_BRANCH, resolveRepo, resolveBranch, describeRepoSource, describeBranchSource } from "../utils/config"; + +export const MANIFEST_FILENAME = "codewiser.json"; + +export interface Manifest { + repo?: string; branch?: string; - reset?: boolean; - show?: boolean; + [key: string]: unknown; } -export async function repoCommand(targetDir: string, repoArg?: string, opts: RepoOptions = {}): Promise { - mkdirSync(targetDir, { recursive: true }); - - if (repoArg) { - if (!validateRepoFormat(repoArg)) { - error(`Invalid repo format: "${repoArg}". Expected / (e.g. mostafamm44/codewiser).`); - process.exitCode = 1; - return; - } - const config = readConfig(targetDir) ?? { - repo: resolveRepo(targetDir), - branch: resolveBranch(targetDir), - }; - const branch = opts.branch ?? config.branch; - writeConfig(targetDir, { repo: repoArg, branch, files: config.files }); - success(`repo set to ${repoArg} (branch: ${branch})`); - info(`saved to ${getConfigPath(targetDir)}`); - return; +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"); +} - if (opts.reset) { - const config = readConfig(targetDir); - const repo = resolveRepo(targetDir); - const branch = resolveBranch(targetDir); - writeConfig(targetDir, { repo, branch, files: config?.files }); - success(`repo reset to ${repo} (branch: ${branch})`); - info(`saved to ${getConfigPath(targetDir)}`); +export function repoGet(dir: string = process.cwd()): void { + 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.`); + process.exitCode = 1; return; } - - const config = readConfig(targetDir); - const repo = resolveRepo(targetDir, undefined, config?.repo); - const branch = resolveBranch(targetDir, undefined, config?.branch); + const repo = resolveRepo(dir, undefined, manifest.repo); + const branch = resolveBranch(dir, undefined, manifest.branch); info(`repo: ${repo}`); info(`branch: ${branch}`); - if (config) info(`config: ${getConfigPath(targetDir)}`); - else info("no .codewiser.json yet — will use auto-detected values"); + info(` from ${describeRepoSource(dir, undefined, manifest.repo)} / ${describeBranchSource(dir, undefined, manifest.branch)}`); + if (!manifest.repo && !manifest.branch) info("(no overrides set; run 'codewiser repo set --branch ' to pin the source)"); +} + +export function repoSet(repo: string, branch?: string, dir: string = process.cwd()): void { + if (!validateRepoFormat(repo)) { + error(`Invalid repo format: "${repo}". Expected / (e.g. mostafamm44/codewiser).`); + process.exitCode = 1; + 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.`); + process.exitCode = 1; + return; + } + const prevRepo = manifest.repo; + manifest.repo = repo; + if (branch) manifest.branch = branch; + writeManifest(dir, manifest); + success(`repo set to ${repo} (branch: ${manifest.branch ?? DEFAULT_BRANCH})`); + info(`updated ${getManifestPath(dir)}`); + if (prevRepo && prevRepo !== repo) info(`was: ${prevRepo}`); +} + +export function repoReset(dir: string = process.cwd()): void { + 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.`); + 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); + const branch = resolveBranch(dir); + if (hadOverride) { + success(`repo overrides removed; will now use ${repo}@${branch}`); + info(` from ${describeRepoSource(dir)} / ${describeBranchSource(dir)}`); + } else { + info("no repo/branch overrides were set"); + } + info(`updated ${getManifestPath(dir)}`); } diff --git a/src/index.ts b/src/index.ts index 55c9ff2..cd4fb2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,19 @@ import meow from "meow"; import { resolve } from "path"; import { init } from "./commands/init"; -import { repoCommand } from "./commands/repo"; +import { repoGet, repoSet, repoReset } from "./commands/repo"; const cli = meow( ` Usage $ codewiser - $ codewiser repo [] [--branch ] - $ codewiser repo --reset + $ codewiser repo Show the repo/branch in ./codewiser.json + $ 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: auto-detect from git remote) --branch Git branch to use (default: auto-detect from current branch) - --reset Reset repo/branch to the auto-detected defaults --help Show this help --version Show version `, @@ -22,31 +22,36 @@ const cli = meow( flags: { repo: { type: "string" }, branch: { type: "string" }, - reset: { type: "boolean", default: false }, }, }, ); -const resolveTarget = (name: string): string => resolve(process.cwd(), "..", name); - const [first, second, third] = cli.input; if (first === "repo") { - if (!second) { - console.error("error: is required"); + const action = second ?? "get"; + if (action === "set") { + if (!third) { + console.error("error: is required (e.g. codewiser repo set mostafamm44/codewiser)"); + console.error(cli.help); + process.exit(1); + } + repoSet(third, cli.flags.branch); + } else if (action === "reset") { + repoReset(); + } 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); } - const repoArg = third && !third.startsWith("-") ? third : undefined; - await repoCommand(resolveTarget(second), repoArg, { - branch: cli.flags.branch, - reset: cli.flags.reset, - }); } else { if (!first) { console.error("error: is required"); console.error(cli.help); process.exit(1); } - await init(resolveTarget(first), cli.flags.repo, cli.flags.branch); + const targetDir = resolve(process.cwd(), "..", first); + await init(targetDir, cli.flags.repo, cli.flags.branch); } diff --git a/src/utils/config.test.ts b/src/utils/config.test.ts deleted file mode 100644 index b1c2665..0000000 --- a/src/utils/config.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { test, expect, describe } from "bun:test"; -import { execSync } from "child_process"; -import { mkdtempSync, rmSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; -import { - resolveRepo, - resolveBranch, - buildRawBase, - validateRepoFormat, - parseGitUrl, - readConfig, - writeConfig, - getConfigPath, - DEFAULT_REPO, - DEFAULT_BRANCH, -} from "./config"; - -function tempDir(): string { - return mkdtempSync(join(tmpdir(), "codewiser-test-")); -} - -describe("parseGitUrl", () => { - test("parses https URLs", () => { - expect(parseGitUrl("https://github.com/mostafamm44/codewiser.git")).toBe("mostafamm44/codewiser"); - expect(parseGitUrl("https://github.com/mostafamm44/codewiser")).toBe("mostafamm44/codewiser"); - }); - - test("parses ssh URLs", () => { - expect(parseGitUrl("git@github.com:yallma3/codewiser.git")).toBe("yallma3/codewiser"); - expect(parseGitUrl("ssh://git@github.com/yallma3/codewiser")).toBe("yallma3/codewiser"); - }); - - test("returns null for non-github or malformed URLs", () => { - expect(parseGitUrl("https://gitlab.com/foo/bar.git")).toBeNull(); - expect(parseGitUrl("not a url")).toBeNull(); - expect(parseGitUrl("https://github.com/single")).toBeNull(); - }); -}); - -describe("validateRepoFormat", () => { - test("accepts owner/repo", () => { - expect(validateRepoFormat("mostafamm44/codewiser")).toBe(true); - expect(validateRepoFormat("org-1/repo.name_v2")).toBe(true); - }); - - test("rejects malformed values", () => { - expect(validateRepoFormat("single")).toBe(false); - expect(validateRepoFormat("a/b/c")).toBe(false); - expect(validateRepoFormat("")).toBe(false); - expect(validateRepoFormat("owner/repo with space")).toBe(false); - }); -}); - -describe("buildRawBase", () => { - test("builds raw.githubusercontent URL", () => { - expect(buildRawBase("foo/bar", "main")).toBe("https://raw.githubusercontent.com/foo/bar/main"); - }); -}); - -describe("resolveRepo", () => { - test("prefers cli flag over config", () => { - const dir = tempDir(); - try { - writeConfig(dir, { repo: "from/config", branch: "b" }); - expect(resolveRepo(dir, "from/cli", "from/config")).toBe("from/cli"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - test("uses config when no cli flag", () => { - const dir = tempDir(); - try { - writeConfig(dir, { repo: "from/config", branch: "b" }); - expect(resolveRepo(dir, undefined, "from/config")).toBe("from/config"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - test("falls back to default in a non-git dir", () => { - const dir = tempDir(); - try { - expect(resolveRepo(dir)).toBe(DEFAULT_REPO); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - test("auto-detects from git remote", () => { - const dir = tempDir(); - try { - execSync("git init", { cwd: dir, stdio: "ignore" }); - execSync("git remote add origin https://github.com/foo/bar.git", { cwd: dir, stdio: "ignore" }); - expect(resolveRepo(dir)).toBe("foo/bar"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); -}); - -describe("resolveBranch", () => { - test("prefers cli flag over config", () => { - const dir = tempDir(); - try { - writeConfig(dir, { repo: "r", branch: "config-branch" }); - expect(resolveBranch(dir, "cli-branch", "config-branch")).toBe("cli-branch"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - test("falls back to default in a non-git dir", () => { - const dir = tempDir(); - try { - expect(resolveBranch(dir)).toBe(DEFAULT_BRANCH); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - test("auto-detects current branch", () => { - const dir = tempDir(); - try { - execSync("git init", { cwd: dir, stdio: "ignore" }); - execSync("git checkout -b feat/x", { cwd: dir, stdio: "ignore" }); - expect(resolveBranch(dir)).toBe("feat/x"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); -}); - -describe("readConfig/writeConfig", () => { - test("round-trips config", () => { - const dir = tempDir(); - try { - writeConfig(dir, { repo: "foo/bar", branch: "main", files: { "a/SKILL.md": "1.2.3" } }); - expect(getConfigPath(dir).endsWith(".codewiser.json")).toBe(true); - const cfg = readConfig(dir); - expect(cfg?.repo).toBe("foo/bar"); - expect(cfg?.branch).toBe("main"); - expect(cfg?.files?.["a/SKILL.md"]).toBe("1.2.3"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - test("returns null when missing", () => { - const dir = tempDir(); - try { - expect(readConfig(dir)).toBeNull(); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/src/utils/config.ts b/src/utils/config.ts index 2751eb5..34a509e 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -3,13 +3,14 @@ import { join } from "path"; import { execSync } from "child_process"; export interface CodewiserConfig { - repo: string; - branch: string; + repo?: string; + branch?: string; files?: Record; } -export const DEFAULT_REPO = "mostafamm44/codewiser"; -export const DEFAULT_BRANCH = "add/codewiser.json"; +export const DEFAULT_REPO = "yallma3/codewiser"; + +export const DEFAULT_BRANCH = "main"; export const CONFIG_FILENAME = ".codewiser.json"; @@ -32,18 +33,32 @@ export function writeConfig(targetDir: string, config: CodewiserConfig): void { writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); } -export function resolveRepo(dir: string, cliRepo?: string, configRepo?: string): string { +export function resolveRepo(dir: string, cliRepo?: string, manifestRepo?: string): string { if (cliRepo) return cliRepo; - if (configRepo) return configRepo; + if (manifestRepo) return manifestRepo; return detectGitRemote(dir) || DEFAULT_REPO; } -export function resolveBranch(dir: string, cliBranch?: string, configBranch?: string): string { +export function resolveBranch(dir: string, cliBranch?: string, manifestBranch?: string): string { if (cliBranch) return cliBranch; - if (configBranch) return configBranch; + if (manifestBranch) return manifestBranch; return detectGitBranch(dir) || DEFAULT_BRANCH; } +export function describeRepoSource(dir: string, cliRepo?: string, manifestRepo?: string): string { + if (cliRepo) return "--repo flag"; + if (manifestRepo) return "./codewiser.json"; + if (detectGitRemote(dir)) return "git remote"; + return "built-in default"; +} + +export function describeBranchSource(dir: string, cliBranch?: string, manifestBranch?: string): string { + if (cliBranch) return "--branch flag"; + if (manifestBranch) return "./codewiser.json"; + if (detectGitBranch(dir)) return "git branch"; + return "built-in default"; +} + export function buildRawBase(repo: string, branch: string): string { return `https://raw.githubusercontent.com/${repo}/${branch}`; } diff --git a/src/utils/manifest.test.ts b/src/utils/manifest.test.ts deleted file mode 100644 index 7a19145..0000000 --- a/src/utils/manifest.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - versionLt, - extractVersion, - flattenModeFiles, - flattenWorkflowFiles, - detectManifestFormat, -} from "./manifest"; - -describe("versionLt", () => { - test("compares patch, minor, major", () => { - expect(versionLt("1.0.0", "1.0.1")).toBe(true); - expect(versionLt("1.0.1", "1.0.0")).toBe(false); - expect(versionLt("1.9.9", "2.0.0")).toBe(true); - expect(versionLt("2.0.0", "1.9.9")).toBe(false); - }); - - test("equal versions are not less", () => { - expect(versionLt("1.0.0", "1.0.0")).toBe(false); - }); - - test("handles different segment lengths", () => { - expect(versionLt("1.0", "1.0.0")).toBe(false); - expect(versionLt("1.0.0", "1.0.1")).toBe(true); - expect(versionLt("1.0.0.1", "1.0.0")).toBe(false); - }); -}); - -describe("extractVersion", () => { - test("returns strings as-is", () => { - expect(extractVersion("1.2.3")).toBe("1.2.3"); - }); - - test("reads version from objects", () => { - expect(extractVersion({ version: "2.0.0", source: "https://x" })).toBe("2.0.0"); - }); - - test("defaults to 0.0.0", () => { - expect(extractVersion(undefined)).toBe("0.0.0"); - expect(extractVersion({})).toBe("0.0.0"); - expect(extractVersion(42)).toBe("0.0.0"); - }); -}); - -describe("flattenModeFiles", () => { - test("flattens string and object entries", () => { - const result = flattenModeFiles({ - files: { - "AGENTS.md": "1.0.0", - "a/SKILL.md": { version: "2.0.0", source: "https://x" }, - }, - }); - expect(result["AGENTS.md"]).toBe("1.0.0"); - expect(result["a/SKILL.md"]).toBe("2.0.0"); - }); - - test("returns empty when no files", () => { - expect(flattenModeFiles({})).toEqual({}); - }); -}); - -describe("detectManifestFormat", () => { - test("detects modes", () => { - expect(detectManifestFormat({ modes: { prototype: {} } }).type).toBe("modes"); - }); - - test("detects workflows", () => { - expect(detectManifestFormat({ workflows: { wf: {} } }).type).toBe("workflows"); - }); - - test("detects files", () => { - expect(detectManifestFormat({ files: { "a.md": "1.0.0" } }).type).toBe("files"); - }); - - test("detects unknown", () => { - expect(detectManifestFormat({}).type).toBe("unknown"); - }); -}); - -describe("flattenWorkflowFiles", () => { - test("flattens files from selected workflows only", () => { - const manifest = { - workflows: { - wf1: { stages: { s1: { files: { "a.md": "1.0.0" } } } }, - wf2: { stages: { s1: { files: { "b.md": "2.0.0" } } } }, - }, - }; - expect(flattenWorkflowFiles(manifest, [0])).toEqual({ "a.md": "1.0.0" }); - expect(flattenWorkflowFiles(manifest, [1])).toEqual({ "b.md": "2.0.0" }); - expect(flattenWorkflowFiles(manifest, [0, 1])).toEqual({ "a.md": "1.0.0", "b.md": "2.0.0" }); - }); - - test("skips invalid indices", () => { - const manifest = { workflows: { wf1: { stages: { s1: { files: { "a.md": "1.0.0" } } } } } }; - expect(flattenWorkflowFiles(manifest, [5])).toEqual({}); - }); -}); From cf60a5ab97a386cd24480a2ca37ff67e8ab28504 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Sun, 2 Aug 2026 12:39:17 +0300 Subject: [PATCH 10/19] replace stale mostafamm44 references with yallma3 --- README.md | 2 +- src/commands/repo.ts | 2 +- src/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d559656..56bab39 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Use `← Back` options to navigate between steps. Press `Esc` at any time to exi ## Managing the Source Repo -Codewiser syncs skills and specs from a GitHub repo's `codewiser.json` manifest. The manifest declares its own `repo` and `branch`; when present, the CLI uses them for all file downloads. Otherwise it auto-detects the repo from your project's git remote and falls back to the bundled default (`mostafamm44/codewiser`). +Codewiser syncs skills and specs from a GitHub repo's `codewiser.json` manifest. The manifest declares its own `repo` and `branch`; when present, the CLI uses them for all file downloads. Otherwise it auto-detects the repo from your project's git remote and falls back to the bundled default (`yallma3/codewiser`). From the root of a project that has a `codewiser.json`: diff --git a/src/commands/repo.ts b/src/commands/repo.ts index 55bf58a..e86d09a 100644 --- a/src/commands/repo.ts +++ b/src/commands/repo.ts @@ -47,7 +47,7 @@ export function repoGet(dir: string = process.cwd()): void { export function repoSet(repo: string, branch?: string, dir: string = process.cwd()): void { if (!validateRepoFormat(repo)) { - error(`Invalid repo format: "${repo}". Expected / (e.g. mostafamm44/codewiser).`); + error(`Invalid repo format: "${repo}". Expected / (e.g. yallma3/codewiser).`); process.exitCode = 1; return; } diff --git a/src/index.ts b/src/index.ts index cd4fb2b..2d49f96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,7 +32,7 @@ if (first === "repo") { const action = second ?? "get"; if (action === "set") { if (!third) { - console.error("error: is required (e.g. codewiser repo set mostafamm44/codewiser)"); + console.error("error: is required (e.g. codewiser repo set yallma3/codewiser)"); console.error(cli.help); process.exit(1); } From d4811d5bd033865ebea9c491fac0248e6840f429 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Sun, 2 Aug 2026 13:17:33 +0300 Subject: [PATCH 11/19] made the branch is mandatory choice --- src/commands/repo.ts | 19 ++++++++++++++----- src/index.ts | 2 +- src/utils/prompts.ts | 23 ++++++++++++++++++++++- src/utils/ui.ts | 12 +++++++++++- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/commands/repo.ts b/src/commands/repo.ts index e86d09a..d7b3f1b 100644 --- a/src/commands/repo.ts +++ b/src/commands/repo.ts @@ -1,7 +1,8 @@ import { existsSync, readFileSync, writeFileSync } from "fs"; import { join } from "path"; -import { error, info, success } from "../utils/ui"; -import { validateRepoFormat, DEFAULT_BRANCH, resolveRepo, resolveBranch, describeRepoSource, describeBranchSource } from "../utils/config"; +import { error, info, success, EXIT } from "../utils/ui"; +import { validateRepoFormat, resolveRepo, resolveBranch, describeRepoSource, describeBranchSource } from "../utils/config"; +import { selectBranch } from "../utils/prompts"; export const MANIFEST_FILENAME = "codewiser.json"; @@ -45,7 +46,7 @@ export function repoGet(dir: string = process.cwd()): void { if (!manifest.repo && !manifest.branch) info("(no overrides set; run 'codewiser repo set --branch ' to pin the source)"); } -export function repoSet(repo: string, branch?: string, dir: string = process.cwd()): void { +export async function repoSet(repo: string, branch?: string, dir: string = process.cwd()): Promise { if (!validateRepoFormat(repo)) { error(`Invalid repo format: "${repo}". Expected / (e.g. yallma3/codewiser).`); process.exitCode = 1; @@ -58,11 +59,19 @@ export function repoSet(repo: string, branch?: string, dir: string = process.cwd 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; - if (branch) manifest.branch = branch; + manifest.branch = branch; writeManifest(dir, manifest); - success(`repo set to ${repo} (branch: ${manifest.branch ?? DEFAULT_BRANCH})`); + success(`repo set to ${repo} (branch: ${branch})`); info(`updated ${getManifestPath(dir)}`); if (prevRepo && prevRepo !== repo) info(`was: ${prevRepo}`); } diff --git a/src/index.ts b/src/index.ts index 2d49f96..9cfb459 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,7 +36,7 @@ if (first === "repo") { console.error(cli.help); process.exit(1); } - repoSet(third, cli.flags.branch); + await repoSet(third, cli.flags.branch); } else if (action === "reset") { repoReset(); } else if (action === "get") { diff --git a/src/utils/prompts.ts b/src/utils/prompts.ts index fd46cfb..55e3b03 100644 --- a/src/utils/prompts.ts +++ b/src/utils/prompts.ts @@ -1,4 +1,4 @@ -import { pick, pickMany, confirmPrompt, BACK, EXIT } from "./ui"; +import { pick, pickMany, confirmPrompt, textPrompt, BACK, EXIT } from "./ui"; export interface SelectedAgents { opencode: boolean; @@ -75,3 +75,24 @@ export async function confirmOverwrite( ): 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/ui.ts b/src/utils/ui.ts index 509ba9c..2be3bcc 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -1,4 +1,4 @@ -import { select, multiselect, confirm, isCancel, spinner } from "@clack/prompts"; +import { select, multiselect, confirm, isCancel, spinner, text } from "@clack/prompts"; import pc from "picocolors"; export const BACK = "back"; @@ -93,6 +93,16 @@ export async function confirmPrompt(message: 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); From c2af05f4d191cee17918d11860c8efec67829676 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Sun, 2 Aug 2026 13:39:09 +0300 Subject: [PATCH 12/19] 00 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 804723a..ad04c4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Shared AI Agent Instructions -Global instructions and behavioral constraints live in this file. +test Global instructions and behavioral constraints live in this file. Project-specific scripts, technology choices, and setup instructions are documented in `README.md`. ## Core Directives From 8205e058d88ded891c742490080519ca0955d338 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Sun, 2 Aug 2026 14:24:07 +0300 Subject: [PATCH 13/19] fix --- src/commands/init.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 8c67e69..bc67c31 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -35,11 +35,11 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: const existingConfig = readConfig(targetDir); const localManifest = readManifest(process.cwd()); - let repo = resolveRepo(targetDir, cliRepo, localManifest?.repo); - let branch = resolveBranch(targetDir, cliBranch, localManifest?.branch); + let repo = resolveRepo(process.cwd(), cliRepo, localManifest?.repo); + let branch = resolveBranch(process.cwd(), cliBranch, localManifest?.branch); let RAW_BASE = buildRawBase(repo, branch); info(`Repo: ${repo} (branch: ${branch})`); - info(` from ${describeRepoSource(targetDir, cliRepo, localManifest?.repo)} / ${describeBranchSource(targetDir, cliBranch, localManifest?.branch)}`); + info(` from ${describeRepoSource(process.cwd(), cliRepo, localManifest?.repo)} / ${describeBranchSource(process.cwd(), cliBranch, localManifest?.branch)}`); let agents: SelectedAgents | null = null; let selectedMode = ""; @@ -113,7 +113,7 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: }); if (result === BACK) { error(`Could not load codewiser.json from ${RAW_BASE}`); - info(`Resolved from ${describeRepoSource(targetDir, cliRepo, localManifest?.repo)} / ${describeBranchSource(targetDir, cliBranch, localManifest?.branch)}`); + info(`Resolved from ${describeRepoSource(process.cwd(), cliRepo, localManifest?.repo)} / ${describeBranchSource(process.cwd(), cliBranch, localManifest?.branch)}`); info("Check the repo/branch, then retry."); info("To switch repo/branch: codewiser repo set --branch "); return; From 7ba823dd424894b9ea0c3927854b1d77560e39c7 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Sun, 2 Aug 2026 17:14:33 +0300 Subject: [PATCH 14/19] fix --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ad04c4e..804723a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Shared AI Agent Instructions -test Global instructions and behavioral constraints live in this file. +Global instructions and behavioral constraints live in this file. Project-specific scripts, technology choices, and setup instructions are documented in `README.md`. ## Core Directives From f1ff66d10ef6a657fbbade26978cb6283a8a7ddf Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Sun, 2 Aug 2026 17:33:06 +0300 Subject: [PATCH 15/19] fix coderabbitai reviews --- README.md | 2 +- package.json | 2 +- src/commands/init.ts | 29 ++++++++++++--------------- src/utils/symlinks.ts | 46 +++++++++++++++++++++++-------------------- src/utils/ui.ts | 1 - 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 56bab39..7999540 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Example: the [git-worktrees skill](.agents/skills/shared/git-worktrees/SKILL.md) ## Requirements -- **Bun** (recommended) or **Node.js 18+** +- **Bun** (recommended) or **Node.js 20.12+** - Git ## Development diff --git a/package.json b/package.json index b346a66..4a09005 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "codewiser": "src/index.ts" }, "type": "module", - "private": true, + "version": "0.1.2", "scripts": { "start": "bun src/index.ts", "test": "bun test" diff --git a/src/commands/init.ts b/src/commands/init.ts index bc67c31..354fcb0 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -105,11 +105,9 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: if (!cachedManifest) { const manifestUrl = `${RAW_BASE}/codewiser.json`; const result = await runSpinner("Fetching manifest...", async () => { - const res = await fetch(manifestUrl); + const res = await fetch(manifestUrl, { signal: AbortSignal.timeout(10000) }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${manifestUrl} is not reachable`); - const data = (await res.json()) as Record; - cachedManifest = data; - return data; + return (await res.json()) as Record; }); if (result === BACK) { error(`Could not load codewiser.json from ${RAW_BASE}`); @@ -217,15 +215,15 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: // Phase 2: Execute (all file operations) info("Creating directories..."); - mkdirSync(`${targetDir}\\.agents\\skills`, { recursive: true }); - mkdirSync(`${targetDir}\\.agents\\specs`, { recursive: true }); - mkdirSync(`${targetDir}\\.agents\\plans`, { recursive: true }); - mkdirSync(`${targetDir}\\.agents\\research`, { recursive: true }); + 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(`${targetDir}\\.claude`, { recursive: true }); - if (agents?.cursor) mkdirSync(`${targetDir}\\.cursor`, { recursive: true }); - if (agents?.antigravity) mkdirSync(`${targetDir}\\.antigravity`, { recursive: true }); - if (agents?.kilo) mkdirSync(`${targetDir}\\.kilo`, { 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"); @@ -233,8 +231,7 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: let downloaded = 0; for (const [filePath, remoteVer] of Object.entries(remoteFiles)) { - const localPath = filePath.replace(/\//g, "\\"); - const dest = `${targetDir}\\${localPath}`; + const dest = join(targetDir, ...filePath.split("/")); const url = `${RAW_BASE}/${filePath}`; if (!existsSync(dest)) { @@ -279,10 +276,10 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: stepHeader(6, "Create Symlinks"); const symlinkConfigs: Array<{ relativeSrc: string; relativeDest: string; label: string }> = []; if (agents?.claude) { - symlinkConfigs.push({ relativeSrc: ".claude\\skills", relativeDest: ".agents\\skills", label: "Claude Code" }); + symlinkConfigs.push({ relativeSrc: join(".claude", "skills"), relativeDest: join(".agents", "skills"), label: "Claude Code" }); } if (agents?.cursor) { - symlinkConfigs.push({ relativeSrc: ".cursor\\skills", relativeDest: ".agents\\skills", label: "Cursor" }); + symlinkConfigs.push({ relativeSrc: join(".cursor", "skills"), relativeDest: join(".agents", "skills"), label: "Cursor" }); } if (symlinkConfigs.length > 0) { diff --git a/src/utils/symlinks.ts b/src/utils/symlinks.ts index aeaf10d..c373896 100644 --- a/src/utils/symlinks.ts +++ b/src/utils/symlinks.ts @@ -1,7 +1,8 @@ -import { existsSync, lstatSync, symlinkSync, unlinkSync, rmSync, mkdirSync, readdirSync } from "fs"; +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; @@ -25,10 +26,23 @@ async function handleSymlink(targetDir: string, cfg: SymlinkConfig): Promise(label: string, fn: () => Promise): Promis const result = await fn(); return result; } catch (e) { - s.stop(label); error(String(e)); return BACK; } finally { From 93d5a3411129424ebdebcaa80dbac83cc6abd902 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Sun, 2 Aug 2026 17:46:46 +0300 Subject: [PATCH 16/19] fix: prevent command injection in elevated symlink creation Encode the inner PowerShell command with paths embedded as single-quoted literals and pass it via -EncodedCommand with no user data on the command line. The previous version interpolated srcPath/destPath into the outer script, which could break on spaces/quotes and allowed injection. --- src/utils/symlinks.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/utils/symlinks.ts b/src/utils/symlinks.ts index c373896..eb45b04 100644 --- a/src/utils/symlinks.ts +++ b/src/utils/symlinks.ts @@ -61,8 +61,11 @@ async function handleSymlink(targetDir: string, cfg: SymlinkConfig): Promise "'" + 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()) { From b53401d76284dd591d3b25957fad0fcd46ea8bd1 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Sun, 2 Aug 2026 20:43:25 +0300 Subject: [PATCH 17/19] empty commit --- src/utils/download.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/download.ts b/src/utils/download.ts index fcae467..028b7fe 100644 --- a/src/utils/download.ts +++ b/src/utils/download.ts @@ -6,6 +6,7 @@ export async function download(url: string, dest: string): Promise { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } + try { const res = await fetch(url); if (!res.ok) return false; From 26bcd8b66dd2410635485fcf8a50036ddab0e115 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Sun, 9 Aug 2026 19:15:37 +0300 Subject: [PATCH 18/19] add -g config --- README.md | 14 ++++--- codewiser.json | 4 +- src/commands/init.ts | 11 +++--- src/commands/repo.ts | 85 ++++++++++++++++++++++++++++++++--------- src/index.ts | 12 +++--- src/utils/config.ts | 91 +++++++++++++++++--------------------------- 6 files changed, 127 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 7999540..dfcb717 100644 --- a/README.md +++ b/README.md @@ -67,12 +67,12 @@ Use `← Back` options to navigate between steps. Press `Esc` at any time to exi ## Managing the Source Repo -Codewiser syncs skills and specs from a GitHub repo's `codewiser.json` manifest. The manifest declares its own `repo` and `branch`; when present, the CLI uses them for all file downloads. Otherwise it auto-detects the repo from your project's git remote and falls back to the bundled default (`yallma3/codewiser`). +Codewiser syncs skills and specs from a GitHub repo's `codewiser.json` manifest. The manifest declares its own `repo` and `branch`; when present, the CLI uses them for all file downloads. Otherwise it falls back to your user-profile `~/.codewiser.json` (if set) and finally to the bundled default (`yallma3/codewiser`). From the root of a project that has a `codewiser.json`: ```bash -# Show the repo/branch the manifest declares (or the defaults) +# Show the effective repo/branch and which source each value comes from codewiser repo # Point the manifest at a different repo/branch @@ -80,9 +80,13 @@ 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) → project's git remote/branch → bundled default. `/.codewiser.json` only tracks downloaded file versions (for re-run comparisons); it never overrides the manifest. +`--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 @@ -141,12 +145,12 @@ The CLI uses [@clack/prompts](https://github.com/natemoo-re/clack) for interacti - `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 +- `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` read/write, repo/branch resolution, git auto-detection +- `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 diff --git a/codewiser.json b/codewiser.json index 8df2598..02ddace 100644 --- a/codewiser.json +++ b/codewiser.json @@ -143,5 +143,7 @@ ".agents/specs/spec-index.json": "1.0.0" } } - } + }, + "repo": "proejctsetuprepo/codewiser", + "branch": "main" } \ No newline at end of file diff --git a/src/commands/init.ts b/src/commands/init.ts index 354fcb0..7aa04d0 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -20,7 +20,7 @@ import { addExecutionProtocolToAgentsMD, } from "../utils/generate-configs"; import { createAllSymlinks } from "../utils/symlinks"; -import { readConfig, writeConfig, resolveRepo, resolveBranch, buildRawBase, describeRepoSource, describeBranchSource } from "../utils/config"; +import { readConfig, writeConfig, readGlobalConfig, resolveRepo, resolveBranch, buildRawBase, describeRepoSource, describeBranchSource } from "../utils/config"; import { readManifest } from "./repo"; import type { SelectedAgents } from "../utils/prompts"; @@ -35,11 +35,12 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: const existingConfig = readConfig(targetDir); const localManifest = readManifest(process.cwd()); - let repo = resolveRepo(process.cwd(), cliRepo, localManifest?.repo); - let branch = resolveBranch(process.cwd(), cliBranch, localManifest?.branch); + const globalConfig = readGlobalConfig(); + let repo = resolveRepo(process.cwd(), cliRepo, localManifest?.repo, globalConfig?.repo); + let branch = resolveBranch(process.cwd(), cliBranch, localManifest?.branch, globalConfig?.branch); let RAW_BASE = buildRawBase(repo, branch); info(`Repo: ${repo} (branch: ${branch})`); - info(` from ${describeRepoSource(process.cwd(), cliRepo, localManifest?.repo)} / ${describeBranchSource(process.cwd(), cliBranch, localManifest?.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 = ""; @@ -111,7 +112,7 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: }); if (result === BACK) { error(`Could not load codewiser.json from ${RAW_BASE}`); - info(`Resolved from ${describeRepoSource(process.cwd(), cliRepo, localManifest?.repo)} / ${describeBranchSource(process.cwd(), cliBranch, localManifest?.branch)}`); + 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; diff --git a/src/commands/repo.ts b/src/commands/repo.ts index d7b3f1b..e87121d 100644 --- a/src/commands/repo.ts +++ b/src/commands/repo.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from "fs"; import { join } from "path"; import { error, info, success, EXIT } from "../utils/ui"; -import { validateRepoFormat, resolveRepo, resolveBranch, describeRepoSource, describeBranchSource } from "../utils/config"; +import { validateRepoFormat, resolveRepo, resolveBranch, describeRepoSource, describeBranchSource, readGlobalConfig, writeGlobalConfig, getGlobalConfigPath } from "../utils/config"; import { selectBranch } from "../utils/prompts"; export const MANIFEST_FILENAME = "codewiser.json"; @@ -32,30 +32,63 @@ export function writeManifest(dir: string, manifest: Manifest): void { export function repoGet(dir: string = process.cwd()): void { 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.`); - process.exitCode = 1; - return; - } - const repo = resolveRepo(dir, undefined, manifest.repo); - const branch = resolveBranch(dir, undefined, manifest.branch); + 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)} / ${describeBranchSource(dir, undefined, manifest.branch)}`); - if (!manifest.repo && !manifest.branch) info("(no overrides set; run 'codewiser repo set --branch ' to pin the source)"); + 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()): Promise { +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.`); + 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; } @@ -76,11 +109,27 @@ export async function repoSet(repo: string, branch?: string, dir: string = proce if (prevRepo && prevRepo !== repo) info(`was: ${prevRepo}`); } -export function repoReset(dir: string = process.cwd()): void { +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.`); + 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; } @@ -88,11 +137,11 @@ export function repoReset(dir: string = process.cwd()): void { delete manifest.repo; delete manifest.branch; writeManifest(dir, manifest); - const repo = resolveRepo(dir); - const branch = resolveBranch(dir); + 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)} / ${describeBranchSource(dir)}`); + info(` from ${describeRepoSource(dir, undefined, undefined, readGlobalConfig()?.repo)} / ${describeBranchSource(dir, undefined, undefined, readGlobalConfig()?.branch)}`); } else { info("no repo/branch overrides were set"); } diff --git a/src/index.ts b/src/index.ts index 9cfb459..2f9baef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,13 +7,14 @@ const cli = meow( ` Usage $ codewiser - $ codewiser repo Show the repo/branch in ./codewiser.json + $ 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: auto-detect from git remote) - --branch Git branch to use (default: auto-detect from current branch) + --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 `, @@ -22,6 +23,7 @@ const cli = meow( flags: { repo: { type: "string" }, branch: { type: "string" }, + global: { type: "boolean", shortFlag: "g" }, }, }, ); @@ -36,9 +38,9 @@ if (first === "repo") { console.error(cli.help); process.exit(1); } - await repoSet(third, cli.flags.branch); + await repoSet(third, cli.flags.branch, process.cwd(), cli.flags.global); } else if (action === "reset") { - repoReset(); + repoReset(process.cwd(), cli.flags.global); } else if (action === "get") { repoGet(); } else { diff --git a/src/utils/config.ts b/src/utils/config.ts index 34a509e..770fd9a 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, writeFileSync } from "fs"; +import { homedir } from "os"; import { join } from "path"; -import { execSync } from "child_process"; export interface CodewiserConfig { repo?: string; @@ -18,44 +18,64 @@ export function getConfigPath(targetDir: string): string { return join(targetDir, CONFIG_FILENAME); } -export function readConfig(targetDir: string): CodewiserConfig | null { - const configPath = getConfigPath(targetDir); - if (!existsSync(configPath)) return null; +export function getGlobalConfigPath(): string { + return join(homedir(), CONFIG_FILENAME); +} + +function readConfigFromPath(path: string): CodewiserConfig | null { + if (!existsSync(path)) return null; try { - return JSON.parse(readFileSync(configPath, "utf-8")) as CodewiserConfig; + return JSON.parse(readFileSync(path, "utf-8")) as CodewiserConfig; } 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 { - const configPath = getConfigPath(targetDir); - writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8"); + writeConfigToPath(getConfigPath(targetDir), config); +} + +export function readGlobalConfig(): CodewiserConfig | null { + return readConfigFromPath(getGlobalConfigPath()); } -export function resolveRepo(dir: string, cliRepo?: string, manifestRepo?: string): string { +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; - return detectGitRemote(dir) || DEFAULT_REPO; + if (globalRepo) return globalRepo; + return DEFAULT_REPO; } -export function resolveBranch(dir: string, cliBranch?: string, manifestBranch?: string): string { +export function resolveBranch(_dir: string, cliBranch?: string, manifestBranch?: string, globalBranch?: string): string { if (cliBranch) return cliBranch; if (manifestBranch) return manifestBranch; - return detectGitBranch(dir) || DEFAULT_BRANCH; + if (globalBranch) return globalBranch; + return DEFAULT_BRANCH; } -export function describeRepoSource(dir: string, cliRepo?: string, manifestRepo?: string): string { +export function describeRepoSource(_dir: string, cliRepo?: string, manifestRepo?: string, globalRepo?: string): string { if (cliRepo) return "--repo flag"; if (manifestRepo) return "./codewiser.json"; - if (detectGitRemote(dir)) return "git remote"; + if (globalRepo) return "user profile (~/.codewiser.json)"; return "built-in default"; } -export function describeBranchSource(dir: string, cliBranch?: string, manifestBranch?: string): string { +export function describeBranchSource(_dir: string, cliBranch?: string, manifestBranch?: string, globalBranch?: string): string { if (cliBranch) return "--branch flag"; if (manifestBranch) return "./codewiser.json"; - if (detectGitBranch(dir)) return "git branch"; + if (globalBranch) return "user profile (~/.codewiser.json)"; return "built-in default"; } @@ -66,44 +86,3 @@ export function buildRawBase(repo: string, branch: string): string { export function validateRepoFormat(repo: string): boolean { return /^[\w.-]+\/[\w.-]+$/.test(repo); } - -export function parseGitUrl(url: string): string | null { - for (const pattern of GIT_URL_PATTERNS) { - const m = url.match(pattern); - if (m && m[1]) return m[1]; - } - return null; -} - -function detectGitRemote(dir: string): string | null { - try { - const url = execSync("git remote get-url origin", { - cwd: dir, - encoding: "utf-8", - timeout: 3000, - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - return parseGitUrl(url); - } catch { - return null; - } -} - -function detectGitBranch(dir: string): string | null { - try { - const branch = execSync("git symbolic-ref --short HEAD", { - cwd: dir, - encoding: "utf-8", - timeout: 3000, - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - return branch || null; - } catch { - return null; - } -} - -const GIT_URL_PATTERNS = [ - /github\.com[/:]([\w.-]+\/[\w.-]+?)(?:\.git)?$/, - /git@github\.com:([\w.-]+\/[\w.-]+?)(?:\.git)?$/, -]; From a1244ef6be58672904e51e9464b1820bb74170ac Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Sun, 9 Aug 2026 19:56:20 +0300 Subject: [PATCH 19/19] fixes --- README.md | 2 +- codewiser.json | 4 ++-- src/commands/init.ts | 15 +++------------ src/utils/config.ts | 19 ++++++++++++++++++- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index dfcb717..801f955 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Use `← Back` options to navigate between steps. Press `Esc` at any time to exi ## Managing the Source Repo -Codewiser syncs skills and specs from a GitHub repo's `codewiser.json` manifest. The manifest declares its own `repo` and `branch`; when present, the CLI uses them for all file downloads. Otherwise it falls back to your user-profile `~/.codewiser.json` (if set) and finally to the bundled default (`yallma3/codewiser`). +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`: diff --git a/codewiser.json b/codewiser.json index 02ddace..b2ccd60 100644 --- a/codewiser.json +++ b/codewiser.json @@ -144,6 +144,6 @@ } } }, - "repo": "proejctsetuprepo/codewiser", - "branch": "main" + "repo": "mostafamm44/codewiser", + "branch": "sync/codewiser.json" } \ No newline at end of file diff --git a/src/commands/init.ts b/src/commands/init.ts index 7aa04d0..fd0ad04 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -36,9 +36,9 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: const existingConfig = readConfig(targetDir); const localManifest = readManifest(process.cwd()); const globalConfig = readGlobalConfig(); - let repo = resolveRepo(process.cwd(), cliRepo, localManifest?.repo, globalConfig?.repo); - let branch = resolveBranch(process.cwd(), cliBranch, localManifest?.branch, globalConfig?.branch); - let RAW_BASE = buildRawBase(repo, branch); + 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)}`); @@ -118,15 +118,6 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: return; } cachedManifest = result; - - const manifestRepo = typeof cachedManifest.repo === "string" ? cachedManifest.repo : undefined; - const manifestBranch = typeof cachedManifest.branch === "string" ? cachedManifest.branch : undefined; - if (manifestRepo && manifestBranch) { - repo = manifestRepo; - branch = manifestBranch; - RAW_BASE = buildRawBase(repo, branch); - info(`Manifest sources files from ${repo}@${branch}`); - } } const format = detectManifestFormat(cachedManifest); diff --git a/src/utils/config.ts b/src/utils/config.ts index 770fd9a..ef3bbc3 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -22,10 +22,27 @@ 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 JSON.parse(readFileSync(path, "utf-8")) as CodewiserConfig; + return parseConfig(JSON.parse(readFileSync(path, "utf-8"))); } catch { return null; }