claude-dev-env 2.35.0 → 2.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.agents/skills/autoconverge/SKILL.md +19 -15
  2. package/.agents/skills/autoconverge/reference/gotchas.md +3 -3
  3. package/.agents/skills/autoconverge/reference/multi-pr.md +6 -2
  4. package/.agents/skills/autoconverge/workflow/converge.contract.test.mjs +11 -0
  5. package/.agents/skills/autoconverge/workflow/converge.mjs +26 -33
  6. package/.agents/skills/autoconverge/workflow/converge.run-input.test.mjs +26 -2
  7. package/.agents/skills/autoconverge/workflow/converge_multi.mjs +12 -28
  8. package/.agents/skills/autoconverge/workflow/converge_multi.run-input.test.mjs +16 -0
  9. package/bin/cde.mjs +175 -0
  10. package/bin/cde.test.mjs +222 -0
  11. package/bin/install-constants.mjs +4 -0
  12. package/bin/install.cursor-rules.test.mjs +233 -2
  13. package/bin/install.mjs +49 -3
  14. package/bin/pstack-model-preferences.codex.json +23 -0
  15. package/bin/select-pstack-models.test.mjs +378 -0
  16. package/hooks/hooks_constants/tool_names.py +20 -0
  17. package/hooks/validators/fast_save_validators.py +63 -89
  18. package/hooks/validators/run_all_validators.py +16 -8
  19. package/hooks/validators/test_fast_save_validators_policy_lint.py +25 -0
  20. package/hooks/validators/test_run_all_validators_policy_lint.py +52 -0
  21. package/package.json +3 -2
  22. package/rules/pstack-models.md +119 -0
  23. package/scripts/cde_lint.py +258 -0
  24. package/scripts/policy_lint/__init__.py +41 -0
  25. package/scripts/policy_lint/adapter_configuration.py +91 -0
  26. package/scripts/policy_lint/adapter_detectors.py +328 -0
  27. package/scripts/policy_lint/adapter_pairing.py +145 -0
  28. package/scripts/policy_lint/adapter_support.py +97 -0
  29. package/scripts/policy_lint/adapters.py +352 -0
  30. package/scripts/policy_lint/config/__init__.py +1 -0
  31. package/scripts/policy_lint/config/constants.py +66 -0
  32. package/scripts/policy_lint/engine.py +224 -0
  33. package/scripts/policy_lint/model.py +387 -0
  34. package/scripts/policy_lint/registry.py +162 -0
  35. package/scripts/policy_lint/render.py +91 -0
  36. package/scripts/policy_lint/selection.py +372 -0
  37. package/scripts/policy_lint/selection_git.py +147 -0
  38. package/scripts/select_pstack_models.mjs +304 -0
  39. package/scripts/tests/test_cde_lint.py +374 -0
  40. package/scripts/tests/test_policy_lint_rules_engine.py +381 -0
  41. package/scripts/tests/test_policy_lint_rules_registry.py +252 -0
  42. package/scripts/tests/test_policy_lint_selection.py +373 -0
@@ -59,8 +59,10 @@ python "$HOME/.claude/skills/_shared/pr-loop/scripts/select_converge_pacer.py" \
59
59
  [`../_shared/pr-loop/portable-driver.md`](../_shared/pr-loop/portable-driver.md).
60
60
  **Do not abort** because the Workflow tool is missing.
61
61
 
62
- Transport still needs authenticated GitHub access for the PR's owner (`gh` or
63
- `pr-loop-cloud-transport`).
62
+ Set the GitHub transport directive before any GitHub step. Use `transport: "cloud"` in a
63
+ Cloud session. Use `transport: "local"` in a local session. Cloud uses the
64
+ connected GitHub MCP tools and never starts a local GitHub CLI authentication
65
+ flow.
64
66
 
65
67
  ## Review-lens boundary
66
68
 
@@ -68,18 +70,19 @@ The code-review lens boundary (workflow agent versus the built-in `/code-review`
68
70
  command) is defined on the **Code-review lens** bullet in
69
71
  [`reference/convergence.md`](reference/convergence.md).
70
72
 
71
- ## Transport check (before any GitHub step)
73
+ ## Select the GitHub transport directive before any GitHub step
72
74
 
73
- Run `command -v gh`; when it succeeds, run `gh auth status`; once the PR
74
- scope is resolved, run `gh api repos/<owner>/<repo> --jq .permissions.push`
75
- and take `true` as the pass. When any check fails, run the
76
- `pr-loop-cloud-transport` skill first and route every `gh` operation in this
77
- skill through its substitution matrix. The workflow script
78
- (`workflow/converge.mjs`) embeds `gh` commands and gh-backed helper scripts in
79
- its agent prompts, so a cloud run also applies the same substitution to those
80
- agent prompts when it authors the launch — the transport skill covers the
81
- orchestrating session's own steps, and the script's agent-prompt text carries
82
- `gh` calls the spawned agents cannot run in a cloud session.
75
+ In a Cloud session, set `transport: "cloud"` and load the GitHub MCP schemas
76
+ before the first GitHub tool call. Do not run any local GitHub CLI
77
+ authentication, account-switch, or token command. Use the operation in
78
+ [`cloud-pr-loop-compatibility.md`](../../../docs/references/cloud-pr-loop-compatibility.md)
79
+ for each GitHub read or write. Use the Cloud session's credentialed push path
80
+ for local commits. If that path rejects a push, report the exact error instead
81
+ of trying to log in through the local CLI.
82
+
83
+ In a local session, set `transport: "local"` and use the authenticated local
84
+ GitHub CLI. The workflow defaults to this value when older callers omit the
85
+ field.
83
86
 
84
87
  ## Pre-flight (main session)
85
88
 
@@ -161,7 +164,7 @@ orchestrating session's own steps, and the script's agent-prompt text carries
161
164
  ```
162
165
  Workflow({
163
166
  scriptPath: "<this skill dir>/workflow/converge.mjs",
164
- args: { owner: "<O>", repo: "<R>", prNumber: <N>, bugbotDisabled: false, copilotDisabled: false, homeDirectory: "<HOME>" }
167
+ args: { owner: "<O>", repo: "<R>", prNumber: <N>, bugbotDisabled: false, copilotDisabled: false, homeDirectory: "<HOME>", transport: "<cloud|local>" }
165
168
  })
166
169
  ```
167
170
 
@@ -173,7 +176,8 @@ the absolute path to the directory that holds `.claude` (resolve it once from
173
176
  runs in a sandbox with no access to environment variables, so the run reads the
174
177
  home directory from this arg to build the path to the codex-review scripts the
175
178
  Codex gate calls; leave it out and the Codex gate cannot find those scripts. Set
176
- `bugbotDisabled: true` only when the user has opted Cursor Bugbot out for the
179
+ `transport: "cloud"` for Cloud and `transport: "local"` for local execution.
180
+ Set `bugbotDisabled: true` only when the user has opted Cursor Bugbot out for the
177
181
  run; otherwise the workflow detects an opt-out or an unreachable Bugbot on its
178
182
  own. Set `copilotDisabled: true` when the step 5 quota pre-check exits non-zero,
179
183
  and `false` when it exits 0; on `true` the workflow skips the Copilot gate with
@@ -46,9 +46,9 @@ fails in a new way.
46
46
  not `~/.claude/...`; a tilde resolves to the wrong home and gives a
47
47
  file-not-found error that looks like a script failure.
48
48
 
49
- - **`gh` token drift across accounts.** When a run touches more than one GitHub
50
- account, pin the token with `--user <login>`; `gh auth token` alone can return
51
- another account's token after a switch.
49
+ - **Local identity drift across accounts.** A local run that touches more than
50
+ one GitHub account must use the selected local account. Cloud runs use the
51
+ connected GitHub MCP identity and never use local CLI identity commands.
52
52
 
53
53
  - **Commit or push fails after a repair.** Read the hook or command output,
54
54
  repair the named issue, and record the rerun. Apply the
@@ -73,6 +73,7 @@ Workflow({
73
73
  args: {
74
74
  convergeScriptPath: "<this skill dir>/workflow/converge.mjs",
75
75
  homeDirectory: "<HOME>",
76
+ transport: "<cloud|local>",
76
77
  prs: [
77
78
  { owner: "<O>", repo: "<R>", prNumber: <N1>, repoPath: "<abs worktree 1>", bugbotDisabled: false, copilotDisabled: false },
78
79
  { owner: "<O>", repo: "<R>", prNumber: <N2>, repoPath: "<abs worktree 2>", bugbotDisabled: false, copilotDisabled: false }
@@ -87,8 +88,11 @@ checked out in. `homeDirectory` is the absolute path to the directory that holds
87
88
  `.claude` (resolve it once from `$HOME`, or `$env:USERPROFILE` on Windows, with
88
89
  forward slashes); the fan-out forwards it to every child run, which needs it to
89
90
  build the codex-review scripts path because the workflow sandbox has no access to
90
- environment variables. The workflow runs in the background and notifies the session on
91
- completion; watch live progress with `/workflows`, where each PR's child run
91
+ environment variables. Set `transport: "cloud"` in a Cloud session and
92
+ `transport: "local"` in a local session. Cloud uses the connected GitHub MCP
93
+ tools and skips local CLI authentication. The workflow runs in the background
94
+ and notifies the session on completion; watch live progress with `/workflows`,
95
+ where each PR's child run
92
96
  appears under its own group.
93
97
 
94
98
  The workflow returns
@@ -32,6 +32,17 @@ test('review tasks use code-quality-agent and keep the working tree read-only',
32
32
  assert.match(reviewTaskSource, /Make no edit to the tree under verification/)
33
33
  })
34
34
 
35
+ test('workflow agents receive the selected GitHub transport before role instructions', () => {
36
+ assert.match(
37
+ workflowSource,
38
+ /HEADLESS_EDIT_PREAMBLE\}\$\{githubTransportDirective\(activeGithubTransport\)\}\$\{worktreeDirective/,
39
+ )
40
+ assert.match(
41
+ workflowSource,
42
+ /HEADLESS_READONLY_PREAMBLE\}\$\{githubTransportDirective\(activeGithubTransport\)\}\$\{worktreeDirective/,
43
+ )
44
+ })
45
+
35
46
  test('review result parser accepts one standalone terminal disposition only', () => {
36
47
  const parseReviewResult = loadReviewResultParser()
37
48
 
@@ -91,6 +91,7 @@ const HEADLESS_READONLY_PREAMBLE = HEADLESS_EDIT_PREAMBLE.replace(
91
91
  )
92
92
 
93
93
  let activeRepoPath = null
94
+ let activeGithubTransport = 'local'
94
95
 
95
96
  /**
96
97
  * Build the per-agent worktree directive for a path-scoped run.
@@ -111,33 +112,11 @@ const worktreeDirective = (repoPath) =>
111
112
  ? `WORKTREE — this PR is checked out at ${repoPath}. Unless a step explicitly names a different repository directory (for example an environment-hardening repo checkout, which you cd into exactly as that step directs), run every git, gh, diff, edit, commit, push, and test command for this PR in that worktree: cd "${repoPath}" before any such command, and resolve repository roots from there.\n\n`
112
113
  : ''
113
114
 
114
- /**
115
- * Spawn a workflow agent that edits code, with the full edit preamble prepended
116
- * to its prompt. An edit agent may run rm during cleanup, so it inherits the
117
- * rm-shape rules alongside the no-confirmation-prompt guidance. On a path-scoped
118
- * run the worktree directive is prepended too, so the agent runs in the PR's own
119
- * worktree (activeRepoPath); on a single-PR run that directive is empty and the
120
- * agent keeps its own working directory. A read-only agent (a review, verify, or
121
- * utility spawn) routes through convergeReadOnlyAgent for the trimmed preamble.
122
- * @param {string} prompt the agent's role-specific instruction body
123
- * @param {object} options the agent() options (label, phase, schema, agentType, model)
124
- * @returns {Promise<*>} the agent() result
125
- */
126
115
  const convergeAgent = (prompt, options) =>
127
- agent(`${HEADLESS_EDIT_PREAMBLE}${worktreeDirective(activeRepoPath)}${prompt}`, options)
116
+ agent(`${HEADLESS_EDIT_PREAMBLE}${githubTransportDirective(activeGithubTransport)}${worktreeDirective(activeRepoPath)}${prompt}`, options)
128
117
 
129
- /**
130
- * Spawn a read-only workflow agent with the trimmed read-only preamble prepended
131
- * to its prompt. A review, verify, or utility agent edits nothing and never runs
132
- * rm, so it receives the read-only preamble that drops the rm-shape-rules bullet.
133
- * The worktree directive is prepended on a path-scoped run exactly as for
134
- * convergeAgent, so a read-only agent still runs in the PR's own worktree.
135
- * @param {string} prompt the agent's role-specific instruction body
136
- * @param {object} options the agent() options (label, phase, schema, agentType, model)
137
- * @returns {Promise<*>} the agent() result
138
- */
139
118
  const convergeReadOnlyAgent = (prompt, options) =>
140
- agent(`${HEADLESS_READONLY_PREAMBLE}${worktreeDirective(activeRepoPath)}${prompt}`, options)
119
+ agent(`${HEADLESS_READONLY_PREAMBLE}${githubTransportDirective(activeGithubTransport)}${worktreeDirective(activeRepoPath)}${prompt}`, options)
141
120
 
142
121
  /**
143
122
  * Spawn a fresh git-utility Explore agent for a specific task. The one task,
@@ -1627,19 +1606,32 @@ function normalizeRunInput(rawArgs) {
1627
1606
  }
1628
1607
  }
1629
1608
 
1630
- /**
1631
- * Validate the normalized run input into either usable coordinates or a
1632
- * structured blocker. The run cannot build a single GitHub call without owner,
1633
- * repo, and prNumber, so a null payload (failed parse or missing args) or a
1634
- * payload missing any coordinate yields a blocker the top-level run reports as
1635
- * {converged:false, blocker} rather than throwing on input.owner at startup.
1636
- * @param {string|object} rawArgs the workflow args global (JSON string or object)
1637
- * @returns {{input: object|null, blocker: string|null}} usable coordinates or a blocker
1638
- */
1609
+ const DEFAULT_GITHUB_TRANSPORT = 'local'
1610
+ const GITHUB_TRANSPORT_DIRECTIVES = {
1611
+ local:
1612
+ 'LOCAL GITHUB TRANSPORT DIRECTIVE. Use the authenticated local GitHub CLI for GitHub operations. Report local authentication or permission failures directly.\n\n',
1613
+ cloud:
1614
+ 'CLOUD GITHUB TRANSPORT DIRECTIVE. Use the connected GitHub MCP tools for every GitHub API read and write. Load the GitHub MCP schemas before the first MCP call and resolve the connected identity with mcp__github__get_me. Do not invoke a local GitHub CLI for authentication, account switching, token lookup, pull-request operations, review operations, or push setup. Replace local CLI steps in this prompt with the matching operation in docs/references/cloud-pr-loop-compatibility.md. For a local commit push, use only the credentialed push path supplied by the Cloud session. If no credentialed path is available, return a named blocker instead of attempting a login.\n\n',
1615
+ }
1616
+ const VALID_GITHUB_TRANSPORTS = new Set(Object.keys(GITHUB_TRANSPORT_DIRECTIVES))
1617
+
1618
+ function githubTransportDirective(transport) {
1619
+ return GITHUB_TRANSPORT_DIRECTIVES[transport] || GITHUB_TRANSPORT_DIRECTIVES[DEFAULT_GITHUB_TRANSPORT]
1620
+ }
1621
+
1639
1622
  function classifyRunInput(rawArgs) {
1640
1623
  const candidate = normalizeRunInput(rawArgs)
1641
1624
  const hasUsableCoordinates =
1642
1625
  candidate != null && candidate.owner && candidate.repo && candidate.prNumber
1626
+ const hasValidTransport =
1627
+ candidate != null &&
1628
+ (candidate.transport === undefined || VALID_GITHUB_TRANSPORTS.has(candidate.transport))
1629
+ if (hasUsableCoordinates && !hasValidTransport) {
1630
+ return {
1631
+ input: null,
1632
+ blocker: 'invalid run transport: expected "local" or "cloud"',
1633
+ }
1634
+ }
1643
1635
  if (hasUsableCoordinates) return { input: candidate, blocker: null }
1644
1636
  return {
1645
1637
  input: null,
@@ -1654,6 +1646,7 @@ if (runInput.blocker) {
1654
1646
  }
1655
1647
  const input = runInput.input
1656
1648
  activeRepoPath = typeof input.repoPath === 'string' && input.repoPath ? input.repoPath : null
1649
+ activeGithubTransport = input.transport === undefined ? DEFAULT_GITHUB_TRANSPORT : input.transport
1657
1650
  const prCoordinates = `owner=${input.owner} repo=${input.repo} PR #${input.prNumber} (https://github.com/${input.owner}/${input.repo}/pull/${input.prNumber})`
1658
1651
 
1659
1652
  /**
@@ -17,10 +17,10 @@ function sourceSliceBetween(startNeedle, endNeedle) {
17
17
 
18
18
  const productionModule = new Function(
19
19
  `${sourceSliceBetween('function normalizeRunInput(', '\nconst runInput =')}\n` +
20
- 'return { normalizeRunInput, classifyRunInput };',
20
+ 'return { normalizeRunInput, classifyRunInput, githubTransportDirective };',
21
21
  )();
22
22
 
23
- const { normalizeRunInput, classifyRunInput } = productionModule;
23
+ const { normalizeRunInput, classifyRunInput, githubTransportDirective } = productionModule;
24
24
 
25
25
  const VALID_COORDINATES = { owner: 'jl-cmd', repo: 'claude-dev-env', prNumber: 543 };
26
26
 
@@ -46,6 +46,19 @@ test('classifyRunInput accepts coordinates carrying owner, repo, and prNumber',
46
46
  assert.equal(classified.blocker, null);
47
47
  });
48
48
 
49
+ test('classifyRunInput accepts the cloud transport without changing the input shape', () => {
50
+ const cloudInput = { ...VALID_COORDINATES, transport: 'cloud' };
51
+ const classified = classifyRunInput(cloudInput);
52
+ assert.deepEqual(classified.input, cloudInput);
53
+ assert.equal(classified.blocker, null);
54
+ });
55
+
56
+ test('classifyRunInput blocks an unknown transport before any GitHub work starts', () => {
57
+ const classified = classifyRunInput({ ...VALID_COORDINATES, transport: 'proxy' });
58
+ assert.equal(classified.input, null);
59
+ assert.match(classified.blocker, /transport/i);
60
+ });
61
+
49
62
  test('classifyRunInput blocks a failed parse with a structured blocker and no input', () => {
50
63
  const classified = classifyRunInput('not json at all');
51
64
  assert.equal(classified.input, null);
@@ -90,3 +103,14 @@ test('classifyRunInput leaves copilotDisabled undefined when the input omits it'
90
103
  const classified = classifyRunInput(VALID_COORDINATES);
91
104
  assert.equal(classified.input.copilotDisabled, undefined);
92
105
  });
106
+
107
+ test('the cloud transport directive selects MCP and contains no local CLI auth command', () => {
108
+ const cloudDirective = githubTransportDirective('cloud');
109
+ assert.match(cloudDirective, /mcp__github__/);
110
+ assert.match(cloudDirective, /credentialed push path/);
111
+ assert.doesNotMatch(cloudDirective, /gh auth/);
112
+ });
113
+
114
+ test('the local transport directive keeps the local CLI path', () => {
115
+ assert.match(githubTransportDirective('local'), /authenticated local GitHub CLI/);
116
+ });
@@ -57,18 +57,9 @@ function isUsablePrEntry(prEntry) {
57
57
  )
58
58
  }
59
59
 
60
- /**
61
- * Validate the normalized multi-PR input into usable coordinates or a blocker.
62
- *
63
- * A fan-out run needs the absolute converge.mjs script path and a non-empty list
64
- * of PR entries that each carry owner, repo, prNumber, and the absolute worktree
65
- * path the PR is checked out in. A payload that fails to parse, a non-string
66
- * convergeScriptPath, a missing or empty prs list, or any entry missing a
67
- * coordinate yields a blocker the top-level run reports as
68
- * {converged:false, blocker} rather than throwing on a missing field.
69
- * @param {string|object} rawArgs the workflow args global (JSON string or object)
70
- * @returns {{input: object|null, blocker: string|null}} usable coordinates or a blocker
71
- */
60
+ const DEFAULT_GITHUB_TRANSPORT = 'local'
61
+ const VALID_GITHUB_TRANSPORTS = new Set(['local', 'cloud'])
62
+
72
63
  function classifyMultiInput(rawArgs) {
73
64
  const candidate = normalizeMultiInput(rawArgs)
74
65
  if (candidate == null) {
@@ -84,6 +75,12 @@ function classifyMultiInput(rawArgs) {
84
75
  'invalid run coordinates: convergeScriptPath (absolute path to converge.mjs) is required',
85
76
  }
86
77
  }
78
+ if (candidate.transport !== undefined && !VALID_GITHUB_TRANSPORTS.has(candidate.transport)) {
79
+ return {
80
+ input: null,
81
+ blocker: 'invalid run transport: expected "local" or "cloud"',
82
+ }
83
+ }
87
84
  if (!Array.isArray(candidate.prs) || candidate.prs.length === 0) {
88
85
  return {
89
86
  input: null,
@@ -102,21 +99,7 @@ function classifyMultiInput(rawArgs) {
102
99
  return { input: candidate, blocker: null }
103
100
  }
104
101
 
105
- /**
106
- * Build the converge.mjs child-run args for one validated PR entry.
107
- *
108
- * Every per-run opt-out the child converge.mjs reads is forwarded here so the
109
- * fan-out honors it: bugbotDisabled skips the Bugbot phase, and copilotDisabled
110
- * short-circuits the Copilot quota gate when the account is out of premium
111
- * requests. An entry that omits an opt-out defaults it to false so the child
112
- * runs that phase. homeDirectory is a run-level value forwarded to every child
113
- * so the child can build its codex-review scripts path: the workflow sandbox
114
- * exposes no process global, so the child reads home from its args, not env.
115
- * @param {object} prEntry one validated element of the args.prs array
116
- * @param {string} homeDirectory the run-level home directory for every child
117
- * @returns {object} the args object passed to the converge.mjs child run
118
- */
119
- function childRunInput(prEntry, homeDirectory) {
102
+ function childRunInput(prEntry, homeDirectory, transport = DEFAULT_GITHUB_TRANSPORT) {
120
103
  return {
121
104
  owner: prEntry.owner,
122
105
  repo: prEntry.repo,
@@ -125,6 +108,7 @@ function childRunInput(prEntry, homeDirectory) {
125
108
  bugbotDisabled: Boolean(prEntry.bugbotDisabled),
126
109
  copilotDisabled: Boolean(prEntry.copilotDisabled),
127
110
  homeDirectory,
111
+ transport,
128
112
  }
129
113
  }
130
114
 
@@ -141,7 +125,7 @@ const childResults = await parallel(
141
125
  input.prs.map((eachPr) => async () => {
142
126
  const childOutcome = await workflow(
143
127
  { scriptPath: input.convergeScriptPath },
144
- childRunInput(eachPr, input.homeDirectory),
128
+ childRunInput(eachPr, input.homeDirectory, input.transport),
145
129
  )
146
130
  return {
147
131
  owner: eachPr.owner,
@@ -68,6 +68,18 @@ test('valid coordinates classify with no blocker and keep every PR entry', () =>
68
68
  assert.equal(classified.input.prs.length, 2);
69
69
  });
70
70
 
71
+ test('a cloud transport classifies with no blocker', () => {
72
+ const classified = classifyMultiInput({ ...validArgs(), transport: 'cloud' });
73
+ assert.equal(classified.blocker, null);
74
+ assert.equal(classified.input.transport, 'cloud');
75
+ });
76
+
77
+ test('an unknown transport is blocked before child workflows start', () => {
78
+ const classified = classifyMultiInput({ ...validArgs(), transport: 'proxy' });
79
+ assert.equal(classified.input, null);
80
+ assert.match(classified.blocker, /transport/i);
81
+ });
82
+
71
83
  test('a missing convergeScriptPath is blocked', () => {
72
84
  const classified = classifyMultiInput({ prs: [validEntry(101)] });
73
85
  assert.equal(classified.input, null);
@@ -138,6 +150,10 @@ test('childRunInput defaults bugbotDisabled to false when the entry omits it', (
138
150
  assert.equal(childRunInput(validEntry(101)).bugbotDisabled, false);
139
151
  });
140
152
 
153
+ test('childRunInput forwards the selected cloud transport', () => {
154
+ assert.equal(childRunInput(validEntry(101), '/home/dev', 'cloud').transport, 'cloud');
155
+ });
156
+
141
157
  test('childRunInput forwards the run-level home directory to the child run', () => {
142
158
  const childArgs = childRunInput(validEntry(101), '/home/dev');
143
159
  assert.equal(childArgs.homeDirectory, '/home/dev');
package/bin/cde.mjs ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { constants as osConstants } from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+
7
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
+ const lintScriptPath = path.join(packageRoot, "scripts", "cde_lint.py");
9
+ const posixSignalStatusOffset = 128;
10
+ const lintCommandName = "lint";
11
+ const pythonOptionName = "--python";
12
+ const pythonEnvironmentName = "CDE_PYTHON";
13
+
14
+
15
+ export function createHelpText() {
16
+ return [
17
+ "Usage: cde lint [options]",
18
+ "",
19
+ "Run the policy linter. Source modes are mutually exclusive:",
20
+ " --files PATH [PATH ...] Current worktree files",
21
+ " --staged Staged index content",
22
+ " --base REVISION Changes from a base revision",
23
+ " --repository Tracked worktree files",
24
+ " --text-as PATH One editor buffer from standard input",
25
+ "",
26
+ "Report formats: --format text|json|editor",
27
+ "",
28
+ "Launcher options:",
29
+ " --python <command> Python interpreter override",
30
+ " Exit 0 clean, 1 diagnostics, 2 invalid input or start failure, 3 failed rule",
31
+ "",
32
+ "Python may also be selected with CDE_PYTHON.",
33
+ ].join("\n");
34
+ }
35
+
36
+
37
+ export function pythonInterpreterNames(platformName = process.platform) {
38
+ if (platformName === "win32") return ["py", "python"];
39
+ return ["python3", "python"];
40
+ }
41
+
42
+
43
+ export function findPython(
44
+ commandNames = pythonInterpreterNames(),
45
+ canRun = (commandName) => spawnSync(commandName, ["--version"], { stdio: "ignore", shell: false }).status === 0,
46
+ ) {
47
+ for (const eachCommand of commandNames) {
48
+ if (canRun(eachCommand)) return eachCommand;
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+
54
+ export function buildLintCommand(interpreter, forwardedArguments) {
55
+ return {
56
+ executable: interpreter,
57
+ arguments: [lintScriptPath, ...forwardedArguments],
58
+ };
59
+ }
60
+
61
+
62
+ function takePythonOverride(allArguments) {
63
+ const remainingArguments = [];
64
+ let interpreterOverride;
65
+ for (let index = 0; index < allArguments.length; index += 1) {
66
+ if (allArguments[index] === pythonOptionName) {
67
+ const nextArgument = allArguments[index + 1];
68
+ if (nextArgument === undefined || nextArgument.startsWith("--")) {
69
+ return { error: "--python requires an interpreter command" };
70
+ }
71
+ interpreterOverride = nextArgument;
72
+ index += 1;
73
+ continue;
74
+ }
75
+ remainingArguments.push(allArguments[index]);
76
+ }
77
+ return { interpreterOverride, remainingArguments, error: undefined };
78
+ }
79
+
80
+
81
+ function attachSignalForwarding(childProcess, processEmitter) {
82
+ const allSignals = ["SIGINT", "SIGTERM"];
83
+ if (process.platform !== "win32") {
84
+ allSignals.push("SIGHUP");
85
+ }
86
+ const listenerBySignal = [];
87
+ for (const eachSignal of allSignals) {
88
+ const listener = () => {
89
+ try {
90
+ childProcess.kill(eachSignal);
91
+ } catch {
92
+ return;
93
+ }
94
+ };
95
+ processEmitter.on(eachSignal, listener);
96
+ listenerBySignal.push([eachSignal, listener]);
97
+ }
98
+ return () => {
99
+ for (const [eachSignal, listener] of listenerBySignal) {
100
+ processEmitter.off(eachSignal, listener);
101
+ }
102
+ };
103
+ }
104
+
105
+
106
+ function statusCodeForSignal(signalName) {
107
+ const signalNumber = osConstants.signals?.[signalName];
108
+ if (signalNumber === undefined) return 1;
109
+ return posixSignalStatusOffset + signalNumber;
110
+ }
111
+
112
+
113
+ export function runLintCommand(command, dependencies = {}) {
114
+ const runChild = dependencies.runChild ?? spawn;
115
+ const processEmitter = dependencies.processEmitter ?? process;
116
+ return new Promise((resolve) => {
117
+ let childProcess;
118
+ try {
119
+ childProcess = runChild(command.executable, command.arguments, {
120
+ stdio: "inherit",
121
+ shell: false,
122
+ windowsHide: true,
123
+ });
124
+ } catch {
125
+ processEmitter.stderr.write("Unable to start the policy linter.\n");
126
+ resolve(2);
127
+ return;
128
+ }
129
+ const detachSignals = attachSignalForwarding(childProcess, processEmitter);
130
+ childProcess.on("error", () => {
131
+ detachSignals();
132
+ processEmitter.stderr.write("Unable to start the policy linter.\n");
133
+ resolve(2);
134
+ });
135
+ childProcess.on("close", (exitCode, signalName) => {
136
+ detachSignals();
137
+ if (exitCode !== null) {
138
+ resolve(exitCode);
139
+ return;
140
+ }
141
+ resolve(statusCodeForSignal(signalName));
142
+ });
143
+ });
144
+ }
145
+
146
+
147
+ export async function main(argumentsList = process.argv.slice(2), dependencies = {}) {
148
+ if (argumentsList.length === 0 || argumentsList.includes("--help") || argumentsList.includes("-h")) {
149
+ process.stdout.write(`${createHelpText()}\n`);
150
+ return 0;
151
+ }
152
+ if (argumentsList[0] !== lintCommandName) {
153
+ process.stderr.write(`${createHelpText()}\n`);
154
+ return 2;
155
+ }
156
+ const { interpreterOverride, remainingArguments, error } = takePythonOverride(argumentsList.slice(1));
157
+ if (error) {
158
+ process.stderr.write(`${error}\n`);
159
+ return 2;
160
+ }
161
+ const resolvePython = dependencies.findPython ?? findPython;
162
+ const interpreter = interpreterOverride ?? process.env[pythonEnvironmentName] ?? await resolvePython();
163
+ if (!interpreter) {
164
+ process.stderr.write("No usable Python interpreter found. Use --python or CDE_PYTHON.\n");
165
+ return 2;
166
+ }
167
+ const command = buildLintCommand(interpreter, remainingArguments);
168
+ const runCommand = dependencies.runCommand ?? runLintCommand;
169
+ return await runCommand(command, dependencies);
170
+ }
171
+
172
+
173
+ const isLaunchedDirectly = process.argv[1] !== undefined
174
+ && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
175
+ if (isLaunchedDirectly) process.exitCode = await main();