diff --git a/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/.openspec.yaml b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/.openspec.yaml new file mode 100644 index 0000000..25345f4 --- /dev/null +++ b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-22 diff --git a/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/proposal.md b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/proposal.md new file mode 100644 index 0000000..acebb3b --- /dev/null +++ b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/proposal.md @@ -0,0 +1,18 @@ +## Why + +- `bin/multiagent-safety.js` is roughly 7,864 lines long and currently mixes CLI parsing, template rendering, git/worktree plumbing, protected-base sandboxing, finish/merge logic, toolchain self-update, and output/report formatting. +- That shared module scope makes even small changes hard to review and easy to regress because unrelated helpers are tightly coupled and tests have to exercise one monolith. +- The requested outcome is a seam-based decomposition so future Guardex CLI changes can land in smaller diffs with clearer ownership and lower regression risk. + +## What Changes + +- Introduce a `src/` runtime layout that separates `cli`, `output`, `git`, `scaffold`, `hooks`, `toolchain`, `sandbox`, and `finish`, with only small shared helpers left in `src/context.js` and `src/core/runtime.js`. +- Reduce `bin/multiagent-safety.js` to a thin entrypoint that boots `src/cli/main.js`. +- Preserve the current command surface, aliases, and targeted behavior while moving the existing logic wholesale into the new modules. +- Update package shipping and regression coverage so installed CLIs still include `src/**` and the extracted runtime stays exercised by install/metadata tests. + +## Impact + +- Primary surfaces: `bin/multiagent-safety.js`, new `src/**` modules, `package.json`, and CLI regression tests. +- Main refactor risk is hidden cross-module coupling in `doctor`, protected-main sandbox flows, and finish/cleanup helpers, so extraction should move lower-risk seams first and verify after each pass. +- This is an internal architecture cleanup only; it must not intentionally change command names, output contracts, or the zero-copy install surface. diff --git a/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/specs/cli-modularization/spec.md b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/specs/cli-modularization/spec.md new file mode 100644 index 0000000..f009646 --- /dev/null +++ b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/specs/cli-modularization/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Thin CLI entrypoint +The CLI SHALL keep `bin/multiagent-safety.js` as a thin bootstrap surface that delegates command execution into `src/cli`. + +#### Scenario: Entrypoint delegates into src/cli +- **WHEN** the published CLI binary is executed +- **THEN** `bin/multiagent-safety.js` loads the modular runtime from `src/cli/main.js` +- **AND** command dispatch logic no longer depends on the monolithic file body. + +### Requirement: Module seams mirror operational responsibility +The CLI SHALL separate major operational seams into dedicated modules under `src/` instead of keeping them in one file. + +#### Scenario: Responsibilities live under dedicated src modules +- **WHEN** a maintainer inspects the refactored CLI +- **THEN** argument parsing and dispatch live under `src/cli` +- **AND** output formatting lives under `src/output` +- **AND** git/worktree helpers live under `src/git` +- **AND** managed-file and template logic live under `src/scaffold` and `src/hooks` +- **AND** toolchain and self-update logic live under `src/toolchain` +- **AND** protected-base sandbox and finish flows live under `src/sandbox` and `src/finish`. + +### Requirement: Refactor preserves targeted CLI behavior +The modularization SHALL preserve the current command surface for targeted verified flows. + +#### Scenario: Targeted CLI regressions stay green after extraction +- **WHEN** the focused install/metadata/command regression suites and packaging checks are run after the extraction +- **THEN** they pass without command-name regressions +- **AND** the published package still contains the runtime files required by the extracted `src/**` modules. diff --git a/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/tasks.md b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/tasks.md new file mode 100644 index 0000000..4667bba --- /dev/null +++ b/openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/tasks.md @@ -0,0 +1,42 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-codex-decompose-cli-monolith-2026-04-22-11-06`; branch=`agent/codex/decompose-cli-monolith-2026-04-22-11-06`; scope=`bin/multiagent-safety.js`, new `src/**` runtime modules, packaging metadata, and targeted CLI regression tests; action=`decompose the monolithic CLI into seam-owned modules while preserving the command surface`. +- Copy prompt: Continue `agent-codex-decompose-cli-monolith-2026-04-22-11-06` on branch `agent/codex/decompose-cli-monolith-2026-04-22-11-06`. Work inside the existing sandbox, review `openspec/changes/agent-codex-decompose-cli-monolith-2026-04-22-11-06/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/decompose-cli-monolith-2026-04-22-11-06 --base dev --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-decompose-cli-monolith-2026-04-22-11-06`. +- [x] 1.2 Define normative requirements in `specs/cli-modularization/spec.md`. + +## 2. Implementation + +- [x] 2.1 Add shared `src/context.js` / `src/core/runtime.js` foundations for constants, process helpers, and low-level utilities. +- [x] 2.2 Extract low-risk seams into `src/output`, `src/git`, `src/scaffold`, `src/hooks`, and `src/toolchain`. +- [x] 2.3 Extract higher-coupling seams into `src/sandbox`, `src/finish`, and `src/cli`. +- [x] 2.4 Reduce `bin/multiagent-safety.js` to a thin launcher that boots `src/cli/main.js`. +- [x] 2.5 Update publish packaging / metadata so installed CLIs ship the new `src/**` runtime. + +## 3. Verification + +- [x] 3.1 Add/update targeted regression coverage for the thin entrypoint, representative command routes, and package shipping of `src/**`. +- [x] 3.2 Run syntax checks for the entrypoint and extracted modules (`node --check bin/multiagent-safety.js` plus `node --check` on `src/**`). +- [x] 3.3 Run focused install/metadata/command regression suites. +- [x] 3.4 Run `npm pack --dry-run` to confirm `src/**` ships in the package. +- [x] 3.5 Run `openspec validate agent-codex-decompose-cli-monolith-2026-04-22-11-06 --type change --strict`. +- [x] 3.6 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [x] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/decompose-cli-monolith-2026-04-22-11-06 --base dev --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [x] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [x] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). + +Completion handoff: PR https://github.com/recodeee/gitguardex/pull/294 state=`MERGED` merged_at=`2026-04-22T10:38:31Z`; `git worktree list` no longer shows `.omx/agent-worktrees/agent__codex__decompose-cli-monolith-2026-04-22-11-06`; `git branch -a --list 'agent/codex/decompose-cli-monolith-2026-04-22-11-06' 'origin/agent/codex/decompose-cli-monolith-2026-04-22-11-06'` returns no refs after `git fetch --prune origin`. diff --git a/src/cli/dispatch.js b/src/cli/dispatch.js new file mode 100644 index 0000000..63ec94e --- /dev/null +++ b/src/cli/dispatch.js @@ -0,0 +1,86 @@ +const { + TOOL_NAME, + COMMAND_TYPO_ALIASES, + DEPRECATED_COMMAND_ALIASES, + SUGGESTIBLE_COMMANDS, +} = require('../context'); + +function levenshteinDistance(a, b) { + const rows = a.length + 1; + const cols = b.length + 1; + const matrix = Array.from({ length: rows }, () => Array(cols).fill(0)); + + for (let i = 0; i < rows; i += 1) matrix[i][0] = i; + for (let j = 0; j < cols; j += 1) matrix[0][j] = j; + + for (let i = 1; i < rows; i += 1) { + for (let j = 1; j < cols; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + cost, + ); + } + } + return matrix[a.length][b.length]; +} + +function maybeSuggestCommand(command) { + let best = null; + let bestDistance = Number.POSITIVE_INFINITY; + + for (const candidate of SUGGESTIBLE_COMMANDS) { + const dist = levenshteinDistance(command, candidate); + if (dist < bestDistance) { + bestDistance = dist; + best = candidate; + } + } + + if (best && bestDistance <= 2) { + return best; + } + + return null; +} + +function normalizeCommandOrThrow(command) { + if (COMMAND_TYPO_ALIASES.has(command)) { + const mapped = COMMAND_TYPO_ALIASES.get(command); + console.log(`[${TOOL_NAME}] Interpreting '${command}' as '${mapped}'.`); + return mapped; + } + return command; +} + +function warnDeprecatedAlias(aliasName) { + const entry = DEPRECATED_COMMAND_ALIASES.get(aliasName); + if (!entry) return; + console.error( + `[${TOOL_NAME}] '${aliasName}' is deprecated and will be removed in a future major release. ` + + `Use: ${entry.hint}`, + ); +} + +function extractFlag(args, ...names) { + const flagSet = new Set(names); + let found = false; + const remaining = []; + for (const arg of args) { + if (flagSet.has(arg)) { + found = true; + } else { + remaining.push(arg); + } + } + return { found, remaining }; +} + +module.exports = { + levenshteinDistance, + maybeSuggestCommand, + normalizeCommandOrThrow, + warnDeprecatedAlias, + extractFlag, +}; diff --git a/src/context.js b/src/context.js new file mode 100644 index 0000000..f64dbf3 --- /dev/null +++ b/src/context.js @@ -0,0 +1,503 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const cp = require('node:child_process'); + +const PACKAGE_ROOT = path.resolve(__dirname, '..'); +const CLI_ENTRY_PATH = path.join(PACKAGE_ROOT, 'bin', 'multiagent-safety.js'); +const packageJsonPath = path.join(PACKAGE_ROOT, 'package.json'); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + +const TOOL_NAME = 'gitguardex'; +const SHORT_TOOL_NAME = 'gx'; +if (!process.env.GUARDEX_CLI_ENTRY) { + process.env.GUARDEX_CLI_ENTRY = CLI_ENTRY_PATH; +} +if (!process.env.GUARDEX_NODE_BIN) { + process.env.GUARDEX_NODE_BIN = process.execPath; +} +const LEGACY_NAMES = ['guardex', 'multiagent-safety']; +const GLOBAL_INSTALL_COMMAND = `npm i -g ${packageJson.name}`; +const OPENSPEC_PACKAGE = '@fission-ai/openspec'; +const OMC_PACKAGE = 'oh-my-claude-sisyphus'; +const OMC_REPO_URL = 'https://github.com/Yeachan-Heo/oh-my-claudecode'; +const CAVEMEM_PACKAGE = 'cavemem'; +const NPX_BIN = process.env.GUARDEX_NPX_BIN || 'npx'; +const GUARDEX_HOME_DIR = path.resolve(process.env.GUARDEX_HOME_DIR || os.homedir()); +const GLOBAL_TOOLCHAIN_SERVICES = [ + { name: 'oh-my-codex', packageName: 'oh-my-codex' }, + { + name: 'oh-my-claudecode', + packageName: OMC_PACKAGE, + dependencyUrl: OMC_REPO_URL, + }, + { name: OPENSPEC_PACKAGE, packageName: OPENSPEC_PACKAGE }, + { name: CAVEMEM_PACKAGE, packageName: CAVEMEM_PACKAGE }, + { + name: '@imdeadpool/codex-account-switcher', + packageName: '@imdeadpool/codex-account-switcher', + }, +]; +const GLOBAL_TOOLCHAIN_PACKAGES = [ + ...GLOBAL_TOOLCHAIN_SERVICES.map((service) => service.packageName), +]; +const OPTIONAL_LOCAL_COMPANION_TOOLS = [ + { + name: 'cavekit', + candidatePaths: [ + '.cavekit/plugin.json', + '.codex/local-marketplaces/cavekit/.agents/plugins/marketplace.json', + ], + installCommand: `${NPX_BIN} skills add JuliusBrussee/cavekit`, + installArgs: ['skills', 'add', 'JuliusBrussee/cavekit'], + }, + { + name: 'caveman', + candidatePaths: [ + '.config/caveman/config.json', + '.cavekit/skills/caveman/SKILL.md', + ], + installCommand: `${NPX_BIN} skills add JuliusBrussee/caveman`, + installArgs: ['skills', 'add', 'JuliusBrussee/caveman'], + }, +]; +const GH_BIN = process.env.GUARDEX_GH_BIN || 'gh'; +const REQUIRED_SYSTEM_TOOLS = [ + { + name: 'gh', + displayName: 'GitHub (gh)', + command: GH_BIN, + installHint: 'https://cli.github.com/', + }, +]; +const MAINTAINER_RELEASE_REPO = path.resolve( + process.env.GUARDEX_RELEASE_REPO || path.resolve(PACKAGE_ROOT), +); +const NPM_BIN = process.env.GUARDEX_NPM_BIN || 'npm'; +const OPENSPEC_BIN = process.env.GUARDEX_OPENSPEC_BIN || 'openspec'; +const SCORECARD_BIN = process.env.GUARDEX_SCORECARD_BIN || 'scorecard'; +const GIT_PROTECTED_BRANCHES_KEY = 'multiagent.protectedBranches'; +const GIT_BASE_BRANCH_KEY = 'multiagent.baseBranch'; +const GIT_SYNC_STRATEGY_KEY = 'multiagent.sync.strategy'; +const GUARDEX_REPO_TOGGLE_ENV = 'GUARDEX_ON'; +const DEFAULT_PROTECTED_BRANCHES = ['dev', 'main', 'master']; +const DEFAULT_BASE_BRANCH = 'dev'; +const DEFAULT_SYNC_STRATEGY = 'rebase'; +const DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES = 60; +const COMPOSE_HINT_FILES = [ + 'docker-compose.yml', + 'docker-compose.yaml', + 'compose.yml', + 'compose.yaml', +]; + +const TEMPLATE_ROOT = path.join(PACKAGE_ROOT, 'templates'); + +const HOOK_NAMES = ['pre-commit', 'pre-push', 'post-merge', 'post-checkout']; + +function toDestinationPath(relativeTemplatePath) { + if (relativeTemplatePath.startsWith('scripts/')) { + return relativeTemplatePath; + } + if (relativeTemplatePath.startsWith('githooks/')) { + return `.${relativeTemplatePath}`; + } + if (relativeTemplatePath.startsWith('codex/')) { + return `.${relativeTemplatePath}`; + } + if (relativeTemplatePath.startsWith('claude/')) { + return `.${relativeTemplatePath}`; + } + if (relativeTemplatePath.startsWith('github/')) { + return `.${relativeTemplatePath}`; + } + if (relativeTemplatePath.startsWith('vscode/')) { + return relativeTemplatePath; + } + throw new Error(`Unsupported template path: ${relativeTemplatePath}`); +} + +const TEMPLATE_FILES = [ + 'scripts/agent-session-state.js', + 'scripts/guardex-docker-loader.sh', + 'scripts/guardex-env.sh', + 'scripts/install-vscode-active-agents-extension.js', + 'github/pull.yml.example', + 'github/workflows/cr.yml', + 'vscode/guardex-active-agents/package.json', + 'vscode/guardex-active-agents/extension.js', + 'vscode/guardex-active-agents/session-schema.js', + 'vscode/guardex-active-agents/README.md', +]; + +const LEGACY_WORKFLOW_SHIM_SPECS = [ + { relativePath: 'scripts/agent-branch-start.sh', kind: 'shell', command: ['branch', 'start'] }, + { relativePath: 'scripts/agent-branch-finish.sh', kind: 'shell', command: ['branch', 'finish'] }, + { relativePath: 'scripts/agent-branch-merge.sh', kind: 'shell', command: ['branch', 'merge'] }, + { relativePath: 'scripts/codex-agent.sh', kind: 'shell', command: ['internal', 'run-shell', 'codexAgent'] }, + { relativePath: 'scripts/review-bot-watch.sh', kind: 'shell', command: ['internal', 'run-shell', 'reviewBot'] }, + { relativePath: 'scripts/agent-worktree-prune.sh', kind: 'shell', command: ['worktree', 'prune'] }, + { relativePath: 'scripts/agent-file-locks.py', kind: 'python', command: ['locks'] }, + { relativePath: 'scripts/openspec/init-plan-workspace.sh', kind: 'shell', command: ['internal', 'run-shell', 'planInit'] }, + { relativePath: 'scripts/openspec/init-change-workspace.sh', kind: 'shell', command: ['internal', 'run-shell', 'changeInit'] }, +]; + +const LEGACY_WORKFLOW_SHIMS = LEGACY_WORKFLOW_SHIM_SPECS.map((entry) => entry.relativePath); + +const MANAGED_TEMPLATE_DESTINATIONS = TEMPLATE_FILES.map((entry) => toDestinationPath(entry)); +const MANAGED_TEMPLATE_SCRIPT_FILES = MANAGED_TEMPLATE_DESTINATIONS.filter((entry) => + entry.startsWith('scripts/'), +); + +const LEGACY_MANAGED_REPO_FILES = [ + ...LEGACY_WORKFLOW_SHIMS, + 'scripts/agent-session-state.js', + 'scripts/guardex-docker-loader.sh', + 'scripts/install-vscode-active-agents-extension.js', + 'scripts/guardex-env.sh', + 'scripts/install-agent-git-hooks.sh', + '.githooks/pre-commit', + '.githooks/pre-push', + '.githooks/post-merge', + '.githooks/post-checkout', + '.codex/skills/gitguardex/SKILL.md', + '.codex/skills/guardex-merge-skills-to-dev/SKILL.md', + '.claude/commands/gitguardex.md', +]; + +const REQUIRED_MANAGED_REPO_FILES = [ + ...MANAGED_TEMPLATE_DESTINATIONS, + ...HOOK_NAMES.map((entry) => path.posix.join('.githooks', entry)), + '.omx/state/agent-file-locks.json', +]; + +const LEGACY_MANAGED_PACKAGE_SCRIPTS = { + 'agent:codex': 'bash ./scripts/codex-agent.sh', + 'agent:branch:start': 'bash ./scripts/agent-branch-start.sh', + 'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh', + 'agent:branch:merge': 'bash ./scripts/agent-branch-merge.sh', + 'agent:cleanup': 'gx cleanup', + 'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh', + 'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim', + 'agent:locks:allow-delete': 'python3 ./scripts/agent-file-locks.py allow-delete', + 'agent:locks:release': 'python3 ./scripts/agent-file-locks.py release', + 'agent:locks:status': 'python3 ./scripts/agent-file-locks.py status', + 'agent:plan:init': 'bash ./scripts/openspec/init-plan-workspace.sh', + 'agent:change:init': 'bash ./scripts/openspec/init-change-workspace.sh', + 'agent:protect:list': 'gx protect list', + 'agent:branch:sync': 'gx sync', + 'agent:branch:sync:check': 'gx sync --check', + 'agent:safety:setup': 'gx setup', + 'agent:safety:scan': 'gx status --strict', + 'agent:safety:fix': 'gx setup --repair', + 'agent:safety:doctor': 'gx doctor', + 'agent:docker:load': 'bash ./scripts/guardex-docker-loader.sh', + 'agent:review:watch': 'bash ./scripts/review-bot-watch.sh', + 'agent:finish': 'gx finish --all', +}; + +const PACKAGE_SCRIPT_ASSETS = { + branchStart: path.join(TEMPLATE_ROOT, 'scripts', 'agent-branch-start.sh'), + branchFinish: path.join(TEMPLATE_ROOT, 'scripts', 'agent-branch-finish.sh'), + branchMerge: path.join(TEMPLATE_ROOT, 'scripts', 'agent-branch-merge.sh'), + codexAgent: path.join(TEMPLATE_ROOT, 'scripts', 'codex-agent.sh'), + reviewBot: path.join(TEMPLATE_ROOT, 'scripts', 'review-bot-watch.sh'), + worktreePrune: path.join(TEMPLATE_ROOT, 'scripts', 'agent-worktree-prune.sh'), + lockTool: path.join(TEMPLATE_ROOT, 'scripts', 'agent-file-locks.py'), + planInit: path.join(TEMPLATE_ROOT, 'scripts', 'openspec', 'init-plan-workspace.sh'), + changeInit: path.join(TEMPLATE_ROOT, 'scripts', 'openspec', 'init-change-workspace.sh'), +}; + +const USER_LEVEL_SKILL_ASSETS = [ + { + source: path.join(TEMPLATE_ROOT, 'codex', 'skills', 'gitguardex', 'SKILL.md'), + destination: path.join('.codex', 'skills', 'gitguardex', 'SKILL.md'), + }, + { + source: path.join(TEMPLATE_ROOT, 'codex', 'skills', 'guardex-merge-skills-to-dev', 'SKILL.md'), + destination: path.join('.codex', 'skills', 'guardex-merge-skills-to-dev', 'SKILL.md'), + }, + { + source: path.join(TEMPLATE_ROOT, 'claude', 'commands', 'gitguardex.md'), + destination: path.join('.claude', 'commands', 'gitguardex.md'), + }, +]; + +const EXECUTABLE_RELATIVE_PATHS = new Set([ + ...MANAGED_TEMPLATE_SCRIPT_FILES, + ...HOOK_NAMES.map((entry) => path.posix.join('.githooks', entry)), +]); + +const CRITICAL_GUARDRAIL_PATHS = new Set([ + 'AGENTS.md', + ...HOOK_NAMES.map((entry) => path.posix.join('.githooks', entry)), + 'scripts/guardex-env.sh', +]); + +const LOCK_FILE_RELATIVE = '.omx/state/agent-file-locks.json'; +const AGENTS_BOTS_STATE_RELATIVE = '.omx/state/agents-bots.json'; +const AGENTS_MARKER_START = ''; +const AGENTS_MARKER_END = ''; +const GITIGNORE_MARKER_START = '# multiagent-safety:START'; +const GITIGNORE_MARKER_END = '# multiagent-safety:END'; +const CODEX_WORKTREE_RELATIVE_DIR = path.join('.omx', 'agent-worktrees'); +const CLAUDE_WORKTREE_RELATIVE_DIR = path.join('.omc', 'agent-worktrees'); +const AGENT_WORKTREE_RELATIVE_DIRS = [ + CODEX_WORKTREE_RELATIVE_DIR, + CLAUDE_WORKTREE_RELATIVE_DIR, +]; +const MANAGED_GITIGNORE_PATHS = [ + '.omx/', + '.omc/', + 'scripts/agent-session-state.js', + 'scripts/guardex-docker-loader.sh', + 'scripts/guardex-env.sh', + 'scripts/install-vscode-active-agents-extension.js', + '.githooks', + 'oh-my-codex/', + LOCK_FILE_RELATIVE, +]; +const REPO_SCAFFOLD_DIRECTORIES = ['bin']; +const OMX_SCAFFOLD_DIRECTORIES = [ + '.omx', + '.omx/state', + '.omx/logs', + '.omx/plans', + CODEX_WORKTREE_RELATIVE_DIR, + '.omc', + CLAUDE_WORKTREE_RELATIVE_DIR, +]; +const OMX_SCAFFOLD_FILES = new Map([ + ['.omx/notepad.md', '\n\n## WORKING MEMORY\n'], + ['.omx/project-memory.json', '{}\n'], +]); +const TARGETED_FORCEABLE_MANAGED_PATHS = new Set([ + 'AGENTS.md', + '.gitignore', + ...Array.from(OMX_SCAFFOLD_FILES.keys()), + ...REQUIRED_MANAGED_REPO_FILES, + ...LEGACY_WORKFLOW_SHIMS, +]); +const COMMAND_TYPO_ALIASES = new Map([ + ['relaese', 'release'], + ['realaese', 'release'], + ['relase', 'release'], + ['setpu', 'setup'], + ['inti', 'init'], + ['intsall', 'install'], + ['docter', 'doctor'], + ['doctro', 'doctor'], + ['cleunup', 'cleanup'], + ['scna', 'scan'], +]); +const SUGGESTIBLE_COMMANDS = [ + 'status', + 'setup', + 'doctor', + 'branch', + 'locks', + 'worktree', + 'hook', + 'migrate', + 'install-agent-skills', + 'agents', + 'merge', + 'finish', + 'report', + 'protect', + 'sync', + 'cleanup', + 'prompt', + 'help', + 'version', + 'init', + 'install', + 'fix', + 'scan', + 'review', + 'copy-prompt', + 'copy-commands', + 'print-agents-snippet', + 'release', +]; +const CLI_COMMAND_DESCRIPTIONS = [ + ['status', 'Show GitGuardex CLI + service health without modifying files'], + ['setup', 'Install, repair, and verify guardrails (flags: --repair, --install-only, --target)'], + ['doctor', 'Repair drift + verify (auto-sandboxes on protected main)'], + ['branch', 'CLI-owned branch workflow surface (start/finish/merge)'], + ['locks', 'CLI-owned file lock surface (claim/allow-delete/release/status/validate)'], + ['worktree', 'CLI-owned worktree cleanup surface (prune)'], + ['hook', 'Hook dispatch/install surface used by managed shims'], + ['migrate', 'Convert legacy repo-local installs to the zero-copy CLI-owned surface'], + ['install-agent-skills', 'Install Guardex Codex/Claude skills into the user home'], + ['protect', 'Manage protected branches (list/add/remove/set/reset)'], + ['merge', 'Create/reuse an integration lane and merge overlapping agent branches'], + ['sync', 'Sync agent branches with origin/'], + ['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'], + ['cleanup', 'Prune merged/stale agent branches and worktrees'], + ['release', 'Create or update the current GitHub release with README-generated notes'], + ['agents', 'Start/stop repo-scoped review + cleanup bots'], + ['prompt', 'Print AI setup checklist (--exec, --snippet)'], + ['report', 'Security/safety reports (e.g. OpenSSF scorecard)'], + ['help', 'Show this help output'], + ['version', 'Print GitGuardex version'], +]; +const DEPRECATED_COMMAND_ALIASES = new Map([ + ['init', { target: 'setup', hint: 'gx setup' }], + ['install', { target: 'setup', hint: 'gx setup --install-only' }], + ['fix', { target: 'setup', hint: 'gx setup --repair' }], + ['scan', { target: 'status', hint: 'gx status --strict' }], + ['copy-prompt', { target: 'prompt', hint: 'gx prompt' }], + ['copy-commands', { target: 'prompt', hint: 'gx prompt --exec' }], + ['print-agents-snippet', { target: 'prompt', hint: 'gx prompt --snippet' }], + ['review', { target: 'agents', hint: 'gx agents start (runs review + cleanup)' }], +]); +const AGENT_BOT_DESCRIPTIONS = [ + ['agents', 'Start/stop review + cleanup bots for this repo'], +]; +const DOCTOR_AUTO_FINISH_DETAIL_LIMIT = 6; +const DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX = 72; +const DOCTOR_AUTO_FINISH_MESSAGE_MAX = 160; + +function envFlagIsTruthy(raw) { + const lowered = String(raw || '').trim().toLowerCase(); + return lowered === '1' || lowered === 'true' || lowered === 'yes' || lowered === 'on'; +} + +function isClaudeCodeSession(env = process.env) { + return envFlagIsTruthy(env.CLAUDECODE) || Boolean(env.CLAUDE_CODE_SESSION_ID); +} + +function defaultAgentWorktreeRelativeDir(env = process.env) { + return isClaudeCodeSession(env) ? CLAUDE_WORKTREE_RELATIVE_DIR : CODEX_WORKTREE_RELATIVE_DIR; +} + +const AI_SETUP_PROMPT = `GitGuardex (gx) setup checklist for Codex/Claude in this repo. + +1) Install: ${GLOBAL_INSTALL_COMMAND} && gh --version +2) Bootstrap: gx setup +3) Repair: gx doctor +4) Task loop: gx branch start "" "" + then gx locks claim --branch "" -> gx branch finish +5) Integrate: gx merge --branch --branch +6) Finish: gx finish --all +7) Cleanup: gx cleanup +8) OpenSpec: /opsx:propose -> /opsx:apply -> /opsx:archive +9) Optional: gx protect add release staging +10) Optional: gx sync --check && gx sync +11) Review bot: install https://github.com/apps/cr-gpt + set OPENAI_API_KEY +12) Fork sync: install https://github.com/apps/pull + cp .github/pull.yml.example .github/pull.yml +`; + +const AI_SETUP_COMMANDS = `${GLOBAL_INSTALL_COMMAND} +gh --version +gx setup +gx doctor +gx branch start "" "" +gx locks claim --branch "" +gx merge --branch "" --branch "" +gx finish --all +gx cleanup +gx protect add release staging +gx sync --check && gx sync +`; + +const SCORECARD_RISK_BY_CHECK = { + 'Dangerous-Workflow': 'Critical', + 'Code-Review': 'High', + Maintained: 'High', + 'Binary-Artifacts': 'High', + 'Dependency-Update-Tool': 'High', + 'Token-Permissions': 'High', + Vulnerabilities: 'High', + 'Branch-Protection': 'High', + Fuzzing: 'Medium', + 'Pinned-Dependencies': 'Medium', + SAST: 'Medium', + 'Security-Policy': 'Medium', + 'CII-Best-Practices': 'Low', + Contributors: 'Low', + License: 'Low', +}; + +module.exports = { + fs, + os, + path, + cp, + PACKAGE_ROOT, + CLI_ENTRY_PATH, + packageJsonPath, + packageJson, + TOOL_NAME, + SHORT_TOOL_NAME, + LEGACY_NAMES, + GLOBAL_INSTALL_COMMAND, + OPENSPEC_PACKAGE, + OMC_PACKAGE, + OMC_REPO_URL, + CAVEMEM_PACKAGE, + NPX_BIN, + GUARDEX_HOME_DIR, + GLOBAL_TOOLCHAIN_SERVICES, + GLOBAL_TOOLCHAIN_PACKAGES, + OPTIONAL_LOCAL_COMPANION_TOOLS, + GH_BIN, + REQUIRED_SYSTEM_TOOLS, + MAINTAINER_RELEASE_REPO, + NPM_BIN, + OPENSPEC_BIN, + SCORECARD_BIN, + GIT_PROTECTED_BRANCHES_KEY, + GIT_BASE_BRANCH_KEY, + GIT_SYNC_STRATEGY_KEY, + GUARDEX_REPO_TOGGLE_ENV, + DEFAULT_PROTECTED_BRANCHES, + DEFAULT_BASE_BRANCH, + DEFAULT_SYNC_STRATEGY, + DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES, + COMPOSE_HINT_FILES, + TEMPLATE_ROOT, + HOOK_NAMES, + toDestinationPath, + TEMPLATE_FILES, + LEGACY_WORKFLOW_SHIM_SPECS, + LEGACY_WORKFLOW_SHIMS, + MANAGED_TEMPLATE_DESTINATIONS, + MANAGED_TEMPLATE_SCRIPT_FILES, + LEGACY_MANAGED_REPO_FILES, + REQUIRED_MANAGED_REPO_FILES, + LEGACY_MANAGED_PACKAGE_SCRIPTS, + PACKAGE_SCRIPT_ASSETS, + USER_LEVEL_SKILL_ASSETS, + EXECUTABLE_RELATIVE_PATHS, + CRITICAL_GUARDRAIL_PATHS, + LOCK_FILE_RELATIVE, + AGENTS_BOTS_STATE_RELATIVE, + AGENTS_MARKER_START, + AGENTS_MARKER_END, + GITIGNORE_MARKER_START, + GITIGNORE_MARKER_END, + CODEX_WORKTREE_RELATIVE_DIR, + CLAUDE_WORKTREE_RELATIVE_DIR, + AGENT_WORKTREE_RELATIVE_DIRS, + MANAGED_GITIGNORE_PATHS, + REPO_SCAFFOLD_DIRECTORIES, + OMX_SCAFFOLD_DIRECTORIES, + OMX_SCAFFOLD_FILES, + TARGETED_FORCEABLE_MANAGED_PATHS, + COMMAND_TYPO_ALIASES, + SUGGESTIBLE_COMMANDS, + CLI_COMMAND_DESCRIPTIONS, + DEPRECATED_COMMAND_ALIASES, + AGENT_BOT_DESCRIPTIONS, + DOCTOR_AUTO_FINISH_DETAIL_LIMIT, + DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX, + DOCTOR_AUTO_FINISH_MESSAGE_MAX, + envFlagIsTruthy, + isClaudeCodeSession, + defaultAgentWorktreeRelativeDir, + AI_SETUP_PROMPT, + AI_SETUP_COMMANDS, + SCORECARD_RISK_BY_CHECK, +}; diff --git a/src/core/runtime.js b/src/core/runtime.js new file mode 100644 index 0000000..c5044ca --- /dev/null +++ b/src/core/runtime.js @@ -0,0 +1,119 @@ +const { + fs, + path, + CLI_ENTRY_PATH, + PACKAGE_SCRIPT_ASSETS, +} = require('../context'); + +function requireValue(rawArgs, index, flagName) { + const value = rawArgs[index + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`${flagName} requires a value`); + } + return value; +} + +function run(cmd, args, options = {}) { + return require('node:child_process').spawnSync(cmd, args, { + encoding: 'utf8', + stdio: options.stdio || 'pipe', + cwd: options.cwd, + env: options.env ? { ...process.env, ...options.env } : process.env, + timeout: options.timeout, + }); +} + +function extractTargetedArgs(rawArgs, defaultTarget = process.cwd()) { + const passthrough = []; + let target = defaultTarget; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--target' || arg === '-t') { + target = requireValue(rawArgs, index, '--target'); + index += 1; + continue; + } + passthrough.push(arg); + } + + return { target, passthrough }; +} + +function packageAssetEnv(extraEnv = {}) { + return { + GUARDEX_CLI_ENTRY: CLI_ENTRY_PATH, + GUARDEX_NODE_BIN: process.execPath, + ...extraEnv, + }; +} + +function packageAssetPath(assetKey) { + const assetPath = PACKAGE_SCRIPT_ASSETS[assetKey]; + if (!assetPath) { + throw new Error(`Unknown package asset: ${assetKey}`); + } + if (!fs.existsSync(assetPath)) { + throw new Error(`Missing package asset: ${assetPath}`); + } + return assetPath; +} + +function runPackageAsset(assetKey, rawArgs, options = {}) { + const assetPath = packageAssetPath(assetKey); + let cmd = 'bash'; + if (assetPath.endsWith('.py')) { + cmd = 'python3'; + } else if (assetPath.endsWith('.js')) { + cmd = process.execPath; + } + return run(cmd, [assetPath, ...rawArgs], { + cwd: options.cwd || process.cwd(), + stdio: options.stdio || 'pipe', + timeout: options.timeout, + env: packageAssetEnv(options.env), + }); +} + +function repoLocalLegacyScriptPath(repoRoot, relativePath) { + const assetPath = path.join(repoRoot, relativePath); + return fs.existsSync(assetPath) ? assetPath : null; +} + +function runReviewBotCommand(repoRoot, rawArgs, options = {}) { + const legacyScript = repoLocalLegacyScriptPath(repoRoot, 'scripts/review-bot-watch.sh'); + if (legacyScript) { + return run('bash', [legacyScript, ...rawArgs], { + cwd: repoRoot, + stdio: options.stdio || 'pipe', + timeout: options.timeout, + env: packageAssetEnv(options.env), + }); + } + return runPackageAsset('reviewBot', rawArgs, { + ...options, + cwd: repoRoot, + }); +} + +function invokePackageAsset(assetKey, rawArgs, options = {}) { + const result = runPackageAsset(assetKey, rawArgs, options); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + if (result.status !== 0) { + throw new Error(`${assetKey} command failed with status ${result.status}`); + } + process.exitCode = 0; + return result; +} + +module.exports = { + run, + extractTargetedArgs, + packageAssetEnv, + packageAssetPath, + runPackageAsset, + repoLocalLegacyScriptPath, + runReviewBotCommand, + invokePackageAsset, +}; diff --git a/src/git/index.js b/src/git/index.js new file mode 100644 index 0000000..45edb88 --- /dev/null +++ b/src/git/index.js @@ -0,0 +1,112 @@ +const { path } = require('../context'); +const { run } = require('../core/runtime'); + +function gitRun(repoRoot, args, { allowFailure = false } = {}) { + const result = run('git', ['-C', repoRoot, ...args]); + if (!allowFailure && result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || '').trim()}`); + } + return result; +} + +function resolveRepoRoot(targetPath) { + const resolvedTarget = path.resolve(targetPath || process.cwd()); + const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']); + if (result.status !== 0) { + const stderr = (result.stderr || '').trim(); + throw new Error( + `Target is not inside a git repository: ${resolvedTarget}${stderr ? `\n${stderr}` : ''}`, + ); + } + return result.stdout.trim(); +} + +function isGitRepo(targetPath) { + const resolvedTarget = path.resolve(targetPath || process.cwd()); + const result = run('git', ['-C', resolvedTarget, 'rev-parse', '--show-toplevel']); + return result.status === 0; +} + +const NESTED_REPO_DEFAULT_MAX_DEPTH = 6; +const NESTED_REPO_DEFAULT_SKIP_DIRS = new Set([ + 'node_modules', + '.git', + 'dist', + 'build', + '.next', + '.cache', + 'target', + 'vendor', + '.venv', + '.pnpm-store', +]); + +function discoverNestedGitRepos(rootPath, opts = {}) { + const maxDepth = Number.isFinite(opts.maxDepth) + ? Math.max(1, opts.maxDepth) + : NESTED_REPO_DEFAULT_MAX_DEPTH; + const extraSkip = new Set(Array.isArray(opts.extraSkip) ? opts.extraSkip : []); + const includeSubmodules = Boolean(opts.includeSubmodules); + const resolvedRoot = path.resolve(rootPath); + + if (!isGitRepo(resolvedRoot)) { + throw new Error(`Target is not inside a git repository: ${resolvedRoot}`); + } + + const results = []; + const seen = new Set(); + + function visit(directoryPath, depth) { + const repoRoot = resolveRepoRoot(directoryPath); + if (!seen.has(repoRoot)) { + seen.add(repoRoot); + results.push(repoRoot); + } + + if (depth >= maxDepth) { + return; + } + + let entries = []; + try { + entries = require('node:fs').readdirSync(directoryPath, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + if (NESTED_REPO_DEFAULT_SKIP_DIRS.has(entry.name) || extraSkip.has(entry.name)) { + continue; + } + + const childPath = path.join(directoryPath, entry.name); + const gitDir = path.join(childPath, '.git'); + if (require('node:fs').existsSync(gitDir)) { + if (!includeSubmodules) { + const gitInfo = require('node:fs').lstatSync(gitDir); + if (gitInfo.isFile()) { + continue; + } + } + visit(childPath, depth + 1); + continue; + } + + visit(childPath, depth + 1); + } + } + + visit(resolvedRoot, 0); + return results; +} + +module.exports = { + DEFAULT_NESTED_REPO_MAX_DEPTH: NESTED_REPO_DEFAULT_MAX_DEPTH, + gitRun, + resolveRepoRoot, + isGitRepo, + discoverNestedGitRepos, +}; diff --git a/src/output/index.js b/src/output/index.js new file mode 100644 index 0000000..8d43f57 --- /dev/null +++ b/src/output/index.js @@ -0,0 +1,398 @@ +const { + path, + packageJson, + TOOL_NAME, + SHORT_TOOL_NAME, + LEGACY_NAMES, + GUARDEX_REPO_TOGGLE_ENV, + CLI_COMMAND_DESCRIPTIONS, + AGENT_BOT_DESCRIPTIONS, + DOCTOR_AUTO_FINISH_DETAIL_LIMIT, + DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX, + DOCTOR_AUTO_FINISH_MESSAGE_MAX, +} = require('../context'); + +function runtimeVersion() { + return `${packageJson.name}/${packageJson.version} ${process.platform}-${process.arch} node-${process.version}`; +} + +function supportsAnsiColors() { + const forced = String(process.env.FORCE_COLOR || '').trim().toLowerCase(); + if (['0', 'false', 'no', 'off'].includes(forced)) { + return false; + } + if (forced.length > 0) { + return true; + } + if (process.env.NO_COLOR) { + return false; + } + return Boolean(process.stdout.isTTY) && process.env.TERM !== 'dumb'; +} + +function colorize(text, colorCode) { + if (!supportsAnsiColors()) { + return text; + } + return `\u001B[${colorCode}m${text}\u001B[0m`; +} + +function doctorOutputColorCode(status) { + const normalized = String(status || '').trim().toLowerCase(); + if (['active', 'done', 'ok', 'safe', 'success'].includes(normalized)) { + return '32'; + } + if (normalized === 'disabled') { + return '36'; + } + if (['degraded', 'pending', 'skip', 'warn', 'warning'].includes(normalized)) { + return '33'; + } + if (['error', 'fail', 'inactive', 'unsafe'].includes(normalized)) { + return '31'; + } + return null; +} + +function colorizeDoctorOutput(text, status) { + const colorCode = doctorOutputColorCode(status); + return colorCode ? colorize(text, colorCode) : text; +} + +function detectAutoFinishDetailStatus(detail) { + const trimmed = String(detail || '').trim(); + const match = trimmed.match(/^\[(\w+)\]/); + if (match) { + return match[1].toLowerCase(); + } + if (/^Skipped\b/i.test(trimmed) || /^No local agent branches found\b/i.test(trimmed)) { + return 'skip'; + } + return null; +} + +function detectAutoFinishSummaryStatus(summary) { + if (!summary || summary.enabled === false) { + return detectAutoFinishDetailStatus(summary?.details?.[0]); + } + if ((summary.failed || 0) > 0) { + return 'fail'; + } + if ((summary.completed || 0) > 0) { + return 'done'; + } + if ((summary.skipped || 0) > 0) { + return 'skip'; + } + return null; +} + +function statusDot(status) { + if (status === 'active') { + return colorize('●', '32'); + } + if (status === 'inactive') { + return colorize('●', '31'); + } + if (status === 'disabled') { + return colorize('●', '36'); + } + return colorize('●', '33'); +} + +function commandCatalogLines(indent = ' ') { + const maxCommandLength = CLI_COMMAND_DESCRIPTIONS.reduce( + (max, [command]) => Math.max(max, command.length), + 0, + ); + return CLI_COMMAND_DESCRIPTIONS.map( + ([command, description]) => `${indent}${command.padEnd(maxCommandLength + 2)}${description}`, + ); +} + +function agentBotCatalogLines(indent = ' ') { + const maxCommandLength = AGENT_BOT_DESCRIPTIONS.reduce( + (max, [command]) => Math.max(max, command.length), + 0, + ); + return AGENT_BOT_DESCRIPTIONS.map( + ([command, description]) => `${indent}${command.padEnd(maxCommandLength + 2)}${description}`, + ); +} + +function repoToggleLines(indent = ' ') { + return [ + `${indent}Set repo-root .env: ${GUARDEX_REPO_TOGGLE_ENV}=0 disables Guardex, ${GUARDEX_REPO_TOGGLE_ENV}=1 enables it again`, + ]; +} + +function printToolLogsSummary() { + const usageLine = ` $ ${SHORT_TOOL_NAME} [options]`; + const commandDetails = commandCatalogLines(' '); + const agentBotDetails = agentBotCatalogLines(' '); + const repoToggleDetails = repoToggleLines(' '); + + if (!supportsAnsiColors()) { + console.log(`${TOOL_NAME}-tools logs:`); + console.log(' USAGE'); + console.log(usageLine); + console.log(' COMMANDS'); + for (const line of commandDetails) { + console.log(line); + } + console.log(' AGENT BOT'); + for (const line of agentBotDetails) { + console.log(line); + } + console.log(' REPO TOGGLE'); + for (const line of repoToggleDetails) { + console.log(line); + } + return; + } + + const title = colorize(`${TOOL_NAME}-tools logs`, '1;36'); + const usageHeader = colorize('USAGE', '1'); + const commandsHeader = colorize('COMMANDS', '1'); + const agentBotHeader = colorize('AGENT BOT', '1'); + const repoToggleHeader = colorize('REPO TOGGLE', '1'); + const pipe = colorize('│', '90'); + const tee = colorize('├', '90'); + const corner = colorize('└', '90'); + + console.log(`${title}:`); + console.log(` ${tee}─ ${usageHeader}`); + console.log(` ${pipe}${usageLine}`); + console.log(` ${tee}─ ${commandsHeader}`); + for (const line of commandDetails) { + if (!line) { + console.log(` ${pipe}`); + continue; + } + console.log(` ${pipe}${line.slice(2)}`); + } + console.log(` ${tee}─ ${agentBotHeader}`); + for (const line of agentBotDetails) { + if (!line) { + console.log(` ${pipe}`); + continue; + } + console.log(` ${pipe}${line.slice(2)}`); + } + console.log(` ${tee}─ ${repoToggleHeader}`); + for (const line of repoToggleDetails) { + if (!line) { + console.log(` ${pipe}`); + continue; + } + console.log(` ${pipe}${line.slice(2)}`); + } + console.log(` ${corner}─ ${colorize(`Try '${TOOL_NAME} doctor' for one-step repair + verification.`, '2')}`); +} + +function usage(options = {}) { + const { outsideGitRepo = false } = options; + + console.log(`A command-line tool that sets up hardened multi-agent safety for git repositories. + +VERSION + ${runtimeVersion()} + +USAGE + $ ${SHORT_TOOL_NAME} [options] + +COMMANDS +${commandCatalogLines().join('\n')} + +AGENT BOT +${agentBotCatalogLines().join('\n')} + +REPO TOGGLE +${repoToggleLines().join('\n')} + +NOTES + - No command = ${SHORT_TOOL_NAME} status. ${SHORT_TOOL_NAME} init is an alias of ${SHORT_TOOL_NAME} setup. + - Global installs need Y/N approval; GitHub CLI (gh) is required for PR automation. + - Target another repo: ${SHORT_TOOL_NAME} --target . + - On protected main, setup/install/fix/doctor auto-sandbox via agent branch + PR flow. + - Run '${SHORT_TOOL_NAME} cleanup' to prune merged agent branches/worktrees. + - Legacy aliases: ${LEGACY_NAMES.join(', ')}.`); + + if (outsideGitRepo) { + console.log(` +[${TOOL_NAME}] No git repository detected in current directory. +[${TOOL_NAME}] Start from a repo root, or pass an explicit target: + ${TOOL_NAME} setup --target + ${TOOL_NAME} doctor --target `); + } +} + +function formatElapsedDuration(ms) { + const durationMs = Number.isFinite(ms) ? Math.max(0, ms) : 0; + if (durationMs < 1000) { + return `${Math.round(durationMs)}ms`; + } + if (durationMs < 10_000) { + return `${(durationMs / 1000).toFixed(1)}s`; + } + return `${Math.round(durationMs / 1000)}s`; +} + +function truncateMiddle(value, maxLength) { + const text = String(value || ''); + const limit = Number.isFinite(maxLength) ? Math.max(4, maxLength) : 0; + if (!limit || text.length <= limit) { + return text; + } + + const visible = limit - 1; + const headLength = Math.ceil(visible / 2); + const tailLength = Math.floor(visible / 2); + return `${text.slice(0, headLength)}…${text.slice(text.length - tailLength)}`; +} + +function truncateTail(value, maxLength) { + const text = String(value || ''); + const limit = Number.isFinite(maxLength) ? Math.max(4, maxLength) : 0; + if (!limit || text.length <= limit) { + return text; + } + return `${text.slice(0, limit - 1)}…`; +} + +function compactAutoFinishPathSegments(message) { + return String(message || '').replace(/\((\/[^)]+)\)/g, (_, rawPath) => { + if ( + rawPath.includes(`${path.sep}.omx${path.sep}agent-worktrees${path.sep}`) || + rawPath.includes(`${path.sep}.omc${path.sep}agent-worktrees${path.sep}`) + ) { + return `(${path.basename(rawPath)})`; + } + return `(${truncateMiddle(rawPath, 72)})`; + }); +} + +function detectRecoverableAutoFinishConflict(message) { + const text = String(message || '').trim(); + if (!text) { + return null; + } + + if (/rebase --continue/i.test(text) && /rebase --abort/i.test(text)) { + return { + rawLabel: 'auto-finish requires manual rebase.', + summary: 'manual rebase required in the source-probe worktree; run rebase --continue or rebase --abort', + }; + } + + if (/Rebase\/merge '.+' into '.+' and resolve conflicts before finishing\./i.test(text)) { + return { + rawLabel: 'auto-finish requires manual rebase or merge.', + summary: 'manual rebase or merge required before auto-finish can continue', + }; + } + + if (/Merge conflict detected while merging/i.test(text)) { + return { + rawLabel: 'auto-finish requires manual merge resolution.', + summary: 'manual merge resolution required before auto-finish can continue', + }; + } + + return null; +} + +function summarizeAutoFinishDetail(detail) { + const trimmed = String(detail || '').trim(); + const match = trimmed.match(/^\[(\w+)\]\s+([^:]+):\s*(.*)$/); + if (!match) { + return truncateTail(compactAutoFinishPathSegments(trimmed), DOCTOR_AUTO_FINISH_MESSAGE_MAX); + } + + const [, status, rawBranch, rawMessage] = match; + const branch = truncateMiddle(rawBranch, DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX); + let message = String(rawMessage || '').trim(); + const recoverableConflict = status === 'skip' ? detectRecoverableAutoFinishConflict(message) : null; + + if (recoverableConflict) { + message = recoverableConflict.summary; + } else if (status === 'fail') { + message = message.replace(/^auto-finish failed\.?\s*/i, ''); + if (/\[agent-sync-guard\]/.test(message) && /Resolve conflicts/i.test(message)) { + message = 'rebase conflict in finish flow; run rebase --continue or rebase --abort in the source-probe worktree'; + } else if (/unable to compute ahead\/behind/i.test(message)) { + const aheadBehindMatch = message.match(/unable to compute ahead\/behind(?: \([^)]+\))?/i); + if (aheadBehindMatch) { + message = aheadBehindMatch[0]; + } + } else if (/remote ref does not exist/i.test(message)) { + message = 'branch merged, but the remote ref was already removed during cleanup'; + } + } + + message = compactAutoFinishPathSegments(message) + .replace(/\s+\|\s+/g, '; ') + .trim(); + + return `[${status}] ${branch}: ${truncateTail(message, DOCTOR_AUTO_FINISH_MESSAGE_MAX)}`; +} + +function printAutoFinishSummary(summary, options = {}) { + const enabled = Boolean(summary && summary.enabled); + const details = Array.isArray(summary && summary.details) ? summary.details : []; + const baseBranch = String(options.baseBranch || summary?.baseBranch || '').trim(); + const verbose = Boolean(options.verbose); + const detailLimit = Number.isFinite(options.detailLimit) + ? Math.max(0, options.detailLimit) + : DOCTOR_AUTO_FINISH_DETAIL_LIMIT; + + if (enabled) { + console.log( + colorizeDoctorOutput( + `[${TOOL_NAME}] Auto-finish sweep (base=${baseBranch}): attempted=${summary.attempted}, completed=${summary.completed}, skipped=${summary.skipped}, failed=${summary.failed}`, + detectAutoFinishSummaryStatus(summary), + ), + ); + const visibleDetails = verbose ? details : details.slice(0, detailLimit).map(summarizeAutoFinishDetail); + for (const detail of visibleDetails) { + console.log(colorizeDoctorOutput(`[${TOOL_NAME}] ${detail}`, detectAutoFinishDetailStatus(detail))); + } + if (!verbose && details.length > visibleDetails.length) { + console.log( + colorizeDoctorOutput( + `[${TOOL_NAME}] … ${details.length - visibleDetails.length} more branch result(s). Re-run with --verbose-auto-finish for full details.`, + 'warn', + ), + ); + } + return; + } + + if (details.length > 0) { + const detail = verbose ? details[0] : summarizeAutoFinishDetail(details[0]); + console.log(colorizeDoctorOutput(`[${TOOL_NAME}] ${detail}`, detectAutoFinishDetailStatus(detail))); + } +} + +module.exports = { + runtimeVersion, + supportsAnsiColors, + colorize, + doctorOutputColorCode, + colorizeDoctorOutput, + detectAutoFinishDetailStatus, + detectAutoFinishSummaryStatus, + statusDot, + commandCatalogLines, + agentBotCatalogLines, + repoToggleLines, + printToolLogsSummary, + usage, + formatElapsedDuration, + truncateMiddle, + truncateTail, + compactAutoFinishPathSegments, + detectRecoverableAutoFinishConflict, + summarizeAutoFinishDetail, + printAutoFinishSummary, +}; diff --git a/src/scaffold/index.js b/src/scaffold/index.js new file mode 100644 index 0000000..d6b3b08 --- /dev/null +++ b/src/scaffold/index.js @@ -0,0 +1,169 @@ +const { + fs, + path, + TOOL_NAME, + SHORT_TOOL_NAME, + EXECUTABLE_RELATIVE_PATHS, + CRITICAL_GUARDRAIL_PATHS, +} = require('../context'); + +function toDestinationPath(relativeTemplatePath) { + if (relativeTemplatePath.startsWith('scripts/')) { + return relativeTemplatePath; + } + if (relativeTemplatePath.startsWith('githooks/')) { + return `.${relativeTemplatePath}`; + } + if (relativeTemplatePath.startsWith('codex/')) { + return `.${relativeTemplatePath}`; + } + if (relativeTemplatePath.startsWith('claude/')) { + return `.${relativeTemplatePath}`; + } + if (relativeTemplatePath.startsWith('github/')) { + return `.${relativeTemplatePath}`; + } + if (relativeTemplatePath.startsWith('vscode/')) { + return relativeTemplatePath; + } + throw new Error(`Unsupported template path: ${relativeTemplatePath}`); +} + +function ensureParentDir(repoRoot, filePath, dryRun) { + if (dryRun) return; + + const parentDir = path.dirname(filePath); + const relativeParentDir = path.relative(repoRoot, parentDir); + const segments = relativeParentDir.split(path.sep).filter(Boolean); + let currentPath = repoRoot; + + for (const segment of segments) { + currentPath = path.join(currentPath, segment); + if (fs.existsSync(currentPath) && !fs.statSync(currentPath).isDirectory()) { + const blockingPath = path.relative(repoRoot, currentPath) || path.basename(currentPath); + const targetPath = path.relative(repoRoot, filePath) || path.basename(filePath); + throw new Error( + `Path conflict: ${blockingPath} exists as a file, but ${targetPath} needs it to be a directory. ` + + `Remove or rename ${blockingPath} and rerun '${SHORT_TOOL_NAME} setup'.`, + ); + } + } + + fs.mkdirSync(parentDir, { recursive: true }); +} + +function ensureExecutable(destinationPath, relativePath, dryRun) { + if (dryRun) return; + if (EXECUTABLE_RELATIVE_PATHS.has(relativePath)) { + fs.chmodSync(destinationPath, 0o755); + } +} + +function isCriticalGuardrailPath(relativePath) { + return CRITICAL_GUARDRAIL_PATHS.has(relativePath); +} + +function shellSingleQuote(value) { + return `'${String(value).replace(/'/g, `'\"'\"'`)}'`; +} + +function renderShellDispatchShim(commandParts) { + const rendered = commandParts.map((part) => shellSingleQuote(part)).join(' '); + return ( + '#!/usr/bin/env bash\n' + + 'set -euo pipefail\n' + + '\n' + + 'if [[ -n "${GUARDEX_CLI_ENTRY:-}" ]]; then\n' + + ' node_bin="${GUARDEX_NODE_BIN:-node}"\n' + + ` exec "$node_bin" "$GUARDEX_CLI_ENTRY" ${rendered} "$@"\n` + + 'fi\n' + + '\n' + + 'resolve_guardex_cli() {\n' + + ' if [[ -n "${GUARDEX_CLI_BIN:-}" ]]; then\n' + + ' printf \'%s\' "$GUARDEX_CLI_BIN"\n' + + ' return 0\n' + + ' fi\n' + + ' if command -v gx >/dev/null 2>&1; then\n' + + ' printf \'%s\' "gx"\n' + + ' return 0\n' + + ' fi\n' + + ' if command -v gitguardex >/dev/null 2>&1; then\n' + + ' printf \'%s\' "gitguardex"\n' + + ' return 0\n' + + ' fi\n' + + ' echo "[gitguardex-shim] Missing gx CLI in PATH." >&2\n' + + ' exit 1\n' + + '}\n' + + '\n' + + 'cli_bin="$(resolve_guardex_cli)"\n' + + `exec "$cli_bin" ${rendered} "$@"\n` + ); +} + +function renderPythonDispatchShim(commandParts) { + return ( + '#!/usr/bin/env python3\n' + + 'import os\n' + + 'import shutil\n' + + 'import subprocess\n' + + 'import sys\n' + + '\n' + + `COMMAND = ${JSON.stringify(commandParts)}\n` + + '\n' + + 'entry = os.environ.get("GUARDEX_CLI_ENTRY")\n' + + 'if entry:\n' + + ' node_bin = os.environ.get("GUARDEX_NODE_BIN") or shutil.which("node") or "node"\n' + + ' raise SystemExit(subprocess.call([node_bin, entry, *COMMAND, *sys.argv[1:]]))\n' + + 'cli = os.environ.get("GUARDEX_CLI_BIN") or shutil.which("gx") or shutil.which("gitguardex")\n' + + 'if not cli:\n' + + ' sys.stderr.write("[gitguardex-shim] Missing gx CLI in PATH.\\n")\n' + + ' raise SystemExit(1)\n' + + 'raise SystemExit(subprocess.call([cli, *COMMAND, *sys.argv[1:]]))\n' + ); +} + +function managedForceConflictMessage(relativePath) { + return ( + `Refusing to overwrite existing file without --force: ${relativePath}\n` + + `Use '--force ${relativePath}' to rewrite only this managed file, or '--force' to rewrite all managed files.` + ); +} + +function printOperations(title, payload, dryRun = false) { + console.log(`[${TOOL_NAME}] ${title}: ${payload.repoRoot}`); + for (const operation of payload.operations) { + const note = operation.note ? ` (${operation.note})` : ''; + console.log(` - ${operation.status.padEnd(12)} ${operation.file}${note}`); + } + console.log( + ` - hooksPath ${payload.hookResult.status} ${payload.hookResult.key}=${payload.hookResult.value}`, + ); + + if (dryRun) { + console.log(`[${TOOL_NAME}] Dry run complete. No files were modified.`); + } +} + +function printStandaloneOperations(title, rootLabel, operations, dryRun = false) { + console.log(`[${TOOL_NAME}] ${title}: ${rootLabel}`); + for (const operation of operations) { + const note = operation.note ? ` (${operation.note})` : ''; + console.log(` - ${operation.status.padEnd(12)} ${operation.file}${note}`); + } + if (dryRun) { + console.log(`[${TOOL_NAME}] Dry run complete. No files were modified.`); + } +} + +module.exports = { + toDestinationPath, + ensureParentDir, + ensureExecutable, + isCriticalGuardrailPath, + shellSingleQuote, + renderShellDispatchShim, + renderPythonDispatchShim, + managedForceConflictMessage, + printOperations, + printStandaloneOperations, +}; diff --git a/test/metadata.test.js b/test/metadata.test.js index c67cb2c..e163695 100644 --- a/test/metadata.test.js +++ b/test/metadata.test.js @@ -146,6 +146,7 @@ test('thin CLI entrypoint delegates to src/cli runtime', () => { const entrySource = fs.readFileSync(entryPath, 'utf8'); assert.match(entrySource, /require\('\.\.\/src\/cli\/main'\)/); assert.match(entrySource, /runFromBin\(\)/); + assert.ok((fs.statSync(entryPath).mode & 0o111) !== 0, 'bin/multiagent-safety.js must stay executable'); }); test('package manifest ships the extracted src runtime', () => {