From 9c410cee409ef412b5d226c4150c0cf4d7714556 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Mon, 27 Jul 2026 13:38:33 +0300 Subject: [PATCH 01/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] - 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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; } From fe424cdb1d533c17dde463941d215aa772322f4b Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Thu, 13 Aug 2026 02:57:01 +0300 Subject: [PATCH 20/30] new sync method for skills --- README.md | 34 ++++- src/commands/init.ts | 91 +++++++---- src/commands/publish.ts | 329 ++++++++++++++++++++++++++++++++++++++++ src/commands/pull.ts | 85 +++++++++++ src/commands/sync.ts | 11 ++ src/index.ts | 12 ++ src/utils/config.ts | 90 +++++++++-- src/utils/hash.ts | 22 +++ src/utils/manifest.ts | 30 ++++ src/utils/prompts.ts | 30 ++++ src/utils/remote.ts | 65 ++++++++ src/utils/sync-files.ts | 132 ++++++++++++++++ src/utils/ui.ts | 4 +- 13 files changed, 887 insertions(+), 48 deletions(-) create mode 100644 src/commands/publish.ts create mode 100644 src/commands/pull.ts create mode 100644 src/commands/sync.ts create mode 100644 src/utils/hash.ts create mode 100644 src/utils/remote.ts create mode 100644 src/utils/sync-files.ts diff --git a/README.md b/README.md index 801f955..669e42f 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,31 @@ codewiser repo set owner/repo --branch main -g codewiser repo reset -g # clear the user-wide default ``` -`--repo` and `--branch` flags on the main command act as one-off overrides for a single run; they do not persist. Repo/branch resolution order is: CLI flag → `./codewiser.json` in the current directory (what `codewiser repo set` edits) → `~/.codewiser.json` in your user profile (what `codewiser repo set -g` edits) → bundled default (`yallma3/codewiser` @ `main`). `/.codewiser.json` only tracks downloaded file versions (for re-run comparisons); it never overrides the manifest. +`--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`). A synced project's `./codewiser.json` (the merged manifest) always has `repo`/`branch`, so a project can be pointed at its own fork or a team fork with `codewiser repo set`. + +## Team Skill Sync + +The core goal of codewiser is letting the whole team share and evolve the same skills. Every synced project keeps a **merged `codewiser.json`** that lists each installed file with its **version and a SHA-256 content hash**. Two-way sync turns that ledger into a collaboration loop: + +```bash +# Pull — fetch skills other members added or bumped upstream +codewiser pull + +# Publish — open a pull request with YOUR locally edited skills +codewiser publish + +# Both, in one interactive pass +codewiser sync +``` + +- **`codewiser pull`** compares the project's tracked versions/hashes against the upstream `codewiser.json`. It lists new files from the team (prompts to install), files with newer versions upstream (prompts to update), and warns about files you edited locally that also have newer upstream versions so you never silently lose your work. +- **`codewiser publish`** detects locally edited skills by recomputing content hashes and comparing them to the ledger. It: + + 1. Checks upstream first — if another member already released a newer version of a skill you edited, it tells you and lets you **update to the latest or keep your version** before proceeding. + 2. Lets you pick which modified skills to publish and **type a new version for each one** (that's what teammates see on their next `codewiser pull`). + 3. Opens a pull request to the source repo. It uses the [GitHub CLI](https://cli.github.com) (`gh repo clone`, `git push`, `gh pr create`), falling back to a fork when you don't have write access. It updates the skill versions inside `codewiser.json` as part of the PR. + + After a successful PR your local ledger is updated so the same edits aren't re-detected. ## Supported Agents @@ -146,11 +170,17 @@ 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 or user-profile (`-g`) +- `src/commands/pull.ts` — `pull` subcommand: sync new/updated skills from the team +- `src/commands/publish.ts` — `publish` subcommand: open a PR with locally edited skills (via gh CLI) +- `src/commands/sync.ts` — `sync` subcommand: runs pull then publish - `src/utils/ui.ts` — Prompt wrappers with stdin resilience (@clack wrappers) - `src/utils/prompts.ts` — Typed prompt functions for agent/mode/workflow selection - `src/utils/download.ts` — HTTP download via `fetch()` + `Bun.write()` - `src/utils/manifest.ts` — Manifest parsing, version comparison, file flattening -- `src/utils/config.ts` — `.codewiser.json` / `~/.codewiser.json` read/write, repo/branch resolution +- `src/utils/hash.ts` — SHA-256 content hashing for local-edit detection +- `src/utils/sync-files.ts` — Shared download/compare engine (versions + hashes) used by init and pull +- `src/utils/remote.ts` — Remote manifest fetch and flattening +- `src/utils/config.ts` — Merged project `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/src/commands/init.ts b/src/commands/init.ts index fd0ad04..b6c768b 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -4,7 +4,6 @@ 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, @@ -20,7 +19,8 @@ import { addExecutionProtocolToAgentsMD, } from "../utils/generate-configs"; import { createAllSymlinks } from "../utils/symlinks"; -import { readConfig, writeConfig, readGlobalConfig, resolveRepo, resolveBranch, buildRawBase, describeRepoSource, describeBranchSource } from "../utils/config"; +import { readConfig, writeConfig, readGlobalConfig, resolveRepo, resolveBranch, buildRawBase, describeRepoSource, describeBranchSource, normalizeFileVersions } from "../utils/config"; +import { syncFiles, type SyncOutcome } from "../utils/sync-files"; import { readManifest } from "./repo"; import type { SelectedAgents } from "../utils/prompts"; @@ -221,41 +221,66 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: stepHeader(4, "Download Files"); - let downloaded = 0; - for (const [filePath, remoteVer] of Object.entries(remoteFiles)) { - const dest = join(targetDir, ...filePath.split("/")); - const url = `${RAW_BASE}/${filePath}`; - - if (!existsSync(dest)) { - const ok = await download(url, dest); - if (ok) { downloaded++; fileStatus(filePath, "new"); } - else warn(`Failed: ${filePath}`); - } else if (filePath.endsWith("/SKILL.md")) { - const localVer = existingConfig?.files?.[filePath] ?? "0.0.0"; - if (versionLt(localVer, remoteVer)) { - const overwrite = await confirmOverwrite(filePath, localVer, remoteVer); - if (overwrite === EXIT) return; - if (overwrite) { - const ok = await download(url, dest); - if (ok) { downloaded++; fileStatus(filePath, "updated"); } - else warn(`Failed: ${filePath}`); - } - } else { - fileStatus(filePath, "current"); - } - } else { - fileStatus(filePath, "current"); - } + const abort = new Error("init aborted"); + const localFiles = normalizeFileVersions(existingConfig?.files); + + let outcome: SyncOutcome; + try { + outcome = await syncFiles({ + targetDir, + rawBase: RAW_BASE, + remoteFiles, + localFiles, + callbacks: { + async onNew(paths) { return paths; }, + async onUpdate(paths) { + const accepted: string[] = []; + for (const path of paths) { + if (!path.endsWith("/SKILL.md")) continue; + const remoteVer = remoteFiles[path] ?? "0.0.0"; + const localVer = localFiles[path]?.version ?? "0.0.0"; + if (!versionLt(localVer, remoteVer)) continue; + const overwrite = await confirmOverwrite(path, localVer, remoteVer); + if (overwrite === EXIT) throw abort; + if (overwrite) accepted.push(path); + } + return accepted; + }, + async onUpdateDirty() { return []; }, + }, + }); + } catch (e) { + if (e === abort) return; + throw e; } - if (downloaded > 0) success(`${downloaded} file(s) downloaded`); - else info("All files up to date"); + for (const p of outcome.downloadedNew) fileStatus(p, "new"); + for (const p of outcome.downloadedUpdates) fileStatus(p, "updated"); + for (const p of outcome.keptDirty) warn(`Modified locally, kept: ${p}`); + for (const p of outcome.conflicts) warn(`Modified locally with a newer version upstream: ${p}`); + for (const p of outcome.upToDate) fileStatus(p, "current"); - const fileVersions: Record = {}; - for (const [filePath, ver] of Object.entries(remoteFiles)) { - fileVersions[filePath] = ver; + if (outcome.downloadedNew.length + outcome.downloadedUpdates.length > 0) { + success(`${outcome.downloadedNew.length + outcome.downloadedUpdates.length} file(s) downloaded`); + } else { + info("All files up to date"); } - writeConfig(targetDir, { files: fileVersions }); + + writeConfig(targetDir, { + repo, + branch, + mode: selectedMode || undefined, + agents: agents + ? { + opencode: agents.opencode, + claude: agents.claude, + cursor: agents.cursor, + antigravity: agents.antigravity, + kilo: agents.kilo, + } + : undefined, + files: outcome.files, + }); stepHeader(5, "Generate Configs"); generateOpenCodeConfig(targetDir, skillDirs, agents?.opencode ?? false); diff --git a/src/commands/publish.ts b/src/commands/publish.ts new file mode 100644 index 0000000..b4bd484 --- /dev/null +++ b/src/commands/publish.ts @@ -0,0 +1,329 @@ +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { execFileSync } from "child_process"; +import { readConfig, writeConfig, readGlobalConfig, resolveRepo, resolveBranch, buildRawBase, normalizeFileVersions } from "../utils/config"; +import { info, warn, error, success, confirmPrompt, EXIT, BACK } from "../utils/ui"; +import { selectPublishFiles, enterNewVersion, chooseUpdateOrKeep } from "../utils/prompts"; +import { download } from "../utils/download"; +import { versionLt } from "../utils/manifest"; +import { sha256File } from "../utils/hash"; +import { fetchManifest, flattenRemoteManifest } from "../utils/remote"; + +function filePath(dir: string, rel: string): string { + return join(dir, ...rel.split("/")); +} + +function runCmd(cmd: string, args: string[], cwd?: string): { ok: boolean; out: string; err: string } { + try { + const out = execFileSync(cmd, args, { + cwd, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 60000, + }); + return { ok: true, out: String(out).trim(), err: "" }; + } catch (e) { + const err = e as { stdout?: unknown; stderr?: unknown; message?: string }; + return { + ok: false, + out: String(err.stdout ?? "").trim(), + err: String(err.stderr ?? err.message ?? "").trim(), + }; + } +} + +function hasGh(): boolean { + return runCmd("gh", ["--version"]).ok; +} + +export async function publish(dir: string = process.cwd()): Promise { + const config = readConfig(dir); + + if (!config || !config.files) { + error("No synced codewiser project found here."); + info("Run 'codewiser init ' first, or cd into a directory that contains a codewiser.json manifest."); + process.exitCode = 1; + return; + } + + const globalConfig = readGlobalConfig(); + const repo = resolveRepo(dir, undefined, config.repo, globalConfig?.repo); + const branch = resolveBranch(dir, undefined, config.branch, globalConfig?.branch); + const RAW_BASE = buildRawBase(repo, branch); + info(`Publishing against ${repo}@${branch}`); + + const localFiles = normalizeFileVersions(config.files); + + // 1. Detect locally modified skills by comparing content hashes. + const dirty = new Set(); + for (const [path, entry] of Object.entries(localFiles)) { + if (!path.startsWith(".agents/skills/")) continue; + const dest = filePath(dir, path); + if (!existsSync(dest)) continue; + const hash = sha256File(dest); + if (!entry.sha256) { + // Legacy entry without a tracked hash: adopt the current hash as baseline. + entry.sha256 = hash ?? undefined; + continue; + } + if (hash !== null && hash !== entry.sha256) dirty.add(path); + } + + // 2. Check the remote for newer versions of the edited skills. Let the member + // update first (rebasing on the team's latest) or keep their own. + const remote = await fetchManifest(RAW_BASE); + if (remote) { + const remoteFiles = flattenRemoteManifest(remote, config.mode); + for (const path of [...dirty]) { + const remoteVer = remoteFiles[path]; + if (!remoteVer) continue; + const localVer = localFiles[path]?.version ?? "0.0.0"; + if (!versionLt(localVer, remoteVer)) continue; + const choice = await chooseUpdateOrKeep(path, localVer, remoteVer); + if (choice === EXIT || choice === BACK) { + process.exitCode = 1; + return; + } + if (choice === "update") { + const ok = await download(`${RAW_BASE}/${path}`, filePath(dir, path)); + if (!ok) { + warn(`Failed to update ${path}; continuing with the local version.`); + continue; + } + info(`Updated ${path} to ${remoteVer}`); + localFiles[path] = { version: remoteVer, sha256: sha256File(filePath(dir, path)) ?? undefined }; + dirty.delete(path); + } else { + info(`Keeping local version of ${path}`); + } + } + } else { + warn("Could not reach the upstream manifest; cannot check for newer versions."); + } + + if (dirty.size === 0) { + info("No locally modified skills to publish. Run 'codewiser pull' instead to fetch team updates."); + return; + } + + const dirtyPaths = [...dirty].sort(); + const selected = await selectPublishFiles(dirtyPaths); + if (selected === EXIT || selected === BACK) { + process.exitCode = 1; + return; + } + if (selected.length === 0) { + info("Nothing to publish."); + return; + } + + const versions = new Map(); + for (const path of selected) { + const current = localFiles[path]?.version ?? "0.0.0"; + const next = await enterNewVersion(path, current); + if (next === EXIT) { + process.exitCode = 1; + return; + } + versions.set(path, next); + } + + const ok = await confirmPrompt(`Create a pull request to ${repo} with ${selected.length} modified skill(s)?`); + if (ok === EXIT || !ok) { + info("Aborted — no pull request created."); + return; + } + + if (!hasGh()) { + error("GitHub CLI is required to open a pull request."); + info("Install it from https://cli.github.com and run 'gh auth login' first."); + process.exitCode = 1; + return; + } + + // 3. Open a PR against the source repo via gh. + const defaultBranch = + runCmd("gh", ["repo", "view", repo, "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"]).out || "main"; + const login = runCmd("gh", ["api", "user", "--jq", ".login"]).out; + if (!login) { + error("Could not identify your GitHub login. Run 'gh auth login' first."); + process.exitCode = 1; + return; + } + + const tmp = mkdtempSync(join(tmpdir(), "codewiser-publish-")); + try { + const clone = runCmd("gh", ["repo", "clone", repo, tmp, "--", "--depth", "1"]); + if (!clone.ok) { + error(`Failed to clone ${repo}: ${clone.err}`); + process.exitCode = 1; + return; + } + + const branchName = `codewiser/skills-${Date.now()}`; + const checkout = runCmd("git", ["checkout", "-b", branchName], tmp); + if (!checkout.ok) { + error(`Failed to create branch: ${checkout.err}`); + process.exitCode = 1; + return; + } + + for (const path of selected) { + const src = filePath(dir, path); + const dest = filePath(tmp, path); + if (existsSync(src)) { + cpSync(src, dest, { force: true }); + info(`Staged ${path}`); + } + } + + const manifestPath = filePath(tmp, "codewiser.json"); + if (existsSync(manifestPath)) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as Record; + bumpManifestVersions(manifest, selected, versions); + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8"); + info("Updated versions in codewiser.json"); + } else { + warn("No codewiser.json manifest in the source repo — skill versions were not bumped."); + } + + const title = buildTitle(selected, versions); + const body = selected.map((p) => `- \`${p}\` ${localFiles[p]?.version ?? "0.0.0"} -> ${versions.get(p)}`).join("\n"); + + const gitUser = ["-c", "user.name=codewiser", "-c", `user.email=${login}@users.noreply.github.com`]; + const commit = runCmd("git", [...gitUser, "commit", "-m", title], tmp); + if (!commit.ok) { + error(`Commit failed: ${commit.err}`); + process.exitCode = 1; + return; + } + + let head = branchName; + let push = runCmd("git", ["push", "-u", "origin", "HEAD"], tmp); + if (!push.ok) { + warn(`Direct push failed (${push.err}); trying a fork...`); + const fork = runCmd("gh", ["repo", "fork", repo, "--remote"]); + if (!fork.ok) { + error(`Fork failed: ${fork.err}`); + error(`Check write access to ${repo} and your gh auth.`); + process.exitCode = 1; + return; + } + const remotes = runCmd("git", ["remote"], tmp); + const forkRemote = remotes.out + .split(/\s+/) + .filter(Boolean) + .find((r) => r !== "origin"); + if (!forkRemote) { + error("Created a fork but could not find its git remote."); + process.exitCode = 1; + return; + } + push = runCmd("git", ["push", "-u", forkRemote, "HEAD"], tmp); + if (!push.ok) { + error(`Push to fork failed: ${push.err}`); + process.exitCode = 1; + return; + } + head = `${login}:${branchName}`; + } + + const pr = runCmd("gh", [ + "pr", "create", + "--repo", repo, + "--base", defaultBranch, + "--head", head, + "--title", title, + "--body", body, + ]); + if (!pr.ok) { + error(`Could not open pull request: ${pr.err}`); + process.exitCode = 1; + return; + } + + success(`Pull request created: ${pr.out}`); + success("Someone on the team can review and merge it. Other members will then see 'codewiser pull' offer the update."); + + // 4. Record the published versions/hashes so this work isn't re-detected. + for (const path of selected) { + const version = versions.get(path); + if (!version) continue; + localFiles[path] = { + version, + sha256: sha256File(filePath(dir, path)) ?? undefined, + }; + } + writeConfig(dir, { ...config, repo, branch, files: localFiles }); + } finally { + try { + rmSync(tmp, { recursive: true, force: true }); + } catch { + // best effort cleanup + } + } +} + +function bumpManifestVersions( + manifest: Record, + paths: string[], + versions: Map, +): void { + const wanted = new Set(paths); + let found = 0; + + const bumpFiles = (files: unknown): void => { + if (!files || typeof files !== "object" || Array.isArray(files)) return; + const rec = files as Record; + for (const [key, value] of Object.entries(rec)) { + if (!wanted.has(key)) continue; + const next = versions.get(key); + if (!next) continue; + if (typeof value === "string") { + rec[key] = next; + } else if (value && typeof value === "object") { + (value as Record).version = next; + } else { + rec[key] = next; + } + found++; + } + }; + + const modes = manifest.modes; + if (modes && typeof modes === "object" && !Array.isArray(modes)) { + for (const mode of Object.values(modes as Record)) { + if (mode && typeof mode === "object" && "files" in mode) { + bumpFiles((mode as { files?: unknown }).files); + } + } + } + if ("files" in manifest) bumpFiles(manifest.files); + + if (found > 0) return; + + // The paths are not present upstream (e.g. brand-new skills): add them to + // every mode so teammates see the update. + if (modes && typeof modes === "object" && !Array.isArray(modes)) { + for (const mode of Object.values(modes as Record }>)) { + if (!mode || typeof mode !== "object") continue; + const files = mode.files; + if (files && typeof files === "object" && !Array.isArray(files)) { + const rec = files as Record; + for (const path of paths) { + const next = versions.get(path); + if (next) rec[path] = next; + } + } + } + } +} + +function buildTitle(paths: string[], versions: Map): string { + const parts = paths.map((p) => { + const name = p.split("/").slice(-2, -1)[0] ?? p; + return `${name} v${versions.get(p)}`; + }); + return `skills: ${parts.join(", ")}`; +} \ No newline at end of file diff --git a/src/commands/pull.ts b/src/commands/pull.ts new file mode 100644 index 0000000..ce0a106 --- /dev/null +++ b/src/commands/pull.ts @@ -0,0 +1,85 @@ +import { readConfig, writeConfig, readGlobalConfig, resolveRepo, resolveBranch, buildRawBase, normalizeFileVersions } from "../utils/config"; +import { info, warn, error, success, fileStatus, runSpinner, confirmPrompt, EXIT } from "../utils/ui"; +import { syncFiles, type SyncOutcome } from "../utils/sync-files"; +import { fetchManifest, flattenRemoteManifest } from "../utils/remote"; + +export async function pull(dir: string = process.cwd()): Promise { + const config = readConfig(dir); + + if (!config || !config.files) { + error("No synced codewiser project found here."); + info("Run 'codewiser init ' first, or cd into a directory that contains a codewiser.json manifest."); + process.exitCode = 1; + return; + } + + const globalConfig = readGlobalConfig(); + const repo = resolveRepo(dir, undefined, config.repo, globalConfig?.repo); + const branch = resolveBranch(dir, undefined, config.branch, globalConfig?.branch); + const RAW_BASE = buildRawBase(repo, branch); + info(`Syncing from ${repo}@${branch}`); + + const remote = await runSpinner("Checking for skill updates...", async () => { + const m = await fetchManifest(RAW_BASE); + if (!m) throw new Error(`Could not load codewiser.json from ${RAW_BASE}`); + return m; + }); + if (remote === "back") return; + + const remoteFiles = flattenRemoteManifest(remote, config.mode); + const localFiles = normalizeFileVersions(config.files); + + const outcome: SyncOutcome = await syncFiles({ + targetDir: dir, + rawBase: RAW_BASE, + remoteFiles, + localFiles, + callbacks: { + async onNew(paths) { + if (paths.length === 0) return []; + info(`New files available from the team:`); + for (const p of paths) info(` ${p}`); + const ok = await confirmPrompt(`Install ${paths.length} new file(s)?`); + if (ok === EXIT || !ok) return []; + return paths; + }, + async onUpdate(paths) { + if (paths.length === 0) return []; + info(`Updated versions available from the team:`); + for (const p of paths) { + const localVer = localFiles[p]?.version ?? "0.0.0"; + const remoteVer = remoteFiles[p] ?? "0.0.0"; + info(` ${p} (${localVer} -> ${remoteVer})`); + } + const ok = await confirmPrompt(`Update ${paths.length} file(s) to the latest versions?`); + if (ok === EXIT || !ok) return []; + return paths; + }, + async onUpdateDirty(paths) { + if (paths.length === 0) return []; + warn(`You have local edits on files with newer upstream versions:`); + for (const p of paths) warn(` ${p}`); + const ok = await confirmPrompt("Publish your edits first (recommended) or overwrite them with the upstream versions?", false); + if (ok === true) return paths; + return []; + }, + }, + }); + + let changed = 0; + for (const p of outcome.downloadedNew) { fileStatus(p, "new"); changed++; } + for (const p of outcome.downloadedUpdates) { fileStatus(p, "updated"); changed++; } + for (const p of outcome.keptDirty) warn(`Local edits kept (not published): ${p}`); + for (const p of outcome.conflicts) warn(`Local edits kept over newer upstream version: ${p}`); + for (const p of outcome.upToDate) fileStatus(p, "current"); + + if (changed > 0) success(`${changed} file(s) updated`); + else info("Everything is up to date."); + + writeConfig(dir, { + ...config, + repo, + branch, + files: outcome.files, + }); +} \ No newline at end of file diff --git a/src/commands/sync.ts b/src/commands/sync.ts new file mode 100644 index 0000000..3ce1656 --- /dev/null +++ b/src/commands/sync.ts @@ -0,0 +1,11 @@ +import { pull } from "./pull"; +import { publish } from "./publish"; +import { stepHeader } from "../utils/ui"; + +export async function sync(dir: string = process.cwd()): Promise { + stepHeader(1, "Sync From Team (pull)"); + await pull(dir); + + stepHeader(2, "Publish Local Changes (pull request)"); + await publish(dir); +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 2f9baef..68df4d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,9 @@ import meow from "meow"; import { resolve } from "path"; import { init } from "./commands/init"; import { repoGet, repoSet, repoReset } from "./commands/repo"; +import { pull } from "./commands/pull"; +import { publish } from "./commands/publish"; +import { sync } from "./commands/sync"; const cli = meow( ` @@ -10,6 +13,9 @@ const cli = meow( $ 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 + $ codewiser pull Sync new/updated skills from the team (run inside a synced project) + $ codewiser publish Open a pull request with your locally edited skills (run inside a synced project) + $ codewiser sync Run pull and publish in one go Options --repo GitHub repository to sync from (default: project, then user profile, then built-in) @@ -48,6 +54,12 @@ if (first === "repo") { console.error(cli.help); process.exit(1); } +} else if (first === "pull") { + await pull(process.cwd()); +} else if (first === "publish") { + await publish(process.cwd()); +} else if (first === "sync") { + await sync(process.cwd()); } else { if (!first) { console.error("error: is required"); diff --git a/src/utils/config.ts b/src/utils/config.ts index ef3bbc3..65307d2 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,40 +1,83 @@ -import { existsSync, readFileSync, writeFileSync } from "fs"; +import { existsSync, readFileSync, writeFileSync, rmSync } from "fs"; import { homedir } from "os"; import { join } from "path"; +export interface FileVersion { + version: string; + sha256?: string; +} + +export type FileRecord = Record; + export interface CodewiserConfig { repo?: string; branch?: string; - files?: Record; + mode?: string; + agents?: Record; + files?: FileRecord; } export const DEFAULT_REPO = "yallma3/codewiser"; export const DEFAULT_BRANCH = "main"; +// The merged version manifest written into a synced project. Doubles as the +// project's own `codewiser.json` (repo/branch/mode/agents + per-file versions and hashes). +export const PROJECT_MANIFEST_FILENAME = "codewiser.json"; + +// The user-profile config (~/.codewiser.json) holding machine-wide repo/branch defaults. export const CONFIG_FILENAME = ".codewiser.json"; -export function getConfigPath(targetDir: string): string { - return join(targetDir, CONFIG_FILENAME); +// Legacy per-project ledger (hidden dotfile) that predates the merged manifest. +export const LEGACY_CONFIG_FILENAME = ".codewiser.json"; + +export function getProjectManifestPath(targetDir: string): string { + return join(targetDir, PROJECT_MANIFEST_FILENAME); +} + +export function getLegacyConfigPath(targetDir: string): string { + return join(targetDir, LEGACY_CONFIG_FILENAME); } export function getGlobalConfigPath(): string { return join(homedir(), CONFIG_FILENAME); } +function parseFileVersion(value: unknown): string | FileVersion | null { + if (typeof value === "string") return { version: value }; + if (value && typeof value === "object" && !Array.isArray(value)) { + const candidate = value as Record; + const entry: FileVersion = { version: "0.0.0" }; + if (typeof candidate.version === "string") entry.version = candidate.version; + if (typeof candidate.sha256 === "string") entry.sha256 = candidate.sha256; + return entry; + } + return null; +} + 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; + if (typeof candidate.mode === "string") config.mode = candidate.mode; + const agents = candidate.agents; + if (typeof agents === "object" && agents !== null && !Array.isArray(agents)) { + const agentFlags: Record = {}; + for (const [name, enabled] of Object.entries(agents as Record)) { + if (typeof enabled === "boolean") agentFlags[name] = enabled; + } + config.agents = agentFlags; + } 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; + const fileRecord: FileRecord = {}; + for (const [name, value] of Object.entries(files as Record)) { + const parsed = parseFileVersion(value); + if (parsed) fileRecord[name] = parsed; } - config.files = fileVersions; + config.files = fileRecord; } return config; } @@ -52,12 +95,24 @@ function writeConfigToPath(path: string, config: CodewiserConfig): void { writeFileSync(path, JSON.stringify(config, null, 2), "utf-8"); } +// Project manifest: prefer `./codewiser.json` (merged), fall back to the legacy +// `./.codewiser.json` dotfile so pre-sync projects keep working. export function readConfig(targetDir: string): CodewiserConfig | null { - return readConfigFromPath(getConfigPath(targetDir)); + return readConfigFromPath(getProjectManifestPath(targetDir)) ?? readConfigFromPath(getLegacyConfigPath(targetDir)); } +// Writes the merged `./codewiser.json` and removes a legacy dotfile if present. export function writeConfig(targetDir: string, config: CodewiserConfig): void { - writeConfigToPath(getConfigPath(targetDir), config); + const path = getProjectManifestPath(targetDir); + writeConfigToPath(path, config); + const legacy = getLegacyConfigPath(targetDir); + if (legacy !== path && existsSync(legacy)) { + try { + rmSync(legacy, { force: true }); + } catch { + // non-fatal: legacy dotfile left in place + } + } } export function readGlobalConfig(): CodewiserConfig | null { @@ -68,6 +123,19 @@ export function writeGlobalConfig(config: CodewiserConfig): void { writeConfigToPath(getGlobalConfigPath(), config); } +export function normalizeFileVersions(files?: FileRecord): Record { + const result: Record = {}; + if (!files) return result; + for (const [path, value] of Object.entries(files)) { + if (typeof value === "string") { + result[path] = { version: value }; + } else { + result[path] = { version: value?.version ?? "0.0.0", sha256: value.sha256 }; + } + } + return result; +} + export function resolveRepo(_dir: string, cliRepo?: string, manifestRepo?: string, globalRepo?: string): string { if (cliRepo) return cliRepo; if (manifestRepo) return manifestRepo; @@ -102,4 +170,4 @@ export function buildRawBase(repo: string, branch: string): string { export function validateRepoFormat(repo: string): boolean { return /^[\w.-]+\/[\w.-]+$/.test(repo); -} +} \ No newline at end of file diff --git a/src/utils/hash.ts b/src/utils/hash.ts new file mode 100644 index 0000000..b14bbb0 --- /dev/null +++ b/src/utils/hash.ts @@ -0,0 +1,22 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "fs"; + +export function sha256Bytes(data: Uint8Array | Buffer): string | null { + try { + return createHash("sha256").update(data as Uint8Array).digest("hex"); + } catch { + return null; + } +} + +export function sha256File(filePath: string): string | null { + try { + return sha256Bytes(readFileSync(filePath)); + } catch { + return null; + } +} + +export function sha256Text(text: string): string { + return createHash("sha256").update(text, "utf-8").digest("hex"); +} \ No newline at end of file diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts index a11b957..66d3c70 100644 --- a/src/utils/manifest.ts +++ b/src/utils/manifest.ts @@ -70,3 +70,33 @@ export function detectManifestFormat(obj: Record): ManifestForm if (obj.files) return { type: "files", files: obj.files as Record }; return { type: "unknown" }; } + +// Flatten the file/version map of a single named mode. +export function flattenModeByName(raw: Record, modeName: string): Record { + const modes = raw.modes; + if (modes && typeof modes === "object") { + const mode = (modes as Record)[modeName]; + if (mode) return flattenModeFiles(mode); + } + return {}; +} + +// Union of all mode file/version maps. When versions repeat across modes the +// highest one wins, so a pull against the full manifest surfaces every artifact. +export function flattenAllModeFiles(raw: Record): Record { + const result: Record = {}; + const modes = raw.modes; + if (modes && typeof modes === "object") { + for (const entry of Object.values(modes as Record)) { + if (!entry || typeof entry !== "object") continue; + const files = (entry as ModeEntry).files; + if (!files) continue; + for (const [path, val] of Object.entries(files)) { + const v = extractVersion(val); + const prev = result[path]; + if (!prev || versionLt(prev, v)) result[path] = v; + } + } + } + return result; +} diff --git a/src/utils/prompts.ts b/src/utils/prompts.ts index 55e3b03..f8e5943 100644 --- a/src/utils/prompts.ts +++ b/src/utils/prompts.ts @@ -96,3 +96,33 @@ export async function selectBranch(): Promise { if (ok === EXIT || !ok) return EXIT; return branch; } + +export async function selectPublishFiles(paths: string[]): Promise { + const result = await pickMany( + "Which modified skills do you want to publish?", + paths.map((p) => ({ value: p, label: p })), + { required: false }, + ); + if (result === EXIT) return EXIT; + return result; +} + +export async function enterNewVersion(path: string, current: string): Promise { + const next = await textPrompt({ + message: `New version for ${path}:`, + placeholder: current, + initialValue: current, + }); + if (next === EXIT) return EXIT; + const trimmed = next.trim(); + if (!trimmed) return EXIT; + return trimmed; +} + +export async function chooseUpdateOrKeep(path: string, local: string, remote: string): Promise<"update" | "keep" | typeof BACK | typeof EXIT> { + const choice = await pick(`"${path}" has a newer version upstream (${local} -> ${remote})`, [ + { value: "update", label: "Update to latest first" }, + { value: "keep", label: "Keep my local version" }, + ]); + return choice; +} diff --git a/src/utils/remote.ts b/src/utils/remote.ts new file mode 100644 index 0000000..ade1fcf --- /dev/null +++ b/src/utils/remote.ts @@ -0,0 +1,65 @@ +export const MANIFEST_TIMEOUT_MS = 10000; + +export async function fetchManifest(rawBase: string): Promise | null> { + const url = `${rawBase}/codewiser.json`; + try { + const res = await fetch(url, { signal: AbortSignal.timeout(MANIFEST_TIMEOUT_MS) }); + if (!res.ok) return null; + return (await res.json()) as Record; + } catch { + return null; + } +} + +export function flattenRemoteManifest(raw: Record, modeName?: string): Record { + const result: Record = {}; + + const collect = (files: Record): void => { + for (const [path, val] of Object.entries(files)) { + const version = typeof val === "string" ? val : (val && typeof val === "object" && "version" in (val as Record)) + ? String((val as Record).version) + : "0.0.0"; + const prev = result[path]; + if (!prev || versionGt(version, prev)) result[path] = version; + } + }; + + const modes = raw.modes; + if (modes && typeof modes === "object") { + const modeEntries = modes as Record }>; + if (modeName && modeEntries[modeName]) { + collect(modeEntries[modeName]?.files ?? {}); + } else { + for (const entry of Object.values(modeEntries)) collect(entry.files ?? {}); + } + if (Object.keys(result).length > 0) return result; + } + + const workflows = raw.workflows; + if (workflows && typeof workflows === "object") { + for (const wf of Object.values(workflows as Record }> }>)) { + for (const stage of Object.values(wf.stages ?? {})) collect(stage.files ?? {}); + } + if (Object.keys(result).length > 0) return result; + } + + const files = raw.files; + if (files && typeof files === "object") { + collect(files as Record); + } + + return result; +} + +function versionGt(a: string, b: string): boolean { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i++) { + const x = pa[i] ?? 0; + const y = pb[i] ?? 0; + if (x > y) return true; + if (x < y) return false; + } + return false; +} \ No newline at end of file diff --git a/src/utils/sync-files.ts b/src/utils/sync-files.ts new file mode 100644 index 0000000..4f00b48 --- /dev/null +++ b/src/utils/sync-files.ts @@ -0,0 +1,132 @@ +import { existsSync } from "fs"; +import { join } from "path"; +import { download } from "./download"; +import { sha256File } from "./hash"; +import { versionLt } from "./manifest"; +import type { FileVersion } from "./config"; + +export type FilesMap = Record; + +// Each callback receives the candidate paths and returns the subset it wants applied. +// This keeps per-file prompting in the caller while all state lives here. +export interface SyncCallbacks { + onNew: (paths: string[]) => Promise; + onUpdate: (paths: string[]) => Promise; + onUpdateDirty: (paths: string[]) => Promise; +} + +export interface SyncOptions { + targetDir: string; + rawBase: string; + remoteFiles: Record; + localFiles?: FilesMap; + callbacks: SyncCallbacks; +} + +export interface SyncOutcome { + files: FilesMap; + downloadedNew: string[]; + downloadedUpdates: string[]; + keptDirty: string[]; + conflicts: string[]; + upToDate: string[]; +} + +export async function syncFiles(opts: SyncOptions): Promise { + const { targetDir, rawBase, remoteFiles, localFiles = {}, callbacks } = opts; + + const newCandidates: string[] = []; + const updateCandidates: string[] = []; + const dirtyUpdateCandidates: string[] = []; + const keptDirtyCandidates: string[] = []; + const upToDateCandidates: string[] = []; + + for (const [path, remoteVer] of Object.entries(remoteFiles)) { + const dest = join(targetDir, ...path.split("/")); + if (!existsSync(dest)) { + newCandidates.push(path); + continue; + } + + const stored = localFiles[path]; + const localVer = stored?.version ?? "0.0.0"; + const storedHash = stored?.sha256; + const currentHash = sha256File(dest); + + let dirty = false; + if (storedHash) { + dirty = currentHash !== null && currentHash !== storedHash; + } else { + // Legacy entry without a tracked hash: adopt the current hash as baseline. + if (stored) stored.sha256 = currentHash ?? undefined; + dirty = false; + } + + if (dirty) { + if (versionLt(localVer, remoteVer)) dirtyUpdateCandidates.push(path); + else keptDirtyCandidates.push(path); + } else if (versionLt(localVer, remoteVer)) { + updateCandidates.push(path); + } else { + upToDateCandidates.push(path); + } + } + + const applyNew = await callbacks.onNew([...newCandidates]); + const applyUpdate = await callbacks.onUpdate([...updateCandidates]); + const applyDirty = await callbacks.onUpdateDirty([...dirtyUpdateCandidates]); + + const downloadedNew = new Set(); + const downloadedUpdates = new Set(); + + const syncBatch = async (paths: string[], target: Set): Promise => { + for (const path of paths) { + const dest = join(targetDir, ...path.split("/")); + const ok = await download(`${rawBase}/${path}`, dest); + if (ok) target.add(path); + } + }; + + await syncBatch(applyNew, downloadedNew); + await syncBatch([...applyUpdate, ...applyDirty], downloadedUpdates); + + const files: FilesMap = { ...localFiles }; + for (const [path, remoteVer] of Object.entries(remoteFiles)) { + const dest = join(targetDir, ...path.split("/")); + if (downloadedNew.has(path) || downloadedUpdates.has(path)) { + files[path] = { version: remoteVer, sha256: sha256File(dest) ?? undefined }; + continue; + } + if (keptDirtyCandidates.includes(path)) { + const stored = localFiles[path]; + if (stored?.sha256) { + files[path] = { version: stored.version, sha256: stored.sha256 }; + } else { + files[path] = { version: stored?.version ?? remoteVer, sha256: sha256File(dest) ?? undefined }; + } + continue; + } + if (newCandidates.includes(path)) { + // Declined new file: leave it untracked. + continue; + } + if (!existsSync(dest)) continue; + const stored = localFiles[path]; + files[path] = { + version: stored?.version ?? "0.0.0", + sha256: stored?.sha256 ?? sha256File(dest) ?? undefined, + }; + } + + const keptDirty = [...keptDirtyCandidates]; + const conflicts = [...dirtyUpdateCandidates]; + + return { + files, + downloadedNew: [...downloadedNew], + downloadedUpdates: [...downloadedUpdates], + keptDirty, + conflicts, + upToDate: upToDateCandidates, + }; +} \ No newline at end of file diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 3068bd3..bcdc2ca 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -84,10 +84,10 @@ export async function pickMany( return result as T[]; } -export async function confirmPrompt(message: string): Promise { +export async function confirmPrompt(message: string, initialValue?: boolean): Promise { const result = await confirm({ message, - initialValue: true, + initialValue: initialValue ?? true, }); if (isCancel(result)) return EXIT; return result as boolean; From 5a4cbdbd63952fefbbd6cd339e9590e4c7887522 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Thu, 13 Aug 2026 03:11:55 +0300 Subject: [PATCH 21/30] testing the update script --- .agents/skills/analyze/SKILL.md | 2 +- src/commands/publish.ts | 31 ++++++++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.agents/skills/analyze/SKILL.md b/.agents/skills/analyze/SKILL.md index 5c9f93b..62788b6 100644 --- a/.agents/skills/analyze/SKILL.md +++ b/.agents/skills/analyze/SKILL.md @@ -8,7 +8,7 @@ license: MIT ## Purpose -Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. +-test-Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. ### Unscoped Invocation diff --git a/src/commands/publish.ts b/src/commands/publish.ts index b4bd484..7839cee 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -152,9 +152,24 @@ export async function publish(dir: string = process.cwd()): Promise { return; } + // The PR targets the sync branch this project pulls from (so merged updates + // reach teammates on their next `codewiser pull`), falling back to the repo's + // default branch when the sync branch isn't on the remote. + const syncBranchOnRemote = runCmd( + "gh", + ["api", `repos/${repo}/branches/${encodeURIComponent(branch)}`, "--jq", ".name"], + ).ok; + const baseBranch = syncBranchOnRemote ? branch : defaultBranch; + if (baseBranch !== branch) { + warn(`Branch "${branch}" not found on ${repo}; PR will target default branch "${defaultBranch}".`); + } + const tmp = mkdtempSync(join(tmpdir(), "codewiser-publish-")); try { - const clone = runCmd("gh", ["repo", "clone", repo, tmp, "--", "--depth", "1"]); + const clone = runCmd("gh", [ + "repo", "clone", repo, tmp, "--", "--depth", "1", + ...(baseBranch === branch ? ["--branch", branch, "--single-branch"] : []), + ]); if (!clone.ok) { error(`Failed to clone ${repo}: ${clone.err}`); process.exitCode = 1; @@ -185,12 +200,22 @@ export async function publish(dir: string = process.cwd()): Promise { writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8"); info("Updated versions in codewiser.json"); } else { - warn("No codewiser.json manifest in the source repo — skill versions were not bumped."); + error(`No codewiser.json manifest on ${repo}@${baseBranch} — cannot bump skill versions.`); + info("Point this project at a branch that contains the manifest: codewiser repo set --branch "); + process.exitCode = 1; + return; } const title = buildTitle(selected, versions); const body = selected.map((p) => `- \`${p}\` ${localFiles[p]?.version ?? "0.0.0"} -> ${versions.get(p)}`).join("\n"); + const add = runCmd("git", ["add", "-A"], tmp); + if (!add.ok) { + error(`git add failed: ${add.err}`); + process.exitCode = 1; + return; + } + const gitUser = ["-c", "user.name=codewiser", "-c", `user.email=${login}@users.noreply.github.com`]; const commit = runCmd("git", [...gitUser, "commit", "-m", title], tmp); if (!commit.ok) { @@ -232,7 +257,7 @@ export async function publish(dir: string = process.cwd()): Promise { const pr = runCmd("gh", [ "pr", "create", "--repo", repo, - "--base", defaultBranch, + "--base", baseBranch, "--head", head, "--title", title, "--body", body, From 5d4cc3b85cb6bb2052cf4156ba552ac334c9c0a0 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Thu, 13 Aug 2026 03:39:35 +0300 Subject: [PATCH 22/30] select each file while update --- .agents/skills/analyze/SKILL.md | 2 +- src/commands/pull.ts | 72 +++++++++++++++++++++++---------- src/utils/prompts.ts | 19 +++++---- src/utils/sync-files.ts | 51 ++++++++++++++++++++--- src/utils/ui.ts | 3 +- 5 files changed, 112 insertions(+), 35 deletions(-) diff --git a/.agents/skills/analyze/SKILL.md b/.agents/skills/analyze/SKILL.md index 62788b6..5c9f93b 100644 --- a/.agents/skills/analyze/SKILL.md +++ b/.agents/skills/analyze/SKILL.md @@ -8,7 +8,7 @@ license: MIT ## Purpose --test-Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. +Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. ### Unscoped Invocation diff --git a/src/commands/pull.ts b/src/commands/pull.ts index ce0a106..ba757de 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -1,7 +1,8 @@ import { readConfig, writeConfig, readGlobalConfig, resolveRepo, resolveBranch, buildRawBase, normalizeFileVersions } from "../utils/config"; -import { info, warn, error, success, fileStatus, runSpinner, confirmPrompt, EXIT } from "../utils/ui"; +import { info, warn, error, success, fileStatus, runSpinner, BACK, EXIT } from "../utils/ui"; import { syncFiles, type SyncOutcome } from "../utils/sync-files"; -import { fetchManifest, flattenRemoteManifest } from "../utils/remote"; +import { selectFilesToUpdate } from "../utils/prompts"; +import { fetchManifest, flattenRemoteManifest, MANIFEST_TIMEOUT_MS } from "../utils/remote"; export async function pull(dir: string = process.cwd()): Promise { const config = readConfig(dir); @@ -29,39 +30,55 @@ export async function pull(dir: string = process.cwd()): Promise { const remoteFiles = flattenRemoteManifest(remote, config.mode); const localFiles = normalizeFileVersions(config.files); + // Compare file content too, so upstream edits that didn't bump the version in + // codewiser.json are still detected. + const contentFetcher = async (path: string): Promise => { + try { + const res = await fetch(`${RAW_BASE}/${path}`, { signal: AbortSignal.timeout(MANIFEST_TIMEOUT_MS) }); + if (!res.ok) return null; + return await res.text(); + } catch { + return null; + } + }; + const outcome: SyncOutcome = await syncFiles({ targetDir: dir, rawBase: RAW_BASE, remoteFiles, localFiles, + contentFetcher, callbacks: { async onNew(paths) { if (paths.length === 0) return []; - info(`New files available from the team:`); - for (const p of paths) info(` ${p}`); - const ok = await confirmPrompt(`Install ${paths.length} new file(s)?`); - if (ok === EXIT || !ok) return []; - return paths; + const selected = await selectFilesToUpdate(paths, "New files available from the team (select which to install):"); + if (selected === EXIT || selected === BACK) return []; + return selected; }, async onUpdate(paths) { if (paths.length === 0) return []; - info(`Updated versions available from the team:`); - for (const p of paths) { + const entries = paths.map((p) => { const localVer = localFiles[p]?.version ?? "0.0.0"; const remoteVer = remoteFiles[p] ?? "0.0.0"; - info(` ${p} (${localVer} -> ${remoteVer})`); - } - const ok = await confirmPrompt(`Update ${paths.length} file(s) to the latest versions?`); - if (ok === EXIT || !ok) return []; - return paths; + const contentChanged = localVer === remoteVer ? " — content changed" : ""; + return { value: p, label: `${p} (${localVer} -> ${remoteVer})${contentChanged}` }; + }); + const selected = await selectFilesToUpdate(entries, "Updates available from the team (select which to pull):"); + if (selected === EXIT || selected === BACK) return []; + return selected; }, async onUpdateDirty(paths) { if (paths.length === 0) return []; - warn(`You have local edits on files with newer upstream versions:`); - for (const p of paths) warn(` ${p}`); - const ok = await confirmPrompt("Publish your edits first (recommended) or overwrite them with the upstream versions?", false); - if (ok === true) return paths; - return []; + const entries = paths.map((p) => ({ + value: p, + label: `${p} (local edits — newer upstream)`, + })); + const selected = await selectFilesToUpdate( + entries, + "You edited these locally and they changed upstream. Select the ones to overwrite with the team's version (leave unselected to keep yours):", + ); + if (selected === EXIT || selected === BACK) return []; + return selected; }, }, }); @@ -72,9 +89,22 @@ export async function pull(dir: string = process.cwd()): Promise { for (const p of outcome.keptDirty) warn(`Local edits kept (not published): ${p}`); for (const p of outcome.conflicts) warn(`Local edits kept over newer upstream version: ${p}`); for (const p of outcome.upToDate) fileStatus(p, "current"); + for (const p of outcome.keptUpdates) info(`Kept local (not updated): ${p}`); + for (const p of outcome.skippedNew) info(`Skipped new file: ${p}`); + if (outcome.unverifiedContent.length > 0) { + warn(`Could not verify ${outcome.unverifiedContent.length} file(s) against upstream: ${outcome.unverifiedContent.join(", ")}`); + } - if (changed > 0) success(`${changed} file(s) updated`); - else info("Everything is up to date."); + if (changed > 0) { + success(`${changed} file(s) updated`); + } else if ( + outcome.keptUpdates.length + outcome.skippedNew.length + + outcome.conflicts.length + outcome.keptDirty.length > 0 + ) { + info("Nothing updated — your local versions were kept."); + } else { + info("Everything is up to date."); + } writeConfig(dir, { ...config, diff --git a/src/utils/prompts.ts b/src/utils/prompts.ts index f8e5943..3c9c54c 100644 --- a/src/utils/prompts.ts +++ b/src/utils/prompts.ts @@ -97,16 +97,21 @@ export async function selectBranch(): Promise { return branch; } -export async function selectPublishFiles(paths: string[]): Promise { - const result = await pickMany( - "Which modified skills do you want to publish?", - paths.map((p) => ({ value: p, label: p })), - { required: false }, - ); - if (result === EXIT) return EXIT; +export async function selectFilesToUpdate( + entries: string[] | { value: string; label: string }[], + message: string, +): Promise { + if (entries.length === 0) return []; + const options = entries.map((e) => (typeof e === "string" ? { value: e, label: e } : e)); + const result = await pickMany(message, options, { required: false }); + if (result === BACK || result === EXIT) return result; return result; } +export async function selectPublishFiles(paths: string[]): Promise { + return selectFilesToUpdate(paths, "Which modified skills do you want to publish?"); +} + export async function enterNewVersion(path: string, current: string): Promise { const next = await textPrompt({ message: `New version for ${path}:`, diff --git a/src/utils/sync-files.ts b/src/utils/sync-files.ts index 4f00b48..51a6b74 100644 --- a/src/utils/sync-files.ts +++ b/src/utils/sync-files.ts @@ -1,7 +1,7 @@ import { existsSync } from "fs"; import { join } from "path"; import { download } from "./download"; -import { sha256File } from "./hash"; +import { sha256File, sha256Text } from "./hash"; import { versionLt } from "./manifest"; import type { FileVersion } from "./config"; @@ -21,25 +21,39 @@ export interface SyncOptions { remoteFiles: Record; localFiles?: FilesMap; callbacks: SyncCallbacks; + // When provided, files at the same version are also compared by content hash so + // upstream edits that didn't bump the version are still surfaced as updates. + contentFetcher?: (path: string) => Promise; } export interface SyncOutcome { files: FilesMap; downloadedNew: string[]; downloadedUpdates: string[]; + // Update candidates the caller decided NOT to apply; they stay tracked at + // their current version/hash and will be offered again on the next run. + keptUpdates: string[]; + // New files the caller decided NOT to install; left untracked locally. + skippedNew: string[]; + // Files edited locally (dirty) that the caller chose to keep instead of + // overwriting with the upstream version. keptDirty: string[]; conflicts: string[]; upToDate: string[]; + // Paths where the content comparison could not be performed (fetch/hash + // failure), so a same-version upstream edit may have been missed silently. + unverifiedContent: string[]; } export async function syncFiles(opts: SyncOptions): Promise { - const { targetDir, rawBase, remoteFiles, localFiles = {}, callbacks } = opts; + const { targetDir, rawBase, remoteFiles, localFiles = {}, callbacks, contentFetcher } = opts; const newCandidates: string[] = []; const updateCandidates: string[] = []; const dirtyUpdateCandidates: string[] = []; const keptDirtyCandidates: string[] = []; const upToDateCandidates: string[] = []; + const unverifiedContentCandidates: string[] = []; for (const [path, remoteVer] of Object.entries(remoteFiles)) { const dest = join(targetDir, ...path.split("/")); @@ -62,10 +76,30 @@ export async function syncFiles(opts: SyncOptions): Promise { dirty = false; } + const versionNewer = versionLt(localVer, remoteVer); + + // Detect upstream content changes even when the version didn't move. Only + // worth fetching when the version alone wouldn't already flag an update. + let remoteDiffers = false; + if (!versionNewer && contentFetcher) { + if (currentHash) { + const remoteContent = await contentFetcher(path); + if (remoteContent !== null) { + remoteDiffers = sha256Text(remoteContent) !== currentHash; + } else { + unverifiedContentCandidates.push(path); + } + } else { + // Couldn't hash the local file (missing/unreadable): content comparison + // is skipped rather than silently risking a missed change. + unverifiedContentCandidates.push(path); + } + } + if (dirty) { - if (versionLt(localVer, remoteVer)) dirtyUpdateCandidates.push(path); + if (versionNewer || remoteDiffers) dirtyUpdateCandidates.push(path); else keptDirtyCandidates.push(path); - } else if (versionLt(localVer, remoteVer)) { + } else if (versionNewer || remoteDiffers) { updateCandidates.push(path); } else { upToDateCandidates.push(path); @@ -119,14 +153,21 @@ export async function syncFiles(opts: SyncOptions): Promise { } const keptDirty = [...keptDirtyCandidates]; - const conflicts = [...dirtyUpdateCandidates]; + const skippedNew = [...newCandidates.filter((p) => !downloadedNew.has(p))]; + // "downloadedUpdates" merges clean updates and overwritten dirty files; since a + // path can only ever be one kind of candidate, the filtering is unambiguous. + const keptUpdates = [...updateCandidates.filter((p) => !downloadedUpdates.has(p))]; + const conflicts = [...dirtyUpdateCandidates.filter((p) => !downloadedUpdates.has(p))]; return { files, downloadedNew: [...downloadedNew], downloadedUpdates: [...downloadedUpdates], + keptUpdates, + skippedNew, keptDirty, conflicts, upToDate: upToDateCandidates, + unverifiedContent: unverifiedContentCandidates, }; } \ No newline at end of file diff --git a/src/utils/ui.ts b/src/utils/ui.ts index bcdc2ca..7081faf 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -73,12 +73,13 @@ export async function pick( export async function pickMany( message: string, options: Option[], - config?: { required?: boolean }, + config?: { required?: boolean; initialValues?: T[] }, ): Promise { const result = await multiselect({ message, options: options as any, required: config?.required ?? false, + initialValues: config?.initialValues, }); if (isCancel(result)) return EXIT; return result as T[]; From c4df9884b12efd21126b691f56b6ac8431ba48df Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Thu, 13 Aug 2026 03:59:48 +0300 Subject: [PATCH 23/30] fix dirctions of the sync --- src/commands/pull.ts | 3 ++- src/utils/sync-files.ts | 54 ++++++++++++++++++++++++++--------------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/commands/pull.ts b/src/commands/pull.ts index ba757de..ce8df81 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -91,6 +91,7 @@ export async function pull(dir: string = process.cwd()): Promise { for (const p of outcome.upToDate) fileStatus(p, "current"); for (const p of outcome.keptUpdates) info(`Kept local (not updated): ${p}`); for (const p of outcome.skippedNew) info(`Skipped new file: ${p}`); + for (const p of outcome.resynced) info(`Already in sync with the repo (adopted): ${p}`); if (outcome.unverifiedContent.length > 0) { warn(`Could not verify ${outcome.unverifiedContent.length} file(s) against upstream: ${outcome.unverifiedContent.join(", ")}`); } @@ -99,7 +100,7 @@ export async function pull(dir: string = process.cwd()): Promise { success(`${changed} file(s) updated`); } else if ( outcome.keptUpdates.length + outcome.skippedNew.length + - outcome.conflicts.length + outcome.keptDirty.length > 0 + outcome.conflicts.length + outcome.keptDirty.length + outcome.resynced.length > 0 ) { info("Nothing updated — your local versions were kept."); } else { diff --git a/src/utils/sync-files.ts b/src/utils/sync-files.ts index 51a6b74..64d04d5 100644 --- a/src/utils/sync-files.ts +++ b/src/utils/sync-files.ts @@ -39,6 +39,11 @@ export interface SyncOutcome { // overwriting with the upstream version. keptDirty: string[]; conflicts: string[]; + // Files where both the repo and the on-disk file moved off the tracked + // baseline but now agree with each other (e.g. an edit already published or + // pulled in). The change came from the repo, so the baseline is re-adopted + // with no download or prompt. + resynced: string[]; upToDate: string[]; // Paths where the content comparison could not be performed (fetch/hash // failure), so a same-version upstream edit may have been missed silently. @@ -52,6 +57,7 @@ export async function syncFiles(opts: SyncOptions): Promise { const updateCandidates: string[] = []; const dirtyUpdateCandidates: string[] = []; const keptDirtyCandidates: string[] = []; + const resyncedCandidates: string[] = []; const upToDateCandidates: string[] = []; const unverifiedContentCandidates: string[] = []; @@ -77,29 +83,31 @@ export async function syncFiles(opts: SyncOptions): Promise { } const versionNewer = versionLt(localVer, remoteVer); - - // Detect upstream content changes even when the version didn't move. Only - // worth fetching when the version alone wouldn't already flag an update. - let remoteDiffers = false; - if (!versionNewer && contentFetcher) { - if (currentHash) { - const remoteContent = await contentFetcher(path); - if (remoteContent !== null) { - remoteDiffers = sha256Text(remoteContent) !== currentHash; - } else { - unverifiedContentCandidates.push(path); - } - } else { - // Couldn't hash the local file (missing/unreadable): content comparison - // is skipped rather than silently risking a missed change. - unverifiedContentCandidates.push(path); - } + const baselineHash = storedHash ?? currentHash; + + // Fetch the upstream file once and compare it against the tracked baseline, + // NOT the on-disk file. Comparing against the disk would misattribute a + // local-only edit as an upstream change (and vice versa). + let remoteHash: string | null = null; + if (contentFetcher && baselineHash) { + const remoteContent = await contentFetcher(path); + if (remoteContent !== null) remoteHash = sha256Text(remoteContent); + else unverifiedContentCandidates.push(path); } + const remoteChanged = versionNewer || (remoteHash !== null && remoteHash !== baselineHash); + // When the on-disk file already matches the repo, the change came from the + // repo (or was published): both sides agree, only the baseline is stale. + const bothAgree = remoteHash !== null && currentHash !== null && remoteHash === currentHash; + if (dirty) { - if (versionNewer || remoteDiffers) dirtyUpdateCandidates.push(path); - else keptDirtyCandidates.push(path); - } else if (versionNewer || remoteDiffers) { + if (remoteChanged) { + if (bothAgree) resyncedCandidates.push(path); + else dirtyUpdateCandidates.push(path); + } else { + keptDirtyCandidates.push(path); + } + } else if (remoteChanged) { updateCandidates.push(path); } else { upToDateCandidates.push(path); @@ -131,6 +139,11 @@ export async function syncFiles(opts: SyncOptions): Promise { files[path] = { version: remoteVer, sha256: sha256File(dest) ?? undefined }; continue; } + if (resyncedCandidates.includes(path)) { + // The on-disk file already equals the repo; adopt it as the new baseline. + files[path] = { version: remoteVer, sha256: sha256File(dest) ?? undefined }; + continue; + } if (keptDirtyCandidates.includes(path)) { const stored = localFiles[path]; if (stored?.sha256) { @@ -167,6 +180,7 @@ export async function syncFiles(opts: SyncOptions): Promise { skippedNew, keptDirty, conflicts, + resynced: resyncedCandidates, upToDate: upToDateCandidates, unverifiedContent: unverifiedContentCandidates, }; From 8761af485ff85834e397e50911208d38274f2467 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Thu, 13 Aug 2026 04:01:21 +0300 Subject: [PATCH 24/30] test commit --- .agents/skills/analyze/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/analyze/SKILL.md b/.agents/skills/analyze/SKILL.md index 5c9f93b..62788b6 100644 --- a/.agents/skills/analyze/SKILL.md +++ b/.agents/skills/analyze/SKILL.md @@ -8,7 +8,7 @@ license: MIT ## Purpose -Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. +-test-Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. ### Unscoped Invocation From add4981594fda4f8a161e606365f66cd62b260dd Mon Sep 17 00:00:00 2001 From: codewiser Date: Thu, 13 Aug 2026 04:18:12 +0300 Subject: [PATCH 25/30] skills: analyze v1.1.0 --- .agents/skills/analyze/SKILL.md | 2 +- codewiser.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/skills/analyze/SKILL.md b/.agents/skills/analyze/SKILL.md index 62788b6..5c9f93b 100644 --- a/.agents/skills/analyze/SKILL.md +++ b/.agents/skills/analyze/SKILL.md @@ -8,7 +8,7 @@ license: MIT ## Purpose --test-Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. +Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. ### Unscoped Invocation diff --git a/codewiser.json b/codewiser.json index b2ccd60..495c597 100644 --- a/codewiser.json +++ b/codewiser.json @@ -146,4 +146,4 @@ }, "repo": "mostafamm44/codewiser", "branch": "sync/codewiser.json" -} \ No newline at end of file +} From f39c93f866158318e0936709df80d73ade765285 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Thu, 13 Aug 2026 04:22:52 +0300 Subject: [PATCH 26/30] update sync --- src/commands/pull.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/commands/pull.ts b/src/commands/pull.ts index ce8df81..ee6cb66 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -86,23 +86,25 @@ export async function pull(dir: string = process.cwd()): Promise { let changed = 0; for (const p of outcome.downloadedNew) { fileStatus(p, "new"); changed++; } for (const p of outcome.downloadedUpdates) { fileStatus(p, "updated"); changed++; } - for (const p of outcome.keptDirty) warn(`Local edits kept (not published): ${p}`); - for (const p of outcome.conflicts) warn(`Local edits kept over newer upstream version: ${p}`); - for (const p of outcome.upToDate) fileStatus(p, "current"); - for (const p of outcome.keptUpdates) info(`Kept local (not updated): ${p}`); - for (const p of outcome.skippedNew) info(`Skipped new file: ${p}`); - for (const p of outcome.resynced) info(`Already in sync with the repo (adopted): ${p}`); + for (const p of outcome.resynced) info(`Already matching the repo — re-baselined (no download): ${p}`); + for (const p of outcome.keptUpdates) info(`Team updated upstream — you kept your local copy: ${p}`); + for (const p of outcome.skippedNew) info(`New on the team repo — not installed: ${p}`); + for (const p of outcome.keptDirty) warn(`Your local change — team's copy unchanged: ${p}`); + for (const p of outcome.conflicts) warn(`Changed on both sides — you kept your version: ${p}`); if (outcome.unverifiedContent.length > 0) { warn(`Could not verify ${outcome.unverifiedContent.length} file(s) against upstream: ${outcome.unverifiedContent.join(", ")}`); } + if (outcome.upToDate.length > 0) { + info(`${outcome.upToDate.length} file(s) already up to date with the team.`); + } if (changed > 0) { - success(`${changed} file(s) updated`); + success(`${changed} file(s) updated (${outcome.downloadedNew.length} new, ${outcome.downloadedUpdates.length} updated).`); } else if ( outcome.keptUpdates.length + outcome.skippedNew.length + outcome.conflicts.length + outcome.keptDirty.length + outcome.resynced.length > 0 ) { - info("Nothing updated — your local versions were kept."); + info("Nothing changed locally — see the lines above for what the team has and what you kept."); } else { info("Everything is up to date."); } From 655fd4ecf012bffadd03ea0c2ff51a2368d2f62b Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Thu, 13 Aug 2026 05:15:16 +0300 Subject: [PATCH 27/30] add 3-way git merge-file --- .agents/skills/analyze/SKILL.md | 2 +- .gitignore | 3 + src/commands/init.ts | 11 +++- src/commands/publish.ts | 113 ++++++++++++++++++++++++++------ src/commands/pull.ts | 17 ++++- src/utils/cache.ts | 37 +++++++++++ src/utils/prompts.ts | 10 +++ src/utils/sync-files.ts | 21 +++++- 8 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 src/utils/cache.ts diff --git a/.agents/skills/analyze/SKILL.md b/.agents/skills/analyze/SKILL.md index 5c9f93b..488f1a9 100644 --- a/.agents/skills/analyze/SKILL.md +++ b/.agents/skills/analyze/SKILL.md @@ -8,7 +8,7 @@ license: MIT ## Purpose -Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. +ctest-Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. ### Unscoped Invocation diff --git a/.gitignore b/.gitignore index a14702c..4dfaaa2 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .cache *.tsbuildinfo +# codewiser base-content cache (three-way merge baseline) +.codewiser-cache/ + # IntelliJ based IDEs .idea diff --git a/src/commands/init.ts b/src/commands/init.ts index b6c768b..598c34c 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync } from "fs"; +import { existsSync, mkdirSync, readFileSync } from "fs"; import { join, resolve } from "path"; import { showTitle, showDone, stepHeader, info, warn, error, success, item, fileStatus, runSpinner, pick, confirmPrompt, @@ -21,6 +21,7 @@ import { import { createAllSymlinks } from "../utils/symlinks"; import { readConfig, writeConfig, readGlobalConfig, resolveRepo, resolveBranch, buildRawBase, describeRepoSource, describeBranchSource, normalizeFileVersions } from "../utils/config"; import { syncFiles, type SyncOutcome } from "../utils/sync-files"; +import { writeBase } from "../utils/cache"; import { readManifest } from "./repo"; import type { SelectedAgents } from "../utils/prompts"; @@ -260,6 +261,14 @@ export async function init(targetDirInput: string, cliRepo?: string, cliBranch?: for (const p of outcome.conflicts) warn(`Modified locally with a newer version upstream: ${p}`); for (const p of outcome.upToDate) fileStatus(p, "current"); + for (const p of [...outcome.downloadedNew, ...outcome.downloadedUpdates]) { + try { + writeBase(targetDir, p, readFileSync(join(targetDir, ...p.split("/")), "utf-8")); + } catch { + // best-effort + } + } + if (outcome.downloadedNew.length + outcome.downloadedUpdates.length > 0) { success(`${outcome.downloadedNew.length + outcome.downloadedUpdates.length} file(s) downloaded`); } else { diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 7839cee..0a4999c 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -4,11 +4,12 @@ import { join } from "path"; import { execFileSync } from "child_process"; import { readConfig, writeConfig, readGlobalConfig, resolveRepo, resolveBranch, buildRawBase, normalizeFileVersions } from "../utils/config"; import { info, warn, error, success, confirmPrompt, EXIT, BACK } from "../utils/ui"; -import { selectPublishFiles, enterNewVersion, chooseUpdateOrKeep } from "../utils/prompts"; +import { selectPublishFiles, enterNewVersion, chooseUpdateOrKeep, choosePullFirst } from "../utils/prompts"; import { download } from "../utils/download"; import { versionLt } from "../utils/manifest"; -import { sha256File } from "../utils/hash"; -import { fetchManifest, flattenRemoteManifest } from "../utils/remote"; +import { sha256File, sha256Text } from "../utils/hash"; +import { fetchManifest, flattenRemoteManifest, MANIFEST_TIMEOUT_MS } from "../utils/remote"; +import { readBase, writeBase } from "../utils/cache"; function filePath(dir: string, rel: string): string { return join(dir, ...rel.split("/")); @@ -70,36 +71,73 @@ export async function publish(dir: string = process.cwd()): Promise { if (hash !== null && hash !== entry.sha256) dirty.add(path); } - // 2. Check the remote for newer versions of the edited skills. Let the member - // update first (rebasing on the team's latest) or keep their own. + // 2. Check the team's copy of each edited skill. If they changed it since our + // last sync, offer to pull their changes into our edits first (a three-way + // merge against the cached base) so we rebase on the team's latest before + // opening a pull request. const remote = await fetchManifest(RAW_BASE); if (remote) { const remoteFiles = flattenRemoteManifest(remote, config.mode); for (const path of [...dirty]) { const remoteVer = remoteFiles[path]; - if (!remoteVer) continue; const localVer = localFiles[path]?.version ?? "0.0.0"; - if (!versionLt(localVer, remoteVer)) continue; - const choice = await chooseUpdateOrKeep(path, localVer, remoteVer); - if (choice === EXIT || choice === BACK) { + const fetched = await fetchRemoteContent(RAW_BASE, path); + const base = readBase(dir, path); + const remoteChanged = + (remoteVer !== undefined && versionLt(localVer, remoteVer)) || + (fetched !== null && base !== null && sha256Text(fetched) !== sha256Text(base)); + + if (!remoteChanged) continue; + + if (base === null || fetched === null) { + // No content baseline cached (or the fetch failed): keep the win-lose + // choice, but only when the remote version is genuinely newer. + if (remoteVer === undefined || !versionLt(localVer, remoteVer)) continue; + const choice = await chooseUpdateOrKeep(path, localVer, remoteVer); + if (choice === EXIT || choice === BACK) { + process.exitCode = 1; + return; + } + if (choice === "update") { + const ok = await download(`${RAW_BASE}/${path}`, filePath(dir, path)); + if (!ok) { + warn(`Failed to update ${path}; continuing with the local version.`); + continue; + } + info(`Updated ${path} to ${remoteVer}`); + localFiles[path] = { version: remoteVer, sha256: sha256File(filePath(dir, path)) ?? undefined }; + writeBase(dir, path, readFileSync(filePath(dir, path), "utf-8")); + dirty.delete(path); + } else { + info(`Keeping local version of ${path}`); + } + continue; + } + + const choice = await choosePullFirst(path); + if (choice === EXIT) { process.exitCode = 1; return; } - if (choice === "update") { - const ok = await download(`${RAW_BASE}/${path}`, filePath(dir, path)); - if (!ok) { - warn(`Failed to update ${path}; continuing with the local version.`); - continue; - } - info(`Updated ${path} to ${remoteVer}`); - localFiles[path] = { version: remoteVer, sha256: sha256File(filePath(dir, path)) ?? undefined }; - dirty.delete(path); + if (choice === "asIs") { + info(`Publishing local version of ${path} as-is (team's changes left for PR review).`); + continue; + } + + const result = mergeThreeWay(readFileSync(filePath(dir, path), "utf-8"), base, fetched); + writeFileSync(filePath(dir, path), result.merged, "utf-8"); + if (result.ok) { + info(`Merged team changes into ${path}; your edits are kept.`); } else { - info(`Keeping local version of ${path}`); + dirty.delete(path); + warn( + `Merged ${path} but your edits overlap the team's — conflict markers left in the file. ` + + `Resolve them, then run 'codewiser publish' again. Excluded from this PR.`, + ); } } } else { - warn("Could not reach the upstream manifest; cannot check for newer versions."); + warn("Could not reach the upstream manifest; cannot check for newer changes."); } if (dirty.size === 0) { @@ -351,4 +389,39 @@ function buildTitle(paths: string[], versions: Map): string { return `${name} v${versions.get(p)}`; }); return `skills: ${parts.join(", ")}`; +} + +async function fetchRemoteContent(rawBase: string, path: string): Promise { + try { + const res = await fetch(`${rawBase}/${path}`, { signal: AbortSignal.timeout(MANIFEST_TIMEOUT_MS) }); + if (!res.ok) return null; + return await res.text(); + } catch { + return null; + } +} + +// Three-way merge of the team's latest content into a locally edited file, using +// the last-synced content as the common ancestor. Returns the merged text; when +// `ok` is false the merge hit overlaps and the text contains conflict markers. +function mergeThreeWay(local: string, base: string, theirs: string): { ok: boolean; merged: string } { + const tmp = mkdtempSync(join(tmpdir(), "codewiser-merge-")); + try { + const mine = join(tmp, "mine"); + const baseFile = join(tmp, "base"); + const theirsFile = join(tmp, "theirs"); + writeFileSync(mine, local, "utf-8"); + writeFileSync(baseFile, base, "utf-8"); + writeFileSync(theirsFile, theirs, "utf-8"); + const r = runCmd("git", ["merge-file", mine, baseFile, theirsFile]); + return { ok: r.ok, merged: readFileSync(mine, "utf-8") }; + } catch { + return { ok: false, merged: local }; + } finally { + try { + rmSync(tmp, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } } \ No newline at end of file diff --git a/src/commands/pull.ts b/src/commands/pull.ts index ee6cb66..5672243 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -3,6 +3,9 @@ import { info, warn, error, success, fileStatus, runSpinner, BACK, EXIT } from " import { syncFiles, type SyncOutcome } from "../utils/sync-files"; import { selectFilesToUpdate } from "../utils/prompts"; import { fetchManifest, flattenRemoteManifest, MANIFEST_TIMEOUT_MS } from "../utils/remote"; +import { writeBase } from "../utils/cache"; +import { join } from "path"; +import { readFileSync } from "fs"; export async function pull(dir: string = process.cwd()): Promise { const config = readConfig(dir); @@ -83,12 +86,23 @@ export async function pull(dir: string = process.cwd()): Promise { }, }); + // Record the downloaded/resynced content as the merge base for future pulls. + for (const p of [...outcome.downloadedNew, ...outcome.downloadedUpdates, ...outcome.resynced]) { + const dest = join(dir, ...p.split("/")); + try { + writeBase(dir, p, readFileSync(dest, "utf-8")); + } catch { + // best-effort + } + } + let changed = 0; for (const p of outcome.downloadedNew) { fileStatus(p, "new"); changed++; } for (const p of outcome.downloadedUpdates) { fileStatus(p, "updated"); changed++; } for (const p of outcome.resynced) info(`Already matching the repo — re-baselined (no download): ${p}`); for (const p of outcome.keptUpdates) info(`Team updated upstream — you kept your local copy: ${p}`); for (const p of outcome.skippedNew) info(`New on the team repo — not installed: ${p}`); + for (const p of outcome.localAhead) info(`Your version is ahead of the team repo (PR not merged yet?): ${p}`); for (const p of outcome.keptDirty) warn(`Your local change — team's copy unchanged: ${p}`); for (const p of outcome.conflicts) warn(`Changed on both sides — you kept your version: ${p}`); if (outcome.unverifiedContent.length > 0) { @@ -102,7 +116,8 @@ export async function pull(dir: string = process.cwd()): Promise { success(`${changed} file(s) updated (${outcome.downloadedNew.length} new, ${outcome.downloadedUpdates.length} updated).`); } else if ( outcome.keptUpdates.length + outcome.skippedNew.length + - outcome.conflicts.length + outcome.keptDirty.length + outcome.resynced.length > 0 + outcome.conflicts.length + outcome.keptDirty.length + outcome.resynced.length + + outcome.localAhead.length > 0 ) { info("Nothing changed locally — see the lines above for what the team has and what you kept."); } else { diff --git a/src/utils/cache.ts b/src/utils/cache.ts new file mode 100644 index 0000000..087ec21 --- /dev/null +++ b/src/utils/cache.ts @@ -0,0 +1,37 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { dirname, join } from "path"; + +// Store the last-synced content of each synced file (the "base" for three-way +// merges on publish) out-of-band under `.codewiser-cache/`. The manifest only +// tracks hashes; the cached bytes are exactly what was downloaded (LF), so +// their sha256 matches the stored baseline. Best-effort: a missing cache entry +// simply disables auto-merge for that file. +export const CACHE_DIRNAME = ".codewiser-cache"; + +export function getCacheDir(targetDir: string): string { + return join(targetDir, CACHE_DIRNAME); +} + +export function cachePath(targetDir: string, path: string): string { + return join(getCacheDir(targetDir), ...path.split("/")); +} + +export function readBase(targetDir: string, path: string): string | null { + try { + const f = cachePath(targetDir, path); + if (!existsSync(f)) return null; + return readFileSync(f, "utf-8"); + } catch { + return null; + } +} + +export function writeBase(targetDir: string, path: string, content: string): void { + try { + const f = cachePath(targetDir, path); + mkdirSync(dirname(f), { recursive: true }); + writeFileSync(f, content, "utf-8"); + } catch { + // best-effort: merge falls back to per-file choose-next time + } +} \ No newline at end of file diff --git a/src/utils/prompts.ts b/src/utils/prompts.ts index 3c9c54c..4a9e069 100644 --- a/src/utils/prompts.ts +++ b/src/utils/prompts.ts @@ -124,6 +124,16 @@ export async function enterNewVersion(path: string, current: string): Promise { + const choice = await pick(`"${path}" was also changed by the team since your last sync.`, [ + { value: "merge", label: "Pull their changes into my edits and publish (merge)" }, + { value: "asIs", label: "Just publish my edits as-is" }, + { value: "__cancel", label: "Cancel and run 'codewiser pull' first" }, + ]); + if (choice === EXIT || choice === BACK || choice === "__cancel") return EXIT; + return choice; +} + export async function chooseUpdateOrKeep(path: string, local: string, remote: string): Promise<"update" | "keep" | typeof BACK | typeof EXIT> { const choice = await pick(`"${path}" has a newer version upstream (${local} -> ${remote})`, [ { value: "update", label: "Update to latest first" }, diff --git a/src/utils/sync-files.ts b/src/utils/sync-files.ts index 64d04d5..3d1a23f 100644 --- a/src/utils/sync-files.ts +++ b/src/utils/sync-files.ts @@ -44,6 +44,10 @@ export interface SyncOutcome { // pulled in). The change came from the repo, so the baseline is re-adopted // with no download or prompt. resynced: string[]; + // Files whose tracked version is ahead of the repo (e.g. a publish PR not yet + // merged). The remote is behind, not changed, so they are never offered as + // team updates and never downgraded. + localAhead: string[]; upToDate: string[]; // Paths where the content comparison could not be performed (fetch/hash // failure), so a same-version upstream edit may have been missed silently. @@ -58,6 +62,7 @@ export async function syncFiles(opts: SyncOptions): Promise { const dirtyUpdateCandidates: string[] = []; const keptDirtyCandidates: string[] = []; const resyncedCandidates: string[] = []; + const localAheadCandidates: string[] = []; const upToDateCandidates: string[] = []; const unverifiedContentCandidates: string[] = []; @@ -82,7 +87,9 @@ export async function syncFiles(opts: SyncOptions): Promise { dirty = false; } - const versionNewer = versionLt(localVer, remoteVer); + const remoteNewer = versionLt(localVer, remoteVer); + const localAhead = versionLt(remoteVer, localVer); + const versionEqual = !remoteNewer && !localAhead; const baselineHash = storedHash ?? currentHash; // Fetch the upstream file once and compare it against the tracked baseline, @@ -95,7 +102,12 @@ export async function syncFiles(opts: SyncOptions): Promise { else unverifiedContentCandidates.push(path); } - const remoteChanged = versionNewer || (remoteHash !== null && remoteHash !== baselineHash); + // A remote change is a strictly newer version, or — only when versions are + // equal — different content. When our version is already ahead (e.g. a + // publish PR not merged yet) the remote being behind is expected, so it is + // never treated as a team update nor a reason to downgrade. + const remoteChanged = + remoteNewer || (versionEqual && remoteHash !== null && remoteHash !== baselineHash); // When the on-disk file already matches the repo, the change came from the // repo (or was published): both sides agree, only the baseline is stale. const bothAgree = remoteHash !== null && currentHash !== null && remoteHash === currentHash; @@ -104,11 +116,15 @@ export async function syncFiles(opts: SyncOptions): Promise { if (remoteChanged) { if (bothAgree) resyncedCandidates.push(path); else dirtyUpdateCandidates.push(path); + } else if (localAhead) { + localAheadCandidates.push(path); } else { keptDirtyCandidates.push(path); } } else if (remoteChanged) { updateCandidates.push(path); + } else if (localAhead) { + localAheadCandidates.push(path); } else { upToDateCandidates.push(path); } @@ -181,6 +197,7 @@ export async function syncFiles(opts: SyncOptions): Promise { keptDirty, conflicts, resynced: resyncedCandidates, + localAhead: localAheadCandidates, upToDate: upToDateCandidates, unverifiedContent: unverifiedContentCandidates, }; From 4382b2ff7028c83f665155d8a5a5cdebfe3e9d1f Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Thu, 13 Aug 2026 05:44:29 +0300 Subject: [PATCH 28/30] update --- .agents/skills/analyze/SKILL.md | 2 +- src/commands/publish.ts | 23 ++++++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.agents/skills/analyze/SKILL.md b/.agents/skills/analyze/SKILL.md index 488f1a9..5c9f93b 100644 --- a/.agents/skills/analyze/SKILL.md +++ b/.agents/skills/analyze/SKILL.md @@ -8,7 +8,7 @@ license: MIT ## Purpose -ctest-Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. +Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. ### Unscoped Invocation diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 0a4999c..57714a9 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -83,12 +83,22 @@ export async function publish(dir: string = process.cwd()): Promise { const localVer = localFiles[path]?.version ?? "0.0.0"; const fetched = await fetchRemoteContent(RAW_BASE, path); const base = readBase(dir, path); + const fetchedSameAsBase = + fetched !== null && base !== null && sha256Text(fetched) === sha256Text(base); const remoteChanged = (remoteVer !== undefined && versionLt(localVer, remoteVer)) || (fetched !== null && base !== null && sha256Text(fetched) !== sha256Text(base)); if (!remoteChanged) continue; + if (fetchedSameAsBase && remoteVer !== undefined && versionLt(localVer, remoteVer)) { + info( + `Team bumped ${path} to ${remoteVer}, but its content is identical to your last sync — ` + + `nothing new to merge. Publishing your version.`, + ); + continue; + } + if (base === null || fetched === null) { // No content baseline cached (or the fetch failed): keep the win-lose // choice, but only when the remote version is genuinely newer. @@ -124,10 +134,17 @@ export async function publish(dir: string = process.cwd()): Promise { continue; } - const result = mergeThreeWay(readFileSync(filePath(dir, path), "utf-8"), base, fetched); - writeFileSync(filePath(dir, path), result.merged, "utf-8"); - if (result.ok) { + const source = readFileSync(filePath(dir, path), "utf-8"); + const result = mergeThreeWay(source, base, fetched); + if (result.ok && sha256Text(result.merged) !== sha256Text(source)) { + writeFileSync(filePath(dir, path), result.merged, "utf-8"); info(`Merged team changes into ${path}; your edits are kept.`); + writeBase(dir, path, result.merged); + } else if (result.ok) { + info( + `Merged ${path} but the team's copy has no new content since your last sync — ` + + `your version is unchanged.`, + ); } else { dirty.delete(path); warn( From be689873526d0ac598a24aaf16653f20742bac83 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Thu, 13 Aug 2026 06:03:50 +0300 Subject: [PATCH 29/30] update --- .agents/skills/analyze/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/analyze/SKILL.md b/.agents/skills/analyze/SKILL.md index 5c9f93b..3d65815 100644 --- a/.agents/skills/analyze/SKILL.md +++ b/.agents/skills/analyze/SKILL.md @@ -8,7 +8,7 @@ license: MIT ## Purpose -Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. +-this is new test -Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. ### Unscoped Invocation From 55dc3566782b90498b66d968efbdeb2900d06c67 Mon Sep 17 00:00:00 2001 From: mostafamm44 Date: Thu, 13 Aug 2026 06:24:05 +0300 Subject: [PATCH 30/30] finshe testing --- .agents/skills/analyze/SKILL.md | 2 +- src/commands/publish.ts | 43 ++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/.agents/skills/analyze/SKILL.md b/.agents/skills/analyze/SKILL.md index 3d65815..5c9f93b 100644 --- a/.agents/skills/analyze/SKILL.md +++ b/.agents/skills/analyze/SKILL.md @@ -8,7 +8,7 @@ license: MIT ## Purpose --this is new test -Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. +Reverse engineering documents capture **how a specific functionality actually works** in the current codebase and **keep that understanding in sync** as the system evolves. They serve as the project's living map — answering "how does this feature work from entry to exit?" for both human readers and agents. The analysis is either about a how-it-works or a module. ### Unscoped Invocation diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 57714a9..285b0af 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -38,6 +38,32 @@ function hasGh(): boolean { return runCmd("gh", ["--version"]).ok; } +// git merge-file writes markers `<<<<<<<` / `=======` / `>>>>>>>` when the sides +// overlap. Report the 1-based line range of each conflicted hunk. +function conflictRanges(text: string): Array<{ start: number; end: number }> { + const ranges: Array<{ start: number; end: number }> = []; + const lines = text.split("\n"); + let start: number | null = null; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]?.trimStart() ?? ""; + if (line.startsWith("<<<<<<<")) { + start = i + 1; + } else if (line.startsWith(">>>>>>>") && start !== null) { + ranges.push({ start, end: i + 1 }); + start = null; + } + } + return ranges; +} + +function hasConflicts(text: string): boolean { + return text.includes("<<<<<<<"); +} + +function conflictLocationText(text: string): string { + return conflictRanges(text).map((r) => `lines ${r.start}-${r.end}`).join(", ") || "unknown lines"; +} + export async function publish(dir: string = process.cwd()): Promise { const config = readConfig(dir); @@ -124,17 +150,27 @@ export async function publish(dir: string = process.cwd()): Promise { continue; } + const source = readFileSync(filePath(dir, path), "utf-8"); + if (hasConflicts(source)) { + dirty.delete(path); + warn( + `Merged ${path} earlier but conflict markers are still in the file (${conflictLocationText(source)}). ` + + `Resolve the markers, then run 'codewiser publish' again. Excluded from this PR.`, + ); + continue; + } + const choice = await choosePullFirst(path); if (choice === EXIT) { process.exitCode = 1; return; } if (choice === "asIs") { + writeBase(dir, path, source); info(`Publishing local version of ${path} as-is (team's changes left for PR review).`); continue; } - const source = readFileSync(filePath(dir, path), "utf-8"); const result = mergeThreeWay(source, base, fetched); if (result.ok && sha256Text(result.merged) !== sha256Text(source)) { writeFileSync(filePath(dir, path), result.merged, "utf-8"); @@ -146,10 +182,11 @@ export async function publish(dir: string = process.cwd()): Promise { `your version is unchanged.`, ); } else { + writeFileSync(filePath(dir, path), result.merged, "utf-8"); dirty.delete(path); warn( - `Merged ${path} but your edits overlap the team's — conflict markers left in the file. ` + - `Resolve them, then run 'codewiser publish' again. Excluded from this PR.`, + `Merged ${path} but your edits overlap the team's at ${conflictLocationText(result.merged)} — ` + + `conflict markers left in the file. Resolve them, then run 'codewiser publish' again. Excluded from this PR.`, ); } }