keystone-cli 1.1.2 → 1.2.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.
package/README.md CHANGED
@@ -134,22 +134,23 @@ keystone ui
134
134
 
135
135
  `keystone init` seeds these workflows under `.keystone/workflows/` (and the agents they rely on under `.keystone/workflows/agents/`):
136
136
 
137
- Top-level workflows:
138
- - `scaffold-feature`: Interactive workflow scaffolder. Prompts for requirements, plans files, generates content, and writes them.
139
- - `decompose-problem`: Decomposes a problem into research/implementation/review tasks, waits for approval, runs sub-workflows, and summarizes.
140
- - `dev`: Self-bootstrapping DevMode workflow for an interactive plan/implement/verify loop.
141
- - `agent-handoff`: Demonstrates agent handoffs and tool-driven context updates.
142
- - `script-example`: Demonstrates sandboxed JavaScript execution.
143
- - `artifact-example`: Demonstrates artifact upload and download between steps.
144
- - `idempotency-example`: Demonstrates safe retries for side-effecting steps.
145
-
146
- Sub-workflows:
147
- - `scaffold-plan`: Generates a file plan from `requirements` input.
148
- - `scaffold-generate`: Generates file contents from `requirements` plus a `files` plan.
149
- - `decompose-research`: Runs a single research task (`task`) with optional `context`/`constraints`.
150
- - `decompose-implement`: Runs a single implementation task (`task`) with optional `research` findings.
151
- - `decompose-review`: Reviews a single implementation task (`task`) with optional `implementation` results.
152
- - `review-loop`: Reusable generate critique refine loop with a quality gate.
137
+ Top-level workflows (seeded in `.keystone/workflows/`):
138
+ - `scaffold-feature.yaml`: Interactive workflow scaffolder. Prompts for requirements, plans files, generates content, and writes them.
139
+ - `decompose-problem.yaml`: Decomposes a problem into research/implementation/review tasks, waits for approval, runs sub-workflows, and summarizes.
140
+ - `dev.yaml`: Self-bootstrapping DevMode workflow for an interactive plan/implement/verify loop.
141
+ - `agent-handoff.yaml`: Demonstrates agent handoffs and tool-driven context updates.
142
+ - `full-feature-demo.yaml`: A comprehensive workflow demonstrating multiple step types (shell, file, request, etc.).
143
+ - `script-example.yaml`: Demonstrates sandboxed JavaScript execution.
144
+ - `artifact-example.yaml`: Demonstrates artifact upload and download between steps.
145
+ - `idempotency-example.yaml`: Demonstrates safe retries for side-effecting steps.
146
+
147
+ Sub-workflows (seeded in `.keystone/workflows/`):
148
+ - `scaffold-plan.yaml`: Generates a file plan from `requirements` input.
149
+ - `scaffold-generate.yaml`: Generates file contents from `requirements` plus a `files` plan.
150
+ - `decompose-research.yaml`: Runs a single research task (`task`) with optional `context`/`constraints`.
151
+ - `decompose-implement.yaml`: Runs a single implementation task (`task`) with optional `research` findings.
152
+ - `decompose-review.yaml`: Reviews a single implementation task (`task`) with optional `implementation` results.
153
+ - `review-loop.yaml`: Reusable generate → critique → refine loop with a quality gate.
153
154
 
154
155
  Example runs:
155
156
  ```bash
@@ -198,7 +199,7 @@ providers:
198
199
  google-gemini:
199
200
  type: google-gemini
200
201
  base_url: https://cloudcode-pa.googleapis.com
201
- default_model: gemini-3-pro-high
202
+ default_model: gemini-1.5-pro
202
203
  groq:
203
204
  type: openai
204
205
  base_url: https://api.groq.com/openai/v1
@@ -481,7 +482,8 @@ Keystone supports several specialized step types:
481
482
  ```yaml
482
483
  outputMapping:
483
484
  final_result: result_from_subflow
484
- status: state
485
+ # 'from' can be used for explicit mapping or expression
486
+ # status: { from: "steps.some_step.status" }
485
487
  ```
486
488
  - `join`: Aggregate outputs from dependencies and enforce a completion condition.
487
489
  - `condition`: `'all'` (default), `'any'`, or a number.
@@ -499,6 +501,7 @@ Keystone supports several specialized step types:
499
501
  - `op: store`: Store text with metadata.
500
502
  - `op: search`: Search for similar text using vector embeddings.
501
503
  - `text` / `query`: The content to store or search for.
504
+ - `model`: Optional embedding model (defaults to `local`). Currently only local embeddings (via `Transformers.js`) are supported.
502
505
  - `metadata`: Optional object for filtering or additional context.
503
506
  - `limit`: Number of results to return (default `5`).
504
507
  ```yaml
@@ -551,8 +554,8 @@ All steps support common features:
551
554
  - `retry`: `{ count, backoff: 'linear'|'exponential', baseDelay }`.
552
555
  - `timeout`: Maximum execution time in milliseconds (best-effort; supported steps receive an abort signal).
553
556
  - `foreach`: Iterate over an array in parallel.
554
- - `concurrency`: Limit parallel items for `foreach` (must be a positive integer).
555
- - `strategy.matrix`: Experimental parser-time expansion into `foreach` (prefer explicit `foreach` for now).
557
+ - `concurrency`: Limit parallel items for `foreach` (must be a positive integer). Defaults to `50`.
558
+ - `strategy.matrix`: Multi-axis expansion into `foreach` at parse-time.
556
559
  - `pool`: Assign step to a resource pool.
557
560
  - `breakpoint`: Pause before executing the step when running with `--debug`.
558
561
  - `compensate`: Step to run if the workflow rolls back.
@@ -806,6 +809,24 @@ Upload and download files between steps without hardcoded artifact paths.
806
809
 
807
810
  Upload outputs include `artifactPath` and `files` for downstream references.
808
811
 
812
+ - `git`: Perform git operations (clone, worktree, checkout, pull, push, commit).
813
+ - `op`: Required operation (`clone`, `worktree_add`, `worktree_remove`, `checkout`, `pull`, `push`, `commit`).
814
+ - `path`: Local path for clone or worktree.
815
+ - `url`: Repository URL for clone.
816
+ - `branch`: Branch name for clone, checkout, push, pull, or worktree.
817
+ - `message`: Commit message.
818
+ - `cwd`: Directory to run the git command in.
819
+ - `allowOutsideCwd`: Boolean (default `false`). Set `true` to allow operations outside the project root.
820
+ - `allowInsecure`: Boolean (default `false`). Set `true` to allow git commands that fail the security whitelist.
821
+
822
+ ```yaml
823
+ - id: setup_feat
824
+ type: git
825
+ op: worktree_add
826
+ path: ../feat-branch
827
+ branch: feature/x
828
+ ```
829
+
809
830
  ### Structured Events
810
831
 
811
832
  Emit NDJSON events for step and workflow lifecycle updates:
@@ -1224,6 +1245,11 @@ graph TD
1224
1245
  EX --> Script[Script Step]
1225
1246
  EX --> Sleep[Sleep Step]
1226
1247
  EX --> Memory[Memory operations]
1248
+ EX --> Artifact[Artifact operations]
1249
+ EX --> Git[Git operations]
1250
+ EX --> Wait[Wait Step]
1251
+ EX --> Join[Join Step]
1252
+ EX --> Blueprint[Blueprint Step]
1227
1253
 
1228
1254
  LLM --> Adapters[LLM Adapters]
1229
1255
  Adapters --> Providers[OpenAI, Anthropic, Gemini, Copilot, etc.]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-cli",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "A local-first, declarative, agentic workflow orchestrator built on Bun",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,7 @@ import architectAgent from '../templates/agents/keystone-architect.md' with { ty
17
17
  import softwareEngineerAgent from '../templates/agents/software-engineer.md' with { type: 'text' };
18
18
  import summarizerAgent from '../templates/agents/summarizer.md' with { type: 'text' };
19
19
  import testerAgent from '../templates/agents/tester.md' with { type: 'text' };
20
+ import fullFeatureDemo from '../templates/basics/full-feature-demo.yaml' with { type: 'text' };
20
21
  import idempotencyExample from '../templates/control-flow/idempotency-example.yaml' with {
21
22
  type: 'text',
22
23
  };
@@ -118,6 +119,7 @@ const SEEDS = [
118
119
  { path: '.keystone/workflows/script-example.yaml', content: scriptExample },
119
120
  { path: '.keystone/workflows/artifact-example.yaml', content: artifactExample },
120
121
  { path: '.keystone/workflows/idempotency-example.yaml', content: idempotencyExample },
122
+ { path: '.keystone/workflows/full-feature-demo.yaml', content: fullFeatureDemo },
121
123
  ];
122
124
 
123
125
  export function registerInitCommand(program: Command): void {
@@ -395,6 +395,19 @@ const ArtifactStepSchema = BaseStepSchema.extend({
395
395
  allowOutsideCwd: z.boolean().optional(),
396
396
  });
397
397
 
398
+ const GitStepSchema = BaseStepSchema.extend({
399
+ type: z.literal('git'),
400
+ op: z.enum(['clone', 'worktree_add', 'worktree_remove', 'checkout', 'pull', 'push', 'commit']),
401
+ path: z.string().optional(), // Local path for clone or worktree
402
+ url: z.string().optional(), // Repo URL for clone
403
+ branch: z.string().optional(),
404
+ message: z.string().optional(), // For commit
405
+ cwd: z.string().optional(), // Working directory for the git command
406
+ env: z.record(z.string()).optional(),
407
+ allowOutsideCwd: z.boolean().optional(),
408
+ allowInsecure: z.boolean().optional(),
409
+ });
410
+
398
411
  const WaitStepSchema = BaseStepSchema.extend({
399
412
  type: z.literal('wait'),
400
413
  event: z.string(),
@@ -404,23 +417,24 @@ const WaitStepSchema = BaseStepSchema.extend({
404
417
 
405
418
  // ===== Discriminated Union for Steps =====
406
419
 
407
- export const StepSchema: z.ZodType<unknown> = z.lazy(() =>
420
+ export const StepSchema: z.ZodType<any> = z.lazy(() =>
408
421
  z.discriminatedUnion('type', [
409
- ShellStepSchema,
410
- LlmStepSchema,
411
- PlanStepSchema,
412
- WorkflowStepSchema,
413
- FileStepSchema,
414
- RequestStepSchema,
415
- HumanStepSchema,
416
- SleepStepSchema,
417
- ScriptStepSchema,
418
- EngineStepSchema,
419
- MemoryStepSchema,
420
- JoinStepSchema,
421
- BlueprintStepSchema,
422
- ArtifactStepSchema,
423
- WaitStepSchema,
422
+ ShellStepSchema as any,
423
+ LlmStepSchema as any,
424
+ PlanStepSchema as any,
425
+ WorkflowStepSchema as any,
426
+ FileStepSchema as any,
427
+ RequestStepSchema as any,
428
+ HumanStepSchema as any,
429
+ SleepStepSchema as any,
430
+ ScriptStepSchema as any,
431
+ EngineStepSchema as any,
432
+ MemoryStepSchema as any,
433
+ JoinStepSchema as any,
434
+ BlueprintStepSchema as any,
435
+ ArtifactStepSchema as any,
436
+ WaitStepSchema as any,
437
+ GitStepSchema as any,
424
438
  ])
425
439
  );
426
440
 
@@ -487,7 +501,25 @@ export const AgentSchema = z.object({
487
501
 
488
502
  export type WorkflowInput = z.infer<typeof InputSchema>;
489
503
  export type RetryConfig = z.infer<typeof RetrySchema>;
490
- export type Step = z.infer<typeof StepSchema>;
504
+
505
+ export type Step =
506
+ | z.infer<typeof ShellStepSchema>
507
+ | z.infer<typeof LlmStepSchema>
508
+ | z.infer<typeof PlanStepSchema>
509
+ | z.infer<typeof WorkflowStepSchema>
510
+ | z.infer<typeof FileStepSchema>
511
+ | z.infer<typeof RequestStepSchema>
512
+ | z.infer<typeof HumanStepSchema>
513
+ | z.infer<typeof SleepStepSchema>
514
+ | z.infer<typeof ScriptStepSchema>
515
+ | z.infer<typeof EngineStepSchema>
516
+ | z.infer<typeof MemoryStepSchema>
517
+ | z.infer<typeof JoinStepSchema>
518
+ | z.infer<typeof BlueprintStepSchema>
519
+ | z.infer<typeof ArtifactStepSchema>
520
+ | z.infer<typeof WaitStepSchema>
521
+ | z.infer<typeof GitStepSchema>;
522
+
491
523
  export type ShellStep = z.infer<typeof ShellStepSchema>;
492
524
  export type LlmStep = z.infer<typeof LlmStepSchema>;
493
525
  export type PlanStep = z.infer<typeof PlanStepSchema>;
@@ -502,6 +534,7 @@ export type EngineStep = z.infer<typeof EngineStepSchema>;
502
534
  export type JoinStep = z.infer<typeof JoinStepSchema>;
503
535
  export type BlueprintStep = z.infer<typeof BlueprintStepSchema>;
504
536
  export type ArtifactStep = z.infer<typeof ArtifactStepSchema>;
537
+ export type GitStep = z.infer<typeof GitStepSchema>;
505
538
  export type Blueprint = z.infer<typeof BlueprintSchema>;
506
539
  export type Workflow = z.infer<typeof WorkflowSchema>;
507
540
  export type AgentTool = z.infer<typeof AgentToolSchema>;
@@ -531,5 +564,6 @@ export {
531
564
  BlueprintStepSchema,
532
565
  MemoryStepSchema,
533
566
  ArtifactStepSchema,
567
+ GitStepSchema,
534
568
  };
535
569
  export type Agent = z.infer<typeof AgentSchema>;
@@ -36,13 +36,15 @@ describe('file-executor', () => {
36
36
  const blocks: SearchReplaceBlock[] = [
37
37
  { search: ' indent', replace: 'dedent' }, // one space vs two
38
38
  ];
39
- // Should not find ' indent' because actual is ' indent' (double space) if strict?
40
- // Actually ' indent'.split(' indent') -> [' ', ''] -> found once!
41
- // Wait ' indent'.includes(' indent') is true.
42
- // ' indent'.split(' indent') -> [' ', ''] (length 2).
43
- // So it works.
44
39
  const result = applySearchReplaceBlocks(content, blocks);
45
40
  expect(result).toBe(' dedent');
46
41
  });
42
+
43
+ it('should handle CRLF line endings by treating them as LF', () => {
44
+ const content = 'line1\r\nline2\r\nline3';
45
+ const blocks: SearchReplaceBlock[] = [{ search: 'line2', replace: 'modified' }];
46
+ const result = applySearchReplaceBlocks(content, blocks);
47
+ expect(result).toBe('line1\nmodified\nline3');
48
+ });
47
49
  });
48
50
  });
@@ -116,7 +116,7 @@ export function applyUnifiedDiff(content: string, patch: string, targetPath: str
116
116
  const diff = parseUnifiedDiff(patch);
117
117
  assertDiffMatchesTarget(diff.newFile || diff.originalFile, targetPath);
118
118
 
119
- const lines = content.split('\n');
119
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
120
120
  const resultLines = [...lines];
121
121
 
122
122
  // Apply hunks in reverse order to keep line numbers valid
@@ -188,7 +188,7 @@ export function parseSearchReplaceBlocks(patch: string): SearchReplaceBlock[] {
188
188
  }
189
189
 
190
190
  export function applySearchReplaceBlocks(content: string, blocks: SearchReplaceBlock[]): string {
191
- let result = content;
191
+ let result = content.replace(/\r\n/g, '\n');
192
192
 
193
193
  for (const block of blocks) {
194
194
  const parts = result.split(block.search);
@@ -0,0 +1,278 @@
1
+ import { afterEach, beforeEach, describe, expect, it, jest, spyOn } from 'bun:test';
2
+ import type { GitStep } from '../../parser/schema.ts';
3
+ import { ConsoleLogger } from '../../utils/logger.ts';
4
+ import { executeGitStep } from './git-executor.ts';
5
+ import * as shellExecutor from './shell-executor.ts';
6
+
7
+ describe('git-executor', () => {
8
+ const logger = new ConsoleLogger();
9
+ const context = {
10
+ env: {},
11
+ inputs: {},
12
+ steps: {},
13
+ };
14
+
15
+ let executeShellSpy: any;
16
+
17
+ beforeEach(() => {
18
+ executeShellSpy = spyOn(shellExecutor, 'executeShell').mockResolvedValue({
19
+ stdout: 'git output',
20
+ stderr: '',
21
+ exitCode: 0,
22
+ });
23
+ });
24
+
25
+ afterEach(() => {
26
+ executeShellSpy.mockRestore();
27
+ });
28
+
29
+ it('should execute clone operation', async () => {
30
+ const step: GitStep = {
31
+ id: 'git-clone',
32
+ type: 'git',
33
+ op: 'clone',
34
+ url: 'https://github.com/mhingston/keystone-cli.git',
35
+ needs: [],
36
+ };
37
+
38
+ const result = await executeGitStep(step, context, logger);
39
+ expect(result.status).toBe('success');
40
+ expect(executeShellSpy).toHaveBeenCalledWith(
41
+ expect.objectContaining({
42
+ run: 'git clone https://github.com/mhingston/keystone-cli.git ',
43
+ }),
44
+ context,
45
+ logger,
46
+ undefined,
47
+ 'git clone https://github.com/mhingston/keystone-cli.git '
48
+ );
49
+ });
50
+
51
+ it('should execute clone with branch', async () => {
52
+ const step: GitStep = {
53
+ id: 'git-clone-branch',
54
+ type: 'git',
55
+ op: 'clone',
56
+ url: 'https://github.com/mhingston/keystone-cli.git',
57
+ branch: 'main',
58
+ needs: [],
59
+ };
60
+
61
+ const result = await executeGitStep(step, context, logger);
62
+ expect(result.status).toBe('success');
63
+ expect(executeShellSpy).toHaveBeenCalledWith(
64
+ expect.objectContaining({
65
+ run: 'git clone -b main https://github.com/mhingston/keystone-cli.git ',
66
+ }),
67
+ context,
68
+ logger,
69
+ undefined,
70
+ 'git clone -b main https://github.com/mhingston/keystone-cli.git '
71
+ );
72
+ });
73
+
74
+ it('should execute worktree_add operation', async () => {
75
+ const step: GitStep = {
76
+ id: 'git-worktree-add',
77
+ type: 'git',
78
+ op: 'worktree_add',
79
+ path: './tmp-worktree',
80
+ branch: 'feature-branch',
81
+ needs: [],
82
+ };
83
+
84
+ const result = await executeGitStep(step, context, logger);
85
+ expect(result.status).toBe('success');
86
+ expect((result.output as any).worktreePath).toBe('./tmp-worktree');
87
+ expect(executeShellSpy).toHaveBeenCalledWith(
88
+ expect.objectContaining({
89
+ run: 'git worktree add ./tmp-worktree feature-branch',
90
+ }),
91
+ context,
92
+ logger,
93
+ undefined,
94
+ 'git worktree add ./tmp-worktree feature-branch'
95
+ );
96
+ });
97
+
98
+ it('should execute worktree_remove operation', async () => {
99
+ const step: GitStep = {
100
+ id: 'git-worktree-remove',
101
+ type: 'git',
102
+ op: 'worktree_remove',
103
+ path: './tmp-worktree',
104
+ needs: [],
105
+ };
106
+
107
+ const result = await executeGitStep(step, context, logger);
108
+ expect(result.status).toBe('success');
109
+ expect(executeShellSpy).toHaveBeenCalledWith(
110
+ expect.objectContaining({
111
+ run: 'git worktree remove ./tmp-worktree',
112
+ }),
113
+ context,
114
+ logger,
115
+ undefined,
116
+ 'git worktree remove ./tmp-worktree'
117
+ );
118
+ });
119
+
120
+ it('should execute checkout operation', async () => {
121
+ const step: GitStep = {
122
+ id: 'git-checkout',
123
+ type: 'git',
124
+ op: 'checkout',
125
+ branch: 'main',
126
+ needs: [],
127
+ };
128
+
129
+ const result = await executeGitStep(step, context, logger);
130
+ expect(result.status).toBe('success');
131
+ expect(executeShellSpy).toHaveBeenCalledWith(
132
+ expect.objectContaining({
133
+ run: 'git checkout main',
134
+ }),
135
+ context,
136
+ logger,
137
+ undefined,
138
+ 'git checkout main'
139
+ );
140
+ });
141
+
142
+ it('should execute pull operation', async () => {
143
+ const step: GitStep = {
144
+ id: 'git-pull',
145
+ type: 'git',
146
+ op: 'pull',
147
+ branch: 'main',
148
+ needs: [],
149
+ };
150
+
151
+ const result = await executeGitStep(step, context, logger);
152
+ expect(result.status).toBe('success');
153
+ expect(executeShellSpy).toHaveBeenCalledWith(
154
+ expect.objectContaining({
155
+ run: 'git pull origin main',
156
+ }),
157
+ context,
158
+ logger,
159
+ undefined,
160
+ 'git pull origin main'
161
+ );
162
+ });
163
+
164
+ it('should execute push operation', async () => {
165
+ const step: GitStep = {
166
+ id: 'git-push',
167
+ type: 'git',
168
+ op: 'push',
169
+ branch: 'main',
170
+ needs: [],
171
+ };
172
+
173
+ const result = await executeGitStep(step, context, logger);
174
+ expect(result.status).toBe('success');
175
+ expect(executeShellSpy).toHaveBeenCalledWith(
176
+ expect.objectContaining({
177
+ run: 'git push origin main',
178
+ }),
179
+ context,
180
+ logger,
181
+ undefined,
182
+ 'git push origin main'
183
+ );
184
+ });
185
+
186
+ it('should execute commit operation', async () => {
187
+ const step: GitStep = {
188
+ id: 'git-commit',
189
+ type: 'git',
190
+ op: 'commit',
191
+ message: 'test commit',
192
+ needs: [],
193
+ };
194
+
195
+ const result = await executeGitStep(step, context, logger);
196
+ expect(result.status).toBe('success');
197
+ expect(executeShellSpy).toHaveBeenCalledWith(
198
+ expect.objectContaining({
199
+ run: 'git add . && git commit -m "test commit"',
200
+ }),
201
+ context,
202
+ logger,
203
+ undefined,
204
+ 'git add . && git commit -m "test commit"'
205
+ );
206
+ });
207
+
208
+ it('should handle failed operations', async () => {
209
+ executeShellSpy.mockResolvedValue({
210
+ stdout: '',
211
+ stderr: 'git error',
212
+ exitCode: 1,
213
+ });
214
+
215
+ const step: GitStep = {
216
+ id: 'git-fail',
217
+ type: 'git',
218
+ op: 'pull',
219
+ needs: [],
220
+ };
221
+
222
+ const result = await executeGitStep(step, context, logger);
223
+ expect(result.status).toBe('failed');
224
+ expect(result.error).toContain('Git pull failed with code 1: git error');
225
+ });
226
+
227
+ it('should throw error for missing url in clone', async () => {
228
+ const step: GitStep = {
229
+ id: 'git-clone-no-url',
230
+ type: 'git',
231
+ op: 'clone',
232
+ needs: [],
233
+ };
234
+
235
+ await expect(executeGitStep(step, context, logger)).rejects.toThrow(
236
+ 'Git clone requires a "url"'
237
+ );
238
+ });
239
+
240
+ it('should throw error for missing path in worktree_add', async () => {
241
+ const step: GitStep = {
242
+ id: 'git-worktree-no-path',
243
+ type: 'git',
244
+ op: 'worktree_add',
245
+ needs: [],
246
+ };
247
+
248
+ await expect(executeGitStep(step, context, logger)).rejects.toThrow(
249
+ 'Git worktree_add requires a "path"'
250
+ );
251
+ });
252
+
253
+ it('should throw error for missing branch in checkout', async () => {
254
+ const step: GitStep = {
255
+ id: 'git-checkout-no-branch',
256
+ type: 'git',
257
+ op: 'checkout',
258
+ needs: [],
259
+ };
260
+
261
+ await expect(executeGitStep(step, context, logger)).rejects.toThrow(
262
+ 'Git checkout requires a "branch"'
263
+ );
264
+ });
265
+
266
+ it('should throw error for missing message in commit', async () => {
267
+ const step: GitStep = {
268
+ id: 'git-commit-no-msg',
269
+ type: 'git',
270
+ op: 'commit',
271
+ needs: [],
272
+ };
273
+
274
+ await expect(executeGitStep(step, context, logger)).rejects.toThrow(
275
+ 'Git commit requires a "message"'
276
+ );
277
+ });
278
+ });
@@ -0,0 +1,100 @@
1
+ import { ExpressionEvaluator } from '../../expression/evaluator.ts';
2
+ import type { ExpressionContext } from '../../expression/evaluator.ts';
3
+ import type { GitStep } from '../../parser/schema.ts';
4
+ import { ConsoleLogger, type Logger } from '../../utils/logger.ts';
5
+ import { PathResolver } from '../../utils/paths.ts';
6
+ import { type ShellResult, executeShell } from './shell-executor.ts';
7
+ import type { StepResult } from './types.ts';
8
+
9
+ /**
10
+ * Git command executor
11
+ */
12
+ export async function executeGitStep(
13
+ step: GitStep,
14
+ context: ExpressionContext,
15
+ logger: Logger = new ConsoleLogger(),
16
+ abortSignal?: AbortSignal
17
+ ): Promise<StepResult> {
18
+ const op = step.op;
19
+ const cwd = step.cwd ? ExpressionEvaluator.evaluateString(step.cwd, context) : undefined;
20
+
21
+ if (cwd) {
22
+ PathResolver.assertWithinCwd(cwd, step.allowOutsideCwd, 'CWD');
23
+ }
24
+
25
+ // Pre-process common inputs
26
+ const path = step.path ? ExpressionEvaluator.evaluateString(step.path, context) : undefined;
27
+ const url = step.url ? ExpressionEvaluator.evaluateString(step.url, context) : undefined;
28
+ const branch = step.branch ? ExpressionEvaluator.evaluateString(step.branch, context) : undefined;
29
+ const message = step.message
30
+ ? ExpressionEvaluator.evaluateString(step.message, context)
31
+ : undefined;
32
+
33
+ let command = '';
34
+ switch (op) {
35
+ case 'clone':
36
+ if (!url) throw new Error('Git clone requires a "url"');
37
+ command = `git clone ${branch ? `-b ${branch} ` : ''}${url} ${path || ''}`;
38
+ break;
39
+ case 'worktree_add':
40
+ if (!path) throw new Error('Git worktree_add requires a "path"');
41
+ command = `git worktree add ${path} ${branch || ''}`;
42
+ break;
43
+ case 'worktree_remove':
44
+ if (!path) throw new Error('Git worktree_remove requires a "path"');
45
+ command = `git worktree remove ${path}`;
46
+ break;
47
+ case 'checkout':
48
+ if (!branch) throw new Error('Git checkout requires a "branch"');
49
+ command = `git checkout ${branch}`;
50
+ break;
51
+ case 'pull':
52
+ command = `git pull origin ${branch || ''}`;
53
+ break;
54
+ case 'push':
55
+ command = `git push origin ${branch || ''}`;
56
+ break;
57
+ case 'commit':
58
+ if (!message) throw new Error('Git commit requires a "message"');
59
+ command = `git add . && git commit -m "${message.replace(/"/g, '\\"')}"`;
60
+ break;
61
+ default:
62
+ throw new Error(`Unsupported git operation: ${op}`);
63
+ }
64
+
65
+ // Use executeShell to leverage its security checks and output handling
66
+ // We explicitly allow insecure if the user set it, but by default executeShell
67
+ // will check the command against the whitelist.
68
+ // Note: Git commands often contain spaces/slashes which are in the whitelist.
69
+ const result: ShellResult = await executeShell(
70
+ {
71
+ ...step,
72
+ type: 'shell',
73
+ run: command,
74
+ dir: cwd,
75
+ },
76
+ context,
77
+ logger,
78
+ abortSignal,
79
+ command
80
+ );
81
+
82
+ if (result.exitCode !== 0) {
83
+ return {
84
+ output: result,
85
+ status: 'failed',
86
+ error: `Git ${op} failed with code ${result.exitCode}: ${result.stderr}`,
87
+ };
88
+ }
89
+
90
+ // Provide some structured output based on the operation
91
+ const output: any = { ...result };
92
+ if (op === 'worktree_add') {
93
+ output.worktreePath = path;
94
+ }
95
+
96
+ return {
97
+ output,
98
+ status: 'success',
99
+ };
100
+ }