From b94e1a7814fc1cbd835c22ef9617f51a595b0c72 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 22 Apr 2026 14:42:53 +0200 Subject: [PATCH] Make extracted CLI modules the only source of truth main.js was still carrying drift-prone mirrors of context, output, and scaffold helpers even after the earlier seam extraction. This cleanup switches the runtime onto the shared modules, removes dead stubs that were never wired into the command surface, and locks the concrete drift cases with focused tests. Constraint: Keep setup/doctor/install behavior stable while deleting duplicate helper ownership Rejected: Keep local mirrors in main.js | preserves help/gitignore/release drift across modules Confidence: high Scope-risk: moderate Directive: Add future shared CLI helpers in extracted modules first, then import them into main.js instead of reintroducing local copies Tested: node --check src/cli/main.js src/context.js src/output/index.js src/scaffold/index.js; node --test test/cli-args-dispatch.test.js; node --test test/cli-args-dispatch.test.js test/setup.test.js test/doctor.test.js test/install.test.js test/metadata.test.js; openspec validate agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29 --type change --strict; openspec validate --specs; npm test Not-tested: gx branch finish / PR merge automation --- .../proposal.md | 17 + .../specs/cli-modularization/spec.md | 21 + .../tasks.md | 40 + src/cli/main.js | 1283 ++--------------- src/context.js | 3 + src/scaffold/index.js | 23 +- test/cli-args-dispatch.test.js | 44 +- test/metadata.test.js | 6 +- 8 files changed, 214 insertions(+), 1223 deletions(-) create mode 100644 openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/proposal.md create mode 100644 openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/specs/cli-modularization/spec.md create mode 100644 openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/tasks.md diff --git a/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/proposal.md b/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/proposal.md new file mode 100644 index 0000000..bf27f68 --- /dev/null +++ b/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/proposal.md @@ -0,0 +1,17 @@ +## Why + +- `src/cli/main.js` still mirrors constants and helper implementations that already exist in `src/context.js`, `src/output/index.js`, and `src/scaffold/index.js`. +- Those mirrors already drifted: `MANAGED_GITIGNORE_PATHS`, `CLI_COMMAND_DESCRIPTIONS`, and `MAINTAINER_RELEASE_REPO` disagree across modules, so whichever copy a caller reaches changes behavior. +- The current follow-up should stay mechanical: delete dead/duplicate helpers, make the extracted modules the only source of truth, and lock the drift cases with focused tests. + +## What Changes + +- Import shared constants/session helpers from `src/context.js` instead of redefining them in `src/cli/main.js`. +- Import presentation helpers from `src/output/index.js` and scaffold/file-install helpers from `src/scaffold/index.js`, then delete the local mirrors in `src/cli/main.js`. +- Remove dead or duplicated helpers in `src/cli/main.js`, including the duplicate `gitRefExists`, duplicate auto-finish failure log, unused command handlers, unused prompt helper, and redundant truthy-flag wrapper. +- Add focused regression coverage for the shared-source ownership and the concrete drift cases (`--current` help text, `.vscode` gitignore exceptions, and maintainer release repo resolution). + +## Impact + +- Primary files: `src/cli/main.js`, `src/context.js`, `src/output/index.js`, `src/scaffold/index.js`, and `test/cli-args-dispatch.test.js`. +- Main risk is behavior drift while deleting local copies, so verification stays focused on syntax plus targeted CLI regression suites before broader checks. diff --git a/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/specs/cli-modularization/spec.md b/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/specs/cli-modularization/spec.md new file mode 100644 index 0000000..3acfa7a --- /dev/null +++ b/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/specs/cli-modularization/spec.md @@ -0,0 +1,21 @@ +## MODIFIED Requirements + +### Requirement: Module seams mirror operational responsibility +The CLI SHALL keep shared helper ownership in the extracted `src/` modules instead of duplicating the same constants or helper implementations in `src/cli/main.js`. + +#### Scenario: Shared context/output/scaffold seams stay single-source +- **WHEN** maintainers inspect `src/cli/main.js` +- **THEN** shared constants and session helpers are imported from `src/context.js` +- **AND** presentation helpers are imported from `src/output/index.js` +- **AND** scaffold/file-install helpers are imported from `src/scaffold/index.js` +- **AND** `src/cli/main.js` does not redefine those helpers locally. + +### Requirement: Refactor preserves targeted CLI behavior +The modularization SHALL preserve the current command surface for targeted verified flows while deleting the local duplicate helpers. + +#### Scenario: Shared-source drift cases remain stable after cleanup +- **WHEN** focused CLI regression suites are run after the helper cleanup +- **THEN** setup and doctor help continue advertising `--current` +- **AND** managed gitignore handling preserves the `.vscode` exceptions required for shared settings +- **AND** release gating resolves the default maintainer repo to the package root instead of the `src/` directory +- **AND** syntax/require-time failures do not occur from duplicate helper definitions. diff --git a/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/tasks.md b/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/tasks.md new file mode 100644 index 0000000..ddf7f31 --- /dev/null +++ b/openspec/changes/agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29/tasks.md @@ -0,0 +1,40 @@ +## 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, add a `BLOCKED:` line under section 4 and stop. + +## Handoff + +- Handoff: change=`agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29`; branch=`agent/codex/dedupe-cli-main-context-output-scaffold-2026-04-22-14-29`; scope=`src/cli/main.js`, `src/context.js`, `src/output/index.js`, `src/scaffold/index.js`, `test/cli-args-dispatch.test.js`; action=`delete remaining shared-helper duplication from src/cli/main.js and keep context/output/scaffold as the only live source of truth`. + +## 1. Specification + +- [x] 1.1 Capture the mechanical cleanup scope and acceptance criteria for the remaining shared helper duplication. +- [x] 1.2 Add a `cli-modularization` spec delta covering context/output/scaffold single-source ownership and the known drift cases. + +## 2. Implementation + +- [x] 2.1 Add focused regression coverage for shared-source ownership and the known drift cases before editing the cleanup targets. +- [x] 2.2 Move duplicated constants/session helpers in `src/cli/main.js` to `src/context.js` imports and reconcile `MANAGED_GITIGNORE_PATHS`. +- [x] 2.3 Move duplicated presentation helpers in `src/cli/main.js` to `src/output/index.js` imports. +- [x] 2.4 Move duplicated scaffold/file-install helpers in `src/cli/main.js` to `src/scaffold/index.js` imports and make scaffold reuse `context`'s `toDestinationPath`. +- [x] 2.5 Remove dead or duplicate helpers from `src/cli/main.js` (`installMany`, `initWorkspace`, `doctorAudit`, `syncDoctorLocalSupportFiles`, `promptYesNo`, `envFlagEnabled`, one `gitRefExists`, and the duplicate auto-finish failure log). + +## 3. Verification + +- [x] 3.1 Run `node --check src/cli/main.js src/context.js src/output/index.js src/scaffold/index.js`. +- [x] 3.2 Run `node --test test/cli-args-dispatch.test.js`. +- [x] 3.3 Run focused CLI regression suites that cover setup/doctor/install surfaces touched by the cleanup. +- [x] 3.4 Run `openspec validate agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29 --type change --strict`. +- [x] 3.5 Run `openspec validate --specs`. + +Verification note: `node --check src/cli/main.js src/context.js src/output/index.js src/scaffold/index.js`, `node --test test/cli-args-dispatch.test.js`, `node --test test/cli-args-dispatch.test.js test/setup.test.js test/doctor.test.js test/install.test.js test/metadata.test.js`, and `npm test` all passed after the cleanup. `openspec validate agent-codex-dedupe-cli-main-context-output-scaffold-2026-04-22-14-29 --type change --strict` exited `0`, and `openspec validate --specs` exited `0` with `No items found to validate`. + +## 4. Cleanup + +- [ ] 4.1 Run `gx branch finish --branch agent/codex/dedupe-cli-main-context-output-scaffold-2026-04-22-14-29 --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 4.2 Record PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is removed and no local/remote refs remain for the branch. diff --git a/src/cli/main.js b/src/cli/main.js index 2801de4..3836c67 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -1,13 +1,65 @@ #!/usr/bin/env node -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const cp = require('node:child_process'); const hooksModule = require('../hooks'); const sandboxModule = require('../sandbox'); const toolchainModule = require('../toolchain'); const finishModule = require('../finish'); +const { + fs, + path, + cp, + packageJson, + TOOL_NAME, + SHORT_TOOL_NAME, + OPENSPEC_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, + COMPOSE_HINT_FILES, + TEMPLATE_ROOT, + HOOK_NAMES, + TEMPLATE_FILES, + LEGACY_WORKFLOW_SHIM_SPECS, + 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, + AGENT_WORKTREE_RELATIVE_DIRS, + MANAGED_GITIGNORE_PATHS, + REPO_SCAFFOLD_DIRECTORIES, + OMX_SCAFFOLD_DIRECTORIES, + OMX_SCAFFOLD_FILES, + TARGETED_FORCEABLE_MANAGED_PATHS, + DEPRECATED_COMMAND_ALIASES, + defaultAgentWorktreeRelativeDir, + AI_SETUP_PROMPT, + AI_SETUP_COMMANDS, + SCORECARD_RISK_BY_CHECK, +} = require('../context'); const { gitRun, resolveRepoRoot, @@ -42,348 +94,43 @@ const { warnDeprecatedAlias, extractFlag, } = require('./dispatch'); +const { + runtimeVersion, + colorize, + colorizeDoctorOutput, + statusDot, + printToolLogsSummary, + usage, + formatElapsedDuration, + compactAutoFinishPathSegments, + detectRecoverableAutoFinishConflict, + printAutoFinishSummary, +} = require('../output'); +const { + toDestinationPath, + ensureParentDir, + ensureExecutable, + isCriticalGuardrailPath, + shellSingleQuote, + renderShellDispatchShim, + renderPythonDispatchShim, + managedForceConflictMessage, + printOperations, + printStandaloneOperations, +} = require('../scaffold'); let sandboxApi; let toolchainApi; let finishApi; -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(__dirname, '..'), -); -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 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']; - -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 SHARED_VSCODE_SETTINGS_RELATIVE = path.posix.join('.vscode', 'settings.json'); const REPO_SCAN_IGNORED_FOLDERS_SETTING = 'git.repositoryScanIgnoredFolders'; -const AGENT_WORKTREE_RELATIVE_DIRS = [ - CODEX_WORKTREE_RELATIVE_DIR, - CLAUDE_WORKTREE_RELATIVE_DIR, -]; const MANAGED_REPO_SCAN_IGNORED_FOLDERS = [ '.omx/agent-worktrees', '**/.omx/agent-worktrees', '.omc/agent-worktrees', '**/.omc/agent-worktrees', ]; -const MANAGED_GITIGNORE_PATHS = [ - '.omx/', - '.omc/', - '!.vscode/', - '.vscode/*', - '!.vscode/settings.json', - '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 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', - // deprecated aliases still routable with a warning - '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 getSandboxApi() { if (!sandboxApi) { @@ -465,114 +212,6 @@ function getFinishApi() { return finishApi; } -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', -}; - -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; -} - /** * @typedef {Object} AutoFinishSummary * @property {boolean} [enabled] @@ -627,456 +266,6 @@ function colorizeDoctorOutput(text, status) { * @property {AutoFinishSummary} autoFinish * @property {string | null} sandboxLockContent */ - -/** - * @param {string | null | undefined} detail - * @returns {string | null} - */ -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; -} - -/** - * @param {AutoFinishSummary | null | undefined} summary - * @returns {string | 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'); // green - } - if (status === 'inactive') { - return colorize('●', '31'); // red - } - if (status === 'disabled') { - return colorize('●', '36'); // cyan - } - return colorize('●', '33'); // yellow for degraded/unknown -} - -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)}`; -} - -/** - * @param {AutoFinishSummary | null | undefined} summary - * @param {{ baseBranch?: string, verbose?: boolean, detailLimit?: number }} [options] - */ -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 > detailLimit) { - console.log( - colorizeDoctorOutput( - `[${TOOL_NAME}] … ${details.length - detailLimit} 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))); - } -} - -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 renderManagedFile(repoRoot, relativePath, content, options = {}) { const destinationPath = path.join(repoRoot, relativePath); const destinationExists = fs.existsSync(destinationPath); @@ -1891,10 +1080,6 @@ function protectedBaseSandboxWorktreePath(repoRoot, branchName) { return path.join(repoRoot, defaultAgentWorktreeRelativeDir(), branchName.replace(/\//g, '__')); } -function gitRefExists(repoRoot, ref) { - return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0; -} - function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) { run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 }); if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) { @@ -2532,10 +1717,6 @@ function mergeDoctorSandboxRepairsBackToProtectedBase(options, blocked, metadata }; } -function syncDoctorLocalSupportFiles(repoRoot, dryRun) { - return []; -} - /** * @param {string} [note] * @returns {OperationResult} @@ -2732,8 +1913,6 @@ function executeDoctorSandboxLifecycle(options, blocked, metadata) { execution.finish, ); - syncDoctorLocalSupportFiles(blocked.repoRoot, dryRun); - execution.omxScaffoldSync = summarizeDoctorOmxScaffoldSync(blocked.repoRoot, dryRun); execution.lockSync = syncDoctorLockRegistryAfterMerge( blocked.repoRoot, @@ -2850,7 +2029,6 @@ function emitDoctorSandboxConsoleOutput(options, blocked, metadata, startResult, if (execution.finish.stdout) process.stdout.write(execution.finish.stdout); if (execution.finish.stderr) process.stderr.write(execution.finish.stderr); } else if (execution.finish.status === 'failed') { - console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`); console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`); if (execution.finish.stdout) process.stdout.write(execution.finish.stdout); if (execution.finish.stderr) process.stderr.write(execution.finish.stderr); @@ -3968,31 +3146,6 @@ function readSingleLineFromStdin() { } } -function promptYesNo(question, defaultYes = true) { - const hint = defaultYes ? '[Y/n]' : '[y/N]'; - while (true) { - process.stdout.write(`${question} ${hint} `); - const answer = readSingleLineFromStdin().trim().toLowerCase(); - - if (!answer) { - return defaultYes; - } - if (answer === 'y' || answer === 'yes') { - return true; - } - if (answer === 'n' || answer === 'no') { - return false; - } - process.stdout.write('Please answer with y or n.\n'); - } -} - -function envFlagEnabled(name) { - const raw = process.env[name]; - if (raw == null) return false; - return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase()); -} - function parseAutoApproval(name) { const raw = process.env[name]; if (raw == null) return null; @@ -4120,11 +3273,11 @@ function parseNpmVersionOutput(stdout) { } function checkForGuardexUpdate() { - if (envFlagEnabled('GUARDEX_SKIP_UPDATE_CHECK')) { + if (parseBooleanLike(process.env.GUARDEX_SKIP_UPDATE_CHECK) === true) { return { checked: false, reason: 'disabled' }; } - const forceCheck = envFlagEnabled('GUARDEX_FORCE_UPDATE_CHECK'); + const forceCheck = parseBooleanLike(process.env.GUARDEX_FORCE_UPDATE_CHECK) === true; if (!forceCheck && !isInteractiveTerminal()) { return { checked: false, reason: 'non-interactive' }; } @@ -4244,11 +3397,12 @@ function restartIntoUpdatedGuardex(expectedVersion) { } function checkForOpenSpecPackageUpdate() { - if (envFlagEnabled('GUARDEX_SKIP_OPENSPEC_UPDATE_CHECK')) { + if (parseBooleanLike(process.env.GUARDEX_SKIP_OPENSPEC_UPDATE_CHECK) === true) { return { checked: false, reason: 'disabled' }; } - const forceCheck = envFlagEnabled('GUARDEX_FORCE_OPENSPEC_UPDATE_CHECK'); + const forceCheck = + parseBooleanLike(process.env.GUARDEX_FORCE_OPENSPEC_UPDATE_CHECK) === true; if (!forceCheck && !isInteractiveTerminal()) { return { checked: false, reason: 'non-interactive' }; } @@ -4757,21 +3911,6 @@ function runScanInternal(options) { }; } -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 printScanResult(scan, json = false) { if (json) { process.stdout.write( @@ -6082,247 +5221,6 @@ function release(rawArgs) { process.exitCode = 0; } -function installMany(rawArgs) { - const options = parseInstallManyArgs(rawArgs); - const targets = collectInstallManyTargets(options); - - if (!targets.length) { - throw new Error('install-many did not find any targets to process.'); - } - - if (options.usedImplicitWorkspaceDefault) { - console.log( - `[multiagent-safety] No explicit targets provided. Defaulting to workspace scan: ${path.resolve( - options.workspace, - )} (max depth ${options.maxDepth})`, - ); - } - - console.log( - `[multiagent-safety] install-many starting for ${targets.length} target path(s)${ - options.dryRun ? ' [dry-run]' : '' - }`, - ); - - let installed = 0; - let duplicateRepos = 0; - const seenRepoRoots = new Set(); - const failures = []; - - for (const targetPath of targets) { - let repoRoot; - try { - repoRoot = resolveRepoRoot(targetPath); - } catch (error) { - failures.push({ target: targetPath, message: error.message }); - if (options.failFast) { - break; - } - continue; - } - - if (seenRepoRoots.has(repoRoot)) { - duplicateRepos += 1; - console.log(`[multiagent-safety] Skipping duplicate repo target: ${targetPath} -> ${repoRoot}`); - continue; - } - - seenRepoRoots.add(repoRoot); - - try { - const report = installIntoRepoRoot(repoRoot, options); - printInstallReport(report); - installed += 1; - } catch (error) { - failures.push({ target: repoRoot, message: error.message }); - if (options.failFast) { - break; - } - } - } - - console.log( - `[multiagent-safety] install-many summary: installed=${installed}, failures=${failures.length}, duplicate-targets=${duplicateRepos}`, - ); - - if (failures.length > 0) { - console.error('[multiagent-safety] Failed targets:'); - for (const failure of failures) { - console.error(` - ${failure.target}`); - console.error(` ${failure.message}`); - } - throw new Error(`install-many completed with ${failures.length} failure(s)`); - } - - if (options.dryRun) { - console.log('[multiagent-safety] Dry run complete. No files were modified.'); - } else { - console.log('[multiagent-safety] Installed multi-agent safety workflow across all targets.'); - } -} - -function initWorkspace(rawArgs) { - const options = parseInitWorkspaceArgs(rawArgs); - const resolvedWorkspace = path.resolve(options.workspace); - const repos = discoverGitRepos(resolvedWorkspace, options.maxDepth) - .map((repoPath) => path.resolve(repoPath)) - .sort(); - - const outputPath = options.output - ? path.resolve(options.output) - : path.join(resolvedWorkspace, DEFAULT_WORKSPACE_TARGETS_FILE); - - if (fs.existsSync(outputPath) && !options.force) { - throw new Error(`Refusing to overwrite existing file without --force: ${outputPath}`); - } - - const headerLines = [ - '# multiagent-safety workspace targets', - `# generated: ${new Date().toISOString()}`, - `# workspace: ${resolvedWorkspace}`, - `# max-depth: ${options.maxDepth}`, - '#', - '# Run:', - `# multiagent-safety install-many --targets-file "${outputPath}"`, - '', - ]; - const content = `${headerLines.join('\n')}${repos.join('\n')}${repos.length ? '\n' : ''}`; - - fs.mkdirSync(path.dirname(outputPath), { recursive: true }); - fs.writeFileSync(outputPath, content, 'utf8'); - - console.log(`[multiagent-safety] Workspace target file written: ${outputPath}`); - console.log(`[multiagent-safety] Repos discovered: ${repos.length}`); - if (repos.length === 0) { - console.log('[multiagent-safety] No git repos found. You can add target paths manually to the file.'); - } else { - console.log(`[multiagent-safety] Next step: multiagent-safety install-many --targets-file "${outputPath}"`); - } -} - -function doctorAudit(rawArgs) { - const options = parseDoctorArgs(rawArgs); - const repoRoot = resolveRepoRoot(options.target); - const guardexToggle = resolveGuardexRepoToggle(repoRoot); - const failures = []; - const warnings = []; - - function ok(message) { - console.log(` [ok] ${message}`); - } - function warn(message) { - warnings.push(message); - console.log(` [warn] ${message}`); - } - function fail(message) { - failures.push(message); - console.log(` [fail] ${message}`); - } - - console.log(`[multiagent-safety] doctor target: ${repoRoot}`); - if (!guardexToggle.enabled) { - console.log( - `[multiagent-safety] Guardex is disabled for this repo (${describeGuardexRepoToggle(guardexToggle)}).`, - ); - console.log('[multiagent-safety] doctor passed.'); - return; - } - - const hooksPath = run('git', ['-C', repoRoot, 'config', '--get', 'core.hooksPath']); - if (hooksPath.status !== 0) { - fail('git core.hooksPath is not configured'); - } else if (hooksPath.stdout.trim() !== '.githooks') { - fail(`git core.hooksPath is "${hooksPath.stdout.trim()}" (expected ".githooks")`); - } else { - ok('git core.hooksPath is .githooks'); - } - - for (const relativePath of REQUIRED_MANAGED_REPO_FILES) { - const absolutePath = path.join(repoRoot, relativePath); - if (!fs.existsSync(absolutePath)) { - fail(`missing ${relativePath}`); - continue; - } - ok(`found ${relativePath}`); - - if (EXECUTABLE_RELATIVE_PATHS.has(relativePath)) { - try { - fs.accessSync(absolutePath, fs.constants.X_OK); - } catch { - fail(`${relativePath} exists but is not executable`); - } - } - } - - const lockFilePath = path.join(repoRoot, '.omx/state/agent-file-locks.json'); - if (fs.existsSync(lockFilePath)) { - try { - const parsed = JSON.parse(fs.readFileSync(lockFilePath, 'utf8')); - if (!parsed || typeof parsed !== 'object' || typeof parsed.locks !== 'object') { - fail('.omx/state/agent-file-locks.json does not contain a valid { locks: {} } object'); - } else { - ok('lock registry JSON is valid'); - } - } catch (error) { - fail(`lock registry JSON is invalid: ${error.message}`); - } - } - - const packagePath = path.join(repoRoot, 'package.json'); - if (!fs.existsSync(packagePath)) { - warn('package.json not found (legacy agent:* script drift cannot be checked)'); - } else { - try { - const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); - const scripts = pkg.scripts || {}; - const legacyAgentScripts = Object.entries(LEGACY_MANAGED_PACKAGE_SCRIPTS) - .filter(([name, expectedValue]) => scripts[name] === expectedValue) - .map(([name]) => name); - if (legacyAgentScripts.length > 0) { - warn(`legacy agent:* package.json scripts remain (${legacyAgentScripts.join(', ')}); run '${SHORT_TOOL_NAME} migrate' to remove them`); - } else { - ok('package.json does not contain Guardex-managed agent:* helper scripts'); - } - } catch (error) { - fail(`package.json is invalid JSON: ${error.message}`); - } - } - - const agentsPath = path.join(repoRoot, 'AGENTS.md'); - if (!fs.existsSync(agentsPath)) { - warn('AGENTS.md not found (multi-agent contract snippet not present)'); - } else { - const agentsContent = fs.readFileSync(agentsPath, 'utf8'); - if (!agentsContent.includes(AGENTS_MARKER_START)) { - warn('AGENTS.md exists but multiagent-safety snippet marker is missing'); - } else { - ok('AGENTS.md contains multiagent-safety snippet marker'); - } - } - - if (warnings.length) { - console.log(`[multiagent-safety] warnings: ${warnings.length}`); - } - if (failures.length) { - console.log(`[multiagent-safety] failures: ${failures.length}`); - } - - if (failures.length === 0 && (!options.strict || warnings.length === 0)) { - console.log('[multiagent-safety] doctor passed.'); - if (warnings.length > 0) { - console.log('[multiagent-safety] tip: run with --strict to treat warnings as failures.'); - } - return; - } - - if (options.strict && warnings.length > 0 && failures.length === 0) { - console.log('[multiagent-safety] strict mode failed due to warnings.'); - } else { - console.log('[multiagent-safety] doctor failed.'); - } - throw new Error('doctor detected configuration issues'); -} - function printAgentsSnippet() { const snippetPath = path.join(TEMPLATE_ROOT, 'AGENTS.multiagent-safety.md'); process.stdout.write(fs.readFileSync(snippetPath, 'utf8')); @@ -6363,17 +5261,6 @@ function prompt(rawArgs) { return copyPrompt(); } -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.`); - } -} - function branch(rawArgs) { const [subcommand, ...rest] = rawArgs; if (subcommand === 'start') { diff --git a/src/context.js b/src/context.js index 623dc8c..0471617 100644 --- a/src/context.js +++ b/src/context.js @@ -249,6 +249,9 @@ const AGENT_WORKTREE_RELATIVE_DIRS = [ const MANAGED_GITIGNORE_PATHS = [ '.omx/', '.omc/', + '!.vscode/', + '.vscode/*', + '!.vscode/settings.json', 'scripts/agent-session-state.js', 'scripts/guardex-docker-loader.sh', 'scripts/guardex-env.sh', diff --git a/src/scaffold/index.js b/src/scaffold/index.js index d6b3b08..e1f3424 100644 --- a/src/scaffold/index.js +++ b/src/scaffold/index.js @@ -3,32 +3,11 @@ const { path, TOOL_NAME, SHORT_TOOL_NAME, + toDestinationPath, 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; diff --git a/test/cli-args-dispatch.test.js b/test/cli-args-dispatch.test.js index 348da08..5c5dd1c 100644 --- a/test/cli-args-dispatch.test.js +++ b/test/cli-args-dispatch.test.js @@ -5,7 +5,12 @@ const path = require('node:path'); const { DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES, + MANAGED_GITIGNORE_PATHS, + CLI_COMMAND_DESCRIPTIONS, + MAINTAINER_RELEASE_REPO, + toDestinationPath, } = require('../src/context'); +const scaffold = require('../src/scaffold'); const { parseSetupArgs, parseDoctorArgs, @@ -164,12 +169,36 @@ test('dispatch helpers preserve suggestion, alias, deprecation, and flag extract ); }); -test('cli main no longer keeps local copies of extracted parser and dispatch helpers', () => { +test('shared context keeps the drift-prone help text, gitignore paths, and release repo root', () => { + const descriptions = new Map(CLI_COMMAND_DESCRIPTIONS); + + assert.match(descriptions.get('setup'), /--current/); + assert.match(descriptions.get('doctor'), /--current/); + assert.ok(MANAGED_GITIGNORE_PATHS.includes('!.vscode/')); + assert.ok(MANAGED_GITIGNORE_PATHS.includes('.vscode/*')); + assert.ok(MANAGED_GITIGNORE_PATHS.includes('!.vscode/settings.json')); + assert.equal(MAINTAINER_RELEASE_REPO, repoRoot); +}); + +test('scaffold reuses the shared destination-path helper from context', () => { + assert.equal(scaffold.toDestinationPath, toDestinationPath); + assert.equal(scaffold.toDestinationPath('github/pull.yml.example'), '.github/pull.yml.example'); +}); + +test('cli main no longer keeps local copies of extracted shared helpers or dead cleanup code', () => { const source = fs.readFileSync(path.join(repoRoot, 'src', 'cli', 'main.js'), 'utf8'); + assert.match(source, /require\('\.\.\/context'\)/); + assert.match(source, /require\('\.\.\/output'\)/); + assert.match(source, /require\('\.\.\/scaffold'\)/); assert.match(source, /require\('\.\/args'\)/); assert.match(source, /require\('\.\/dispatch'\)/); assert.match(source, /require\('\.\.\/git'\)/); + assert.doesNotMatch(source, /const TOOL_NAME = 'gitguardex';/); + assert.doesNotMatch(source, /const MAINTAINER_RELEASE_REPO = path\.resolve\(/); + assert.doesNotMatch(source, /function envFlagIsTruthy\(raw\)/); + assert.doesNotMatch(source, /function isClaudeCodeSession\(env = process\.env\)/); + assert.doesNotMatch(source, /function defaultAgentWorktreeRelativeDir\(env = process\.env\)/); assert.doesNotMatch(source, /function parseDoctorArgs\(rawArgs\)/); assert.doesNotMatch(source, /function parseSetupArgs\(rawArgs, defaults\)/); assert.doesNotMatch(source, /function parseCleanupArgs\(rawArgs\)/); @@ -182,4 +211,17 @@ test('cli main no longer keeps local copies of extracted parser and dispatch hel assert.doesNotMatch(source, /function normalizeCommandOrThrow\(command\)/); assert.doesNotMatch(source, /function warnDeprecatedAlias\(aliasName\)/); assert.doesNotMatch(source, /function extractFlag\(args, \.\.\.names\)/); + assert.doesNotMatch(source, /function runtimeVersion\(\)/); + assert.doesNotMatch(source, /function usage\(options = \{\}\)/); + assert.doesNotMatch(source, /function toDestinationPath\(relativeTemplatePath\)/); + assert.doesNotMatch(source, /function printOperations\(title, payload, dryRun = false\)/); + assert.doesNotMatch(source, /function printStandaloneOperations\(title, rootLabel, operations, dryRun = false\)/); + assert.doesNotMatch(source, /function promptYesNo\(question, defaultYes = true\)/); + assert.doesNotMatch(source, /function envFlagEnabled\(name\)/); + assert.doesNotMatch(source, /function installMany\(rawArgs\)/); + assert.doesNotMatch(source, /function initWorkspace\(rawArgs\)/); + assert.doesNotMatch(source, /function doctorAudit\(rawArgs\)/); + assert.doesNotMatch(source, /function syncDoctorLocalSupportFiles\(repoRoot, dryRun\)/); + assert.equal((source.match(/function gitRefExists\(/g) || []).length, 1); + assert.equal((source.match(/Auto-finish flow failed for sandbox branch/g) || []).length, 1); }); diff --git a/test/metadata.test.js b/test/metadata.test.js index 1f7e73c..18340da 100644 --- a/test/metadata.test.js +++ b/test/metadata.test.js @@ -156,11 +156,13 @@ test('package manifest ships the extracted src runtime', () => { assert.match(pkg.files.join('\n'), /^src$/m); }); -test('doctor CLI parser exists in src/cli args and main runtime to prevent ReferenceError regressions', () => { +test('doctor CLI parser stays in src/cli args while dead legacy audit stubs stay removed from main runtime', () => { const argsSource = fs.readFileSync(path.join(repoRoot, 'src', 'cli', 'args.js'), 'utf8'); const cliSource = fs.readFileSync(path.join(repoRoot, 'src', 'cli', 'main.js'), 'utf8'); assert.match(argsSource, /function parseDoctorArgs\(rawArgs(?:, options = \{\})?\)/); - assert.match(cliSource, /function doctorAudit\(rawArgs\)/); + assert.doesNotMatch(cliSource, /function doctorAudit\(rawArgs\)/); + assert.doesNotMatch(cliSource, /function installMany\(rawArgs\)/); + assert.doesNotMatch(cliSource, /function initWorkspace\(rawArgs\)/); }); test('cli main delegates extracted seams and keeps doctor single-source', () => {