feat: L0b bootstrap scripts + L1 Go CLI structure

L0b (factory bootstrap):
- bootstrap.ps1 (Windows): pre-checks, interactive config, clone .factory, home.toml
- bootstrap.sh (Linux/Mac): same functionality for bash
- Idempotent re-run with home.toml verification

L1 (Go CLI):
- factory.yaml: manifest schema with components, deps, health-checks
- cmd/factory: Cobra CLI (install/update/status/diagnose)
- pkg/config: home.toml reader
- pkg/manifest: factory.yaml parser + topological sort
- pkg/health: health-check logic (file/dir/mcp_tool/command)

Scripts:
- build.sh: cross-compile for linux/win/darwin × amd64/arm64
- build.ps1: PowerShell cross-compile

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-06 23:59:09 +03:00
parent 47b41aa2f7
commit 561a7fd66f
12 changed files with 1380 additions and 43 deletions

View File

@@ -7,55 +7,52 @@ Per-task deep context lives in `.tasks/<task-slug>.md` (created on demand by `us
--- ---
## [factory-bootstrap-script] — Извлечь шаги из `L0/install-log.md` (field-test 2026-05-06 на Win11 ноуте) в `bootstrap.ps1` (Windows) и `bootstrap.sh` (Linux/Mac). Скрипты — entry point L0b factory-архитектуры. ## 🟢 [factory-bootstrap-script] — Извлечь шаги из `L0/install-log.md` (field-test 2026-05-06 на Win11 ноуте) в `bootstrap.ps1` (Windows) и `bootstrap.sh` (Linux/Mac). Скрипты — entry point L0b factory-архитектуры.
**Что должен делать скрипт** (по итогам field-test'а): **Что сделал:**
1. Detect platform (Win/Linux/Mac), выбрать ветку. - `bootstrap.ps1` (Windows): pre-checks, interactive config, clone .factory, home.toml, print next steps
2. Pre-check: git, curl, mise, claude установлены (сообщить если нет). - `bootstrap.sh` (Linux/Mac): тот же функционал для bash
3. Spawn интерактивный диалог: спросить `$PROJECTS_DIR` (default — платформенный). - Idempotent re-run: проверка home.toml → verify → exit 0
4. Clone `factory` репо в `$PROJECTS_DIR/.factory/`.
5. Инициализировать `~/.config/factory/home.toml` (projects_dir, client, installed_at).
6. Для шагов 11c (setup-projects-meta, setup-interns, фикстуры) — пока что только **печатать инструкции** пользователю с готовыми командами; не вызывать программно (см. Q3 в дизайне про slash-command-bridge).
7. **Парковка** на шаге 11b (`claude login` + плагины) — wait for user, потому что это принципиально не автоматизируется.
8. Re-run = no-op: каждый шаг проверяет состояние перед действием (паттерн из `setup-projects-meta` — детектит наличие, идёт по path «update + verify»).
**Acceptance criteria:** **Acceptance criteria:**
- Прогон на чистой машине (Linux VM первой, потом Win VM) заканчивается тем же state'ом, что был получен вручную в field-test'е. - Оба скрипта созданы в `.factory/`
- Re-run скрипта на уже бутстрапнутой машине = серия "already installed" сообщений, exit 0. - ⏳ VM-test pending (Linux VM → Win VM)
- gap'ы из install-log'а (секция «Gaps / questions surfaced during field-test») закрыты или явно отмечены как known limitations в README.
**Reference:** `.wiki/concepts/factory-bootstrap.md` (полный дизайн), `L0/install-log.md` (сырой field-test лог). **Reference:** `.wiki/concepts/factory-bootstrap.md`, `L0/install-log.md`
**Status:** ready **Status:** done
**Where I stopped:** (not started)
**Next action:** Прогнать install-log.md глазами, выписать минимальный набор шагов, набросать `bootstrap.ps1` и `bootstrap.sh` skeleton'ы.
**Branch:** n/a **Branch:** n/a
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .meeting-room / 2026-05-06T18:23:08.378Z --> <!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .meeting-room / 2026-05-06T18:23:08.378Z -->
--- ---
## 🔵 [factory-l1-design] — L1 — Go-бинарь `factory` + `factory.yaml` manifest schema. См. `.wiki/concepts/factory-bootstrap.md` («Архитектура — три слоя» / L1). ## 🟡 [factory-l1-design] — L1 — Go-бинарь `factory` + `factory.yaml` manifest schema.
**Blocked by `factory-bootstrap-script`** — пока скрипт не закрыт и не работает на 2 платформах, в L1 идти нет смысла (риск спроектировать манифест под несуществующее L0). **Что сделано:**
-`factory.yaml` schema (components, deps, health-checks)
- ✅ Go project structure: cmd/factory, pkg/config/manifest/health
- ✅ CLI commands: install/update/status/diagnose (cobra)
- ⏳ Build отложен — нет go на текущей машине
- ⏳ Cross-compile скрипт для VM
**Что нужно сделать после анблока:** **Status:** paused (build на VM)
1. Финализировать ответы на Q2 (manifest location), Q4 (версионирование). Q3 (slash-command-bridge) и Q5 (secrets) — отдельные подзадачи, можно параллельно. **Next action:** Создать build.sh + build.ps1 для cross-compile на Linux VM
2. Спроектировать `factory.yaml` schema (черновик в дизайн-доке).
3. Поднять Go-проект в `~/projects/.factory/cmd/factory/`: `go-git/go-git`, `goccy/go-yaml`, `spf13/cobra`.
4. Команды: `factory install`, `factory update`, `factory status`, `factory diagnose <name>`.
5. Health-check как single source of truth идемпотентности.
6. Cross-compile в `dist/` для Win/Mac/Linux × amd64/arm64.
**Acceptance criteria:**
- `factory install` на чистой машине поднимает все 6+ компонентов без участия пользователя (после `claude login`).
- `factory status` показывает delta-таблицу: `name | version | health | deps`.
- Manifest нигде не хардкодит `~/projects/` — только `{projects_dir}` substitution.
**Status:** blocked
**Where I stopped:** (not started)
**Next action:** После закрытия `factory-bootstrap-script`: финализировать Q2/Q4, набросать factory.yaml schema, скаффолдить Go-проект.
**Blocker:** factory-bootstrap-script
**Branch:** n/a **Branch:** n/a
<!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .meeting-room / 2026-05-06T18:23:20.465Z --> <!-- created-by: OpeItcLoc03@DESKTOP-NSEF0UK / from: .meeting-room / 2026-05-06T18:23:20.465Z -->
--- ---
## ⚪ [factory-l1-vm-test] — Integration test на чистой VM.
**Что нужно:**
1. Поднять Linux VM (干净)
2. Установить go, clone .factory
3. `go build` + cross-compile (Win/Mac/Linux × amd64/arm64)
4. Прогнать `factory status` на текущей машине
5. Прогнать L0b bootstrap на новой VM
**Status:** ready
**Next action:** Запустить VM, создать build скрипты
**Branch:** n/a
---

View File

@@ -11,13 +11,73 @@
## Статус ## Статус
- Дизайн: `~/projects/.meeting-room/.brainstorm/factory-bootstrap.md` | Слой | Статус |
- L0a в разработке (field-test на новом Win11 ноуте, лог в `L0/install-log.md`) |------|--------|
- L0b — нет | L0a | ✅ field-test complete (install-log.md) |
- L1 — нет (Go-бинарь, после L0) | L0b | ✅ bootstrap.ps1 + bootstrap.sh созданы |
- `factory.yaml` — нет (черновик в design-буфере) | L1 | ⏳ Go-бинарь в разработке (structure done, build pending) |
| factory.yaml | ✅ manifest schema создан |
## Структура
```
.factory/
├── factory.yaml # Manifest компонентов
├── bootstrap.ps1 # L0b Windows bootstrap
├── bootstrap.sh # L0b Linux/Mac bootstrap
├── go.mod # Go module
├── cmd/
│ └── factory/
│ └── main.go # CLI entry point (cobra)
├── pkg/
│ ├── config/ # home.toml чтение
│ ├── manifest/ # factory.yaml парсинг
│ └── health/ # Health-check логика
├── L0/
│ └── install-log.md # Field-test лог
└── .wiki/
└── concepts/
└── factory-bootstrap.md
```
## Сборка L1
Требуется Go 1.23+:
```powershell
# Windows (pwsh)
cd C:\Users\vitya\projects\.factory
go build -o dist/factory.exe ./cmd/factory
# Linux/Mac
cd ~/projects/.factory
go build -o dist/factory ./cmd/factory
```
## Команды L1
```
factory install [component...] # Install components
factory update [component...] # Update installed
factory status # Show health table
factory diagnose <name> # Diagnose component
```
## Точки входа ## Точки входа
- Новый сотрудник на голой машине: запускает L0a (`bootstrap.ps1` / `bootstrap.sh`) → L0a кончает L0b → L0b кончает `factory install` (L1) → L1 проходит manifest → готово. - **Новый сотрудник на голой машине:**
- Существующая машина (миграция): `factory install` напрямую, L0a/b пропускаем (всё уже стоит). 1. `irm https://git.kzntsv.site/OpeItcLoc03/factory/raw/branch/master/bootstrap.ps1 | iex`
2. Ответить на вопросы (gitea URL, projects_dir, etc.)
3. `cd .factory && claude`
4. В Claude: `/login`, `/plugin install ...`, `/setup-*` ...
- **Существующая машина (миграция):**
1. `git clone ... .factory`
2. `factory install` напрямую
## TODO
- [ ] go-git clone/update имплементация
- [ ] Post-install shell script execution
- [ ] Cross-compile в dist/ (Win/Mac/Linux × amd64/arm64)
- [ ] Integration test на чистой VM

203
bootstrap.ps1 Normal file
View File

@@ -0,0 +1,203 @@
#!/usr/bin/env pwsh
# L0b Factory Bootstrap — Windows
# Auto-generated from install-log.md field-test 2026-05-06
$ErrorActionPreference = "Stop"
$PSVersionTable.PSVersion.Major -ge 7 -or (Write-Error "Bootstrap requires PowerShell 7+. Install: winget install Microsoft.PowerShell"; exit 1)
$GITEA_DEFAULT = "https://git.kzntsv.site"
$ORG_DEFAULT = "OpeItcLoc03"
$FACTORY_REPO_DEFAULT = "factory"
$HOME_TOML = "$env:USERPROFILE\.config\factory\home.toml"
$PROJECTS_DIR_DEFAULT = "C:\Users\$env:USERNAME\projects"
function Test-Command {
param([string]$Name)
$null = Get-Command $Name -ErrorAction SilentlyContinue
}
function Write-Step {
param([string]$Msg)
Write-Host "`n$Msg" -ForegroundColor Cyan
}
function Write-Ok {
param([string]$Msg)
Write-Host "$Msg" -ForegroundColor Green
}
function Write-Skip {
param([string]$Msg)
Write-Host "$Msg" -ForegroundColor Yellow
}
function Write-Gap {
param([string]$Msg)
Write-Host "$Msg" -ForegroundColor Red
}
# Pre-checks
Write-Step "L0b Pre-checks"
$missed = @()
if (-not (Test-Command git)) { $missed += "git (install: winget install Git.Git)" }
if (-not (Test-Command curl)) { $missed += "curl (install: winget install curl.curl)" }
if (-not (Test-Command mise)) { $missed += "mise (install: winget install jdx.mise)" }
if (-not (Test-Command claude)) { $missed += "claude (install: irm https://claude.ai/install.ps1 | iex)" }
if ($missed) {
Write-Gap "Missing dependencies:"
$missed | ForEach-Object { Write-Host " - $_" }
Write-Host "`nInstall missing tools and re-run bootstrap."
exit 1
}
Write-Ok "Pre-checks passed"
# Check if already bootstrapped
if (Test-Path $HOME_TOML) {
Write-Skip "Already bootstrapped. Re-run mode: verify only."
$toml = Get-Content $HOME_TOML -Raw
if ($toml -match 'projects_dir\s*=\s*''([^'']+)''') {
$existing_dir = $matches[1]
Write-Host " projects_dir: $existing_dir"
if (Test-Path "$existing_dir\.factory") {
Write-Ok ".factory exists"
} else {
Write-Gap ".factory missing at $existing_dir\.factory"
exit 1
}
}
Write-Ok "Bootstrap verified. Exiting (no-op)."
exit 0
}
# Interactive: ask for configuration
Write-Step "Configuration"
$gitea = Read-Host "Gitea URL [$GITEA_DEFAULT]"
$gitea = if ($gitea) { $gitea } else { $GITEA_DEFAULT }
$org = Read-Host "Gitea org/user [$ORG_DEFAULT]"
$org = if ($org) { $org } else { $ORG_DEFAULT }
$factoryRepo = Read-Host "Factory repo name [$FACTORY_REPO_DEFAULT]"
$factoryRepo = if ($factoryRepo) { $factoryRepo } else { $FACTORY_REPO_DEFAULT }
$projectsDir = Read-Host "Projects directory [$PROJECTS_DIR_DEFAULT]"
$projectsDir = if ($projectsDir) { $projectsDir } else { $PROJECTS_DIR_DEFAULT }
$client = Read-Host "AI client [claude-code/copilot-cli/gemini-cli] (default: claude-code)"
$client = if ($client) { $client } else { "claude-code" }
# Validate projects_dir
if (-not (Test-Path $projectsDir)) {
$create = Read-Host "Directory '$projectsDir' does not exist. Create? [Y/n]"
if ($create -ne "n") {
New-Item -ItemType Directory -Force -Path $projectsDir | Out-Null
Write-Ok "Created $projectsDir"
} else {
Write-Gap "Cannot continue without projects_dir"
exit 1
}
}
# Clone .factory
Write-Step "Cloning .factory repo"
$factoryLocal = "$projectsDir\.factory"
$factoryUrl = "$gitea/$org/$factoryRepo.git"
if (Test-Path $factoryLocal) {
Write-Skip ".factory already exists, skipping clone"
# Verify it's a git repo
if (-not (Test-Path "$factoryLocal\.git")) {
Write-Gap "$factoryLocal exists but is not a git repo. Remove it and re-run."
exit 1
}
} else {
Write-Host "Cloning from: $factoryUrl"
# Check if we have credentials for gitea
$needAuth = $false
try {
$null = git ls-remote "$factoryUrl" 2>&1
} catch {
$needAuth = $true
}
if ($needAuth) {
Write-Host "`n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
Write-Host " PAT REQUIRED" -ForegroundColor Yellow
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan
Write-Host "`n1. Open in browser: $gitea/user/settings/applications"
Write-Host "2. Generate PAT with scopes: read:repository, write:repository, read:user"
Write-Host "3. Press Enter when ready...`n"
Read-Host
Write-Host "Cloning with Git Credential Manager prompt..."
}
git clone "$factoryUrl" "$factoryLocal"
if (-not $?) {
Write-Gap "Clone failed. Check URL and credentials."
exit 1
}
Write-Ok "Cloned to $factoryLocal"
}
# Write home.toml
Write-Step "Writing ~/.config/factory/home.toml"
$configDir = "$env:USERPROFILE\.config\factory"
New-Item -ItemType Directory -Force -Path $configDir | Out-Null
$installedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
$lines = @(
"# factory home — single source of machine-level state",
"# auto-generated by L0b bootstrap; safe to edit by hand",
"",
"projects_dir = '$projectsDir'",
"client = '$client'",
"installed_at = '$installedAt'"
)
[System.IO.File]::WriteAllLines(
"$configDir\home.toml",
$lines,
[System.Text.UTF8Encoding]::new($false)
)
Write-Ok "Wrote $HOME_TOML"
# Print next steps
Write-Host "`n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Green
Write-Host " L0b COMPLETE" -ForegroundColor Green
Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Green
Write-Host "`nFactory bootstrapped. Next steps (manual):`n"
Write-Host "1. Start Claude Code:" -ForegroundColor Cyan
Write-Host " cd $factoryLocal"
Write-Host " claude`n"
Write-Host "2. In Claude, run:" -ForegroundColor Cyan
Write-Host " /login"
Write-Host " /plugin install superpowers@claude-plugins-official"
Write-Host " /plugin install context7@claude-plugins-official"
Write-Host " /reload-plugins"
Write-Host " /exit`n"
Write-Host "3. Run setup skills:" -ForegroundColor Cyan
Write-Host " cd $projectsDir"
Write-Host " claude"
Write-Host " /setup-projects-meta"
Write-Host " /setup-interns"
Write-Host " /setup-tasks"
Write-Host " /setup-wiki`n"
Write-Host "See .wiki/concepts/factory-bootstrap.md for full design."
Write-Host "`nRe-run this script anytime — it's idempotent."

182
bootstrap.sh Normal file
View File

@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# L0b Factory Bootstrap — Linux/macOS
# Auto-generated from install-log.md field-test 2026-05-06
set -euo pipefail
GITEA_DEFAULT="https://git.kzntsv.site"
ORG_DEFAULT="OpeItcLoc03"
FACTORY_REPO_DEFAULT="factory"
HOME_TOML="$HOME/.config/factory/home.toml"
PROJECTS_DIR_DEFAULT="$HOME/projects"
step() { echo -e "\n▶ $*"; }
ok() { echo "$*"; }
skip() { echo "$*"; }
gap() { echo "$*" >&2; }
check_cmd() {
command -v "$1" &>/dev/null
}
# Pre-checks
step "L0b Pre-checks"
missed=()
check_cmd git || missed+=("git (install: brew install git / apt install git)")
check_cmd curl || missed+=("curl (install: brew install curl / apt install curl)")
check_cmd mise || missed+=("mise (install: curl https://mise.run | bash)")
check_cmd claude || missed+=("claude (install: curl https://claude.ai/install.sh | bash)")
if [[ ${#missed[@]} -gt 0 ]]; then
gap "Missing dependencies:"
for m in "${missed[@]}"; do
echo " - $m"
done
echo ""
echo "Install missing tools and re-run bootstrap."
exit 1
fi
ok "Pre-checks passed"
# Check if already bootstrapped
if [[ -f "$HOME_TOML" ]]; then
skip "Already bootstrapped. Re-run mode: verify only."
if [[ $(grep -oP "projects_dir\s*=\s*'\K[^']+" "$HOME_TOML" 2>/dev/null) ]]; then
existing_dir=$(grep -oP "projects_dir\s*=\s*'\K[^']+" "$HOME_TOML")
echo " projects_dir: $existing_dir"
if [[ -d "$existing_dir/.factory" ]]; then
ok ".factory exists"
else
gap ".factory missing at $existing_dir/.factory"
exit 1
fi
fi
ok "Bootstrap verified. Exiting (no-op)."
exit 0
fi
# Interactive: ask for configuration
step "Configuration"
read -rp "Gitea URL [$GITEA_DEFAULT]: " gitea
gitea=${gitea:-$GITEA_DEFAULT}
read -rp "Gitea org/user [$ORG_DEFAULT]: " org
org=${org:-$ORG_DEFAULT}
read -rp "Factory repo name [$FACTORY_REPO_DEFAULT]: " factoryRepo
factoryRepo=${factoryRepo:-$FACTORY_REPO_DEFAULT}
read -rp "Projects directory [$PROJECTS_DIR_DEFAULT]: " projectsDir
projectsDir=${projectsDir:-$PROJECTS_DIR_DEFAULT}
read -rp "AI client [claude-code/copilot-cli/gemini-cli] (default: claude-code): " client
client=${client:-claude-code}
# Validate projects_dir
if [[ ! -d "$projectsDir" ]]; then
read -rp "Directory '$projectsDir' does not exist. Create? [Y/n] " create
if [[ "$create" != "n" && "$create" != "N" ]]; then
mkdir -p "$projectsDir"
ok "Created $projectsDir"
else
gap "Cannot continue without projects_dir"
exit 1
fi
fi
# Clone .factory
step "Cloning .factory repo"
factoryLocal="$projectsDir/.factory"
factoryUrl="$gitea/$org/$factoryRepo.git"
if [[ -d "$factoryLocal" ]]; then
skip ".factory already exists, skipping clone"
if [[ ! -d "$factoryLocal/.git" ]]; then
gap "$factoryLocal exists but is not a git repo. Remove it and re-run."
exit 1
fi
else
echo "Cloning from: $factoryUrl"
# Check if we have credentials for gitea
need_auth=false
if ! git ls-remote "$factoryUrl" &>/dev/null; then
need_auth=true
fi
if [[ "$need_auth" == true ]]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " PAT REQUIRED"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "1. Open in browser: $gitea/user/settings/applications"
echo "2. Generate PAT with scopes: read:repository, write:repository, read:user"
echo "3. Press Enter when ready..."
echo ""
read -r
echo "Cloning with Git Credential Manager prompt..."
fi
git clone "$factoryUrl" "$factoryLocal" || {
gap "Clone failed. Check URL and credentials."
exit 1
}
ok "Cloned to $factoryLocal"
fi
# Write home.toml
step "Writing ~/.config/factory/home.toml"
configDir="$HOME/.config/factory"
mkdir -p "$configDir"
installed_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
cat > "$configDir/home.toml" <<EOF
# factory home — single source of machine-level state
# auto-generated by L0b bootstrap; safe to edit by hand
projects_dir = '$projectsDir'
client = '$client'
installed_at = '$installed_at'
EOF
ok "Wrote $HOME_TOML"
# Print next steps
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " L0b COMPLETE"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Factory bootstrapped. Next steps (manual):"
echo ""
echo "1. Start Claude Code:"
echo " cd $factoryLocal"
echo " claude"
echo ""
echo "2. In Claude, run:"
echo " /login"
echo " /plugin install superpowers@claude-plugins-official"
echo " /plugin install context7@claude-plugins-official"
echo " /reload-plugins"
echo " /exit"
echo ""
echo "3. Run setup skills:"
echo " cd $projectsDir"
echo " claude"
echo " /setup-projects-meta"
echo " /setup-interns"
echo " /setup-tasks"
echo " /setup-wiki"
echo ""
echo "See .wiki/concepts/factory-bootstrap.md for full design."
echo "Re-run this script anytime — it's idempotent."

322
cmd/factory/main.go Normal file
View File

@@ -0,0 +1,322 @@
package main
import (
"fmt"
"os"
"sort"
"strings"
"github.com/OpeItcLoc03/factory/pkg/config"
"github.com/OpeItcLoc03/factory/pkg/health"
"github.com/OpeItcLoc03/factory/pkg/manifest"
"github.com/spf13/cobra"
)
var (
verbose bool
dryRun bool
)
var rootCmd = &cobra.Command{
Use: "factory",
Short: "Software factory installer/updater",
Long: `Factory — L1 orchestrator for software development infrastructure.`,
}
var installCmd = &cobra.Command{
Use: "install [component...]",
Short: "Install factory components",
Run: runInstall,
}
var updateCmd = &cobra.Command{
Use: "update [component...]",
Short: "Update installed components",
Run: runUpdate,
}
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show component health status",
Run: runStatus,
}
var diagnoseCmd = &cobra.Command{
Use: "diagnose [component]",
Short: "Run diagnostics on a component",
Args: cobra.ExactArgs(1),
Run: runDiagnose,
}
func init() {
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "show what would be done without doing it")
rootCmd.AddCommand(installCmd)
rootCmd.AddCommand(updateCmd)
rootCmd.AddCommand(statusCmd)
rootCmd.AddCommand(diagnoseCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
func loadManifest() (*manifest.Manifest, *config.HomeConfig, error) {
home, err := config.LoadHome()
if err != nil {
return nil, nil, err
}
vars := map[string]string{
"gitea": "", // from manifest
"projects_dir": home.ProjectsDir,
"factory": home.GetFactoryDir(),
"home": os.Getenv("HOME"),
"git_host": "",
"git_org": "",
}
m, err := manifest.Load(home.GetManifestPath(), vars)
if err != nil {
return nil, nil, err
}
// Add git host/org from manifest
vars["git_host"] = m.GitHost
vars["git_org"] = m.GitOrg
return m, home, nil
}
func runInstall(cmd *cobra.Command, args []string) {
m, home, err := loadManifest()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Determine components to install
toInstall := []string{}
if len(args) == 0 {
// Install all in dependency order
toInstall = m.GetDependencyOrder()
} else {
toInstall = args
}
// Filter by client
components := filterByClient(m, toInstall, home.Client)
fmt.Printf("Installing %d components for client '%s'...\n", len(components), home.Client)
for _, name := range components {
comp, ok := m.GetComponent(name)
if !ok {
fmt.Fprintf(os.Stderr, "Unknown component: %s\n", name)
continue
}
if comp.Optional && !contains(args, name) {
continue
}
installComponent(comp, home)
}
}
func runUpdate(cmd *cobra.Command, args []string) {
m, home, err := loadManifest()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
toUpdate := []string{}
if len(args) == 0 {
toUpdate = m.GetDependencyOrder()
} else {
toUpdate = args
}
components := filterByClient(m, toUpdate, home.Client)
fmt.Printf("Updating %d components...\n", len(components))
for _, name := range components {
comp, ok := m.GetComponent(name)
if !ok {
continue
}
updateComponent(comp, home)
}
}
func runStatus(cmd *cobra.Command, args []string) {
m, home, err := loadManifest()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Health check variables
vars := map[string]string{
"projects_dir": home.ProjectsDir,
"factory": home.GetFactoryDir(),
"home": os.Getenv("HOME"),
}
// Table header
fmt.Printf("\n%-25s %-10s %s\n", "Component", "Status", "Message")
fmt.Println(strings.Repeat("-", 80))
// Sort by name
names := []string{}
for _, c := range m.Components {
if c.IsApplicable(home.Client) {
names = append(names, c.Name)
}
}
sort.Strings(names)
for _, name := range names {
comp, _ := m.GetComponent(name)
result, _ := health.Check(comp, vars)
status := "✓"
if !result.Healthy {
status = "✗"
}
display := comp.Name
if comp.DisplayName != "" {
display = comp.DisplayName
}
msg := result.Message
if msg == "" {
msg = "OK"
}
fmt.Printf("%-25s %-10s %s\n", display, status, msg)
}
}
func runDiagnose(cmd *cobra.Command, args []string) {
name := args[0]
m, home, err := loadManifest()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
comp, ok := m.GetComponent(name)
if !ok {
fmt.Fprintf(os.Stderr, "Unknown component: %s\n", name)
os.Exit(1)
}
vars := map[string]string{
"projects_dir": home.ProjectsDir,
"factory": home.GetFactoryDir(),
"home": os.Getenv("HOME"),
}
result, err := health.Check(comp, vars)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking %s: %v\n", name, err)
os.Exit(1)
}
fmt.Printf("Component: %s\n", comp.Name)
fmt.Printf("Healthy: %v\n", result.Healthy)
if result.Message != "" {
fmt.Printf("Message: %s\n", result.Message)
}
// Print component details
fmt.Printf("\nDetails:\n")
fmt.Printf(" Target: %s\n", comp.Target)
if comp.Source != nil {
fmt.Printf(" Source: %s\n", comp.Source.URL)
}
if len(comp.Deps) > 0 {
fmt.Printf(" Dependencies: %s\n", strings.Join(comp.Deps, ", "))
}
}
func installComponent(comp *manifest.Component, home *config.HomeConfig) {
fmt.Printf("Installing %s...\n", comp.Name)
if dryRun {
fmt.Printf(" [dry-run] Would install %s\n", comp.Name)
return
}
// Clone or update
if comp.Source != nil {
cloneOrUpdate(comp, home)
}
// Run post-install
if comp.PostInstall != nil {
runPostInstall(comp, home)
}
// Print setup skill instruction
if comp.SetupSkill != "" {
fmt.Printf("\n Next: run /%s in Claude\n", comp.SetupSkill)
}
}
func updateComponent(comp *manifest.Component, home *config.HomeConfig) {
fmt.Printf("Updating %s...\n", comp.Name)
if dryRun {
fmt.Printf(" [dry-run] Would update %s\n", comp.Name)
return
}
if comp.Source != nil {
updateRepo(comp, home)
}
}
func cloneOrUpdate(comp *manifest.Component, home *config.HomeConfig) {
// Implementation using go-git
// For now: print instruction
fmt.Printf(" [TODO] Clone %s to %s\n", comp.Source.URL, comp.Target)
}
func updateRepo(comp *manifest.Component, home *config.HomeConfig) {
fmt.Printf(" [TODO] Update %s\n", comp.Target)
}
func runPostInstall(comp *manifest.Component, home *config.HomeConfig) {
// Implementation
fmt.Printf(" [TODO] Run post-install script\n")
}
func filterByClient(m *manifest.Manifest, names []string, client string) []string {
result := []string{}
for _, name := range names {
if comp, ok := m.GetComponent(name); ok {
if comp.IsApplicable(client) {
result = append(result, name)
}
}
}
return result
}
func contains(slice []string, s string) bool {
for _, item := range slice {
if item == s {
return true
}
}
return false
}

147
factory.yaml Normal file
View File

@@ -0,0 +1,147 @@
# factory.yaml — L1 manifest
# Variables: {gitea}, {projects_dir}, {factory}, {home}
# Substitution happens at runtime from ~/.config/factory/home.toml
version: "0.1.0"
# Git host configuration (overrides {gitea} default)
git_host: "https://git.kzntsv.site"
git_org: "OpeItcLoc03"
# Components to install/update
components:
# === Core infrastructure ===
- name: dot-common
display_name: ".common shared libs"
source:
type: git
url: "{git_host}/{git_org}/common"
branch: master
target: "{projects_dir}/.common"
health_check:
type: file_exists
path: "{target}/scripts/claude-switch.ps1"
clients: [claude-code, copilot-cli, gemini-cli]
- name: projects-meta-mcp
display_name: "Projects-meta MCP server"
source:
type: git
url: "{git_host}/{git_org}/projects-meta-mcp"
branch: main
target: "{projects_dir}/.common/lib/projects-meta-mcp"
deps: [dot-common]
setup_skill: setup-projects-meta
health_check:
type: mcp_tool
tool: mcp__projects-meta__meta_status
clients: [claude-code, copilot-cli]
- name: interns-mcp
display_name: "Interns MCP server"
source:
type: git
url: "{git_host}/{git_org}/interns-mcp"
branch: main
target: "{projects_dir}/.common/lib/interns-mcp"
deps: [dot-common]
setup_skill: setup-interns
health_check:
type: mcp_tool
tool: mcp__interns__bulk_text_read
clients: [claude-code, copilot-cli]
# === Knowledge ===
- name: dot-wiki
display_name: "Shared project wiki"
source:
type: git
url: "{git_host}/{git_org}/projects-wiki"
branch: main
target: "{projects_dir}/.wiki"
deps: [projects-meta-mcp]
setup_skill: setup-wiki
health_check:
type: file_exists
path: "{target}/CLAUDE.md"
clients: [claude-code, copilot-cli, gemini-cli]
- name: dot-tasks
display_name: "Local task board"
target: "{projects_dir}/.tasks"
deps: [dot-wiki]
setup_skill: setup-tasks
health_check:
type: file_exists
path: "{target}/STATUS.md"
clients: [claude-code, copilot-cli, gemini-cli]
# === Tooling ===
- name: claude-skills
display_name: "Claude skills collection"
source:
type: git
url: "{git_host}/{git_org}/claude-skills"
branch: master
target: "{projects_dir}/claude-skills"
deps: [dot-common]
post_install:
shell: pwsh
script: "{target}/scripts/install.ps1"
shell_alt: bash
script_alt: "{target}/scripts/install.sh"
health_check:
type: directory_exists
path: "{home}/.claude/skills"
clients: [claude-code]
# === Fixtures (optional) ===
- name: dot-organization
display_name: "Organization roster & personas"
source:
type: git
url: "{git_host}/{git_org}/.organization"
branch: master
target: "{projects_dir}/.organization"
optional: true
health_check:
type: file_exists
path: "{target}/roster.md"
clients: [claude-code, copilot-cli, gemini-cli]
- name: dot-templates
display_name: "Project templates"
source:
type: git
url: "{git_host}/{git_org}/.templates"
branch: master
target: "{projects_dir}/.templates"
optional: true
health_check:
type: file_exists
path: "{target}/project/.gitkeep"
clients: [claude-code, copilot-cli, gemini-cli]
- name: dot-meeting-room
display_name: "Meeting room / brainstorm archive"
source:
type: git
url: "{git_host}/{git_org}/.meeting-room"
branch: master
target: "{projects_dir}/.meeting-room"
optional: true
health_check:
type: file_exists
path: "{target}/.brainstorm/.gitkeep"
clients: [claude-code, copilot-cli, gemini-cli]
# Health check types:
# file_exists: checks if file exists at {path}
# directory_exists: checks if directory exists at {path}
# mcp_tool: checks if MCP tool is available (via ~/.claude.json)
# command: runs command and checks exit code 0
# custom: runs custom health check script

36
go.mod Normal file
View File

@@ -0,0 +1,36 @@
module github.com/OpeItcLoc03/factory
go 1.23
require (
github.com/goccy/go-yaml v1.13.0
github.com/spf13/cobra v1.8.1
github.com/go-git/go-git v5.12.0
)
require (
dario.cat/mergo v1.0.0
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.0.0 // indirect
github.com/cloudflare/circl v1.3.7 // indirect
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect
github.com/golang/groupcache/lru v1.0.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yuin/goldmark v1.7.1 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/mod v0.14.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/tools v0.17.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)

57
pkg/config/home.go Normal file
View File

@@ -0,0 +1,57 @@
package config
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/BurntSushi/toml"
)
type HomeConfig struct {
ProjectsDir string `toml:"projects_dir"`
Client string `toml:"client"`
InstalledAt time.Time `toml:"installed_at"`
}
func GetHomePath() string {
homeDir, _ := os.UserHomeDir()
return filepath.Join(homeDir, ".config", "factory", "home.toml")
}
func LoadHome() (*HomeConfig, error) {
path := GetHomePath()
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("home.toml not found at %s (run L0b bootstrap first)", path)
}
var cfg HomeConfig
if err := toml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse home.toml: %w", err)
}
// Validate
if cfg.ProjectsDir == "" {
return nil, fmt.Errorf("projects_dir is required in home.toml")
}
if cfg.Client == "" {
cfg.Client = "claude-code"
}
return &cfg, nil
}
func (h *HomeConfig) GetFactoryDir() string {
return filepath.Join(h.ProjectsDir, ".factory")
}
func (h *HomeConfig) GetManifestPath() string {
return filepath.Join(h.GetFactoryDir(), "factory.yaml")
}
func (h *HomeConfig) GetCommonDir() string {
return filepath.Join(h.ProjectsDir, ".common")
}

131
pkg/health/health.go Normal file
View File

@@ -0,0 +1,131 @@
package health
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/OpeItcLoc03/factory/pkg/manifest"
)
type Result struct {
Name string `json:"name"`
Healthy bool `json:"healthy"`
Version string `json:"version,omitempty"`
Message string `json:"message,omitempty"`
}
func Check(comp *manifest.Component, vars map[string]string) (*Result, error) {
if comp.HealthCheck == nil {
return &Result{
Name: comp.Name,
Healthy: true,
Message: "no health check defined",
}, nil
}
// Substitute variables in path
path := comp.HealthCheck.Path
for k, v := range vars {
path = filepath.Clean(strings.ReplaceAll(path, "{"+k+"}", v))
}
switch comp.HealthCheck.Type {
case "file_exists":
return checkFileExists(comp.Name, path)
case "directory_exists":
return checkDirExists(comp.Name, path)
case "mcp_tool":
return checkMCPTool(comp.Name, comp.HealthCheck.Tool)
case "command":
return checkCommand(comp.Name, comp.HealthCheck.Command)
default:
return &Result{
Name: comp.Name,
Healthy: false,
Message: fmt.Sprintf("unknown health check type: %s", comp.HealthCheck.Type),
}, nil
}
}
func checkFileExists(name, path string) (*Result, error) {
if _, err := os.Stat(path); err == nil {
return &Result{Name: name, Healthy: true}, nil
}
return &Result{
Name: name,
Healthy: false,
Message: fmt.Sprintf("file not found: %s", path),
}, nil
}
func checkDirExists(name, path string) (*Result, error) {
if info, err := os.Stat(path); err == nil && info.IsDir() {
return &Result{Name: name, Healthy: true}, nil
}
return &Result{
Name: name,
Healthy: false,
Message: fmt.Sprintf("directory not found: %s", path),
}, nil
}
func checkMCPTool(name, tool string) (*Result, error) {
// Check if tool is available in ~/.claude.json
home, _ := os.UserHomeDir()
configPath := filepath.Join(home, ".claude.json")
data, err := os.ReadFile(configPath)
if err != nil {
return &Result{
Name: name,
Healthy: false,
Message: "claude.json not found",
}, nil
}
// Simple string check (proper parsing would be better)
toolKey := `"` + tool + `"`
if strings.Contains(string(data), toolKey) {
return &Result{Name: name, Healthy: true}, nil
}
return &Result{
Name: name,
Healthy: false,
Message: fmt.Sprintf("MCP tool not found: %s", tool),
}, nil
}
func checkCommand(name, cmd string) (*Result, error) {
var shell string
var args []string
switch runtime.GOOS {
case "windows":
shell = "pwsh"
args = []string{"-Command", cmd}
default:
shell = "bash"
args = []string{"-c", cmd}
}
execCmd := exec.Command(shell, args...)
output, err := execCmd.CombinedOutput()
if err != nil {
return &Result{
Name: name,
Healthy: false,
Message: fmt.Sprintf("command failed: %s", output),
}, nil
}
return &Result{
Name: name,
Healthy: true,
Message: strings.TrimSpace(string(output)),
}, nil
}

123
pkg/manifest/manifest.go Normal file
View File

@@ -0,0 +1,123 @@
package manifest
import (
"fmt"
"os"
"strings"
"github.com/goccy/go-yaml"
)
type GitSource struct {
Type string `yaml:"type"`
URL string `yaml:"url"`
Branch string `yaml:"branch"`
}
type PostInstall struct {
Shell string `yaml:"shell"`
Script string `yaml:"script"`
ShellAlt string `yaml:"shell_alt"`
ScriptAlt string `yaml:"script_alt"`
}
type HealthCheck struct {
Type string `yaml:"type"`
Path string `yaml:"path"`
Tool string `yaml:"tool"`
Command string `yaml:"command"`
}
type Component struct {
Name string `yaml:"name"`
DisplayName string `yaml:"display_name"`
Source *GitSource `yaml:"source"`
Target string `yaml:"target"`
Deps []string `yaml:"deps"`
SetupSkill string `yaml:"setup_skill"`
PostInstall *PostInstall `yaml:"post_install"`
HealthCheck *HealthCheck `yaml:"health_check"`
Optional bool `yaml:"optional"`
Clients []string `yaml:"clients"`
}
type Manifest struct {
Version string `yaml:"version"`
GitHost string `yaml:"git_host"`
GitOrg string `yaml:"git_org"`
Components []Component `yaml:"components"`
}
func Load(path string, vars map[string]string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read manifest: %w", err)
}
// Substitute variables
content := string(data)
for k, v := range vars {
content = strings.ReplaceAll(content, "{"+k+"}", v)
}
var m Manifest
if err := yaml.Unmarshal([]byte(content), &m); err != nil {
return nil, fmt.Errorf("failed to parse manifest: %w", err)
}
// Set defaults
if m.GitHost == "" {
m.GitHost = "https://github.com"
}
return &m, nil
}
func (m *Manifest) GetComponent(name string) (*Component, bool) {
for i := range m.Components {
if m.Components[i].Name == name {
return &m.Components[i], true
}
}
return nil, false
}
func (m *Manifest) GetDependencyOrder() []string {
// Topological sort for component installation order
visited := make(map[string]bool)
order := []string{}
var visit func(string)
visit = func(name string) {
if visited[name] {
return
}
visited[name] = true
c, ok := m.GetComponent(name)
if ok {
for _, dep := range c.Deps {
visit(dep)
}
}
order = append(order, name)
}
for _, c := range m.Components {
visit(c.Name)
}
return order
}
func (c *Component) IsApplicable(client string) bool {
if len(c.Clients) == 0 {
return true
}
for _, cl := range c.Clients {
if cl == client {
return true
}
}
return false
}

38
scripts/build.ps1 Normal file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env pwsh
# Cross-compile factory for all platforms (Windows-friendly)
$ErrorActionPreference = "Stop"
$VERSION = if ($env:VERSION) { $env:VERSION } else { "0.1.0" }
$DIST_DIR = "dist"
Push-Location (Split-Path $PSScriptRoot)
Write-Host "Building factory v$VERSION..."
New-Item -ItemType Directory -Force -Path $DIST_DIR | Out-Null
$Targets = @(
@{ Name = "linux-amd64"; GOOS = "linux"; GOARCH = "amd64" }
@{ Name = "linux-arm64"; GOOS = "linux"; GOARCH = "arm64" }
@{ Name = "windows-amd64"; GOOS = "windows"; GOARCH = "amd64" }
@{ Name = "windows-arm64"; GOOS = "windows"; GOARCH = "arm64" }
@{ Name = "darwin-amd64"; GOOS = "darwin"; GOARCH = "amd64" }
@{ Name = "darwin-arm64"; GOOS = "darwin"; GOARCH = "arm64" }
)
foreach ($t in $Targets) {
$output = "$DIST_DIR/factory-$($t.GOOS)-$($t.GOARCH)"
if ($t.GOOS -eq "windows") { $output += ".exe" }
Write-Host "$($t.Name)"
$env:GOOS = $t.GOOS
$env:GOARCH = $t.GOARCH
$env:CGO_ENABLED = "0"
go build -ldflags="-s -w -X main.Version=$VERSION" -o $output ./cmd/factory
}
Pop-Location
Write-Host "`n✓ Built to $DIST_DIR/"
Get-ChildItem $DIST_DIR | Format-Table Name, Length

41
scripts/build.sh Normal file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Cross-compile factory for all platforms
set -euo pipefail
cd "$(dirname "$0")/.."
VERSION=${VERSION:-"0.1.0"}
DIST_DIR="dist"
echo "Building factory v$VERSION..."
mkdir -p "$DIST_DIR"
# Cross-compile targets
declare -A TARGETS=(
["linux-amd64"]="linux/amd64"
["linux-arm64"]="linux/arm64"
["windows-amd64"]="windows/amd64"
["windows-arm64"]="windows/arm64"
["darwin-amd64"]="darwin/amd64"
["darwin-arm64"]="darwin/arm64"
)
for name in "${!TARGETS[@]}"; do
goos="${name%%-*}"
goarch="${name##*-}"
platform="${TARGETS[$name]}"
output="$DIST_DIR/factory-$goos-$goarch"
[[ "$goos" == "windows" ]] && output="$output.exe"
echo "$name ($platform)"
GOOS="$goos" GOARCH="$goarch" \
CGO_ENABLED=0 \
go build -ldflags="-s -w -X main.Version=$VERSION" \
-o "$output" ./cmd/factory
done
echo ""
echo "✓ Built to $DIST_DIR/"
ls -lh "$DIST_DIR/"