siesa-agents 2.1.81 → 2.1.82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ ---
2
+ name: sa-init-devops
3
+ description: 'Bootstrap the DevOps workspace and skills in Siesa-Agents in one shot: clones architecture-sa-devops into `_siesa-agents/devops/`, detects conflicts between local and upstream copies of the `sa-*` DevOps skills and the deploy workflows (asks the user via AskUserQuestion which side to keep when there are diffs), then MOVES the chosen versions into `.claude/skills/` (and `claude/skills/`) so `/sa-aplicar`, `/sa-nuevo-servicio`, `/sa-auditar-servicio`, etc. become immediately invocable, MOVES the workflows into `.github/workflows/`, deletes the now-empty `.claude/` and `.github/` directories from the clone, then mirrors the cleaned tree to `siesa-agents/devops/`. Use whenever the user wants to set up the DevOps workspace for the first time, refresh it after upstream changes, mentions needing terraform/k8s/environments files, or when any `sa-*` deploy skill is missing from Claude Code.'
4
+ ---
5
+
6
+ # Init DevOps — clone the workspace + materialize the DevOps skills
7
+
8
+ **Goal:** In a single invocation, populate Siesa-Agents with everything an engineer needs to run the Finance DevOps skills. After a successful run the layout is:
9
+
10
+ | Location | Contents |
11
+ |---|---|
12
+ | `_siesa-agents/devops/` | Git working tree cloned from `architecture-sa-devops`. **Only** the deployment workspace: `terraform/`, `k8s/`, `environments/`, `scripts/`, `cicd-templates/`, `docs/`, `CLAUDE.md`, `README.md`, `.gitignore`. The `.claude/` and `.github/` directories from upstream are wiped from the clone after their contents are moved to Siesa-Agents level. |
13
+ | `siesa-agents/devops/` | Flat snapshot mirror of the cleaned clone (no `.git/`, no `.claude/`, no `.github/`). The npm-publishable copy. |
14
+ | `.claude/skills/sa-*/SKILL.md` | The 8 DevOps skills moved out of the clone: `sa-aplicar`, `sa-auditar-servicio`, `sa-nueva-transversal`, `sa-nuevo-ambiente`, `sa-nuevo-servicio`, `sa-onboard-db`, `sa-registrar-permisos`, `sa-agent-sre-sentinel`. |
15
+ | `claude/skills/sa-*/SKILL.md` | Same skills, in the npm mirror. |
16
+ | `.github/workflows/*.yml` | The 4 deploy workflow files moved out of the clone (`infra-pipeline-dev/qa/shared.yml`, `reconcile-geographic-projections.yml`). **These do not fire from Siesa-Agents** — gitignored, for visibility only. They fire from `architecture-sa-devops` where they originally live. |
17
+
18
+ **Why this matters:** Without this skill, Siesa-Agents is missing every Finance DevOps capability. The DevOps skills cannot live statically in this repo because they evolve with the deploy workspace (`environments/`, `terraform/`, `k8s/`) — keeping them in sync would require a PR every time Finance bumps the workspace. Instead the upstream `architecture-sa-devops` repo is the single source of truth, and this skill pulls everything down on demand.
19
+
20
+ ## When to use this skill
21
+
22
+ Trigger immediately when the user:
23
+
24
+ - Says "init devops", "setup devops", "clone architecture-sa-devops", "trae el workspace de despliegue", or similar
25
+ - Tries to invoke any deploy skill (`/sa-aplicar`, `/sa-nuevo-servicio`, etc.) and Claude Code reports the skill is not available
26
+ - Tries to invoke a deploy skill and it complains about missing paths (`environments/shared.yaml`, `terraform/environments/`, `k8s/overlays/`, etc.)
27
+ - Mentions wanting to refresh / pull latest from `architecture-sa-devops`
28
+ - Has just cloned Siesa-Agents and is preparing to use the deploy skills
29
+
30
+ Do **not** trigger for: editing the deploy skills themselves (those edits must go to `architecture-sa-devops`), modifying terraform/k8s contents (those go to `architecture-sa-devops` too), or anything unrelated to bootstrapping.
31
+
32
+ ## Prerequisites
33
+
34
+ - `git` is on the PATH (`git --version` works).
35
+ - The user has network access to GitHub.com.
36
+ - The helper script lives at `_siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js` (source of truth) and is mirrored to `siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js` (npm copy). Prefer the source-of-truth path when both exist.
37
+
38
+ If the script is missing or `git` is unavailable, stop and tell the user — do not improvise the work inline. The script handles all the edge cases (existing clone with wrong remote, partial state, conflicting local edits, skill/workflow copy overwrites) that an inline shell sequence would miss.
39
+
40
+ ## Workflow
41
+
42
+ Two-phase orchestration. The helper does an initial conflict-detection pass; if any local file differs from upstream, the helper aborts and the model asks the user how to resolve, then re-runs with a policy flag.
43
+
44
+ ### Step 1 — Initial run
45
+
46
+ From the project root (or any subdirectory of it — the script walks up to find both `_siesa-agents/` and `siesa-agents/`):
47
+
48
+ ```bash
49
+ node _siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js
50
+ ```
51
+
52
+ Default mode does:
53
+
54
+ 1. **Clone or update.** `git clone` (if `_siesa-agents/devops/` does not exist) **or** `git checkout HEAD -- .claude .github && git fetch && git checkout main && git pull --ff-only` (if it does). The `git checkout HEAD -- .claude .github` step restores files that the previous run wiped so the pull doesn't fail on "uncommitted deletions".
55
+ 2. **Dry-run conflict detection.** For each upstream `.claude/skills/sa-*/SKILL.md` and `.github/workflows/*.yml`, compare to the local copy in Siesa-Agents (`.claude/skills/`, `claude/skills/`, and `.github/workflows/`). The bootstrap skill `sa-init-devops` is always excluded from this comparison.
56
+ 3. **If any local file differs from upstream → abort.** Exit 4 with `status: "conflicts"` and lists `skill_conflicts` / `workflow_conflicts`. Nothing is mutated. The model proceeds to Step 2.
57
+ 4. **If no conflicts → apply.** Copy the upstream files into Siesa-Agents (creating new files, leaving identical ones alone), then delete `.claude/` and `.github/` from the clone working tree, then mirror the cleaned tree to `siesa-agents/devops/`. Exit 0.
58
+
59
+ ### Step 2 — Handle conflicts (only when `status: "conflicts"`)
60
+
61
+ When the JSON output has `status: "conflicts"`, **ask the user which side to keep** before any local file is changed. Use `AskUserQuestion` with the list of conflicting items from `skill_conflicts` and `workflow_conflicts`.
62
+
63
+ For most cases, one question with three options is enough:
64
+
65
+ ```
66
+ question: "Estos N archivos locales difieren del upstream architecture-sa-devops:
67
+ <list with skill_conflicts + workflow_conflicts>
68
+ ¿Cómo resuelvo?"
69
+ options:
70
+ - "Take all from upstream" (overwrite all conflicts with upstream version)
71
+ - "Keep all local versions" (preserve every local edit)
72
+ - "Resolve per file" (only if user wants per-item granularity)
73
+ ```
74
+
75
+ If the user picks **"Take all from upstream"**, re-run with `--take-upstream`:
76
+
77
+ ```bash
78
+ node _siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js --take-upstream
79
+ ```
80
+
81
+ If the user picks **"Keep all local versions"**, re-run with `--keep-local`:
82
+
83
+ ```bash
84
+ node _siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js --keep-local
85
+ ```
86
+
87
+ If the user picks **"Resolve per file"**, ask one more question per conflicting item (with `AskUserQuestion`, max 4 items per call — chunk if needed) collecting which ones to keep local, then re-run with `--keep-local=<csv>`:
88
+
89
+ ```bash
90
+ node _siesa-agents/sa/sa-init-devops/scripts/sa-init-devops.js --keep-local=sa-aplicar,infra-pipeline-qa.yml
91
+ ```
92
+
93
+ The named items keep their local version; everything else takes upstream.
94
+
95
+ ### Step 3 — Interpret the final JSON status
96
+
97
+ After the (possibly second) successful invocation, read the `status` field:
98
+
99
+ - **`status: "cloned"`** — Fresh clone. Tell the user: "Cloned `architecture-sa-devops`. Created `<N>` skills + `<M>` workflows in Siesa-Agents. The deploy skills (`/sa-aplicar`, `/sa-nuevo-servicio`, etc.) are now available in Claude Code."
100
+ - **`status: "updated"`** — Existing clone pulled new commits. Report `before_sha..after_sha` and the per-category counts (`skills_created`, `skills_overwritten`, `skills_kept_local`, `skills_unchanged`, same for workflows).
101
+ - **`status: "already-cloned"`** — Local clone was already at the latest HEAD; no upstream change. The counts reflect what happened in the apply step (likely all `unchanged` if everything matched).
102
+ - **`status: "conflicts"`** — Should only appear on the FIRST run when conflicts exist; never on a re-run with a policy flag. If you see this twice, surface it as a bug.
103
+ - **`status: "invalid-source"`** — `_siesa-agents/devops/` exists but is not a git working tree. Surface the message verbatim and stop. Do not try to delete the directory automatically.
104
+ - **`status: "wrong-remote"`** — Same directory is a clone of a different repo. Surface the message and stop.
105
+ - **`status: "clone-failed"` / `"fetch-failed"` / `"pull-failed"` / `"checkout-failed"`** — Git operation failed. Surface the underlying git error verbatim. Common causes: no network, branch diverged, local edits blocking ff-pull.
106
+
107
+ ### Step 3 — Remind the user about cwd for the deploy skills
108
+
109
+ After a successful run (any of `cloned`, `updated`, `already-cloned`), remind the user once:
110
+
111
+ > Las skills de despliegue (`/sa-aplicar`, `/sa-nuevo-servicio`, etc.) asumen que el cwd está dentro de `_siesa-agents/devops/`. Cambia de directorio (`cd _siesa-agents/devops/`) antes de invocarlas, para que las rutas relativas (`environments/`, `terraform/`, `k8s/`) se resuelvan correctamente.
112
+
113
+ If the user invokes one of those skills from elsewhere, the relative paths in their workflows won't resolve and they'll see confusing "file not found" errors.
114
+
115
+ ## Optional flags
116
+
117
+ - `--check` — Read-only. Clones/pulls if needed, then reports current state plus a dry-run conflict list. Useful to preview what a real run would surface.
118
+ - `--mirror-only` — Skip the git + move steps; rebuild `siesa-agents/devops/` from the existing clone. Useful when only the mirror is out of sync.
119
+ - `--take-upstream` — Resolve all conflicts in favor of upstream (overwrite every conflicting local file). Use after the user confirms they want the upstream versions.
120
+ - `--keep-local` — Keep every local file as-is; only create files that are entirely new in upstream. Use when the user wants to preserve all local edits.
121
+ - `--keep-local=<csv>` — Keep these specific items local (comma-separated names matching `skill_conflicts` / `workflow_conflicts` entries); overwrite all other conflicts with upstream. Example: `--keep-local=sa-aplicar,infra-pipeline-qa.yml`.
122
+
123
+ The three policy flags are mutually exclusive; passing more than one returns exit code 1.
124
+
125
+ ## Edge cases
126
+
127
+ - **Skill conflicts.** The default run aborts on any conflict (local file differs from upstream) and lists them in `skill_conflicts` / `workflow_conflicts`. The model then asks the user with `AskUserQuestion` and re-runs with `--take-upstream`, `--keep-local`, or `--keep-local=<csv>`. Engineers who want their custom edits to survive automatic refresh should fork `architecture-sa-devops` and update `REMOTE_URL` at the top of the helper script — that keeps their fork as the upstream source of truth.
128
+ - **The bootstrap skill is never overwritten.** `sa-init-devops/` is explicitly excluded from the copy loop in the helper, so re-running the skill cannot trash itself.
129
+ - **The clone has 12 "deleted" files in `git status` after every run.** Expected. The script moves `.claude/skills/sa-*/SKILL.md` and `.github/workflows/*.yml` out of the clone and deletes those directories, but the files are still tracked in upstream HEAD. The next run restores them via `git checkout HEAD -- .claude .github` before pulling, then moves and deletes them again. Engineers should never `git add` or `git commit` those deletions inside `_siesa-agents/devops/` — they are intentional working-tree-only state.
130
+ - **Workflows in Siesa-Agents `.github/workflows/`** — moved for inspection but gitignored (see `.gitignore`), so engineers can read them locally but they never fire on Siesa-Agents PRs. The same workflows fire on PRs / pushes to `architecture-sa-devops`, which is where they live.
131
+ - **`_siesa-agents/devops/` exists from a previous run with local edits to non-managed paths.** The restore step touches only `.claude` and `.github`; edits to `environments/`, `terraform/`, `k8s/`, etc. survive the restore step but `git pull --ff-only` will fail if they conflict with upstream. The script surfaces the git error verbatim. The user should commit, stash, or discard their local changes and re-run.
132
+ - **The directory exists but isn't a clone of `architecture-sa-devops`.** The script reports `invalid-source` or `wrong-remote` and refuses to delete. The user must move it aside manually.
133
+ - **The mirror or local skills/workflows have manual edits.** The script overwrites them on every run — that is by design. If you need to preserve edits, make them in the upstream `architecture-sa-devops` repo, not locally.
134
+ - **No `git` on PATH.** The first `tryGit` call fails; the script surfaces a `clone-failed` or `fetch-failed` status. Tell the user to install git or add it to PATH.
135
+
136
+ ## Upstream URL
137
+
138
+ The script currently points at `https://github.com/ssancheze912/architecture-sa-devops.git` as a temporary location while SiesaTeams org-create permissions are being arranged. When the repo is transferred to `https://github.com/SiesaTeams/architecture-sa-devops.git`, update `REMOTE_URL` at the top of the helper script and ship the change.
139
+
140
+ ## Reminders
141
+
142
+ - Step 1 is always safe to re-run. Idempotency is built into the script — on `already-cloned` it does nothing destructive other than refresh the mirror and re-copy the skills/workflows from upstream (which is the intended behavior — keeping local in sync with upstream).
143
+ - Never delete `_siesa-agents/devops/` from this skill. The user owns that directory once cloned; they may have in-progress edits.
144
+ - Do not modify the DevOps skills themselves from this skill. If a deploy skill's behavior needs to change, that is a PR to `architecture-sa-devops`, not a local edit (which would be wiped on the next run).
145
+ - The script's JSON output is the contract. If a future field appears (e.g. a new status), surface it to the user rather than guessing what it means.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siesa-agents",
3
- "version": "2.1.81",
3
+ "version": "2.1.82",
4
4
  "description": "Paquete para instalar y configurar agentes SIESA en tu proyecto",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -0,0 +1,524 @@
1
+ #!/usr/bin/env node
2
+ // sa-init-devops.js — Bootstrap the DevOps workspace and skills in Siesa-Agents.
3
+ //
4
+ // In one invocation the script:
5
+ // 1. Clones (or pulls) architecture-sa-devops into `_siesa-agents/devops/`.
6
+ // 2. Detects conflicts: for each upstream `.claude/skills/sa-*/SKILL.md` and
7
+ // `.github/workflows/*.yml`, compares to the local copy in Siesa-Agents. If any local
8
+ // file differs from upstream and no policy flag was passed, aborts with `status:
9
+ // "conflicts"` and lists the conflicting items so the caller can ask the user how to
10
+ // resolve them.
11
+ // 3. If there are no conflicts, or the caller passed `--take-upstream` / `--keep-local`
12
+ // / `--keep-local=<csv>`, moves the upstream files into Siesa-Agents according to the
13
+ // policy and deletes the `.claude/` and `.github/` directories from the clone.
14
+ // 4. Mirrors the cleaned working tree to `siesa-agents/devops/`.
15
+ //
16
+ // Conflict policy flags (mutually exclusive):
17
+ // --take-upstream Overwrite all conflicting local files with the upstream copy.
18
+ // --keep-local Keep every local file as-is. Only create files that are
19
+ // entirely new in upstream.
20
+ // --keep-local=<csv> Keep these specific files (comma-separated names) local;
21
+ // overwrite the rest. Example:
22
+ // --keep-local=sa-aplicar,infra-pipeline-qa.yml
23
+ //
24
+ // Idempotency: between runs the clone's `.claude/` and `.github/` directories are gone from
25
+ // the working tree but still tracked in upstream HEAD. The script restores those paths
26
+ // (`git checkout HEAD -- .claude .github`) before the pull so it doesn't fail on
27
+ // "uncommitted deletions". The move-and-delete cycle then repeats.
28
+ //
29
+ // Modes:
30
+ // <no args> or --clone Default. Clone or update, detect conflicts, abort if any,
31
+ // otherwise move + delete + mirror.
32
+ // --check Read-only. Report the current state without mutating.
33
+ // --mirror-only Skip the git + move steps; rebuild the mirror from the
34
+ // current clone.
35
+ //
36
+ // Exit codes:
37
+ // 0 Success
38
+ // 1 Argument or usage error
39
+ // 2 No-op (--check on a clean state)
40
+ // 3 Git or filesystem mutation failed
41
+ // 4 Conflicts detected; the caller should ask the user and re-run with a policy flag.
42
+
43
+ 'use strict'
44
+
45
+ const fs = require('fs')
46
+ const path = require('path')
47
+ const { spawnSync } = require('child_process')
48
+
49
+ // TODO(siesa-admin): change to https://github.com/SiesaTeams/architecture-sa-devops.git
50
+ // once the repo is transferred to the SiesaTeams organization.
51
+ const REMOTE_URL = 'https://github.com/ssancheze912/architecture-sa-devops.git'
52
+ const REMOTE_NAME = 'origin'
53
+ const DEFAULT_BRANCH = 'main'
54
+
55
+ function parseArgs(argv) {
56
+ const out = { _keepLocalList: null }
57
+ for (const a of argv) {
58
+ if (!a.startsWith('--')) continue
59
+ const eq = a.indexOf('=')
60
+ if (eq === -1) {
61
+ out[a.slice(2)] = true
62
+ } else {
63
+ const key = a.slice(2, eq)
64
+ const val = a.slice(eq + 1)
65
+ out[key] = val
66
+ if (key === 'keep-local') {
67
+ out._keepLocalList = val.split(',').map(s => s.trim()).filter(Boolean)
68
+ }
69
+ }
70
+ }
71
+ return out
72
+ }
73
+
74
+ function emit(obj) {
75
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n')
76
+ }
77
+
78
+ function findProjectRoot(startDir) {
79
+ let cur = path.resolve(startDir)
80
+ for (let i = 0; i < 40; i++) {
81
+ if (fs.existsSync(path.join(cur, '_siesa-agents')) && fs.existsSync(path.join(cur, 'siesa-agents'))) {
82
+ return cur
83
+ }
84
+ const parent = path.dirname(cur)
85
+ if (parent === cur) return null
86
+ cur = parent
87
+ }
88
+ return null
89
+ }
90
+
91
+ function tryGit(args, cwd) {
92
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
93
+ return {
94
+ ok: r.status === 0,
95
+ code: r.status,
96
+ stdout: (r.stdout || '').trim(),
97
+ stderr: (r.stderr || '').trim(),
98
+ }
99
+ }
100
+
101
+ function isGitWorkingTree(dir) {
102
+ if (!fs.existsSync(path.join(dir, '.git'))) return false
103
+ const r = tryGit(['rev-parse', '--show-toplevel'], dir)
104
+ return r.ok && path.resolve(r.stdout) === path.resolve(dir)
105
+ }
106
+
107
+ function currentRemoteUrl(dir) {
108
+ const r = tryGit(['remote', 'get-url', REMOTE_NAME], dir)
109
+ return r.ok ? r.stdout : null
110
+ }
111
+
112
+ function rmDirSafe(dir) {
113
+ fs.rmSync(dir, { recursive: true, force: true })
114
+ }
115
+
116
+ function copyTreeWithoutGit(srcDir, destDir) {
117
+ fs.mkdirSync(destDir, { recursive: true })
118
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true })
119
+ for (const ent of entries) {
120
+ if (ent.name === '.git') continue
121
+ const srcPath = path.join(srcDir, ent.name)
122
+ const destPath = path.join(destDir, ent.name)
123
+ if (ent.isDirectory()) {
124
+ copyTreeWithoutGit(srcPath, destPath)
125
+ } else if (ent.isFile()) {
126
+ fs.copyFileSync(srcPath, destPath)
127
+ } else if (ent.isSymbolicLink()) {
128
+ const target = fs.readlinkSync(srcPath)
129
+ try { fs.symlinkSync(target, destPath) } catch (_) { /* Windows may need elevated perms */ }
130
+ }
131
+ }
132
+ }
133
+
134
+ // Compare two files for equality, normalizing CRLF→LF so Windows line-ending quirks don't
135
+ // create spurious conflicts. Returns true if both files have the same logical content.
136
+ function filesEqual(a, b) {
137
+ if (!fs.existsSync(a) || !fs.existsSync(b)) return false
138
+ try {
139
+ const ba = fs.readFileSync(a).toString('utf8').replace(/\r\n/g, '\n')
140
+ const bb = fs.readFileSync(b).toString('utf8').replace(/\r\n/g, '\n')
141
+ return ba === bb
142
+ } catch (_) {
143
+ return false
144
+ }
145
+ }
146
+
147
+ function refreshMirror(sourceDir, mirrorDir, status) {
148
+ const hadMirror = fs.existsSync(mirrorDir)
149
+ if (hadMirror) rmDirSafe(mirrorDir)
150
+ copyTreeWithoutGit(sourceDir, mirrorDir)
151
+ status.mirror_refreshed = true
152
+ status.mirror_dir = mirrorDir
153
+ status.mirror_existed_before = hadMirror
154
+ }
155
+
156
+ // Decide what to do with a single upstream item given the policy flags and the local state.
157
+ // Returns one of: 'create' (no local copy yet), 'identical' (already matches upstream),
158
+ // 'conflict' (differs and no policy says how to resolve), 'overwrite' (differs but policy
159
+ // resolves it as upstream-wins), 'keep-local' (differs but policy says keep local).
160
+ function decideAction(name, upstreamPath, localPaths, policy) {
161
+ const anyLocalExists = localPaths.some(p => fs.existsSync(p))
162
+ if (!anyLocalExists) return 'create'
163
+
164
+ // If every local copy matches upstream, nothing to do.
165
+ if (localPaths.every(p => filesEqual(p, upstreamPath))) return 'identical'
166
+
167
+ // At least one local copy differs from upstream — this is a conflict.
168
+ if (policy.keepLocalAll) return 'keep-local'
169
+ if (policy.keepLocalList && policy.keepLocalList.includes(name)) return 'keep-local'
170
+ if (policy.takeUpstream) return 'overwrite'
171
+ return 'conflict'
172
+ }
173
+
174
+ // MOVE `<sourceDir>/.claude/skills/sa-*/SKILL.md` into Siesa-Agents `.claude/skills/sa-*/`
175
+ // (and `claude/skills/sa-*/`) according to the policy. Records conflicts in
176
+ // status.skill_conflicts; the caller checks that before any mutation happens.
177
+ function planAndApplySkills(sourceDir, projectRoot, status, policy, dryRun) {
178
+ const srcSkillsDir = path.join(sourceDir, '.claude', 'skills')
179
+ status.skills_created = []
180
+ status.skills_overwritten = []
181
+ status.skills_kept_local = []
182
+ status.skills_unchanged = []
183
+ status.skill_conflicts = []
184
+
185
+ if (!fs.existsSync(srcSkillsDir)) {
186
+ status.skills_source_missing = true
187
+ return
188
+ }
189
+
190
+ const dstA = path.join(projectRoot, '.claude', 'skills')
191
+ const dstB = path.join(projectRoot, 'claude', 'skills')
192
+
193
+ for (const entry of fs.readdirSync(srcSkillsDir, { withFileTypes: true })) {
194
+ if (!entry.isDirectory()) continue
195
+ if (!entry.name.startsWith('sa-')) continue
196
+ if (entry.name === 'sa-init-devops') continue // never overwrite the bootstrap itself
197
+ const srcSkill = path.join(srcSkillsDir, entry.name, 'SKILL.md')
198
+ if (!fs.existsSync(srcSkill)) continue
199
+
200
+ const dstSkillA = path.join(dstA, entry.name, 'SKILL.md')
201
+ const dstSkillB = path.join(dstB, entry.name, 'SKILL.md')
202
+
203
+ const action = decideAction(entry.name, srcSkill, [dstSkillA, dstSkillB], policy)
204
+
205
+ if (action === 'conflict') {
206
+ status.skill_conflicts.push(entry.name)
207
+ continue
208
+ }
209
+ if (action === 'identical') {
210
+ status.skills_unchanged.push(entry.name)
211
+ continue
212
+ }
213
+ if (dryRun) continue
214
+
215
+ if (action === 'create' || action === 'overwrite') {
216
+ fs.mkdirSync(path.dirname(dstSkillA), { recursive: true })
217
+ fs.mkdirSync(path.dirname(dstSkillB), { recursive: true })
218
+ fs.copyFileSync(srcSkill, dstSkillA)
219
+ fs.copyFileSync(srcSkill, dstSkillB)
220
+ if (action === 'create') status.skills_created.push(entry.name)
221
+ else status.skills_overwritten.push(entry.name)
222
+ } else if (action === 'keep-local') {
223
+ status.skills_kept_local.push(entry.name)
224
+ }
225
+ }
226
+ }
227
+
228
+ function planAndApplyWorkflows(sourceDir, projectRoot, status, policy, dryRun) {
229
+ const srcDir = path.join(sourceDir, '.github', 'workflows')
230
+ status.workflows_created = []
231
+ status.workflows_overwritten = []
232
+ status.workflows_kept_local = []
233
+ status.workflows_unchanged = []
234
+ status.workflow_conflicts = []
235
+
236
+ if (!fs.existsSync(srcDir)) {
237
+ status.workflows_source_missing = true
238
+ return
239
+ }
240
+
241
+ const dstDir = path.join(projectRoot, '.github', 'workflows')
242
+
243
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
244
+ if (!entry.isFile()) continue
245
+ if (!(entry.name.endsWith('.yml') || entry.name.endsWith('.yaml'))) continue
246
+
247
+ const srcFile = path.join(srcDir, entry.name)
248
+ const dstFile = path.join(dstDir, entry.name)
249
+
250
+ const action = decideAction(entry.name, srcFile, [dstFile], policy)
251
+
252
+ if (action === 'conflict') {
253
+ status.workflow_conflicts.push(entry.name)
254
+ continue
255
+ }
256
+ if (action === 'identical') {
257
+ status.workflows_unchanged.push(entry.name)
258
+ continue
259
+ }
260
+ if (dryRun) continue
261
+
262
+ if (action === 'create' || action === 'overwrite') {
263
+ fs.mkdirSync(dstDir, { recursive: true })
264
+ fs.copyFileSync(srcFile, dstFile)
265
+ if (action === 'create') status.workflows_created.push(entry.name)
266
+ else status.workflows_overwritten.push(entry.name)
267
+ } else if (action === 'keep-local') {
268
+ status.workflows_kept_local.push(entry.name)
269
+ }
270
+ }
271
+ }
272
+
273
+ // After a successful apply (no conflicts left), wipe the .claude/ and .github/ directories
274
+ // from the clone working tree. These remain tracked in upstream HEAD; restoreClonePaths()
275
+ // brings them back before the next pull.
276
+ function cleanCloneDirs(sourceDir) {
277
+ for (const sub of ['.claude', '.github']) {
278
+ const p = path.join(sourceDir, sub)
279
+ if (fs.existsSync(p)) rmDirSafe(p)
280
+ }
281
+ }
282
+
283
+ // Before pulling, restore `.claude/` and `.github/` paths in the clone working tree.
284
+ // They are tracked in HEAD but get wiped at the end of every run. Without this restore,
285
+ // `git pull --ff-only` would refuse to proceed because the working tree has uncommitted
286
+ // deletions. `git checkout HEAD -- .claude .github` is a no-op if the paths already exist.
287
+ function restoreClonePaths(sourceDir) {
288
+ tryGit(['checkout', 'HEAD', '--', '.claude', '.github'], sourceDir)
289
+ }
290
+
291
+ function resolveTargets(projectRoot) {
292
+ return {
293
+ sourceDir: path.join(projectRoot, '_siesa-agents', 'devops'),
294
+ mirrorDir: path.join(projectRoot, 'siesa-agents', 'devops'),
295
+ }
296
+ }
297
+
298
+ function buildPolicy(args) {
299
+ // Both --keep-local with no value (`--keep-local` alone, no `=`) AND with a value (`--keep-local=...`)
300
+ // need to be distinguished. parseArgs sets out['keep-local'] = true when no `=`, and = '<value>' when there is.
301
+ const keepLocalArg = args['keep-local']
302
+ return {
303
+ takeUpstream: Boolean(args['take-upstream']),
304
+ keepLocalAll: keepLocalArg === true, // bare `--keep-local`
305
+ keepLocalList: Array.isArray(args._keepLocalList) ? args._keepLocalList : null,
306
+ }
307
+ }
308
+
309
+ function validatePolicy(policy, status) {
310
+ const setCount = [policy.takeUpstream, policy.keepLocalAll, policy.keepLocalList !== null].filter(Boolean).length
311
+ if (setCount > 1) {
312
+ status.status = 'error'
313
+ status.message = 'Conflicting flags: pass at most one of --take-upstream, --keep-local, --keep-local=<csv>.'
314
+ return false
315
+ }
316
+ return true
317
+ }
318
+
319
+ function runCheck(projectRoot) {
320
+ const { sourceDir, mirrorDir } = resolveTargets(projectRoot)
321
+ const state = {
322
+ status: 'unknown',
323
+ project_root: projectRoot,
324
+ source_dir: sourceDir,
325
+ mirror_dir: mirrorDir,
326
+ remote_url: REMOTE_URL,
327
+ }
328
+
329
+ if (!fs.existsSync(sourceDir)) {
330
+ state.status = 'missing'
331
+ state.message = `${sourceDir} does not exist. Run without --check to clone ${REMOTE_URL}.`
332
+ emit(state)
333
+ return 2
334
+ }
335
+
336
+ if (!isGitWorkingTree(sourceDir)) {
337
+ state.status = 'invalid-source'
338
+ state.message = `${sourceDir} exists but is not a git working tree. Move it aside or delete it manually before re-running this skill.`
339
+ emit(state)
340
+ return 3
341
+ }
342
+
343
+ const actualRemote = currentRemoteUrl(sourceDir)
344
+ state.actual_remote_url = actualRemote
345
+ if (actualRemote !== REMOTE_URL) {
346
+ state.status = 'wrong-remote'
347
+ state.message = `${sourceDir} is a git clone but its origin points at ${actualRemote} (expected ${REMOTE_URL}). Move it aside manually before re-running.`
348
+ emit(state)
349
+ return 3
350
+ }
351
+
352
+ state.mirror_exists = fs.existsSync(mirrorDir)
353
+ state.clone_has_claude_dir = fs.existsSync(path.join(sourceDir, '.claude'))
354
+ state.clone_has_github_dir = fs.existsSync(path.join(sourceDir, '.github'))
355
+
356
+ // Dry-run conflict detection so callers can preview before running default mode.
357
+ restoreClonePaths(sourceDir)
358
+ const policy = { takeUpstream: false, keepLocalAll: false, keepLocalList: null }
359
+ planAndApplySkills(sourceDir, projectRoot, state, policy, true)
360
+ planAndApplyWorkflows(sourceDir, projectRoot, state, policy, true)
361
+
362
+ state.status = 'already-cloned'
363
+ const conflictCount = state.skill_conflicts.length + state.workflow_conflicts.length
364
+ state.message = `${sourceDir} is a clean clone of ${REMOTE_URL}. ${conflictCount} conflict(s) would be raised by a default run.`
365
+ emit(state)
366
+ return 0
367
+ }
368
+
369
+ function runClone(projectRoot, mirrorOnly, args) {
370
+ const { sourceDir, mirrorDir } = resolveTargets(projectRoot)
371
+ const status = {
372
+ status: 'unknown',
373
+ project_root: projectRoot,
374
+ source_dir: sourceDir,
375
+ mirror_dir: mirrorDir,
376
+ remote_url: REMOTE_URL,
377
+ }
378
+
379
+ const policy = buildPolicy(args)
380
+ status.policy = {
381
+ take_upstream: policy.takeUpstream,
382
+ keep_local_all: policy.keepLocalAll,
383
+ keep_local_list: policy.keepLocalList,
384
+ }
385
+ if (!validatePolicy(policy, status)) {
386
+ emit(status)
387
+ return 1
388
+ }
389
+
390
+ const sourceExists = fs.existsSync(sourceDir)
391
+
392
+ if (mirrorOnly) {
393
+ if (!sourceExists) {
394
+ status.status = 'error'
395
+ status.message = `--mirror-only requested but ${sourceDir} does not exist yet.`
396
+ emit(status)
397
+ return 3
398
+ }
399
+ refreshMirror(sourceDir, mirrorDir, status)
400
+ status.status = 'mirror-refreshed'
401
+ status.message = `Refreshed ${mirrorDir} from ${sourceDir} (skipped git + move steps).`
402
+ emit(status)
403
+ return 0
404
+ }
405
+
406
+ // --- Path 1: source dir does not exist → clone fresh ---
407
+ if (!sourceExists) {
408
+ fs.mkdirSync(path.dirname(sourceDir), { recursive: true })
409
+ const r = tryGit(['clone', '--branch', DEFAULT_BRANCH, REMOTE_URL, sourceDir], path.dirname(sourceDir))
410
+ if (!r.ok) {
411
+ status.status = 'clone-failed'
412
+ status.message = `git clone failed: ${r.stderr || r.stdout || 'unknown error'}`
413
+ emit(status)
414
+ return 3
415
+ }
416
+ return finalizeMoveAndMirror(sourceDir, projectRoot, mirrorDir, status, policy, 'cloned')
417
+ }
418
+
419
+ // --- Path 2: source exists, validate it's the right clone ---
420
+ if (!isGitWorkingTree(sourceDir)) {
421
+ status.status = 'invalid-source'
422
+ status.message = `${sourceDir} exists but is not a git working tree. Move it aside or delete it manually before re-running.`
423
+ emit(status)
424
+ return 3
425
+ }
426
+
427
+ const actualRemote = currentRemoteUrl(sourceDir)
428
+ status.actual_remote_url = actualRemote
429
+ if (actualRemote !== REMOTE_URL) {
430
+ status.status = 'wrong-remote'
431
+ status.message = `${sourceDir} is a git clone but origin points at ${actualRemote}. Expected ${REMOTE_URL}. Move it aside manually before re-running.`
432
+ emit(status)
433
+ return 3
434
+ }
435
+
436
+ // --- Path 3: existing clean clone → restore .claude/.github + fetch + pull --ff-only ---
437
+ restoreClonePaths(sourceDir)
438
+
439
+ const fetchR = tryGit(['fetch', REMOTE_NAME, DEFAULT_BRANCH, '--prune'], sourceDir)
440
+ if (!fetchR.ok) {
441
+ status.status = 'fetch-failed'
442
+ status.message = `git fetch failed: ${fetchR.stderr || fetchR.stdout || 'unknown error'}`
443
+ emit(status)
444
+ return 3
445
+ }
446
+
447
+ const beforeR = tryGit(['rev-parse', 'HEAD'], sourceDir)
448
+ const before = beforeR.ok ? beforeR.stdout : null
449
+
450
+ const checkoutR = tryGit(['checkout', DEFAULT_BRANCH], sourceDir)
451
+ if (!checkoutR.ok) {
452
+ status.status = 'checkout-failed'
453
+ status.message = `git checkout ${DEFAULT_BRANCH} failed: ${checkoutR.stderr || checkoutR.stdout}`
454
+ emit(status)
455
+ return 3
456
+ }
457
+
458
+ const pullR = tryGit(['pull', '--ff-only', REMOTE_NAME, DEFAULT_BRANCH], sourceDir)
459
+ if (!pullR.ok) {
460
+ status.status = 'pull-failed'
461
+ status.message = `git pull --ff-only failed: ${pullR.stderr || pullR.stdout}. Local edits or diverged branch may be the cause.`
462
+ emit(status)
463
+ return 3
464
+ }
465
+
466
+ const afterR = tryGit(['rev-parse', 'HEAD'], sourceDir)
467
+ const after = afterR.ok ? afterR.stdout : null
468
+ const moved = before && after && before !== after
469
+
470
+ status.before_sha = before
471
+ status.after_sha = after
472
+
473
+ return finalizeMoveAndMirror(sourceDir, projectRoot, mirrorDir, status, policy, moved ? 'updated' : 'already-cloned')
474
+ }
475
+
476
+ function finalizeMoveAndMirror(sourceDir, projectRoot, mirrorDir, status, policy, terminalStatus) {
477
+ // Phase A: dry-run to compute the action plan and conflicts.
478
+ planAndApplySkills(sourceDir, projectRoot, status, policy, true)
479
+ planAndApplyWorkflows(sourceDir, projectRoot, status, policy, true)
480
+
481
+ const totalConflicts = status.skill_conflicts.length + status.workflow_conflicts.length
482
+ if (totalConflicts > 0) {
483
+ // Abort without mutating. The caller (Claude/SKILL.md) asks the user and re-runs with
484
+ // a policy flag.
485
+ status.status = 'conflicts'
486
+ status.message = `Detected ${status.skill_conflicts.length} skill conflict(s) and ${status.workflow_conflicts.length} workflow conflict(s). Re-run with one of: --take-upstream (overwrite all), --keep-local (keep all local versions), or --keep-local=<csv> (keep specific ones).`
487
+ emit(status)
488
+ return 4
489
+ }
490
+
491
+ // Phase B: real apply (no conflicts left to resolve).
492
+ planAndApplySkills(sourceDir, projectRoot, status, policy, false)
493
+ planAndApplyWorkflows(sourceDir, projectRoot, status, policy, false)
494
+ cleanCloneDirs(sourceDir)
495
+ refreshMirror(sourceDir, mirrorDir, status)
496
+
497
+ status.status = terminalStatus
498
+ const created = status.skills_created.length + status.workflows_created.length
499
+ const over = status.skills_overwritten.length + status.workflows_overwritten.length
500
+ const kept = status.skills_kept_local.length + status.workflows_kept_local.length
501
+ const unch = status.skills_unchanged.length + status.workflows_unchanged.length
502
+ status.message = `${terminalStatus === 'cloned' ? 'Cloned' : (terminalStatus === 'updated' ? 'Pulled' : 'No upstream change')}. Created ${created}, overwrote ${over}, kept local ${kept}, unchanged ${unch}. Mirror refreshed.`
503
+ emit(status)
504
+ return 0
505
+ }
506
+
507
+ const args = parseArgs(process.argv.slice(2))
508
+ const projectRoot = findProjectRoot(process.cwd())
509
+ if (!projectRoot) {
510
+ emit({
511
+ status: 'error',
512
+ message: 'Could not locate the project root. Expected to find a parent directory containing both `_siesa-agents/` and `siesa-agents/`.',
513
+ cwd: process.cwd(),
514
+ })
515
+ process.exit(1)
516
+ }
517
+
518
+ if (args['check']) {
519
+ process.exit(runCheck(projectRoot))
520
+ } else if (args['mirror-only']) {
521
+ process.exit(runClone(projectRoot, true, args))
522
+ } else {
523
+ process.exit(runClone(projectRoot, false, args))
524
+ }