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